The Agent only opened the on-screen passcode splash when IsMachineConfigured() was false (Ethernet adapter on DHCP). But during SecureConfig the Service assigns a TEMPORARY static IP to the adapter so it can broadcast — which flips IsMachineConfigured() to true. So an Agent starting at auto-login (in the SecureConfig wait window) saw a "configured" adapter and skipped the splash entirely; the Request ID / Passphrase only reached the log and the COM2 plasma display, never the screen. Gate the splash on the configuring.json file the Service writes (before the temp IP) as well as the DHCP check, and make the splash dismiss robustly when SecureConfig finishes (file gone after being seen, or the machine becomes configured). The COM2 plasma path in the Service was already correct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
956 lines
37 KiB
C#
956 lines
37 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: 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 ──────────────────────────────────────────────
|
||
|
||
/// <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
|
||
}
|
||
|
||
/// <summary>Shared file the Service writes (before assigning the temp IP) while
|
||
/// SecureConfig is in progress; holds the RequestId/Passphrase for the splash.</summary>
|
||
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();
|
||
_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);
|
||
}
|
||
}
|