Console/launcher-managed RIOJoy + real product uninstall

RIOJoy lifecycle is now owned by the console + launcher (the auto-start Run entry
was removed on the RioJoy side): Apps.xml autoRestart false -> true, so the
launcher starts RIOJoy and keeps it alive. Safe with uninstall — the watcher only
relaunches a still-tracked process, and uninstall's kill untracks it first.

Uninstall now physically removes the product (was unregister-only):
- Agent CmdUninstallApp: after kill + unregister, reports the product's
  C:\Games\<dir> to clean up — but only if no remaining registered launch entry
  still uses that folder (orphan check keeps multi-entry products like Red Planet's
  GameClient/LC/MR intact until the last entry is removed).
- Service (SYSTEM): on UninstallApp, runs <dir>\pre-uninstall.bat if present
  (RIOJoy's removes ViGEmBus + config) then deletes the directory, on a background
  thread so it can't trip the Console's RPC timeout. Path-guarded to only ever
  delete a proper subdirectory of C:\Games. Logged to podconf.log.

74 tests green; the cleanup path is pod-behavior, not unit-tested here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-30 18:53:09 -05:00
co-authored by Claude Opus 4.8
parent ce094535fa
commit 4c15197b34
4 changed files with 135 additions and 8 deletions
+6 -5
View File
@@ -80,10 +80,11 @@
<!-- RIOJoy — virtual-gamepad feeder for the cockpit RIO board (separate repo,
net48 framework-dependent). Its dist\RIOJoy-<ver>.zip is purpose-built for
Install Product: extracts to C:\Games (giving postinstall.bat + RIOJoy\),
and the launcher runs postinstall.bat as SYSTEM — it installs ViGEmBus and
registers RIOJoy to auto-start at logon (HKLM ...\Run). The launch entry
below registers it in the pod app list and lets the operator (re)start it on
demand; autoRestart is false because the auto-start Run entry owns startup. -->
and the launcher runs postinstall.bat as SYSTEM to install the ViGEmBus
driver. The console + launcher then own RIOJoy's lifecycle: the launch entry
below starts it and autoRestart keeps it alive (no logon auto-start entry).
On uninstall the launcher runs RIOJoy\pre-uninstall.bat (removes ViGEmBus +
config) and deletes C:\Games\RIOJoy. -->
<Product id="87FBC2E6-6359-4EF4-96A5-DF157823CFF6"
name="RIOJoy"
menuText="RIOJoy..."
@@ -92,7 +93,7 @@
displayName="RIOJoy"
exe="C:\Games\RIOJoy\app\RioJoy.Tray.exe"
args=""
autoRestart="false"
autoRestart="true"
hostType="None" />
</Product>
</AppCatalog>
@@ -32,7 +32,7 @@ namespace TeslaConsole.DiffTests
[Fact]
public void RioJoy_Matches_Expected()
=> Assert.Equal(
@"RIOJoy|fe83e212-45df-48f9-848e-0b3cee0692a3|C:\Games\RIOJoy\app\RioJoy.Tray.exe||C:\Games\RIOJoy\app|False",
@"RIOJoy|fe83e212-45df-48f9-848e-0b3cee0692a3|C:\Games\RIOJoy\app\RioJoy.Tray.exe||C:\Games\RIOJoy\app|True",
Entry("FE83E212-45DF-48F9-848E-0B3CEE0692A3"));
// Each expected string is "DisplayName|LaunchKey|Exe|Args|WorkingDirectory|AutoRestart"
+34 -1
View File
@@ -651,9 +651,42 @@ namespace Tesla.Launcher.Agent
{
var key = msg.LaunchKey ?? throw new Exception("UninstallApp requires LaunchKey");
CmdKillAllOfType(msg);
var removed = _launchApps.FirstOrDefault(a => a.LaunchKey == key);
_launchApps.RemoveAll(a => a.LaunchKey == key);
SaveLaunchApps();
return null;
// If the removed app's product directory under C:\Games is no longer used by
// any remaining registered entry, tell the Service (which runs as SYSTEM) to
// run its pre-uninstall.bat and delete the directory. The orphan check keeps a
// product with several launch entries sharing one folder (e.g. Red Planet's
// GameClient/LC/MR) intact until its LAST entry is removed.
string cleanupDir = null;
if (removed != null)
{
var dir = ProductDirUnderGames(removed.WorkingDirectory)
?? ProductDirUnderGames(removed.ExeFile);
if (dir != null && !_launchApps.Any(a => string.Equals(
ProductDirUnderGames(a.WorkingDirectory) ?? ProductDirUnderGames(a.ExeFile),
dir, StringComparison.OrdinalIgnoreCase)))
{
cleanupDir = dir;
}
}
return new { CleanupDir = cleanupDir };
}
/// <summary>The immediate child of C:\Games that contains <paramref name="path"/>
/// (e.g. C:\Games\RIOJoy\app\x.exe → C:\Games\RIOJoy), or null if not under C:\Games.</summary>
private static string ProductDirUnderGames(string path)
{
if (string.IsNullOrWhiteSpace(path)) return null;
string full;
try { full = Path.GetFullPath(path); } catch { return null; }
const string games = @"C:\Games\";
if (!full.StartsWith(games, StringComparison.OrdinalIgnoreCase)) return null;
var seg = full.Substring(games.Length).Split('\\', '/')[0];
return string.IsNullOrEmpty(seg) ? null : games + seg;
}
private object CmdGetVolumeLevel()
+94 -1
View File
@@ -506,12 +506,39 @@ namespace Tesla.Launcher.Service
case "Shutdown":
case "ClearStore":
case "RemoveApp":
case "UninstallApp":
{
await ForwardToAgentJsonAsync(functionName, args, ct);
return null;
}
case "UninstallApp":
{
// The Agent kills + unregisters the app and returns the orphaned
// product directory to remove (or null). The physical cleanup
// (pre-uninstall.bat + delete) can exceed the Console's RPC timeout,
// so respond immediately and clean up on a background thread.
var json = await ForwardToAgentJsonAsync(functionName, args, ct);
if (json != null)
{
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("CleanupDir", out var cd)
&& cd.ValueKind == JsonValueKind.String)
{
var dir = cd.GetString();
var logPath = Path.Combine(AppContext.BaseDirectory, "podconf.log");
_ = Task.Run(() => CleanupProductDirectory(dir, logPath));
}
}
catch (Exception ex)
{
_logger.LogWarning("UninstallApp cleanup parse failed: {Msg}", ex.Message);
}
}
return null;
}
case "set_VolumeLevel":
await ForwardToAgentJsonAsync(functionName, args, ct);
return null;
@@ -668,6 +695,72 @@ namespace Tesla.Launcher.Service
}
}
// ── Product Uninstall Cleanup ────────────────────────────────────
// Runs as SYSTEM (the Service) on a background thread after UninstallApp,
// when the Agent reports the product directory is orphaned: run its
// pre-uninstall.bat if it ships one (e.g. RIOJoy removes ViGEmBus + config),
// then delete the C:\Games\<product> directory.
private void CleanupProductDirectory(string productDir, string logPath)
{
void Log(string msg) => File.AppendAllText(logPath,
$"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
if (string.IsNullOrWhiteSpace(productDir)) return;
string full;
try { full = Path.GetFullPath(productDir); } catch { return; }
// Safety: only ever remove a proper subdirectory of C:\Games — never
// C:\Games itself, never anything outside it.
var gamesRoot = Path.GetFullPath(GAMES_DIR).TrimEnd('\\');
var target = full.TrimEnd('\\');
if (!target.StartsWith(gamesRoot + "\\", StringComparison.OrdinalIgnoreCase)
|| target.Equals(gamesRoot, StringComparison.OrdinalIgnoreCase))
{
Log($"UNINSTALL: refusing to clean unsafe path '{full}'.");
return;
}
if (!Directory.Exists(full)) { Log($"UNINSTALL: {full} already absent."); return; }
// 1. Run pre-uninstall.bat if the product ships one (idempotent by design).
var preUninstall = Path.Combine(full, "pre-uninstall.bat");
if (File.Exists(preUninstall))
{
Log($"UNINSTALL: running {preUninstall}");
try
{
using var proc = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo
{
FileName = preUninstall,
WorkingDirectory = full,
UseShellExecute = false,
CreateNoWindow = true
});
proc?.WaitForExit(120_000);
Log($"UNINSTALL: pre-uninstall.bat exit code {(proc != null ? proc.ExitCode.ToString() : "?")}");
}
catch (Exception ex) { Log($"UNINSTALL: pre-uninstall.bat error: {ex.Message}"); }
}
// 2. Delete the product directory (retry briefly for lingering handles).
for (int attempt = 1; attempt <= 3; attempt++)
{
try
{
Directory.Delete(full, recursive: true);
Log($"UNINSTALL: removed {full}");
return;
}
catch (Exception ex)
{
if (attempt == 3) { Log($"UNINSTALL: could not remove {full}: {ex.Message}"); return; }
Thread.Sleep(1000);
}
}
}
// ── Flat ↔ Wire Conversion ────────────────────────────────────────
private class FlatLaunchData