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