Two additions to the virtual launcher's real-process mode: - Auto-restart watchdog. Replaces the poll-on-query PruneExitedProcesses with a per-process watcher thread (StartWatcher): when a real-launched app exits on its own -- not via a Kill*/Uninstall, which untrack it first -- it is dropped from the running list and, if its LaunchData has AutoRestart and the "Auto-restart after the app exits (watchdog)" toggle is on, relaunched after the Agent's 2 s delay. A watchdog generation counter cancels pending restarts when the pod goes dark (power off / reboot / reprovision / WipeApps); the console's KillAllApps leaves them pending, matching the real Agent's race. - postinstall.bat toggle. A "Run postinstall.bat after install" checkbox (above "Actually launch apps", off by default) makes an install execute a packaged postinstall.bat via cmd /c (waited up to 5 min) before deleting it, like the real service. Off, it is logged and removed unrun as before -- it runs package script code on the host. Both are opt-in from the vPOD window. Verified against the real LauncherRpcServer over a loopback socket: the watchdog test relaunches an exited ping.exe with a new PID and stops once toggled off; a crafted package's postinstall.bat runs (and is removed) only when enabled. Full differential suite 103/103. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
611 lines
18 KiB
C#
611 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
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, 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.</summary>
|
|
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<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; }
|
|
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);
|
|
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;
|
|
}
|
|
|
|
/// <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();
|
|
|
|
int generation = Volatile.Read(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 != Volatile.Read(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 = 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);
|
|
}
|
|
}
|
|
}
|