diff --git a/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj b/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj
index b32b04a..ee40fc5 100644
--- a/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj
+++ b/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj
@@ -29,6 +29,8 @@
+
+
@@ -53,6 +55,9 @@
+
+
diff --git a/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs b/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs
new file mode 100644
index 0000000..db42df7
--- /dev/null
+++ b/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs
@@ -0,0 +1,268 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using Tesla.Net;
+using VPod;
+using Xunit;
+
+namespace TeslaConsole.DiffTests
+{
+ ///
+ /// End-to-end loopback exercise of vPOD's virtual launcher: the REAL
+ /// console-side client (PodManagerConnection) against vPOD's LauncherRpcServer
+ /// on an ephemeral port. Covers the OFB/CONF handshake, the ILauncherService
+ /// dispatch surface, and the out-of-band InstallProduct transfer — including
+ /// the 99%-complete convention the console's install retry loop depends on.
+ /// This validates both ends: the client the console ships and the server vPOD
+ /// uses to stand in for a pod's TeslaLauncher service.
+ ///
+ public class VPodLauncherServerTests : IDisposable
+ {
+ private readonly string mDataDir;
+ private readonly VirtualLauncher mLauncher;
+ private readonly LauncherRpcServer mServer;
+ private readonly PodManagerConnection mClient;
+ private readonly byte[] mKey;
+ private readonly int mPort;
+
+ public VPodLauncherServerTests()
+ {
+ mDataDir = Path.Combine(Path.GetTempPath(), "vpod-test-" + Guid.NewGuid().ToString("N"));
+ mLauncher = new VirtualLauncher(mDataDir);
+ mKey = new byte[32];
+ new Random(1234).NextBytes(mKey);
+ mPort = GetFreePort();
+ mServer = new LauncherRpcServer(mLauncher, mPort);
+ mServer.Start(mKey);
+ mClient = new PodManagerConnection();
+ mClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), mKey);
+ }
+
+ public void Dispose()
+ {
+ try { mClient.Close(); } catch { }
+ mServer.Stop();
+ try { Directory.Delete(mDataDir, recursive: true); } catch { }
+ }
+
+ [Fact]
+ public void Handshake_And_Ping_RoundTrip()
+ {
+ Assert.True(mClient.IsOpen);
+ var now = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc);
+ Assert.Equal(now, mClient.Ping(now));
+ }
+
+ [Fact]
+ public void Handshake_With_Wrong_Key_Is_Rejected()
+ {
+ var wrongKey = new byte[32];
+ new Random(9999).NextBytes(wrongKey);
+ using (var badClient = new PodManagerConnection())
+ {
+ Assert.Throws(
+ () => badClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), wrongKey));
+ }
+ }
+
+ [Fact]
+ public void InstallApp_Appears_In_GetInstalledApps_And_FullUpdate()
+ {
+ var app = SampleApp("Red Planet 4.11");
+ mClient.InstallApp(app);
+
+ var installed = mClient.GetInstalledApps();
+ Assert.Single(installed);
+ Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey);
+ Assert.Equal(app.ExeFile, installed[0].ExeFile);
+ Assert.Equal(app.Arguments, installed[0].Arguments);
+ Assert.True(installed[0].AutoRestart);
+
+ var launchable = mClient.GetLaunchableApps();
+ Assert.Single(launchable);
+ Assert.Equal("Red Planet 4.11", launchable[0].DisplayName);
+
+ var full = mClient.FullUpdate();
+ Assert.Single(full.InstalledApps);
+ Assert.Empty(full.LaunchedApps);
+ }
+
+ [Fact]
+ public void LaunchApp_Simulates_Pids_And_Kill_Removes_Them()
+ {
+ var app = SampleApp("BattleTech 4.11");
+ mClient.InstallApp(app);
+
+ int pid = mClient.LaunchApp(app.LaunchPair.LaunchKey);
+ Assert.True(pid > 0);
+ var launched = mClient.GetLaunchedApps();
+ Assert.Single(launched);
+ Assert.Equal(pid, launched[0].ProcessId);
+ Assert.Equal(app.LaunchPair.LaunchKey, launched[0].LaunchKey);
+
+ mClient.KillApp(app.LaunchPair.LaunchKey, pid);
+ Assert.Empty(mClient.GetLaunchedApps());
+
+ mClient.LaunchApp(app.LaunchPair.LaunchKey);
+ mClient.LaunchApp(app.LaunchPair.LaunchKey);
+ mClient.KillAllApps();
+ Assert.Empty(mClient.GetLaunchedApps());
+ }
+
+ [Fact]
+ public void VolumeLevel_RoundTrips()
+ {
+ mClient.VolumeLevel = 0.25f;
+ Assert.Equal(0.25f, mClient.VolumeLevel);
+ Assert.Equal(0.25f, mClient.FullUpdate().VolumeLevel);
+ }
+
+ [Fact]
+ public void InstallProduct_Streams_Extracts_And_Completes_At_99()
+ {
+ string zipPath = MakeTestZip("TestGame", "readme.txt", "hello vpod");
+
+ Guid callId = mClient.InstallProduct(zipPath);
+ Assert.NotEqual(Guid.Empty, callId);
+
+ var progress = PollUntilCompleted(callId);
+ // 99, not 100: the console's InstallProductWorker only breaks its
+ // 3-attempt retry loop on exactly 99.
+ Assert.Equal(99, progress.PercentComplete);
+ Assert.Equal("Complete", progress.Status);
+
+ string extracted = Path.Combine(mDataDir, "Games", "TestGame", "readme.txt");
+ Assert.True(File.Exists(extracted), "expected extracted file at " + extracted);
+ Assert.Equal("hello vpod", File.ReadAllText(extracted));
+
+ // The main connection survived the concurrent out-of-band transfer.
+ Assert.True(mClient.IsOpen);
+ var now = DateTime.UtcNow;
+ mClient.Ping(now);
+ }
+
+ [Fact]
+ public void UninstallApp_Removes_Registration_And_Product_Directory()
+ {
+ string zipPath = MakeTestZip("TestGame", "game.exe", "not really an exe");
+ Guid callId = mClient.InstallProduct(zipPath);
+ PollUntilCompleted(callId);
+
+ string productDir = Path.Combine(mDataDir, "Games", "TestGame");
+ var app = new LaunchData
+ {
+ LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Test Game" },
+ WorkingDirectory = productDir,
+ ExeFile = Path.Combine(productDir, "game.exe"),
+ Arguments = "",
+ AutoRestart = false
+ };
+ mClient.InstallApp(app);
+ Assert.Single(mClient.GetInstalledApps());
+ Assert.True(Directory.Exists(productDir));
+
+ mClient.UninstallApp(app.LaunchPair.LaunchKey);
+ Assert.Empty(mClient.GetInstalledApps());
+ Assert.False(Directory.Exists(productDir), "product directory should be cleaned up");
+ }
+
+ [Fact]
+ public void Shutdown_Raises_ShutdownRequested()
+ {
+ bool? restartArg = null;
+ using (var signalled = new ManualResetEventSlim())
+ {
+ mLauncher.ShutdownRequested += restart =>
+ {
+ restartArg = restart;
+ signalled.Set();
+ };
+ mClient.Shutdown(doRestart: true);
+ Assert.True(signalled.Wait(TimeSpan.FromSeconds(5)), "ShutdownRequested was not raised");
+ Assert.True(restartArg);
+ }
+ }
+
+ [Fact]
+ public void ClearStore_Wipes_Apps_And_Requests_Reprovisioning()
+ {
+ mClient.InstallApp(SampleApp("Doomed App"));
+ using (var signalled = new ManualResetEventSlim())
+ {
+ mLauncher.ReprovisionRequested += signalled.Set;
+ mClient.ClearStore(); // one-way: no response frame
+ Assert.True(signalled.Wait(TimeSpan.FromSeconds(5)), "ReprovisionRequested was not raised");
+ }
+ Assert.Empty(mLauncher.GetInstalledApps());
+ }
+
+ [Fact]
+ public void Installed_Apps_Persist_Across_Launcher_Restart()
+ {
+ var app = SampleApp("Persistent App");
+ mClient.InstallApp(app);
+
+ var reloaded = new VirtualLauncher(mDataDir);
+ var installed = reloaded.GetInstalledApps();
+ Assert.Single(installed);
+ Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey);
+ Assert.Equal("Persistent App", installed[0].LaunchPair.DisplayName);
+ }
+
+ // ---- helpers ----
+
+ private static LaunchData SampleApp(string name)
+ {
+ return new LaunchData
+ {
+ LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = name },
+ WorkingDirectory = @"C:\Games\Sample",
+ ExeFile = @"C:\Games\Sample\game.exe",
+ Arguments = "-net 1501 -res 1920 1080",
+ AutoRestart = true
+ };
+ }
+
+ private string MakeTestZip(string folder, string fileName, string content)
+ {
+ string zipPath = Path.Combine(mDataDir, "product-" + Guid.NewGuid().ToString("N") + ".zip");
+ using (var fs = File.Create(zipPath))
+ using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
+ {
+ var entry = zip.CreateEntry(folder + "/" + fileName);
+ using (var writer = new StreamWriter(entry.Open()))
+ {
+ writer.Write(content);
+ }
+ }
+ return zipPath;
+ }
+
+ private OutOfBandProgress PollUntilCompleted(Guid callId)
+ {
+ var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
+ while (DateTime.UtcNow < deadline)
+ {
+ var progress = mClient.GetOutOfBandProgress(callId);
+ if (progress.IsCompleted)
+ {
+ return progress;
+ }
+ Thread.Sleep(50);
+ }
+ throw new TimeoutException("Install did not complete in time.");
+ }
+
+ private static int GetFreePort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+ }
+}
diff --git a/Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs b/Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs
new file mode 100644
index 0000000..799b119
--- /dev/null
+++ b/Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Net;
+using System.Threading;
+using Tesla;
+using VPod;
+using Xunit;
+
+namespace TeslaConsole.DiffTests
+{
+ ///
+ /// End-to-end loopback exercise of vPOD's pod-side SecureConfig provisioning
+ /// against the REAL console-side implementation (Tesla.PodConfigurationServer,
+ /// the exact code the console runs behind Manage Site's "Configure" button):
+ /// RQST beacon reception, the AES "RPLY" config broadcast, and the OFB+RSA
+ /// session-key exchange must all interoperate.
+ ///
+ /// Uses the protocol's fixed ports (UDP 53291/53292, TCP 53292) and real UDP
+ /// broadcasts on loopback — do not run while a console or another vPOD is
+ /// provisioning on this machine.
+ ///
+ public class VPodProvisioningTests
+ {
+ [Fact]
+ public void Console_Provisions_VPod_And_Both_Hold_The_Same_Session_Key()
+ {
+ byte[] mac = PodProvisioning.MacForHost(7);
+ var provisioning = new PodProvisioning(mac);
+
+ byte[] beaconMac = null;
+ string beaconRequestId = null;
+ using (var beaconSeen = new ManualResetEventSlim())
+ using (var podProvisioned = new ManualResetEventSlim())
+ {
+ byte[] podKey = null;
+ provisioning.ConfigReceived += _ => { };
+ provisioning.Provisioned += key =>
+ {
+ podKey = key;
+ podProvisioned.Set();
+ };
+
+ // 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) =>
+ {
+ beaconMac = m;
+ beaconRequestId = id;
+ beaconSeen.Set();
+ });
+
+ try
+ {
+ provisioning.Start();
+ Assert.True(beaconSeen.Wait(TimeSpan.FromSeconds(15)),
+ "console never received vPOD's RQST beacon");
+ Assert.Equal(mac, beaconMac);
+ Assert.Equal(provisioning.RequestId, beaconRequestId);
+
+ // The console side of Configure: broadcast the AES-encrypted
+ // network config and run the RSA key exchange against the pod.
+ byte[] consoleKey = consoleServer.SendEncryptionKey(
+ provisioning.Passphrase,
+ IPAddress.Loopback,
+ IPAddress.Parse("255.255.255.0"),
+ IPAddress.Any,
+ IPAddress.Any,
+ "vpod-test",
+ TimeSpan.FromSeconds(30));
+
+ Assert.True(podProvisioned.Wait(TimeSpan.FromSeconds(15)),
+ "vPOD never completed provisioning");
+ Assert.NotNull(consoleKey);
+ Assert.Equal(32, consoleKey.Length);
+ Assert.Equal(consoleKey, podKey);
+ }
+ finally
+ {
+ provisioning.Stop();
+ }
+ }
+ }
+ }
+}
diff --git a/Console/vPOD/LauncherRpcServer.cs b/Console/vPOD/LauncherRpcServer.cs
new file mode 100644
index 0000000..362b7e6
--- /dev/null
+++ b/Console/vPOD/LauncherRpcServer.cs
@@ -0,0 +1,402 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Net;
+using System.Net.Sockets;
+using System.Text.Json;
+using System.Threading;
+using Tesla;
+using Tesla.Net;
+
+namespace VPod;
+
+///
+/// vPOD's stand-in for the pod's TeslaLauncher service: the OFB-encrypted,
+/// framed-JSON ILauncherService RPC server on TCP 53290 that the console's
+/// Site Management / SitePanel talk to (client: PodManagerConnection).
+/// Mirrors Launcher/TeslaLauncherService.HandleConsoleClient:
+///
+/// - OFB/CONF handshake on the provisioned 32-byte session key
+/// (NegotiateCryptoStreams is symmetric, so the shared implementation
+/// serves the pod side too).
+/// - Loop: PodRpc.ReadRequest -> dispatch by method name -> WriteResponse.
+/// - After answering InitiateInstallProduct, the same connection carries the
+/// product zip out-of-band ([8-byte Int64 size][raw bytes]) and then closes;
+/// the console polls GetOutOfBandProgress on its main connection meanwhile,
+/// so multiple concurrent client connections are required.
+/// - Install completion reports 99% (not 100) — the console's
+/// InstallProductWorker breaks its retry loop only on 99.
+///
+/// All state lives in ; unlike the real service,
+/// postinstall.bat from a package is logged but never executed.
+///
+internal sealed class LauncherRpcServer
+{
+ public const int ManagePort = 53290;
+
+ private readonly VirtualLauncher mLauncher;
+ private readonly int mPort;
+ private byte[] mSessionKey;
+ private TcpListener mListener;
+ private Thread mAcceptThread;
+ private volatile bool mRunning;
+
+ private readonly object mClientsLock = new object();
+ private readonly List mClients = new List();
+
+ public event Action Log;
+ public event Action ConnectionsChanged; // number of active console sessions
+
+ public bool IsListening => mRunning;
+
+ public LauncherRpcServer(VirtualLauncher launcher, int port = ManagePort)
+ {
+ mLauncher = launcher;
+ mPort = port;
+ }
+
+ /// Starts listening with the given provisioned session key. Throws if
+ /// the port cannot be bound (e.g. a real TeslaLauncher on the same machine).
+ public void Start(byte[] sessionKey)
+ {
+ if (mRunning)
+ {
+ return;
+ }
+ mSessionKey = sessionKey;
+ mListener = new TcpListener(IPAddress.Any, mPort);
+ mListener.Start();
+ mRunning = true;
+ mAcceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "vPOD-launcher-accept" };
+ mAcceptThread.Start();
+ Log?.Invoke($"Launcher RPC listening on TCP {mPort}.");
+ }
+
+ public void Stop()
+ {
+ if (!mRunning)
+ {
+ return;
+ }
+ mRunning = false;
+ try { mListener?.Stop(); } catch { }
+ lock (mClientsLock)
+ {
+ foreach (TcpClient client in mClients)
+ {
+ try { client.Close(); } catch { }
+ }
+ mClients.Clear();
+ }
+ ConnectionsChanged?.Invoke(0);
+ Log?.Invoke("Launcher RPC stopped.");
+ }
+
+ private void AcceptLoop()
+ {
+ while (mRunning)
+ {
+ TcpClient client;
+ try
+ {
+ client = mListener.AcceptTcpClient();
+ }
+ catch
+ {
+ break; // listener stopped
+ }
+ // Unlike the Munga side, the console legitimately opens several
+ // concurrent connections (main session + out-of-band installs).
+ Thread worker = new Thread(() => HandleClient(client)) { IsBackground = true, Name = "vPOD-launcher-session" };
+ worker.Start();
+ }
+ }
+
+ private void HandleClient(TcpClient client)
+ {
+ string remote;
+ try
+ {
+ remote = client.Client.RemoteEndPoint?.ToString() ?? "?";
+ }
+ catch
+ {
+ remote = "?";
+ }
+ lock (mClientsLock)
+ {
+ mClients.Add(client);
+ ConnectionsChanged?.Invoke(mClients.Count);
+ }
+ try
+ {
+ using (client)
+ {
+ NetworkStream netStream = client.GetStream();
+ // Same session timeouts as the real service: an idle console
+ // connection is dropped after 30 s and the console reconnects.
+ netStream.WriteTimeout = 10000;
+ netStream.ReadTimeout = 30000;
+
+ if (!PodConfigurationServer.NegotiateCryptoStreams(netStream, mSessionKey, out Stream outStream, out Stream inStream))
+ {
+ Log?.Invoke($"{remote}: CONF mismatch — session key mismatch, dropping connection.");
+ return;
+ }
+ Log?.Invoke($"Console session started from {remote}.");
+ SessionLoop(remote, inStream, outStream);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log?.Invoke($"{remote}: session error: {ex.Message}");
+ }
+ finally
+ {
+ lock (mClientsLock)
+ {
+ mClients.Remove(client);
+ ConnectionsChanged?.Invoke(mClients.Count);
+ }
+ Log?.Invoke($"Console session from {remote} ended.");
+ }
+ }
+
+ private void SessionLoop(string remote, Stream inStream, Stream outStream)
+ {
+ while (mRunning)
+ {
+ RpcRequest request;
+ try
+ {
+ request = PodRpc.ReadRequest(inStream);
+ }
+ catch
+ {
+ return; // disconnected (EOF/IO/timeout)
+ }
+ if (request == null)
+ {
+ return;
+ }
+
+ string method = request.Method ?? "???";
+ IReadOnlyList args = request.Args ?? new List();
+ // GetOutOfBandProgress is polled 4x/second during installs — don't log it.
+ if (method != "GetOutOfBandProgress" && method != "Ping")
+ {
+ Log?.Invoke($"<- {method}");
+ }
+
+ object result = null;
+ string error = null;
+ try
+ {
+ result = Dispatch(method, args);
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ Log?.Invoke($"{method} ERROR: {ex.Message}");
+ }
+
+ try
+ {
+ PodRpc.WriteResponse(outStream, result, error);
+ }
+ catch
+ {
+ return; // disconnected while writing
+ }
+
+ // The product zip follows the InitiateInstallProduct response on this
+ // same connection, then the console closes it.
+ if (method == "InitiateInstallProduct" && error == null && result is Guid installGuid)
+ {
+ ReceiveInstallFile(inStream, installGuid);
+ return;
+ }
+ }
+ }
+
+ /// Maps the console's method names (dispatch-by-name, including the
+ /// get_/set_ property accessors) onto the VirtualLauncher. Mirrors the real
+ /// service's DispatchCommandAsync.
+ private object Dispatch(string method, IReadOnlyList args)
+ {
+ switch (method)
+ {
+ case "Ping":
+ return args.Count > 0 && args[0].ValueKind != JsonValueKind.Null
+ ? mLauncher.Ping(args[0].GetDateTime())
+ : DateTime.Now;
+
+ case "GetInstalledApps":
+ return mLauncher.GetInstalledApps();
+
+ case "GetLaunchableApps":
+ return mLauncher.GetLaunchableApps();
+
+ case "GetLaunchedApps":
+ return mLauncher.GetLaunchedApps();
+
+ case "FullUpdate":
+ return mLauncher.FullUpdate();
+
+ case "GetOutOfBandProgress":
+ return mLauncher.GetOutOfBandProgress(args[0].GetGuid());
+
+ case "InitiateInstallProduct":
+ return mLauncher.InitiateInstallProduct();
+
+ case "InstallApp":
+ mLauncher.InstallApp(args[0].Deserialize(PodRpc.JsonOptions));
+ return null;
+
+ case "UninstallApp":
+ mLauncher.UninstallApp(args[0].GetGuid());
+ return null;
+
+ case "RemoveApp":
+ mLauncher.RemoveApp(args[0].GetGuid());
+ return null;
+
+ case "LaunchApp":
+ return mLauncher.LaunchApp(args[0].GetGuid());
+
+ case "KillApp":
+ mLauncher.KillApp(args[0].GetGuid(), args[1].GetInt32());
+ return null;
+
+ case "KillAllOfType":
+ mLauncher.KillAllOfType(args[0].GetGuid());
+ return null;
+
+ case "KillAllApps":
+ mLauncher.KillAllApps();
+ return null;
+
+ case "Shutdown":
+ mLauncher.Shutdown(args[0].GetBoolean());
+ return null;
+
+ case "ClearStore":
+ mLauncher.ClearStore();
+ return null;
+
+ case "get_VolumeLevel":
+ return mLauncher.VolumeLevel;
+
+ case "set_VolumeLevel":
+ mLauncher.VolumeLevel = args[0].GetSingle();
+ return null;
+
+ default:
+ Log?.Invoke($"Unknown command \"{method}\" — answering null.");
+ return null;
+ }
+ }
+
+ /// Receives the out-of-band product zip and extracts it into the
+ /// virtual games root, reporting progress exactly like the real service:
+ /// 0-50% receive, 50-95% extract, 99% "Complete" (IsCompleted).
+ private void ReceiveInstallFile(Stream stream, Guid callId)
+ {
+ string tempZip = null;
+ try
+ {
+ byte[] sizeBuffer = ReadExact(stream, 8);
+ long fileSize = BitConverter.ToInt64(sizeBuffer, 0);
+ Log?.Invoke($"Install {callId:N}: receiving {fileSize:N0} bytes...");
+ mLauncher.UpdateProgress(callId, 0, "Receiving file...");
+
+ tempZip = Path.Combine(mLauncher.DataDirectory, $"install_{callId:N}.zip");
+ using (FileStream fs = File.Create(tempZip))
+ {
+ byte[] buffer = new byte[65536];
+ long received = 0;
+ while (received < fileSize)
+ {
+ int toRead = (int)Math.Min(buffer.Length, fileSize - received);
+ int read = stream.Read(buffer, 0, toRead);
+ if (read == 0)
+ {
+ throw new IOException("Connection closed during file transfer.");
+ }
+ fs.Write(buffer, 0, read);
+ received += read;
+ mLauncher.UpdateProgress(callId, (int)(received * 50 / fileSize), "Receiving file...");
+ }
+ }
+
+ mLauncher.UpdateProgress(callId, 50, "Extracting...");
+ string gamesRoot = Path.GetFullPath(mLauncher.GamesRoot);
+ Directory.CreateDirectory(gamesRoot);
+ using (ZipArchive zip = ZipFile.OpenRead(tempZip))
+ {
+ int total = zip.Entries.Count;
+ int done = 0;
+ foreach (ZipArchiveEntry entry in zip.Entries)
+ {
+ string destPath = Path.GetFullPath(Path.Combine(gamesRoot, entry.FullName));
+ // Zip-slip protection, as in the real service.
+ if (!destPath.StartsWith(gamesRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+ if (string.IsNullOrEmpty(entry.Name))
+ {
+ Directory.CreateDirectory(destPath);
+ }
+ else
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(destPath));
+ entry.ExtractToFile(destPath, overwrite: true);
+ }
+ done++;
+ mLauncher.UpdateProgress(callId, 50 + done * 45 / Math.Max(total, 1), "Extracting...");
+ }
+ }
+ Log?.Invoke($"Install {callId:N}: extracted to {gamesRoot}");
+
+ // The real service runs (then deletes) a packaged postinstall.bat here.
+ // vPOD never executes package scripts on the host machine.
+ string postInstall = Path.Combine(gamesRoot, "postinstall.bat");
+ if (File.Exists(postInstall))
+ {
+ mLauncher.UpdateProgress(callId, 96, "Skipping postinstall (vPOD)...");
+ Log?.Invoke($"Install {callId:N}: postinstall.bat present — NOT executed (vPOD), removed.");
+ try { File.Delete(postInstall); } catch { }
+ }
+
+ mLauncher.UpdateProgress(callId, 99, "Complete", isCompleted: true);
+ Log?.Invoke($"Install {callId:N}: complete.");
+ }
+ catch (Exception ex)
+ {
+ mLauncher.UpdateProgress(callId, 0, $"Failed: {ex.Message}", isCompleted: true);
+ Log?.Invoke($"Install {callId:N} FAILED: {ex.Message}");
+ }
+ finally
+ {
+ try { if (tempZip != null) File.Delete(tempZip); } catch { }
+ }
+ }
+
+ private static byte[] ReadExact(Stream stream, int count)
+ {
+ byte[] buffer = new byte[count];
+ int offset = 0;
+ while (offset < count)
+ {
+ int read = stream.Read(buffer, offset, count - offset);
+ if (read == 0)
+ {
+ throw new EndOfStreamException("Connection closed mid-read.");
+ }
+ offset += read;
+ }
+ return buffer;
+ }
+}
diff --git a/Console/vPOD/PodArguments.cs b/Console/vPOD/PodArguments.cs
index 163269c..5335f5d 100644
--- a/Console/vPOD/PodArguments.cs
+++ b/Console/vPOD/PodArguments.cs
@@ -14,6 +14,10 @@ namespace VPod;
/// -app rp|bt which ApplicationID to report (RP by default; also
/// switchable live in the UI)
/// -host <id> the responding host id reported in state responses
+///
+/// vPOD-only (not a real game-client option):
+/// -nomanage disable the virtual launcher / site-management side
+/// (no provisioning beacons, no TCP 53290 listener)
///
internal sealed class PodArguments
{
@@ -25,6 +29,8 @@ internal sealed class PodArguments
public int HostId { get; private set; } = 1;
+ public bool NoManage { get; private set; }
+
public static PodArguments Parse(string[] args)
{
PodArguments result = new PodArguments();
@@ -63,6 +69,9 @@ internal sealed class PodArguments
i++;
}
break;
+ case "-nomanage":
+ result.NoManage = true;
+ break;
}
}
return result;
diff --git a/Console/vPOD/PodProvisioning.cs b/Console/vPOD/PodProvisioning.cs
new file mode 100644
index 0000000..bce0cea
--- /dev/null
+++ b/Console/vPOD/PodProvisioning.cs
@@ -0,0 +1,378 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using Tesla;
+
+namespace VPod;
+
+///
+/// The pod side of the SecureConfig first-boot provisioning protocol, display-only:
+/// unlike a real pod (the Launcher's PodSecureConfigurator) it never touches the
+/// NIC, registry or hostname — the network config the console assigns is only
+/// surfaced to the UI. The wire behaviour matches the real pod, so the console's
+/// Manage Site "Configure" flow works unmodified:
+///
+/// 1. Broadcast a "RQST" beacon (MAC + 3-char RequestId) to UDP 53291 every 10 s
+/// — the console shows a "Configure <RequestId>" button.
+/// 2. Operator enters the pod's network settings and the 5-char passphrase shown
+/// in vPOD's window; the console broadcasts an AES-encrypted "RPLY" (network
+/// config) to UDP 53292, key = PBKDF2(passphrase).
+/// 3. The console TCP-connects to the pod's (entered) address on 53292; after the
+/// OFB/CONF handshake on the passphrase key, the pod sends an RSA public key
+/// and receives the RSA-encrypted 32-byte session key — the key that unlocks
+/// the launcher RPC channel (TCP 53290) from then on.
+///
+/// Reuses the shared TeslaSecureConfiguration pieces where they are public
+/// (UdpBeacon, BasicConfigResponse, NegotiateCryptoStreams); the passphrase KDF
+/// salt and the "RQST"/"RPLY" tags are internal there and duplicated below.
+///
+internal sealed class PodProvisioning
+{
+ public const int ConsoleRequestPort = 53291; // console listens for RQST beacons
+ public const int PodReplyPort = 53292; // pod listens for RPLY + the TCP key exchange
+
+ // Mirrors of internals in SecureConfig/SecureConfig.cs (PodConfigurationServer):
+ // the PBKDF2 salt for the passphrase-derived AES key, and the pod-side
+ // passphrase/request-id alphabet + lengths (SetupPod validates passphrase == 5).
+ private static readonly byte[] sPassphraseSalt = new byte[32]
+ {
+ 23, 171, 81, 217, 236, 209, 212, 116, 169, 9,
+ 74, 52, 39, 251, 31, 242, 222, 196, 249, 241,
+ 166, 216, 158, 218, 21, 17, 71, 101, 50, 231,
+ 231, 239
+ };
+ private const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
+ private const int RequestIdLength = 3;
+ private const int PassphraseLength = 5;
+
+ private readonly byte[] mMacAddress;
+ private readonly object mLock = new object();
+ private UdpBeacon mBeacon;
+ private UdpClient mReplyListener;
+ private TcpListener mKeyExchangeListener;
+ private Thread mWorker;
+ private volatile bool mRunning;
+ // Bumped by every Start/Stop so a worker from a previous session can neither
+ // tear down nor complete a newer one (e.g. quick power-off/power-on cycles).
+ private volatile int mGeneration;
+
+ public event Action Log;
+ public event Action ConfigReceived; // display-only network config
+ public event Action Provisioned; // the 32-byte session key
+
+ public string RequestId { get; private set; }
+ public string Passphrase { get; private set; }
+ public bool IsRunning => mRunning;
+
+ /// The pod's stable fake MAC: locally-administered "VPOD" + host id, so
+ /// the console recognizes the same virtual pod across reprovisions (Site.FindPod).
+ public static byte[] MacForHost(int hostId)
+ {
+ return new byte[6] { 0x02, 0x56, 0x50, 0x4F, 0x44, (byte)hostId };
+ }
+
+ public PodProvisioning(byte[] macAddress)
+ {
+ mMacAddress = macAddress;
+ }
+
+ public void Start()
+ {
+ int generation;
+ lock (mLock)
+ {
+ if (mRunning)
+ {
+ return;
+ }
+ mRunning = true;
+ generation = ++mGeneration;
+ RequestId = GenerateRandomString(RequestIdLength);
+ Passphrase = GenerateRandomString(PassphraseLength);
+
+ byte[] payload = new byte[mMacAddress.Length + RequestIdLength];
+ mMacAddress.CopyTo(payload, 0);
+ Encoding.ASCII.GetBytes(RequestId).CopyTo(payload, mMacAddress.Length);
+ mBeacon = new UdpBeacon(Encoding.UTF8.GetBytes("RQST"), payload, 10000.0, ConsoleRequestPort, null);
+ mBeacon.Start();
+
+ mWorker = new Thread(() => ProvisionWorker(generation)) { IsBackground = true, Name = "vPOD-provision" };
+ mWorker.Start();
+ }
+ Log?.Invoke($"Provisioning: beaconing RQST (Request ID {RequestId}, passphrase {Passphrase}).");
+ }
+
+ public void Stop()
+ {
+ lock (mLock)
+ {
+ mGeneration++; // orphan any live worker
+ if (!mRunning)
+ {
+ return;
+ }
+ mRunning = false;
+ try { mBeacon?.Stop(); } catch { }
+ mBeacon = null;
+ try { mReplyListener?.Close(); } catch { }
+ mReplyListener = null;
+ try { mKeyExchangeListener?.Stop(); } catch { }
+ mKeyExchangeListener = null;
+ }
+ }
+
+ private bool IsCurrent(int generation)
+ {
+ return mRunning && generation == mGeneration;
+ }
+
+ private void ProvisionWorker(int generation)
+ {
+ try
+ {
+ // ---- Phase 1: wait for the console's RPLY (proves the operator typed
+ // our passphrase) and surface the assigned network config. ----
+ byte[] weakKey = DeriveKeyFromPassphrase(Passphrase);
+ BasicConfigResponse config = ReceiveReply(weakKey, generation);
+ if (config == null || !IsCurrent(generation))
+ {
+ return; // stopped
+ }
+ Log?.Invoke($"Provisioning: RPLY received — assigned IP {config.Address} / {config.Mask}" +
+ (string.IsNullOrEmpty(config.HostName) ? "" : $", host \"{config.HostName}\"") +
+ " (display only, not applied).");
+ ConfigReceived?.Invoke(config);
+ lock (mLock)
+ {
+ try { mBeacon?.Stop(); } catch { }
+ mBeacon = null;
+ }
+
+ // ---- Phase 2: accept the console's TCP key exchange on 53292. ----
+ TcpListener listener;
+ lock (mLock)
+ {
+ if (!IsCurrent(generation))
+ {
+ return;
+ }
+ try
+ {
+ listener = new TcpListener(IPAddress.Any, PodReplyPort);
+ listener.Start();
+ }
+ catch (Exception ex)
+ {
+ Log?.Invoke($"Provisioning: cannot listen on TCP {PodReplyPort}: {ex.Message}");
+ return;
+ }
+ mKeyExchangeListener = listener;
+ }
+ Log?.Invoke("Provisioning: waiting for the console's key exchange on TCP " + PodReplyPort + "...");
+ while (IsCurrent(generation))
+ {
+ TcpClient client = null;
+ try
+ {
+ client = listener.AcceptTcpClient();
+ byte[] sessionKey = ExchangeSessionKey(client, weakKey);
+ if (sessionKey == null)
+ {
+ Log?.Invoke("Provisioning: key-exchange handshake failed (wrong passphrase key?); still waiting.");
+ continue;
+ }
+ if (!IsCurrent(generation))
+ {
+ return;
+ }
+ Log?.Invoke("Provisioning: session key received — pod is provisioned.");
+ Provisioned?.Invoke(sessionKey);
+ return;
+ }
+ catch (Exception ex)
+ {
+ if (IsCurrent(generation))
+ {
+ Log?.Invoke("Provisioning: key exchange error: " + ex.Message);
+ }
+ else
+ {
+ return; // listener stopped
+ }
+ }
+ finally
+ {
+ try { client?.Close(); } catch { }
+ }
+ }
+ }
+ finally
+ {
+ StopGeneration(generation);
+ }
+ }
+
+ /// Tears the session down only if it is still the one this worker
+ /// belongs to — a newer Start() must not be disturbed by an old worker exiting.
+ private void StopGeneration(int generation)
+ {
+ lock (mLock)
+ {
+ if (generation != mGeneration)
+ {
+ return;
+ }
+ mRunning = false;
+ try { mBeacon?.Stop(); } catch { }
+ mBeacon = null;
+ try { mReplyListener?.Close(); } catch { }
+ mReplyListener = null;
+ try { mKeyExchangeListener?.Stop(); } catch { }
+ mKeyExchangeListener = null;
+ }
+ }
+
+ /// Listens on UDP 53292 for an "RPLY" datagram that decrypts and
+ /// verifies under our passphrase key. Cancellable mirror of the shared
+ /// UdpBeaconListener (which cannot be stopped once blocked in Receive):
+ /// packet = "RPLY"(4) + IV(16) + AES-CBC(config + SHA1(config)).
+ private BasicConfigResponse ReceiveReply(byte[] weakKey, int generation)
+ {
+ UdpClient udp;
+ lock (mLock)
+ {
+ if (!IsCurrent(generation))
+ {
+ return null;
+ }
+ try
+ {
+ udp = new UdpClient(PodReplyPort) { EnableBroadcast = true };
+ }
+ catch (Exception ex)
+ {
+ Log?.Invoke($"Provisioning: cannot listen on UDP {PodReplyPort}: {ex.Message}");
+ return null;
+ }
+ mReplyListener = udp;
+ }
+ byte[] header = Encoding.UTF8.GetBytes("RPLY");
+ using (Rijndael aes = Rijndael.Create())
+ using (SHA1 sha1 = SHA1.Create())
+ {
+ aes.Key = weakKey;
+ while (IsCurrent(generation))
+ {
+ byte[] packet;
+ try
+ {
+ IPEndPoint remote = new IPEndPoint(IPAddress.Any, 0);
+ packet = udp.Receive(ref remote);
+ }
+ catch
+ {
+ return null; // socket closed by Stop()
+ }
+ try
+ {
+ int ivLength = aes.IV.Length;
+ if (packet.Length < header.Length + ivLength)
+ {
+ continue;
+ }
+ bool tagOk = true;
+ for (int i = 0; i < header.Length; i++)
+ {
+ if (packet[i] != header[i]) { tagOk = false; break; }
+ }
+ if (!tagOk)
+ {
+ continue;
+ }
+ byte[] iv = new byte[ivLength];
+ Buffer.BlockCopy(packet, header.Length, iv, 0, ivLength);
+ byte[] plain;
+ using (ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, iv))
+ using (MemoryStream ms = new MemoryStream())
+ {
+ using (CryptoStream cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Write))
+ {
+ cs.Write(packet, header.Length + ivLength, packet.Length - header.Length - ivLength);
+ cs.FlushFinalBlock();
+ }
+ plain = ms.ToArray();
+ }
+ int messageLength = plain.Length - sha1.HashSize / 8;
+ if (messageLength <= 0)
+ {
+ continue;
+ }
+ byte[] hash = sha1.ComputeHash(plain, 0, messageLength);
+ bool hashOk = true;
+ for (int i = 0; i < hash.Length; i++)
+ {
+ if (plain[messageLength + i] != hash[i]) { hashOk = false; break; }
+ }
+ if (!hashOk)
+ {
+ continue; // wrong passphrase (or noise) — keep listening
+ }
+ byte[] message = new byte[messageLength];
+ Buffer.BlockCopy(plain, 0, message, 0, messageLength);
+ return new BasicConfigResponse(message);
+ }
+ catch
+ {
+ // undecryptable/malformed datagram — keep listening
+ }
+ }
+ }
+ return null;
+ }
+
+ /// The RSA leg, mirroring the real pod (PodConfigurationClient): after
+ /// the OFB/CONF handshake on the passphrase key, send our RSA public key and
+ /// decrypt the console's session key with it. Returns null if CONF fails.
+ private byte[] ExchangeSessionKey(TcpClient client, byte[] weakKey)
+ {
+ if (!PodConfigurationServer.NegotiateCryptoStreams(client.GetStream(), weakKey, out Stream outStream, out Stream inStream))
+ {
+ return null;
+ }
+ BinaryWriter writer = new BinaryWriter(outStream);
+ BinaryReader reader = new BinaryReader(inStream);
+ using (RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048))
+ {
+ writer.Write(rsa.ToXmlString(includePrivateParameters: false));
+ writer.Flush();
+ byte[] encryptedKey = reader.ReadBytes(reader.ReadInt32());
+ return rsa.Decrypt(encryptedKey, fOAEP: false);
+ }
+ }
+
+ internal static byte[] DeriveKeyFromPassphrase(string passphrase)
+ {
+ using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(passphrase, sPassphraseSalt, 1000))
+ {
+ return pbkdf2.GetBytes(32);
+ }
+ }
+
+ private static string GenerateRandomString(int length)
+ {
+ // RNGCryptoServiceProvider-quality randomness is unnecessary here (the real
+ // pod uses System.Random too), but avoid same-seed collisions across quick
+ // restarts by seeding from Guid entropy.
+ Random random = new Random(Guid.NewGuid().GetHashCode());
+ char[] chars = new char[length];
+ for (int i = 0; i < length; i++)
+ {
+ chars[i] = Alphabet[random.Next(Alphabet.Length)];
+ }
+ return new string(chars);
+ }
+}
diff --git a/Console/vPOD/README.md b/Console/vPOD/README.md
index 4753935..fb0ebc5 100644
--- a/Console/vPOD/README.md
+++ b/Console/vPOD/README.md
@@ -7,6 +7,12 @@ server on TCP 1501 — the console connects to it exactly as it would a real pod
emulates the pod `ApplicationState` machine, reassembles the streamed egg, and
shows everything on a live display.
+It also impersonates the pod's **TeslaLauncher service** (the "Launcher / Site
+Management" column), so the console's Manage Site — provisioning, Install /
+Uninstall Product, launch/kill, volume, restart/shutdown — can be tested
+end-to-end with no cockpit and **no console changes**. See
+[Site management / virtual launcher](#site-management--virtual-launcher).
+
## What it does
- **Listens on TCP 1501** (configurable) and answers the console's
@@ -37,7 +43,7 @@ shows everything on a live display.
## Running it
```
-vPOD.exe [-net ] [-app rp|bt] [-lc|-mr] [-host ] [-res W H]
+vPOD.exe [-net ] [-app rp|bt] [-lc|-mr] [-host ] [-res W H] [-nomanage]
```
- `-net ` Munga control port (default **1501**).
@@ -47,6 +53,78 @@ vPOD.exe [-net ] [-app rp|bt] [-lc|-mr] [-host ] [-res W H]
- `-host ` responding host id reported in state responses (default 1).
- `-res W H` accepted and ignored (real clients take it; kept for drop-in
launch compatibility).
+- `-nomanage` vPOD-only: disable the virtual launcher / site-management side
+ (no provisioning beacons, no TCP 53290 listener) — e.g. when a real
+ TeslaLauncher runs on the same machine, or for a second vPOD instance.
+
+## Site management / virtual launcher
+
+vPOD's right-hand column is a stand-in for the pod's **TeslaLauncher service**:
+the OFB-encrypted, framed-JSON `ILauncherService` RPC on **TCP 53290** that the
+console's Manage Site and squad/pod panel talk to (`Tesla.Contract` /
+`Tesla.SecureConfig` are the same shared wire libraries both real ends use).
+
+### Provisioning (first run)
+
+The console only talks to a pod's launcher after minting a 32-byte session key
+for it via the SecureConfig **Configure** flow, so an unprovisioned vPOD
+behaves like a freshly-imaged pod — minus the NIC/registry changes (the
+assigned network config is shown in the window but never applied):
+
+1. Run vPOD (no `-nomanage`). It broadcasts `RQST` beacons and displays a
+ **Request ID** and **Passphrase** (the real pod shows these on its screen).
+2. In the console: **Manage Site** — a **"Configure <Request ID>"** button
+ appears at the bottom. Click it, enter the pod's name/squad, its IP
+ (**127.0.0.1** when vPOD runs on the console machine — accepted), any
+ subnet (e.g. 255.255.255.0), and the passphrase from vPOD's window.
+3. The console sends the encrypted config + session key; vPOD stores the key
+ and starts the launcher RPC. The pod row goes healthy (`Idle [ ms]`).
+
+The key persists in `%LocalAppData%\vPOD\TeslaKeyStore.key` (launcher format),
+so provisioning is one-time. **Reprovision** drops the key 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
+drop the key and beacon again).
+
+### 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:
+
+- 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).
+
+### Ports
+
+| Port | Proto | Direction | Purpose |
+|---|---|---|---|
+| 1501 | TCP | console → vPOD | Munga game control (existing) |
+| 53290 | TCP | console → vPOD | Launcher RPC (`ILauncherService`) |
+| 53291 | UDP | vPOD → console (broadcast) | `RQST` provisioning beacon |
+| 53292 | UDP+TCP | console → vPOD | `RPLY` config broadcast + RSA key exchange |
+
+Same-machine testing needs no firewall changes (loopback). Running vPOD on a
+**different machine** needs inbound allows for TCP 1501/53290/53292 and UDP
+53292 on the vPOD machine (the console installer already opens its own side).
+
+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).
+
+An end-to-end loopback test of this server against the console's real
+`PodManagerConnection` client lives in the differential suite:
+`Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs`.
## Deploying from the console (Manage Site → Install Product)
diff --git a/Console/vPOD/VPodForm.cs b/Console/vPOD/VPodForm.cs
index 505107d..2ba32c3 100644
--- a/Console/vPOD/VPodForm.cs
+++ b/Console/vPOD/VPodForm.cs
@@ -3,6 +3,8 @@ using System.Drawing;
using System.Text;
using System.Windows.Forms;
using Munga.Net;
+using Tesla;
+using Tesla.Net;
namespace VPod;
@@ -12,13 +14,18 @@ namespace VPod;
/// with the game toggle (Red Planet / BattleTech) that changes which
/// ApplicationID the pod reports. Below, the left pane is the egg viewer (the
/// last egg the console streamed, one field per line) and the right pane is a
-/// scrolling protocol log.
+/// scrolling protocol log. The right column is the virtual launcher — the pod
+/// side of the console's Site Management (provisioning, product installs,
+/// launch/kill/volume/shutdown), see LauncherRpcServer / PodProvisioning.
///
internal sealed class VPodForm : Form
{
private readonly PodArguments mOptions;
private readonly MungaPodServer mServer;
private readonly PodSimulator mSimulator;
+ private readonly VirtualLauncher mLauncher;
+ private readonly LauncherRpcServer mRpcServer;
+ private readonly PodProvisioning mProvisioning;
private Label mListeningLabel;
private Label mConnectionLabel;
@@ -31,18 +38,39 @@ internal sealed class VPodForm : Form
private Button mResetButton;
private CheckBox mRestartCheckbox;
private Timer mRestartTimer;
+ private Timer mRebootTimer;
private SplitContainer mSplit;
private TextBox mEggBox;
private TextBox mLogBox;
+ // Virtual launcher / site-management column.
+ private Label mProvisionStatusLabel;
+ private Panel mPassphrasePanel;
+ private Label mRequestIdValueLabel;
+ private Label mPassphraseValueLabel;
+ private Label mNetConfigLabel;
+ private Label mRpcStatusLabel;
+ private Label mInstallStatusLabel;
+ private ProgressBar mInstallProgressBar;
+ private Button mReprovisionButton;
+ private ListView mAppsView;
+
+ private bool mPoweredOn;
+
// How long the "watchdog" waits before relaunching the exited game.
private const int WatchdogRestartMs = 1500;
+ // How long a console-requested Shutdown(restart) keeps the pod dark.
+ private const int RebootRestartMs = 4000;
+
public VPodForm(PodArguments options)
{
mOptions = options;
mServer = new MungaPodServer(options.Port);
mSimulator = new PodSimulator(mServer, options.Application, options.HostId);
+ mLauncher = new VirtualLauncher();
+ mRpcServer = new LauncherRpcServer(mLauncher);
+ mProvisioning = new PodProvisioning(PodProvisioning.MacForHost(options.HostId));
BuildUi();
mServer.Log += OnLog;
@@ -54,8 +82,21 @@ internal sealed class VPodForm : Form
mSimulator.EggProgress += OnEggProgress;
mSimulator.EndMissionExit += OnEndMissionExit;
+ mLauncher.Log += OnSiteLog;
+ mLauncher.AppsChanged += OnAppsChanged;
+ mLauncher.InstallProgressChanged += OnInstallProgress;
+ mLauncher.ShutdownRequested += OnShutdownRequested;
+ mLauncher.ReprovisionRequested += OnReprovisionRequested;
+ mRpcServer.Log += OnSiteLog;
+ mRpcServer.ConnectionsChanged += OnRpcConnectionsChanged;
+ mProvisioning.Log += OnSiteLog;
+ mProvisioning.ConfigReceived += OnConfigReceived;
+ mProvisioning.Provisioned += OnProvisioned;
+
mRestartTimer = new Timer { Interval = WatchdogRestartMs };
mRestartTimer.Tick += OnRestartTimerTick;
+ mRebootTimer = new Timer { Interval = RebootRestartMs };
+ mRebootTimer.Tick += OnRebootTimerTick;
Load += OnFormLoad;
FormClosing += OnFormClosing;
@@ -64,8 +105,8 @@ internal sealed class VPodForm : Form
private void BuildUi()
{
Text = $"vPOD - virtual pod (port {mOptions.Port}, host {mOptions.HostId})";
- ClientSize = new Size(920, 560);
- MinimumSize = new Size(760, 460);
+ ClientSize = new Size(1280, 560);
+ MinimumSize = new Size(1100, 500);
Font = new Font("Segoe UI", 9f);
// ---- status panel ----
@@ -212,8 +253,112 @@ internal sealed class VPodForm : Form
split.Panel1.Controls.Add(eggGroup);
split.Panel2.Controls.Add(logGroup);
+ // ---- virtual launcher / site management column ----
+ GroupBox siteGroup = new GroupBox
+ {
+ Text = "Launcher / Site Management",
+ Dock = DockStyle.Right,
+ Width = 360,
+ Padding = new Padding(8)
+ };
+
+ Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 216 };
+
+ mProvisionStatusLabel = new Label
+ {
+ AutoSize = true,
+ Location = new Point(4, 4),
+ Font = new Font("Segoe UI", 9f, FontStyle.Bold),
+ Text = "Provisioning: —"
+ };
+
+ // Stand-in for the real pod's TeslaPasscodeDisplay: the operator reads
+ // these two values into the console's Configure dialog.
+ mPassphrasePanel = new Panel { Location = new Point(4, 28), Size = new Size(336, 62), Visible = false };
+ Label requestIdCaption = new Label { AutoSize = true, Location = new Point(0, 6), Text = "Request ID:" };
+ mRequestIdValueLabel = new Label
+ {
+ AutoSize = true,
+ Location = new Point(90, 0),
+ Font = new Font("Segoe UI", 15f, FontStyle.Bold),
+ Text = "—"
+ };
+ Label passphraseCaption = new Label { AutoSize = true, Location = new Point(0, 38), Text = "Passphrase:" };
+ mPassphraseValueLabel = new Label
+ {
+ AutoSize = true,
+ Location = new Point(90, 32),
+ Font = new Font("Segoe UI", 15f, FontStyle.Bold),
+ ForeColor = Color.Firebrick,
+ Text = "—"
+ };
+ mPassphrasePanel.Controls.Add(requestIdCaption);
+ mPassphrasePanel.Controls.Add(mRequestIdValueLabel);
+ mPassphrasePanel.Controls.Add(passphraseCaption);
+ mPassphrasePanel.Controls.Add(mPassphraseValueLabel);
+
+ mNetConfigLabel = new Label
+ {
+ Location = new Point(4, 94),
+ Size = new Size(336, 32),
+ ForeColor = Color.Gray,
+ Text = ""
+ };
+
+ mRpcStatusLabel = new Label
+ {
+ AutoSize = true,
+ Location = new Point(4, 130),
+ Text = "Launcher RPC: not running"
+ };
+
+ mInstallStatusLabel = new Label
+ {
+ Location = new Point(4, 154),
+ Size = new Size(336, 18),
+ Text = "No install in progress."
+ };
+ mInstallProgressBar = new ProgressBar
+ {
+ Location = new Point(4, 176),
+ Size = new Size(240, 22),
+ Minimum = 0,
+ Maximum = 100
+ };
+ mReprovisionButton = new Button
+ {
+ Text = "Reprovision",
+ Location = new Point(252, 174),
+ Size = new Size(88, 26)
+ };
+ mReprovisionButton.Click += ReprovisionClicked;
+
+ siteTop.Controls.Add(mProvisionStatusLabel);
+ siteTop.Controls.Add(mPassphrasePanel);
+ siteTop.Controls.Add(mNetConfigLabel);
+ siteTop.Controls.Add(mRpcStatusLabel);
+ siteTop.Controls.Add(mInstallStatusLabel);
+ siteTop.Controls.Add(mInstallProgressBar);
+ siteTop.Controls.Add(mReprovisionButton);
+
+ mAppsView = new ListView
+ {
+ Dock = DockStyle.Fill,
+ View = View.Details,
+ FullRowSelect = true,
+ HeaderStyle = ColumnHeaderStyle.Nonclickable
+ };
+ mAppsView.Columns.Add("Installed App", 150);
+ mAppsView.Columns.Add("PID", 48);
+ mAppsView.Columns.Add("Auto", 42);
+ mAppsView.Columns.Add("Exe", 200);
+
+ siteGroup.Controls.Add(mAppsView);
+ siteGroup.Controls.Add(siteTop);
+
Controls.Add(split);
Controls.Add(statusGroup);
+ Controls.Add(siteGroup);
}
private void OnFormLoad(object sender, EventArgs e)
@@ -225,10 +370,12 @@ internal sealed class VPodForm : Form
}
UpdateConnectionLabel(null);
UpdateStateLabel(mSimulator.State);
+ RefreshAppsList();
if (StartServer())
{
mSimulator.PowerOn();
SetPoweredState(on: true);
+ StartManagement();
}
else
{
@@ -239,27 +386,34 @@ internal sealed class VPodForm : Form
private void OnFormClosing(object sender, FormClosingEventArgs e)
{
mServer.Stop();
+ StopManagement();
}
private void PowerOnClicked(object sender, EventArgs e)
{
mRestartTimer.Stop(); // cancel any pending watchdog restart
+ mRebootTimer.Stop();
if (!mServer.IsListening && !StartServer())
{
return; // couldn't bind the port; stay powered off
}
mSimulator.PowerOn();
SetPoweredState(on: true);
+ StartManagement();
}
///
/// Powers the pod off by closing the TCP listener, so the console can no
/// longer connect — the same condition as a pod with no game client running.
+ /// The launcher/provisioning side goes dark too (the whole machine is "off",
+ /// unlike an end-mission game exit where the launcher service survives).
///
private void PowerOffClicked(object sender, EventArgs e)
{
mRestartTimer.Stop();
+ mRebootTimer.Stop();
mServer.Stop();
+ StopManagement();
SetPoweredState(on: false);
}
@@ -326,18 +480,114 @@ internal sealed class VPodForm : Form
/// Reflects the on/off state in the buttons and, when off, the status labels.
private void SetPoweredState(bool on)
{
+ mPoweredOn = on;
mPowerButton.Enabled = !on;
mPowerOffButton.Enabled = on;
mResetButton.Enabled = on;
+ mReprovisionButton.Enabled = on && !mOptions.NoManage;
if (!on)
{
UpdateListeningLabel(false);
UpdateConnectionLabel(null);
mStateLabel.Text = "Powered Off";
mStateLabel.ForeColor = Color.Gray;
+ mProvisionStatusLabel.Text = "Provisioning: pod powered off";
+ mProvisionStatusLabel.ForeColor = Color.Gray;
+ mPassphrasePanel.Visible = false;
+ mRpcStatusLabel.Text = "Launcher RPC: pod powered off";
+ mRpcStatusLabel.ForeColor = Color.Gray;
}
}
+ // ---- virtual launcher / site management lifecycle ----
+
+ /// Brings the launcher side up for a powered-on pod: the RPC server
+ /// when a session key is stored, otherwise the provisioning beacons.
+ private void StartManagement()
+ {
+ if (mOptions.NoManage)
+ {
+ mProvisionStatusLabel.Text = "Provisioning: disabled (-nomanage)";
+ mProvisionStatusLabel.ForeColor = Color.Gray;
+ mRpcStatusLabel.Text = "Launcher RPC: disabled (-nomanage)";
+ mRpcStatusLabel.ForeColor = Color.Gray;
+ return;
+ }
+ byte[] key = mLauncher.LoadKey();
+ if (key != null)
+ {
+ StartRpcServer(key);
+ }
+ else
+ {
+ StartProvisioning();
+ }
+ }
+
+ private void StopManagement()
+ {
+ mProvisioning.Stop();
+ mRpcServer.Stop();
+ }
+
+ private void StartRpcServer(byte[] key)
+ {
+ try
+ {
+ mRpcServer.Start(key);
+ }
+ catch (Exception ex)
+ {
+ OnSiteLog($"FAILED to listen on TCP {LauncherRpcServer.ManagePort}: {ex.Message}");
+ mRpcStatusLabel.Text = $"Launcher RPC: TCP {LauncherRpcServer.ManagePort} unavailable";
+ mRpcStatusLabel.ForeColor = Color.Firebrick;
+ return;
+ }
+ mProvisionStatusLabel.Text = "Provisioned — key " + KeyFingerprint(key);
+ mProvisionStatusLabel.ForeColor = Color.ForestGreen;
+ mPassphrasePanel.Visible = false;
+ mRpcStatusLabel.Text = $"Launcher RPC: listening on TCP {LauncherRpcServer.ManagePort}";
+ mRpcStatusLabel.ForeColor = Color.ForestGreen;
+ }
+
+ private void StartProvisioning()
+ {
+ mProvisioning.Start();
+ mProvisionStatusLabel.Text = "Unprovisioned — beaconing to console...";
+ mProvisionStatusLabel.ForeColor = Color.DarkGoldenrod;
+ mRequestIdValueLabel.Text = mProvisioning.RequestId;
+ mPassphraseValueLabel.Text = mProvisioning.Passphrase;
+ mPassphrasePanel.Visible = true;
+ mNetConfigLabel.Text = "Use Manage Site's \"Configure\" button and enter the passphrase above.";
+ mRpcStatusLabel.Text = "Launcher RPC: waiting for provisioning";
+ mRpcStatusLabel.ForeColor = Color.Gray;
+ }
+
+ /// Drops the session key and re-enters provisioning — from the UI
+ /// button, or when the console's Reconfigure clears our store.
+ private void DropKeyAndReprovision()
+ {
+ mLauncher.DeleteKey();
+ mRpcServer.Stop();
+ mProvisioning.Stop();
+ mNetConfigLabel.Text = "";
+ if (mPoweredOn && !mOptions.NoManage)
+ {
+ StartProvisioning();
+ }
+ }
+
+ private void ReprovisionClicked(object sender, EventArgs e)
+ {
+ OnSiteLog("Reprovision requested from the vPOD window.");
+ DropKeyAndReprovision();
+ }
+
+ private static string KeyFingerprint(byte[] key)
+ {
+ return key.Length >= 4 ? $"{key[0]:X2}{key[1]:X2}{key[2]:X2}{key[3]:X2}…" : "?";
+ }
+
private void GameToggleChanged(object sender, EventArgs e)
{
if (sender is RadioButton rb && !rb.Checked)
@@ -417,6 +667,133 @@ internal sealed class VPodForm : Form
}));
}
+ // ---- virtual launcher event handlers (marshal to UI thread) ----
+
+ private void OnSiteLog(string message)
+ {
+ OnLog("[SITE] " + message);
+ }
+
+ private void OnProvisioned(byte[] key)
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)(() =>
+ {
+ mLauncher.SaveKey(key);
+ mProvisioning.Stop();
+ StartRpcServer(key);
+ }));
+ }
+
+ private void OnConfigReceived(BasicConfigResponse config)
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)(() =>
+ {
+ mNetConfigLabel.Text = $"Assigned (display only): IP {config.Address} / {config.Mask}" +
+ (string.IsNullOrEmpty(config.HostName) ? "" : $"\r\nhost \"{config.HostName}\", gw {config.Gateway}, dns {config.Dns}");
+ }));
+ }
+
+ private void OnRpcConnectionsChanged(int count)
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)(() =>
+ {
+ if (!mRpcServer.IsListening)
+ {
+ return; // Stop() already updated the label
+ }
+ mRpcStatusLabel.Text = count > 0
+ ? $"Launcher RPC: {count} console session(s) on TCP {LauncherRpcServer.ManagePort}"
+ : $"Launcher RPC: listening on TCP {LauncherRpcServer.ManagePort}";
+ mRpcStatusLabel.ForeColor = Color.ForestGreen;
+ }));
+ }
+
+ private void OnAppsChanged()
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)RefreshAppsList);
+ }
+
+ private void RefreshAppsList()
+ {
+ LaunchData[] installed = mLauncher.GetInstalledApps();
+ LaunchedAppData[] launched = mLauncher.GetLaunchedApps();
+ mAppsView.BeginUpdate();
+ mAppsView.Items.Clear();
+ foreach (LaunchData app in installed)
+ {
+ StringBuilder pids = new StringBuilder();
+ foreach (LaunchedAppData proc in launched)
+ {
+ if (proc.LaunchKey == app.LaunchPair.LaunchKey)
+ {
+ if (pids.Length > 0) pids.Append(", ");
+ pids.Append(proc.ProcessId);
+ }
+ }
+ ListViewItem item = new ListViewItem(new[]
+ {
+ app.LaunchPair.DisplayName,
+ pids.ToString(),
+ app.AutoRestart ? "yes" : "",
+ app.ExeFile
+ });
+ mAppsView.Items.Add(item);
+ }
+ mAppsView.EndUpdate();
+ }
+
+ private void OnInstallProgress(Guid callId, OutOfBandProgress progress)
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)(() =>
+ {
+ mInstallProgressBar.Value = Math.Max(0, Math.Min(100, progress.PercentComplete));
+ mInstallStatusLabel.Text = progress.IsCompleted
+ ? "Install: " + progress.Status
+ : $"Installing: {progress.Status} ({progress.PercentComplete}%)";
+ }));
+ }
+
+ /// The console asked the pod to shut down or restart: go fully dark
+ /// (Munga + launcher), and come back after a "reboot" pause when restarting.
+ private void OnShutdownRequested(bool restart)
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)(() =>
+ {
+ mRestartTimer.Stop();
+ mRebootTimer.Stop();
+ mServer.Stop();
+ StopManagement();
+ SetPoweredState(on: false);
+ if (restart)
+ {
+ mStateLabel.Text = "Rebooting...";
+ mStateLabel.ForeColor = Color.DarkOrange;
+ mRebootTimer.Start();
+ }
+ }));
+ }
+
+ private void OnRebootTimerTick(object sender, EventArgs e)
+ {
+ mRebootTimer.Stop();
+ OnLog("[SITE] Pod rebooted.");
+ PowerOnClicked(this, EventArgs.Empty);
+ }
+
+ /// The console's Reconfigure cleared our store — drop the key and go
+ /// back to beaconing so the "Configure" button reappears in Manage Site.
+ private void OnReprovisionRequested()
+ {
+ if (IsDisposed) return;
+ BeginInvoke((Action)DropKeyAndReprovision);
+ }
+
// ---- label helpers ----
private void UpdateListeningLabel(bool listening)
diff --git a/Console/vPOD/VirtualLauncher.cs b/Console/vPOD/VirtualLauncher.cs
new file mode 100644
index 0000000..7df9171
--- /dev/null
+++ b/Console/vPOD/VirtualLauncher.cs
@@ -0,0 +1,398 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using Tesla.Net;
+
+namespace VPod;
+
+///
+/// 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 dispatches the console's
+/// ILauncherService calls onto — the vPOD equivalent of the real launcher's
+/// Service+Agent pair, minus any actual process launching.
+///
+/// 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)
+///
+internal sealed class VirtualLauncher
+{
+ private readonly object mLock = new object();
+ private readonly List mInstalledApps = new List();
+ private readonly List mLaunchedApps = new List();
+ private readonly Dictionary mInstallProgress = new Dictionary();
+ private float mVolumeLevel = 1.0f;
+ private int mNextPid = 4000;
+
+ public event Action Log;
+ public event Action AppsChanged; // installed or launched list changed
+ public event Action InstallProgressChanged;
+ public event Action ShutdownRequested; // restart?
+ public event Action ReprovisionRequested; // console cleared the store (Reconfigure)
+
+ public string DataDirectory { get; }
+ public string GamesRoot => Path.Combine(DataDirectory, "Games");
+ 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)
+ {
+ DataDirectory = dataDirectory;
+ Directory.CreateDirectory(DataDirectory);
+ LoadApps();
+ }
+
+ // ---- session key store (launcher's TeslaKeyStore.key format) ----
+
+ /// The provisioned session key, or null when unprovisioned.
+ 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
+ };
+ }
+ }
+
+ /// Registers a launch entry (upsert by LaunchKey) — the per-entry call
+ /// the console makes after a product install, and the write behind LaunchApps.json.
+ 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);
+ 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.
+ string dir = app.WorkingDirectory;
+ if (!string.IsNullOrEmpty(dir)
+ && IsUnderGamesRoot(dir)
+ && !mInstalledApps.Exists(a => string.Equals(a.WorkingDirectory, dir, StringComparison.OrdinalIgnoreCase)))
+ {
+ cleanupDir = dir;
+ }
+ }
+ 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;
+ }
+ mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey);
+ SaveApps();
+ }
+ Log?.Invoke($"RemoveApp: {launchKey} unregistered.");
+ AppsChanged?.Invoke();
+ }
+
+ public int LaunchApp(Guid launchKey)
+ {
+ LaunchData app;
+ int pid;
+ 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];
+ 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;
+ }
+
+ public void KillApp(Guid launchKey, int processId)
+ {
+ int removed;
+ lock (mLock)
+ {
+ removed = mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey && l.ProcessId == processId);
+ }
+ if (removed > 0)
+ {
+ Log?.Invoke($"KillApp: simulated PID {processId} killed.");
+ AppsChanged?.Invoke();
+ }
+ }
+
+ public void KillAllOfType(Guid launchKey)
+ {
+ int removed;
+ lock (mLock)
+ {
+ removed = mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey);
+ }
+ if (removed > 0)
+ {
+ Log?.Invoke($"KillAllOfType: {removed} simulated process(es) killed.");
+ AppsChanged?.Invoke();
+ }
+ }
+
+ public void KillAllApps()
+ {
+ int removed;
+ lock (mLock)
+ {
+ removed = mLaunchedApps.Count;
+ mLaunchedApps.Clear();
+ }
+ if (removed > 0)
+ {
+ Log?.Invoke($"KillAllApps: {removed} simulated process(es) killed.");
+ AppsChanged?.Invoke();
+ }
+ }
+
+ public void Shutdown(bool restart)
+ {
+ Log?.Invoke(restart ? "Shutdown(restart) — power-cycling the pod." : "Shutdown — powering the pod off.");
+ ShutdownRequested?.Invoke(restart);
+ }
+
+ /// 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.
+ public void ClearStore()
+ {
+ lock (mLock)
+ {
+ mInstalledApps.Clear();
+ mLaunchedApps.Clear();
+ SaveApps();
+ }
+ Log?.Invoke("ClearStore: installed apps wiped; dropping session key to re-enter provisioning.");
+ AppsChanged?.Invoke();
+ ReprovisionRequested?.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 = JsonSerializer.Deserialize(File.ReadAllText(LaunchAppsPath), PodRpc.JsonOptions);
+ if (apps != null)
+ {
+ mInstalledApps.AddRange(apps);
+ }
+ }
+ catch
+ {
+ // unreadable store: start empty, like a fresh pod
+ }
+ }
+
+ private void SaveApps()
+ {
+ try
+ {
+ File.WriteAllText(LaunchAppsPath, JsonSerializer.Serialize(mInstalledApps.ToArray(), PodRpc.JsonOptions));
+ }
+ catch (Exception ex)
+ {
+ Log?.Invoke("Could not persist LaunchApps.json: " + ex.Message);
+ }
+ }
+}
diff --git a/Console/vPOD/vPOD.csproj b/Console/vPOD/vPOD.csproj
index 1f36b8e..6efa382 100644
--- a/Console/vPOD/vPOD.csproj
+++ b/Console/vPOD/vPOD.csproj
@@ -32,6 +32,18 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -41,4 +53,14 @@
+
+
+
+
+
+