using System; using System.Collections.Generic; using System.Diagnostics; 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. Launch/Kill simulate PIDs by default; with /// set they start and terminate real processes, /// mirroring the Agent. /// /// Persisted state: /// %LocalAppData%\vPOD\TeslaKeyStore.key session key, launcher format: [1-byte len][key] /// %LocalAppData%\vPOD\LaunchApps.json installed apps (the wire LaunchData list, PodRpc JSON) /// C:\Games\ where InstallProduct zips are extracted — the /// REAL games root, same as the launcher's GAMES_DIR, /// so deployed products land where their catalog /// launch entries point (C:\Games\...\*.exe) /// internal sealed class VirtualLauncher { /// The launcher's GAMES_DIR. Tests override via the ctor for isolation. public const string DefaultGamesRoot = @"C:\Games"; private readonly object mLock = new object(); private readonly List mInstalledApps = new List(); private readonly List mLaunchedApps = new List(); private readonly Dictionary mRealProcesses = new Dictionary(); // pid -> live process, RealLaunch mode private readonly Dictionary mInstallProgress = new Dictionary(); private float mVolumeLevel = 1.0f; private int mNextPid = 4000; /// When set, LaunchApp actually starts the entry's ExeFile (like the /// real Agent) instead of recording a simulated PID, and the Kill* commands /// terminate those processes. Toggled from the vPOD window; off by default. /// Entries launched in either mode coexist — kills handle both. public bool RealLaunch { get; set; } 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 { get; } 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, string gamesRoot = null) { DataDirectory = dataDirectory; GamesRoot = gamesRoot ?? DefaultGamesRoot; 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() { PruneExitedProcesses(); lock (mLock) { return mLaunchedApps.ToArray(); } } public FullUpdateData FullUpdate() { PruneExitedProcesses(); 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); SaveApps(); // Like the real Agent's CleanupDir: remove the product folder, but only // when it's inside our 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; } } // Kill (real or simulated) BEFORE deleting the product directory, like the // real Agent — a still-running process would hold its files locked. KillRealProcesses(RemoveLaunched(l => l.LaunchKey == launchKey)); 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; } SaveApps(); } KillRealProcesses(RemoveLaunched(l => l.LaunchKey == launchKey)); Log?.Invoke($"RemoveApp: {launchKey} unregistered."); AppsChanged?.Invoke(); } public int LaunchApp(Guid launchKey) { LaunchData app; 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]; } if (RealLaunch) { return LaunchRealProcess(app); } int pid; lock (mLock) { 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; } /// Actually starts the launch entry, mirroring the real Agent's /// CmdLaunchApp: same ProcessStartInfo shape, and the same clean error when /// the entry is registered but its files aren't installed (the console shows /// the thrown message, exactly as against a real pod). private int LaunchRealProcess(LaunchData app) { string name = app.LaunchPair.DisplayName ?? app.LaunchPair.LaunchKey.ToString(); if (string.IsNullOrWhiteSpace(app.ExeFile) || !File.Exists(app.ExeFile)) { Log?.Invoke($"LaunchApp: \"{name}\": executable not found at '{app.ExeFile}'."); throw new Exception( $"Cannot launch '{name}': executable not found at '{app.ExeFile}'. " + "The product may be registered but not yet installed on this pod."); } ProcessStartInfo psi = new ProcessStartInfo { FileName = app.ExeFile, Arguments = app.Arguments ?? "", WorkingDirectory = string.IsNullOrEmpty(app.WorkingDirectory) ? Path.GetDirectoryName(app.ExeFile) : app.WorkingDirectory, UseShellExecute = false }; Process process = Process.Start(psi) ?? throw new Exception($"Process.Start returned null for '{app.ExeFile}'"); lock (mLock) { mLaunchedApps.Add(new LaunchedAppData { ProcessId = process.Id, LaunchKey = app.LaunchPair.LaunchKey }); mRealProcesses[process.Id] = process; } Log?.Invoke($"LaunchApp: \"{name}\" -> started {app.ExeFile} (real PID {process.Id})."); AppsChanged?.Invoke(); return process.Id; } public void KillApp(Guid launchKey, int processId) { List victims = RemoveLaunched(l => l.LaunchKey == launchKey && l.ProcessId == processId); if (victims.Count > 0) { int real = KillRealProcesses(victims); Log?.Invoke($"KillApp: PID {processId} killed{(real > 0 ? "" : " (simulated)")}."); AppsChanged?.Invoke(); } } public void KillAllOfType(Guid launchKey) { List victims = RemoveLaunched(l => l.LaunchKey == launchKey); if (victims.Count > 0) { int real = KillRealProcesses(victims); Log?.Invoke($"KillAllOfType: {victims.Count} process(es) killed ({real} real)."); AppsChanged?.Invoke(); } } public void KillAllApps() { List victims = RemoveLaunched(l => true); if (victims.Count > 0) { int real = KillRealProcesses(victims); Log?.Invoke($"KillAllApps: {victims.Count} process(es) killed ({real} real)."); AppsChanged?.Invoke(); } } /// Removes matching entries from the launched table and returns them, /// so real processes can be killed outside the lock. private List RemoveLaunched(Predicate match) { List removed = new List(); lock (mLock) { for (int i = mLaunchedApps.Count - 1; i >= 0; i--) { if (match(mLaunchedApps[i])) { removed.Add(mLaunchedApps[i]); mLaunchedApps.RemoveAt(i); } } } return removed; } /// Terminates any real processes behind the given (already removed) /// entries; simulated PIDs are ignored. Waits briefly for each exit so a /// following product-directory delete doesn't race the dying process. private int KillRealProcesses(List victims) { int killed = 0; foreach (LaunchedAppData victim in victims) { Process process; lock (mLock) { if (!mRealProcesses.TryGetValue(victim.ProcessId, out process)) { continue; } mRealProcesses.Remove(victim.ProcessId); } try { if (!process.HasExited) { process.Kill(); process.WaitForExit(3000); killed++; } } catch (Exception ex) { Log?.Invoke($"Kill PID {victim.ProcessId}: {ex.Message}"); } finally { process.Dispose(); } } return killed; } /// Drops launched entries whose real process has exited on its own /// (crash or normal exit), so GetLaunchedApps/FullUpdate reflect liveness. private void PruneExitedProcesses() { int pruned = 0; lock (mLock) { for (int i = mLaunchedApps.Count - 1; i >= 0; i--) { if (!mRealProcesses.TryGetValue(mLaunchedApps[i].ProcessId, out Process process)) { continue; // simulated entry: lives until killed } bool exited; try { exited = process.HasExited; } catch { exited = true; } if (exited) { mRealProcesses.Remove(mLaunchedApps[i].ProcessId); process.Dispose(); mLaunchedApps.RemoveAt(i); pruned++; } } } if (pruned > 0) { Log?.Invoke($"{pruned} launched process(es) exited on their own."); 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() { WipeApps(); Log?.Invoke("ClearStore: installed apps wiped; dropping session key to re-enter provisioning."); ReprovisionRequested?.Invoke(); } /// Clears the installed/launched app registries (LaunchApps.json) — /// the store-wipe half of ClearStore, also used by the UI's Reprovision. /// Any real launched processes are terminated first. public void WipeApps() { KillRealProcesses(RemoveLaunched(l => true)); lock (mLock) { mInstalledApps.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(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); } } }