using System; using System.Collections.Generic; using System.IO; using System.Text.Json; using Tesla.Net; namespace VPod; /// /// 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 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) /// internal sealed class VirtualLauncher { private readonly object mLock = new object(); private readonly List mInstalledApps = new List(); private readonly List mLaunchedApps = new List(); private readonly Dictionary mInstallProgress = new Dictionary(); private float mVolumeLevel = 1.0f; private int mNextPid = 4000; public event Action Log; public event Action AppsChanged; // installed or launched list changed public event Action InstallProgressChanged; public event Action 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) ---- /// The provisioned session key, or null when unprovisioned. 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 }; } } /// Registers a launch entry (upsert by LaunchKey) — the per-entry call /// the console makes after a product install, and the write behind LaunchApps.json. 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); } /// 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. 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(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); } } }