Files
TeslaSuite/vPOD/VirtualLauncher.cs
CydandClaude Fable 5 106fa610c0 vPOD: real system volume + pre-uninstall.bat, behind the existing opt-ins
Two launcher behaviors vPOD only simulated are now available for real,
matching the pod exactly:

- "Actually set system volume": set_VolumeLevel drives this machine's
  master volume through the launcher's own chain — nircmd.exe in the
  games root, else Core Audio (Vista+), else winmm. The chain moved out
  of TeslaLauncher.cs into Launcher/VolumeControl.cs and is compiled
  into both apps as linked source (the MiniZip pattern); launcher
  behavior is unchanged. Off by default: the value is stored/echoed
  only, as before.

- pre-uninstall.bat now runs before the product directory is deleted on
  UninstallApp (working dir, hidden window, 120 s wait, exit code
  logged — mirrors CleanupProductDirectory). Gated behind the renamed
  "Run package install/uninstall scripts" checkbox (was "Run
  postinstall.bat after install"; RunPostInstall -> RunPackageScripts),
  closing the asymmetry where install scripts had an opt-in but
  uninstall scripts silently never ran.

Verified: 106/106 diff tests; live master-volume set/restore through
vPOD's build of VolumeControl; functional probe of UninstallApp against
an isolated games root with the flag off (script skipped, dir removed)
and on (script ran, then dir removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:26:38 -05:00

680 lines
20 KiB
C#

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.Launcher;
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. Launch/Kill simulate PIDs by default; with
/// <see cref="RealLaunch" /> 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)
/// </summary>
internal sealed class VirtualLauncher
{
/// <summary>The launcher's GAMES_DIR. Tests override via the ctor for isolation.</summary>
public const string DefaultGamesRoot = @"C:\Games";
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<int, Process> mRealProcesses = new Dictionary<int, Process>(); // pid -> live process, RealLaunch mode
private readonly Dictionary<Guid, OutOfBandProgress> mInstallProgress = new Dictionary<Guid, OutOfBandProgress>();
private float mVolumeLevel = 1.0f;
private int mNextPid = 4000;
/// <summary>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.</summary>
public bool RealLaunch { get; set; }
/// <summary>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.</summary>
public bool RealAutoRestart { get; set; } = true;
/// <summary>When set, packaged product scripts are executed like on the real
/// pod: postinstall.bat at the end of a product install (before being
/// deleted), and pre-uninstall.bat before the product directory is removed
/// on uninstall. Off by default — both run package script code on the host
/// machine — in which case they are logged and removed unrun. Toggled from
/// the vPOD window.</summary>
public bool RunPackageScripts { get; set; }
/// <summary>When set, the console's set_VolumeLevel changes this machine's
/// REAL master volume through the launcher's own chain (nircmd.exe in the
/// games root → Core Audio → winmm), exactly like the real pod. Off by
/// default: the value is only stored and displayed. Toggled from the vPOD
/// window.</summary>
public bool RealVolume { 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<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 { 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) ----
/// <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; }
if (RealVolume)
{
// The launcher's own device chain; probes GamesRoot for
// nircmd.exe first, like the real pod's GAMES_DIR.
VolumeControl.SetMasterScalar(value, GamesRoot);
Log?.Invoke($"Volume set to {value:P0} (applied to system).");
}
else
{
Log?.Invoke($"Volume set to {value:P0} (simulated).");
}
}
}
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);
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))
{
RunPreUninstallScript(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();
}
/// <summary>The real Agent's pre-uninstall hook: a product may ship a
/// pre-uninstall.bat (e.g. RIOJoy removes its driver) that runs before its
/// directory is deleted. Executed only when <see cref="RunPackageScripts" />
/// is opted in — otherwise logged and removed unrun (it dies with the
/// directory). Mirrors the launcher's CleanupProductDirectory.</summary>
private void RunPreUninstallScript(string productDir)
{
string preUninstall = Path.Combine(productDir, "pre-uninstall.bat");
if (!File.Exists(preUninstall))
{
return;
}
if (!RunPackageScripts)
{
Log?.Invoke("UninstallApp: pre-uninstall.bat present — NOT executed (vPOD), removed with the directory.");
return;
}
Log?.Invoke("UninstallApp: running pre-uninstall.bat...");
try
{
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = preUninstall,
WorkingDirectory = productDir,
UseShellExecute = false,
CreateNoWindow = true
};
using (Process proc = Process.Start(psi))
{
if (proc != null && proc.WaitForExit(120000))
{
Log?.Invoke($"UninstallApp: pre-uninstall.bat exited with code {proc.ExitCode}.");
}
else if (proc != null)
{
Log?.Invoke("UninstallApp: pre-uninstall.bat still running after 2 min — continuing.");
}
}
}
catch (Exception ex)
{
Log?.Invoke($"UninstallApp: pre-uninstall.bat failed to run: {ex.Message}");
}
}
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;
}
/// <summary>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).</summary>
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;
}
/// <summary>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.</summary>
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<LaunchedAppData> 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<LaunchedAppData> 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();
}
}
/// <summary>Kills everything launched. <paramref name="cancelPendingRestarts" />
/// 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.</summary>
public void KillAllApps(bool cancelPendingRestarts = false)
{
if (cancelPendingRestarts)
{
Interlocked.Increment(ref mWatchdogGeneration);
}
List<LaunchedAppData> victims = RemoveLaunched(l => true);
if (victims.Count > 0)
{
int real = KillRealProcesses(victims);
Log?.Invoke($"KillAllApps: {victims.Count} process(es) killed ({real} real).");
AppsChanged?.Invoke();
}
}
/// <summary>Removes matching entries from the launched table and returns them,
/// so real processes can be killed outside the lock.</summary>
private List<LaunchedAppData> RemoveLaunched(Predicate<LaunchedAppData> match)
{
List<LaunchedAppData> removed = new List<LaunchedAppData>();
lock (mLock)
{
for (int i = mLaunchedApps.Count - 1; i >= 0; i--)
{
if (match(mLaunchedApps[i]))
{
removed.Add(mLaunchedApps[i]);
mLaunchedApps.RemoveAt(i);
}
}
}
return removed;
}
/// <summary>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.</summary>
private int KillRealProcesses(List<LaunchedAppData> 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);
}
/// <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.
/// Any real launched processes are terminated first, and pending watchdog
/// restarts cancelled.</summary>
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<LaunchData[]>(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);
}
}
}