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>
1292 lines
51 KiB
C#
1292 lines
51 KiB
C#
// =============================================================================
|
||
// 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.0–1.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 { }
|
||
}
|
||
}
|
||
}
|