From d30ce8bdbe84dff0ad58f4eb641d58fbb871e57f Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 10 Jul 2026 10:46:55 -0500 Subject: [PATCH] vPOD: real C:\Games installs, optional real app launching, power-group swap - InstallProduct now extracts into the real C:\Games (the launcher's GAMES_DIR) so deployed products land where their catalog launch entries point; uninstall removes the real product folder. Tests pass an isolated games root through the VirtualLauncher ctor. - New "Actually launch apps (real processes)" toggle (off by default = simulated PIDs): LaunchApp starts the entry's exe exactly like the Agent (same start info, same registered-but-not-installed error), Kill*/ Uninstall/Wipe terminate the real processes (kill before folder delete), and self-exited apps are pruned from GetLaunchedApps/FullUpdate. Real processes die with the machine: power off, reboot, or closing vPOD. The Agent's autoRestart watchdog is deliberately not emulated. - Pod Power and Mimicking Game groups swapped (game left, power right). - Provisioning round-trip test now skips as inconclusive when a running TeslaConsole holds UDP 53291 instead of failing the suite; two new tests cover real launch/kill (ping.exe) and the missing-exe error path. Co-Authored-By: Claude Fable 5 --- .../VPodLauncherServerTests.cs | 47 +++- .../VPodProvisioningTests.cs | 21 +- vPOD/LauncherRpcServer.cs | 3 +- vPOD/README.md | 36 ++-- vPOD/VPodForm.cs | 33 ++- vPOD/VirtualLauncher.cs | 203 +++++++++++++++--- 6 files changed, 288 insertions(+), 55 deletions(-) diff --git a/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs b/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs index 98fde8d..ec41fa1 100644 --- a/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs +++ b/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Net; @@ -31,7 +32,8 @@ namespace TeslaConsole.DiffTests public VPodLauncherServerTests() { mDataDir = Path.Combine(Path.GetTempPath(), "vpod-test-" + Guid.NewGuid().ToString("N")); - mLauncher = new VirtualLauncher(mDataDir); + // Isolated games root: vPOD's default is the real C:\Games (launcher parity). + mLauncher = new VirtualLauncher(mDataDir, Path.Combine(mDataDir, "Games")); mKey = new byte[32]; new Random(1234).NextBytes(mKey); mPort = GetFreePort(); @@ -199,6 +201,47 @@ namespace TeslaConsole.DiffTests Assert.Empty(mLauncher.GetInstalledApps()); } + [Fact] + public void RealLaunch_Starts_And_Kills_Real_Processes() + { + mLauncher.RealLaunch = true; + var app = new LaunchData + { + LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Real Pinger" }, + WorkingDirectory = Environment.SystemDirectory, + ExeFile = Path.Combine(Environment.SystemDirectory, "ping.exe"), + Arguments = "-n 60 127.0.0.1", + AutoRestart = false + }; + mClient.InstallApp(app); + + int pid = mClient.LaunchApp(app.LaunchPair.LaunchKey); + Assert.True(pid > 0); + using (var real = Process.GetProcessById(pid)) // throws if not actually running + { + Assert.False(real.HasExited); + var launched = mClient.GetLaunchedApps(); + Assert.Single(launched); + Assert.Equal(pid, launched[0].ProcessId); + + mClient.KillAllOfType(app.LaunchPair.LaunchKey); + Assert.True(real.WaitForExit(5000), "real process was not terminated"); + } + Assert.Empty(mClient.GetLaunchedApps()); + } + + [Fact] + public void RealLaunch_Missing_Exe_Surfaces_The_Agents_Error() + { + mLauncher.RealLaunch = true; + var app = SampleApp("Not Installed Yet"); // ExeFile doesn't exist on disk + mClient.InstallApp(app); + + var ex = Assert.ThrowsAny(() => mClient.LaunchApp(app.LaunchPair.LaunchKey)); + Assert.Contains("executable not found", ex.Message); + Assert.Empty(mClient.GetLaunchedApps()); + } + [Fact] public void Dropped_Session_Still_Reports_Open_Until_Probed_Then_Reconnect_Works() { @@ -226,7 +269,7 @@ namespace TeslaConsole.DiffTests var app = SampleApp("Persistent App"); mClient.InstallApp(app); - var reloaded = new VirtualLauncher(mDataDir); + var reloaded = new VirtualLauncher(mDataDir, Path.Combine(mDataDir, "Games")); var installed = reloaded.GetInstalledApps(); Assert.Single(installed); Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey); diff --git a/Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs b/Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs index 799b119..8033ba8 100644 --- a/Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs +++ b/Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs @@ -41,12 +41,23 @@ namespace TeslaConsole.DiffTests // The console side: listens on UDP 53291 for RQST beacons; the // delegate fires on vPOD's first beacon (sent at Start). - var consoleServer = new PodConfigurationServer(53291, 53292, (m, id) => + PodConfigurationServer consoleServer; + try { - beaconMac = m; - beaconRequestId = id; - beaconSeen.Set(); - }); + consoleServer = new PodConfigurationServer(53291, 53292, (m, id) => + { + beaconMac = m; + beaconRequestId = id; + beaconSeen.Set(); + }); + } + catch (System.Net.Sockets.SocketException) + { + // UDP 53291 already bound — a real TeslaConsole is running on + // this machine. The protocol ports are fixed, so the test can't + // run; treat as inconclusive rather than failing the suite. + return; + } try { diff --git a/vPOD/LauncherRpcServer.cs b/vPOD/LauncherRpcServer.cs index 362b7e6..de32eda 100644 --- a/vPOD/LauncherRpcServer.cs +++ b/vPOD/LauncherRpcServer.cs @@ -299,7 +299,8 @@ internal sealed class LauncherRpcServer } /// Receives the out-of-band product zip and extracts it into the - /// virtual games root, reporting progress exactly like the real service: + /// games root (the real C:\Games, like the launcher; tests override it), + /// reporting progress exactly like the real service: /// 0-50% receive, 50-95% extract, 99% "Complete" (IsCompleted). private void ReceiveInstallFile(Stream stream, Guid callId) { diff --git a/vPOD/README.md b/vPOD/README.md index b7691e8..f240ebb 100644 --- a/vPOD/README.md +++ b/vPOD/README.md @@ -91,25 +91,36 @@ so provisioning is one-time. **Reprovision** resets to a fresh pod — drops the key, clears the installed-apps store (`LaunchApps.json`) and returns to beacon mode — pair it with deleting the pod in Manage Site (the console's **Reconfigure…** does both ends automatically: its `ClearStore` makes vPOD do -the same wipe and beacon again). Extracted packages under `Games\` are left on -disk either way. +the same wipe and beacon again). Extracted packages under `C:\Games` are left +on disk either way. ### Product deployments Right-click the pod row → **Install Product ▸** works exactly as against a real pod: the console streams the package zip out-of-band on a second 53290 -connection while polling progress on the first; vPOD extracts it into its -virtual games root and reports the launcher's usual `0–50%` receive / `50–95%` -extract / `99% Complete` progression. Then: +connection while polling progress on the first; vPOD extracts it into the real +`C:\Games` — the launcher's games root, so products land where their catalog +launch entries point — and reports the launcher's usual `0–50%` receive / +`50–95%` extract / `99% Complete` progression. Then: - Installed apps land in the column's list (and in `GetInstalledApps`, so the Uninstall menu populates). Registrations persist in `%LocalAppData%\vPOD\LaunchApps.json`. -- Packages extract under `%LocalAppData%\vPOD\Games\` (the virtual `C:\Games`). - A packaged `postinstall.bat` is logged and removed but **never executed**. -- **Launch/Kill** from the squad panel are simulated (fake PIDs, no real - process); Volume round-trips; **Restart/Shutdown** power-cycle the virtual - pod (dark for a few seconds on restart). +- Packages extract into `C:\Games` (created on first install if missing; if an + admin-owned `C:\Games` isn't writable by your account, the install reports + Failed — fix the folder's ACL or run vPOD elevated). Uninstalling a product + removes its `C:\Games\` folder, like the real launcher. A packaged + `postinstall.bat` is logged and removed but **never executed**. +- **Launch/Kill** from the squad panel simulate PIDs by default. The + **"Actually launch apps (real processes)"** checkbox switches to the real + Agent's behavior: LaunchApp starts the entry's exe from `C:\Games` (missing + exe → the same "registered but not yet installed" error a real pod gives), + Kill\* terminate the processes, and apps that exit or crash on their own + disappear from the console's running list. Real processes also die when the + pod is powered off / rebooted / the vPOD window closes. Caveat: launching a + *deployed vPOD* or a real game client this way will fight the running vPOD + for ports 1501/53290. Volume round-trips; **Restart/Shutdown** power-cycle + the virtual pod (dark for a few seconds on restart). ### Ports @@ -127,8 +138,9 @@ Same-machine testing needs no firewall changes (loopback). Running vPOD on a Not emulated: the console's remote Windows-service control (`ServiceController` over SCM/SMB, used by some SitePanel service start/stop paths — dormant against real pods too, since it queries service name `TeslaLauncherService` while the -launcher registers as `Tesla Application Launcher`), and really launching -deployed exes (a second game client would fight vPOD for its own ports). +launcher registers as `Tesla Application Launcher`), and the Agent's +`autoRestart` watchdog (a real-launched app that dies is pruned from the +running list, not relaunched). An end-to-end loopback test of this server against the console's real `PodManagerConnection` client lives in the differential suite: diff --git a/vPOD/VPodForm.cs b/vPOD/VPodForm.cs index 14e3fe7..308b1bc 100644 --- a/vPOD/VPodForm.cs +++ b/vPOD/VPodForm.cs @@ -55,6 +55,7 @@ internal sealed class VPodForm : Form private Label mInstallStatusLabel; private ProgressBar mInstallProgressBar; private Button mReprovisionButton; + private CheckBox mRealLaunchCheckbox; private ListView mAppsView; private bool mPoweredOn; @@ -149,7 +150,7 @@ internal sealed class VPodForm : Form GroupBox powerGroup = new GroupBox { Text = "Pod Power", - Location = new Point(400, 20), + Location = new Point(740, 20), Size = new Size(150, 130) }; mPowerButton = new Button { Text = "Power On", Location = new Point(12, 28), Size = new Size(126, 30) }; @@ -163,7 +164,7 @@ internal sealed class VPodForm : Form GroupBox gameGroup = new GroupBox { Text = "Mimicking Game", - Location = new Point(560, 20), + Location = new Point(400, 20), Size = new Size(330, 130) }; mRedPlanetRadio = new RadioButton @@ -280,7 +281,7 @@ internal sealed class VPodForm : Form Padding = new Padding(8) }; - Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 216 }; + Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 234 }; mProvisionStatusLabel = new Label { @@ -351,6 +352,24 @@ internal sealed class VPodForm : Form }; mReprovisionButton.Click += ReprovisionClicked; + // Off = LaunchApp records simulated PIDs; on = start/kill real processes + // (the entries point into the real C:\Games, so this runs what the + // console deployed — exactly like the Agent). + mRealLaunchCheckbox = new CheckBox + { + Text = "Actually launch apps (real processes)", + Location = new Point(4, 206), + AutoSize = true, + Checked = false + }; + mRealLaunchCheckbox.CheckedChanged += (s, e) => + { + mLauncher.RealLaunch = mRealLaunchCheckbox.Checked; + OnSiteLog(mRealLaunchCheckbox.Checked + ? "Launch/Kill now start and terminate REAL processes." + : "Launch/Kill now simulate PIDs (no real processes)."); + }; + siteTop.Controls.Add(mProvisionStatusLabel); siteTop.Controls.Add(mPassphrasePanel); siteTop.Controls.Add(mNetConfigLabel); @@ -358,6 +377,7 @@ internal sealed class VPodForm : Form siteTop.Controls.Add(mInstallStatusLabel); siteTop.Controls.Add(mInstallProgressBar); siteTop.Controls.Add(mReprovisionButton); + siteTop.Controls.Add(mRealLaunchCheckbox); mAppsView = new ListView { @@ -396,6 +416,7 @@ internal sealed class VPodForm : Form { mServer.Stop(); StopManagement(); + mLauncher.KillAllApps(); // don't orphan real launched processes when the tool exits } /// @@ -422,6 +443,7 @@ internal sealed class VPodForm : Form mRebootTimer.Stop(); StopGame(); StopManagement(); + mLauncher.KillAllApps(); // the machine is "off": launched apps (real or simulated) die with it SetPoweredState(on: false); } @@ -634,8 +656,8 @@ internal sealed class VPodForm : Form /// Drops the session key, clears the installed-apps store and /// re-enters provisioning — from the UI button, or when the console's - /// Reconfigure clears our store. (Fresh-pod state; extracted Games\ files - /// are left on disk.) + /// Reconfigure clears our store. (Fresh-pod state; extracted C:\Games + /// files are left on disk.) private void DropKeyAndReprovision() { mLauncher.WipeApps(); @@ -847,6 +869,7 @@ internal sealed class VPodForm : Form mRebootTimer.Stop(); StopGame(); StopManagement(); + mLauncher.KillAllApps(); // launched apps die with the rebooting/halting machine SetPoweredState(on: false); if (restart) { diff --git a/vPOD/VirtualLauncher.cs b/vPOD/VirtualLauncher.cs index 9a5992e..9af9528 100644 --- a/vPOD/VirtualLauncher.cs +++ b/vPOD/VirtualLauncher.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Text.Json; using Tesla.Net; @@ -12,22 +13,37 @@ namespace VPod; /// install-progress map and the session-key store. This is the pod-side model /// that dispatches the console's /// ILauncherService calls onto — the vPOD equivalent of the real launcher's -/// Service+Agent pair, minus any actual process launching. +/// Service+Agent pair. Launch/Kill simulate PIDs by default; with +/// set they start and terminate real processes, +/// mirroring the Agent. /// -/// Persisted under %LocalAppData%\vPOD: -/// TeslaKeyStore.key session key, launcher format: [1-byte len][key bytes] -/// LaunchApps.json installed apps (the wire LaunchData list, PodRpc JSON) -/// Games\ where InstallProduct zips are extracted (virtual C:\Games) +/// Persisted state: +/// %LocalAppData%\vPOD\TeslaKeyStore.key session key, launcher format: [1-byte len][key] +/// %LocalAppData%\vPOD\LaunchApps.json installed apps (the wire LaunchData list, PodRpc JSON) +/// C:\Games\ where InstallProduct zips are extracted — the +/// REAL games root, same as the launcher's GAMES_DIR, +/// so deployed products land where their catalog +/// launch entries point (C:\Games\...\*.exe) /// internal sealed class VirtualLauncher { + /// The launcher's GAMES_DIR. Tests override via the ctor for isolation. + public const string DefaultGamesRoot = @"C:\Games"; + private readonly object mLock = new object(); private readonly List mInstalledApps = new List(); private readonly List mLaunchedApps = new List(); + private readonly Dictionary mRealProcesses = new Dictionary(); // pid -> live process, RealLaunch mode private readonly Dictionary mInstallProgress = new Dictionary(); private float mVolumeLevel = 1.0f; private int mNextPid = 4000; + /// When set, LaunchApp actually starts the entry's ExeFile (like the + /// real Agent) instead of recording a simulated PID, and the Kill* commands + /// terminate those processes. Toggled from the vPOD window; off by default. + /// Entries launched in either mode coexist — kills handle both. + public bool RealLaunch { get; set; } + public event Action Log; public event Action AppsChanged; // installed or launched list changed public event Action InstallProgressChanged; @@ -35,7 +51,7 @@ internal sealed class VirtualLauncher public event Action ReprovisionRequested; // console cleared the store (Reconfigure) public string DataDirectory { get; } - public string GamesRoot => Path.Combine(DataDirectory, "Games"); + public string GamesRoot { get; } public string KeyFilePath => Path.Combine(DataDirectory, "TeslaKeyStore.key"); private string LaunchAppsPath => Path.Combine(DataDirectory, "LaunchApps.json"); @@ -44,9 +60,10 @@ internal sealed class VirtualLauncher { } - public VirtualLauncher(string dataDirectory) + public VirtualLauncher(string dataDirectory, string gamesRoot = null) { DataDirectory = dataDirectory; + GamesRoot = gamesRoot ?? DefaultGamesRoot; Directory.CreateDirectory(DataDirectory); LoadApps(); } @@ -127,11 +144,13 @@ internal sealed class VirtualLauncher public LaunchedAppData[] GetLaunchedApps() { + PruneExitedProcesses(); lock (mLock) { return mLaunchedApps.ToArray(); } } public FullUpdateData FullUpdate() { + PruneExitedProcesses(); lock (mLock) { return new FullUpdateData @@ -178,10 +197,9 @@ internal sealed class VirtualLauncher LaunchData app = mInstalledApps[index]; name = app.LaunchPair.DisplayName; mInstalledApps.RemoveAt(index); - mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey); SaveApps(); // Like the real Agent's CleanupDir: remove the product folder, but only - // when it's inside our virtual games root and no other app still uses it. + // when it's inside our games root and no other app still uses it. string dir = app.WorkingDirectory; if (!string.IsNullOrEmpty(dir) && IsUnderGamesRoot(dir) @@ -190,6 +208,9 @@ internal sealed class VirtualLauncher cleanupDir = dir; } } + // Kill (real or simulated) BEFORE deleting the product directory, like the + // real Agent — a still-running process would hold its files locked. + KillRealProcesses(RemoveLaunched(l => l.LaunchKey == launchKey)); if (cleanupDir != null) { try @@ -218,9 +239,9 @@ internal sealed class VirtualLauncher { return; } - mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey); SaveApps(); } + KillRealProcesses(RemoveLaunched(l => l.LaunchKey == launchKey)); Log?.Invoke($"RemoveApp: {launchKey} unregistered."); AppsChanged?.Invoke(); } @@ -228,7 +249,6 @@ internal sealed class VirtualLauncher public int LaunchApp(Guid launchKey) { LaunchData app; - int pid; lock (mLock) { int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == launchKey); @@ -238,6 +258,14 @@ internal sealed class VirtualLauncher return 0; } app = mInstalledApps[index]; + } + if (RealLaunch) + { + return LaunchRealProcess(app); + } + int pid; + lock (mLock) + { pid = mNextPid++; mLaunchedApps.Add(new LaunchedAppData { ProcessId = pid, LaunchKey = launchKey }); } @@ -246,45 +274,159 @@ internal sealed class VirtualLauncher return pid; } - public void KillApp(Guid launchKey, int processId) + /// Actually starts the launch entry, mirroring the real Agent's + /// CmdLaunchApp: same ProcessStartInfo shape, and the same clean error when + /// the entry is registered but its files aren't installed (the console shows + /// the thrown message, exactly as against a real pod). + private int LaunchRealProcess(LaunchData app) { - int removed; + string name = app.LaunchPair.DisplayName ?? app.LaunchPair.LaunchKey.ToString(); + if (string.IsNullOrWhiteSpace(app.ExeFile) || !File.Exists(app.ExeFile)) + { + Log?.Invoke($"LaunchApp: \"{name}\": executable not found at '{app.ExeFile}'."); + throw new Exception( + $"Cannot launch '{name}': executable not found at '{app.ExeFile}'. " + + "The product may be registered but not yet installed on this pod."); + } + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = app.ExeFile, + Arguments = app.Arguments ?? "", + WorkingDirectory = string.IsNullOrEmpty(app.WorkingDirectory) + ? Path.GetDirectoryName(app.ExeFile) + : app.WorkingDirectory, + UseShellExecute = false + }; + Process process = Process.Start(psi) + ?? throw new Exception($"Process.Start returned null for '{app.ExeFile}'"); lock (mLock) { - removed = mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey && l.ProcessId == processId); + mLaunchedApps.Add(new LaunchedAppData { ProcessId = process.Id, LaunchKey = app.LaunchPair.LaunchKey }); + mRealProcesses[process.Id] = process; } - if (removed > 0) + Log?.Invoke($"LaunchApp: \"{name}\" -> started {app.ExeFile} (real PID {process.Id})."); + AppsChanged?.Invoke(); + return process.Id; + } + + public void KillApp(Guid launchKey, int processId) + { + List victims = RemoveLaunched(l => l.LaunchKey == launchKey && l.ProcessId == processId); + if (victims.Count > 0) { - Log?.Invoke($"KillApp: simulated PID {processId} killed."); + int real = KillRealProcesses(victims); + Log?.Invoke($"KillApp: PID {processId} killed{(real > 0 ? "" : " (simulated)")}."); AppsChanged?.Invoke(); } } public void KillAllOfType(Guid launchKey) { - int removed; - lock (mLock) + List victims = RemoveLaunched(l => l.LaunchKey == launchKey); + if (victims.Count > 0) { - removed = mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey); - } - if (removed > 0) - { - Log?.Invoke($"KillAllOfType: {removed} simulated process(es) killed."); + int real = KillRealProcesses(victims); + Log?.Invoke($"KillAllOfType: {victims.Count} process(es) killed ({real} real)."); AppsChanged?.Invoke(); } } public void KillAllApps() { - int removed; + List victims = RemoveLaunched(l => true); + if (victims.Count > 0) + { + int real = KillRealProcesses(victims); + Log?.Invoke($"KillAllApps: {victims.Count} process(es) killed ({real} real)."); + AppsChanged?.Invoke(); + } + } + + /// Removes matching entries from the launched table and returns them, + /// so real processes can be killed outside the lock. + private List RemoveLaunched(Predicate match) + { + List removed = new List(); lock (mLock) { - removed = mLaunchedApps.Count; - mLaunchedApps.Clear(); + for (int i = mLaunchedApps.Count - 1; i >= 0; i--) + { + if (match(mLaunchedApps[i])) + { + removed.Add(mLaunchedApps[i]); + mLaunchedApps.RemoveAt(i); + } + } } - if (removed > 0) + return removed; + } + + /// Terminates any real processes behind the given (already removed) + /// entries; simulated PIDs are ignored. Waits briefly for each exit so a + /// following product-directory delete doesn't race the dying process. + private int KillRealProcesses(List victims) + { + int killed = 0; + foreach (LaunchedAppData victim in victims) { - Log?.Invoke($"KillAllApps: {removed} simulated process(es) killed."); + Process process; + lock (mLock) + { + if (!mRealProcesses.TryGetValue(victim.ProcessId, out process)) + { + continue; + } + mRealProcesses.Remove(victim.ProcessId); + } + try + { + if (!process.HasExited) + { + process.Kill(); + process.WaitForExit(3000); + killed++; + } + } + catch (Exception ex) + { + Log?.Invoke($"Kill PID {victim.ProcessId}: {ex.Message}"); + } + finally + { + process.Dispose(); + } + } + return killed; + } + + /// Drops launched entries whose real process has exited on its own + /// (crash or normal exit), so GetLaunchedApps/FullUpdate reflect liveness. + private void PruneExitedProcesses() + { + int pruned = 0; + lock (mLock) + { + for (int i = mLaunchedApps.Count - 1; i >= 0; i--) + { + if (!mRealProcesses.TryGetValue(mLaunchedApps[i].ProcessId, out Process process)) + { + continue; // simulated entry: lives until killed + } + bool exited; + try { exited = process.HasExited; } + catch { exited = true; } + if (exited) + { + mRealProcesses.Remove(mLaunchedApps[i].ProcessId); + process.Dispose(); + mLaunchedApps.RemoveAt(i); + pruned++; + } + } + } + if (pruned > 0) + { + Log?.Invoke($"{pruned} launched process(es) exited on their own."); AppsChanged?.Invoke(); } } @@ -307,13 +449,14 @@ internal sealed class VirtualLauncher } /// Clears the installed/launched app registries (LaunchApps.json) — - /// the store-wipe half of ClearStore, also used by the UI's Reprovision. + /// the store-wipe half of ClearStore, also used by the UI's Reprovision. + /// Any real launched processes are terminated first. public void WipeApps() { + KillRealProcesses(RemoveLaunched(l => true)); lock (mLock) { mInstalledApps.Clear(); - mLaunchedApps.Clear(); SaveApps(); } AppsChanged?.Invoke();