Files
TeslaSuite/Launcher/TeslaLauncher.cs
T
CydandClaude Fable 5 6973e7d60c Launcher: kill whole process tree on stop; fix Win10 install.bat icacls
Two field issues from the live rollout, both launcher-side.

1) Console "Stop" did nothing. Every Tesla game runs under a supervisor
   that stays alive and respawns the game (Firestorm -> launcher.exe,
   Red Planet -> a looping .bat under cmd.exe, tesla410revival ->
   pod-launch.exe -> dosbox). net40's Process.Kill() terminates only the
   tracked supervisor PID, so the game survived (and the supervisor/loop
   relaunched it). New KillProcessTree uses `taskkill /PID <pid> /T /F`
   (whole tree; XP Pro + Win10/11), Process.Kill() as fallback. All three
   kill paths (KillApp/KillAllOfType/KillAllApps) now untrack under the
   lock and tree-kill outside it, so the RPC lock isn't held across
   taskkill and our auto-restart watcher won't relaunch. Proven against a
   real supervisor->child: old Kill orphaned the child; tree-kill takes both.

2) install.bat threw "(CI)M was unexpected at this time" at [1/7] on
   Windows 10. The icacls `/grant *S-1-5-32-545:(OI)(CI)M` sat inside an
   `else ( ... )` block; cmd read the literal ) in (OI)(CI) as the block
   end. XP was fine (its branch uses cacls, no parens). Quote the grant
   token at all three icacls sites; move the explanatory notes above the
   blocks (a stray ) even in a rem inside ( ) is the same trap). Verified
   on Win11: as-shipped reproduces the error, fixed form parses (rc=0) and
   applies the identical ACE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:38:36 -05:00

1238 lines
49 KiB
C#
Raw 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);
var victims = new List<Process>();
// Untrack under the lock, terminate outside it: KillProcessTree spawns
// taskkill and waits, which must not block the RPC lock. Untracking
// first also stops our own auto-restart watcher from relaunching (its
// stillTracked check fails once the key is gone).
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)
{
victims.Add(p);
procs.Remove(p);
}
if (procs.Count == 0) _runningProcesses.Remove(key);
}
KillProcessTrees(victims);
}
private void CmdKillAllOfType(Guid launchKey)
{
var key = FindKeyString(launchKey);
List<Process> victims;
lock (_processLock)
{
if (key == null || !_runningProcesses.TryGetValue(key, out victims)) return;
_runningProcesses.Remove(key);
}
KillProcessTrees(victims);
}
private void CmdKillAllApps()
{
var victims = new List<Process>();
lock (_processLock)
{
foreach (var kvp in _runningProcesses)
victims.AddRange(kvp.Value);
_runningProcesses.Clear();
}
KillProcessTrees(victims);
SetTrayStatus("All apps stopped");
}
private static void KillProcessTrees(IEnumerable<Process> procs)
{
if (procs == null) return;
foreach (var p in procs) KillProcessTree(p);
}
// Every Tesla game runs under a supervisor that stays alive and re-spawns
// the game (Firestorm -> launcher.exe, Red Planet -> a looping .bat under
// cmd.exe, tesla410revival -> pod-launch.exe -> dosbox). net40's
// Process.Kill() terminates ONLY the tracked supervisor PID, so the game
// survives (and the supervisor/loop relaunches it). taskkill /T walks the
// parent-PID tree and terminates the supervisor AND its children in one
// shot; it ships on XP Professional and Win10/11. Process.Kill() remains
// the fallback if taskkill is somehow unavailable.
private static void KillProcessTree(Process p)
{
if (p == null) return;
int pid;
try
{
if (p.HasExited) return;
pid = p.Id;
}
catch { return; }
try
{
var psi = new ProcessStartInfo("taskkill", "/PID " + pid + " /T /F")
{
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using (var tk = Process.Start(psi))
{
if (tk != null)
{
tk.WaitForExit(10000);
return;
}
}
}
catch { /* taskkill missing/failed — fall back to single-process kill */ }
try { if (!p.HasExited) p.Kill(); } catch { }
}
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.)
}