Files
TeslaSuite/Launcher/TeslaLauncherAgent.cs
T
CydandClaude Opus 4.8 548550b312 Initial commit: TeslaSuite monorepo (TeslaConsole + TeslaLauncher)
Co-locate the two cockpit-pod projects into a single repository:

- Console/  : TeslaConsole, the net48 WinForms operator console (decompiled
              reconstruction) plus its differential + catalog test suite.
- Launcher/ : TeslaLauncher, the net6 pod-side Service + Agent rewrite.

Adds a combined TeslaSuite.sln, root README documenting the shared wire
contract (and its current duplication, the main follow-up), and a root
.gitignore. Histories were not preserved per request; this is a fresh start
from the current working state of both projects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:43:28 -05:00

942 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================================
// TeslaLauncher — 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<LaunchData> _launchApps = new();
private readonly Dictionary<string, List<Process>> _runningProcesses = new();
private readonly object _processLock = new();
private readonly List<Thread> _watcherThreads = new();
private bool _stopping = false;
private readonly Dictionary<string, OutOfBandProgress> _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: if this machine is unconfigured (DHCP adapter),
// show a waiting splash. The Service (Session 0) runs the full
// SecureConfig protocol and applies the static IP.
// When the splash detects configuration is complete it closes itself,
// and we fall through to start the normal Agent with the pipe server.
if (!IsMachineConfigured())
{
ShowConfiguringWait(); // blocks until config detected or form closed
// If still not configured (user closed the splash manually), exit.
if (!IsMachineConfigured())
return;
}
Application.Run(new AgentApplication());
}
// ── Secure Configuration ──────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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
}
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 codesLoaded = false;
var cfgFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"TeslaLauncher", "configuring.json");
// 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
// 3. Agent reads and displays codes
// 4. Service deletes file when config is complete → splash closes
var timer = new System.Windows.Forms.Timer { Interval = 2000 };
timer.Tick += (s, e) =>
{
if (File.Exists(cfgFile))
{
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();
codesLoaded = true;
}
}
catch { /* file may be mid-write, retry next tick */ }
}
else if (codesLoaded)
{
// Service deleted the file → configuration 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();
_trayMenu.Items.Add("Tesla Launcher Agent", 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<LaunchData>();
SetTrayStatus("Config not found");
return;
}
var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
using var reader = File.OpenRead(CONFIG_FILE);
_launchApps = (List<LaunchData>)xml.Deserialize(reader)
?? new List<LaunchData>();
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<LaunchData>));
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<IpcMessage>(
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));
pipe.Write(resBytes);
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<Process>();
_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<object[]>(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<LaunchedAppData>();
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<JsonElement[]>(msg.PayloadJson);
if (parms == null || parms.Length == 0)
throw new Exception("InstallApp: no parameters");
var appData = JsonSerializer.Deserialize<LaunchData>(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);
_launchApps.RemoveAll(a => a.LaunchKey == key);
SaveLaunchApps();
return null;
}
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<JsonElement[]>(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.Clamp(scalar, 0f, 1f));
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<JsonElement[]>(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<JsonElement[]>(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.01.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).
/// <summary>
/// Agent-internal representation of a launchable app.
/// Loaded from LaunchApps.xml via XmlSerializer.
/// </summary>
[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; }
}
/// <summary>
/// Agent-internal tracking of a running process.
/// Returned via JSON IPC (string LaunchKey, not Guid).
/// </summary>
[Serializable]
public class LaunchedAppData
{
public string LaunchKey { get; set; }
public int ProcessId { get; set; }
}
/// <summary>
/// Agent-internal full state snapshot.
/// Returned via JSON IPC for the FullUpdate command.
/// </summary>
[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);
}
}