Files
TeslaSuite/Console/vPOD/VirtualLauncher.cs
T
CydandClaude Fable 5 60893172c3 vPOD: Reprovision also wipes the installed-apps store
The UI's Reprovision button only dropped the session key; the console-driven
ClearStore was the only path that cleared LaunchApps.json. Both now share
VirtualLauncher.WipeApps, so Reprovision = fresh pod (key + app registry
gone, beacon mode; extracted Games\ files stay, as on a real pod).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:47:48 -05:00

406 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()
{
WipeApps();
Log?.Invoke("ClearStore: installed apps wiped; dropping session key to re-enter provisioning.");
ReprovisionRequested?.Invoke();
}
/// <summary>Clears the installed/launched app registries (LaunchApps.json) —
/// the store-wipe half of ClearStore, also used by the UI's Reprovision.</summary>
public void WipeApps()
{
lock (mLock)
{
mInstalledApps.Clear();
mLaunchedApps.Clear();
SaveApps();
}
AppsChanged?.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);
}
}
}