using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; // No System.Text.Json on net40 — persistence goes through Newtonsoft, which // writes the same JSON shape (public fields, PascalCase; see PodRpcProtocol). using Newtonsoft.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; } /// The Agent's autoRestart watchdog for real-launched apps: when a /// tracked process exits on its own (not via a Kill* command), relaunch it /// after 2 s — but only for entries whose LaunchData.AutoRestart is set, /// exactly like the real pod. Toggled from the vPOD window. public bool RealAutoRestart { get; set; } = true; /// When set, a packaged postinstall.bat is executed at the end of a /// product install (like the real Agent) before being deleted, instead of /// being logged and removed unrun. Toggled from the vPOD window; off by /// default, since it runs package script code on the host machine. public bool RunPostInstall { get; set; } // Bumped to cancel watchdog restarts pending in their 2 s delay — the pod // "machine" went dark (power off / reboot / reprovision), so nothing may // relaunch after it. private int mWatchdogGeneration; 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() { 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); 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; } StartWatcher(app, process, process.Id); Log?.Invoke($"LaunchApp: \"{name}\" -> started {app.ExeFile} (real PID {process.Id})."); AppsChanged?.Invoke(); return process.Id; } /// The Agent's per-process watcher: waits for exit, and if the process /// is STILL TRACKED (Kill*/Uninstall remove it from the table first, so a /// console-ordered kill never restarts), drops it from the running list and — /// when the watchdog toggle and the entry's AutoRestart both apply — relaunches /// it after the Agent's 2 s delay. private void StartWatcher(LaunchData app, Process process, int pid) { string name = app.LaunchPair.DisplayName ?? app.LaunchPair.LaunchKey.ToString(); new Thread(() => { try { process.WaitForExit(); } catch { /* handle disposed by a Kill* — it is no longer tracked */ } bool stillTracked; lock (mLock) { stillTracked = mRealProcesses.TryGetValue(pid, out Process tracked) && ReferenceEquals(tracked, process); if (stillTracked) { mRealProcesses.Remove(pid); mLaunchedApps.RemoveAll(l => l.ProcessId == pid); process.Dispose(); } } if (!stillTracked) { return; // killed via the console / power-off; whoever killed it cleaned up } AppsChanged?.Invoke(); // Thread.VolatileRead, not Volatile.Read: the latter is net45+. int generation = Thread.VolatileRead(ref mWatchdogGeneration); if (!RealAutoRestart || !app.AutoRestart) { Log?.Invoke($"\"{name}\" (PID {pid}) exited on its own (no watchdog restart)."); return; } Log?.Invoke($"\"{name}\" (PID {pid}) exited on its own — watchdog restarting in 2 s..."); Thread.Sleep(2000); // Still wanted? The pod may have powered off/reprovisioned (generation), // the mode or toggle flipped, or the app been uninstalled meanwhile. if (generation != Thread.VolatileRead(ref mWatchdogGeneration) || !RealLaunch || !RealAutoRestart) { return; } LaunchData current; lock (mLock) { int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == app.LaunchPair.LaunchKey); if (index < 0) { return; // uninstalled during the delay } current = mInstalledApps[index]; } try { LaunchRealProcess(current); } catch (Exception ex) { Log?.Invoke($"Watchdog restart of \"{name}\" failed: {ex.Message}"); } }) { IsBackground = true, Name = $"vPOD-watchdog-{pid}" }.Start(); } 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(); } } /// Kills everything launched. /// is the pod-power path (off/reboot/exit): it also aborts watchdog restarts /// waiting out their 2 s delay. The console's KillAllApps RPC leaves them /// pending, matching the real Agent's race behavior. public void KillAllApps(bool cancelPendingRestarts = false) { if (cancelPendingRestarts) { Interlocked.Increment(ref mWatchdogGeneration); } 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; } 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, and pending watchdog /// restarts cancelled. public void WipeApps() { Interlocked.Increment(ref mWatchdogGeneration); 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 = JsonConvert.DeserializeObject(File.ReadAllText(LaunchAppsPath)); if (apps != null) { mInstalledApps.AddRange(apps); } } catch { // unreadable store: start empty, like a fresh pod } } private void SaveApps() { try { File.WriteAllText(LaunchAppsPath, JsonConvert.SerializeObject(mInstalledApps.ToArray())); } catch (Exception ex) { Log?.Invoke("Could not persist LaunchApps.json: " + ex.Message); } } }