- InstallProduct now extracts into the real C:\Games (the launcher's GAMES_DIR) so deployed products land where their catalog launch entries point; uninstall removes the real product folder. Tests pass an isolated games root through the VirtualLauncher ctor. - New "Actually launch apps (real processes)" toggle (off by default = simulated PIDs): LaunchApp starts the entry's exe exactly like the Agent (same start info, same registered-but-not-installed error), Kill*/ Uninstall/Wipe terminate the real processes (kill before folder delete), and self-exited apps are pruned from GetLaunchedApps/FullUpdate. Real processes die with the machine: power off, reboot, or closing vPOD. The Agent's autoRestart watchdog is deliberately not emulated. - Pod Power and Mimicking Game groups swapped (game left, power right). - Provisioning round-trip test now skips as inconclusive when a running TeslaConsole holds UDP 53291 instead of failing the suite; two new tests cover real launch/kill (ping.exe) and the missing-exe error path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
404 lines
11 KiB
C#
404 lines
11 KiB
C#
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;
|
|
|
|
/// <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" />; unlike the real service,
|
|
/// postinstall.bat from a package is logged but never executed.
|
|
/// </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 ?? "???";
|
|
IReadOnlyList<JsonElement> args = request.Args ?? new List<JsonElement>();
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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, IReadOnlyList<JsonElement> 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<LaunchData>(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;
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
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;
|
|
}
|
|
}
|