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>
942 lines
36 KiB
C#
942 lines
36 KiB
C#
// =============================================================================
|
||
// 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.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).
|
||
|
||
/// <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);
|
||
}
|
||
}
|