diff --git a/Console/RedPlanet/Apps.xml b/Console/RedPlanet/Apps.xml index f48c060..14dddde 100644 --- a/Console/RedPlanet/Apps.xml +++ b/Console/RedPlanet/Apps.xml @@ -80,10 +80,11 @@ + 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. --> diff --git a/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs b/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs index 54203f9..5f07e22 100644 --- a/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs +++ b/Console/tests/TeslaConsole.DiffTests/CatalogTests.cs @@ -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" diff --git a/Launcher/TeslaLauncherAgent.cs b/Launcher/TeslaLauncherAgent.cs index 0472d11..3349f39 100644 --- a/Launcher/TeslaLauncherAgent.cs +++ b/Launcher/TeslaLauncherAgent.cs @@ -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 }; + } + + /// The immediate child of C:\Games that contains + /// (e.g. C:\Games\RIOJoy\app\x.exe → C:\Games\RIOJoy), or null if not under C:\Games. + 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() diff --git a/Launcher/TeslaLauncherService.cs b/Launcher/TeslaLauncherService.cs index ff7cfc6..7b5fdea 100644 --- a/Launcher/TeslaLauncherService.cs +++ b/Launcher/TeslaLauncherService.cs @@ -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\ 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