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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-10 10:46:55 -05:00
co-authored by Claude Fable 5
parent cb7c655530
commit d30ce8bdbe
6 changed files with 288 additions and 55 deletions
@@ -1,4 +1,5 @@
using System; using System;
using System.Diagnostics;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Net; using System.Net;
@@ -31,7 +32,8 @@ namespace TeslaConsole.DiffTests
public VPodLauncherServerTests() public VPodLauncherServerTests()
{ {
mDataDir = Path.Combine(Path.GetTempPath(), "vpod-test-" + Guid.NewGuid().ToString("N")); 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]; mKey = new byte[32];
new Random(1234).NextBytes(mKey); new Random(1234).NextBytes(mKey);
mPort = GetFreePort(); mPort = GetFreePort();
@@ -199,6 +201,47 @@ namespace TeslaConsole.DiffTests
Assert.Empty(mLauncher.GetInstalledApps()); 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<Exception>(() => mClient.LaunchApp(app.LaunchPair.LaunchKey));
Assert.Contains("executable not found", ex.Message);
Assert.Empty(mClient.GetLaunchedApps());
}
[Fact] [Fact]
public void Dropped_Session_Still_Reports_Open_Until_Probed_Then_Reconnect_Works() public void Dropped_Session_Still_Reports_Open_Until_Probed_Then_Reconnect_Works()
{ {
@@ -226,7 +269,7 @@ namespace TeslaConsole.DiffTests
var app = SampleApp("Persistent App"); var app = SampleApp("Persistent App");
mClient.InstallApp(app); mClient.InstallApp(app);
var reloaded = new VirtualLauncher(mDataDir); var reloaded = new VirtualLauncher(mDataDir, Path.Combine(mDataDir, "Games"));
var installed = reloaded.GetInstalledApps(); var installed = reloaded.GetInstalledApps();
Assert.Single(installed); Assert.Single(installed);
Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey); Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey);
@@ -41,12 +41,23 @@ namespace TeslaConsole.DiffTests
// The console side: listens on UDP 53291 for RQST beacons; the // The console side: listens on UDP 53291 for RQST beacons; the
// delegate fires on vPOD's first beacon (sent at Start). // delegate fires on vPOD's first beacon (sent at Start).
var consoleServer = new PodConfigurationServer(53291, 53292, (m, id) => PodConfigurationServer consoleServer;
try
{ {
beaconMac = m; consoleServer = new PodConfigurationServer(53291, 53292, (m, id) =>
beaconRequestId = id; {
beaconSeen.Set(); 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 try
{ {
+2 -1
View File
@@ -299,7 +299,8 @@ internal sealed class LauncherRpcServer
} }
/// <summary>Receives the out-of-band product zip and extracts it into the /// <summary>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).</summary> /// 0-50% receive, 50-95% extract, 99% "Complete" (IsCompleted).</summary>
private void ReceiveInstallFile(Stream stream, Guid callId) private void ReceiveInstallFile(Stream stream, Guid callId)
{ {
+24 -12
View File
@@ -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 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 mode — pair it with deleting the pod in Manage Site (the console's
**Reconfigure…** does both ends automatically: its `ClearStore` makes vPOD do **Reconfigure…** does both ends automatically: its `ClearStore` makes vPOD do
the same wipe and beacon again). Extracted packages under `Games\` are left on the same wipe and beacon again). Extracted packages under `C:\Games` are left
disk either way. on disk either way.
### Product deployments ### Product deployments
Right-click the pod row → **Install Product ▸** works exactly as against a real 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 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 connection while polling progress on the first; vPOD extracts it into the real
virtual games root and reports the launcher's usual `050%` receive / `5095%` `C:\Games` — the launcher's games root, so products land where their catalog
extract / `99% Complete` progression. Then: launch entries point — and reports the launcher's usual `050%` receive /
`5095%` extract / `99% Complete` progression. Then:
- Installed apps land in the column's list (and in `GetInstalledApps`, so the - Installed apps land in the column's list (and in `GetInstalledApps`, so the
Uninstall menu populates). Registrations persist in Uninstall menu populates). Registrations persist in
`%LocalAppData%\vPOD\LaunchApps.json`. `%LocalAppData%\vPOD\LaunchApps.json`.
- Packages extract under `%LocalAppData%\vPOD\Games\` (the virtual `C:\Games`). - Packages extract into `C:\Games` (created on first install if missing; if an
A packaged `postinstall.bat` is logged and removed but **never executed**. admin-owned `C:\Games` isn't writable by your account, the install reports
- **Launch/Kill** from the squad panel are simulated (fake PIDs, no real Failed — fix the folder's ACL or run vPOD elevated). Uninstalling a product
process); Volume round-trips; **Restart/Shutdown** power-cycle the virtual removes its `C:\Games\<product>` folder, like the real launcher. A packaged
pod (dark for a few seconds on restart). `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 ### 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` Not emulated: the console's remote Windows-service control (`ServiceController`
over SCM/SMB, used by some SitePanel service start/stop paths — dormant against over SCM/SMB, used by some SitePanel service start/stop paths — dormant against
real pods too, since it queries service name `TeslaLauncherService` while the real pods too, since it queries service name `TeslaLauncherService` while the
launcher registers as `Tesla Application Launcher`), and really launching launcher registers as `Tesla Application Launcher`), and the Agent's
deployed exes (a second game client would fight vPOD for its own ports). `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 An end-to-end loopback test of this server against the console's real
`PodManagerConnection` client lives in the differential suite: `PodManagerConnection` client lives in the differential suite:
+28 -5
View File
@@ -55,6 +55,7 @@ internal sealed class VPodForm : Form
private Label mInstallStatusLabel; private Label mInstallStatusLabel;
private ProgressBar mInstallProgressBar; private ProgressBar mInstallProgressBar;
private Button mReprovisionButton; private Button mReprovisionButton;
private CheckBox mRealLaunchCheckbox;
private ListView mAppsView; private ListView mAppsView;
private bool mPoweredOn; private bool mPoweredOn;
@@ -149,7 +150,7 @@ internal sealed class VPodForm : Form
GroupBox powerGroup = new GroupBox GroupBox powerGroup = new GroupBox
{ {
Text = "Pod Power", Text = "Pod Power",
Location = new Point(400, 20), Location = new Point(740, 20),
Size = new Size(150, 130) Size = new Size(150, 130)
}; };
mPowerButton = new Button { Text = "Power On", Location = new Point(12, 28), Size = new Size(126, 30) }; 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 GroupBox gameGroup = new GroupBox
{ {
Text = "Mimicking Game", Text = "Mimicking Game",
Location = new Point(560, 20), Location = new Point(400, 20),
Size = new Size(330, 130) Size = new Size(330, 130)
}; };
mRedPlanetRadio = new RadioButton mRedPlanetRadio = new RadioButton
@@ -280,7 +281,7 @@ internal sealed class VPodForm : Form
Padding = new Padding(8) 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 mProvisionStatusLabel = new Label
{ {
@@ -351,6 +352,24 @@ internal sealed class VPodForm : Form
}; };
mReprovisionButton.Click += ReprovisionClicked; 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(mProvisionStatusLabel);
siteTop.Controls.Add(mPassphrasePanel); siteTop.Controls.Add(mPassphrasePanel);
siteTop.Controls.Add(mNetConfigLabel); siteTop.Controls.Add(mNetConfigLabel);
@@ -358,6 +377,7 @@ internal sealed class VPodForm : Form
siteTop.Controls.Add(mInstallStatusLabel); siteTop.Controls.Add(mInstallStatusLabel);
siteTop.Controls.Add(mInstallProgressBar); siteTop.Controls.Add(mInstallProgressBar);
siteTop.Controls.Add(mReprovisionButton); siteTop.Controls.Add(mReprovisionButton);
siteTop.Controls.Add(mRealLaunchCheckbox);
mAppsView = new ListView mAppsView = new ListView
{ {
@@ -396,6 +416,7 @@ internal sealed class VPodForm : Form
{ {
mServer.Stop(); mServer.Stop();
StopManagement(); StopManagement();
mLauncher.KillAllApps(); // don't orphan real launched processes when the tool exits
} }
/// <summary> /// <summary>
@@ -422,6 +443,7 @@ internal sealed class VPodForm : Form
mRebootTimer.Stop(); mRebootTimer.Stop();
StopGame(); StopGame();
StopManagement(); StopManagement();
mLauncher.KillAllApps(); // the machine is "off": launched apps (real or simulated) die with it
SetPoweredState(on: false); SetPoweredState(on: false);
} }
@@ -634,8 +656,8 @@ internal sealed class VPodForm : Form
/// <summary>Drops the session key, clears the installed-apps store and /// <summary>Drops the session key, clears the installed-apps store and
/// re-enters provisioning — from the UI button, or when the console's /// re-enters provisioning — from the UI button, or when the console's
/// Reconfigure clears our store. (Fresh-pod state; extracted Games\ files /// Reconfigure clears our store. (Fresh-pod state; extracted C:\Games
/// are left on disk.)</summary> /// files are left on disk.)</summary>
private void DropKeyAndReprovision() private void DropKeyAndReprovision()
{ {
mLauncher.WipeApps(); mLauncher.WipeApps();
@@ -847,6 +869,7 @@ internal sealed class VPodForm : Form
mRebootTimer.Stop(); mRebootTimer.Stop();
StopGame(); StopGame();
StopManagement(); StopManagement();
mLauncher.KillAllApps(); // launched apps die with the rebooting/halting machine
SetPoweredState(on: false); SetPoweredState(on: false);
if (restart) if (restart)
{ {
+173 -30
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using Tesla.Net; using Tesla.Net;
@@ -12,22 +13,37 @@ namespace VPod;
/// install-progress map and the session-key store. This is the pod-side model /// install-progress map and the session-key store. This is the pod-side model
/// that <see cref="LauncherRpcServer" /> dispatches the console's /// that <see cref="LauncherRpcServer" /> dispatches the console's
/// ILauncherService calls onto — the vPOD equivalent of the real launcher'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
/// <see cref="RealLaunch" /> set they start and terminate real processes,
/// mirroring the Agent.
/// ///
/// Persisted under %LocalAppData%\vPOD: /// Persisted state:
/// TeslaKeyStore.key session key, launcher format: [1-byte len][key bytes] /// %LocalAppData%\vPOD\TeslaKeyStore.key session key, launcher format: [1-byte len][key]
/// LaunchApps.json installed apps (the wire LaunchData list, PodRpc JSON) /// %LocalAppData%\vPOD\LaunchApps.json installed apps (the wire LaunchData list, PodRpc JSON)
/// Games\ where InstallProduct zips are extracted (virtual C:\Games) /// 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)
/// </summary> /// </summary>
internal sealed class VirtualLauncher internal sealed class VirtualLauncher
{ {
/// <summary>The launcher's GAMES_DIR. Tests override via the ctor for isolation.</summary>
public const string DefaultGamesRoot = @"C:\Games";
private readonly object mLock = new object(); private readonly object mLock = new object();
private readonly List<LaunchData> mInstalledApps = new List<LaunchData>(); private readonly List<LaunchData> mInstalledApps = new List<LaunchData>();
private readonly List<LaunchedAppData> mLaunchedApps = new List<LaunchedAppData>(); private readonly List<LaunchedAppData> mLaunchedApps = new List<LaunchedAppData>();
private readonly Dictionary<int, Process> mRealProcesses = new Dictionary<int, Process>(); // pid -> live process, RealLaunch mode
private readonly Dictionary<Guid, OutOfBandProgress> mInstallProgress = new Dictionary<Guid, OutOfBandProgress>(); private readonly Dictionary<Guid, OutOfBandProgress> mInstallProgress = new Dictionary<Guid, OutOfBandProgress>();
private float mVolumeLevel = 1.0f; private float mVolumeLevel = 1.0f;
private int mNextPid = 4000; private int mNextPid = 4000;
/// <summary>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.</summary>
public bool RealLaunch { get; set; }
public event Action<string> Log; public event Action<string> Log;
public event Action AppsChanged; // installed or launched list changed public event Action AppsChanged; // installed or launched list changed
public event Action<Guid, OutOfBandProgress> InstallProgressChanged; public event Action<Guid, OutOfBandProgress> InstallProgressChanged;
@@ -35,7 +51,7 @@ internal sealed class VirtualLauncher
public event Action ReprovisionRequested; // console cleared the store (Reconfigure) public event Action ReprovisionRequested; // console cleared the store (Reconfigure)
public string DataDirectory { get; } public string DataDirectory { get; }
public string GamesRoot => Path.Combine(DataDirectory, "Games"); public string GamesRoot { get; }
public string KeyFilePath => Path.Combine(DataDirectory, "TeslaKeyStore.key"); public string KeyFilePath => Path.Combine(DataDirectory, "TeslaKeyStore.key");
private string LaunchAppsPath => Path.Combine(DataDirectory, "LaunchApps.json"); 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; DataDirectory = dataDirectory;
GamesRoot = gamesRoot ?? DefaultGamesRoot;
Directory.CreateDirectory(DataDirectory); Directory.CreateDirectory(DataDirectory);
LoadApps(); LoadApps();
} }
@@ -127,11 +144,13 @@ internal sealed class VirtualLauncher
public LaunchedAppData[] GetLaunchedApps() public LaunchedAppData[] GetLaunchedApps()
{ {
PruneExitedProcesses();
lock (mLock) { return mLaunchedApps.ToArray(); } lock (mLock) { return mLaunchedApps.ToArray(); }
} }
public FullUpdateData FullUpdate() public FullUpdateData FullUpdate()
{ {
PruneExitedProcesses();
lock (mLock) lock (mLock)
{ {
return new FullUpdateData return new FullUpdateData
@@ -178,10 +197,9 @@ internal sealed class VirtualLauncher
LaunchData app = mInstalledApps[index]; LaunchData app = mInstalledApps[index];
name = app.LaunchPair.DisplayName; name = app.LaunchPair.DisplayName;
mInstalledApps.RemoveAt(index); mInstalledApps.RemoveAt(index);
mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey);
SaveApps(); SaveApps();
// Like the real Agent's CleanupDir: remove the product folder, but only // 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; string dir = app.WorkingDirectory;
if (!string.IsNullOrEmpty(dir) if (!string.IsNullOrEmpty(dir)
&& IsUnderGamesRoot(dir) && IsUnderGamesRoot(dir)
@@ -190,6 +208,9 @@ internal sealed class VirtualLauncher
cleanupDir = dir; 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) if (cleanupDir != null)
{ {
try try
@@ -218,9 +239,9 @@ internal sealed class VirtualLauncher
{ {
return; return;
} }
mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey);
SaveApps(); SaveApps();
} }
KillRealProcesses(RemoveLaunched(l => l.LaunchKey == launchKey));
Log?.Invoke($"RemoveApp: {launchKey} unregistered."); Log?.Invoke($"RemoveApp: {launchKey} unregistered.");
AppsChanged?.Invoke(); AppsChanged?.Invoke();
} }
@@ -228,7 +249,6 @@ internal sealed class VirtualLauncher
public int LaunchApp(Guid launchKey) public int LaunchApp(Guid launchKey)
{ {
LaunchData app; LaunchData app;
int pid;
lock (mLock) lock (mLock)
{ {
int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == launchKey); int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == launchKey);
@@ -238,6 +258,14 @@ internal sealed class VirtualLauncher
return 0; return 0;
} }
app = mInstalledApps[index]; app = mInstalledApps[index];
}
if (RealLaunch)
{
return LaunchRealProcess(app);
}
int pid;
lock (mLock)
{
pid = mNextPid++; pid = mNextPid++;
mLaunchedApps.Add(new LaunchedAppData { ProcessId = pid, LaunchKey = launchKey }); mLaunchedApps.Add(new LaunchedAppData { ProcessId = pid, LaunchKey = launchKey });
} }
@@ -246,45 +274,159 @@ internal sealed class VirtualLauncher
return pid; return pid;
} }
public void KillApp(Guid launchKey, int processId) /// <summary>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).</summary>
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) 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<LaunchedAppData> 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(); AppsChanged?.Invoke();
} }
} }
public void KillAllOfType(Guid launchKey) public void KillAllOfType(Guid launchKey)
{ {
int removed; List<LaunchedAppData> victims = RemoveLaunched(l => l.LaunchKey == launchKey);
lock (mLock) if (victims.Count > 0)
{ {
removed = mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey); int real = KillRealProcesses(victims);
} Log?.Invoke($"KillAllOfType: {victims.Count} process(es) killed ({real} real).");
if (removed > 0)
{
Log?.Invoke($"KillAllOfType: {removed} simulated process(es) killed.");
AppsChanged?.Invoke(); AppsChanged?.Invoke();
} }
} }
public void KillAllApps() public void KillAllApps()
{ {
int removed; List<LaunchedAppData> victims = RemoveLaunched(l => true);
if (victims.Count > 0)
{
int real = KillRealProcesses(victims);
Log?.Invoke($"KillAllApps: {victims.Count} process(es) killed ({real} real).");
AppsChanged?.Invoke();
}
}
/// <summary>Removes matching entries from the launched table and returns them,
/// so real processes can be killed outside the lock.</summary>
private List<LaunchedAppData> RemoveLaunched(Predicate<LaunchedAppData> match)
{
List<LaunchedAppData> removed = new List<LaunchedAppData>();
lock (mLock) lock (mLock)
{ {
removed = mLaunchedApps.Count; for (int i = mLaunchedApps.Count - 1; i >= 0; i--)
mLaunchedApps.Clear(); {
if (match(mLaunchedApps[i]))
{
removed.Add(mLaunchedApps[i]);
mLaunchedApps.RemoveAt(i);
}
}
} }
if (removed > 0) return removed;
}
/// <summary>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.</summary>
private int KillRealProcesses(List<LaunchedAppData> 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;
}
/// <summary>Drops launched entries whose real process has exited on its own
/// (crash or normal exit), so GetLaunchedApps/FullUpdate reflect liveness.</summary>
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(); AppsChanged?.Invoke();
} }
} }
@@ -307,13 +449,14 @@ internal sealed class VirtualLauncher
} }
/// <summary>Clears the installed/launched app registries (LaunchApps.json) — /// <summary>Clears the installed/launched app registries (LaunchApps.json) —
/// the store-wipe half of ClearStore, also used by the UI's Reprovision.</summary> /// the store-wipe half of ClearStore, also used by the UI's Reprovision.
/// Any real launched processes are terminated first.</summary>
public void WipeApps() public void WipeApps()
{ {
KillRealProcesses(RemoveLaunched(l => true));
lock (mLock) lock (mLock)
{ {
mInstalledApps.Clear(); mInstalledApps.Clear();
mLaunchedApps.Clear();
SaveApps(); SaveApps();
} }
AppsChanged?.Invoke(); AppsChanged?.Invoke();