pod: fully explicit activation (--profile) + RIOJoy.Tray.Ready signal

No detection anywhere in the pod chain (Cyd: foreground detection is too
slow - the pad appears after the game already enumerated controllers).
--profile <name> activates immediately at startup, never runs the
auto-switch watcher, and on success signals the named manual-reset event
RIOJoy.Tray.Ready (process-lifetime, never stale) so a launcher waits on
the signal instead of counting winmm devices. Legible failures for the
launcher: exit 4 unknown profile, exit 5 activation failed with the reason
on stderr - both verified live against the built exe. Editor close
re-activates the explicit profile. build-pod start scripts now pass
--profile <Name> --exit-with <exe>; docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-31 23:01:44 -05:00
co-authored by Claude Fable 5
parent 5befd1d511
commit bdd30678e3
5 changed files with 164 additions and 28 deletions
+40 -8
View File
@@ -33,6 +33,17 @@ internal static class Program
/// then gone away (or never appeared within the startup grace). Also makes
/// startup wait briefly for a predecessor instance instead of exiting, so
/// back-to-back game launches hand the cockpit over cleanly.</para>
///
/// <para><c>--profile &lt;name&gt;</c> activates that profile immediately
/// and explicitly — <b>no foreground detection at all</b>. Pod launch
/// scripts pass it so the ViGEm pad and the ports exist <i>before</i> the
/// game starts and enumerates controllers; the auto-switch watcher never
/// runs. On success the named event
/// <see cref="TrayApplicationContext.ReadyEventName"/> is signaled — a pod
/// launcher waits on it (with a timeout) instead of counting input
/// devices. Exit code 4 when the named profile is not in the config; 5
/// when explicit activation fails (reason on stderr) — distinguishable
/// from a hang, so launcher failures stay legible.</para>
/// </summary>
[STAThread]
private static int Main(string[] args)
@@ -41,37 +52,58 @@ internal static class Program
return ImportProfile(args);
CompanionTarget? exitWith;
string? profileName;
try
{
exitWith = ParseExitWith(args);
exitWith = ParseValue(args, "--exit-with", "a process name or pid") is string target
? CompanionTarget.Parse(target)
: null;
profileName = ParseValue(args, "--profile", "a profile name");
}
catch (ArgumentException ex)
{
Console.Error.WriteLine($"usage: RioJoy.Tray [--exit-with <exe|pid>] ({ex.Message})");
Console.Error.WriteLine($"usage: RioJoy.Tray [--profile <name>] [--exit-with <exe|pid>] ({ex.Message})");
return 2;
}
// Validate the explicit profile up front so a pod script's typo is a
// scriptable failure, not a silently idle tray.
if (profileName is not null &&
ConfigStore.Load(TrayApplicationContext.ConfigPath).FindProfile(profileName) is null)
{
Console.Error.WriteLine(
$"profile '{profileName}' not found in {TrayApplicationContext.ConfigPath}");
return 4;
}
using var instance = new Mutex(initiallyOwned: true, SingleInstanceMutex, out bool createdNew);
if (!createdNew && !WaitForPredecessor(instance, wait: exitWith is not null))
bool podMode = exitWith is not null || profileName is not null;
if (!createdNew && !WaitForPredecessor(instance, wait: podMode))
return 0; // another RIOJoy is already running in this session
// net48 has no source-generated ApplicationConfiguration.Initialize();
// do the equivalent setup directly.
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new TrayApplicationContext(exitWith));
var context = new TrayApplicationContext(exitWith, profileName);
if (!context.TryActivateExplicitProfile())
{
context.Dispose(); // failure reason already on stderr
return 5;
}
Application.Run(context);
return 0;
}
private static CompanionTarget? ParseExitWith(string[] args)
private static string? ParseValue(string[] args, string flag, string what)
{
for (int i = 0; i < args.Length; i++)
{
if (!string.Equals(args[i], "--exit-with", StringComparison.OrdinalIgnoreCase))
if (!string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase))
continue;
if (i + 1 >= args.Length)
throw new ArgumentException("--exit-with needs a process name or pid");
return CompanionTarget.Parse(args[i + 1]);
throw new ArgumentException($"{flag} needs {what}");
return args[i + 1];
}
return null;
}
+70 -8
View File
@@ -30,7 +30,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
private readonly AppConfig _config;
private readonly RioCoordinator _coordinator;
private readonly AutoSwitchWatcher _watcher;
private readonly AutoSwitchWatcher? _watcher; // null = explicit (--profile) mode
private readonly System.Windows.Forms.Timer _pollTimer;
private readonly NotifyIcon _trayIcon;
private readonly ToolStripMenuItem _statusItem;
@@ -39,17 +39,27 @@ internal sealed class TrayApplicationContext : ApplicationContext
private readonly CompanionTarget? _exitWith;
private readonly CompanionExit _companionExit = new();
private readonly Stopwatch _sinceStart = Stopwatch.StartNew();
private readonly string? _explicitProfile;
public TrayApplicationContext(CompanionTarget? exitWith = null)
public TrayApplicationContext(CompanionTarget? exitWith = null, string? explicitProfile = null)
{
_exitWith = exitWith;
_explicitProfile = explicitProfile;
_config = ConfigStore.Load(ConfigPath);
_coordinator = new RioCoordinator(() => _config);
_coordinator.StatusChanged += _ => RefreshOnUiThread();
_watcher = new AutoSwitchWatcher(new ForegroundProcessProvider(), () => _config);
_watcher.DecisionChanged += d => _coordinator.ApplyDecision(d);
// Explicit (--profile) mode runs NO foreground detection at all: pod
// launch scripts activate the profile before the game starts, so the
// ViGEm pad and the ports already exist when the game enumerates
// controllers — a watcher activating ~1 s after the window appears is
// too late for startup enumeration.
if (explicitProfile is null)
{
_watcher = new AutoSwitchWatcher(new ForegroundProcessProvider(), () => _config);
_watcher.DecisionChanged += d => _coordinator.ApplyDecision(d);
}
_statusItem = new ToolStripMenuItem("Status: starting…") { Enabled = false };
@@ -65,11 +75,54 @@ internal sealed class TrayApplicationContext : ApplicationContext
_pollTimer = new System.Windows.Forms.Timer { Interval = (int)PollInterval.TotalMilliseconds };
_pollTimer.Tick += (_, _) =>
{
_watcher.Poll();
_watcher?.Poll();
CheckCompanion();
};
_pollTimer.Start();
_watcher.Poll();
if (explicitProfile is null)
_watcher!.Poll();
// Explicit activation runs via TryActivateExplicitProfile (called by
// Program between construction and the message loop, so a failure can
// become an exit code the launcher chain can read).
}
/// <summary>
/// The named event a pod launcher waits on instead of guessing from device
/// enumeration: signaled once the explicit profile is active (ports
/// acquired; virtual pad present when ViGEmBus is installed). Dies with
/// the process, so it can never go stale.
/// </summary>
internal const string ReadyEventName = "RIOJoy.Tray.Ready";
private EventWaitHandle? _readyEvent;
/// <summary>
/// Explicit (--profile) activation: activate now, and signal
/// <see cref="ReadyEventName"/> on success so the launcher can start the
/// game knowing the virtual pad already exists. False = activation failed
/// (port busy, bad endpoint, …) — the caller reports and exits.
/// </summary>
internal bool TryActivateExplicitProfile()
{
if (_explicitProfile is null)
return true; // resident mode — nothing to do
// Program.Main validated the name against the same store already.
RioProfile profile = _config.FindProfile(_explicitProfile)!;
_coordinator.SetManualProfile(profile);
if (_coordinator.Runtime is null)
{
Console.Error.WriteLine($"RioJoy: explicit activation failed: {_coordinator.Status}");
return false;
}
// Either side may create the event first (launcher-waits-then-start or
// start-then-wait); ManualReset + same name converge on one object.
_readyEvent = new EventWaitHandle(
initialState: false, EventResetMode.ManualReset, ReadyEventName);
_readyEvent.Set();
return true;
}
// Pod-bundled mode: quit (full teardown — ports released, wallpaper restored,
@@ -338,8 +391,16 @@ internal sealed class TrayApplicationContext : ApplicationContext
{
unhook?.Invoke();
_coordinator.EndEditorSession();
_watcher.Reset(); // re-sync with the foreground app (may re-activate a game)
_watcher.Poll();
if (_watcher is not null)
{
_watcher.Reset(); // re-sync with the foreground app (may re-activate a game)
_watcher.Poll();
}
else if (_explicitProfile is not null &&
_config.FindProfile(_explicitProfile) is RioProfile explicitProfile)
{
_coordinator.SetManualProfile(explicitProfile); // back to the explicit pod profile
}
};
editor.Show();
}
@@ -439,6 +500,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
_pollTimer.Dispose();
_coordinator.Dispose();
_trayIcon.Dispose();
_readyEvent?.Dispose();
}
base.Dispose(disposing);