From 97caf124a613890275c09c1c6960719869e1c8e9 Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 31 Jul 2026 22:01:52 -0500 Subject: [PATCH] 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 (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 --- README.md | 7 +- deploy/build-pod.ps1 | 145 ++++++++++++++++++ docs/INPUT-INTEGRATION.md | 42 +++++ docs/PLAN.md | 39 +++++ src/RioJoy.Core/Hosting/CompanionExit.cs | 63 ++++++++ src/RioJoy.Core/Profiles/ConfigLocator.cs | 29 ++++ src/RioJoy.Tray/Program.cs | 57 ++++++- src/RioJoy.Tray/TrayApplicationContext.cs | 63 +++++++- .../Hosting/CompanionExitTests.cs | 66 ++++++++ .../Profiles/ConfigLocatorTests.cs | 50 ++++++ 10 files changed, 549 insertions(+), 12 deletions(-) create mode 100644 deploy/build-pod.ps1 create mode 100644 src/RioJoy.Core/Hosting/CompanionExit.cs create mode 100644 src/RioJoy.Core/Profiles/ConfigLocator.cs create mode 100644 tests/RioJoy.Core.Tests/Hosting/CompanionExitTests.cs create mode 100644 tests/RioJoy.Core.Tests/Profiles/ConfigLocatorTests.cs diff --git a/README.md b/README.md index 702b350..3c9b705 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,12 @@ dotnet test RioJoy.sln ## Status -Phases 1–5 and 9 are implemented and tested (442 unit tests). Games (or sim +Phases 1–5, 9 and 10 are implemented and tested (455 unit tests). Games (or sim export scripts) can drive the cockpit lamps and plasma display back through the -running app — see [`docs/FEEDBACK.md`](docs/FEEDBACK.md). The `RioGamepad` virtual +running app — see [`docs/FEEDBACK.md`](docs/FEEDBACK.md). For cockpit cabinets, +RIOJoy deploys **bundled per game** (`deploy\build-pod.ps1`, portable config + +`--exit-with` self-teardown) rather than resident — see the pod section in +[`docs/INPUT-INTEGRATION.md`](docs/INPUT-INTEGRATION.md). The `RioGamepad` virtual HID driver is built (KMDF + VHF), **test-signed, installed, and verified**: it enumerates in `joy.cpl`, and the C# HID feeder (`DeviceIoControl` → `RioGamepad.sys`) drives its axes, buttons, and hat end-to-end (see diff --git a/deploy/build-pod.ps1 b/deploy/build-pod.ps1 new file mode 100644 index 0000000..cd1c9bc --- /dev/null +++ b/deploy/build-pod.ps1 @@ -0,0 +1,145 @@ +<# +.SYNOPSIS + Build a POD-BUNDLED RIOJoy for one podized game (PLAN.md §Phase 10): a + drop-in folder the game's install carries inside its own directory — + app + portable config (the game's profile) + start script. RIOJoy starts + with the game (--exit-with) and exits by itself when the game exits, so + no resident RIOJoy runs on the pod and the console operator never touches + it. Carries NO drivers/vendor payload: the pod is provisioned once by the + universal package (build-package.ps1 → install-rio.ps1). + +.PARAMETER ProfileJson + Path to the game's single-profile JSON document (must carry "Name"; + same format as profiles\descent-d1x.json). +.PARAMETER GameExe + Game executable name for --exit-with. Default: the profile's first + MatchExecutables entry. +.PARAMETER Flavor + net48 (Windows 10/11 pods, default) or net40 (XP pods, x86). +.PARAMETER OutDir + Where to write the zip (default: dist, relative to the repo root). +.PARAMETER Configuration + Build configuration (default: Release). +#> +param( + [Parameter(Mandatory = $true)] + [string]$ProfileJson, + [string]$GameExe, + [ValidateSet('net48', 'net40')] + [string]$Flavor = 'net48', + [string]$OutDir = 'dist', + [string]$Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent # deploy\ -> repo root +$staging = Join-Path ([IO.Path]::GetTempPath()) "riojoy-pod-$([Guid]::NewGuid().ToString('N'))" + +Write-Host '== RIOJoy pod bundle ==' -ForegroundColor Cyan + +# --- profile document ----------------------------------------------------- +if (-not (Test-Path $ProfileJson)) { throw "Profile document not found: $ProfileJson" } +$profileText = Get-Content $ProfileJson -Raw +$profileDoc = $profileText | ConvertFrom-Json +if (-not $profileDoc.Name) { throw "Profile file '$ProfileJson' has no Name." } +if (-not $GameExe) { + $GameExe = @($profileDoc.MatchExecutables) | Select-Object -First 1 + if (-not $GameExe) { throw "Profile has no MatchExecutables; pass -GameExe ." } +} + +# --- version stamp -------------------------------------------------------- +$sha = (& git -C $repo rev-parse --short HEAD 2>$null) +if (-not $sha) { $sha = 'nogit' } +$version = "{0}-{1}" -f (Get-Date -Format 'yyyyMMdd'), $sha +$safeName = $profileDoc.Name +foreach ($c in [IO.Path]::GetInvalidFileNameChars()) { $safeName = $safeName.Replace($c, '_') } +$safeName = $safeName -replace ' ', '-' + +try { + $appOut = Join-Path $staging 'riojoy' + New-Item -ItemType Directory -Force -Path $appOut | Out-Null + + # 1. Publish the tray app (framework-dependent, like the universal package). + Write-Host "Publishing RioJoy.Tray ($Configuration, $Flavor)..." + & dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') ` + -c $Configuration -f $Flavor -p:DebugType=none ` + -o $appOut | Out-Null + if ($LASTEXITCODE -ne 0) { throw "dotnet publish ($Flavor) failed." } + + if ($Flavor -eq 'net48') { + # Same SkiaSharp pruning as build-package.ps1: keep the win-x64 native + # at the app root, drop arch subdirs and non-Windows natives. + foreach ($d in 'x64', 'x86', 'arm64') { + $nd = Join-Path $appOut $d + if (Test-Path $nd) { Remove-Item $nd -Recurse -Force } + } + Get-ChildItem $appOut -Filter '*.dylib' -ErrorAction SilentlyContinue | Remove-Item -Force + Get-ChildItem $appOut -Filter '*.so' -ErrorAction SilentlyContinue | Remove-Item -Force + } + + # 2. Portable config beside the exe (ConfigLocator picks it over %APPDATA%): + # an AppConfig wrapping just this game's profile, verbatim. + $configJson = "{`n `"Profiles`": [`n$profileText`n ]`n}" + Set-Content -Path (Join-Path $appOut 'config.json') -Value $configJson -Encoding utf8 + Set-Content -Path (Join-Path $appOut 'VERSION.txt') -Value "RIOJoy pod bundle $version ($Flavor) - $($profileDoc.Name)" -Encoding utf8 + + # 3. Start script: the game's launch script calls this before the game. + $startBat = @( + '@echo off' + "rem RIOJoy pod companion for $($profileDoc.Name)." + 'rem Call from the game''s launch script BEFORE starting the game;' + 'rem RIOJoy exits by itself when the game exits (--exit-with).' + "start `"`" `"%~dp0riojoy\RioJoy.Tray.exe`" --exit-with $GameExe" + ) -join "`r`n" + Set-Content -Path (Join-Path $staging 'start-riojoy.bat') -Value $startBat -Encoding Ascii + + # 4. Integrator note. + $readme = @( + "RIOJoy pod bundle - $($profileDoc.Name) ($version, $Flavor)" + '' + 'Drop the riojoy\ folder and start-riojoy.bat into the game''s install' + 'directory on the pod, and call start-riojoy.bat from the game''s launch' + 'script before starting the game. RIOJoy activates when the game window' + 'comes foreground and exits by itself when the game exits - no resident' + 'RIOJoy, nothing for the console operator to manage.' + '' + 'The pod must be provisioned once with the universal RIOJoy package' + '(drivers: ViGEmBus / RioGamepad - see install-rio.ps1); pod bundles' + 'deliberately carry no drivers.' + '' + 'The game''s profile lives in riojoy\config.json (portable mode - the' + 'per-user %APPDATA% config is ignored while it exists). Edit it there,' + 'or run riojoy\RioJoy.Tray.exe with no arguments for the profile editor.' + ) -join "`r`n" + Set-Content -Path (Join-Path $staging 'README-POD.txt') -Value $readme -Encoding Ascii + + # 5. Zip with forward-slash entry names (same rationale as build-package.ps1). + $outDirFull = if ([IO.Path]::IsPathRooted($OutDir)) { $OutDir } else { Join-Path $repo $OutDir } + New-Item -ItemType Directory -Force -Path $outDirFull | Out-Null + $zip = Join-Path $outDirFull "RIOJoy-pod-$safeName-$version.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Write-Host "Zipping -> $zip" + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $base = (Resolve-Path $staging).Path.TrimEnd('\') + '\' + $fs = [System.IO.File]::Open($zip, [System.IO.FileMode]::CreateNew) + try { + $archive = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($file in Get-ChildItem -Path $staging -Recurse -File) { + $entryName = $file.FullName.Substring($base.Length) -replace '\\', '/' + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, $file.FullName, $entryName, + [System.IO.Compression.CompressionLevel]::Optimal) | Out-Null + } + } finally { $archive.Dispose() } + } finally { $fs.Dispose() } + + $size = '{0:N1} MB' -f ((Get-Item $zip).Length / 1MB) + Write-Host '' + Write-Host "Pod bundle built: $zip ($size)" -ForegroundColor Green + Write-Host "Integrate: extract into the game's folder, call start-riojoy.bat from its launch script (game exe: $GameExe)." +} +finally { + if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } +} diff --git a/docs/INPUT-INTEGRATION.md b/docs/INPUT-INTEGRATION.md index 3d0beb3..2c8b677 100644 --- a/docs/INPUT-INTEGRATION.md +++ b/docs/INPUT-INTEGRATION.md @@ -141,6 +141,14 @@ example: throttle → `RightThumbY` `UnipolarPositive`, rudder mix → ## Shipping a profile with your game +Two models, chosen per deployment: + +- **Pod bundle (production — cockpit cabinets):** the game's folder carries + its own RIOJoy copy + profile; nothing is registered anywhere. See + [Pod-bundled deployment](#pod-bundled-deployment-production) below. +- **Import into a resident RIOJoy (dev boxes):** hand a profile document to + the shared tray install, as follows. + A game (or its installer/launcher) hands its profile to RIOJoy as a **single-profile JSON document** plus one command: @@ -186,6 +194,40 @@ repo as the source of truth — dxx-rebirth does this, and a RIOJoy test (`ShippedDescentProfile_MatchesDxxRebirthReferenceCopy`) asserts the two checkouts stay byte-identical so drift is caught in CI. +## Pod-bundled deployment (production) + +On the pods (the cockpit cabinets) no resident RIOJoy runs at all. Each +podized game's install carries its own copy, built by: + +``` +deploy\build-pod.ps1 -ProfileJson +``` + +That emits a ~5 MB drop-in — `riojoy\` (app + a **portable** `config.json` +holding just this game's profile; a config beside the exe wins over the +per-user `%APPDATA%` store) plus `start-riojoy.bat` — which the game's +launch script calls before starting the game: + +``` +start "" "...\riojoy\RioJoy.Tray.exe" --exit-with +``` + +`--exit-with` makes RIOJoy self-managing: it activates when the game's window +comes foreground, and once the game process has run and then exited it tears +itself down completely (ports released, wallpaper restored, plasma blanked) +and quits. If the game never appears within 60 s it also quits, so a failed +launch can't strand it. Back-to-back launches hand over cleanly: a starting +`--exit-with` instance waits up to 15 s for the previous game's copy to +release the single-instance lock. + +Properties that matter on a cabinet: each game pins the RIOJoy build it was +verified with (updating RIOJoy for a new game can't regress an old one); +native games simply don't bundle RIOJoy, so the COM ports are free for them +by construction; and drivers stay a one-time pod provisioning step (the +universal package's `install-rio.ps1`) — pod bundles deliberately carry none. +The bundled exe is still the full tray app: run it with no arguments on the +pod and you have the profile editor. + ## Testing without hardware or game - **vRIO** (`pipe:vrio`): click buttons on the emulator's panel and watch them diff --git a/docs/PLAN.md b/docs/PLAN.md index d851888..e125c62 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -490,6 +490,45 @@ spec + client snippets in [`docs/FEEDBACK.md`](FEEDBACK.md). Delivers the settings (JSON-only today); shipped client examples (SimHub plugin / DCS export script) beyond the FEEDBACK.md snippets. +### Phase 10 — Pod-bundled deployment — code-complete ✅ +Deployment topology decision (2026-07-31): RIO hardware exists only on **pods** +(the cockpit cabinets) and dev boxes — no freestanding end-user PCs. Production +model is therefore **one RIOJoy copy bundled inside each podized game's +folder**, started by the game's launch script and exiting with the game; no +resident RIOJoy runs on a pod, and the native games simply don't bundle one +(making the COM-port yield machinery vestigial in production). The resident +tray + auto-switch reclassifies as the development harness. 455 xUnit tests +total across the suite. +- **Portable config**: `ConfigLocator.Resolve` — a `config.json` beside the + exe wins over `%APPDATA%\RIOJoy\config.json`; `TrayApplicationContext. + ConfigPath` resolves through it, so `--import-profile` targets the same + store. A pod bundle needs no import step: its config *is* the profile. +- **`--exit-with `** (`CompanionTarget.Parse` — pid, or a name + normalized like auto-switch triggers): the tray polls the companion on its + existing 1 s timer and quits through the normal teardown (ports released, + wallpaper restored, plasma blanked) once the game has run and then gone. + `CompanionExit` holds the pure decision — launch order isn't guaranteed, so + a never-seen companion only triggers exit after a 60 s startup grace + (also covers "game failed to launch"). Clock-free and unit-tested + (`tests/.../Hosting/CompanionExitTests`). +- **Instance handoff**: with `--exit-with`, a starting instance waits up to + 15 s for the predecessor's single-instance mutex (game A's copy tearing + down while game B's starts) instead of the historical silent exit-0; plain + launches keep the instant-exit behavior. Abandoned mutex (predecessor + crash) counts as acquired. +- **`deploy/build-pod.ps1`**: emits the per-game drop-in — `riojoy\` app + (flavor-selectable net48/net40, Skia-pruned) + portable `config.json` + wrapping the game's profile document verbatim + `start-riojoy.bat` + (`--exit-with` prefilled from the profile's first trigger) + README-POD — + zipped as `RIOJoy-pod--.zip` (~4.5 MB). Deliberately **no + drivers**: pods are provisioned once by the universal package. Verified by + building the Descent bundle and round-tripping its emitted config through + `ConfigStore.Load`. +- ⏳ **Remaining:** on-pod verification of the full launch/handoff cycle + (launcher → game A → quit → game B); podize a first real game with the + bundle; revisit the universal zip's `install.bat` framing (dev-setup only) + once pod deploys are routine. + --- ## Open items / risks diff --git a/src/RioJoy.Core/Hosting/CompanionExit.cs b/src/RioJoy.Core/Hosting/CompanionExit.cs new file mode 100644 index 0000000..3d0fb3a --- /dev/null +++ b/src/RioJoy.Core/Hosting/CompanionExit.cs @@ -0,0 +1,63 @@ +using System.Globalization; +using RioJoy.Core.Profiles; + +namespace RioJoy.Core.Hosting; + +/// +/// The process a pod-bundled RIOJoy lives alongside (--exit-with): +/// either a PID or an executable name, normalized the same way auto-switch +/// triggers are (basename, no .exe, lower-case) so launch scripts can +/// pass whatever they have. +/// +public sealed record CompanionTarget +{ + public int? Pid { get; init; } + + /// Normalized executable name (when is null). + 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 ?? "?"; +} + +/// +/// Pure decision core of --exit-with: 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 never +/// 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. +/// +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; + } + + /// True once RIOJoy should tear down and exit. + public bool ShouldExit(bool companionRunning, TimeSpan elapsed) + { + if (companionRunning) + { + _seen = true; + return false; + } + return _seen || elapsed >= _grace; + } +} diff --git a/src/RioJoy.Core/Profiles/ConfigLocator.cs b/src/RioJoy.Core/Profiles/ConfigLocator.cs new file mode 100644 index 0000000..4a9d94d --- /dev/null +++ b/src/RioJoy.Core/Profiles/ConfigLocator.cs @@ -0,0 +1,29 @@ +namespace RioJoy.Core.Profiles; + +/// +/// Resolves which config file the app uses: a portable +/// config.json 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. +/// +public static class ConfigLocator +{ + /// The portable config's file name, looked for beside the exe. + public const string PortableConfigFileName = "config.json"; + + /// + /// The portable config path for if one + /// exists there, else . + /// + 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; + } +} diff --git a/src/RioJoy.Tray/Program.cs b/src/RioJoy.Tray/Program.cs index 5fa5ae4..7a4573e 100644 --- a/src/RioJoy.Tray/Program.cs +++ b/src/RioJoy.Tray/Program.cs @@ -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); + /// /// 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. + /// + /// --exit-with <exe|pid> 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. /// [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 ] ({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) diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs index 2478062..3c32dff 100644 --- a/src/RioJoy.Tray/TrayApplicationContext.cs +++ b/src/RioJoy.Tray/TrayApplicationContext.cs @@ -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 --exit-with), 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. /// 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(); diff --git a/tests/RioJoy.Core.Tests/Hosting/CompanionExitTests.cs b/tests/RioJoy.Core.Tests/Hosting/CompanionExitTests.cs new file mode 100644 index 0000000..580c880 --- /dev/null +++ b/tests/RioJoy.Core.Tests/Hosting/CompanionExitTests.cs @@ -0,0 +1,66 @@ +using RioJoy.Core.Hosting; +using Xunit; + +namespace RioJoy.Core.Tests.Hosting; + +public class CompanionTargetTests +{ + [Fact] + public void NumericValue_IsAPid() + { + CompanionTarget target = CompanionTarget.Parse("4312"); + Assert.Equal(4312, target.Pid); + Assert.Null(target.Name); + } + + [Theory] + [InlineData("d1x-rebirth")] + [InlineData("D1X-Rebirth.exe")] + [InlineData(@"C:\games\descent\d1x-rebirth.exe")] + public void NameForms_NormalizeLikeTriggers(string value) + { + CompanionTarget target = CompanionTarget.Parse(value); + Assert.Null(target.Pid); + Assert.Equal("d1x-rebirth", target.Name); + } + + [Fact] + public void BlankValue_Throws() + { + Assert.Throws(() => CompanionTarget.Parse(" ")); + } +} + +public class CompanionExitTests +{ + private static readonly TimeSpan Grace = TimeSpan.FromSeconds(60); + + [Fact] + public void CompanionSeenThenGone_Exits() + { + var exit = new CompanionExit(Grace); + Assert.False(exit.ShouldExit(companionRunning: true, TimeSpan.FromSeconds(1))); + Assert.False(exit.ShouldExit(companionRunning: true, TimeSpan.FromSeconds(2))); + Assert.True(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(3))); + } + + [Fact] + public void CompanionNeverSeen_WaitsOutTheStartupGrace() + { + // Launch order is not guaranteed: the game may still be loading. + var exit = new CompanionExit(Grace); + Assert.False(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(5))); + Assert.False(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(59))); + Assert.True(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(60))); + } + + [Fact] + public void LateStart_WithinGrace_StillTracksTheCompanion() + { + var exit = new CompanionExit(Grace); + Assert.False(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(30))); + Assert.False(exit.ShouldExit(companionRunning: true, TimeSpan.FromSeconds(45))); // game arrived late + Assert.False(exit.ShouldExit(companionRunning: true, TimeSpan.FromSeconds(90))); // grace no longer matters + Assert.True(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(91))); + } +} diff --git a/tests/RioJoy.Core.Tests/Profiles/ConfigLocatorTests.cs b/tests/RioJoy.Core.Tests/Profiles/ConfigLocatorTests.cs new file mode 100644 index 0000000..94a78ed --- /dev/null +++ b/tests/RioJoy.Core.Tests/Profiles/ConfigLocatorTests.cs @@ -0,0 +1,50 @@ +using RioJoy.Core.Profiles; +using Xunit; + +namespace RioJoy.Core.Tests.Profiles; + +public class ConfigLocatorTests +{ + [Fact] + public void NoPortableFile_ResolvesToRoaming() + { + string dir = Path.Combine(Path.GetTempPath(), $"riojoy-loc-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + Assert.Equal(@"C:\roaming\config.json", + ConfigLocator.Resolve(dir, @"C:\roaming\config.json")); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void PortableFileBesideExe_Wins() + { + string dir = Path.Combine(Path.GetTempPath(), $"riojoy-loc-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + string portable = Path.Combine(dir, ConfigLocator.PortableConfigFileName); + File.WriteAllText(portable, "{}"); + Assert.Equal(portable, ConfigLocator.Resolve(dir, @"C:\roaming\config.json")); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void MissingExeDirectory_FallsBackToRoaming(string? exeDir) + { + Assert.Equal(@"C:\roaming\config.json", + ConfigLocator.Resolve(exeDir, @"C:\roaming\config.json")); + } +}