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
+5 -2
View File
@@ -44,9 +44,12 @@ dotnet test RioJoy.sln
## Status
Phases 15 and 9 are implemented and tested (442 unit tests). Games (or sim
Phases 15, 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
+145
View File
@@ -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 <name>." }
}
# --- 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 }
}
+42
View File
@@ -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 <your-game-profile.json>
```
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 <game exe>
```
`--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
+39
View File
@@ -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 <exe|pid>`** (`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-<name>-<stamp>.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
+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();
@@ -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<ArgumentException>(() => 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)));
}
}
@@ -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"));
}
}