pod: bundled per-game deployment (portable config, --exit-with, build-pod)

Phase 10: RIO hardware exists only on pods + dev boxes, so production is one
RIOJoy copy inside each podized game folder, no resident tray. ConfigLocator
makes a config.json beside the exe win over %APPDATA%; --exit-with <exe|pid>
(CompanionTarget/CompanionExit, 60s startup grace) tears down and quits when
the game exits; a starting --exit-with instance waits up to 15s for the
predecessor mutex instead of silently exiting. deploy/build-pod.ps1 emits
the ~4.5MB drop-in (app + portable config wrapping the profile + start
script, no drivers) - verified against the shipped Descent profile. 455
tests; PLAN.md Phase 10 + INPUT-INTEGRATION.md pod section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-31 22:01:52 -05:00
co-authored by Claude Fable 5
parent 9a792193f9
commit 97caf124a6
10 changed files with 549 additions and 12 deletions
+63
View File
@@ -0,0 +1,63 @@
using System.Globalization;
using RioJoy.Core.Profiles;
namespace RioJoy.Core.Hosting;
/// <summary>
/// The process a pod-bundled RIOJoy lives alongside (<c>--exit-with</c>):
/// either a PID or an executable name, normalized the same way auto-switch
/// triggers are (basename, no <c>.exe</c>, lower-case) so launch scripts can
/// pass whatever they have.
/// </summary>
public sealed record CompanionTarget
{
public int? Pid { get; init; }
/// <summary>Normalized executable name (when <see cref="Pid"/> is null).</summary>
public string? Name { get; init; }
public static CompanionTarget Parse(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Companion target is required.", nameof(value));
return int.TryParse(value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int pid)
? new CompanionTarget { Pid = pid }
: new CompanionTarget { Name = AutoSwitchResolver.Normalize(value) };
}
public override string ToString() => Pid is int p ? $"pid {p}" : Name ?? "?";
}
/// <summary>
/// Pure decision core of <c>--exit-with</c>: RIOJoy should exit once its
/// companion game has run and then gone away. Launch order is not guaranteed
/// (the pod start script fires both), so a companion that has <i>never</i>
/// been seen only triggers exit after a startup grace — covering both "game
/// still loading" and "game failed to launch, don't linger forever". The
/// caller polls (the tray's 1 s timer) and supplies elapsed time, so this
/// stays clock-free and unit-testable.
/// </summary>
public sealed class CompanionExit
{
public static readonly TimeSpan DefaultStartupGrace = TimeSpan.FromSeconds(60);
private readonly TimeSpan _grace;
private bool _seen;
public CompanionExit(TimeSpan? startupGrace = null)
{
_grace = startupGrace ?? DefaultStartupGrace;
}
/// <summary>True once RIOJoy should tear down and exit.</summary>
public bool ShouldExit(bool companionRunning, TimeSpan elapsed)
{
if (companionRunning)
{
_seen = true;
return false;
}
return _seen || elapsed >= _grace;
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace RioJoy.Core.Profiles;
/// <summary>
/// Resolves which config file the app uses: a <b>portable</b>
/// <c>config.json</c> sitting beside the executable wins over the per-user
/// roaming store. Portable mode is how a pod-bundled RIOJoy (one copy shipped
/// inside each podized game's folder, PLAN.md §Phase 10) carries its own
/// profile with no shared state and no import step; the roaming store remains
/// the resident/dev-box default.
/// </summary>
public static class ConfigLocator
{
/// <summary>The portable config's file name, looked for beside the exe.</summary>
public const string PortableConfigFileName = "config.json";
/// <summary>
/// The portable config path for <paramref name="exeDirectory"/> if one
/// exists there, else <paramref name="roamingConfigPath"/>.
/// </summary>
public static string Resolve(string? exeDirectory, string roamingConfigPath)
{
if (roamingConfigPath is null) throw new ArgumentNullException(nameof(roamingConfigPath));
if (string.IsNullOrWhiteSpace(exeDirectory))
return roamingConfigPath;
string portable = Path.Combine(exeDirectory, PortableConfigFileName);
return File.Exists(portable) ? portable : roamingConfigPath;
}
}
+55 -2
View File
@@ -1,3 +1,4 @@
using RioJoy.Core.Hosting;
using RioJoy.Core.Profiles;
namespace RioJoy.Tray;
@@ -10,6 +11,11 @@ internal static class Program
// so a crash never leaves a stale lock.
private const string SingleInstanceMutex = "RIOJoy.Tray.SingleInstance";
// Pod handoff (game A's copy tearing down while game B's starts): how long a
// --exit-with launch waits for the predecessor to release the mutex before
// giving up. Plain launches keep the historical instant silent exit.
private static readonly TimeSpan PredecessorWait = TimeSpan.FromSeconds(15);
/// <summary>
/// Entry point. RIOJoy runs as a background tray application with no main
/// window: an ApplicationContext owns the NotifyIcon and the runtime, so the
@@ -20,6 +26,13 @@ internal static class Program
/// exits without starting the tray. Output goes to stdout/stderr, which a
/// GUI-subsystem exe only delivers when redirected — check the exit code
/// (0 ok, 1 failed, 2 usage, 3 tray running) when scripting it.</para>
///
/// <para><c>--exit-with &lt;exe|pid&gt;</c> runs as a pod-bundled companion
/// (PLAN.md §Phase 10): RIOJoy exits by itself — full teardown, ports
/// released, wallpaper restored — once the named game process has run and
/// 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>
/// </summary>
[STAThread]
private static int Main(string[] args)
@@ -27,18 +40,58 @@ internal static class Program
if (args.Length >= 1 && string.Equals(args[0], "--import-profile", StringComparison.OrdinalIgnoreCase))
return ImportProfile(args);
CompanionTarget? exitWith;
try
{
exitWith = ParseExitWith(args);
}
catch (ArgumentException ex)
{
Console.Error.WriteLine($"usage: RioJoy.Tray [--exit-with <exe|pid>] ({ex.Message})");
return 2;
}
using var instance = new Mutex(initiallyOwned: true, SingleInstanceMutex, out bool createdNew);
if (!createdNew)
if (!createdNew && !WaitForPredecessor(instance, wait: exitWith is not null))
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());
Application.Run(new TrayApplicationContext(exitWith));
return 0;
}
private static CompanionTarget? ParseExitWith(string[] args)
{
for (int i = 0; i < args.Length; i++)
{
if (!string.Equals(args[i], "--exit-with", 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]);
}
return null;
}
// The predecessor's mutex is released by the OS when its process exits; an
// abandoned wait means it crashed while owning it — either way we own it now.
private static bool WaitForPredecessor(Mutex instance, bool wait)
{
if (!wait)
return false;
try
{
return instance.WaitOne(PredecessorWait);
}
catch (AbandonedMutexException)
{
return true;
}
}
private static int ImportProfile(string[] args)
{
if (args.Length != 2)
+55 -8
View File
@@ -1,4 +1,6 @@
using System.Diagnostics;
using RioJoy.Core;
using RioJoy.Core.Hosting;
using RioJoy.Core.Mapping;
using RioJoy.Core.Overlay;
using RioJoy.Core.Profiles;
@@ -11,15 +13,18 @@ namespace RioJoy.Tray;
/// Owns the tray icon, menu, and the RIOJoy runtime. The menu mirrors the legacy
/// console menu (axis resets, version/status, diagnostic toggles, quit) and adds
/// profile selection (auto vs. manual). The app's start/stop lifecycle is owned by
/// the TeslaConsole launcher, so there is no "start with Windows" toggle. The
/// auto-switch watcher is polled on a UI timer so menu/status updates stay on the
/// UI thread.
/// the TeslaConsole launcher (or, pod-bundled, by <c>--exit-with</c>), so there is
/// no "start with Windows" toggle. The auto-switch watcher is polled on a UI timer
/// so menu/status updates stay on the UI thread.
/// </summary>
internal sealed class TrayApplicationContext : ApplicationContext
{
// Internal so Program's --import-profile writes the same store the tray reads.
internal static readonly string ConfigPath =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RIOJoy", "config.json");
// A portable config.json beside the exe (pod-bundled deploys) wins over the
// per-user roaming store (resident/dev-box mode) — see ConfigLocator.
internal static readonly string ConfigPath = ConfigLocator.Resolve(
AppDomain.CurrentDomain.BaseDirectory,
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RIOJoy", "config.json"));
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
@@ -30,8 +35,14 @@ internal sealed class TrayApplicationContext : ApplicationContext
private readonly NotifyIcon _trayIcon;
private readonly ToolStripMenuItem _statusItem;
public TrayApplicationContext()
// --exit-with companion state (null = resident mode).
private readonly CompanionTarget? _exitWith;
private readonly CompanionExit _companionExit = new();
private readonly Stopwatch _sinceStart = Stopwatch.StartNew();
public TrayApplicationContext(CompanionTarget? exitWith = null)
{
_exitWith = exitWith;
_config = ConfigStore.Load(ConfigPath);
_coordinator = new RioCoordinator(() => _config);
@@ -50,13 +61,49 @@ internal sealed class TrayApplicationContext : ApplicationContext
ContextMenuStrip = BuildMenu(),
};
// Poll the foreground app on the UI thread.
// Poll the foreground app (and the --exit-with companion) on the UI thread.
_pollTimer = new System.Windows.Forms.Timer { Interval = (int)PollInterval.TotalMilliseconds };
_pollTimer.Tick += (_, _) => _watcher.Poll();
_pollTimer.Tick += (_, _) =>
{
_watcher.Poll();
CheckCompanion();
};
_pollTimer.Start();
_watcher.Poll();
}
// Pod-bundled mode: quit (full teardown — ports released, wallpaper restored,
// plasma blanked) once the companion game has run and then exited, or never
// appeared within the startup grace.
private void CheckCompanion()
{
if (_exitWith is null)
return;
if (_companionExit.ShouldExit(IsCompanionRunning(_exitWith), _sinceStart.Elapsed))
Quit();
}
private static bool IsCompanionRunning(CompanionTarget target)
{
if (target.Pid is int pid)
{
try
{
using Process process = Process.GetProcessById(pid);
return !process.HasExited;
}
catch (ArgumentException)
{
return false; // no such process
}
}
Process[] matches = Process.GetProcessesByName(target.Name);
foreach (Process process in matches)
process.Dispose();
return matches.Length > 0;
}
private ContextMenuStrip BuildMenu()
{
var menu = new ContextMenuStrip();