diff --git a/Contract/PodRpcProtocol.cs b/Contract/PodRpcProtocol.cs index 60a028e..784a30d 100644 --- a/Contract/PodRpcProtocol.cs +++ b/Contract/PodRpcProtocol.cs @@ -16,10 +16,22 @@ // one file, so the request/response shape cannot drift. // ============================================================================= +// NET40 (XP11 single-binary launcher): System.Text.Json has no net40 target, so +// this leg serializes with Newtonsoft.Json instead. Same framing, same JSON shape +// (PascalCase member names, fields included, Guids as strings, ISO-8601 dates) — +// an STJ client and a Newtonsoft server interoperate byte-compatibly on the wire. +// Arguments surface as JToken here instead of JsonElement. + using System; using System.Collections.Generic; using System.IO; +using System.Text; +#if NET40 +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +#else using System.Text.Json; +#endif namespace Tesla.Net { @@ -27,13 +39,21 @@ namespace Tesla.Net public sealed class RpcRequest { public string Method { get; set; } +#if NET40 + public List Args { get; set; } +#else public List Args { get; set; } +#endif } /// One RPC result: the return value as JSON, or an error message. public sealed class RpcResponse { +#if NET40 + public JToken Result { get; set; } // JSON null for void / null +#else public JsonElement Result { get; set; } // JsonValueKind.Null for void / null +#endif public string Error { get; set; } // null on success } @@ -45,12 +65,18 @@ namespace Tesla.Net /// streamed out-of-band, not framed), guarding against hostile lengths. public const int MaxFrameBytes = 16 * 1024 * 1024; +#if NET40 + // Newtonsoft serializes public fields of the wire types by default, and + // writes ISO-8601 dates / string Guids like STJ — no special options needed. + public static readonly JsonSerializer JsonOptions = JsonSerializer.CreateDefault(); +#else public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { // The Tesla.Net wire types (LaunchData, LaunchPair, ...) expose public // FIELDS, which System.Text.Json ignores unless this is set. IncludeFields = true, }; +#endif // ── Framing ────────────────────────────────────────────────────────── @@ -88,6 +114,25 @@ namespace Tesla.Net // ── Request ────────────────────────────────────────────────────────── +#if NET40 + public static void WriteRequest(Stream stream, string method, object[] args) + { + var req = new RpcRequest { Method = method, Args = new List() }; + if (args != null) + foreach (var a in args) + req.Args.Add(a == null ? JValue.CreateNull() : JToken.FromObject(a, JsonOptions)); + WriteFrame(stream, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(req))); + } + + public static RpcRequest ReadRequest(Stream stream) + => JsonConvert.DeserializeObject( + Encoding.UTF8.GetString(ReadFrame(stream)), ReadSettings); + + // Keep date-looking strings as raw strings so an echoed argument (Ping) + // goes back byte-identical instead of reformatted through DateTime. + private static readonly JsonSerializerSettings ReadSettings = + new JsonSerializerSettings { DateParseHandling = DateParseHandling.None }; +#else public static void WriteRequest(Stream stream, string method, object[] args) { var req = new RpcRequest { Method = method, Args = new List() }; @@ -99,9 +144,26 @@ namespace Tesla.Net public static RpcRequest ReadRequest(Stream stream) => JsonSerializer.Deserialize(ReadFrame(stream), JsonOptions); +#endif // ── Response ───────────────────────────────────────────────────────── +#if NET40 + public static void WriteResponse(Stream stream, object result, string error) + { + object payload = error == null ? result : null; + var resp = new RpcResponse + { + Result = payload == null ? JValue.CreateNull() : JToken.FromObject(payload, JsonOptions), + Error = error, + }; + WriteFrame(stream, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(resp))); + } + + public static RpcResponse ReadResponse(Stream stream) + => JsonConvert.DeserializeObject( + Encoding.UTF8.GetString(ReadFrame(stream)), ReadSettings); +#else public static void WriteResponse(Stream stream, object result, string error) { var resp = new RpcResponse @@ -116,5 +178,6 @@ namespace Tesla.Net public static RpcResponse ReadResponse(Stream stream) => JsonSerializer.Deserialize(ReadFrame(stream), JsonOptions); +#endif } } diff --git a/Contract/Tesla.Contract.csproj b/Contract/Tesla.Contract.csproj index c3a8c24..610d6b2 100644 --- a/Contract/Tesla.Contract.csproj +++ b/Contract/Tesla.Contract.csproj @@ -1,9 +1,11 @@ - - net48 + + net48;net40 - + + + + + + + + + + handshake in TeslaSecureConfiguration.dll. The Launcher is the SERVER end of + this protocol and never references these classes, so the net40 leg carries + only the wire data types + PodRpc framing. --> diff --git a/Launcher/LaunchModels_Shared.cs b/Launcher/LaunchModels_Shared.cs deleted file mode 100644 index 8f8bdbf..0000000 --- a/Launcher/LaunchModels_Shared.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ============================================================================= -// TeslaLauncher — Shared IPC Models -// ============================================================================= -// IPC types (Tesla.Launcher.Shared): JSON messages between the Windows Service -// and the user-session Agent over a Named Pipe. These never touch the Console -// TCP connection. -// -// The BinaryFormatter wire types (Tesla.Net: LaunchData, InvokeCommand, ...) -// formerly replicated here by hand now live in the shared Tesla.Contract project -// (assembly TeslaConsoleLaunchLib), referenced by the Service. This file is also -// compiled into the Agent, which uses only the IPC types below. -// ============================================================================= - -namespace Tesla.Launcher.Shared -{ - /// Command forwarded from the Windows Service to the Userspace Agent. - public sealed class IpcMessage - { - public string Command { get; set; } - public string LaunchKey { get; set; } - public string PayloadJson { get; set; } - } - - /// Response from the Userspace Agent back to the Windows Service. - public sealed class IpcResponse - { - public bool Success { get; set; } - public string Message { get; set; } - public object Data { get; set; } - } -} diff --git a/Launcher/MiniZip.cs b/Launcher/MiniZip.cs new file mode 100644 index 0000000..ba2d91a --- /dev/null +++ b/Launcher/MiniZip.cs @@ -0,0 +1,273 @@ +// ============================================================================= +// TeslaLauncher — MiniZip +// ============================================================================= +// Minimal ZIP extractor for net40: System.IO.Compression.ZipFile/ZipArchive are +// net45+, so they do not exist on the XP-compatible framework. This reads the +// archive via the central directory and supports exactly what the Console's +// install packages use: +// - compression methods 0 (stored) and 8 (deflate; DeflateStream is net20+) +// - UTF-8 (general-purpose flag bit 11) or ANSI/CP437 entry names +// - ZIP64 sizes/offsets/entry counts (archives > 4 GB or > 65535 entries) +// CRC is not verified — archives arrive from our own Console over the +// OFB-encrypted link, and the install already fails loudly on truncation. +// ============================================================================= + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace Tesla.Launcher +{ + internal static class MiniZip + { + /// Extracts every entry of under + /// (existing files overwritten), reporting + /// (entriesDone, entriesTotal) after each entry. Entries that would + /// escape the destination directory (zip-slip) are skipped. + public static void ExtractToDirectory(string zipPath, string destDir, Action onEntry) + { + using (var fs = new FileStream(zipPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + var entries = ReadCentralDirectory(fs); + var destRoot = Path.GetFullPath(destDir).TrimEnd('\\'); + int done = 0; + foreach (var e in entries) + { + ExtractEntry(fs, e, destRoot); + done++; + if (onEntry != null) onEntry(done, entries.Count); + } + } + } + + private sealed class Entry + { + public string Name; + public int Method; + public long CompressedSize; + public long LocalHeaderOffset; + } + + // ── Central directory ───────────────────────────────────────────────── + + private static List ReadCentralDirectory(FileStream fs) + { + // End-of-central-directory record: fixed 22 bytes + up to 64K comment. + long fileLen = fs.Length; + int maxScan = (int)Math.Min(fileLen, 22 + 65535); + var tail = new byte[maxScan]; + fs.Seek(fileLen - maxScan, SeekOrigin.Begin); + ReadExact(fs, tail, maxScan); + + int eocd = -1; + for (int i = maxScan - 22; i >= 0; i--) + { + if (tail[i] == 0x50 && tail[i + 1] == 0x4B && + tail[i + 2] == 0x05 && tail[i + 3] == 0x06) { eocd = i; break; } + } + if (eocd < 0) throw new IOException("Not a ZIP archive (no end-of-central-directory record)."); + + long eocdPos = fileLen - maxScan + eocd; + long totalEntries = BitConverter.ToUInt16(tail, eocd + 10); + long cdOffset = BitConverter.ToUInt32(tail, eocd + 16); + + // ZIP64: any maxed-out field means the real values live in the + // ZIP64 EOCD record, found via the locator 20 bytes before EOCD. + if (totalEntries == 0xFFFF || cdOffset == 0xFFFFFFFF) + { + long locPos = eocdPos - 20; + if (locPos >= 0) + { + var loc = new byte[20]; + fs.Seek(locPos, SeekOrigin.Begin); + ReadExact(fs, loc, 20); + if (loc[0] == 0x50 && loc[1] == 0x4B && loc[2] == 0x06 && loc[3] == 0x07) + { + long z64Pos = BitConverter.ToInt64(loc, 8); + var z64 = new byte[56]; + fs.Seek(z64Pos, SeekOrigin.Begin); + ReadExact(fs, z64, 56); + if (!(z64[0] == 0x50 && z64[1] == 0x4B && z64[2] == 0x06 && z64[3] == 0x06)) + throw new IOException("Corrupt ZIP64 end-of-central-directory record."); + totalEntries = BitConverter.ToInt64(z64, 32); + cdOffset = BitConverter.ToInt64(z64, 48); + } + } + } + + var list = new List((int)totalEntries); + fs.Seek(cdOffset, SeekOrigin.Begin); + var br = new BinaryReader(fs); // not disposed — would close fs + + for (long i = 0; i < totalEntries; i++) + { + if (br.ReadUInt32() != 0x02014B50) + throw new IOException("Corrupt central directory (bad entry signature)."); + + br.ReadUInt16(); // version made by + br.ReadUInt16(); // version needed + ushort flags = br.ReadUInt16(); + ushort method = br.ReadUInt16(); + br.ReadUInt32(); // DOS mod time/date + br.ReadUInt32(); // CRC-32 + long comp = br.ReadUInt32(); + long uncomp = br.ReadUInt32(); + ushort nameLen = br.ReadUInt16(); + ushort extraLen = br.ReadUInt16(); + ushort commentLen = br.ReadUInt16(); + br.ReadUInt16(); // disk number start + br.ReadUInt16(); // internal attributes + br.ReadUInt32(); // external attributes + long lho = br.ReadUInt32(); + + byte[] nameBytes = br.ReadBytes(nameLen); + byte[] extra = br.ReadBytes(extraLen); + if (commentLen > 0) br.ReadBytes(commentLen); + + // ZIP64 extended-information extra field (id 0x0001): 8-byte + // values present, in order, only for headers that were 0xFFFFFFFF. + int p = 0; + while (p + 4 <= extra.Length) + { + ushort id = BitConverter.ToUInt16(extra, p); + ushort sz = BitConverter.ToUInt16(extra, p + 2); + if (id == 0x0001) + { + int q = p + 4; + if (uncomp == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { uncomp = BitConverter.ToInt64(extra, q); q += 8; } + if (comp == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { comp = BitConverter.ToInt64(extra, q); q += 8; } + if (lho == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { lho = BitConverter.ToInt64(extra, q); } + } + p += 4 + sz; + } + + bool utf8 = (flags & 0x0800) != 0; + var name = (utf8 ? Encoding.UTF8 : Encoding.Default).GetString(nameBytes); + + list.Add(new Entry + { + Name = name, + Method = method, + CompressedSize = comp, + LocalHeaderOffset = lho + }); + } + return list; + } + + // ── Entry extraction ────────────────────────────────────────────────── + + private static void ExtractEntry(FileStream fs, Entry e, string destRoot) + { + if (string.IsNullOrEmpty(e.Name)) return; + + string destPath; + try { destPath = Path.GetFullPath(Path.Combine(destRoot, e.Name.Replace('/', '\\'))); } + catch { return; } // illegal characters in name — skip + + // Zip-slip protection: never write outside the destination root. + if (!destPath.StartsWith(destRoot + "\\", StringComparison.OrdinalIgnoreCase) + && !destPath.Equals(destRoot, StringComparison.OrdinalIgnoreCase)) + return; + + if (e.Name.EndsWith("/") || e.Name.EndsWith("\\")) + { + Directory.CreateDirectory(destPath); + return; + } + + // Local header: its name/extra lengths can differ from the central + // directory's, so read them from the local header itself. + fs.Seek(e.LocalHeaderOffset, SeekOrigin.Begin); + var lh = new byte[30]; + ReadExact(fs, lh, 30); + if (!(lh[0] == 0x50 && lh[1] == 0x4B && lh[2] == 0x03 && lh[3] == 0x04)) + throw new IOException("Corrupt local header for entry '" + e.Name + "'."); + int lNameLen = BitConverter.ToUInt16(lh, 26); + int lExtraLen = BitConverter.ToUInt16(lh, 28); + fs.Seek(lNameLen + lExtraLen, SeekOrigin.Current); + + Directory.CreateDirectory(Path.GetDirectoryName(destPath)); + using (var outFs = File.Create(destPath)) + { + if (e.Method == 0) // stored + { + CopyExactly(fs, outFs, e.CompressedSize); + } + else if (e.Method == 8) // deflate + { + var limited = new LimitedReadStream(fs, e.CompressedSize); + using (var inflate = new DeflateStream(limited, CompressionMode.Decompress, leaveOpen: true)) + inflate.CopyTo(outFs); + } + else + { + throw new IOException(string.Format( + "Unsupported compression method {0} for entry '{1}'.", e.Method, e.Name)); + } + } + } + + private static void CopyExactly(Stream src, Stream dst, long count) + { + var buffer = new byte[65536]; + while (count > 0) + { + int n = src.Read(buffer, 0, (int)Math.Min(buffer.Length, count)); + if (n == 0) throw new IOException("Unexpected end of archive."); + dst.Write(buffer, 0, n); + count -= n; + } + } + + private static void ReadExact(Stream stream, byte[] buf, int count) + { + int off = 0; + while (off < count) + { + int n = stream.Read(buf, off, count - off); + if (n == 0) throw new IOException("Unexpected end of archive."); + off += n; + } + } + + /// Caps reads at N bytes of the underlying stream so DeflateStream's + /// read-ahead cannot consume the next entry's bytes. Does not own the inner + /// stream. + private sealed class LimitedReadStream : Stream + { + private readonly Stream _inner; + private long _remaining; + + public LimitedReadStream(Stream inner, long limit) + { + _inner = inner; + _remaining = limit; + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_remaining <= 0) return 0; + int n = _inner.Read(buffer, offset, (int)Math.Min(count, _remaining)); + _remaining -= n; + return n; + } + + public override bool CanRead => true; + public override bool CanWrite => false; + public override bool CanSeek => false; + public override void Flush() { } + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + } +} diff --git a/Launcher/README.md b/Launcher/README.md index 9955d4a..4086727 100644 --- a/Launcher/README.md +++ b/Launcher/README.md @@ -1,23 +1,33 @@ -# TeslaLauncher +# TeslaLauncher (XP11 — single binary) -.NET Framework 4.8 (framework-dependent) rewrite of the original Elsewhen Studios LLC software (Windows 2000 / .NET Framework 2.0). net48 ships in Windows 10/11, so the pod needs no separate runtime install and the package stays small (~3.7 MB). +.NET Framework **4.0** (framework-dependent) rewrite of the original Elsewhen Studios LLC +software. One `TeslaLauncher.exe` runs on **Windows XP SP3 through Windows 11**: +net40 is the newest framework XP can install, and net40 assemblies load in-place on the +4.8 runtime built into Windows 10/11. XP pods need the .NET 4.0 redistributable +(installed automatically by `install.bat` when `dotnet40\` is in the package); +Win10/11 pods need nothing extra. ## Architecture -TeslaLauncher has three components that work together: +One userland tray application — no Windows Service, no IPC. -### TeslaLauncherService (Windows Service, Session 0) -- Runs at boot before any user logs in +The original Elsewhen software was a single service (on Win2k/XP a service could still +touch the desktop). The modern rewrite split it into Service + Agent purely to work +around Vista+ **Session 0 isolation**. XP11 closes the loop: everything runs in the +auto-logged-in kiosk session, where the desktop, audio, and game processes live anyway. + +### TeslaLauncher (WinForms tray app, user session) - Listens on **TCP 53290** for OFB-encrypted framed-JSON RPC from TeslaConsole -- Forwards commands to the Agent via Named Pipe (`TeslaLauncherIPC`) -- Handles first-boot network configuration (SecureConfig) -- Handles game file transfers from the Console (InstallProduct) +- Handles first-boot network configuration (SecureConfig) and shows the + Request ID / Passphrase on screen + COM2 plasma +- Handles game file transfers from the Console (InstallProduct → `C:\Games`, + `postinstall.bat`, `pre-uninstall.bat` on uninstall) +- Launches/kills/watches simulation apps, controls volume, manages `LaunchApps.xml` +- Registers with WER for restart-after-crash (Vista+; no-op on XP) -### TeslaLauncherAgent (WinForms tray app, user session) -- Runs in the logged-in user's desktop session -- Executes commands that require desktop access: launching/killing apps, volume control -- Manages `LaunchApps.xml` (installed games registry) -- On first boot, displays SecureConfig Request ID and Passphrase +Requires the kiosk account (`Firestorm`) to be in **Administrators** — SecureConfig's +`netsh`/hostname writes and product `postinstall.bat` driver installs need the admin +token (UAC is disabled by the installer on modern Windows, so no prompts). ### SecureConfig (first-boot protocol) - Assigns a temporary IP and broadcasts a UDP beacon so the Console can discover the pod @@ -26,29 +36,25 @@ TeslaLauncher has three components that work together: - TCP handshake establishes an OFB-encrypted session with RSA key exchange - Session key is saved for all subsequent Console connections +Uses the old-style `netsh interface ip` commands throughout — they update the live +TCP/IP stack immediately and are the only form XP understands. + ## Communication Flow ``` -TeslaConsole ──TCP 53290 (OFB + framed JSON)──> TeslaLauncherService - │ - Named Pipe (JSON) - │ - v - TeslaLauncherAgent +TeslaConsole ──TCP 53290 (OFB + framed JSON)──> TeslaLauncher.exe (user session) ``` ## Files | File | Description | |------|-------------| -| `TeslaLauncherService.cs` | Windows Service implementation | -| `TeslaLauncherService.csproj` | Service project (net48, generic host + Windows service) | -| `TeslaLauncherAgent.cs` | Userspace Agent implementation | -| `TeslaLauncherAgent.csproj` | Agent project (WinForms, net48) | -| `LaunchModels_Shared.cs` | Service↔Agent IPC types (Tesla.Launcher.Shared). Wire types (Tesla.Net) now come from `../Contract/Tesla.Contract.csproj` | -| `SecureConfig.cs` | First-boot secure configuration protocol | -| `build.bat` | Builds both components | -| `install.bat` | Installs on a cockpit PC (run as Administrator) | +| `TeslaLauncher.cs` | The whole launcher: tray, TCP listener, RPC dispatch, install, processes, volume | +| `TeslaLauncher.csproj` | net40 WinForms exe project | +| `MiniZip.cs` | Central-directory ZIP extractor (net40 has no `ZipFile`; stored + deflate + ZIP64) | +| `SecureConfig.cs` | First-boot secure configuration protocol + OFB duplex stream | +| `build.bat` | Builds + assembles the package | +| `install.bat` | Dual-OS installer (XP SP3 and Win10/11 code paths; run as Administrator) | ## Building @@ -57,34 +63,47 @@ Requirements: - Internet access for NuGet restore (first build only) ``` -build.bat :: build both components + assemble the package -build.bat /service :: build Service only -build.bat /agent :: build Agent only +build.bat :: build + assemble the package ``` -Output goes to `dist\TeslaLauncher\` (with `Service\` and `Agent\` subdirectories plus -`install.bat`) and `dist\TeslaLauncher-podpkg.zip`, mirroring `Console\dist\`. The projects -are published in place (framework-dependent net48) — they reference `../Contract`, so they -cannot be staged into a temp folder. Each folder holds the exe plus its dependency DLLs; -the target pod needs only .NET Framework 4.8 (built into Windows 10/11), no bundled runtime. +Output goes to `dist\TeslaLauncher\` (with `App\` plus `install.bat` and redist +folders) and `dist\TeslaLauncher-podpkg.zip`. The project is published in place +(framework-dependent net40) — it references `../Contract`, so it cannot be staged +into a temp folder. `App\` holds the exe plus `Newtonsoft.Json.dll` and +`TeslaConsoleLaunchLib.dll` (the net40 leg of the shared contract). + +### Bench-testing switches + +``` +TeslaLauncher.exe /skipconfig :: skip the DHCP SecureConfig gate +TeslaLauncher.exe /port:53291 :: listen on a non-standard port +``` ## Installation -1. Copy the `TeslaLauncher\` folder to each cockpit PC +1. Copy the `TeslaLauncher\` folder to each cockpit PC (XP SP3 or Win10/11) 2. Run `TeslaLauncher\install.bat` as Administrator -The installer: -- Registers the Service (delayed auto-start) -- Configures the Agent for auto-login startup -- Installs OpenAL and DirectX runtimes -- Enables SMB1 file sharing -- Creates `C:\Games` with appropriate permissions -- Resets network adapters to DHCP for SecureConfig +The installer detects the OS and branches where the tooling differs: + +| Step | XP SP3 | Windows 10/11 | +|------|--------|---------------| +| .NET | installs 4.0 redist from `dotnet40\` if missing | 4.8 built in — nothing | +| ACLs | `cacls` | `icacls` | +| Firewall off | `netsh firewall` | `netsh advfirewall` | +| SMB1 / DirectPlay | native — skipped | `dism /Enable-Feature` | +| DHCP reset | `netsh interface ip set address ... dhcp` | PowerShell `Set-NetIPInterface` | +| Notifications / UAC | n/a | policy keys + `EnableLUA=0` | +| UltraVNC | `UltraVNC_x86_Setup.exe` (if bundled) | `UltraVNC_x64_Setup.exe` | + +Common to both: auto-login (`Firestorm`), HKLM Run key for the launcher +(**no service registration**), `C:\Games` + data dir creation, shares, workgroup, +power settings, reboot. ## First Boot -1. Cockpit boots with DHCP (unconfigured state) -2. Service runs SecureConfig: broadcasts beacon, displays codes on screen +1. Cockpit boots with DHCP (unconfigured state), auto-logs into the kiosk account +2. Launcher runs SecureConfig: broadcasts beacon, displays codes on screen + plasma 3. Console operator sees the pod's Request ID and enters the Passphrase 4. Console sends encrypted network configuration 5. Pod applies the configuration and is ready for normal operation @@ -100,21 +119,27 @@ The Console connects to each configured pod on TCP 53290 and can: ## Key Paths +`` is `C:\ProgramData` on Vista+, and +`C:\Documents and Settings\All Users\Application Data` on XP — the launcher and +installer both resolve it per-OS; nothing hardcodes `C:\ProgramData` anymore. + | Path | Purpose | |------|---------| -| `C:\ProgramData\TeslaLauncher\TeslaKeyStore.key` | Session key (32 bytes) | -| `C:\ProgramData\TeslaLauncher\LaunchApps.xml` | Installed games registry | -| `C:\ProgramData\TeslaLauncher\configuring.json` | Transient: SecureConfig codes for Agent display | +| `\TeslaLauncher\TeslaKeyStore.key` | Session key (32 bytes) | +| `\TeslaLauncher\LaunchApps.xml` | Installed games registry (same XML shape as the two-process Agent wrote) | +| `\TeslaLauncher\podconf.log` | Launcher log (was next to the exe pre-XP11) | +| `\TeslaLauncher\configuring.json` | Transient: SecureConfig codes (kept for external diagnostics) | | `C:\Games\` | Game installation directory | ## Wire Protocol -The Console talks to the Service with **length-prefixed System.Text.Json frames** -over the OFB-encrypted TCP stream (dispatch by method name) — see -`../Contract/PodRpcProtocol.cs`, shared by both ends. This replaced the original -`BinaryFormatter` + serialized-`MethodBase` scheme. The `Tesla.Net` wire types now -live in `../Contract/Tesla.Contract.csproj`, the single source of truth shared with -the Console. +The Console talks to the launcher with **length-prefixed JSON frames** over the +OFB-encrypted TCP stream (dispatch by method name) — see +`../Contract/PodRpcProtocol.cs`, shared by both ends. The Contract multi-targets +`net48;net40`: the Console's net48 leg serializes with System.Text.Json, the +launcher's net40 leg with Newtonsoft.Json (STJ has no net40 target). The JSON is +shape-identical on the wire; the request reader keeps date strings raw so a Ping +echo returns byte-identical. -The Service-to-Agent IPC uses length-prefixed JSON over a Named Pipe, with flat types -that avoid the nested struct layout of the wire format. +Volume on XP falls back from CoreAudio (Vista+) to `nircmd.exe` / winmm +`waveOutSetVolume`. diff --git a/Launcher/SecureConfig.cs b/Launcher/SecureConfig.cs index f70ea7c..0cd8dfc 100644 --- a/Launcher/SecureConfig.cs +++ b/Launcher/SecureConfig.cs @@ -416,7 +416,10 @@ namespace TeslaSecureConfig } else { - (_adapterIndex, _adapterId, _adapterName) = FindFirstEthernetAdapter(); + var adapter = FindFirstEthernetAdapter(); + _adapterIndex = adapter.Index; + _adapterId = adapter.Id; + _adapterName = adapter.Name; } _log = logger ?? (s => Debug.WriteLine(s)); } @@ -845,23 +848,23 @@ namespace TeslaSecureConfig Log("Secure connection to console negotiated."); // ── 4. RSA key exchange ───────────────────────────────── - using var rsa = RSA.Create(Proto.RsaKeySize); + // net40: RSA.Create(int) and RSAEncryptionPadding are net46+. + // RSACryptoServiceProvider.Decrypt(data, false) is the same + // PKCS#1 v1.5 padding, and works on XP SP3's CAPI. + using var rsa = new RSACryptoServiceProvider(Proto.RsaKeySize); Log("Sending console final key."); - using (var bw = new BinaryWriter(ofb, Encoding.UTF8, leaveOpen: true)) - { - bw.Write(rsa.ToXmlString(false)); // public key only - bw.Flush(); - } + // net40 BinaryWriter/Reader have no leaveOpen overload; not + // disposing them keeps the OFB stream open, which is the intent. + var bw = new BinaryWriter(ofb, Encoding.UTF8); + bw.Write(rsa.ToXmlString(false)); // public key only + bw.Flush(); Log("Receiving console key."); - byte[] sessionKey; - using (var br = new BinaryReader(ofb, Encoding.UTF8, leaveOpen: true)) - { - int encLen = br.ReadInt32(); - byte[] enc = br.ReadBytes(encLen); - sessionKey = rsa.Decrypt(enc, RSAEncryptionPadding.Pkcs1); - } + var br = new BinaryReader(ofb, Encoding.UTF8); + int encLen = br.ReadInt32(); + byte[] enc = br.ReadBytes(encLen); + byte[] sessionKey = rsa.Decrypt(enc, false); // PKCS#1 v1.5 Log($"Console key received ({sessionKey.Length} bytes)."); // Store session key — used for OFB on the management port (53290) @@ -945,11 +948,12 @@ namespace TeslaSecureConfig _plasma.WriteLine("Passphrase: {0}", _passphrase); #if WINFORMS - // WinForms dialog — only shown when running as the Agent (user session) + // WinForms dialog on its own STA thread. Do NOT call + // SetCompatibleTextRenderingDefault here: the single-binary launcher + // already created its tray form, and calling it after any control + // exists throws InvalidOperationException. var t = new Thread(() => { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); _displayForm = new PasscodeDisplayForm(_requestId, _passphrase); _displayForm.ShowDialog(); }) @@ -1129,10 +1133,18 @@ namespace TeslaSecureConfig Log($"Warning: target IP {targetIp} not on any local subnet — keeping [{_adapterIndex}] {_adapterName}"); } - // Returns (IPv4 interface index, adapter GUID). - // Index is used with `netsh interface ipv4 ... interface=N` (avoids name-quoting issues). - // GUID is used for direct registry writes to persist static IP across reboots. - private static (int index, string id, string name) FindFirstEthernetAdapter() + // IPv4 interface index + adapter GUID + display name (a struct instead of a + // ValueTuple — net40 has no System.ValueTuple). + // Index avoids netsh name-quoting issues; GUID is used for direct registry + // writes to persist the static IP across reboots. + private struct AdapterInfo + { + public int Index; + public string Id; + public string Name; + } + + private static AdapterInfo FindFirstEthernetAdapter() { foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { @@ -1150,7 +1162,7 @@ namespace TeslaSecureConfig { int idx = nic.GetIPProperties().GetIPv4Properties().Index; Log2($"Selected physical adapter: [{idx}] {nic.Name} / {nic.Description}"); - return (idx, nic.Id, nic.Name); + return new AdapterInfo { Index = idx, Id = nic.Id, Name = nic.Name }; } catch (NetworkInformationException) { } } @@ -1165,7 +1177,7 @@ namespace TeslaSecureConfig string.Equals(nic.Description, displayName, StringComparison.OrdinalIgnoreCase)) return nic.Id; } - return FindFirstEthernetAdapter().id; // .name ignored in fallback + return FindFirstEthernetAdapter().Id; // Name ignored in fallback } // Find index for a named adapter (fallback when caller passes a display name) @@ -1181,7 +1193,7 @@ namespace TeslaSecureConfig } } // Fall back to auto-detect if name not matched - return FindFirstEthernetAdapter().index; // .name ignored in fallback + return FindFirstEthernetAdapter().Index; // Name ignored in fallback } // --- Random string generator --- diff --git a/Launcher/TeslaLauncher.cs b/Launcher/TeslaLauncher.cs new file mode 100644 index 0000000..f1e471b --- /dev/null +++ b/Launcher/TeslaLauncher.cs @@ -0,0 +1,1291 @@ +// ============================================================================= +// TeslaLauncher — XP11 single-binary launcher (tray app, user session) +// ============================================================================= +// One userland process replaces the TeslaLauncherService (Session 0) + +// TeslaLauncherAgent (tray) pair. The split existed only to work around +// Vista+ Session 0 isolation; running everything in the auto-logged-in user +// session needs no service, no named pipe, and no flat<->wire conversions. +// The original Elsewhen software was likewise a single binary (a service — +// which could still touch the desktop on Win2k/XP). +// +// Targets net40 so the SAME exe runs on Windows XP SP3 (newest framework XP +// can install) and on Windows 10/11 (net40 loads in-place on the 4.8 runtime). +// +// Responsibilities: +// - Listen on TCP 53290 for OFB-encrypted framed-JSON RPC from TeslaConsole +// - First boot (DHCP): run SecureConfig, show Request ID + Passphrase +// - Install products (receive zip, extract to C:\Games, postinstall.bat) +// - Launch/kill/watch simulation apps, volume, LaunchApps.xml registry +// +// Command line: +// /skipconfig skip the DHCP SecureConfig gate (bench testing) +// /port:NNNN listen on a non-standard port (bench testing) +// ============================================================================= + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.IO.IsolatedStorage; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Windows.Forms; +using Newtonsoft.Json.Linq; +using TeslaSecureConfig; +using Wire = Tesla.Net; + +namespace Tesla.Launcher +{ + public class LauncherApplication : Form + { + // ── Configuration ───────────────────────────────────────────────────── + + private const int DEFAULT_PORT = 53290; + private const string GAMES_DIR = @"C:\Games"; + + // CommonApplicationData differs by OS (XP: ...\All Users\Application Data, + // Vista+: C:\ProgramData) — never hardcode C:\ProgramData here. + private static readonly string DATA_DIR = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "TeslaLauncher"); + private static readonly string CONFIG_FILE = Path.Combine(DATA_DIR, "LaunchApps.xml"); + private static readonly string KEY_FILE = Path.Combine(DATA_DIR, "TeslaKeyStore.key"); + private static readonly string LOG_FILE = Path.Combine(DATA_DIR, "podconf.log"); + + private static bool _skipConfigGate; + private static int _port = DEFAULT_PORT; + + // ── State ───────────────────────────────────────────────────────────── + + private NotifyIcon _trayIcon; + private ContextMenuStrip _trayMenu; + + private List _launchApps = new List(); + private readonly Dictionary> _runningProcesses = + new Dictionary>(); + private readonly object _processLock = new object(); + private volatile bool _stopping; + + private readonly Dictionary _installProgress = + new Dictionary(); + private readonly object _installLock = new object(); + + private byte[] _sessionKey; + private TcpListener _listener; + + // ── Entry point ─────────────────────────────────────────────────────── + + [STAThread] + public static void Main(string[] args) + { + bool createdNew; + using (new Mutex(true, "TeslaLauncherSingleInstance", out createdNew)) + { + if (!createdNew) return; // already running + + foreach (var a in args ?? new string[0]) + { + if (string.Equals(a, "/skipconfig", StringComparison.OrdinalIgnoreCase)) + _skipConfigGate = true; + else if (a.StartsWith("/port:", StringComparison.OrdinalIgnoreCase)) + { + int p; + if (int.TryParse(a.Substring(6), out p) && p > 0 && p < 65536) + _port = p; + } + } + + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new LauncherApplication()); + } + } + + public LauncherApplication() + { + ShowInTaskbar = false; + WindowState = FormWindowState.Minimized; + Opacity = 0; + + BuildTrayIcon(); + LoadLaunchApps(); + + var orchestrator = new Thread(OrchestratorLoop) + { + IsBackground = true, + Name = "Orchestrator" + }; + orchestrator.Start(); + } + + // ── Orchestrator: SecureConfig gate → session key → command listener ── + + private void OrchestratorLoop() + { + Log("=== Launcher starting (single-binary) ==="); + TryRegisterApplicationRestart(); + LogNicState(); + + if (!_skipConfigGate && !IsMachineConfigured()) + { + Log("IsMachineConfigured=false -> running SecureConfig"); + SetTrayStatus("Configuring..."); + RunSecureConfiguration(); + } + + _sessionKey = LoadSessionKey(); + if (_sessionKey == null) + { + Log("ERROR: TeslaKeyStore.key not found - cannot start command listener. " + + "Reboot with DHCP to re-run SecureConfig."); + SetTrayStatus("Not configured (no session key)"); + return; + } + + Log(string.Format("Session key loaded ({0} bytes) -> starting command listener on port {1}", + _sessionKey.Length, _port)); + ListenerLoop(); + } + + private void RunSecureConfiguration() + { + try + { + using (var configurator = new PodSecureConfigurator( + adapterName: null, logger: Log)) + { + // 10-minute timeout — rebroadcasts automatically every 2 s. + var config = configurator.Configure(timeoutMs: 600_000); + if (config == null) + { + Log("SecureConfiguration timed out or failed. Reboot to retry."); + SetTrayStatus("Configuration failed - reboot to retry"); + return; + } + Log("SecureConfiguration complete."); + } + } + catch (Exception ex) + { + Log("SecureConfiguration error: " + ex.Message); + } + } + + // ── TCP command listener (Console connections) ──────────────────────── + + private void ListenerLoop() + { + try + { + _listener = new TcpListener(IPAddress.Any, _port); + _listener.Start(); + } + catch (SocketException ex) + { + Log(string.Format("Cannot listen on port {0}: {1}", _port, ex.Message)); + SetTrayStatus("Port " + _port + " unavailable"); + return; + } + + SetTrayStatus(_launchApps.Count + " apps configured"); + Log("Listening. Waiting for Console connections..."); + + while (!_stopping) + { + TcpClient client; + try + { + client = _listener.AcceptTcpClient(); + } + catch (Exception) + { + if (_stopping) break; + Thread.Sleep(500); + continue; + } + + Log("Console connected from " + client.Client.RemoteEndPoint); + var t = new Thread(() => HandleConsoleClient(client)) + { + IsBackground = true, + Name = "ConsoleClient" + }; + t.Start(); + } + + try { _listener.Stop(); } catch { } + Log("Command listener stopped."); + } + + private void HandleConsoleClient(TcpClient tcpClient) + { + try + { + using (tcpClient) + using (var netStream = tcpClient.GetStream()) + { + netStream.WriteTimeout = 10_000; + netStream.ReadTimeout = 30_000; + + // ── OFB crypto negotiation ──────────────────────────────── + // Both sides exchange 16-byte IVs, then verify "CONF". + var consoleIv = new byte[16]; + ReadExactSync(netStream, consoleIv); + + var podIv = new byte[16]; + using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(podIv); + netStream.Write(podIv, 0, 16); + netStream.Flush(); + + using (var ofb = new OFBDuplexStream(netStream, _sessionKey, + writeIv: consoleIv, // pod→console (other's IV) + readIv: podIv)) // console→pod (own IV) + { + var confMsg = Encoding.UTF8.GetBytes("CONF"); + ofb.Write(confMsg, 0, confMsg.Length); + ofb.Flush(); + + var confReply = new byte[4]; + ReadExactSync(ofb, confReply); + if (!confReply.SequenceEqual(confMsg)) + { + Log("CONF mismatch - session key mismatch, dropping connection."); + return; + } + + RpcSessionLoop(ofb, tcpClient); + } + } + } + catch (IOException) { /* console went away mid-handshake */ } + catch (Exception ex) + { + Log(string.Format("Console handler error ({0}): {1}", ex.GetType().Name, ex.Message)); + } + Log("Console session ended."); + } + + private void RpcSessionLoop(Stream rpcStream, TcpClient client) + { + while (!_stopping && client.Connected) + { + Wire.RpcRequest request; + try + { + request = Wire.PodRpc.ReadRequest(rpcStream); + } + catch (EndOfStreamException) { break; } + catch (IOException) { break; } + catch (Exception ex) + { + Log(string.Format("Request read error ({0}): {1}", ex.GetType().Name, ex.Message)); + break; + } + if (request == null) break; + + var funcName = request.Method ?? "???"; + var args = request.Args ?? new List(); + + var start = DateTime.UtcNow; + object resultObj = null; + string error = null; + try + { + resultObj = DispatchCommand(funcName, args); + } + catch (Exception ex) + { + error = ex.Message; + Log(string.Format("CMD {0} ERROR: {1}", funcName, ex.Message)); + } + + if (error == null) + Log(string.Format("CMD {0} OK ({1:F0}ms)", funcName, + (DateTime.UtcNow - start).TotalMilliseconds)); + + try + { + Wire.PodRpc.WriteResponse(rpcStream, resultObj, error); + } + catch (IOException) { break; } + catch (Exception serEx) + { + Log(string.Format("Serialize failed for {0}: {1}", funcName, serEx.Message)); + try { Wire.PodRpc.WriteResponse(rpcStream, null, "Pod error: " + serEx.Message); } + catch { break; } + } + + // ── File receive after InitiateInstallProduct ──────────────── + // The Console streams the game archive on this same OFB stream + // right after the Guid response: 8-byte Int64 size + raw bytes. + if (funcName == "InitiateInstallProduct" && error == null && resultObj is Guid) + { + ReceiveInstallFile(rpcStream, (Guid)resultObj); + break; // connection done after file transfer + } + } + } + + // ── Command dispatch ────────────────────────────────────────────────── + // Arguments arrive as JTokens (Newtonsoft leg of PodRpc); results are + // wire types serialized straight into the response — no IPC hop. + + private object DispatchCommand(string functionName, List args) + { + switch (functionName) + { + case "Ping": + // Echo the DateTime argument byte-identically (the request + // reader keeps date strings raw for exactly this purpose). + return HasArg(args, 0) ? (object)args[0] : DateTime.Now; + + case "GetLaunchableApps": + { + lock (_processLock) + return _launchApps.Select(a => new Wire.LaunchPair + { + LaunchKey = ParseGuid(a.LaunchKey), + DisplayName = a.DisplayName + }).ToArray(); + } + + case "GetInstalledApps": + lock (_processLock) + return _launchApps.Select(ToWire).ToArray(); + + case "GetLaunchedApps": + return SnapshotLaunchedApps(); + + case "FullUpdate": + { + var launched = SnapshotLaunchedApps(); + lock (_processLock) + return new Wire.FullUpdateData + { + InstalledApps = _launchApps.Select(ToWire).ToArray(), + LaunchedApps = launched, + VolumeLevel = GetMasterVolume() + }; + } + + case "get_VolumeLevel": + return GetMasterVolume(); + + case "set_VolumeLevel": + SetMasterVolume(Math.Max(0f, Math.Min(1f, ArgFloat(args, 0, 1.0f)))); + return null; + + case "LaunchApp": + return CmdLaunchApp(ArgGuid(args, 0)); + + case "KillApp": + CmdKillApp(ArgGuid(args, 0), ArgIntOrNull(args, 1)); + return null; + + case "KillAllOfType": + CmdKillAllOfType(ArgGuid(args, 0)); + return null; + + case "KillAllApps": + CmdKillAllApps(); + return null; + + case "Shutdown": + CmdShutdown(ArgBool(args, 0)); + return null; + + case "ClearStore": + CmdClearStore(); + return null; + + case "RemoveApp": + CmdRemoveApp(ArgGuid(args, 0)); + return null; + + case "InstallApp": + { + if (!HasArg(args, 0)) throw new Exception("InstallApp requires LaunchData"); + CmdInstallApp(args[0].ToObject()); + return null; + } + + case "UninstallApp": + CmdUninstallApp(ArgGuid(args, 0)); + return null; + + case "InitiateInstallProduct": + { + var callId = Guid.NewGuid(); + lock (_installLock) + _installProgress[callId] = new Wire.OutOfBandProgress + { + PercentComplete = 0, + Status = "Waiting for file...", + IsCompleted = false + }; + return callId; + } + + case "GetOutOfBandProgress": + { + var id = ArgGuid(args, 0); + lock (_installLock) + { + Wire.OutOfBandProgress prog; + if (_installProgress.TryGetValue(id, out prog)) return prog; + } + return new Wire.OutOfBandProgress + { + PercentComplete = 0, + Status = "Unknown call ID", + IsCompleted = true + }; + } + + default: + throw new Exception("Unknown command: " + functionName); + } + } + + // ── Command implementations ─────────────────────────────────────────── + + private int CmdLaunchApp(Guid launchKey) + { + var key = launchKey.ToString(); + var app = FindApp(key); + if (app == null) + throw new Exception("No app configured for key '" + key + "'"); + + // A launch entry can be registered (pushed from the Console) before its + // game files are installed. Fail cleanly with an actionable message. + if (string.IsNullOrEmpty(app.ExeFile) || !File.Exists(app.ExeFile)) + throw new Exception(string.Format( + "Cannot launch '{0}': executable not found at '{1}'. The product may be " + + "registered but not yet installed on this pod.", + app.DisplayName ?? key, app.ExeFile)); + + var psi = new ProcessStartInfo + { + FileName = app.ExeFile, + Arguments = app.Arguments ?? "", + WorkingDirectory = string.IsNullOrEmpty(app.WorkingDirectory) + ? Path.GetDirectoryName(app.ExeFile) + : app.WorkingDirectory, + UseShellExecute = false + }; + + var process = Process.Start(psi); + if (process == null) + throw new Exception("Process.Start returned null for '" + app.ExeFile + "'"); + + lock (_processLock) + { + List procs; + if (!_runningProcesses.TryGetValue(app.LaunchKey, out procs)) + _runningProcesses[app.LaunchKey] = procs = new List(); + procs.Add(process); + } + + if (app.AutoRestart) StartWatcher(app, process); + + SetTrayStatus("Running: " + (app.DisplayName ?? key)); + return process.Id; + } + + private void CmdKillApp(Guid launchKey, int? pid) + { + var key = FindKeyString(launchKey); + lock (_processLock) + { + List procs; + if (key == null || !_runningProcesses.TryGetValue(key, out procs)) return; + + var toKill = pid.HasValue + ? procs.Where(p => p.Id == pid.Value).ToList() + : procs.ToList(); + + foreach (var p in toKill) + { + try { if (!p.HasExited) p.Kill(); } catch { } + procs.Remove(p); + } + if (procs.Count == 0) _runningProcesses.Remove(key); + } + } + + private void CmdKillAllOfType(Guid launchKey) + { + var key = FindKeyString(launchKey); + lock (_processLock) + { + List procs; + if (key == null || !_runningProcesses.TryGetValue(key, out procs)) return; + foreach (var p in procs) + try { if (!p.HasExited) p.Kill(); } catch { } + _runningProcesses.Remove(key); + } + } + + private void CmdKillAllApps() + { + lock (_processLock) + { + foreach (var kvp in _runningProcesses) + foreach (var p in kvp.Value) + try { if (!p.HasExited) p.Kill(); } catch { } + _runningProcesses.Clear(); + } + SetTrayStatus("All apps stopped"); + } + + private void CmdShutdown(bool restart) + { + CmdKillAllApps(); + var flag = restart ? "/r" : "/s"; + new Thread(() => + { + Thread.Sleep(2000); + Process.Start("shutdown", flag + " /t 0"); + }) + { IsBackground = true }.Start(); + } + + private void CmdClearStore() + { + var store = IsolatedStorageFile.GetMachineStoreForAssembly(); + foreach (var file in store.GetFileNames("*")) + store.DeleteFile(file); + LoadLaunchApps(); + } + + private void CmdRemoveApp(Guid launchKey) + { + var key = launchKey.ToString(); + lock (_processLock) + _launchApps.RemoveAll(a => + string.Equals(a.LaunchKey, key, StringComparison.OrdinalIgnoreCase)); + SaveLaunchApps(); + } + + private void CmdInstallApp(Wire.LaunchData data) + { + var entry = FromWire(data); + lock (_processLock) + { + var existing = _launchApps.FindIndex(a => + string.Equals(a.LaunchKey, entry.LaunchKey, StringComparison.OrdinalIgnoreCase)); + if (existing >= 0) _launchApps[existing] = entry; + else _launchApps.Add(entry); + } + SaveLaunchApps(); + } + + private void CmdUninstallApp(Guid launchKey) + { + CmdKillAllOfType(launchKey); + + var key = launchKey.ToString(); + LaunchData removed; + string cleanupDir = null; + lock (_processLock) + { + removed = _launchApps.FirstOrDefault(a => + string.Equals(a.LaunchKey, key, StringComparison.OrdinalIgnoreCase)); + _launchApps.RemoveAll(a => + string.Equals(a.LaunchKey, key, StringComparison.OrdinalIgnoreCase)); + + // If the removed app's product directory under C:\Games is no longer + // used by any remaining entry, clean it up (pre-uninstall.bat + delete). + // The orphan check keeps a product with several launch entries sharing + // one folder (e.g. Red Planet's GameClient/LC/MR) intact until its + // LAST entry is removed. + if (removed != null) + { + var dir = ProductDirUnderGames(removed.WorkingDirectory) + ?? ProductDirUnderGames(removed.ExeFile); + if (dir != null && !_launchApps.Any(a => string.Equals( + ProductDirUnderGames(a.WorkingDirectory) ?? ProductDirUnderGames(a.ExeFile), + dir, StringComparison.OrdinalIgnoreCase))) + cleanupDir = dir; + } + } + SaveLaunchApps(); + + // Physical cleanup can exceed the Console's RPC timeout — background it. + if (cleanupDir != null) + new Thread(() => CleanupProductDirectory(cleanupDir)) + { IsBackground = true }.Start(); + } + + // ── Install product: file receive + extract ────────────────────────── + + private void ReceiveInstallFile(Stream stream, Guid callId) + { + string tempZip = null; + try + { + // ── Phase 1: Receive file ──────────────────────────────────── + var sizeBuf = new byte[8]; + ReadExactSync(stream, sizeBuf); + long fileSize = BitConverter.ToInt64(sizeBuf, 0); + + Log(string.Format("INSTALL {0:N}: receiving {1:N0} bytes...", callId, fileSize)); + UpdateProgress(callId, 0, "Receiving file..."); + + Directory.CreateDirectory(DATA_DIR); + tempZip = Path.Combine(DATA_DIR, string.Format("install_{0:N}.zip", callId)); + + using (var fs = File.Create(tempZip)) + { + var buffer = new byte[65536]; + long received = 0; + while (received < fileSize) + { + var toRead = (int)Math.Min(buffer.Length, fileSize - received); + var n = stream.Read(buffer, 0, toRead); + if (n == 0) throw new IOException("Connection closed during file transfer."); + fs.Write(buffer, 0, n); + received += n; + UpdateProgress(callId, (int)(received * 50 / fileSize), "Receiving file..."); + } + } + + Log(string.Format("INSTALL {0:N}: file received ({1:N0} bytes)", callId, fileSize)); + + // ── Phase 2: Extract to C:\Games (MiniZip: net40 has no ZipFile) ─ + UpdateProgress(callId, 50, "Extracting..."); + Directory.CreateDirectory(GAMES_DIR); + MiniZip.ExtractToDirectory(tempZip, GAMES_DIR, + (done, total) => UpdateProgress(callId, + 50 + done * 45 / Math.Max(total, 1), "Extracting...")); + + Log(string.Format("INSTALL {0:N}: extracted to {1}", callId, GAMES_DIR)); + + // ── Phase 3: Run postinstall.bat if present ────────────────── + var postInstall = Path.Combine(GAMES_DIR, "postinstall.bat"); + if (File.Exists(postInstall)) + { + UpdateProgress(callId, 96, "Running postinstall..."); + Log(string.Format("INSTALL {0:N}: running postinstall.bat", callId)); + try + { + using (var proc = Process.Start(new ProcessStartInfo + { + FileName = postInstall, + WorkingDirectory = GAMES_DIR, + UseShellExecute = false, + CreateNoWindow = true + })) + { + if (proc != null) proc.WaitForExit(60_000); + } + } + catch (Exception ex) + { + Log(string.Format("INSTALL {0:N}: postinstall.bat error: {1}", callId, ex.Message)); + } + try { File.Delete(postInstall); } catch { } + } + + // Console checks PercentComplete == 99 to break out of its + // 3-iteration install loop (InstallProductWorker in PodInfo). + UpdateProgress(callId, 99, "Complete", isCompleted: true); + Log(string.Format("INSTALL {0:N}: install complete", callId)); + } + catch (Exception ex) + { + UpdateProgress(callId, 0, "Failed: " + ex.Message, isCompleted: true); + Log(string.Format("INSTALL {0:N}: FAILED: {1}", callId, ex.Message)); + } + finally + { + try { if (tempZip != null) File.Delete(tempZip); } catch { } + } + } + + private void UpdateProgress(Guid callId, int pct, string status, bool isCompleted = false) + { + lock (_installLock) + _installProgress[callId] = new Wire.OutOfBandProgress + { + PercentComplete = pct, + Status = status, + IsCompleted = isCompleted + }; + } + + // ── Product uninstall cleanup ───────────────────────────────────────── + // Run the product's pre-uninstall.bat if it ships one (e.g. RIOJoy removes + // ViGEmBus + config), then delete the C:\Games\ directory. + + private void CleanupProductDirectory(string productDir) + { + if (string.IsNullOrEmpty(productDir)) return; + + string full; + try { full = Path.GetFullPath(productDir); } catch { return; } + + // Safety: only ever remove a proper subdirectory of C:\Games. + var gamesRoot = Path.GetFullPath(GAMES_DIR).TrimEnd('\\'); + var target = full.TrimEnd('\\'); + if (!target.StartsWith(gamesRoot + "\\", StringComparison.OrdinalIgnoreCase) + || target.Equals(gamesRoot, StringComparison.OrdinalIgnoreCase)) + { + Log("UNINSTALL: refusing to clean unsafe path '" + full + "'."); + return; + } + if (!Directory.Exists(full)) { Log("UNINSTALL: " + full + " already absent."); return; } + + var preUninstall = Path.Combine(full, "pre-uninstall.bat"); + if (File.Exists(preUninstall)) + { + Log("UNINSTALL: running " + preUninstall); + try + { + using (var proc = Process.Start(new ProcessStartInfo + { + FileName = preUninstall, + WorkingDirectory = full, + UseShellExecute = false, + CreateNoWindow = true + })) + { + if (proc != null) + { + proc.WaitForExit(120_000); + Log("UNINSTALL: pre-uninstall.bat exit code " + proc.ExitCode); + } + } + } + catch (Exception ex) { Log("UNINSTALL: pre-uninstall.bat error: " + ex.Message); } + } + + for (int attempt = 1; attempt <= 3; attempt++) + { + try + { + Directory.Delete(full, recursive: true); + Log("UNINSTALL: removed " + full); + return; + } + catch (Exception ex) + { + if (attempt == 3) { Log("UNINSTALL: could not remove " + full + ": " + ex.Message); return; } + Thread.Sleep(1000); + } + } + } + + /// 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.IsNullOrEmpty(path)) return null; + string full; + try { full = Path.GetFullPath(path); } catch { return null; } + const string games = @"C:\Games\"; + if (!full.StartsWith(games, StringComparison.OrdinalIgnoreCase)) return null; + var seg = full.Substring(games.Length).Split('\\', '/')[0]; + return string.IsNullOrEmpty(seg) ? null : games + seg; + } + + // ── Process tracking helpers ────────────────────────────────────────── + + private Wire.LaunchedAppData[] SnapshotLaunchedApps() + { + var result = new List(); + lock (_processLock) + { + foreach (var kvp in _runningProcesses) + { + kvp.Value.RemoveAll(p => p.HasExited); + foreach (var p in kvp.Value) + result.Add(new Wire.LaunchedAppData + { + LaunchKey = ParseGuid(kvp.Key), + ProcessId = p.Id + }); + } + foreach (var key in _runningProcesses.Keys + .Where(k => _runningProcesses[k].Count == 0).ToList()) + _runningProcesses.Remove(key); + } + return result.ToArray(); + } + + private LaunchData FindApp(string launchKey) + { + lock (_processLock) + return _launchApps.FirstOrDefault(a => + string.Equals(a.LaunchKey, launchKey, StringComparison.OrdinalIgnoreCase)); + } + + /// Maps a wire Guid to the tracked key string (which preserves the + /// exact casing used when the process was launched). + private string FindKeyString(Guid launchKey) + { + var key = launchKey.ToString(); + lock (_processLock) + { + foreach (var k in _runningProcesses.Keys) + if (string.Equals(k, key, StringComparison.OrdinalIgnoreCase)) return k; + } + return key; + } + + private void StartWatcher(LaunchData app, Process process) + { + new Thread(() => + { + process.WaitForExit(); + if (_stopping) return; + + bool stillTracked; + lock (_processLock) + { + List procs; + stillTracked = _runningProcesses.TryGetValue(app.LaunchKey, out procs) + && procs.Contains(process); + } + if (!stillTracked) return; + + Thread.Sleep(2000); + try { CmdLaunchApp(ParseGuid(app.LaunchKey)); } catch { } + }) + { IsBackground = true, Name = "Watcher-" + app.LaunchKey }.Start(); + } + + // ── LaunchApps.xml ──────────────────────────────────────────────────── + + private void LoadLaunchApps() + { + try + { + if (!File.Exists(CONFIG_FILE)) + { + lock (_processLock) _launchApps = new List(); + SetTrayStatus("Config not found"); + return; + } + + var xml = new System.Xml.Serialization.XmlSerializer(typeof(List)); + using (var reader = File.OpenRead(CONFIG_FILE)) + { + var apps = (List)xml.Deserialize(reader) ?? new List(); + lock (_processLock) _launchApps = apps; + } + SetTrayStatus(_launchApps.Count + " apps configured"); + } + catch (Exception ex) + { + SetTrayStatus("Config error: " + ex.Message); + } + } + + private void SaveLaunchApps() + { + try + { + var xml = new System.Xml.Serialization.XmlSerializer(typeof(List)); + Directory.CreateDirectory(Path.GetDirectoryName(CONFIG_FILE)); + List snapshot; + lock (_processLock) snapshot = new List(_launchApps); + using (var writer = File.CreateText(CONFIG_FILE)) + xml.Serialize(writer, snapshot); + } + catch (Exception ex) + { + Log("SaveLaunchApps error: " + ex.Message); + } + } + + private static Wire.LaunchData ToWire(LaunchData e) + { + return new Wire.LaunchData + { + LaunchPair = new Wire.LaunchPair + { + LaunchKey = ParseGuid(e.LaunchKey), + DisplayName = e.DisplayName + }, + WorkingDirectory = e.WorkingDirectory, + ExeFile = e.ExeFile, + Arguments = e.Arguments, + AutoRestart = e.AutoRestart + }; + } + + private static LaunchData FromWire(Wire.LaunchData d) + { + return new LaunchData + { + LaunchKey = d.LaunchPair.LaunchKey.ToString(), + DisplayName = d.LaunchPair.DisplayName, + WorkingDirectory = d.WorkingDirectory, + ExeFile = d.ExeFile, + Arguments = d.Arguments, + AutoRestart = d.AutoRestart + }; + } + + // ── Volume control ──────────────────────────────────────────────────── + // The Console stores/retrieves volume as a float (0.0–1.0 scalar). We + // cache the exact value the Console sent so get returns the same value + // without device roundtrip quantization error. + // + // Setter chain: nircmd.exe (legacy, works everywhere incl. XP) + // → CoreAudio (Vista+) → winmm waveOutSetVolume (XP fallback). + + private float _cachedVolumeScalar = 1.0f; + + private float GetMasterVolume() + { + return _cachedVolumeScalar; + } + + private void SetMasterVolume(float scalar) + { + _cachedVolumeScalar = scalar; + + var nircmd = Path.Combine(GAMES_DIR, "nircmd.exe"); + if (File.Exists(nircmd)) + { + try + { + var p = Process.Start(nircmd, "setsysvolume " + (int)(scalar * 65535)); + if (p != null) p.Dispose(); + return; + } + catch { /* fall through to API */ } + } + + if (Environment.OSVersion.Version.Major >= 6) + { + try { CoreAudio.SetMasterScalar(scalar); return; } + catch { /* fall through */ } + } + + WinMmVolume.SetMasterScalar(scalar); + } + + // ── Session key ─────────────────────────────────────────────────────── + + private static byte[] LoadSessionKey() + { + if (!File.Exists(KEY_FILE)) return null; + using (var fs = File.Open(KEY_FILE, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + var len = fs.ReadByte(); + if (len <= 0) return null; + var key = new byte[len]; + int off = 0; + while (off < key.Length) + { + int n = fs.Read(key, off, key.Length - off); + if (n == 0) break; + off += n; + } + return off == key.Length ? key : null; + } + } + + // ── Machine-configured check (same rule as SecureConfig) ───────────── + + private static bool IsMachineConfigured() + { + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue; + if (nic.OperationalStatus != OperationalStatus.Up) continue; + if (IsVirtualAdapter(nic)) continue; + try + { + var ipv4 = nic.GetIPProperties().GetIPv4Properties(); + if (!ipv4.IsDhcpEnabled) return true; // static = configured + } + catch (NetworkInformationException) { } + } + return false; + } + + private static bool IsVirtualAdapter(NetworkInterface nic) + { + var desc = nic.Description ?? ""; + var name = nic.Name ?? ""; + string[] markers = { + "Virtual", "Hyper-V", "VMware", "VirtualBox", + "Loopback", "Tunnel", "Miniport", "Wi-Fi Direct", + "Bluetooth", "WAN Miniport", "Microsoft Kernel Debug" + }; + foreach (var m in markers) + if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + var mac = nic.GetPhysicalAddress().GetAddressBytes(); + if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true; // locally administered + return false; + } + + private void LogNicState() + { + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue; + if (nic.OperationalStatus != OperationalStatus.Up) continue; + try + { + var ipv4 = nic.GetIPProperties().GetIPv4Properties(); + Log(string.Format(" NIC [{0}] DHCP={1} Virtual={2}", + nic.Name, ipv4.IsDhcpEnabled, IsVirtualAdapter(nic))); + } + catch { Log(string.Format(" NIC [{0}] (no IPv4 props)", nic.Name)); } + } + } + + // ── Tray UI ─────────────────────────────────────────────────────────── + + private void BuildTrayIcon() + { + _trayMenu = new ContextMenuStrip(); + var version = typeof(LauncherApplication).Assembly.GetName().Version; + _trayMenu.Items.Add("Tesla Launcher v" + version, null, null).Enabled = false; + _trayMenu.Items.Add(new ToolStripSeparator()); + _trayMenu.Items.Add("Reload Config", null, (s, e) => LoadLaunchApps()); + _trayMenu.Items.Add("Kill All Apps", null, (s, e) => CmdKillAllApps()); + _trayMenu.Items.Add(new ToolStripSeparator()); + _trayMenu.Items.Add("Exit", null, (s, e) => ExitLauncher()); + + _trayIcon = new NotifyIcon + { + Text = "Tesla Launcher", + Icon = SystemIcons.Application, + ContextMenuStrip = _trayMenu, + Visible = true + }; + } + + private void ExitLauncher() + { + _stopping = true; + try { if (_listener != null) _listener.Stop(); } catch { } + _trayIcon.Visible = false; + Application.Exit(); + } + + private void SetTrayStatus(string status) + { + if (_trayIcon == null) return; + var text = "Tesla Launcher: " + status; + if (text.Length > 63) text = text.Substring(0, 63); // NotifyIcon.Text limit + try { _trayIcon.Text = text; } catch { } + } + + protected override void OnLoad(EventArgs e) + { + base.OnLoad(e); + Visible = false; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + if (_trayIcon != null) _trayIcon.Dispose(); + if (_trayMenu != null) _trayMenu.Dispose(); + } + base.Dispose(disposing); + } + + // ── Argument helpers (JTokens from the Newtonsoft PodRpc leg) ───────── + + private static bool HasArg(List args, int i) + => args != null && args.Count > i && args[i] != null + && args[i].Type != JTokenType.Null; + + private static Guid ArgGuid(List args, int i) + { + if (!HasArg(args, i)) return Guid.Empty; + Guid g; + return Guid.TryParse(args[i].Value(), out g) ? g : Guid.Empty; + } + + private static int? ArgIntOrNull(List args, int i) + { + if (!HasArg(args, i)) return null; + var t = args[i]; + if (t.Type == JTokenType.Integer || t.Type == JTokenType.Float) + return t.Value(); + return null; + } + + private static bool ArgBool(List args, int i) + => HasArg(args, i) && args[i].Type == JTokenType.Boolean && args[i].Value(); + + private static float ArgFloat(List args, int i, float fallback) + { + if (!HasArg(args, i)) return fallback; + var t = args[i]; + if (t.Type == JTokenType.Integer || t.Type == JTokenType.Float) + return t.Value(); + return fallback; + } + + private static Guid ParseGuid(string s) + { + Guid g; + return s != null && Guid.TryParse(s, out g) ? g : Guid.Empty; + } + + // ── Misc helpers ────────────────────────────────────────────────────── + + /// Reads exactly buf.Length bytes from stream; throws IOException on EOF. + private static void ReadExactSync(Stream stream, byte[] buf) + { + int off = 0; + while (off < buf.Length) + { + int n = stream.Read(buf, off, buf.Length - off); + if (n == 0) throw new IOException("Connection closed before all bytes received."); + off += n; + } + } + + private static readonly object _logLock = new object(); + + private static void Log(string msg) + { + try + { + lock (_logLock) + { + Directory.CreateDirectory(DATA_DIR); + File.AppendAllText(LOG_FILE, + string.Format("[{0:HH:mm:ss}] {1}{2}", DateTime.Now, msg, Environment.NewLine)); + } + } + catch { /* logging must never take the launcher down */ } + } + + // WER relaunches the app after a crash/hang (Vista+; no-op on XP). + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int RegisterApplicationRestart(string commandLine, int flags); + + private static void TryRegisterApplicationRestart() + { + if (Environment.OSVersion.Version.Major < 6) return; + try { RegisterApplicationRestart(null, 0); } catch { } + } + } + + // ── Launcher-local data type ────────────────────────────────────────────── + // Loaded from LaunchApps.xml via XmlSerializer. The CLASS NAME and property + // names are load-bearing: they define the XML element names, and existing + // pods already have LaunchApps.xml files written by the two-process Agent + // with this exact shape. (The wire struct is Tesla.Net.LaunchData.) + + [Serializable] + public class LaunchData + { + public string LaunchKey { get; set; } + public string DisplayName { get; set; } + public string WorkingDirectory { get; set; } + public string ExeFile { get; set; } + public string Arguments { get; set; } + public bool AutoRestart { get; set; } + } + + // ── Windows Core Audio API — minimal COM interop (Vista+) ───────────────── + // No external dependencies. Vtable slot order matches the SDK headers exactly. + // Methods we don't call are declared as void stubs to preserve vtable offsets. + // NOT available on XP — callers must gate on OS version. + + internal static class CoreAudio + { + internal static void SetMasterScalar(float scalar) + { + var ep = GetEndpointVolume(); + try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); } + finally { Marshal.ReleaseComObject(ep); } + } + + private static IAudioEndpointVolume GetEndpointVolume() + { + var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass(); + try + { + IMMDevice device; + enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device); + try + { + var iid = typeof(IAudioEndpointVolume).GUID; + object obj; + device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out obj); + return (IAudioEndpointVolume)obj; + } + finally { Marshal.ReleaseComObject(device); } + } + finally { Marshal.ReleaseComObject(enumerator); } + } + } + + [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] + internal class MMAudioEnumeratorComClass { } + + [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IMMDeviceEnumerator + { + void _unused_EnumAudioEndpoints(); // slot 0 — not used + [PreserveSig] + int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint); + } + + [Guid("D666063F-1587-4E43-81F1-B948E807363F"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IMMDevice + { + [PreserveSig] + int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams, + [MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer); + } + + // Vtable order (after IUnknown): RegisterControlChangeNotify(0), + // UnregisterControlChangeNotify(1), GetChannelCount(2), + // SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4), + // GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6) + [Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IAudioEndpointVolume + { + void _unused_RegisterControlChangeNotify(); // slot 0 + void _unused_UnregisterControlChangeNotify(); // slot 1 + void _unused_GetChannelCount(); // slot 2 + [PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx); + [PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx); + [PreserveSig] int GetMasterVolumeLevel(out float levelDB); + [PreserveSig] int GetMasterVolumeLevelScalar(out float level); + } + + // ── winmm wave-out volume (XP fallback) ─────────────────────────────────── + // waveOutSetVolume with device -1 sets the wave mixer level of the default + // device: low 16 bits = left channel, high 16 bits = right channel. + + internal static class WinMmVolume + { + [DllImport("winmm.dll")] + private static extern int waveOutSetVolume(IntPtr hwo, uint dwVolume); + + internal static void SetMasterScalar(float scalar) + { + uint level = (uint)(Math.Max(0f, Math.Min(1f, scalar)) * 0xFFFF); + try { waveOutSetVolume(IntPtr.Zero, (level << 16) | level); } catch { } + } + } +} diff --git a/Launcher/TeslaLauncher.csproj b/Launcher/TeslaLauncher.csproj new file mode 100644 index 0000000..72f82bd --- /dev/null +++ b/Launcher/TeslaLauncher.csproj @@ -0,0 +1,42 @@ + + + + + net40 + WinExe + disable + disable + latest + 4.11.4.1 + app.ico + TeslaLauncher + Tesla.Launcher + Tesla.Launcher.LauncherApplication + + $(DefineConstants);WINFORMS + + + + + + + + + + + + + + diff --git a/Launcher/TeslaLauncher.sln b/Launcher/TeslaLauncher.sln index 0575f46..4498b81 100644 --- a/Launcher/TeslaLauncher.sln +++ b/Launcher/TeslaLauncher.sln @@ -2,9 +2,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherAgent", "TeslaLauncherAgent.csproj", "{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherService", "TeslaLauncherService.csproj", "{37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncher", "TeslaLauncher.csproj", "{96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -16,10 +14,6 @@ Global {96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}.Debug|Any CPU.Build.0 = Debug|Any CPU {96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}.Release|Any CPU.ActiveCfg = Release|Any CPU {96766D0D-C3A1-A4C4-E93D-B963FBAC4C56}.Release|Any CPU.Build.0 = Release|Any CPU - {37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}.Debug|Any CPU.Build.0 = Debug|Any CPU - {37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}.Release|Any CPU.ActiveCfg = Release|Any CPU - {37D7E6C3-8DFD-CCB8-AFDF-18F2C0A48862}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Launcher/TeslaLauncherAgent.cs b/Launcher/TeslaLauncherAgent.cs deleted file mode 100644 index 3349f39..0000000 --- a/Launcher/TeslaLauncherAgent.cs +++ /dev/null @@ -1,989 +0,0 @@ -// ============================================================================= -// 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); - } -} diff --git a/Launcher/TeslaLauncherAgent.csproj b/Launcher/TeslaLauncherAgent.csproj deleted file mode 100644 index b119c72..0000000 --- a/Launcher/TeslaLauncherAgent.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - net48 - WinExe - true - disable - disable - latest - - 4.11.4.1 - app.ico - TeslaLauncherAgent - Tesla.Launcher.Agent - Tesla.Launcher.Agent.AgentApplication - - - - - - - - - - - - - - - diff --git a/Launcher/TeslaLauncherService.cs b/Launcher/TeslaLauncherService.cs deleted file mode 100644 index 7b5fdea..0000000 --- a/Launcher/TeslaLauncherService.cs +++ /dev/null @@ -1,1046 +0,0 @@ -// ============================================================================= -// TeslaLauncher — Windows Service (Session 0) -// ============================================================================= -// Replaces TeslaLauncherService.exe (Elsewhen Studios LLC, .NET Framework 2.0). -// Runs in Session 0, auto-starts at boot before user login. -// Listens on TCP 53290 for OFB-encrypted framed-JSON RPC from TeslaConsole. -// Forwards commands to TeslaLauncherAgent (user session) via Named Pipe. -// -// On first boot (DHCP detected), runs SecureConfig to receive network -// configuration from the Console and establish the session key. -// ============================================================================= - -using System; -using System.Collections.Generic; -using System.Linq; -using System.IO; -using System.IO.Compression; -using System.IO.Pipes; -using System.Net; -using System.Net.Sockets; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using TeslaSecureConfig; -using Tesla.Launcher.Shared; -using Tesla.Net; - -namespace Tesla.Launcher.Service -{ - public class TeslaLauncherService : BackgroundService - { - // --------------------------------------------------------------- - // CONFIGURATION - // --------------------------------------------------------------- - - /// - /// TCP port the Console connects to for RPC commands. - /// Original: ManagePort = 53290 (confirmed from decompiled TeslaLauncherService.exe). - /// Each connection is wrapped in OFB encryption via NegotiateCryptoStreams - /// using the session key from SecureConfig (TeslaKeyStore.key). - /// - private const int CONSOLE_PORT = 53290; - - /// - /// Named pipe connecting this service to TeslaLauncherAgent.exe. - /// Must match PIPE_NAME in TeslaLauncherAgent.cs. - /// - private const string PIPE_NAME = "TeslaLauncherIPC"; - - /// - /// Seconds to wait for the Agent to connect on the Named Pipe. - /// If the Agent isn't running (user not yet logged in), we return an error. - /// - private const int AGENT_CONNECT_TIMEOUT_MS = 3000; - - // --------------------------------------------------------------- - - /// Path to session key file (original KeyStore format). - private static readonly string KEY_FILE = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - "TeslaLauncher", "TeslaKeyStore.key"); - - private readonly ILogger _logger; - private TcpListener _listener; - private readonly List _clientTasks = new(); - private readonly object _taskLock = new(); - private byte[] _sessionKey; - - // Install product — file transfer progress tracking. - // InitiateInstallProduct creates a Guid; the Console then streams the - // game archive on the SAME OFB connection. GetOutOfBandProgress polls - // this dictionary from a separate connection. - private readonly Dictionary _installProgress = new(); - private readonly object _installLock = new(); - - public TeslaLauncherService(ILogger logger) - { - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation( - "Tesla Launcher Service starting on port {Port}", CONSOLE_PORT); - - // ── Boot diagnostic — always written so every reboot is traceable ── - // Logs adapter state to podconf.log before IsMachineConfigured() decides, - // visible even if SecureConfig does not run. - var logPath = Path.Combine(AppContext.BaseDirectory, "podconf.log"); - void StartupLog(string msg) => File.AppendAllText(logPath, - $"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}"); - StartupLog("=== Service starting ==="); - foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()) - { - if (nic.NetworkInterfaceType != System.Net.NetworkInformation.NetworkInterfaceType.Ethernet) continue; - if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue; - try - { - var ipv4 = nic.GetIPProperties().GetIPv4Properties(); - StartupLog($" NIC [{nic.Name}] DHCP={ipv4.IsDhcpEnabled} Virtual={IsVirtualAdapter(nic)}"); - } - catch { StartupLog($" NIC [{nic.Name}] (no IPv4 props)"); } - } - - // ── First-boot secure configuration ────────────────────────────── - // If the Ethernet adapter is still on DHCP the cockpit has not yet - // been configured. Run PodSecureConfigurator here in Session 0: - // • broadcasts the RQST beacon (MAC + RequestId) - // • displays passphrase on COM2 / PlasmaIO - // • receives UDP RPLY with AES-encrypted network configuration - // • applies netsh IP settings and hostname - // • completes TCP handshake with Console + RSA key exchange - // • saves 32-byte session key to TeslaKeyStore.key - // After SecureConfig, fall through to the normal command listener — - // the original Service does the same (ExecutionMode after Configure). - if (!IsMachineConfigured()) - { - StartupLog("IsMachineConfigured=false → running SecureConfig"); - _logger.LogInformation("Machine is unconfigured (DHCP). Running SecureConfiguration."); - await Task.Run(() => RunSecureConfiguration(stoppingToken), stoppingToken); - // Load key that was saved during SecureConfig - _sessionKey = LoadSessionKey(); - if (_sessionKey == null) - { - _logger.LogWarning("SecureConfig completed but no session key was saved. Cannot start command listener."); - return; - } - StartupLog("SecureConfig complete → starting command listener on port 53290"); - } - else - { - StartupLog("IsMachineConfigured=true → loading session key"); - _sessionKey = LoadSessionKey(); - if (_sessionKey == null) - { - _logger.LogWarning("Machine is configured but TeslaKeyStore.key not found. Reboot with DHCP to re-run SecureConfig."); - StartupLog("ERROR: TeslaKeyStore.key not found — cannot start command listener"); - return; - } - StartupLog($"Session key loaded ({_sessionKey.Length} bytes) → starting normal service"); - } - _logger.LogInformation("Starting command listener on port {Port}.", CONSOLE_PORT); - - try - { - _listener = new TcpListener(IPAddress.Any, CONSOLE_PORT); - _listener.Start(); - _logger.LogInformation("Listening. Waiting for Console connections..."); - - // net48's TcpListener has no CancellationToken overload for accept; - // stop the listener on cancellation so the pending accept unblocks. - using var stopReg = stoppingToken.Register(() => { try { _listener.Stop(); } catch { } }); - - while (!stoppingToken.IsCancellationRequested) - { - TcpClient client; - try - { - client = await _listener.AcceptTcpClientAsync(); - } - catch (OperationCanceledException) { break; } - catch (Exception) when (stoppingToken.IsCancellationRequested) { break; } - catch (Exception ex) - { - _logger.LogError(ex, "Error accepting Console connection"); - continue; - } - - _logger.LogInformation( - "Console connected from {EP}", client.Client.RemoteEndPoint); - - // Handle each Console connection on its own thread. - // Frame reads are synchronous, so we offload to a - // thread-pool thread via Task.Run. - var task = Task.Run( - () => HandleConsoleClient(client, stoppingToken), - stoppingToken); - - lock (_taskLock) - { - _clientTasks.Add(task); - _clientTasks.RemoveAll(t => t.IsCompleted); - } - } - } - finally - { - _listener?.Stop(); - _logger.LogInformation("Tesla Launcher Service stopped."); - } - } - - // ── Console Connection Handler ─────────────────────────────────── - - private async Task HandleConsoleClient(TcpClient tcpClient, CancellationToken ct) - { - using var client = tcpClient; - using var netStream = client.GetStream(); - netStream.WriteTimeout = 10_000; - netStream.ReadTimeout = 30_000; - - // ── OFB crypto negotiation ──────────────────────────────────── - // Both sides exchange 16-byte IVs, then verify "CONF" handshake. - - // Read Console's IV - var consoleIv = new byte[16]; - try { ReadExactSync(netStream, consoleIv); } - catch (Exception ex) - { - _logger.LogWarning("Failed reading Console IV: {Msg}", ex.Message); - return; - } - - // Generate and send our IV - var podIv = new byte[16]; - using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(podIv); - netStream.Write(podIv, 0, 16); - netStream.Flush(); - - // Create OFB duplex stream — lives for the entire session - using var ofb = new OFBDuplexStream(netStream, _sessionKey, - writeIv: consoleIv, // pod→console (other's IV) - readIv: podIv); // console→pod (own IV) - - // CONF handshake — verify both sides have the same session key - var confMsg = Encoding.UTF8.GetBytes("CONF"); - ofb.Write(confMsg, 0, confMsg.Length); - ofb.Flush(); - - var confReply = new byte[4]; - try { ReadExactSync(ofb, confReply); } - catch (Exception ex) - { - _logger.LogWarning("CONF read failed: {Msg}", ex.Message); - return; - } - if (!confReply.SequenceEqual(confMsg)) - { - _logger.LogWarning("CONF mismatch — session key mismatch, dropping connection."); - return; - } - - Stream rpcStream = ofb; - var diagLog = Path.Combine(AppContext.BaseDirectory, "podconf.log"); - _logger.LogInformation("Console session started."); - - try - { - while (!ct.IsCancellationRequested && client.Connected) - { - RpcRequest request; - try - { - request = PodRpc.ReadRequest(rpcStream); - } - catch (EndOfStreamException) - { - _logger.LogInformation("Console disconnected (EOF)."); - break; - } - catch (IOException ex) - { - _logger.LogInformation("Console disconnected (I/O: {Msg}).", ex.Message); - break; - } - catch (Exception ex) - { - _logger.LogWarning("Request read error ({Type}): {Msg}", - ex.GetType().Name, ex.Message); - File.AppendAllText(diagLog, - $"[{DateTime.Now:HH:mm:ss}] REQ ERROR: {ex}{Environment.NewLine}"); - break; - } - - if (request == null) break; - - var funcName = request.Method ?? "???"; - IReadOnlyList args = request.Args ?? new List(); - _logger.LogInformation("Command: {Func}({ArgCount} args)", funcName, args.Count); - - var start = DateTime.UtcNow; - object resultObj = null; - string error = null; - - try - { - resultObj = await DispatchCommandAsync(funcName, args, ct); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error executing {Func}", funcName); - error = ex.Message; - File.AppendAllText(diagLog, - $"[{DateTime.Now:HH:mm:ss}] CMD {funcName} ERROR: {ex.Message}{Environment.NewLine}"); - } - - var dur = DateTime.UtcNow - start; - if (error == null) - { - File.AppendAllText(diagLog, - $"[{DateTime.Now:HH:mm:ss}] CMD {funcName} OK ({dur.TotalMilliseconds:F0}ms)" + - $"{(resultObj != null ? $" → {resultObj}" : "")}{Environment.NewLine}"); - } - - try - { - PodRpc.WriteResponse(rpcStream, resultObj, error); - } - catch (IOException) - { - _logger.LogInformation("Console disconnected while writing response."); - break; - } - catch (Exception serEx) - { - // Result type not JSON-serializable — send error instead. - _logger.LogWarning("Serialize failed for {Func}: {Msg}", - funcName, serEx.Message); - try { PodRpc.WriteResponse(rpcStream, null, $"Pod error: {serEx.Message}"); } - catch { break; } - } - - // ── File receive after InitiateInstallProduct ──────────── - // The Console streams the game archive on this same OFB - // connection immediately after receiving the Guid response: - // 8-byte Int64 file size + raw file bytes - // Then closes the connection (FIN). - if (funcName == "InitiateInstallProduct" - && error == null - && resultObj is Guid installGuid) - { - ReceiveInstallFile(rpcStream, installGuid, diagLog); - break; // connection done after file transfer - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Unhandled error in Console handler"); - File.AppendAllText(diagLog, - $"[{DateTime.Now:HH:mm:ss}] HANDLER ERROR: {ex.GetType().Name}: {ex.Message}{Environment.NewLine}" + - $"[{DateTime.Now:HH:mm:ss}] Inner: {ex.InnerException?.Message}{Environment.NewLine}"); - } - - _logger.LogInformation("Console session ended."); - } - - // ── Command Dispatch ───────────────────────────────────────────── - // Arguments arrive as JSON elements (RpcRequest.Args); each case extracts - // the typed values it needs. The returned object is serialized straight - // into the RpcResponse. - - private async Task DispatchCommandAsync( - string functionName, IReadOnlyList args, CancellationToken ct) - { - switch (functionName) - { - case "Ping": - // Echo back the DateTime parameter (Console measures round-trip) - return args.Count > 0 && args[0].ValueKind != JsonValueKind.Null - ? args[0].GetDateTime() - : DateTime.Now; - - case "GetLaunchableApps": - { - var json = await ForwardToAgentJsonAsync(functionName, args, ct); - var wire = FlatArrayToWire(json); - var pairs = new LaunchPair[wire.Length]; - for (int i = 0; i < wire.Length; i++) - pairs[i] = wire[i].LaunchPair; - return pairs; - } - - case "GetInstalledApps": - { - var json = await ForwardToAgentJsonAsync(functionName, args, ct); - return FlatArrayToWire(json); - } - - case "GetLaunchedApps": - { - var json = await ForwardToAgentJsonAsync(functionName, args, ct); - return FlatLaunchedArrayToWire(json); - } - - case "FullUpdate": - { - var json = await ForwardToAgentJsonAsync(functionName, args, ct); - if (json == null) return new FullUpdateData(); - var flat = JsonSerializer.Deserialize(json); - if (flat == null) return new FullUpdateData(); - var result2 = new FullUpdateData - { - InstalledApps = flat.InstalledApps != null - ? Array.ConvertAll(flat.InstalledApps, FlatToWire) - : Array.Empty(), - LaunchedApps = flat.LaunchedApps != null - ? Array.ConvertAll(flat.LaunchedApps, FlatLaunchedToWire) - : Array.Empty(), - VolumeLevel = flat.VolumeLevel - }; - return result2; - } - - case "GetOutOfBandProgress": - { - // Handled directly in the Service — progress is tracked - // during file receive on the InitiateInstallProduct connection. - var diagPath = Path.Combine(AppContext.BaseDirectory, "podconf.log"); - Guid progressId = Guid.Empty; - bool guidMatch = false; - if (args.Count > 0 && args[0].ValueKind == JsonValueKind.String - && Guid.TryParse(args[0].GetString(), out var pg)) - { - progressId = pg; - guidMatch = true; - } - - if (guidMatch) - { - lock (_installLock) - { - if (_installProgress.TryGetValue(progressId, out var prog)) - { - _logger.LogDebug( - "GetOutOfBandProgress({Id}): {Pct}% {Status} done={Done}", - progressId, prog.PercentComplete, prog.Status, prog.IsCompleted); - return prog; - } - } - File.AppendAllText(diagPath, - $"[{DateTime.Now:HH:mm:ss}] OOBP: Guid {progressId} NOT FOUND in _installProgress (keys={string.Join(",", GetProgressKeys())}){Environment.NewLine}"); - } - else - { - File.AppendAllText(diagPath, - $"[{DateTime.Now:HH:mm:ss}] OOBP: arg[0] not a Guid (kind={(args.Count > 0 ? args[0].ValueKind.ToString() : "none")}){Environment.NewLine}"); - } - return new OutOfBandProgress - { - PercentComplete = 0, - Status = "Unknown call ID", - IsCompleted = true - }; - } - - case "LaunchApp": - { - var json = await ForwardToAgentJsonAsync(functionName, args, ct); - if (json == null) return 0; - var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("ProcessId", out var pid)) - return pid.GetInt32(); - return 0; - } - - case "get_VolumeLevel": - { - var json = await ForwardToAgentJsonAsync(functionName, args, ct); - return json != null ? JsonSerializer.Deserialize(json) : 1.0f; - } - - case "InitiateInstallProduct": - { - var callId = Guid.NewGuid(); - lock (_installLock) - { - _installProgress[callId] = new OutOfBandProgress - { - PercentComplete = 0, - Status = "Waiting for file...", - IsCompleted = false - }; - } - return callId; - } - - case "InstallApp": - { - // Flatten the wire LaunchData into the Agent's flat JSON format. - var ld = args.Count > 0 - ? args[0].Deserialize(PodRpc.JsonOptions) - : default; - var flat = new - { - LaunchKey = ld.LaunchPair.LaunchKey.ToString(), - DisplayName = ld.LaunchPair.DisplayName, - WorkingDirectory = ld.WorkingDirectory, - ExeFile = ld.ExeFile, - Arguments = ld.Arguments, - AutoRestart = ld.AutoRestart - }; - var payloadJson = JsonSerializer.Serialize(new object[] { flat }); - await ForwardToAgentCoreAsync(functionName, launchKey: null, payloadJson, ct); - return null; - } - - case "KillApp": - case "KillAllOfType": - case "KillAllApps": - case "Shutdown": - case "ClearStore": - case "RemoveApp": - { - await ForwardToAgentJsonAsync(functionName, args, ct); - return null; - } - - case "UninstallApp": - { - // The Agent kills + unregisters the app and returns the orphaned - // product directory to remove (or null). The physical cleanup - // (pre-uninstall.bat + delete) can exceed the Console's RPC timeout, - // so respond immediately and clean up on a background thread. - var json = await ForwardToAgentJsonAsync(functionName, args, ct); - if (json != null) - { - try - { - using var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("CleanupDir", out var cd) - && cd.ValueKind == JsonValueKind.String) - { - var dir = cd.GetString(); - var logPath = Path.Combine(AppContext.BaseDirectory, "podconf.log"); - _ = Task.Run(() => CleanupProductDirectory(dir, logPath)); - } - } - catch (Exception ex) - { - _logger.LogWarning("UninstallApp cleanup parse failed: {Msg}", ex.Message); - } - } - return null; - } - - case "set_VolumeLevel": - await ForwardToAgentJsonAsync(functionName, args, ct); - return null; - - default: - _logger.LogWarning("Unknown command: {Func}", functionName); - await ForwardToAgentJsonAsync(functionName, args, ct); - return null; - } - } - - // ── Install Product File Receive ───────────────────────────────── - - private const string GAMES_DIR = @"C:\Games"; - - private void ReceiveInstallFile(Stream stream, Guid callId, string logPath) - { - void Log(string msg) => File.AppendAllText(logPath, - $"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}"); - - string tempZip = null; - - try - { - // ── Phase 1: Receive file ──────────────────────────────── - var sizeBuf = new byte[8]; - ReadExactSync(stream, sizeBuf); - long fileSize = BitConverter.ToInt64(sizeBuf, 0); - - Log($"INSTALL {callId:N}: receiving {fileSize:N0} bytes..."); - UpdateProgress(callId, 0, "Receiving file..."); - - var dataDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - "TeslaLauncher"); - Directory.CreateDirectory(dataDir); - - tempZip = Path.Combine(dataDir, $"install_{callId:N}.zip"); - - using (var fs = File.Create(tempZip)) - { - var buffer = new byte[65536]; - long received = 0; - - while (received < fileSize) - { - var toRead = (int)Math.Min(buffer.Length, fileSize - received); - var n = stream.Read(buffer, 0, toRead); - if (n == 0) throw new IOException("Connection closed during file transfer."); - fs.Write(buffer, 0, n); - received += n; - - // Report receive progress as 0–50% - int pct = (int)(received * 50 / fileSize); - UpdateProgress(callId, pct, "Receiving file..."); - } - } - - Log($"INSTALL {callId:N}: file received ({fileSize:N0} bytes)"); - - // ── Phase 2: Extract to C:\Games\ ──────────────────────── - UpdateProgress(callId, 50, "Extracting..."); - Directory.CreateDirectory(GAMES_DIR); - - using (var zip = ZipFile.OpenRead(tempZip)) - { - int total = zip.Entries.Count; - int done = 0; - - foreach (var entry in zip.Entries) - { - var destPath = Path.GetFullPath( - Path.Combine(GAMES_DIR, entry.FullName)); - - // Zip-slip protection - if (!destPath.StartsWith(GAMES_DIR, StringComparison.OrdinalIgnoreCase)) - continue; - - if (string.IsNullOrEmpty(entry.Name)) - { - // Directory entry - Directory.CreateDirectory(destPath); - } - else - { - Directory.CreateDirectory(Path.GetDirectoryName(destPath)); - entry.ExtractToFile(destPath, overwrite: true); - } - - done++; - // Report extract progress as 50–95% - int pct = 50 + (done * 45 / Math.Max(total, 1)); - UpdateProgress(callId, pct, "Extracting..."); - } - } - - Log($"INSTALL {callId:N}: extracted to {GAMES_DIR}"); - - // ── Phase 3: Run postinstall.bat if present ────────────── - var postInstall = Path.Combine(GAMES_DIR, "postinstall.bat"); - if (File.Exists(postInstall)) - { - UpdateProgress(callId, 96, "Running postinstall..."); - Log($"INSTALL {callId:N}: running postinstall.bat"); - - try - { - using var proc = System.Diagnostics.Process.Start( - new System.Diagnostics.ProcessStartInfo - { - FileName = postInstall, - WorkingDirectory = GAMES_DIR, - UseShellExecute = false, - CreateNoWindow = true - }); - proc?.WaitForExit(60_000); // 60 s timeout - } - catch (Exception ex) - { - Log($"INSTALL {callId:N}: postinstall.bat error: {ex.Message}"); - } - - try { File.Delete(postInstall); } catch { } - Log($"INSTALL {callId:N}: postinstall.bat completed and removed"); - } - - // ── Done ───────────────────────────────────────────────── - // Console checks PercentComplete == 99 to break out of its - // 3-iteration install loop (InstallProductWorker in PodInfo). - // Using 100 causes the Console to retry all 3 times. - UpdateProgress(callId, 99, "Complete", isCompleted: true); - Log($"INSTALL {callId:N}: install complete"); - } - catch (Exception ex) - { - UpdateProgress(callId, 0, $"Failed: {ex.Message}", isCompleted: true); - Log($"INSTALL {callId:N}: FAILED: {ex.Message}"); - _logger.LogError(ex, "Install {Id} failed", callId); - } - finally - { - // Clean up temp zip - try { if (tempZip != null) File.Delete(tempZip); } catch { } - } - } - - private string[] GetProgressKeys() - { - lock (_installLock) - { - return Array.ConvertAll( - new List(_installProgress.Keys).ToArray(), - g => g.ToString()); - } - } - - // ── Product Uninstall Cleanup ──────────────────────────────────── - // Runs as SYSTEM (the Service) on a background thread after UninstallApp, - // when the Agent reports the product directory is orphaned: run its - // pre-uninstall.bat if it ships one (e.g. RIOJoy removes ViGEmBus + config), - // then delete the C:\Games\ directory. - - private void CleanupProductDirectory(string productDir, string logPath) - { - void Log(string msg) => File.AppendAllText(logPath, - $"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}"); - - if (string.IsNullOrWhiteSpace(productDir)) return; - - string full; - try { full = Path.GetFullPath(productDir); } catch { return; } - - // Safety: only ever remove a proper subdirectory of C:\Games — never - // C:\Games itself, never anything outside it. - var gamesRoot = Path.GetFullPath(GAMES_DIR).TrimEnd('\\'); - var target = full.TrimEnd('\\'); - if (!target.StartsWith(gamesRoot + "\\", StringComparison.OrdinalIgnoreCase) - || target.Equals(gamesRoot, StringComparison.OrdinalIgnoreCase)) - { - Log($"UNINSTALL: refusing to clean unsafe path '{full}'."); - return; - } - if (!Directory.Exists(full)) { Log($"UNINSTALL: {full} already absent."); return; } - - // 1. Run pre-uninstall.bat if the product ships one (idempotent by design). - var preUninstall = Path.Combine(full, "pre-uninstall.bat"); - if (File.Exists(preUninstall)) - { - Log($"UNINSTALL: running {preUninstall}"); - try - { - using var proc = System.Diagnostics.Process.Start( - new System.Diagnostics.ProcessStartInfo - { - FileName = preUninstall, - WorkingDirectory = full, - UseShellExecute = false, - CreateNoWindow = true - }); - proc?.WaitForExit(120_000); - Log($"UNINSTALL: pre-uninstall.bat exit code {(proc != null ? proc.ExitCode.ToString() : "?")}"); - } - catch (Exception ex) { Log($"UNINSTALL: pre-uninstall.bat error: {ex.Message}"); } - } - - // 2. Delete the product directory (retry briefly for lingering handles). - for (int attempt = 1; attempt <= 3; attempt++) - { - try - { - Directory.Delete(full, recursive: true); - Log($"UNINSTALL: removed {full}"); - return; - } - catch (Exception ex) - { - if (attempt == 3) { Log($"UNINSTALL: could not remove {full}: {ex.Message}"); return; } - Thread.Sleep(1000); - } - } - } - - // ── Flat ↔ Wire Conversion ──────────────────────────────────────── - - private class FlatLaunchData - { - 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; } - } - - private class FlatLaunchedAppData - { - public string LaunchKey { get; set; } - public int ProcessId { get; set; } - } - - private class FlatFullUpdateData - { - public FlatLaunchData[] InstalledApps { get; set; } - public FlatLaunchedAppData[] LaunchedApps { get; set; } - public float VolumeLevel { get; set; } - } - - private static LaunchData FlatToWire(FlatLaunchData f) - { - return new LaunchData - { - LaunchPair = new LaunchPair - { - LaunchKey = Guid.TryParse(f.LaunchKey, out var g) ? g : Guid.Empty, - DisplayName = f.DisplayName - }, - WorkingDirectory = f.WorkingDirectory, - ExeFile = f.ExeFile, - Arguments = f.Arguments, - AutoRestart = f.AutoRestart - }; - } - - private static LaunchedAppData FlatLaunchedToWire(FlatLaunchedAppData f) - { - return new LaunchedAppData - { - LaunchKey = Guid.TryParse(f.LaunchKey, out var g) ? g : Guid.Empty, - ProcessId = f.ProcessId - }; - } - - private static LaunchedAppData[] FlatLaunchedArrayToWire(string json) - { - if (json == null) return Array.Empty(); - var flat = JsonSerializer.Deserialize(json); - if (flat == null) return Array.Empty(); - return Array.ConvertAll(flat, FlatLaunchedToWire); - } - - private static LaunchData[] FlatArrayToWire(string json) - { - if (json == null) return Array.Empty(); - var flat = JsonSerializer.Deserialize(json); - if (flat == null) return Array.Empty(); - var result = new LaunchData[flat.Length]; - for (int i = 0; i < flat.Length; i++) - result[i] = FlatToWire(flat[i]); - return result; - } - - private void UpdateProgress(Guid callId, int pct, string status, bool isCompleted = false) - { - lock (_installLock) - { - _installProgress[callId] = new OutOfBandProgress - { - PercentComplete = pct, - Status = status, - IsCompleted = isCompleted - }; - } - } - - // ── Secure Configuration ───────────────────────────────────────── - - private static bool IsVirtualAdapter(System.Net.NetworkInformation.NetworkInterface nic) - { - var desc = nic.Description ?? ""; var name = nic.Name ?? ""; - string[] markers = { "Virtual", "Hyper-V", "VMware", "VirtualBox", - "Loopback", "Tunnel", "Miniport", "Wi-Fi Direct", "Bluetooth" }; - 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; - return false; - } - - private static bool IsMachineConfigured() - { - foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()) - { - if (nic.NetworkInterfaceType != System.Net.NetworkInformation.NetworkInterfaceType.Ethernet) continue; - if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue; - if (IsVirtualAdapter(nic)) continue; - try - { - var ipv4 = nic.GetIPProperties().GetIPv4Properties(); - if (!ipv4.IsDhcpEnabled) return true; // static = configured - } - catch (System.Net.NetworkInformation.NetworkInformationException) { } - } - return false; - } - - private void RunSecureConfiguration(CancellationToken ct) - { - var logPath = Path.Combine(AppContext.BaseDirectory, "podconf.log"); - void Log(string msg) => File.AppendAllText(logPath, - $"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}"); - - try - { - using var configurator = new PodSecureConfigurator( - adapterName: null, - logger: msg => { Log(msg); _logger.LogInformation(msg); }); - - // 10-minute timeout — rebroadcasts automatically every 2 s - var config = configurator.Configure(timeoutMs: 600_000); - - if (config == null) - { - _logger.LogWarning( - "SecureConfiguration timed out. Machine will need a reboot to retry."); - return; - } - - _logger.LogInformation("SecureConfiguration complete."); - } - catch (Exception ex) - { - _logger.LogError(ex, "SecureConfiguration failed"); - } - } - - // ── Session Key ────────────────────────────────────────────────── - - private static byte[] LoadSessionKey() - { - if (!File.Exists(KEY_FILE)) return null; - using var fs = File.Open(KEY_FILE, FileMode.Open, FileAccess.Read, FileShare.Read); - var len = fs.ReadByte(); - if (len <= 0) return null; - var key = new byte[len]; - int off = 0; - while (off < key.Length) - { - int n = fs.Read(key, off, key.Length - off); - if (n == 0) break; - off += n; - } - return off == key.Length ? key : null; - } - - /// Reads exactly buf.Length bytes from stream; throws IOException on EOF. - private static void ReadExactSync(Stream stream, byte[] buf) - { - int off = 0; - while (off < buf.Length) - { - int n = stream.Read(buf, off, buf.Length - off); - if (n == 0) throw new IOException("Connection closed before all bytes received."); - off += n; - } - } - - // ── Named Pipe IPC ─────────────────────────────────────────────── - - private Task ForwardToAgentJsonAsync( - string functionName, IReadOnlyList args, CancellationToken ct) - { - string launchKey = null; - if (args != null && args.Count > 0 && args[0].ValueKind == JsonValueKind.String - && Guid.TryParse(args[0].GetString(), out var guid)) - launchKey = guid.ToString(); - - string payloadJson = (args != null && args.Count > 0) - ? JsonSerializer.Serialize(args) - : null; - - return ForwardToAgentCoreAsync(functionName, launchKey, payloadJson, ct); - } - - private async Task ForwardToAgentCoreAsync( - string functionName, string launchKey, string payloadJson, CancellationToken ct) - { - var msg = new IpcMessage - { - Command = functionName.ToUpperInvariant(), - LaunchKey = launchKey, - PayloadJson = payloadJson - }; - - using var pipe = new NamedPipeClientStream( - ".", PIPE_NAME, PipeDirection.InOut, PipeOptions.Asynchronous); - - try - { - await pipe.ConnectAsync(AGENT_CONNECT_TIMEOUT_MS, ct); - } - catch (TimeoutException) - { - _logger.LogWarning( - "Agent pipe timeout — is TeslaLauncherAgent.exe running?"); - throw new Exception( - "Cockpit agent is not available. " + - "Ensure TeslaLauncherAgent.exe is running in the user session."); - } - - // Send IpcMessage - var reqBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(msg)); - await pipe.WriteAsync(BitConverter.GetBytes(reqBytes.Length), 0, 4, ct); - await pipe.WriteAsync(reqBytes, 0, reqBytes.Length, ct); - await pipe.FlushAsync(ct); - - // Read IpcResponse - var lenBuf = new byte[4]; - await ReadExactAsync(pipe, lenBuf, ct); - var resBuf = new byte[BitConverter.ToInt32(lenBuf, 0)]; - await ReadExactAsync(pipe, resBuf, ct); - - var response = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(resBuf)); - - if (response == null) - throw new Exception("Null response from Agent."); - - if (!response.Success) - throw new Exception(response.Message ?? "Agent returned failure."); - - if (response.Data is System.Text.Json.JsonElement je) - return je.GetRawText(); - return response.Data?.ToString(); - } - - private static async Task ReadExactAsync(Stream stream, byte[] buf, CancellationToken ct) - { - var off = 0; - while (off < buf.Length) - { - var n = await stream.ReadAsync(buf, off, buf.Length - off, ct); - if (n == 0) throw new IOException("Pipe closed before all bytes were read."); - off += n; - } - } - } - - // ── Entry Point ────────────────────────────────────────────────────── - - public class Program - { - public static void Main(string[] args) - { - Host.CreateDefaultBuilder(args) - .UseWindowsService(options => - { - options.ServiceName = "Tesla Application Launcher"; - }) - .ConfigureServices(services => - { - services.AddHostedService(); - }) - .ConfigureLogging(logging => - { - logging.AddEventLog(settings => - { - settings.SourceName = "Tesla Application Launcher"; - }); - }) - .Build() - .Run(); - } - } -} diff --git a/Launcher/TeslaLauncherService.csproj b/Launcher/TeslaLauncherService.csproj deleted file mode 100644 index 406795f..0000000 --- a/Launcher/TeslaLauncherService.csproj +++ /dev/null @@ -1,45 +0,0 @@ - - - - net48 - Exe - disable - disable - latest - - true - 4.11.4.1 - app.ico - TeslaLauncherService - Tesla.Launcher.Service - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Launcher/build.bat b/Launcher/build.bat index aa0b0af..9429783 100644 --- a/Launcher/build.bat +++ b/Launcher/build.bat @@ -1,55 +1,52 @@ @echo off :: ============================================================================= -:: TeslaLauncher — Unified Build / Package Script +:: TeslaLauncher - Unified Build / Package Script (XP11 single binary) :: ============================================================================= -:: Publishes TeslaLauncherService.exe and TeslaLauncherAgent.exe as -:: framework-dependent net48 builds and assembles the installable TeslaLauncher\ -:: package next to install.bat. The target pod needs .NET Framework 4.8 (built -:: into Windows 10/11) — there is no bundled runtime, so the package is small. +:: Publishes TeslaLauncher.exe as a framework-dependent net40 build and +:: assembles the installable TeslaLauncher\ package next to install.bat. +:: +:: net40 is deliberate: it is the newest .NET Framework that installs on +:: Windows XP SP3, and net40 assemblies run in-place on the 4.8 runtime built +:: into Windows 10/11 - so this ONE exe covers XP SP3 through Windows 11. +:: - XP SP3 pods additionally need the .NET Framework 4.0 redistributable +:: (dotNetFx40_Full_x86_x64.exe); drop it into assets\dotnet40\ to have +:: install.bat run it automatically when missing. +:: - Windows 10/11 pods need nothing extra. :: :: Requirements: :: .NET SDK (6.0+) to drive the build https://dotnet.microsoft.com/download :: Internet access for NuGet restore (first build only) :: :: Usage: -:: build.bat - build both components + package -:: build.bat /service - build Service only -:: build.bat /agent - build Agent only +:: build.bat - build + package +:: build.bat /q - no pause on exit :: :: Output (under dist\, parity with Console\dist\): :: dist\TeslaLauncher\ :: install.bat -:: Service\TeslaLauncherService.exe -:: Agent\TeslaLauncherAgent.exe -:: dx9201006\ openal\ UltraVNC\ (redist, mirrored from assets\) -:: dist\TeslaLauncher-podpkg.zip (the deployable package) +:: App\TeslaLauncher.exe (+ Newtonsoft.Json.dll, TeslaConsoleLaunchLib.dll) +:: dx9201006\ openal\ UltraVNC\ dotnet40\ (redist, mirrored from assets\) +:: dist\TeslaLauncher-podpkg.zip (the deployable package) :: -:: NOTE: the projects reference ..\Contract\Tesla.Contract.csproj, so they are -:: published IN PLACE (not staged into a temp folder) — the project reference +:: NOTE: the project references ..\Contract\Tesla.Contract.csproj, so it is +:: published IN PLACE (not staged into a temp folder) - the project reference :: must be able to resolve relative to this directory. :: ============================================================================= setlocal enabledelayedexpansion set ROOT=%~dp0 -:: Package under dist\ (parity with Console\dist\). set BUILD_DIR=%ROOT%dist\TeslaLauncher set ZIP=%ROOT%dist\TeslaLauncher-podpkg.zip -:: -- Parse arguments ---------------------------------------------------------- -set BUILD_SERVICE=1 -set BUILD_AGENT=1 set QUIET=0 - for %%a in (%*) do ( - if /i "%%~a"=="/service" set BUILD_AGENT=0 - if /i "%%~a"=="/agent" set BUILD_SERVICE=0 - if /i "%%~a"=="/q" set QUIET=1 + if /i "%%~a"=="/q" set QUIET=1 ) echo. echo ============================================================ -echo Tesla Launcher v4.11.4.1 - Build ^& Package (net48, framework-dependent) +echo Tesla Launcher v4.11.4.1 - Build ^& Package (net40 single binary) echo Output : %BUILD_DIR% echo ============================================================ echo. @@ -68,60 +65,38 @@ echo SDK : %SDK_VER% echo. :: -- Clean prior package output ---------------------------------------------- +if exist "%BUILD_DIR%\App" rmdir /s /q "%BUILD_DIR%\App" if exist "%BUILD_DIR%\Service" rmdir /s /q "%BUILD_DIR%\Service" if exist "%BUILD_DIR%\Agent" rmdir /s /q "%BUILD_DIR%\Agent" if exist "%BUILD_DIR%\dx9201006" rmdir /s /q "%BUILD_DIR%\dx9201006" if exist "%BUILD_DIR%\openal" rmdir /s /q "%BUILD_DIR%\openal" if exist "%BUILD_DIR%\UltraVNC" rmdir /s /q "%BUILD_DIR%\UltraVNC" +if exist "%BUILD_DIR%\dotnet40" rmdir /s /q "%BUILD_DIR%\dotnet40" -:: -- Build Service ------------------------------------------------------------ -if %BUILD_SERVICE%==0 goto :skip_service - -echo [1/2] Publishing TeslaLauncherService (Session 0 Windows Service)... +:: -- Build -------------------------------------------------------------------- +echo [1/1] Publishing TeslaLauncher (net40 single binary)... echo. -dotnet publish "%ROOT%TeslaLauncherService.csproj" ^ +dotnet publish "%ROOT%TeslaLauncher.csproj" ^ -c Release ^ - -o "%BUILD_DIR%\Service" + -o "%BUILD_DIR%\App" if errorlevel 1 ( echo. - echo ERROR: Service build failed. + echo ERROR: build failed. if "%QUIET%"=="0" pause exit /b 1 ) echo. -echo Service : %BUILD_DIR%\Service\TeslaLauncherService.exe +echo Launcher : %BUILD_DIR%\App\TeslaLauncher.exe echo. -:skip_service - -:: -- Build Agent -------------------------------------------------------------- -if %BUILD_AGENT%==0 goto :skip_agent - -if %BUILD_SERVICE%==1 (echo [2/2] Publishing TeslaLauncherAgent...) else (echo [1/1] Publishing TeslaLauncherAgent...) -echo. -dotnet publish "%ROOT%TeslaLauncherAgent.csproj" ^ - -c Release ^ - "-p:DefineConstants=WINFORMS" ^ - -o "%BUILD_DIR%\Agent" -if errorlevel 1 ( - echo. - echo ERROR: Agent build failed. - if "%QUIET%"=="0" pause - exit /b 1 -) -echo. -echo Agent : %BUILD_DIR%\Agent\TeslaLauncherAgent.exe -echo. - -:skip_agent - :: -- Copy shared assets into the package -------------------------------------- :: Mirror each redist folder under assets\ into the package root so install.bat's -:: paths (%ROOT%\dx9201006\, %ROOT%\openal\, %ROOT%\UltraVNC\) resolve. +:: paths (%ROOT%\dx9201006\ etc.) resolve. if exist "%ROOT%install.bat" copy /y "%ROOT%install.bat" "%BUILD_DIR%\" >nul if exist "%ROOT%assets\dx9201006" xcopy /y /s /i /q "%ROOT%assets\dx9201006" "%BUILD_DIR%\dx9201006" >nul if exist "%ROOT%assets\openal" xcopy /y /s /i /q "%ROOT%assets\openal" "%BUILD_DIR%\openal" >nul if exist "%ROOT%assets\UltraVNC" xcopy /y /s /i /q "%ROOT%assets\UltraVNC" "%BUILD_DIR%\UltraVNC" >nul +if exist "%ROOT%assets\dotnet40" xcopy /y /s /i /q "%ROOT%assets\dotnet40" "%BUILD_DIR%\dotnet40" >nul :: -- Zip the package ---------------------------------------------------------- echo Zipping package... @@ -133,14 +108,13 @@ echo ============================================================ echo Build complete echo ============================================================ echo. -if %BUILD_SERVICE%==1 echo Service : %BUILD_DIR%\Service\TeslaLauncherService.exe -if %BUILD_AGENT%==1 echo Agent : %BUILD_DIR%\Agent\TeslaLauncherAgent.exe +echo Launcher : %BUILD_DIR%\App\TeslaLauncher.exe echo. echo Package : %BUILD_DIR% echo Zip : %ZIP% echo. echo Next steps: -echo 1. Copy TeslaLauncher-podpkg.zip to the pod (or stand-in) PC and extract it +echo 1. Copy TeslaLauncher-podpkg.zip to the pod (XP SP3 or Win10/11) and extract echo 2. Run TeslaLauncher\install.bat as Administrator echo. if "%QUIET%"=="0" pause diff --git a/Launcher/install.bat b/Launcher/install.bat index 453decc..65e994c 100644 --- a/Launcher/install.bat +++ b/Launcher/install.bat @@ -1,40 +1,53 @@ @echo off :: ============================================================================= -:: TeslaLauncher Modern — Installation Script +:: TeslaLauncher Modern - Installation Script (XP11 single binary, dual-OS) :: ============================================================================= -:: Run as Administrator on each cockpit PC. +:: Run as Administrator on each cockpit PC. Works on Windows XP SP3 and on +:: Windows 10/11 - one binary, one installer, two OS code paths where the +:: tooling differs (cacls/icacls, netsh firewall/advfirewall, no dism on XP...). :: Must be run from the TeslaLauncher\ folder produced by build.bat: :: :: TeslaLauncher\ :: install.bat <- this file -:: Service\TeslaLauncherService.exe -:: Agent\TeslaLauncherAgent.exe +:: App\TeslaLauncher.exe (net40 single binary) :: dx9201006\DXSETUP.exe (DirectX June 2010 redist) :: openal\oalinst.exe (OpenAL redist) -:: UltraVNC\UltraVNC_x64_Setup.exe (UltraVNC server + UltraVNC.inf) +:: UltraVNC\UltraVNC_x64_Setup.exe (UltraVNC server, Win10/11) +:: UltraVNC\UltraVNC_x86_Setup.exe (optional: UltraVNC for XP) +:: dotnet40\dotNetFx40_Full_x86_x64.exe (optional: .NET 4.0 for XP) :: ============================================================================= setlocal enabledelayedexpansion echo ============================================================ -echo Tesla Launcher v4.11.4.1 - Installation +echo Tesla Launcher v4.11.4.1 - Installation (single binary) echo ============================================================ echo. -:: ── Paths ───────────────────────────────────────────────────────────────────── +:: -- OS detection -------------------------------------------------------------- +:: NT 5.x = XP / Server 2003 era. Everything else is treated as modern Windows. +set ISXP=0 +ver | findstr /r /c:"Version 5\." >nul && set ISXP=1 +if "%ISXP%"=="1" (echo OS : Windows XP era ^(NT 5.x^)) else (echo OS : Windows Vista or later) +echo. + +:: -- Paths ---------------------------------------------------------------------- set ROOT=%~dp0 -set SERVICE_SRC=%ROOT%Service -set AGENT_SRC=%ROOT%Agent -set INSTALL_DIR=C:\Program Files\TeslaLauncher -set DATA_DIR=C:\ProgramData\TeslaLauncher -set SERVICE_EXE=%INSTALL_DIR%\TeslaLauncherService.exe -set AGENT_EXE=%INSTALL_DIR%\TeslaLauncherAgent.exe -set SERVICE_NAME=Tesla Application Launcher -set SERVICE_DISPLAY=Tesla Application Launcher +set APP_SRC=%ROOT%App +set INSTALL_DIR=%ProgramFiles%\TeslaLauncher +set LAUNCHER_EXE=%INSTALL_DIR%\TeslaLauncher.exe set CONSOLE_PORT=53290 +:: CommonApplicationData differs by OS. The launcher resolves it via +:: Environment.GetFolderPath; these must match what it computes. +if "%ISXP%"=="1" ( + set DATA_DIR=%ALLUSERSPROFILE%\Application Data\TeslaLauncher +) else ( + set DATA_DIR=%ALLUSERSPROFILE%\TeslaLauncher +) + :: Auto-login account for the cockpit (kiosk). Plain-text password in the -:: registry is by design here — closed network, single-purpose PC. +:: registry is by design here - closed network, single-purpose PC. set AUTOLOGIN_USER=Firestorm set AUTOLOGIN_PASS=thor6 @@ -42,139 +55,161 @@ set AUTOLOGIN_PASS=thor6 :: the mw4files / c shares browse cleanly. Change once per site if needed. set WORKGROUP=Tesla -:: ── Admin check ─────────────────────────────────────────────────────────────── +:: -- Admin check ----------------------------------------------------------------- net session >nul 2>&1 if %errorlevel% neq 0 ( echo ERROR: This script must be run as Administrator. pause & exit /b 1 ) -:: ── Verify source files ─────────────────────────────────────────────────────── -if not exist "%SERVICE_SRC%\TeslaLauncherService.exe" ( - echo ERROR: TeslaLauncherService.exe not found in %SERVICE_SRC% - echo Run build.bat first, then run install.bat from the TeslaLauncher\ folder. - pause & exit /b 1 -) -if not exist "%AGENT_SRC%\TeslaLauncherAgent.exe" ( - echo ERROR: TeslaLauncherAgent.exe not found in %AGENT_SRC% +:: -- Verify source files --------------------------------------------------------- +if not exist "%APP_SRC%\TeslaLauncher.exe" ( + echo ERROR: TeslaLauncher.exe not found in %APP_SRC% echo Run build.bat first, then run install.bat from the TeslaLauncher\ folder. pause & exit /b 1 ) -:: ── Detect and clean up existing installation ───────────────────────────────── -set EXISTING=0 -if exist "%SERVICE_EXE%" set EXISTING=1 -if exist "%AGENT_EXE%" set EXISTING=1 - -if "%EXISTING%"=="1" ( - echo Existing installation detected. Cleaning up... - echo. - - :: Stop and kill the Agent process - taskkill /F /IM TeslaLauncherAgent.exe >nul 2>&1 - echo Agent process stopped. - - :: Stop and remove the service, wait for it to fully stop - sc stop "%SERVICE_NAME%" >nul 2>&1 - sc stop "Tesla Application Launcher" >nul 2>&1 - timeout /t 3 /nobreak >nul - sc delete "%SERVICE_NAME%" >nul 2>&1 - sc delete "Tesla Application Launcher" >nul 2>&1 - timeout /t 2 /nobreak >nul - echo Service stopped and removed. - - :: Delete old executables, logs, and session key so SecureConfig re-runs - del /F /Q "%SERVICE_EXE%" >nul 2>&1 - del /F /Q "%AGENT_EXE%" >nul 2>&1 - del /F /Q "%INSTALL_DIR%\podconf.log" >nul 2>&1 - del /F /Q "%DATA_DIR%\TeslaKeyStore.key" >nul 2>&1 - del /F /Q "%DATA_DIR%\LaunchApps.xml" >nul 2>&1 - del /F /Q "%DATA_DIR%\configuring.json" >nul 2>&1 - echo Old executables, logs, session key, and app config deleted. - - :: Reset all physical Ethernet adapters to DHCP so the SecureConfig - :: protocol triggers correctly on the next boot. - :: We must REMOVE the static IP address entry first, then enable DHCP. - :: Set-NetIPInterface -Dhcp Enabled alone only flips the DHCP flag but - :: leaves the static address in the registry, so IsMachineConfigured() - :: still sees a non-DHCP adapter on the next boot and skips SecureConfig. - echo Resetting network adapters to DHCP... - powershell -NoProfile -Command "Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | ForEach-Object { $n=$_.Name; Get-NetIPAddress -InterfaceAlias $n -AddressFamily IPv4 -EA 0 | Where-Object {$_.PrefixOrigin -ne 'WellKnown' -and $_.PrefixOrigin -ne 'Dhcp'} | Remove-NetIPAddress -Confirm:$false -EA 0; Set-NetIPInterface -InterfaceAlias $n -Dhcp Enabled -EA 0 }; Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | Set-DnsClientServerAddress -ResetServerAddresses" >nul 2>&1 - echo Network adapters reset to DHCP. - echo. +:: -- .NET Framework 4.0 check (XP ships no .NET; Win10/11 has 4.8 built in) ------ +reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" /v Install >nul 2>&1 +if not errorlevel 1 goto :dotnet_ok +reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Client" /v Install >nul 2>&1 +if not errorlevel 1 goto :dotnet_ok +if exist "%ROOT%dotnet40\dotNetFx40_Full_x86_x64.exe" ( + echo .NET Framework 4.0 not found - installing it now ^(takes a few minutes^)... + "%ROOT%dotnet40\dotNetFx40_Full_x86_x64.exe" /q /norestart + echo .NET Framework 4.0 installed. ) else ( - :: No existing install — still remove any stale service registration - sc stop "%SERVICE_NAME%" >nul 2>&1 - sc stop "Tesla Application Launcher" >nul 2>&1 - sc delete "%SERVICE_NAME%" >nul 2>&1 - sc delete "Tesla Application Launcher" >nul 2>&1 - timeout /t 2 /nobreak >nul + echo ERROR: .NET Framework 4.0 is not installed and dotnet40\ redist is not + echo in this package. Install dotNetFx40_Full_x86_x64.exe first. + pause & exit /b 1 ) +:dotnet_ok -:: ── STEP 1: Create directories ──────────────────────────────────────────────── -echo [1/8] Creating directories... +:: -- Detect and clean up existing installation ----------------------------------- +echo Cleaning up any existing installation... + +:: Stop launcher / legacy agent processes +taskkill /F /IM TeslaLauncher.exe >nul 2>&1 +taskkill /F /IM TeslaLauncherAgent.exe >nul 2>&1 + +:: Remove the legacy two-process service registration (pre-XP11 installs) +sc stop "Tesla Application Launcher" >nul 2>&1 +ping -n 4 127.0.0.1 >nul +sc delete "Tesla Application Launcher" >nul 2>&1 +reg delete "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v TeslaLauncherAgent /f >nul 2>&1 + +:: Delete old executables, logs, and session key so SecureConfig re-runs +if exist "%LAUNCHER_EXE%" ( + del /F /Q "%INSTALL_DIR%\*.exe" >nul 2>&1 + del /F /Q "%INSTALL_DIR%\*.dll" >nul 2>&1 + del /F /Q "%INSTALL_DIR%\podconf.log" >nul 2>&1 +) +del /F /Q "%INSTALL_DIR%\TeslaLauncherService.exe" >nul 2>&1 +del /F /Q "%INSTALL_DIR%\TeslaLauncherAgent.exe" >nul 2>&1 +del /F /Q "%DATA_DIR%\TeslaKeyStore.key" >nul 2>&1 +del /F /Q "%DATA_DIR%\LaunchApps.xml" >nul 2>&1 +del /F /Q "%DATA_DIR%\configuring.json" >nul 2>&1 +del /F /Q "%DATA_DIR%\podconf.log" >nul 2>&1 +echo Old executables, logs, session key, and app config deleted. + +:: Reset all Ethernet adapters to DHCP so the SecureConfig protocol triggers +:: on the next boot. The static address entry must be REMOVED, not just the +:: DHCP flag flipped, or IsMachineConfigured() still sees a static adapter. +echo Resetting network adapters to DHCP... +if "%ISXP%"=="1" ( + rem XP: netsh "interface ip set address dhcp" clears the static entry. + rem Enumerate interface names from "netsh interface show interface" (col 4+). + for /f "skip=3 tokens=4*" %%i in ('netsh interface show interface') do ( + if "%%j"=="" ( + netsh interface ip set address "%%i" dhcp >nul 2>&1 + netsh interface ip set dns "%%i" dhcp >nul 2>&1 + ) else ( + netsh interface ip set address "%%i %%j" dhcp >nul 2>&1 + netsh interface ip set dns "%%i %%j" dhcp >nul 2>&1 + ) + ) +) else ( + powershell -NoProfile -Command "Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | ForEach-Object { $n=$_.Name; Get-NetIPAddress -InterfaceAlias $n -AddressFamily IPv4 -EA 0 | Where-Object {$_.PrefixOrigin -ne 'WellKnown' -and $_.PrefixOrigin -ne 'Dhcp'} | Remove-NetIPAddress -Confirm:$false -EA 0; Set-NetIPInterface -InterfaceAlias $n -Dhcp Enabled -EA 0 }; Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | Set-DnsClientServerAddress -ResetServerAddresses" >nul 2>&1 +) +echo Network adapters reset to DHCP. +echo. + +:: -- STEP 1: Create directories --------------------------------------------------- +echo [1/7] Creating directories... if not exist "%INSTALL_DIR%" mkdir "%INSTALL_DIR%" if not exist "%DATA_DIR%" mkdir "%DATA_DIR%" if not exist "C:\Games" mkdir "C:\Games" -:: Grant all users modify access to the data and games directories so -:: the Service and Agent can write files (LaunchApps.xml, game installs). -icacls "%DATA_DIR%" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1 -icacls "C:\Games" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1 +:: Grant Users modify access to the data and games directories so the launcher +:: can write files (LaunchApps.xml, session key, game installs) from any account. +if "%ISXP%"=="1" ( + cacls "%DATA_DIR%" /T /E /G Users:C >nul 2>&1 + cacls "C:\Games" /T /E /G Users:C >nul 2>&1 +) else ( + icacls "%DATA_DIR%" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1 + icacls "C:\Games" /grant *S-1-5-32-545:(OI)(CI)M /T >nul 2>&1 +) echo %INSTALL_DIR% echo %DATA_DIR% (Users: modify access) echo C:\Games (Users: modify access) -:: ── STEP 2: Copy files ──────────────────────────────────────────────────────── -echo [2/8] Copying files... -:: net48 framework-dependent build: each folder holds the exe + its dependency -:: DLLs + .config. Copy both folders into the install dir (shared DLLs overwrite -:: identically; the two .exe.config files coexist). -copy /Y "%SERVICE_SRC%\*.*" "%INSTALL_DIR%\" >nul -copy /Y "%AGENT_SRC%\*.*" "%INSTALL_DIR%\" >nul +:: -- STEP 2: Copy files ------------------------------------------------------------ +echo [2/7] Copying files... +:: net40 framework-dependent build: the folder holds the exe + its dependency +:: DLLs (Newtonsoft.Json, TeslaConsoleLaunchLib) + .config. +copy /Y "%APP_SRC%\*.*" "%INSTALL_DIR%\" >nul -:: Install DirectX June 2010 runtime (required by games) -if exist "%ROOT%\dx9201006\DXSETUP.exe" ( +:: Install DirectX June 2010 runtime (required by games; supports XP and Win10/11) +if exist "%ROOT%dx9201006\DXSETUP.exe" ( echo Installing DirectX runtime... - "%ROOT%\dx9201006\DXSETUP.exe" /silent + "%ROOT%dx9201006\DXSETUP.exe" /silent echo DirectX installed. ) :: Install OpenAL runtime (required by game audio) -if exist "%ROOT%\openal\oalinst.exe" ( +if exist "%ROOT%openal\oalinst.exe" ( echo Installing OpenAL runtime... - "%ROOT%\openal\oalinst.exe" /s + "%ROOT%openal\oalinst.exe" /s echo OpenAL installed. ) -:: Install UltraVNC server (remote diagnostics). The Inno installer lays down -:: the files and (via /loadinf) sets the install dir + VNC password; we then -:: register and start the service EXPLICITLY with winvnc.exe. Doing the service -:: step ourselves means a missing/incomplete "install service" task in the -:: answer file can't leave the pod with the files present but nothing listening. -set UVNC_SETUP=%ROOT%UltraVNC\UltraVNC_x64_Setup.exe -set UVNC_DIR=C:\Program Files\uvnc bvba\UltraVNC -if exist "%UVNC_SETUP%" ( +:: Install UltraVNC server (remote diagnostics). x64 build for modern Windows; +:: XP uses the x86 build when the package carries one. +set UVNC_SETUP= +if "%ISXP%"=="1" ( + if exist "%ROOT%UltraVNC\UltraVNC_x86_Setup.exe" set UVNC_SETUP=%ROOT%UltraVNC\UltraVNC_x86_Setup.exe +) else ( + if exist "%ROOT%UltraVNC\UltraVNC_x64_Setup.exe" set UVNC_SETUP=%ROOT%UltraVNC\UltraVNC_x64_Setup.exe +) +if defined UVNC_SETUP ( echo Installing UltraVNC server... start "" /wait "%UVNC_SETUP%" /verysilent /norestart /loadinf="%ROOT%UltraVNC\UltraVNC.inf" - ) +) else ( + if "%ISXP%"=="1" echo UltraVNC x86 setup not in package - skipped on XP. +) -:: Enable SMB1 client AND server (required by game networking). The pod both -:: reaches SMB1 shares (client) and hosts the mw4files / c shares below (server), -:: and Win10/11 ship both SMB1 sub-features off by default — so enable all three -:: (umbrella + client + server); each child pulls in the umbrella via /All. -echo Enabling SMB1 file sharing (client + server)... -dism /Online /Enable-Feature /FeatureName:SMB1Protocol /All /NoRestart >nul 2>&1 -dism /Online /Enable-Feature /FeatureName:SMB1Protocol-Client /All /NoRestart >nul 2>&1 -dism /Online /Enable-Feature /FeatureName:SMB1Protocol-Server /All /NoRestart >nul 2>&1 -echo SMB1 client + server enabled (reboot required to take effect). +:: SMB1 + DirectPlay: native on XP; must be re-enabled on Win10/11. +if "%ISXP%"=="0" ( + echo Enabling SMB1 file sharing ^(client + server^)... + dism /Online /Enable-Feature /FeatureName:SMB1Protocol /All /NoRestart >nul 2>&1 + dism /Online /Enable-Feature /FeatureName:SMB1Protocol-Client /All /NoRestart >nul 2>&1 + dism /Online /Enable-Feature /FeatureName:SMB1Protocol-Server /All /NoRestart >nul 2>&1 + echo SMB1 client + server enabled ^(reboot required to take effect^). + echo Enabling DirectPlay... + dism /Online /Enable-Feature /FeatureName:DirectPlay /All /NoRestart >nul 2>&1 + echo DirectPlay enabled. +) else ( + echo SMB1 and DirectPlay are native on XP - nothing to enable. +) -:: Create and share game-data folders (closed network — open to Everyone). +:: Create and share game-data folders (closed network - open to Everyone). echo Creating network shares... if not exist "C:\mw4files" mkdir "C:\mw4files" -:: Grant Everyone modify on the NTFS side so the share grant is effective. -icacls "C:\mw4files" /grant *S-1-1-0:(OI)(CI)M /T >nul 2>&1 -:: Recreate shares idempotently (net share fails if the name already exists). +if "%ISXP%"=="1" ( + cacls "C:\mw4files" /T /E /G Everyone:C >nul 2>&1 +) else ( + icacls "C:\mw4files" /grant *S-1-1-0:(OI)(CI)M /T >nul 2>&1 +) net share mw4files /delete >nul 2>&1 net share mw4files=C:\mw4files /grant:Everyone,FULL >nul 2>&1 echo "Share \\%COMPUTERNAME%\mw4files -> C:\mw4files (Everyone: Full)" @@ -182,146 +217,114 @@ net share c /delete >nul 2>&1 net share c=C:\ /grant:Everyone,FULL >nul 2>&1 echo "Share \\%COMPUTERNAME%\c -> C:\ (Everyone: Full)" -:: Join the site workgroup so every pod shares one browse list. Add-Computer -:: throws if the machine is already in that workgroup, so the error is ignored. -:: Takes effect on the reboot at the end of this script (same as the hostname -:: SecureConfig applies on first boot). +:: Join the site workgroup so every pod shares one browse list. echo Joining workgroup "%WORKGROUP%"... -powershell -NoProfile -Command "try { Add-Computer -WorkgroupName '%WORKGROUP%' -Force -ErrorAction Stop } catch { }" >nul 2>&1 +if "%ISXP%"=="1" ( + wmic computersystem where "name='%COMPUTERNAME%'" call joindomainorworkgroup name="%WORKGROUP%" >nul 2>&1 +) else ( + powershell -NoProfile -Command "try { Add-Computer -WorkgroupName '%WORKGROUP%' -Force -ErrorAction Stop } catch { }" >nul 2>&1 +) echo Workgroup set to "%WORKGROUP%" (effective after reboot). - -:: Enable DirectPlay (required by older games) -echo Enabling DirectPlay... -dism /Online /Enable-Feature /FeatureName:DirectPlay /All /NoRestart >nul 2>&1 -echo DirectPlay enabled. echo Done. -:: ── STEP 3: Install Windows Service ────────────────────────────────────────── -echo [3/8] Installing Windows Service... -sc create "%SERVICE_NAME%" ^ - binPath= "\"%SERVICE_EXE%\"" ^ - DisplayName= "%SERVICE_DISPLAY%" ^ - start= delayed-auto ^ - obj= LocalSystem ^ - type= own - -if %errorlevel% neq 0 ( - echo ERROR: Failed to register Windows Service. - pause & exit /b 1 -) - -sc description "%SERVICE_NAME%" ^ - "Tesla Application Launcher - accepts TeslaConsole connections and controls simulation software" -sc failure "%SERVICE_NAME%" ^ - reset= 86400 actions= restart/5000/restart/10000/restart/30000 - -:: Delay auto-start by 20 seconds to let the network stack settle -reg add "HKLM\SYSTEM\CurrentControlSet\Services\Tesla Application Launcher" ^ - /v AutoStartDelay /t REG_DWORD /d 20000 /f >nul - -:: Set the global delayed-auto-start delay to 20 seconds (default is 120s) -reg add "HKLM\SYSTEM\CurrentControlSet\Control" ^ - /v AutoStartDelay /t REG_DWORD /d 20000 /f >nul - -echo Service registered: "%SERVICE_NAME%" (20s per-service delay, 20s global delay) - -:: ── STEP 4: Disable Windows Firewall (closed network) ──────────────────────── -echo. -echo [4/8] Disabling Windows Firewall (closed network)... -:: Disable firewall on all profiles — pods run on a closed network -netsh advfirewall set allprofiles state off -echo Windows Firewall disabled on all profiles. - -:: ── STEP 5: Power settings (max performance, no sleep) ────────────────────── -echo. -echo [5/8] Configuring power settings... - -:: Activate Ultimate Performance plan (hidden by default, GUID is well-known) -powercfg -duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 >nul 2>&1 -for /f "tokens=4" %%g in ('powercfg -list ^| findstr /i "Ultimate"') do ( - powercfg -setactive %%g -) -echo Ultimate Performance power plan activated. - -:: Disable all sleep/hibernate/standby timeouts (AC power) -powercfg -change -standby-timeout-ac 0 -powercfg -change -hibernate-timeout-ac 0 -powercfg -change -monitor-timeout-ac 0 -powercfg -change -disk-timeout-ac 0 -powercfg -h off >nul 2>&1 -echo Sleep, hibernate, monitor timeout, and disk timeout disabled. - -:: ── STEP 6: Disable notifications ─────────────────────────────────────────── -echo. -echo [6/8] Disabling notifications... -:: These are machine-wide Group Policy keys (HKLM) so they apply to EVERY user, -:: including the auto-login kiosk account. Per-user HKCU tweaks would only touch -:: the admin running this installer, not the account the pod actually runs as. - -:: Remove Action Center / Notification Center entirely -reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Explorer" ^ - /v DisableNotificationCenter /t REG_DWORD /d 1 /f >nul - -:: Kill all toast/app notifications (desktop and lock screen) and cloud toasts -reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^ - /v NoToastApplicationNotification /t REG_DWORD /d 1 /f >nul -reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^ - /v NoToastApplicationNotificationOnLockScreen /t REG_DWORD /d 1 /f >nul -reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^ - /v NoCloudApplicationNotification /t REG_DWORD /d 1 /f >nul - -:: Suppress tips, suggestions, Spotlight, and other consumer content pop-ups -reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^ - /v DisableSoftLanding /t REG_DWORD /d 1 /f >nul -reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^ - /v DisableWindowsSpotlightFeatures /t REG_DWORD /d 1 /f >nul -reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^ - /v DisableWindowsConsumerFeatures /t REG_DWORD /d 1 /f >nul - -:: Silence Security & Maintenance (formerly Action Center) health notifications -reg add "HKLM\SOFTWARE\Microsoft\Windows Defender Security Center\Notifications" ^ - /v DisableNotifications /t REG_DWORD /d 1 /f >nul -echo All Windows notifications disabled (machine-wide). - -:: ── STEP 7: Disable UAC ───────────────────────────────────────────────────── -echo. -echo [7/8] Disabling UAC... -reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^ - /v EnableLUA /t REG_DWORD /d 0 /f >nul -reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^ - /v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f >nul -reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^ - /v PromptOnSecureDesktop /t REG_DWORD /d 0 /f >nul -echo UAC disabled (takes effect after reboot). - -:: ── STEP 8: Configure Agent auto-start and auto-login ──────────────────────── -echo. -echo [8/8] Configuring Agent auto-start and auto-login... - +:: -- STEP 3: Launcher auto-start (no Windows Service on XP11) ---------------------- +echo [3/7] Configuring launcher auto-start... +:: The single binary runs in the auto-logged-in user session. Restart-after-crash +:: comes from RegisterApplicationRestart (Vista+) inside the launcher itself. set AUTORUN_KEY=HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run -reg add "%AUTORUN_KEY%" /v "TeslaLauncherAgent" /t REG_SZ ^ - /d "\"%AGENT_EXE%\"" /f >nul -echo Added TeslaLauncherAgent to HKLM\...\Run. +reg add "%AUTORUN_KEY%" /v "TeslaLauncher" /t REG_SZ ^ + /d "\"%LAUNCHER_EXE%\"" /f >nul +echo Added TeslaLauncher to HKLM\...\Run (no service - single userland binary). -:: Auto-login: Winlogon reads these on every boot. AutoAdminLogon=1 tells -:: Winlogon to sign the DefaultUserName in with DefaultPassword without a -:: prompt. DefaultDomainName="." means the local machine account. +:: -- STEP 4: Disable Windows Firewall (closed network) ----------------------------- +echo. +echo [4/7] Disabling Windows Firewall (closed network)... +if "%ISXP%"=="1" ( + netsh firewall set opmode mode=disable >nul 2>&1 +) else ( + netsh advfirewall set allprofiles state off >nul +) +echo Windows Firewall disabled. + +:: -- STEP 5: Power settings (max performance, no sleep) ---------------------------- +echo. +echo [5/7] Configuring power settings... +if "%ISXP%"=="1" ( + powercfg /setactive "Always On" >nul 2>&1 + powercfg /change "Always On" /monitor-timeout-ac 0 >nul 2>&1 + powercfg /change "Always On" /disk-timeout-ac 0 >nul 2>&1 + powercfg /change "Always On" /standby-timeout-ac 0 >nul 2>&1 + powercfg /hibernate off >nul 2>&1 + echo "Always On" power scheme activated; sleep/hibernate disabled. +) else ( + powercfg -duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 >nul 2>&1 + for /f "tokens=4" %%g in ('powercfg -list ^| findstr /i "Ultimate"') do ( + powercfg -setactive %%g + ) + powercfg -change -standby-timeout-ac 0 + powercfg -change -hibernate-timeout-ac 0 + powercfg -change -monitor-timeout-ac 0 + powercfg -change -disk-timeout-ac 0 + powercfg -h off >nul 2>&1 + echo Ultimate Performance plan activated; sleep/hibernate disabled. +) + +:: -- STEP 6: Quiet the OS (notifications / UAC - modern Windows only) -------------- +echo. +echo [6/7] Disabling notifications and UAC... +if "%ISXP%"=="1" ( + echo XP has no Action Center or UAC - nothing to disable. +) else ( + rem Machine-wide Group Policy keys (HKLM) so they apply to EVERY user, + rem including the auto-login kiosk account. + reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Explorer" ^ + /v DisableNotificationCenter /t REG_DWORD /d 1 /f >nul + reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^ + /v NoToastApplicationNotification /t REG_DWORD /d 1 /f >nul + reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^ + /v NoToastApplicationNotificationOnLockScreen /t REG_DWORD /d 1 /f >nul + reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\PushNotifications" ^ + /v NoCloudApplicationNotification /t REG_DWORD /d 1 /f >nul + reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^ + /v DisableSoftLanding /t REG_DWORD /d 1 /f >nul + reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^ + /v DisableWindowsSpotlightFeatures /t REG_DWORD /d 1 /f >nul + reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\CloudContent" ^ + /v DisableWindowsConsumerFeatures /t REG_DWORD /d 1 /f >nul + reg add "HKLM\SOFTWARE\Microsoft\Windows Defender Security Center\Notifications" ^ + /v DisableNotifications /t REG_DWORD /d 1 /f >nul + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^ + /v EnableLUA /t REG_DWORD /d 0 /f >nul + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^ + /v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f >nul + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" ^ + /v PromptOnSecureDesktop /t REG_DWORD /d 0 /f >nul + echo Notifications disabled; UAC disabled ^(takes effect after reboot^). +) + +:: -- STEP 7: Auto-login -------------------------------------------------------------- +echo. +echo [7/7] Configuring auto-login... +:: Winlogon reads these on every boot (same keys since NT). AutoAdminLogon=1 +:: signs DefaultUserName in with DefaultPassword without a prompt. set WINLOGON=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon -reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d "1" /f >nul -reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d "%AUTOLOGIN_USER%" /f >nul -reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d "%AUTOLOGIN_PASS%" /f >nul +reg add "%WINLOGON%" /v AutoAdminLogon /t REG_SZ /d "1" /f >nul +reg add "%WINLOGON%" /v DefaultUserName /t REG_SZ /d "%AUTOLOGIN_USER%" /f >nul +reg add "%WINLOGON%" /v DefaultPassword /t REG_SZ /d "%AUTOLOGIN_PASS%" /f >nul reg add "%WINLOGON%" /v DefaultDomainName /t REG_SZ /d "." /f >nul :: Re-arm auto-login after every sign-out (kiosk should never sit at a prompt). -reg add "%WINLOGON%" /v ForceAutoLogon /t REG_SZ /d "1" /f >nul +reg add "%WINLOGON%" /v ForceAutoLogon /t REG_SZ /d "1" /f >nul :: Clear any logon-count cap that would disable auto-login after N boots. reg delete "%WINLOGON%" /v AutoLogonCount /f >nul 2>&1 -:: Some builds require this flag off for a plain-text auto-login password to -:: be honored (device-passwordless / Windows Hello preference). -reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\PasswordLess\Device" ^ - /v DevicePasswordLessBuildVersion /t REG_DWORD /d 0 /f >nul 2>&1 +if "%ISXP%"=="0" ( + rem Some Win10/11 builds require this flag off for a plain-text auto-login + rem password to be honored (device-passwordless preference). + reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\PasswordLess\Device" ^ + /v DevicePasswordLessBuildVersion /t REG_DWORD /d 0 /f >nul 2>&1 +) echo Auto-login configured for user "%AUTOLOGIN_USER%". -:: ── Done ────────────────────────────────────────────────────────────────────── +:: -- Done ---------------------------------------------------------------------------- echo. echo ============================================================ echo Installation Complete! @@ -332,9 +335,9 @@ echo Config dir : %DATA_DIR% echo Firewall : disabled (closed network) echo. echo Rebooting in 10 seconds... -echo The Service and Agent will start automatically in the correct order. -echo On first boot with DHCP, SecureConfig runs automatically — -echo watch plasma or on main screen for the Request ID and Passphrase. +echo The launcher starts automatically at login (single binary, no service). +echo On first boot with DHCP, SecureConfig runs automatically - +echo watch plasma or the main screen for the Request ID and Passphrase. echo. echo After reboot: use Console to install apps. echo. diff --git a/TeslaSuite.sln b/TeslaSuite.sln index 9313102..fbc45bc 100644 --- a/TeslaSuite.sln +++ b/TeslaSuite.sln @@ -11,9 +11,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{27C769F3 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaConsole.DiffTests", "Console\tests\TeslaConsole.DiffTests\TeslaConsole.DiffTests.csproj", "{467AA87A-FBD4-45D7-B8F8-9336C95884D2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherService", "Launcher\TeslaLauncherService.csproj", "{910D4404-B3A2-4217-B61C-D43E7F22CDAA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncherAgent", "Launcher\TeslaLauncherAgent.csproj", "{916DCDA5-3379-4383-96F0-AC7B26FAF64E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaLauncher", "Launcher\TeslaLauncher.csproj", "{E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tesla.Contract", "Contract\Tesla.Contract.csproj", "{0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}" EndProject @@ -35,14 +33,10 @@ Global {467AA87A-FBD4-45D7-B8F8-9336C95884D2}.Debug|Any CPU.Build.0 = Debug|Any CPU {467AA87A-FBD4-45D7-B8F8-9336C95884D2}.Release|Any CPU.ActiveCfg = Release|Any CPU {467AA87A-FBD4-45D7-B8F8-9336C95884D2}.Release|Any CPU.Build.0 = Release|Any CPU - {910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {910D4404-B3A2-4217-B61C-D43E7F22CDAA}.Release|Any CPU.Build.0 = Release|Any CPU - {916DCDA5-3379-4383-96F0-AC7B26FAF64E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {916DCDA5-3379-4383-96F0-AC7B26FAF64E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {916DCDA5-3379-4383-96F0-AC7B26FAF64E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {916DCDA5-3379-4383-96F0-AC7B26FAF64E}.Release|Any CPU.Build.0 = Release|Any CPU + {E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1B7C4A9-5D2C-4F0B-9A3E-7C61D4B8F0A2}.Release|Any CPU.Build.0 = Release|Any CPU {0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}.Debug|Any CPU.Build.0 = Debug|Any CPU {0B2E3F1F-56C4-4B1A-AC26-4BF34AA10F1B}.Release|Any CPU.ActiveCfg = Release|Any CPU