XP11: single net40 binary runs on XP SP3 through Windows 11

Merge TeslaLauncherService (Session 0) + TeslaLauncherAgent (tray) into one
userland TeslaLauncher.exe. The split existed only to work around Vista+
Session 0 isolation; running everything in the auto-logged-in admin session
needs no service, no named pipe, and no flat<->wire conversion layer. The
original Elsewhen software was likewise a single binary.

net40 is the newest framework XP SP3 can install, and net40 assemblies load
in-place on the 4.8 runtime in Win10/11 -- one exe covers both.

- Contract: multi-target net48;net40. The net40 leg of PodRpcProtocol uses
  Newtonsoft.Json (STJ has no net40 target); JSON is shape-identical on the
  wire, and the request reader keeps date strings raw so Ping echoes
  byte-identically. Console keeps the untouched net48/STJ leg.
- MiniZip.cs: central-directory ZIP extractor (stored/deflate/ZIP64) since
  net40 has no ZipFile; zip-slip guarded.
- SecureConfig: RSACryptoServiceProvider instead of RSA.Create (net46+),
  no leaveOpen BinaryWriter/Reader, struct instead of ValueTuple, and no
  SetCompatibleTextRenderingDefault on the passcode thread (the merged app
  already has a form). netsh "interface ip" syntax was already XP-correct.
- Volume: nircmd -> CoreAudio (Vista+) -> winmm waveOutSetVolume (XP).
- Paths: CommonApplicationData resolved per-OS (XP has no C:\ProgramData);
  launcher log moved next to the key/config in the data dir.
- install.bat: dual-OS (cacls/icacls, netsh firewall/advfirewall, dism and
  UAC/notification steps skipped on XP, .NET 4.0 redist check, XP DHCP reset
  via netsh); no service registration -- HKLM Run key + auto-login on both;
  RegisterApplicationRestart supplies crash-restart on Vista+.
- Bench switches: /skipconfig, /port:NNNN.

Verified: Console + diff suite (103 tests) still green on the net48 leg;
E2E smoke test drove the net40 launcher over real TCP with the Console's own
PodManagerConnection (STJ client vs Newtonsoft server) -- Ping echo, volume,
app registry, launch/kill/watch, InstallProduct zip transfer + extract, and
uninstall orphan cleanup: 17/17 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-10 18:59:06 -05:00
co-authored by Claude Fable 5
parent 1d95a800fd
commit 8730b9b966
16 changed files with 2090 additions and 2548 deletions
+63
View File
@@ -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<JToken> Args { get; set; }
#else
public List<JsonElement> Args { get; set; }
#endif
}
/// <summary>One RPC result: the return value as JSON, or an error message.</summary>
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.</summary>
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<JToken>() };
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<RpcRequest>(
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<JsonElement>() };
@@ -99,9 +144,26 @@ namespace Tesla.Net
public static RpcRequest ReadRequest(Stream stream)
=> JsonSerializer.Deserialize<RpcRequest>(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<RpcResponse>(
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<RpcResponse>(ReadFrame(stream), JsonOptions);
#endif
}
}
+21 -9
View File
@@ -1,9 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net48 only: both consumers (Console and Launcher) target .NET Framework 4.8.
(Was multi-targeted net48;net8.0-windows while the Launcher was on net8.) -->
<TargetFramework>net48</TargetFramework>
<!-- net48: the Console (and the client/crypto stack under Client/**).
net40: the XP11 single-binary Launcher — the oldest framework installable
on Windows XP SP3, and net40 assemblies run in-place on the 4.8 runtime,
so ONE launcher binary covers XP SP3 through Windows 11. -->
<TargetFrameworks>net48;net40</TargetFrameworks>
<!-- CRITICAL: the output assembly MUST be named TeslaConsoleLaunchLib at
version 1.0.0.0. BinaryFormatter embeds the assembly name in the wire
@@ -24,17 +26,27 @@
<NoWarn>$(NoWarn);SYSLIB0011</NoWarn>
</PropertyGroup>
<!-- net48 reference assemblies so the project builds without a full targeting pack,
plus System.Text.Json (built into the net8 shared framework, a package on net48). -->
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<!-- .NET Framework reference assemblies so the project builds without a full
targeting pack (resolves per-TFM, covers net40 and net48). -->
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
</ItemGroup>
<!-- JSON serializer per leg. System.Text.Json has no net40 target, so the
net40 leg of PodRpcProtocol.cs uses Newtonsoft.Json (which still ships
lib/net40). The JSON bytes on the wire are shape-identical; the
XpWireCompatTests exercise STJ-client <-> Newtonsoft-server for real. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net40'">
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<!-- The TCP/OFB client (Client/**) is net48-only: it depends on the crypto-stream
handshake in TeslaSecureConfiguration.dll. The Launcher (net6) is the SERVER
end of this protocol and never references these classes, so they are excluded
from the net6.0-windows build (which carries only the wire data types). -->
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. -->
<ItemGroup Condition="'$(TargetFramework)' != 'net48'">
<Compile Remove="Client\**\*.cs" />
</ItemGroup>
-31
View File
@@ -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
{
/// <summary>Command forwarded from the Windows Service to the Userspace Agent.</summary>
public sealed class IpcMessage
{
public string Command { get; set; }
public string LaunchKey { get; set; }
public string PayloadJson { get; set; }
}
/// <summary>Response from the Userspace Agent back to the Windows Service.</summary>
public sealed class IpcResponse
{
public bool Success { get; set; }
public string Message { get; set; }
public object Data { get; set; }
}
}
+273
View File
@@ -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
{
/// <summary>Extracts every entry of <paramref name="zipPath"/> under
/// <paramref name="destDir"/> (existing files overwritten), reporting
/// (entriesDone, entriesTotal) after each entry. Entries that would
/// escape the destination directory (zip-slip) are skipped.</summary>
public static void ExtractToDirectory(string zipPath, string destDir, Action<int, int> 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<Entry> 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<Entry>((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;
}
}
/// <summary>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.</summary>
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();
}
}
}
+81 -56
View File
@@ -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
`<CommonAppData>` 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 |
| `<CommonAppData>\TeslaLauncher\TeslaKeyStore.key` | Session key (32 bytes) |
| `<CommonAppData>\TeslaLauncher\LaunchApps.xml` | Installed games registry (same XML shape as the two-process Agent wrote) |
| `<CommonAppData>\TeslaLauncher\podconf.log` | Launcher log (was next to the exe pre-XP11) |
| `<CommonAppData>\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`.
+36 -24
View File
@@ -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 ---
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- ===========================================================================
XP11 single-binary launcher.
TargetFramework net40 is deliberate: it is the newest .NET Framework that
installs on Windows XP SP3, and net40 assemblies load in-place on the 4.8
runtime that ships in Windows 10/11 — so this ONE exe covers XP SP3
through Windows 11. Everything net45+ is off-limits here:
- System.Text.Json -> Newtonsoft.Json (net40 leg of Tesla.Contract)
- ZipFile/ZipArchive -> MiniZip.cs
- async/await, Task.Run -> threads
- RSA.Create(int) -> RSACryptoServiceProvider
=========================================================================== -->
<PropertyGroup>
<TargetFramework>net40</TargetFramework>
<OutputType>WinExe</OutputType>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>4.11.4.1</Version>
<ApplicationIcon>app.ico</ApplicationIcon>
<AssemblyName>TeslaLauncher</AssemblyName>
<RootNamespace>Tesla.Launcher</RootNamespace>
<StartupObject>Tesla.Launcher.LauncherApplication</StartupObject>
<!-- SecureConfig.cs shows the PasscodeDisplayForm in-process. -->
<DefineConstants>$(DefineConstants);WINFORMS</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<!-- WinForms via plain framework references: UseWindowsForms is not wired up
for net40, and all UI here is code-built (no designer). -->
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Drawing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Contract\Tesla.Contract.csproj" />
</ItemGroup>
</Project>
+1 -7
View File
@@ -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
-989
View File
@@ -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<LaunchData> _launchApps = new();
private readonly Dictionary<string, List<Process>> _runningProcesses = new();
private readonly object _processLock = new();
private readonly List<Thread> _watcherThreads = new();
private bool _stopping = false;
private readonly Dictionary<string, OutOfBandProgress> _installProgress = new();
private readonly object _installLock = new();
private Thread _pipeThread;
private CancellationTokenSource _cts = new();
// ── Entry point ───────────────────────────────────────────────────────
[STAThread]
public static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
// First-boot check: show the configuring splash (with the SecureConfig
// Request ID + Passphrase) when the machine is unconfigured OR when
// SecureConfig is actively running.
//
// The second condition is essential: during SecureConfig the Service
// assigns a TEMPORARY static IP to the Ethernet adapter so it can
// broadcast. That makes IsMachineConfigured() report true, so the DHCP
// check ALONE would skip the splash and the codes would never reach the
// screen — they'd only be in the log / on the COM2 plasma display. The
// Service writes configuring.json *before* assigning that temp IP, so its
// presence is the authoritative "SecureConfig in progress" signal.
if (!IsMachineConfigured() || File.Exists(ConfiguringFilePath()))
{
ShowConfiguringWait(); // blocks until config completes or form closed
// If still not configured (config failed / user closed splash), exit.
if (!IsMachineConfigured())
return;
}
Application.Run(new AgentApplication());
}
// ── Secure Configuration ──────────────────────────────────────────────
/// <summary>
/// Returns true when the cockpit has already been configured —
/// detected by the Ethernet adapter having a static IP assignment.
/// SecureConfiguration writes the permanent address via netsh with
/// no DHCP, so a static adapter means configuration is complete.
/// A DHCP adapter (or no adapter) means we are on a fresh machine
/// and need to run the SecureConfiguration protocol.
/// </summary>
private static bool IsMachineConfigured()
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
if (nic.OperationalStatus != OperationalStatus.Up) continue;
if (IsVirtualAdapter(nic)) continue;
try
{
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
// IsDhcpEnabled == false → static IP → already configured
if (!ipv4.IsDhcpEnabled)
return true;
}
catch (NetworkInformationException)
{
// GetIPv4Properties() throws if IPv4 is not configured at all;
// treat that the same as DHCP (i.e. not yet configured).
}
}
return false; // all Ethernet adapters are DHCP or absent → needs configuration
}
/// <summary>Shared file the Service writes (before assigning the temp IP) while
/// SecureConfig is in progress; holds the RequestId/Passphrase for the splash.</summary>
private static string ConfiguringFilePath() => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"TeslaLauncher", "configuring.json");
private static bool IsVirtualAdapter(NetworkInterface nic)
{
var desc = nic.Description ?? "";
var name = nic.Name ?? "";
string[] markers = {
"Virtual", "Hyper-V", "VMware", "VirtualBox",
"Loopback", "Tunnel", "Miniport", "Wi-Fi Direct",
"Bluetooth", "WAN Miniport", "Microsoft Kernel Debug"
};
foreach (var m in markers)
if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)
return true;
var mac = nic.GetPhysicalAddress().GetAddressBytes();
if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true; // locally-administered MAC
return false;
}
private static void ShowConfiguringWait()
{
// The Service handles SecureConfig in Session 0. The Agent shows
// a splash with the RequestId and Passphrase for the operator.
using var form = new Form
{
Text = "Tesla Cockpit Configuration",
FormBorderStyle = FormBorderStyle.FixedDialog,
StartPosition = FormStartPosition.CenterScreen,
ClientSize = new System.Drawing.Size(480, 260),
MaximizeBox = false,
MinimizeBox = false,
TopMost = true,
};
var lblStatus = new Label
{
Text = "This cockpit is being configured.\r\n" +
"Configuration will complete automatically.",
AutoSize = false,
Dock = DockStyle.Top,
Height = 50,
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
Font = new System.Drawing.Font("Arial", 10f),
Padding = new Padding(12, 8, 12, 0),
};
form.Controls.Add(lblStatus);
var lblRequestIdTitle = new Label
{
Text = "Request ID:",
AutoSize = false,
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
Font = new System.Drawing.Font("Arial", 11f),
Top = 60,
Left = 0,
Width = 480,
Height = 25,
};
form.Controls.Add(lblRequestIdTitle);
var lblRequestId = new Label
{
Text = "waiting...",
AutoSize = false,
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
Font = new System.Drawing.Font("Consolas", 28f, System.Drawing.FontStyle.Bold),
ForeColor = System.Drawing.Color.DarkBlue,
Top = 85,
Left = 0,
Width = 480,
Height = 45,
};
form.Controls.Add(lblRequestId);
var lblPassphraseTitle = new Label
{
Text = "Passphrase:",
AutoSize = false,
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
Font = new System.Drawing.Font("Arial", 11f),
Top = 140,
Left = 0,
Width = 480,
Height = 25,
};
form.Controls.Add(lblPassphraseTitle);
var lblPassphrase = new Label
{
Text = "waiting...",
AutoSize = false,
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
Font = new System.Drawing.Font("Consolas", 28f, System.Drawing.FontStyle.Bold),
ForeColor = System.Drawing.Color.DarkRed,
Top = 165,
Left = 0,
Width = 480,
Height = 45,
};
form.Controls.Add(lblPassphrase);
var lblHint = new Label
{
Text = "Read the Passphrase to the console operator.",
AutoSize = false,
TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
Font = new System.Drawing.Font("Arial", 9f, System.Drawing.FontStyle.Italic),
ForeColor = System.Drawing.Color.Gray,
Top = 220,
Left = 0,
Width = 480,
Height = 25,
};
form.Controls.Add(lblHint);
string lastJson = null;
bool sawFile = false;
var cfgFile = ConfiguringFilePath();
// Poll every 2 s — read codes from the Service's shared file.
// The file lifecycle:
// 1. Service deletes stale file on startup
// 2. Service writes fresh file with new codes (before the temp IP)
// 3. Agent reads and displays codes
// 4. Service deletes file when config is complete → splash closes
// If the Agent started before the file appeared, it keeps waiting; it
// closes once the file we saw is gone, or the machine becomes configured
// (covers the case where config finished before we managed to read it).
var timer = new System.Windows.Forms.Timer { Interval = 2000 };
timer.Tick += (s, e) =>
{
if (File.Exists(cfgFile))
{
sawFile = true;
try
{
var json = File.ReadAllText(cfgFile);
if (json != lastJson)
{
lastJson = json;
var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("RequestId", out var rid))
lblRequestId.Text = rid.GetString();
if (root.TryGetProperty("Passphrase", out var pp))
lblPassphrase.Text = pp.GetString();
}
}
catch { /* file may be mid-write, retry next tick */ }
}
else if (sawFile || IsMachineConfigured())
{
// File gone after we saw it, or the machine is now configured →
// SecureConfig is complete.
timer.Stop();
form.Close();
}
};
timer.Start();
Application.Run(form);
}
// ── Form / tray setup ─────────────────────────────────────────────────
public AgentApplication()
{
ShowInTaskbar = false;
WindowState = FormWindowState.Minimized;
Opacity = 0;
BuildTrayIcon();
LoadLaunchApps();
StartPipeServer();
}
private void BuildTrayIcon()
{
_trayMenu = new ContextMenuStrip();
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<LaunchData>();
SetTrayStatus("Config not found");
return;
}
var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
using var reader = File.OpenRead(CONFIG_FILE);
_launchApps = (List<LaunchData>)xml.Deserialize(reader)
?? new List<LaunchData>();
SetTrayStatus($"{_launchApps.Count} apps configured");
}
catch (Exception ex)
{
SetTrayStatus($"Config error: {ex.Message}");
}
}
private void SaveLaunchApps()
{
var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
Directory.CreateDirectory(Path.GetDirectoryName(CONFIG_FILE)!);
using var writer = File.CreateText(CONFIG_FILE);
xml.Serialize(writer, _launchApps);
}
private void SetTrayStatus(string status)
{
if (_trayIcon != null) _trayIcon.Text = $"Tesla Launcher: {status}";
}
// ── Named Pipe Server ─────────────────────────────────────────────────
private void StartPipeServer()
{
_pipeThread = new Thread(PipeServerLoop)
{
IsBackground = true,
Name = "TeslaLauncherPipeServer"
};
_pipeThread.Start();
}
private void PipeServerLoop()
{
var ct = _cts.Token;
while (!ct.IsCancellationRequested)
{
try
{
using var server = new NamedPipeServerStream(
PIPE_NAME,
PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
server.WaitForConnectionAsync(ct).GetAwaiter().GetResult();
HandlePipeRequest(server);
}
catch (OperationCanceledException) { break; }
catch (Exception)
{
if (!ct.IsCancellationRequested) Thread.Sleep(500);
}
}
}
private void HandlePipeRequest(NamedPipeServerStream pipe)
{
IpcResponse response;
try
{
var lenBuf = new byte[4];
ReadExact(pipe, lenBuf);
var reqBuf = new byte[BitConverter.ToInt32(lenBuf, 0)];
ReadExact(pipe, reqBuf);
var msg = JsonSerializer.Deserialize<IpcMessage>(
Encoding.UTF8.GetString(reqBuf));
response = ExecuteCommand(msg);
}
catch (Exception ex)
{
response = new IpcResponse { Success = false, Message = ex.Message };
}
var resBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(response));
pipe.Write(BitConverter.GetBytes(resBytes.Length), 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<Process>();
_runningProcesses[key].Add(process);
}
if (app.AutoRestart) StartWatcher(app, process);
SetTrayStatus($"Running: {app.DisplayName ?? key}");
return new LaunchedAppData { LaunchKey = key, ProcessId = process.Id };
}
private object CmdKillApp(IpcMessage msg)
{
var key = msg.LaunchKey;
int? pid = null;
if (msg.PayloadJson != null)
{
var parms = JsonSerializer.Deserialize<object[]>(msg.PayloadJson);
if (parms?.Length > 1 && parms[1] is JsonElement je)
pid = je.ValueKind == JsonValueKind.Number ? je.GetInt32() : (int?)null;
}
lock (_processLock)
{
if (!_runningProcesses.TryGetValue(key ?? "", out var procs)) return null;
var toKill = pid.HasValue
? procs.Where(p => p.Id == pid.Value).ToList()
: procs.ToList();
foreach (var p in toKill)
{
try { if (!p.HasExited) p.Kill(); } catch { }
procs.Remove(p);
}
if (procs.Count == 0) _runningProcesses.Remove(key ?? "");
}
return null;
}
private object CmdKillAllOfType(IpcMessage msg)
{
var key = msg.LaunchKey ?? throw new Exception("KillAllOfType requires LaunchKey");
lock (_processLock)
{
if (_runningProcesses.TryGetValue(key, out var procs))
{
foreach (var p in procs)
try { if (!p.HasExited) p.Kill(); } catch { }
_runningProcesses.Remove(key);
}
}
return null;
}
private object CmdKillAllApps()
{
lock (_processLock)
{
foreach (var kvp in _runningProcesses)
foreach (var p in kvp.Value)
try { if (!p.HasExited) p.Kill(); } catch { }
_runningProcesses.Clear();
}
SetTrayStatus("All apps stopped");
return null;
}
private object CmdGetLaunchedApps()
{
var result = new List<LaunchedAppData>();
lock (_processLock)
{
foreach (var kvp in _runningProcesses)
{
kvp.Value.RemoveAll(p => p.HasExited);
foreach (var p in kvp.Value)
result.Add(new LaunchedAppData
{
LaunchKey = kvp.Key,
ProcessId = p.Id
});
}
foreach (var key in _runningProcesses.Keys
.Where(k => _runningProcesses[k].Count == 0).ToList())
_runningProcesses.Remove(key);
}
return result.ToArray();
}
private object CmdGetLaunchableApps() => _launchApps.ToArray();
private object CmdRemoveApp(IpcMessage msg)
{
var key = msg.LaunchKey ?? throw new Exception("RemoveApp requires LaunchKey");
_launchApps.RemoveAll(a => a.LaunchKey == key);
SaveLaunchApps();
return null;
}
private object CmdInstallApp(IpcMessage msg)
{
if (msg.PayloadJson == null)
throw new Exception("InstallApp requires PayloadJson");
var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
if (parms == null || parms.Length == 0)
throw new Exception("InstallApp: no parameters");
var appData = JsonSerializer.Deserialize<LaunchData>(parms[0].GetRawText())
?? throw new Exception("InstallApp: could not deserialize LaunchData");
var existing = _launchApps.FindIndex(a => a.LaunchKey == appData.LaunchKey);
if (existing >= 0) _launchApps[existing] = appData;
else _launchApps.Add(appData);
SaveLaunchApps();
return null;
}
private object CmdUninstallApp(IpcMessage msg)
{
var key = msg.LaunchKey ?? throw new Exception("UninstallApp requires LaunchKey");
CmdKillAllOfType(msg);
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 };
}
/// <summary>The immediate child of C:\Games that contains <paramref name="path"/>
/// (e.g. C:\Games\RIOJoy\app\x.exe → C:\Games\RIOJoy), or null if not under C:\Games.</summary>
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<JsonElement[]>(msg.PayloadJson);
if (parms == null || parms.Length == 0)
throw new Exception("Invalid volume value");
var scalar = parms[0].ValueKind == JsonValueKind.Number
? (float)parms[0].GetDouble()
: throw new Exception("Invalid volume value");
SetMasterVolume(Math.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<JsonElement[]>(msg.PayloadJson)?[0].GetString()
: null;
if (callId == null)
throw new Exception("GetOutOfBandProgress requires a callId");
lock (_installLock)
{
if (_installProgress.TryGetValue(callId, out var prog)) return prog;
}
return new OutOfBandProgress
{
PercentComplete = 0,
Status = "Unknown call ID",
IsCompleted = true
};
}
private object CmdShutdown(IpcMessage msg)
{
bool doRestart = false;
if (msg.PayloadJson != null)
{
try
{
var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
if (parms?.Length > 0 && parms[0].ValueKind == JsonValueKind.True)
doRestart = true;
}
catch { /* default to shutdown */ }
}
CmdKillAllApps();
var flag = doRestart ? "/r" : "/s";
new Thread(() =>
{
Thread.Sleep(2000);
Process.Start("shutdown", $"{flag} /t 0");
}) { IsBackground = true }.Start();
return null;
}
// ── Helpers ───────────────────────────────────────────────────────────
private LaunchData FindApp(string launchKey) =>
_launchApps.FirstOrDefault(a =>
string.Equals(a.LaunchKey, launchKey, StringComparison.OrdinalIgnoreCase));
private void StartWatcher(LaunchData app, Process process)
{
new Thread(() =>
{
process.WaitForExit();
if (_stopping) return;
bool stillTracked;
lock (_processLock)
{
stillTracked = _runningProcesses.TryGetValue(
app.LaunchKey, out var procs) && procs.Contains(process);
}
if (!stillTracked) return;
Thread.Sleep(2000);
try { CmdLaunchApp(new IpcMessage { LaunchKey = app.LaunchKey }); }
catch { }
})
{ IsBackground = true, Name = $"Watcher-{app.LaunchKey}" }.Start();
}
// ── Volume Control ────────────────────────────────────────────────────
// The Console stores/retrieves volume as a float (0.01.0 scalar).
// We cache the exact value the Console sent so get returns the same
// value without CoreAudio roundtrip quantization error.
private float _cachedVolumeScalar = 1.0f;
private float GetMasterVolume()
{
return _cachedVolumeScalar;
}
private void SetMasterVolume(float scalar)
{
_cachedVolumeScalar = scalar;
// Try nircmd.exe first (legacy compatibility)
var nircmd = Path.Combine(GAMES_DIR, "nircmd.exe");
if (File.Exists(nircmd))
{
Process.Start(nircmd, $"setsysvolume {(int)(scalar * 65535)}")?.Dispose();
return;
}
// Windows Core Audio API fallback (Vista+, no external dependencies)
CoreAudio.SetMasterScalar(scalar);
}
// ── Response helpers ──────────────────────────────────────────────────
private static IpcResponse Ok(object data = null) => new() { Success = true, Data = data };
private static IpcResponse Fail(string msg) => new() { Success = false, Message = msg };
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
Visible = false;
}
protected override void Dispose(bool disposing)
{
if (disposing) { _trayIcon?.Dispose(); _trayMenu?.Dispose(); }
base.Dispose(disposing);
}
}
// ── Agent-local data types ────────────────────────────────────────────────
// These are used internally by the Agent (XML config, process management).
// They are NOT the BinaryFormatter wire types (Tesla.Net namespace).
/// <summary>
/// Agent-internal representation of a launchable app.
/// Loaded from LaunchApps.xml via XmlSerializer.
/// </summary>
[Serializable]
public class LaunchData
{
public string LaunchKey { get; set; }
public string DisplayName { get; set; }
public string WorkingDirectory { get; set; }
public string ExeFile { get; set; }
public string Arguments { get; set; }
public bool AutoRestart { get; set; }
}
/// <summary>
/// Agent-internal tracking of a running process.
/// Returned via JSON IPC (string LaunchKey, not Guid).
/// </summary>
[Serializable]
public class LaunchedAppData
{
public string LaunchKey { get; set; }
public int ProcessId { get; set; }
}
/// <summary>
/// Agent-internal full state snapshot.
/// Returned via JSON IPC for the FullUpdate command.
/// </summary>
[Serializable]
public class FullUpdateData
{
public LaunchData[] InstalledApps { get; set; }
public LaunchedAppData[] LaunchedApps { get; set; }
public float VolumeLevel { get; set; }
}
[Serializable]
public class OutOfBandProgress
{
public int PercentComplete { get; set; }
public string Status { get; set; }
public bool IsCompleted { get; set; }
}
// ── Windows Core Audio API — minimal COM interop (Vista+) ─────────────────
// No external dependencies. Vtable slot order matches the SDK headers exactly.
// Methods we don't call are declared as void stubs to preserve vtable offsets.
internal static class CoreAudio
{
internal static float GetMasterScalar()
{
var ep = GetEndpointVolume();
try { ep.GetMasterVolumeLevelScalar(out float v); return v; }
finally { ReleaseAll(ep); }
}
internal static void SetMasterScalar(float scalar)
{
var ep = GetEndpointVolume();
try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); }
finally { ReleaseAll(ep); }
}
private static IAudioEndpointVolume GetEndpointVolume()
{
var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass();
try
{
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out var device);
try
{
var iid = typeof(IAudioEndpointVolume).GUID;
device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out var obj);
return (IAudioEndpointVolume)obj;
}
finally { Marshal.ReleaseComObject(device); }
}
finally { Marshal.ReleaseComObject(enumerator); }
}
private static void ReleaseAll(object obj)
{
if (obj != null) Marshal.ReleaseComObject(obj);
}
}
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
internal class MMAudioEnumeratorComClass { }
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDeviceEnumerator
{
void _unused_EnumAudioEndpoints(); // slot 0 — not used
[PreserveSig]
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
}
[Guid("D666063F-1587-4E43-81F1-B948E807363F"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDevice
{
[PreserveSig]
int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams,
[MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer);
}
// Vtable order (after IUnknown): RegisterControlChangeNotify(0),
// UnregisterControlChangeNotify(1), GetChannelCount(2),
// SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4),
// GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6)
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IAudioEndpointVolume
{
void _unused_RegisterControlChangeNotify(); // slot 0
void _unused_UnregisterControlChangeNotify(); // slot 1
void _unused_GetChannelCount(); // slot 2
[PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx);
[PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx);
[PreserveSig] int GetMasterVolumeLevel(out float levelDB);
[PreserveSig] int GetMasterVolumeLevelScalar(out float level);
}
}
-30
View File
@@ -1,30 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<OutputType>WinExe</OutputType>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<WarningsAsErrors></WarningsAsErrors>
<Version>4.11.4.1</Version>
<ApplicationIcon>app.ico</ApplicationIcon>
<AssemblyName>TeslaLauncherAgent</AssemblyName>
<RootNamespace>Tesla.Launcher.Agent</RootNamespace>
<StartupObject>Tesla.Launcher.Agent.AgentApplication</StartupObject>
</PropertyGroup>
<ItemGroup>
<!-- Exclude service-only source files — they belong to TeslaLauncherService.csproj -->
<Compile Remove="TeslaLauncherService.cs" />
<Compile Remove="SecureConfig.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<!-- Agent parses configuring.json + the IPC JSON; built-in on net6, a package on net48. -->
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
</Project>
File diff suppressed because it is too large Load Diff
-45
View File
@@ -1,45 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<OutputType>Exe</OutputType>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<WarningsAsErrors></WarningsAsErrors>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Version>4.11.4.1</Version>
<ApplicationIcon>app.ico</ApplicationIcon>
<AssemblyName>TeslaLauncherService</AssemblyName>
<RootNamespace>Tesla.Launcher.Service</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Exclude the Agent source — it belongs to TeslaLauncherAgent.csproj only -->
<Compile Remove="TeslaLauncherAgent.cs" />
</ItemGroup>
<ItemGroup>
<!-- net48 reference assemblies + GAC refs the framework-dependent build needs. -->
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
<Reference Include="System.ServiceProcess" />
</ItemGroup>
<ItemGroup>
<!-- Generic host on .NET Framework: Microsoft.Extensions.Hosting(.WindowsServices)
8.x ship a net462 target, so they run on net48 unchanged. -->
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.*" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.*" />
<PackageReference Include="Microsoft.Extensions.Logging.EventLog" Version="8.*" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<!-- COM2 / PlasmaIO serial output in SecureConfig.cs -->
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Contract\Tesla.Contract.csproj" />
</ItemGroup>
</Project>
+32 -58
View File
@@ -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
+245 -242
View File
@@ -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 <name> 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.
+5 -11
View File
@@ -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