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; /// /// 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 ; packaged product scripts /// (postinstall.bat here, pre-uninstall.bat in UninstallApp) are logged and /// removed unrun unless is set /// from the vPOD window, in which case they run like on the real pod. /// 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 ?? "???"; List 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; } } } // 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(JToken arg) => arg.ToObject(PodRpc.JsonOptions); /// 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, List args) { switch (method) { case "Ping": return args.Count > 0 && !IsNull(args[0]) ? mLauncher.Ping(Arg(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(args[0])); case "InitiateInstallProduct": return mLauncher.InitiateInstallProduct(); case "InstallApp": mLauncher.InstallApp(Arg(args[0])); return null; case "UninstallApp": mLauncher.UninstallApp(Arg(args[0])); return null; case "RemoveApp": mLauncher.RemoveApp(Arg(args[0])); return null; case "LaunchApp": return mLauncher.LaunchApp(Arg(args[0])); case "KillApp": mLauncher.KillApp(Arg(args[0]), Arg(args[1])); return null; case "KillAllOfType": mLauncher.KillAllOfType(Arg(args[0])); return null; case "KillAllApps": mLauncher.KillAllApps(); return null; case "Shutdown": mLauncher.Shutdown(Arg(args[0])); return null; case "ClearStore": mLauncher.ClearStore(); return null; case "get_VolumeLevel": return mLauncher.VolumeLevel; case "set_VolumeLevel": mLauncher.VolumeLevel = Arg(args[0]); return null; default: Log?.Invoke($"Unknown command \"{method}\" — answering null."); return null; } } /// 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). 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 RunPackageScripts; // 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.RunPackageScripts) { 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; } }