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.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
{
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
{
+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
/// 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
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
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 `050%` receive / `5095%`
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 `050%` receive /
`5095%` 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
View File
@@ -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)
{
+173 -30
View File
@@ -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)
{
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();
}
}
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();
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;
}
/// <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();
}
}
@@ -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();