Files
TeslaSuite/vPOD/LauncherRpcServer.cs
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

428 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using Newtonsoft.Json.Linq;
using Tesla;
using Tesla.Launcher;
using Tesla.Net;
namespace VPod;
/// <summary>
/// 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 <see cref="VirtualLauncher" />; a packaged postinstall.bat
/// is logged and removed unrun unless <see cref="VirtualLauncher.RunPostInstall" />
/// is set from the vPOD window, in which case it is executed like the real service.
/// </summary>
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<TcpClient> mClients = new List<TcpClient>();
public event Action<string> Log;
public event Action<int> ConnectionsChanged; // number of active console sessions
public bool IsListening => mRunning;
public LauncherRpcServer(VirtualLauncher launcher, int port = ManagePort)
{
mLauncher = launcher;
mPort = port;
}
/// <summary>Starts listening with the given provisioned session key. Throws if
/// the port cannot be bound (e.g. a real TeslaLauncher on the same machine).</summary>
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 ?? "???";
List<JToken> args = request.Args ?? new List<JToken>();
// 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;
}
}
}
// RPC args surface as Newtonsoft JTokens (the Contract is net40;
// System.Text.Json has no net40 target).
private static bool IsNull(JToken arg) => arg == null || arg.Type == JTokenType.Null;
private static T Arg<T>(JToken arg) => arg.ToObject<T>(PodRpc.JsonOptions);
/// <summary>Maps the console's method names (dispatch-by-name, including the
/// get_/set_ property accessors) onto the VirtualLauncher. Mirrors the real
/// service's DispatchCommandAsync.</summary>
private object Dispatch(string method, List<JToken> args)
{
switch (method)
{
case "Ping":
return args.Count > 0 && !IsNull(args[0])
? mLauncher.Ping(Arg<DateTime>(args[0]))
: 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(Arg<Guid>(args[0]));
case "InitiateInstallProduct":
return mLauncher.InitiateInstallProduct();
case "InstallApp":
mLauncher.InstallApp(Arg<LaunchData>(args[0]));
return null;
case "UninstallApp":
mLauncher.UninstallApp(Arg<Guid>(args[0]));
return null;
case "RemoveApp":
mLauncher.RemoveApp(Arg<Guid>(args[0]));
return null;
case "LaunchApp":
return mLauncher.LaunchApp(Arg<Guid>(args[0]));
case "KillApp":
mLauncher.KillApp(Arg<Guid>(args[0]), Arg<int>(args[1]));
return null;
case "KillAllOfType":
mLauncher.KillAllOfType(Arg<Guid>(args[0]));
return null;
case "KillAllApps":
mLauncher.KillAllApps();
return null;
case "Shutdown":
mLauncher.Shutdown(Arg<bool>(args[0]));
return null;
case "ClearStore":
mLauncher.ClearStore();
return null;
case "get_VolumeLevel":
return mLauncher.VolumeLevel;
case "set_VolumeLevel":
mLauncher.VolumeLevel = Arg<float>(args[0]);
return null;
default:
Log?.Invoke($"Unknown command \"{method}\" — answering null.");
return null;
}
}
/// <summary>Receives the out-of-band product zip and extracts it into the
/// 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)
{
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);
// The Launcher's own extractor (zip-slip protection included): net40
// has no ZipFile/ZipArchive, and sharing it keeps vPOD's extraction
// byte-identical to the real pod service.
MiniZip.ExtractToDirectory(tempZip, gamesRoot, (done, total) =>
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 only does so when the operator opts in via RunPostInstall;
// otherwise the script is logged and removed unrun (default), since it
// runs package code on the host machine.
string postInstall = Path.Combine(gamesRoot, "postinstall.bat");
if (File.Exists(postInstall))
{
if (mLauncher.RunPostInstall)
{
mLauncher.UpdateProgress(callId, 96, "Running postinstall...");
Log?.Invoke($"Install {callId:N}: running postinstall.bat...");
try
{
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = "/c \"" + postInstall + "\"",
WorkingDirectory = gamesRoot,
UseShellExecute = false,
CreateNoWindow = true
};
using (Process proc = Process.Start(psi))
{
if (proc.WaitForExit(300000))
{
Log?.Invoke($"Install {callId:N}: postinstall.bat exited with code {proc.ExitCode}.");
}
else
{
Log?.Invoke($"Install {callId:N}: postinstall.bat still running after 5 min — leaving it, continuing.");
}
}
}
catch (Exception ex)
{
Log?.Invoke($"Install {callId:N}: postinstall.bat failed to run: {ex.Message}");
}
}
else
{
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;
}
}