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
+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