Files
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

1185 lines
47 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================================
// TeslaLauncher — XP11 single-binary launcher (tray app, user session)
// =============================================================================
// One userland process replaces the TeslaLauncherService (Session 0) +
// TeslaLauncherAgent (tray) pair. The split existed only to work around
// Vista+ Session 0 isolation; running everything in the auto-logged-in user
// session needs no service, no named pipe, and no flat<->wire conversions.
// The original Elsewhen software was likewise a single binary (a service —
// which could still touch the desktop on Win2k/XP).
//
// Targets net40 so the SAME exe runs on Windows XP SP3 (newest framework XP
// can install) and on Windows 10/11 (net40 loads in-place on the 4.8 runtime).
//
// Responsibilities:
// - Listen on TCP 53290 for OFB-encrypted framed-JSON RPC from TeslaConsole
// - First boot (DHCP): run SecureConfig, show Request ID + Passphrase
// - Install products (receive zip, extract to C:\Games, postinstall.bat)
// - Launch/kill/watch simulation apps, volume, LaunchApps.xml registry
//
// Command line:
// /skipconfig skip the DHCP SecureConfig gate (bench testing)
// /port:NNNN listen on a non-standard port (bench testing)
// =============================================================================
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.IO.IsolatedStorage;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Windows.Forms;
using Newtonsoft.Json.Linq;
using TeslaSecureConfig;
using Wire = Tesla.Net;
namespace Tesla.Launcher
{
public class LauncherApplication : Form
{
// ── Configuration ─────────────────────────────────────────────────────
private const int DEFAULT_PORT = 53290;
private const string GAMES_DIR = @"C:\Games";
// CommonApplicationData differs by OS (XP: ...\All Users\Application Data,
// Vista+: C:\ProgramData) — never hardcode C:\ProgramData here.
private static readonly string DATA_DIR = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"TeslaLauncher");
private static readonly string CONFIG_FILE = Path.Combine(DATA_DIR, "LaunchApps.xml");
private static readonly string KEY_FILE = Path.Combine(DATA_DIR, "TeslaKeyStore.key");
private static readonly string LOG_FILE = Path.Combine(DATA_DIR, "podconf.log");
private static bool _skipConfigGate;
private static int _port = DEFAULT_PORT;
// ── State ─────────────────────────────────────────────────────────────
private NotifyIcon _trayIcon;
private ContextMenuStrip _trayMenu;
private List<LaunchData> _launchApps = new List<LaunchData>();
private readonly Dictionary<string, List<Process>> _runningProcesses =
new Dictionary<string, List<Process>>();
private readonly object _processLock = new object();
private volatile bool _stopping;
private readonly Dictionary<Guid, Wire.OutOfBandProgress> _installProgress =
new Dictionary<Guid, Wire.OutOfBandProgress>();
private readonly object _installLock = new object();
private byte[] _sessionKey;
private TcpListener _listener;
// ── Entry point ───────────────────────────────────────────────────────
[STAThread]
public static void Main(string[] args)
{
bool createdNew;
using (new Mutex(true, "TeslaLauncherSingleInstance", out createdNew))
{
if (!createdNew) return; // already running
foreach (var a in args ?? new string[0])
{
if (string.Equals(a, "/skipconfig", StringComparison.OrdinalIgnoreCase))
_skipConfigGate = true;
else if (a.StartsWith("/port:", StringComparison.OrdinalIgnoreCase))
{
int p;
if (int.TryParse(a.Substring(6), out p) && p > 0 && p < 65536)
_port = p;
}
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new LauncherApplication());
}
}
public LauncherApplication()
{
ShowInTaskbar = false;
WindowState = FormWindowState.Minimized;
Opacity = 0;
BuildTrayIcon();
LoadLaunchApps();
var orchestrator = new Thread(OrchestratorLoop)
{
IsBackground = true,
Name = "Orchestrator"
};
orchestrator.Start();
}
// ── Orchestrator: SecureConfig gate → session key → command listener ──
private void OrchestratorLoop()
{
Log("=== Launcher starting (single-binary) ===");
TryRegisterApplicationRestart();
LogNicState();
if (!_skipConfigGate && !IsMachineConfigured())
{
Log("IsMachineConfigured=false -> running SecureConfig");
SetTrayStatus("Configuring...");
RunSecureConfiguration();
}
_sessionKey = LoadSessionKey();
if (_sessionKey == null)
{
Log("ERROR: TeslaKeyStore.key not found - cannot start command listener. " +
"Reboot with DHCP to re-run SecureConfig.");
SetTrayStatus("Not configured (no session key)");
return;
}
Log(string.Format("Session key loaded ({0} bytes) -> starting command listener on port {1}",
_sessionKey.Length, _port));
ListenerLoop();
}
private void RunSecureConfiguration()
{
try
{
using (var configurator = new PodSecureConfigurator(
adapterName: null, logger: Log))
{
// 10-minute timeout — rebroadcasts automatically every 2 s.
var config = configurator.Configure(timeoutMs: 600_000);
if (config == null)
{
Log("SecureConfiguration timed out or failed. Reboot to retry.");
SetTrayStatus("Configuration failed - reboot to retry");
return;
}
Log("SecureConfiguration complete.");
}
}
catch (Exception ex)
{
Log("SecureConfiguration error: " + ex.Message);
}
}
// ── TCP command listener (Console connections) ────────────────────────
private void ListenerLoop()
{
try
{
_listener = new TcpListener(IPAddress.Any, _port);
_listener.Start();
}
catch (SocketException ex)
{
Log(string.Format("Cannot listen on port {0}: {1}", _port, ex.Message));
SetTrayStatus("Port " + _port + " unavailable");
return;
}
SetTrayStatus(_launchApps.Count + " apps configured");
Log("Listening. Waiting for Console connections...");
while (!_stopping)
{
TcpClient client;
try
{
client = _listener.AcceptTcpClient();
}
catch (Exception)
{
if (_stopping) break;
Thread.Sleep(500);
continue;
}
Log("Console connected from " + client.Client.RemoteEndPoint);
var t = new Thread(() => HandleConsoleClient(client))
{
IsBackground = true,
Name = "ConsoleClient"
};
t.Start();
}
try { _listener.Stop(); } catch { }
Log("Command listener stopped.");
}
private void HandleConsoleClient(TcpClient tcpClient)
{
try
{
using (tcpClient)
using (var netStream = tcpClient.GetStream())
{
netStream.WriteTimeout = 10_000;
netStream.ReadTimeout = 30_000;
// ── OFB crypto negotiation ────────────────────────────────
// Both sides exchange 16-byte IVs, then verify "CONF".
var consoleIv = new byte[16];
ReadExactSync(netStream, consoleIv);
var podIv = new byte[16];
using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(podIv);
netStream.Write(podIv, 0, 16);
netStream.Flush();
using (var ofb = new OFBDuplexStream(netStream, _sessionKey,
writeIv: consoleIv, // pod→console (other's IV)
readIv: podIv)) // console→pod (own IV)
{
var confMsg = Encoding.UTF8.GetBytes("CONF");
ofb.Write(confMsg, 0, confMsg.Length);
ofb.Flush();
var confReply = new byte[4];
ReadExactSync(ofb, confReply);
if (!confReply.SequenceEqual(confMsg))
{
Log("CONF mismatch - session key mismatch, dropping connection.");
return;
}
RpcSessionLoop(ofb, tcpClient);
}
}
}
catch (IOException) { /* console went away mid-handshake */ }
catch (Exception ex)
{
Log(string.Format("Console handler error ({0}): {1}", ex.GetType().Name, ex.Message));
}
Log("Console session ended.");
}
private void RpcSessionLoop(Stream rpcStream, TcpClient client)
{
while (!_stopping && client.Connected)
{
Wire.RpcRequest request;
try
{
request = Wire.PodRpc.ReadRequest(rpcStream);
}
catch (EndOfStreamException) { break; }
catch (IOException) { break; }
catch (Exception ex)
{
Log(string.Format("Request read error ({0}): {1}", ex.GetType().Name, ex.Message));
break;
}
if (request == null) break;
var funcName = request.Method ?? "???";
var args = request.Args ?? new List<JToken>();
var start = DateTime.UtcNow;
object resultObj = null;
string error = null;
try
{
resultObj = DispatchCommand(funcName, args);
}
catch (Exception ex)
{
error = ex.Message;
Log(string.Format("CMD {0} ERROR: {1}", funcName, ex.Message));
}
if (error == null)
Log(string.Format("CMD {0} OK ({1:F0}ms)", funcName,
(DateTime.UtcNow - start).TotalMilliseconds));
try
{
Wire.PodRpc.WriteResponse(rpcStream, resultObj, error);
}
catch (IOException) { break; }
catch (Exception serEx)
{
Log(string.Format("Serialize failed for {0}: {1}", funcName, serEx.Message));
try { Wire.PodRpc.WriteResponse(rpcStream, null, "Pod error: " + serEx.Message); }
catch { break; }
}
// ── File receive after InitiateInstallProduct ────────────────
// The Console streams the game archive on this same OFB stream
// right after the Guid response: 8-byte Int64 size + raw bytes.
if (funcName == "InitiateInstallProduct" && error == null && resultObj is Guid)
{
ReceiveInstallFile(rpcStream, (Guid)resultObj);
break; // connection done after file transfer
}
}
}
// ── Command dispatch ──────────────────────────────────────────────────
// Arguments arrive as JTokens (Newtonsoft leg of PodRpc); results are
// wire types serialized straight into the response — no IPC hop.
private object DispatchCommand(string functionName, List<JToken> args)
{
switch (functionName)
{
case "Ping":
// Echo the DateTime argument byte-identically (the request
// reader keeps date strings raw for exactly this purpose).
return HasArg(args, 0) ? (object)args[0] : DateTime.Now;
case "GetLaunchableApps":
{
lock (_processLock)
return _launchApps.Select(a => new Wire.LaunchPair
{
LaunchKey = ParseGuid(a.LaunchKey),
DisplayName = a.DisplayName
}).ToArray();
}
case "GetInstalledApps":
lock (_processLock)
return _launchApps.Select(ToWire).ToArray();
case "GetLaunchedApps":
return SnapshotLaunchedApps();
case "FullUpdate":
{
var launched = SnapshotLaunchedApps();
lock (_processLock)
return new Wire.FullUpdateData
{
InstalledApps = _launchApps.Select(ToWire).ToArray(),
LaunchedApps = launched,
VolumeLevel = GetMasterVolume()
};
}
case "get_VolumeLevel":
return GetMasterVolume();
case "set_VolumeLevel":
SetMasterVolume(Math.Max(0f, Math.Min(1f, ArgFloat(args, 0, 1.0f))));
return null;
case "LaunchApp":
return CmdLaunchApp(ArgGuid(args, 0));
case "KillApp":
CmdKillApp(ArgGuid(args, 0), ArgIntOrNull(args, 1));
return null;
case "KillAllOfType":
CmdKillAllOfType(ArgGuid(args, 0));
return null;
case "KillAllApps":
CmdKillAllApps();
return null;
case "Shutdown":
CmdShutdown(ArgBool(args, 0));
return null;
case "ClearStore":
CmdClearStore();
return null;
case "RemoveApp":
CmdRemoveApp(ArgGuid(args, 0));
return null;
case "InstallApp":
{
if (!HasArg(args, 0)) throw new Exception("InstallApp requires LaunchData");
CmdInstallApp(args[0].ToObject<Wire.LaunchData>());
return null;
}
case "UninstallApp":
CmdUninstallApp(ArgGuid(args, 0));
return null;
case "InitiateInstallProduct":
{
var callId = Guid.NewGuid();
lock (_installLock)
_installProgress[callId] = new Wire.OutOfBandProgress
{
PercentComplete = 0,
Status = "Waiting for file...",
IsCompleted = false
};
return callId;
}
case "GetOutOfBandProgress":
{
var id = ArgGuid(args, 0);
lock (_installLock)
{
Wire.OutOfBandProgress prog;
if (_installProgress.TryGetValue(id, out prog)) return prog;
}
return new Wire.OutOfBandProgress
{
PercentComplete = 0,
Status = "Unknown call ID",
IsCompleted = true
};
}
default:
throw new Exception("Unknown command: " + functionName);
}
}
// ── Command implementations ───────────────────────────────────────────
private int CmdLaunchApp(Guid launchKey)
{
var key = launchKey.ToString();
var app = FindApp(key);
if (app == null)
throw new Exception("No app configured for key '" + key + "'");
// A launch entry can be registered (pushed from the Console) before its
// game files are installed. Fail cleanly with an actionable message.
if (string.IsNullOrEmpty(app.ExeFile) || !File.Exists(app.ExeFile))
throw new Exception(string.Format(
"Cannot launch '{0}': executable not found at '{1}'. The product may be " +
"registered but not yet installed on this pod.",
app.DisplayName ?? key, app.ExeFile));
var psi = new ProcessStartInfo
{
FileName = app.ExeFile,
Arguments = app.Arguments ?? "",
WorkingDirectory = string.IsNullOrEmpty(app.WorkingDirectory)
? Path.GetDirectoryName(app.ExeFile)
: app.WorkingDirectory,
UseShellExecute = false
};
var process = Process.Start(psi);
if (process == null)
throw new Exception("Process.Start returned null for '" + app.ExeFile + "'");
lock (_processLock)
{
List<Process> procs;
if (!_runningProcesses.TryGetValue(app.LaunchKey, out procs))
_runningProcesses[app.LaunchKey] = procs = new List<Process>();
procs.Add(process);
}
if (app.AutoRestart) StartWatcher(app, process);
SetTrayStatus("Running: " + (app.DisplayName ?? key));
return process.Id;
}
private void CmdKillApp(Guid launchKey, int? pid)
{
var key = FindKeyString(launchKey);
lock (_processLock)
{
List<Process> procs;
if (key == null || !_runningProcesses.TryGetValue(key, out procs)) return;
var toKill = pid.HasValue
? procs.Where(p => p.Id == pid.Value).ToList()
: procs.ToList();
foreach (var p in toKill)
{
try { if (!p.HasExited) p.Kill(); } catch { }
procs.Remove(p);
}
if (procs.Count == 0) _runningProcesses.Remove(key);
}
}
private void CmdKillAllOfType(Guid launchKey)
{
var key = FindKeyString(launchKey);
lock (_processLock)
{
List<Process> procs;
if (key == null || !_runningProcesses.TryGetValue(key, out procs)) return;
foreach (var p in procs)
try { if (!p.HasExited) p.Kill(); } catch { }
_runningProcesses.Remove(key);
}
}
private void CmdKillAllApps()
{
lock (_processLock)
{
foreach (var kvp in _runningProcesses)
foreach (var p in kvp.Value)
try { if (!p.HasExited) p.Kill(); } catch { }
_runningProcesses.Clear();
}
SetTrayStatus("All apps stopped");
}
private void CmdShutdown(bool restart)
{
CmdKillAllApps();
var flag = restart ? "/r" : "/s";
new Thread(() =>
{
Thread.Sleep(2000);
Process.Start("shutdown", flag + " /t 0");
})
{ IsBackground = true }.Start();
}
private void CmdClearStore()
{
var store = IsolatedStorageFile.GetMachineStoreForAssembly();
foreach (var file in store.GetFileNames("*"))
store.DeleteFile(file);
LoadLaunchApps();
}
private void CmdRemoveApp(Guid launchKey)
{
var key = launchKey.ToString();
lock (_processLock)
_launchApps.RemoveAll(a =>
string.Equals(a.LaunchKey, key, StringComparison.OrdinalIgnoreCase));
SaveLaunchApps();
}
private void CmdInstallApp(Wire.LaunchData data)
{
var entry = FromWire(data);
lock (_processLock)
{
var existing = _launchApps.FindIndex(a =>
string.Equals(a.LaunchKey, entry.LaunchKey, StringComparison.OrdinalIgnoreCase));
if (existing >= 0) _launchApps[existing] = entry;
else _launchApps.Add(entry);
}
SaveLaunchApps();
}
private void CmdUninstallApp(Guid launchKey)
{
CmdKillAllOfType(launchKey);
var key = launchKey.ToString();
LaunchData removed;
string cleanupDir = null;
lock (_processLock)
{
removed = _launchApps.FirstOrDefault(a =>
string.Equals(a.LaunchKey, key, StringComparison.OrdinalIgnoreCase));
_launchApps.RemoveAll(a =>
string.Equals(a.LaunchKey, key, StringComparison.OrdinalIgnoreCase));
// If the removed app's product directory under C:\Games is no longer
// used by any remaining entry, clean it up (pre-uninstall.bat + delete).
// The orphan check keeps a product with several launch entries sharing
// one folder (e.g. Red Planet's GameClient/LC/MR) intact until its
// LAST entry is removed.
if (removed != null)
{
var dir = ProductDirUnderGames(removed.WorkingDirectory)
?? ProductDirUnderGames(removed.ExeFile);
if (dir != null && !_launchApps.Any(a => string.Equals(
ProductDirUnderGames(a.WorkingDirectory) ?? ProductDirUnderGames(a.ExeFile),
dir, StringComparison.OrdinalIgnoreCase)))
cleanupDir = dir;
}
}
SaveLaunchApps();
// Physical cleanup can exceed the Console's RPC timeout — background it.
if (cleanupDir != null)
new Thread(() => CleanupProductDirectory(cleanupDir))
{ IsBackground = true }.Start();
}
// ── Install product: file receive + extract ──────────────────────────
private void ReceiveInstallFile(Stream stream, Guid callId)
{
string tempZip = null;
try
{
// ── Phase 1: Receive file ────────────────────────────────────
var sizeBuf = new byte[8];
ReadExactSync(stream, sizeBuf);
long fileSize = BitConverter.ToInt64(sizeBuf, 0);
Log(string.Format("INSTALL {0:N}: receiving {1:N0} bytes...", callId, fileSize));
UpdateProgress(callId, 0, "Receiving file...");
Directory.CreateDirectory(DATA_DIR);
tempZip = Path.Combine(DATA_DIR, string.Format("install_{0:N}.zip", callId));
using (var fs = File.Create(tempZip))
{
var buffer = new byte[65536];
long received = 0;
while (received < fileSize)
{
var toRead = (int)Math.Min(buffer.Length, fileSize - received);
var n = stream.Read(buffer, 0, toRead);
if (n == 0) throw new IOException("Connection closed during file transfer.");
fs.Write(buffer, 0, n);
received += n;
UpdateProgress(callId, (int)(received * 50 / fileSize), "Receiving file...");
}
}
Log(string.Format("INSTALL {0:N}: file received ({1:N0} bytes)", callId, fileSize));
// ── Phase 2: Extract to C:\Games (MiniZip: net40 has no ZipFile) ─
UpdateProgress(callId, 50, "Extracting...");
Directory.CreateDirectory(GAMES_DIR);
MiniZip.ExtractToDirectory(tempZip, GAMES_DIR,
(done, total) => UpdateProgress(callId,
50 + done * 45 / Math.Max(total, 1), "Extracting..."));
Log(string.Format("INSTALL {0:N}: extracted to {1}", callId, GAMES_DIR));
// ── Phase 3: Run postinstall.bat if present ──────────────────
var postInstall = Path.Combine(GAMES_DIR, "postinstall.bat");
if (File.Exists(postInstall))
{
UpdateProgress(callId, 96, "Running postinstall...");
Log(string.Format("INSTALL {0:N}: running postinstall.bat", callId));
try
{
using (var proc = Process.Start(new ProcessStartInfo
{
FileName = postInstall,
WorkingDirectory = GAMES_DIR,
UseShellExecute = false,
CreateNoWindow = true
}))
{
if (proc != null) proc.WaitForExit(60_000);
}
}
catch (Exception ex)
{
Log(string.Format("INSTALL {0:N}: postinstall.bat error: {1}", callId, ex.Message));
}
try { File.Delete(postInstall); } catch { }
}
// Console checks PercentComplete == 99 to break out of its
// 3-iteration install loop (InstallProductWorker in PodInfo).
UpdateProgress(callId, 99, "Complete", isCompleted: true);
Log(string.Format("INSTALL {0:N}: install complete", callId));
}
catch (Exception ex)
{
UpdateProgress(callId, 0, "Failed: " + ex.Message, isCompleted: true);
Log(string.Format("INSTALL {0:N}: FAILED: {1}", callId, ex.Message));
}
finally
{
try { if (tempZip != null) File.Delete(tempZip); } catch { }
}
}
private void UpdateProgress(Guid callId, int pct, string status, bool isCompleted = false)
{
lock (_installLock)
_installProgress[callId] = new Wire.OutOfBandProgress
{
PercentComplete = pct,
Status = status,
IsCompleted = isCompleted
};
}
// ── Product uninstall cleanup ─────────────────────────────────────────
// Run the product's pre-uninstall.bat if it ships one (e.g. RIOJoy removes
// ViGEmBus + config), then delete the C:\Games\<product> directory.
private void CleanupProductDirectory(string productDir)
{
if (string.IsNullOrEmpty(productDir)) return;
string full;
try { full = Path.GetFullPath(productDir); } catch { return; }
// Safety: only ever remove a proper subdirectory of C:\Games.
var gamesRoot = Path.GetFullPath(GAMES_DIR).TrimEnd('\\');
var target = full.TrimEnd('\\');
if (!target.StartsWith(gamesRoot + "\\", StringComparison.OrdinalIgnoreCase)
|| target.Equals(gamesRoot, StringComparison.OrdinalIgnoreCase))
{
Log("UNINSTALL: refusing to clean unsafe path '" + full + "'.");
return;
}
if (!Directory.Exists(full)) { Log("UNINSTALL: " + full + " already absent."); return; }
var preUninstall = Path.Combine(full, "pre-uninstall.bat");
if (File.Exists(preUninstall))
{
Log("UNINSTALL: running " + preUninstall);
try
{
using (var proc = Process.Start(new ProcessStartInfo
{
FileName = preUninstall,
WorkingDirectory = full,
UseShellExecute = false,
CreateNoWindow = true
}))
{
if (proc != null)
{
proc.WaitForExit(120_000);
Log("UNINSTALL: pre-uninstall.bat exit code " + proc.ExitCode);
}
}
}
catch (Exception ex) { Log("UNINSTALL: pre-uninstall.bat error: " + ex.Message); }
}
for (int attempt = 1; attempt <= 3; attempt++)
{
try
{
Directory.Delete(full, recursive: true);
Log("UNINSTALL: removed " + full);
return;
}
catch (Exception ex)
{
if (attempt == 3) { Log("UNINSTALL: could not remove " + full + ": " + ex.Message); return; }
Thread.Sleep(1000);
}
}
}
/// <summary>The immediate child of C:\Games that contains <paramref name="path"/>
/// (e.g. C:\Games\RIOJoy\app\x.exe → C:\Games\RIOJoy), or null if not under C:\Games.</summary>
private static string ProductDirUnderGames(string path)
{
if (string.IsNullOrEmpty(path)) return null;
string full;
try { full = Path.GetFullPath(path); } catch { return null; }
const string games = @"C:\Games\";
if (!full.StartsWith(games, StringComparison.OrdinalIgnoreCase)) return null;
var seg = full.Substring(games.Length).Split('\\', '/')[0];
return string.IsNullOrEmpty(seg) ? null : games + seg;
}
// ── Process tracking helpers ──────────────────────────────────────────
private Wire.LaunchedAppData[] SnapshotLaunchedApps()
{
var result = new List<Wire.LaunchedAppData>();
lock (_processLock)
{
foreach (var kvp in _runningProcesses)
{
kvp.Value.RemoveAll(p => p.HasExited);
foreach (var p in kvp.Value)
result.Add(new Wire.LaunchedAppData
{
LaunchKey = ParseGuid(kvp.Key),
ProcessId = p.Id
});
}
foreach (var key in _runningProcesses.Keys
.Where(k => _runningProcesses[k].Count == 0).ToList())
_runningProcesses.Remove(key);
}
return result.ToArray();
}
private LaunchData FindApp(string launchKey)
{
lock (_processLock)
return _launchApps.FirstOrDefault(a =>
string.Equals(a.LaunchKey, launchKey, StringComparison.OrdinalIgnoreCase));
}
/// <summary>Maps a wire Guid to the tracked key string (which preserves the
/// exact casing used when the process was launched).</summary>
private string FindKeyString(Guid launchKey)
{
var key = launchKey.ToString();
lock (_processLock)
{
foreach (var k in _runningProcesses.Keys)
if (string.Equals(k, key, StringComparison.OrdinalIgnoreCase)) return k;
}
return key;
}
private void StartWatcher(LaunchData app, Process process)
{
new Thread(() =>
{
process.WaitForExit();
if (_stopping) return;
bool stillTracked;
lock (_processLock)
{
List<Process> procs;
stillTracked = _runningProcesses.TryGetValue(app.LaunchKey, out procs)
&& procs.Contains(process);
}
if (!stillTracked) return;
Thread.Sleep(2000);
try { CmdLaunchApp(ParseGuid(app.LaunchKey)); } catch { }
})
{ IsBackground = true, Name = "Watcher-" + app.LaunchKey }.Start();
}
// ── LaunchApps.xml ────────────────────────────────────────────────────
private void LoadLaunchApps()
{
try
{
if (!File.Exists(CONFIG_FILE))
{
lock (_processLock) _launchApps = new List<LaunchData>();
SetTrayStatus("Config not found");
return;
}
var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
using (var reader = File.OpenRead(CONFIG_FILE))
{
var apps = (List<LaunchData>)xml.Deserialize(reader) ?? new List<LaunchData>();
lock (_processLock) _launchApps = apps;
}
SetTrayStatus(_launchApps.Count + " apps configured");
}
catch (Exception ex)
{
SetTrayStatus("Config error: " + ex.Message);
}
}
private void SaveLaunchApps()
{
try
{
var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
Directory.CreateDirectory(Path.GetDirectoryName(CONFIG_FILE));
List<LaunchData> snapshot;
lock (_processLock) snapshot = new List<LaunchData>(_launchApps);
using (var writer = File.CreateText(CONFIG_FILE))
xml.Serialize(writer, snapshot);
}
catch (Exception ex)
{
Log("SaveLaunchApps error: " + ex.Message);
}
}
private static Wire.LaunchData ToWire(LaunchData e)
{
return new Wire.LaunchData
{
LaunchPair = new Wire.LaunchPair
{
LaunchKey = ParseGuid(e.LaunchKey),
DisplayName = e.DisplayName
},
WorkingDirectory = e.WorkingDirectory,
ExeFile = e.ExeFile,
Arguments = e.Arguments,
AutoRestart = e.AutoRestart
};
}
private static LaunchData FromWire(Wire.LaunchData d)
{
return new LaunchData
{
LaunchKey = d.LaunchPair.LaunchKey.ToString(),
DisplayName = d.LaunchPair.DisplayName,
WorkingDirectory = d.WorkingDirectory,
ExeFile = d.ExeFile,
Arguments = d.Arguments,
AutoRestart = d.AutoRestart
};
}
// ── Volume control ────────────────────────────────────────────────────
// The Console stores/retrieves volume as a float (0.01.0 scalar). We
// cache the exact value the Console sent so get returns the same value
// without device roundtrip quantization error. The device chain
// (nircmd → CoreAudio → winmm) lives in VolumeControl.cs, shared with vPOD.
private float _cachedVolumeScalar = 1.0f;
private float GetMasterVolume()
{
return _cachedVolumeScalar;
}
private void SetMasterVolume(float scalar)
{
_cachedVolumeScalar = scalar;
VolumeControl.SetMasterScalar(scalar, GAMES_DIR);
}
// ── Session key ───────────────────────────────────────────────────────
private static byte[] LoadSessionKey()
{
if (!File.Exists(KEY_FILE)) return null;
using (var fs = File.Open(KEY_FILE, FileMode.Open, FileAccess.Read, FileShare.Read))
{
var len = fs.ReadByte();
if (len <= 0) return null;
var key = new byte[len];
int off = 0;
while (off < key.Length)
{
int n = fs.Read(key, off, key.Length - off);
if (n == 0) break;
off += n;
}
return off == key.Length ? key : null;
}
}
// ── Machine-configured check (same rule as SecureConfig) ─────────────
private static bool IsMachineConfigured()
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
if (nic.OperationalStatus != OperationalStatus.Up) continue;
if (IsVirtualAdapter(nic)) continue;
try
{
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
if (!ipv4.IsDhcpEnabled) return true; // static = configured
}
catch (NetworkInformationException) { }
}
return false;
}
private static bool IsVirtualAdapter(NetworkInterface nic)
{
var desc = nic.Description ?? "";
var name = nic.Name ?? "";
string[] markers = {
"Virtual", "Hyper-V", "VMware", "VirtualBox",
"Loopback", "Tunnel", "Miniport", "Wi-Fi Direct",
"Bluetooth", "WAN Miniport", "Microsoft Kernel Debug"
};
foreach (var m in markers)
if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)
return true;
var mac = nic.GetPhysicalAddress().GetAddressBytes();
if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true; // locally administered
return false;
}
private void LogNicState()
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
if (nic.OperationalStatus != OperationalStatus.Up) continue;
try
{
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
Log(string.Format(" NIC [{0}] DHCP={1} Virtual={2}",
nic.Name, ipv4.IsDhcpEnabled, IsVirtualAdapter(nic)));
}
catch { Log(string.Format(" NIC [{0}] (no IPv4 props)", nic.Name)); }
}
}
// ── Tray UI ───────────────────────────────────────────────────────────
private void BuildTrayIcon()
{
_trayMenu = new ContextMenuStrip();
var version = typeof(LauncherApplication).Assembly.GetName().Version;
_trayMenu.Items.Add("Tesla Launcher v" + version, null, null).Enabled = false;
_trayMenu.Items.Add(new ToolStripSeparator());
_trayMenu.Items.Add("Reload Config", null, (s, e) => LoadLaunchApps());
_trayMenu.Items.Add("Kill All Apps", null, (s, e) => CmdKillAllApps());
_trayMenu.Items.Add(new ToolStripSeparator());
_trayMenu.Items.Add("Exit", null, (s, e) => ExitLauncher());
_trayIcon = new NotifyIcon
{
Text = "Tesla Launcher",
Icon = SystemIcons.Application,
ContextMenuStrip = _trayMenu,
Visible = true
};
}
private void ExitLauncher()
{
_stopping = true;
try { if (_listener != null) _listener.Stop(); } catch { }
_trayIcon.Visible = false;
Application.Exit();
}
private void SetTrayStatus(string status)
{
if (_trayIcon == null) return;
var text = "Tesla Launcher: " + status;
if (text.Length > 63) text = text.Substring(0, 63); // NotifyIcon.Text limit
try { _trayIcon.Text = text; } catch { }
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
Visible = false;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
if (_trayIcon != null) _trayIcon.Dispose();
if (_trayMenu != null) _trayMenu.Dispose();
}
base.Dispose(disposing);
}
// ── Argument helpers (JTokens from the Newtonsoft PodRpc leg) ─────────
private static bool HasArg(List<JToken> args, int i)
=> args != null && args.Count > i && args[i] != null
&& args[i].Type != JTokenType.Null;
private static Guid ArgGuid(List<JToken> args, int i)
{
if (!HasArg(args, i)) return Guid.Empty;
Guid g;
return Guid.TryParse(args[i].Value<string>(), out g) ? g : Guid.Empty;
}
private static int? ArgIntOrNull(List<JToken> args, int i)
{
if (!HasArg(args, i)) return null;
var t = args[i];
if (t.Type == JTokenType.Integer || t.Type == JTokenType.Float)
return t.Value<int>();
return null;
}
private static bool ArgBool(List<JToken> args, int i)
=> HasArg(args, i) && args[i].Type == JTokenType.Boolean && args[i].Value<bool>();
private static float ArgFloat(List<JToken> args, int i, float fallback)
{
if (!HasArg(args, i)) return fallback;
var t = args[i];
if (t.Type == JTokenType.Integer || t.Type == JTokenType.Float)
return t.Value<float>();
return fallback;
}
private static Guid ParseGuid(string s)
{
Guid g;
return s != null && Guid.TryParse(s, out g) ? g : Guid.Empty;
}
// ── Misc helpers ──────────────────────────────────────────────────────
/// <summary>Reads exactly buf.Length bytes from stream; throws IOException on EOF.</summary>
private static void ReadExactSync(Stream stream, byte[] buf)
{
int off = 0;
while (off < buf.Length)
{
int n = stream.Read(buf, off, buf.Length - off);
if (n == 0) throw new IOException("Connection closed before all bytes received.");
off += n;
}
}
private static readonly object _logLock = new object();
private static void Log(string msg)
{
try
{
lock (_logLock)
{
Directory.CreateDirectory(DATA_DIR);
File.AppendAllText(LOG_FILE,
string.Format("[{0:HH:mm:ss}] {1}{2}", DateTime.Now, msg, Environment.NewLine));
}
}
catch { /* logging must never take the launcher down */ }
}
// WER relaunches the app after a crash/hang (Vista+; no-op on XP).
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern int RegisterApplicationRestart(string commandLine, int flags);
private static void TryRegisterApplicationRestart()
{
if (Environment.OSVersion.Version.Major < 6) return;
try { RegisterApplicationRestart(null, 0); } catch { }
}
}
// ── Launcher-local data type ──────────────────────────────────────────────
// Loaded from LaunchApps.xml via XmlSerializer. The CLASS NAME and property
// names are load-bearing: they define the XML element names, and existing
// pods already have LaunchApps.xml files written by the two-process Agent
// with this exact shape. (The wire struct is Tesla.Net.LaunchData.)
[Serializable]
public class LaunchData
{
public string LaunchKey { get; set; }
public string DisplayName { get; set; }
public string WorkingDirectory { get; set; }
public string ExeFile { get; set; }
public string Arguments { get; set; }
public bool AutoRestart { get; set; }
}
// (Core Audio / winmm volume interop moved to VolumeControl.cs — shared with vPOD.)
}