Files
TeslaSuite/vPOD/VirtualLauncher.cs
T
CydandClaude Fable 5 91640dcbf2 XP11: whole suite on net40 — Console + vPOD run on XP SP3 through Win11
The Launcher's XP11 port (8730b9b) now extends to everything: one net40
flavor across Console, vPOD, Contract, and SecureConfig (Newtonsoft.Json
everywhere; the net48/System.Text.Json legs and their #if splits are gone
since nothing consumed them).

Console (net40, single TFM like the Launcher):
- The ~31 BinaryFormatter bitmap blobs in the .resx files became raw
  embedded files under assets/icons/ (extracted byte-faithfully via a
  serialization surrogate — the animated square_throbber.gif survives),
  loaded by Properties.Resources.EmbeddedBitmap/EmbeddedIcon. Reason:
  System.Resources.Extensions' DeserializingResourceReader is net461+
  and cannot load on net40. Strings stay in the .resx.
- IReadOnlyList -> IList in AppRegistry (net45+ interface).

vPOD (net40, single TFM):
- Zip extraction now shares the Launcher's MiniZip.cs (linked source), so
  the diff-test install round-trip exercises it against ZipArchive zips.
- RPC args as JTokens; LaunchApps.json persistence via Newtonsoft;
  Thread.VolatileRead instead of Volatile.Read.

Contract/SecureConfig: net40-only; Client/** (PodManagerConnection) now
ships in the one build. The Launcher package gains
TeslaSecureConfiguration.dll as a dependency of the client half.

Tests: the net48 xunit host loads the net40 assemblies (both CLR4), so
the suite exercises exactly what ships — 106/106 green. Also verified
live: net40 console provisioned, managed, and ran a full RP mission
against net40 vPOD (beacon/passphrase/RSA, 53290 RPC, egg load,
Run/Stop Mission).

Version: 4.11.4.3 across Launcher, Console, and vPOD (vPOD joins the
suite version line; was 1.0.0). Ship the dotNetFx40 redistributable in
Launcher/assets for XP-era pods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:01:34 -05:00

614 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
// No System.Text.Json on net40 — persistence goes through Newtonsoft, which
// writes the same JSON shape (public fields, PascalCase; see PodRpcProtocol).
using Newtonsoft.Json;
using Tesla.Net;
namespace VPod;
/// <summary>
/// The state behind vPOD's virtual TeslaLauncher service: the installed-app
/// registry, the (simulated) launched-process table, the volume level, the
/// 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. Launch/Kill simulate PIDs by default; with
/// <see cref="RealLaunch" /> set they start and terminate real processes,
/// mirroring the Agent.
///
/// 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; }
/// <summary>The Agent's autoRestart watchdog for real-launched apps: when a
/// tracked process exits on its own (not via a Kill* command), relaunch it
/// after 2 s — but only for entries whose LaunchData.AutoRestart is set,
/// exactly like the real pod. Toggled from the vPOD window.</summary>
public bool RealAutoRestart { get; set; } = true;
/// <summary>When set, a packaged postinstall.bat is executed at the end of a
/// product install (like the real Agent) before being deleted, instead of
/// being logged and removed unrun. Toggled from the vPOD window; off by
/// default, since it runs package script code on the host machine.</summary>
public bool RunPostInstall { get; set; }
// Bumped to cancel watchdog restarts pending in their 2 s delay — the pod
// "machine" went dark (power off / reboot / reprovision), so nothing may
// relaunch after it.
private int mWatchdogGeneration;
public event Action<string> Log;
public event Action AppsChanged; // installed or launched list changed
public event Action<Guid, OutOfBandProgress> InstallProgressChanged;
public event Action<bool> ShutdownRequested; // restart?
public event Action ReprovisionRequested; // console cleared the store (Reconfigure)
public string DataDirectory { get; }
public string GamesRoot { get; }
public string KeyFilePath => Path.Combine(DataDirectory, "TeslaKeyStore.key");
private string LaunchAppsPath => Path.Combine(DataDirectory, "LaunchApps.json");
public VirtualLauncher()
: this(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "vPOD"))
{
}
public VirtualLauncher(string dataDirectory, string gamesRoot = null)
{
DataDirectory = dataDirectory;
GamesRoot = gamesRoot ?? DefaultGamesRoot;
Directory.CreateDirectory(DataDirectory);
LoadApps();
}
// ---- session key store (launcher's TeslaKeyStore.key format) ----
/// <summary>The provisioned session key, or null when unprovisioned.</summary>
public byte[] LoadKey()
{
try
{
if (!File.Exists(KeyFilePath))
{
return null;
}
byte[] raw = File.ReadAllBytes(KeyFilePath);
if (raw.Length < 2 || raw.Length != raw[0] + 1)
{
return null;
}
byte[] key = new byte[raw[0]];
Buffer.BlockCopy(raw, 1, key, 0, key.Length);
return key;
}
catch
{
return null;
}
}
public void SaveKey(byte[] key)
{
byte[] raw = new byte[key.Length + 1];
raw[0] = (byte)key.Length;
key.CopyTo(raw, 1);
File.WriteAllBytes(KeyFilePath, raw);
}
public void DeleteKey()
{
try { File.Delete(KeyFilePath); } catch { }
}
// ---- ILauncherService state ----
public DateTime Ping(DateTime now)
{
return now;
}
public float VolumeLevel
{
get { lock (mLock) { return mVolumeLevel; } }
set
{
lock (mLock) { mVolumeLevel = value; }
Log?.Invoke($"Volume set to {value:P0}.");
}
}
public LaunchData[] GetInstalledApps()
{
lock (mLock) { return mInstalledApps.ToArray(); }
}
public LaunchPair[] GetLaunchableApps()
{
lock (mLock)
{
LaunchPair[] pairs = new LaunchPair[mInstalledApps.Count];
for (int i = 0; i < mInstalledApps.Count; i++)
{
pairs[i] = mInstalledApps[i].LaunchPair;
}
return pairs;
}
}
public LaunchedAppData[] GetLaunchedApps()
{
lock (mLock) { return mLaunchedApps.ToArray(); }
}
public FullUpdateData FullUpdate()
{
lock (mLock)
{
return new FullUpdateData
{
InstalledApps = mInstalledApps.ToArray(),
LaunchedApps = mLaunchedApps.ToArray(),
VolumeLevel = mVolumeLevel
};
}
}
/// <summary>Registers a launch entry (upsert by LaunchKey) — the per-entry call
/// the console makes after a product install, and the write behind LaunchApps.json.</summary>
public void InstallApp(LaunchData data)
{
lock (mLock)
{
int existing = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == data.LaunchPair.LaunchKey);
if (existing >= 0)
{
mInstalledApps[existing] = data;
}
else
{
mInstalledApps.Add(data);
}
SaveApps();
}
Log?.Invoke($"InstallApp: \"{data.LaunchPair.DisplayName}\" -> {data.ExeFile} {data.Arguments}");
AppsChanged?.Invoke();
}
public void UninstallApp(Guid launchKey)
{
string cleanupDir = null;
string name = null;
lock (mLock)
{
int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == launchKey);
if (index < 0)
{
return;
}
LaunchData app = mInstalledApps[index];
name = app.LaunchPair.DisplayName;
mInstalledApps.RemoveAt(index);
SaveApps();
// Like the real Agent's CleanupDir: remove the product folder, but only
// when it's inside our games root and no other app still uses it.
string dir = app.WorkingDirectory;
if (!string.IsNullOrEmpty(dir)
&& IsUnderGamesRoot(dir)
&& !mInstalledApps.Exists(a => string.Equals(a.WorkingDirectory, dir, StringComparison.OrdinalIgnoreCase)))
{
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
{
if (Directory.Exists(cleanupDir))
{
Directory.Delete(cleanupDir, recursive: true);
Log?.Invoke($"UninstallApp: removed product directory {cleanupDir}");
}
}
catch (Exception ex)
{
Log?.Invoke($"UninstallApp: could not remove {cleanupDir}: {ex.Message}");
}
}
Log?.Invoke($"UninstallApp: \"{name}\" removed.");
AppsChanged?.Invoke();
}
public void RemoveApp(Guid launchKey)
{
lock (mLock)
{
int removed = mInstalledApps.RemoveAll(a => a.LaunchPair.LaunchKey == launchKey);
if (removed == 0)
{
return;
}
SaveApps();
}
KillRealProcesses(RemoveLaunched(l => l.LaunchKey == launchKey));
Log?.Invoke($"RemoveApp: {launchKey} unregistered.");
AppsChanged?.Invoke();
}
public int LaunchApp(Guid launchKey)
{
LaunchData app;
lock (mLock)
{
int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == launchKey);
if (index < 0)
{
Log?.Invoke($"LaunchApp: unknown launch key {launchKey}.");
return 0;
}
app = mInstalledApps[index];
}
if (RealLaunch)
{
return LaunchRealProcess(app);
}
int pid;
lock (mLock)
{
pid = mNextPid++;
mLaunchedApps.Add(new LaunchedAppData { ProcessId = pid, LaunchKey = launchKey });
}
Log?.Invoke($"LaunchApp: \"{app.LaunchPair.DisplayName}\" -> simulated PID {pid} (no real process).");
AppsChanged?.Invoke();
return pid;
}
/// <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)
{
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)
{
mLaunchedApps.Add(new LaunchedAppData { ProcessId = process.Id, LaunchKey = app.LaunchPair.LaunchKey });
mRealProcesses[process.Id] = process;
}
StartWatcher(app, process, process.Id);
Log?.Invoke($"LaunchApp: \"{name}\" -> started {app.ExeFile} (real PID {process.Id}).");
AppsChanged?.Invoke();
return process.Id;
}
/// <summary>The Agent's per-process watcher: waits for exit, and if the process
/// is STILL TRACKED (Kill*/Uninstall remove it from the table first, so a
/// console-ordered kill never restarts), drops it from the running list and —
/// when the watchdog toggle and the entry's AutoRestart both apply — relaunches
/// it after the Agent's 2 s delay.</summary>
private void StartWatcher(LaunchData app, Process process, int pid)
{
string name = app.LaunchPair.DisplayName ?? app.LaunchPair.LaunchKey.ToString();
new Thread(() =>
{
try { process.WaitForExit(); }
catch { /* handle disposed by a Kill* — it is no longer tracked */ }
bool stillTracked;
lock (mLock)
{
stillTracked = mRealProcesses.TryGetValue(pid, out Process tracked) && ReferenceEquals(tracked, process);
if (stillTracked)
{
mRealProcesses.Remove(pid);
mLaunchedApps.RemoveAll(l => l.ProcessId == pid);
process.Dispose();
}
}
if (!stillTracked)
{
return; // killed via the console / power-off; whoever killed it cleaned up
}
AppsChanged?.Invoke();
// Thread.VolatileRead, not Volatile.Read: the latter is net45+.
int generation = Thread.VolatileRead(ref mWatchdogGeneration);
if (!RealAutoRestart || !app.AutoRestart)
{
Log?.Invoke($"\"{name}\" (PID {pid}) exited on its own (no watchdog restart).");
return;
}
Log?.Invoke($"\"{name}\" (PID {pid}) exited on its own — watchdog restarting in 2 s...");
Thread.Sleep(2000);
// Still wanted? The pod may have powered off/reprovisioned (generation),
// the mode or toggle flipped, or the app been uninstalled meanwhile.
if (generation != Thread.VolatileRead(ref mWatchdogGeneration) || !RealLaunch || !RealAutoRestart)
{
return;
}
LaunchData current;
lock (mLock)
{
int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == app.LaunchPair.LaunchKey);
if (index < 0)
{
return; // uninstalled during the delay
}
current = mInstalledApps[index];
}
try
{
LaunchRealProcess(current);
}
catch (Exception ex)
{
Log?.Invoke($"Watchdog restart of \"{name}\" failed: {ex.Message}");
}
})
{ IsBackground = true, Name = $"vPOD-watchdog-{pid}" }.Start();
}
public void KillApp(Guid launchKey, int processId)
{
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)
{
List<LaunchedAppData> victims = RemoveLaunched(l => l.LaunchKey == launchKey);
if (victims.Count > 0)
{
int real = KillRealProcesses(victims);
Log?.Invoke($"KillAllOfType: {victims.Count} process(es) killed ({real} real).");
AppsChanged?.Invoke();
}
}
/// <summary>Kills everything launched. <paramref name="cancelPendingRestarts" />
/// is the pod-power path (off/reboot/exit): it also aborts watchdog restarts
/// waiting out their 2 s delay. The console's KillAllApps RPC leaves them
/// pending, matching the real Agent's race behavior.</summary>
public void KillAllApps(bool cancelPendingRestarts = false)
{
if (cancelPendingRestarts)
{
Interlocked.Increment(ref mWatchdogGeneration);
}
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)
{
for (int i = mLaunchedApps.Count - 1; i >= 0; i--)
{
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;
}
public void Shutdown(bool restart)
{
Log?.Invoke(restart ? "Shutdown(restart) — power-cycling the pod." : "Shutdown — powering the pod off.");
ShutdownRequested?.Invoke(restart);
}
/// <summary>The console's Reconfigure calls this before deleting the pod from the
/// site. Wipe the store AND drop the session key: the console no longer holds it,
/// so re-entering provisioning (beacon mode) is what makes Reconfigure completable
/// without the real pod's reboot-with-DHCP step.</summary>
public void ClearStore()
{
WipeApps();
Log?.Invoke("ClearStore: installed apps wiped; dropping session key to re-enter provisioning.");
ReprovisionRequested?.Invoke();
}
/// <summary>Clears the installed/launched app registries (LaunchApps.json) —
/// the store-wipe half of ClearStore, also used by the UI's Reprovision.
/// Any real launched processes are terminated first, and pending watchdog
/// restarts cancelled.</summary>
public void WipeApps()
{
Interlocked.Increment(ref mWatchdogGeneration);
KillRealProcesses(RemoveLaunched(l => true));
lock (mLock)
{
mInstalledApps.Clear();
SaveApps();
}
AppsChanged?.Invoke();
}
// ---- install progress (InitiateInstallProduct / GetOutOfBandProgress) ----
public Guid InitiateInstallProduct()
{
Guid callId = Guid.NewGuid();
UpdateProgress(callId, 0, "Waiting for file...");
return callId;
}
public OutOfBandProgress GetOutOfBandProgress(Guid callId)
{
lock (mLock)
{
if (mInstallProgress.TryGetValue(callId, out OutOfBandProgress progress))
{
return progress;
}
}
return new OutOfBandProgress { PercentComplete = 0, Status = "Unknown call ID", IsCompleted = true };
}
public void UpdateProgress(Guid callId, int percent, string status, bool isCompleted = false)
{
OutOfBandProgress progress = new OutOfBandProgress
{
PercentComplete = percent,
Status = status,
IsCompleted = isCompleted
};
lock (mLock)
{
mInstallProgress[callId] = progress;
}
InstallProgressChanged?.Invoke(callId, progress);
}
private bool IsUnderGamesRoot(string path)
{
try
{
string full = Path.GetFullPath(path);
string root = Path.GetFullPath(GamesRoot) + Path.DirectorySeparatorChar;
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
// ---- persistence (PodRpc's JSON options round-trip the wire types exactly) ----
private void LoadApps()
{
try
{
if (!File.Exists(LaunchAppsPath))
{
return;
}
LaunchData[] apps = JsonConvert.DeserializeObject<LaunchData[]>(File.ReadAllText(LaunchAppsPath));
if (apps != null)
{
mInstalledApps.AddRange(apps);
}
}
catch
{
// unreadable store: start empty, like a fresh pod
}
}
private void SaveApps()
{
try
{
File.WriteAllText(LaunchAppsPath, JsonConvert.SerializeObject(mInstalledApps.ToArray()));
}
catch (Exception ex)
{
Log?.Invoke("Could not persist LaunchApps.json: " + ex.Message);
}
}
}