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