vPOD now also impersonates the pod's TeslaLauncher service, so the console's Manage Site works against it unmodified: - LauncherRpcServer: ILauncherService over OFB + framed JSON on TCP 53290, mirroring TeslaLauncherService (concurrent sessions, out-of-band install zip on a second connection, the 99%-not-100 completion convention). Packages extract to %LocalAppData%\vPOD\Games; postinstall.bat is logged but never executed. - PodProvisioning: pod side of SecureConfig (RQST beacon, RPLY decrypt, RSA session-key exchange), display-only — never touches the NIC/registry. The console's Configure flow mints the key exactly as for a real pod; console Reconfigure (ClearStore) drops the key and re-enters beacon mode. - VirtualLauncher: installed-app registry (persisted), simulated launch PIDs, volume, install progress; console Shutdown/Restart power-cycles the pod. - Form gets a Launcher/Site Management column (passcode display, RPC status, install progress, app list, Reprovision); Power Off darkens the launcher side too; new -nomanage flag disables it. vPOD references the shared Tesla.Contract/Tesla.SecureConfig projects (server side of the existing contract only, no new RPCs). Loopback tests drive the real PodManagerConnection and PodConfigurationServer against the new code (VPodLauncherServerTests, VPodProvisioningTests) — suite now 99 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
399 lines
10 KiB
C#
399 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text.Json;
|
|
using Tesla.Net;
|
|
|
|
namespace VPod;
|
|
|
|
/// <summary>
|
|
/// The state behind vPOD's virtual TeslaLauncher service: the installed-app
|
|
/// registry, the (simulated) launched-process table, the volume level, the
|
|
/// install-progress map and the session-key store. This is the pod-side model
|
|
/// that <see cref="LauncherRpcServer" /> dispatches the console's
|
|
/// ILauncherService calls onto — the vPOD equivalent of the real launcher's
|
|
/// Service+Agent pair, 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)
|
|
/// </summary>
|
|
internal sealed class VirtualLauncher
|
|
{
|
|
private readonly object mLock = new object();
|
|
private readonly List<LaunchData> mInstalledApps = new List<LaunchData>();
|
|
private readonly List<LaunchedAppData> mLaunchedApps = new List<LaunchedAppData>();
|
|
private readonly Dictionary<Guid, OutOfBandProgress> mInstallProgress = new Dictionary<Guid, OutOfBandProgress>();
|
|
private float mVolumeLevel = 1.0f;
|
|
private int mNextPid = 4000;
|
|
|
|
public event Action<string> Log;
|
|
public event Action AppsChanged; // installed or launched list changed
|
|
public event Action<Guid, OutOfBandProgress> InstallProgressChanged;
|
|
public event Action<bool> ShutdownRequested; // restart?
|
|
public event Action ReprovisionRequested; // console cleared the store (Reconfigure)
|
|
|
|
public string DataDirectory { get; }
|
|
public string GamesRoot => 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) ----
|
|
|
|
/// <summary>The provisioned session key, or null when unprovisioned.</summary>
|
|
public byte[] LoadKey()
|
|
{
|
|
try
|
|
{
|
|
if (!File.Exists(KeyFilePath))
|
|
{
|
|
return null;
|
|
}
|
|
byte[] raw = File.ReadAllBytes(KeyFilePath);
|
|
if (raw.Length < 2 || raw.Length != raw[0] + 1)
|
|
{
|
|
return null;
|
|
}
|
|
byte[] key = new byte[raw[0]];
|
|
Buffer.BlockCopy(raw, 1, key, 0, key.Length);
|
|
return key;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public void SaveKey(byte[] key)
|
|
{
|
|
byte[] raw = new byte[key.Length + 1];
|
|
raw[0] = (byte)key.Length;
|
|
key.CopyTo(raw, 1);
|
|
File.WriteAllBytes(KeyFilePath, raw);
|
|
}
|
|
|
|
public void DeleteKey()
|
|
{
|
|
try { File.Delete(KeyFilePath); } catch { }
|
|
}
|
|
|
|
// ---- ILauncherService state ----
|
|
|
|
public DateTime Ping(DateTime now)
|
|
{
|
|
return now;
|
|
}
|
|
|
|
public float VolumeLevel
|
|
{
|
|
get { lock (mLock) { return mVolumeLevel; } }
|
|
set
|
|
{
|
|
lock (mLock) { mVolumeLevel = value; }
|
|
Log?.Invoke($"Volume set to {value:P0}.");
|
|
}
|
|
}
|
|
|
|
public LaunchData[] GetInstalledApps()
|
|
{
|
|
lock (mLock) { return mInstalledApps.ToArray(); }
|
|
}
|
|
|
|
public LaunchPair[] GetLaunchableApps()
|
|
{
|
|
lock (mLock)
|
|
{
|
|
LaunchPair[] pairs = new LaunchPair[mInstalledApps.Count];
|
|
for (int i = 0; i < mInstalledApps.Count; i++)
|
|
{
|
|
pairs[i] = mInstalledApps[i].LaunchPair;
|
|
}
|
|
return pairs;
|
|
}
|
|
}
|
|
|
|
public LaunchedAppData[] GetLaunchedApps()
|
|
{
|
|
lock (mLock) { return mLaunchedApps.ToArray(); }
|
|
}
|
|
|
|
public FullUpdateData FullUpdate()
|
|
{
|
|
lock (mLock)
|
|
{
|
|
return new FullUpdateData
|
|
{
|
|
InstalledApps = mInstalledApps.ToArray(),
|
|
LaunchedApps = mLaunchedApps.ToArray(),
|
|
VolumeLevel = mVolumeLevel
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>Registers a launch entry (upsert by LaunchKey) — the per-entry call
|
|
/// the console makes after a product install, and the write behind LaunchApps.json.</summary>
|
|
public void InstallApp(LaunchData data)
|
|
{
|
|
lock (mLock)
|
|
{
|
|
int existing = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == data.LaunchPair.LaunchKey);
|
|
if (existing >= 0)
|
|
{
|
|
mInstalledApps[existing] = data;
|
|
}
|
|
else
|
|
{
|
|
mInstalledApps.Add(data);
|
|
}
|
|
SaveApps();
|
|
}
|
|
Log?.Invoke($"InstallApp: \"{data.LaunchPair.DisplayName}\" -> {data.ExeFile} {data.Arguments}");
|
|
AppsChanged?.Invoke();
|
|
}
|
|
|
|
public void UninstallApp(Guid launchKey)
|
|
{
|
|
string cleanupDir = null;
|
|
string name = null;
|
|
lock (mLock)
|
|
{
|
|
int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == launchKey);
|
|
if (index < 0)
|
|
{
|
|
return;
|
|
}
|
|
LaunchData app = mInstalledApps[index];
|
|
name = app.LaunchPair.DisplayName;
|
|
mInstalledApps.RemoveAt(index);
|
|
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);
|
|
}
|
|
|
|
/// <summary>The console's Reconfigure calls this before deleting the pod from the
|
|
/// site. Wipe the store AND drop the session key: the console no longer holds it,
|
|
/// so re-entering provisioning (beacon mode) is what makes Reconfigure completable
|
|
/// without the real pod's reboot-with-DHCP step.</summary>
|
|
public void ClearStore()
|
|
{
|
|
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<LaunchData[]>(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);
|
|
}
|
|
}
|
|
}
|