Files
TeslaSuite/Launcher/TeslaLauncher.cs
T
CydandClaude Fable 5 8730b9b966 XP11: single net40 binary runs on XP SP3 through Windows 11
Merge TeslaLauncherService (Session 0) + TeslaLauncherAgent (tray) into one
userland TeslaLauncher.exe. The split existed only to work around Vista+
Session 0 isolation; running everything in the auto-logged-in admin session
needs no service, no named pipe, and no flat<->wire conversion layer. The
original Elsewhen software was likewise a single binary.

net40 is the newest framework XP SP3 can install, and net40 assemblies load
in-place on the 4.8 runtime in Win10/11 -- one exe covers both.

- Contract: multi-target net48;net40. The net40 leg of PodRpcProtocol uses
  Newtonsoft.Json (STJ has no net40 target); JSON is shape-identical on the
  wire, and the request reader keeps date strings raw so Ping echoes
  byte-identically. Console keeps the untouched net48/STJ leg.
- MiniZip.cs: central-directory ZIP extractor (stored/deflate/ZIP64) since
  net40 has no ZipFile; zip-slip guarded.
- SecureConfig: RSACryptoServiceProvider instead of RSA.Create (net46+),
  no leaveOpen BinaryWriter/Reader, struct instead of ValueTuple, and no
  SetCompatibleTextRenderingDefault on the passcode thread (the merged app
  already has a form). netsh "interface ip" syntax was already XP-correct.
- Volume: nircmd -> CoreAudio (Vista+) -> winmm waveOutSetVolume (XP).
- Paths: CommonApplicationData resolved per-OS (XP has no C:\ProgramData);
  launcher log moved next to the key/config in the data dir.
- install.bat: dual-OS (cacls/icacls, netsh firewall/advfirewall, dism and
  UAC/notification steps skipped on XP, .NET 4.0 redist check, XP DHCP reset
  via netsh); no service registration -- HKLM Run key + auto-login on both;
  RegisterApplicationRestart supplies crash-restart on Vista+.
- Bench switches: /skipconfig, /port:NNNN.

Verified: Console + diff suite (103 tests) still green on the net48 leg;
E2E smoke test drove the net40 launcher over real TCP with the Console's own
PodManagerConnection (STJ client vs Newtonsoft server) -- Ping echo, volume,
app registry, launch/kill/watch, InstallProduct zip transfer + extract, and
uninstall orphan cleanup: 17/17 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:59:06 -05:00

1292 lines
51 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.
//
// Setter chain: nircmd.exe (legacy, works everywhere incl. XP)
// → CoreAudio (Vista+) → winmm waveOutSetVolume (XP fallback).
private float _cachedVolumeScalar = 1.0f;
private float GetMasterVolume()
{
return _cachedVolumeScalar;
}
private void SetMasterVolume(float scalar)
{
_cachedVolumeScalar = scalar;
var nircmd = Path.Combine(GAMES_DIR, "nircmd.exe");
if (File.Exists(nircmd))
{
try
{
var p = Process.Start(nircmd, "setsysvolume " + (int)(scalar * 65535));
if (p != null) p.Dispose();
return;
}
catch { /* fall through to API */ }
}
if (Environment.OSVersion.Version.Major >= 6)
{
try { CoreAudio.SetMasterScalar(scalar); return; }
catch { /* fall through */ }
}
WinMmVolume.SetMasterScalar(scalar);
}
// ── 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; }
}
// ── Windows Core Audio API — minimal COM interop (Vista+) ─────────────────
// No external dependencies. Vtable slot order matches the SDK headers exactly.
// Methods we don't call are declared as void stubs to preserve vtable offsets.
// NOT available on XP — callers must gate on OS version.
internal static class CoreAudio
{
internal static void SetMasterScalar(float scalar)
{
var ep = GetEndpointVolume();
try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); }
finally { Marshal.ReleaseComObject(ep); }
}
private static IAudioEndpointVolume GetEndpointVolume()
{
var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass();
try
{
IMMDevice device;
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device);
try
{
var iid = typeof(IAudioEndpointVolume).GUID;
object obj;
device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out obj);
return (IAudioEndpointVolume)obj;
}
finally { Marshal.ReleaseComObject(device); }
}
finally { Marshal.ReleaseComObject(enumerator); }
}
}
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
internal class MMAudioEnumeratorComClass { }
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDeviceEnumerator
{
void _unused_EnumAudioEndpoints(); // slot 0 — not used
[PreserveSig]
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
}
[Guid("D666063F-1587-4E43-81F1-B948E807363F"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDevice
{
[PreserveSig]
int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams,
[MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer);
}
// Vtable order (after IUnknown): RegisterControlChangeNotify(0),
// UnregisterControlChangeNotify(1), GetChannelCount(2),
// SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4),
// GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6)
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IAudioEndpointVolume
{
void _unused_RegisterControlChangeNotify(); // slot 0
void _unused_UnregisterControlChangeNotify(); // slot 1
void _unused_GetChannelCount(); // slot 2
[PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx);
[PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx);
[PreserveSig] int GetMasterVolumeLevel(out float levelDB);
[PreserveSig] int GetMasterVolumeLevelScalar(out float level);
}
// ── winmm wave-out volume (XP fallback) ───────────────────────────────────
// waveOutSetVolume with device -1 sets the wave mixer level of the default
// device: low 16 bits = left channel, high 16 bits = right channel.
internal static class WinMmVolume
{
[DllImport("winmm.dll")]
private static extern int waveOutSetVolume(IntPtr hwo, uint dwVolume);
internal static void SetMasterScalar(float scalar)
{
uint level = (uint)(Math.Max(0f, Math.Min(1f, scalar)) * 0xFFFF);
try { waveOutSetVolume(IntPtr.Zero, (level << 16) | level); } catch { }
}
}
}