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:
@@ -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<Exception>(() => 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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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
|
||||
{
|
||||
|
||||
@@ -299,7 +299,8 @@ internal sealed class LauncherRpcServer
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private void ReceiveInstallFile(Stream stream, Guid callId)
|
||||
{
|
||||
|
||||
+24
-12
@@ -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\<product>` 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:
|
||||
|
||||
+28
-5
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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
|
||||
|
||||
/// <summary>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.)</summary>
|
||||
/// Reconfigure clears our store. (Fresh-pod state; extracted C:\Games
|
||||
/// files are left on disk.)</summary>
|
||||
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)
|
||||
{
|
||||
|
||||
+174
-31
@@ -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 <see cref="LauncherRpcServer" /> 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
|
||||
/// <see cref="RealLaunch" /> 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)
|
||||
/// </summary>
|
||||
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 List<LaunchData> mInstalledApps = new List<LaunchData>();
|
||||
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 float mVolumeLevel = 1.0f;
|
||||
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 AppsChanged; // installed or launched list changed
|
||||
public event Action<Guid, OutOfBandProgress> 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)
|
||||
/// <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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
Log?.Invoke($"KillApp: simulated PID {processId} killed.");
|
||||
List<LaunchedAppData> victims = RemoveLaunched(l => l.LaunchKey == launchKey && l.ProcessId == processId);
|
||||
if (victims.Count > 0)
|
||||
{
|
||||
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<LaunchedAppData> 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<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)
|
||||
{
|
||||
removed = mLaunchedApps.Count;
|
||||
mLaunchedApps.Clear();
|
||||
}
|
||||
if (removed > 0)
|
||||
for (int i = mLaunchedApps.Count - 1; i >= 0; i--)
|
||||
{
|
||||
Log?.Invoke($"KillAllApps: {removed} simulated process(es) killed.");
|
||||
if (match(mLaunchedApps[i]))
|
||||
{
|
||||
removed.Add(mLaunchedApps[i]);
|
||||
mLaunchedApps.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -307,13 +449,14 @@ internal sealed class VirtualLauncher
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
KillRealProcesses(RemoveLaunched(l => true));
|
||||
lock (mLock)
|
||||
{
|
||||
mInstalledApps.Clear();
|
||||
mLaunchedApps.Clear();
|
||||
SaveApps();
|
||||
}
|
||||
AppsChanged?.Invoke();
|
||||
|
||||
Reference in New Issue
Block a user