// ============================================================================= // TeslaLauncher — Userspace Agent // ============================================================================= // Runs as a WinForms tray application in the logged-in user's desktop session. // On first boot (unconfigured machine), displays a splash with the SecureConfig // Request ID and Passphrase so the operator can read them to the console. // On subsequent boots, listens on a Named Pipe for commands from // TeslaLauncherService and manages simulation applications. // ============================================================================= using System; using System.Collections.Generic; using System.Diagnostics; using System.Drawing; using System.IO; using System.IO.IsolatedStorage; using System.IO.Pipes; using System.Linq; using System.Net.NetworkInformation; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using Tesla.Launcher.Shared; namespace Tesla.Launcher.Agent { public class AgentApplication : Form { // ── Configuration ───────────────────────────────────────────────────── private const string PIPE_NAME = "TeslaLauncherIPC"; private const string CONFIG_FILE = @"C:\ProgramData\TeslaLauncher\LaunchApps.xml"; private const string GAMES_DIR = @"C:\Games"; // ───────────────────────────────────────────────────────────────────── private NotifyIcon _trayIcon; private ContextMenuStrip _trayMenu; private List _launchApps = new(); private readonly Dictionary> _runningProcesses = new(); private readonly object _processLock = new(); private readonly List _watcherThreads = new(); private bool _stopping = false; private readonly Dictionary _installProgress = new(); private readonly object _installLock = new(); private Thread _pipeThread; private CancellationTokenSource _cts = new(); // ── Entry point ─────────────────────────────────────────────────────── [STAThread] public static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); // First-boot check: show the configuring splash (with the SecureConfig // Request ID + Passphrase) when the machine is unconfigured OR when // SecureConfig is actively running. // // The second condition is essential: during SecureConfig the Service // assigns a TEMPORARY static IP to the Ethernet adapter so it can // broadcast. That makes IsMachineConfigured() report true, so the DHCP // check ALONE would skip the splash and the codes would never reach the // screen — they'd only be in the log / on the COM2 plasma display. The // Service writes configuring.json *before* assigning that temp IP, so its // presence is the authoritative "SecureConfig in progress" signal. if (!IsMachineConfigured() || File.Exists(ConfiguringFilePath())) { ShowConfiguringWait(); // blocks until config completes or form closed // If still not configured (config failed / user closed splash), exit. if (!IsMachineConfigured()) return; } Application.Run(new AgentApplication()); } // ── Secure Configuration ────────────────────────────────────────────── /// /// Returns true when the cockpit has already been configured — /// detected by the Ethernet adapter having a static IP assignment. /// SecureConfiguration writes the permanent address via netsh with /// no DHCP, so a static adapter means configuration is complete. /// A DHCP adapter (or no adapter) means we are on a fresh machine /// and need to run the SecureConfiguration protocol. /// 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(); // IsDhcpEnabled == false → static IP → already configured if (!ipv4.IsDhcpEnabled) return true; } catch (NetworkInformationException) { // GetIPv4Properties() throws if IPv4 is not configured at all; // treat that the same as DHCP (i.e. not yet configured). } } return false; // all Ethernet adapters are DHCP or absent → needs configuration } /// Shared file the Service writes (before assigning the temp IP) while /// SecureConfig is in progress; holds the RequestId/Passphrase for the splash. private static string ConfiguringFilePath() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "TeslaLauncher", "configuring.json"); 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 MAC return false; } private static void ShowConfiguringWait() { // The Service handles SecureConfig in Session 0. The Agent shows // a splash with the RequestId and Passphrase for the operator. using var form = new Form { Text = "Tesla Cockpit Configuration", FormBorderStyle = FormBorderStyle.FixedDialog, StartPosition = FormStartPosition.CenterScreen, ClientSize = new System.Drawing.Size(480, 260), MaximizeBox = false, MinimizeBox = false, TopMost = true, }; var lblStatus = new Label { Text = "This cockpit is being configured.\r\n" + "Configuration will complete automatically.", AutoSize = false, Dock = DockStyle.Top, Height = 50, TextAlign = System.Drawing.ContentAlignment.MiddleCenter, Font = new System.Drawing.Font("Arial", 10f), Padding = new Padding(12, 8, 12, 0), }; form.Controls.Add(lblStatus); var lblRequestIdTitle = new Label { Text = "Request ID:", AutoSize = false, TextAlign = System.Drawing.ContentAlignment.MiddleCenter, Font = new System.Drawing.Font("Arial", 11f), Top = 60, Left = 0, Width = 480, Height = 25, }; form.Controls.Add(lblRequestIdTitle); var lblRequestId = new Label { Text = "waiting...", AutoSize = false, TextAlign = System.Drawing.ContentAlignment.MiddleCenter, Font = new System.Drawing.Font("Consolas", 28f, System.Drawing.FontStyle.Bold), ForeColor = System.Drawing.Color.DarkBlue, Top = 85, Left = 0, Width = 480, Height = 45, }; form.Controls.Add(lblRequestId); var lblPassphraseTitle = new Label { Text = "Passphrase:", AutoSize = false, TextAlign = System.Drawing.ContentAlignment.MiddleCenter, Font = new System.Drawing.Font("Arial", 11f), Top = 140, Left = 0, Width = 480, Height = 25, }; form.Controls.Add(lblPassphraseTitle); var lblPassphrase = new Label { Text = "waiting...", AutoSize = false, TextAlign = System.Drawing.ContentAlignment.MiddleCenter, Font = new System.Drawing.Font("Consolas", 28f, System.Drawing.FontStyle.Bold), ForeColor = System.Drawing.Color.DarkRed, Top = 165, Left = 0, Width = 480, Height = 45, }; form.Controls.Add(lblPassphrase); var lblHint = new Label { Text = "Read the Passphrase to the console operator.", AutoSize = false, TextAlign = System.Drawing.ContentAlignment.MiddleCenter, Font = new System.Drawing.Font("Arial", 9f, System.Drawing.FontStyle.Italic), ForeColor = System.Drawing.Color.Gray, Top = 220, Left = 0, Width = 480, Height = 25, }; form.Controls.Add(lblHint); string lastJson = null; bool sawFile = false; var cfgFile = ConfiguringFilePath(); // Poll every 2 s — read codes from the Service's shared file. // The file lifecycle: // 1. Service deletes stale file on startup // 2. Service writes fresh file with new codes (before the temp IP) // 3. Agent reads and displays codes // 4. Service deletes file when config is complete → splash closes // If the Agent started before the file appeared, it keeps waiting; it // closes once the file we saw is gone, or the machine becomes configured // (covers the case where config finished before we managed to read it). var timer = new System.Windows.Forms.Timer { Interval = 2000 }; timer.Tick += (s, e) => { if (File.Exists(cfgFile)) { sawFile = true; try { var json = File.ReadAllText(cfgFile); if (json != lastJson) { lastJson = json; var doc = System.Text.Json.JsonDocument.Parse(json); var root = doc.RootElement; if (root.TryGetProperty("RequestId", out var rid)) lblRequestId.Text = rid.GetString(); if (root.TryGetProperty("Passphrase", out var pp)) lblPassphrase.Text = pp.GetString(); } } catch { /* file may be mid-write, retry next tick */ } } else if (sawFile || IsMachineConfigured()) { // File gone after we saw it, or the machine is now configured → // SecureConfig is complete. timer.Stop(); form.Close(); } }; timer.Start(); Application.Run(form); } // ── Form / tray setup ───────────────────────────────────────────────── public AgentApplication() { ShowInTaskbar = false; WindowState = FormWindowState.Minimized; Opacity = 0; BuildTrayIcon(); LoadLaunchApps(); StartPipeServer(); } private void BuildTrayIcon() { _trayMenu = new ContextMenuStrip(); var version = typeof(AgentApplication).Assembly.GetName().Version; _trayMenu.Items.Add($"Tesla Launcher Agent 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) => ExitAgent()); _trayIcon = new NotifyIcon { Text = "Tesla Launcher Agent", Icon = SystemIcons.Application, ContextMenuStrip = _trayMenu, Visible = true }; } private void ExitAgent() { _stopping = true; _cts.Cancel(); _trayIcon.Visible = false; Application.Exit(); } // ── LaunchApps.xml ──────────────────────────────────────────────────── private void LoadLaunchApps() { try { if (!File.Exists(CONFIG_FILE)) { _launchApps = new List(); SetTrayStatus("Config not found"); return; } var xml = new System.Xml.Serialization.XmlSerializer(typeof(List)); using var reader = File.OpenRead(CONFIG_FILE); _launchApps = (List)xml.Deserialize(reader) ?? new List(); SetTrayStatus($"{_launchApps.Count} apps configured"); } catch (Exception ex) { SetTrayStatus($"Config error: {ex.Message}"); } } private void SaveLaunchApps() { var xml = new System.Xml.Serialization.XmlSerializer(typeof(List)); Directory.CreateDirectory(Path.GetDirectoryName(CONFIG_FILE)!); using var writer = File.CreateText(CONFIG_FILE); xml.Serialize(writer, _launchApps); } private void SetTrayStatus(string status) { if (_trayIcon != null) _trayIcon.Text = $"Tesla Launcher: {status}"; } // ── Named Pipe Server ───────────────────────────────────────────────── private void StartPipeServer() { _pipeThread = new Thread(PipeServerLoop) { IsBackground = true, Name = "TeslaLauncherPipeServer" }; _pipeThread.Start(); } private void PipeServerLoop() { var ct = _cts.Token; while (!ct.IsCancellationRequested) { try { using var server = new NamedPipeServerStream( PIPE_NAME, PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); server.WaitForConnectionAsync(ct).GetAwaiter().GetResult(); HandlePipeRequest(server); } catch (OperationCanceledException) { break; } catch (Exception) { if (!ct.IsCancellationRequested) Thread.Sleep(500); } } } private void HandlePipeRequest(NamedPipeServerStream pipe) { IpcResponse response; try { var lenBuf = new byte[4]; ReadExact(pipe, lenBuf); var reqBuf = new byte[BitConverter.ToInt32(lenBuf, 0)]; ReadExact(pipe, reqBuf); var msg = JsonSerializer.Deserialize( Encoding.UTF8.GetString(reqBuf)); response = ExecuteCommand(msg); } catch (Exception ex) { response = new IpcResponse { Success = false, Message = ex.Message }; } var resBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(response)); pipe.Write(BitConverter.GetBytes(resBytes.Length), 0, 4); pipe.Write(resBytes, 0, resBytes.Length); pipe.Flush(); } private static void ReadExact(Stream stream, byte[] buf) { var off = 0; while (off < buf.Length) { var n = stream.Read(buf, off, buf.Length - off); if (n == 0) throw new IOException("Pipe closed."); off += n; } } // ── Command Dispatcher ──────────────────────────────────────────────── private IpcResponse ExecuteCommand(IpcMessage msg) { if (msg == null) return Fail("Null message received."); try { object result = msg.Command switch { "PING" => (object)null, "CLEARSTORE" => CmdClearStore(), "LAUNCHAPP" => CmdLaunchApp(msg), "KILLAPP" => CmdKillApp(msg), "KILLALLOFTYPE" => CmdKillAllOfType(msg), "KILLALLAPPS" => CmdKillAllApps(), "GETLAUNCHEDAPPS" => CmdGetLaunchedApps(), "GETLAUNCHABLEAPPS" => CmdGetLaunchableApps(), "GETINSTALLEDAPPS" => CmdGetLaunchableApps(), "REMOVEAPP" => CmdRemoveApp(msg), "INSTALLAPP" => CmdInstallApp(msg), "UNINSTALLAPP" => CmdUninstallApp(msg), "GET_VOLUMELEVEL" => CmdGetVolumeLevel(), "SET_VOLUMELEVEL" => CmdSetVolumeLevel(msg), "FULLUPDATE" => CmdFullUpdate(), "INITIATEINSTALLPRODUCT" => CmdInitiateInstallProduct(msg), "GETOUTOFBANDPROGRESS" => CmdGetOutOfBandProgress(msg), "SHUTDOWN" => CmdShutdown(msg), _ => throw new Exception($"Unknown command: {msg.Command}") }; return Ok(result); } catch (Exception ex) { return Fail(ex.Message); } } // ── Command Implementations ─────────────────────────────────────────── private object CmdClearStore() { var store = IsolatedStorageFile.GetMachineStoreForAssembly(); foreach (var file in store.GetFileNames("*")) store.DeleteFile(file); LoadLaunchApps(); return null; } private object CmdLaunchApp(IpcMessage msg) { var key = msg.LaunchKey ?? throw new Exception("LaunchApp requires a LaunchKey"); var app = FindApp(key) ?? 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 rather than // surfacing a raw Win32 "file not found" from Process.Start. if (string.IsNullOrWhiteSpace(app.ExeFile) || !File.Exists(app.ExeFile)) throw new Exception( $"Cannot launch '{app.DisplayName ?? key}': executable not found at " + $"'{app.ExeFile}'. The product may be registered but not yet installed on this pod."); var psi = new ProcessStartInfo { FileName = app.ExeFile, Arguments = app.Arguments ?? "", WorkingDirectory = app.WorkingDirectory ?? Path.GetDirectoryName(app.ExeFile), UseShellExecute = false }; var process = Process.Start(psi) ?? throw new Exception($"Process.Start returned null for '{app.ExeFile}'"); lock (_processLock) { if (!_runningProcesses.ContainsKey(key)) _runningProcesses[key] = new List(); _runningProcesses[key].Add(process); } if (app.AutoRestart) StartWatcher(app, process); SetTrayStatus($"Running: {app.DisplayName ?? key}"); return new LaunchedAppData { LaunchKey = key, ProcessId = process.Id }; } private object CmdKillApp(IpcMessage msg) { var key = msg.LaunchKey; int? pid = null; if (msg.PayloadJson != null) { var parms = JsonSerializer.Deserialize(msg.PayloadJson); if (parms?.Length > 1 && parms[1] is JsonElement je) pid = je.ValueKind == JsonValueKind.Number ? je.GetInt32() : (int?)null; } lock (_processLock) { if (!_runningProcesses.TryGetValue(key ?? "", out var procs)) return null; 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 ?? ""); } return null; } private object CmdKillAllOfType(IpcMessage msg) { var key = msg.LaunchKey ?? throw new Exception("KillAllOfType requires LaunchKey"); lock (_processLock) { if (_runningProcesses.TryGetValue(key, out var procs)) { foreach (var p in procs) try { if (!p.HasExited) p.Kill(); } catch { } _runningProcesses.Remove(key); } } return null; } private object 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"); return null; } private object CmdGetLaunchedApps() { var result = new List(); lock (_processLock) { foreach (var kvp in _runningProcesses) { kvp.Value.RemoveAll(p => p.HasExited); foreach (var p in kvp.Value) result.Add(new LaunchedAppData { LaunchKey = 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 object CmdGetLaunchableApps() => _launchApps.ToArray(); private object CmdRemoveApp(IpcMessage msg) { var key = msg.LaunchKey ?? throw new Exception("RemoveApp requires LaunchKey"); _launchApps.RemoveAll(a => a.LaunchKey == key); SaveLaunchApps(); return null; } private object CmdInstallApp(IpcMessage msg) { if (msg.PayloadJson == null) throw new Exception("InstallApp requires PayloadJson"); var parms = JsonSerializer.Deserialize(msg.PayloadJson); if (parms == null || parms.Length == 0) throw new Exception("InstallApp: no parameters"); var appData = JsonSerializer.Deserialize(parms[0].GetRawText()) ?? throw new Exception("InstallApp: could not deserialize LaunchData"); var existing = _launchApps.FindIndex(a => a.LaunchKey == appData.LaunchKey); if (existing >= 0) _launchApps[existing] = appData; else _launchApps.Add(appData); SaveLaunchApps(); return null; } private object CmdUninstallApp(IpcMessage msg) { var key = msg.LaunchKey ?? throw new Exception("UninstallApp requires LaunchKey"); CmdKillAllOfType(msg); var removed = _launchApps.FirstOrDefault(a => a.LaunchKey == key); _launchApps.RemoveAll(a => a.LaunchKey == key); SaveLaunchApps(); // If the removed app's product directory under C:\Games is no longer used by // any remaining registered entry, tell the Service (which runs as SYSTEM) to // run its pre-uninstall.bat and delete the directory. 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. string cleanupDir = null; 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; } } return new { CleanupDir = cleanupDir }; } /// The immediate child of C:\Games that contains /// (e.g. C:\Games\RIOJoy\app\x.exe → C:\Games\RIOJoy), or null if not under C:\Games. private static string ProductDirUnderGames(string path) { if (string.IsNullOrWhiteSpace(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; } private object CmdGetVolumeLevel() { return GetMasterVolume(); } private object CmdSetVolumeLevel(IpcMessage msg) { if (msg.PayloadJson == null) throw new Exception("set_VolumeLevel requires parameters"); var parms = JsonSerializer.Deserialize(msg.PayloadJson); if (parms == null || parms.Length == 0) throw new Exception("Invalid volume value"); var scalar = parms[0].ValueKind == JsonValueKind.Number ? (float)parms[0].GetDouble() : throw new Exception("Invalid volume value"); SetMasterVolume(Math.Max(0f, Math.Min(1f, scalar))); return null; } private object CmdFullUpdate() => new FullUpdateData { InstalledApps = _launchApps.ToArray(), LaunchedApps = (LaunchedAppData[])CmdGetLaunchedApps(), VolumeLevel = GetMasterVolume() }; private object CmdInitiateInstallProduct(IpcMessage msg) { // ILauncherService.InitiateInstallProduct() takes no parameters. // It just returns a tracking Guid. The actual game registration // happens via InstallApp(LaunchData) in a separate RPC call. var callId = Guid.NewGuid().ToString("N"); var progress = new OutOfBandProgress { PercentComplete = 100, Status = "Complete", IsCompleted = true }; lock (_installLock) _installProgress[callId] = progress; return callId; } private object CmdGetOutOfBandProgress(IpcMessage msg) { var callId = msg.PayloadJson != null ? JsonSerializer.Deserialize(msg.PayloadJson)?[0].GetString() : null; if (callId == null) throw new Exception("GetOutOfBandProgress requires a callId"); lock (_installLock) { if (_installProgress.TryGetValue(callId, out var prog)) return prog; } return new OutOfBandProgress { PercentComplete = 0, Status = "Unknown call ID", IsCompleted = true }; } private object CmdShutdown(IpcMessage msg) { bool doRestart = false; if (msg.PayloadJson != null) { try { var parms = JsonSerializer.Deserialize(msg.PayloadJson); if (parms?.Length > 0 && parms[0].ValueKind == JsonValueKind.True) doRestart = true; } catch { /* default to shutdown */ } } CmdKillAllApps(); var flag = doRestart ? "/r" : "/s"; new Thread(() => { Thread.Sleep(2000); Process.Start("shutdown", $"{flag} /t 0"); }) { IsBackground = true }.Start(); return null; } // ── Helpers ─────────────────────────────────────────────────────────── private LaunchData FindApp(string launchKey) => _launchApps.FirstOrDefault(a => string.Equals(a.LaunchKey, launchKey, StringComparison.OrdinalIgnoreCase)); private void StartWatcher(LaunchData app, Process process) { new Thread(() => { process.WaitForExit(); if (_stopping) return; bool stillTracked; lock (_processLock) { stillTracked = _runningProcesses.TryGetValue( app.LaunchKey, out var procs) && procs.Contains(process); } if (!stillTracked) return; Thread.Sleep(2000); try { CmdLaunchApp(new IpcMessage { LaunchKey = app.LaunchKey }); } catch { } }) { IsBackground = true, Name = $"Watcher-{app.LaunchKey}" }.Start(); } // ── 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 CoreAudio roundtrip quantization error. private float _cachedVolumeScalar = 1.0f; private float GetMasterVolume() { return _cachedVolumeScalar; } private void SetMasterVolume(float scalar) { _cachedVolumeScalar = scalar; // Try nircmd.exe first (legacy compatibility) var nircmd = Path.Combine(GAMES_DIR, "nircmd.exe"); if (File.Exists(nircmd)) { Process.Start(nircmd, $"setsysvolume {(int)(scalar * 65535)}")?.Dispose(); return; } // Windows Core Audio API fallback (Vista+, no external dependencies) CoreAudio.SetMasterScalar(scalar); } // ── Response helpers ────────────────────────────────────────────────── private static IpcResponse Ok(object data = null) => new() { Success = true, Data = data }; private static IpcResponse Fail(string msg) => new() { Success = false, Message = msg }; protected override void OnLoad(EventArgs e) { base.OnLoad(e); Visible = false; } protected override void Dispose(bool disposing) { if (disposing) { _trayIcon?.Dispose(); _trayMenu?.Dispose(); } base.Dispose(disposing); } } // ── Agent-local data types ──────────────────────────────────────────────── // These are used internally by the Agent (XML config, process management). // They are NOT the BinaryFormatter wire types (Tesla.Net namespace). /// /// Agent-internal representation of a launchable app. /// Loaded from LaunchApps.xml via XmlSerializer. /// [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; } } /// /// Agent-internal tracking of a running process. /// Returned via JSON IPC (string LaunchKey, not Guid). /// [Serializable] public class LaunchedAppData { public string LaunchKey { get; set; } public int ProcessId { get; set; } } /// /// Agent-internal full state snapshot. /// Returned via JSON IPC for the FullUpdate command. /// [Serializable] public class FullUpdateData { public LaunchData[] InstalledApps { get; set; } public LaunchedAppData[] LaunchedApps { get; set; } public float VolumeLevel { get; set; } } [Serializable] public class OutOfBandProgress { public int PercentComplete { get; set; } public string Status { get; set; } public bool IsCompleted { 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. internal static class CoreAudio { internal static float GetMasterScalar() { var ep = GetEndpointVolume(); try { ep.GetMasterVolumeLevelScalar(out float v); return v; } finally { ReleaseAll(ep); } } internal static void SetMasterScalar(float scalar) { var ep = GetEndpointVolume(); try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); } finally { ReleaseAll(ep); } } private static IAudioEndpointVolume GetEndpointVolume() { var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass(); try { enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out var device); try { var iid = typeof(IAudioEndpointVolume).GUID; device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out var obj); return (IAudioEndpointVolume)obj; } finally { Marshal.ReleaseComObject(device); } } finally { Marshal.ReleaseComObject(enumerator); } } private static void ReleaseAll(object obj) { if (obj != null) Marshal.ReleaseComObject(obj); } } [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); } }