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