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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <exe|pid></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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user