Files
TeslaSuite/Launcher/TeslaLauncherService.cs
T
CydandClaude Opus 4.8 4c15197b34 Console/launcher-managed RIOJoy + real product uninstall
RIOJoy lifecycle is now owned by the console + launcher (the auto-start Run entry
was removed on the RioJoy side): Apps.xml autoRestart false -> true, so the
launcher starts RIOJoy and keeps it alive. Safe with uninstall — the watcher only
relaunches a still-tracked process, and uninstall's kill untracks it first.

Uninstall now physically removes the product (was unregister-only):
- Agent CmdUninstallApp: after kill + unregister, reports the product's
  C:\Games\<dir> to clean up — but only if no remaining registered launch entry
  still uses that folder (orphan check keeps multi-entry products like Red Planet's
  GameClient/LC/MR intact until the last entry is removed).
- Service (SYSTEM): on UninstallApp, runs <dir>\pre-uninstall.bat if present
  (RIOJoy's removes ViGEmBus + config) then deletes the directory, on a background
  thread so it can't trip the Console's RPC timeout. Path-guarded to only ever
  delete a proper subdirectory of C:\Games. Logged to podconf.log.

74 tests green; the cleanup path is pod-behavior, not unit-tested here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:53:09 -05:00

1047 lines
45 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================================
// TeslaLauncher — Windows Service (Session 0)
// =============================================================================
// Replaces TeslaLauncherService.exe (Elsewhen Studios LLC, .NET Framework 2.0).
// Runs in Session 0, auto-starts at boot before user login.
// Listens on TCP 53290 for OFB-encrypted framed-JSON RPC from TeslaConsole.
// Forwards commands to TeslaLauncherAgent (user session) via Named Pipe.
//
// On first boot (DHCP detected), runs SecureConfig to receive network
// configuration from the Console and establish the session key.
// =============================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.IO.Compression;
using System.IO.Pipes;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TeslaSecureConfig;
using Tesla.Launcher.Shared;
using Tesla.Net;
namespace Tesla.Launcher.Service
{
public class TeslaLauncherService : BackgroundService
{
// ---------------------------------------------------------------
// CONFIGURATION
// ---------------------------------------------------------------
/// <summary>
/// TCP port the Console connects to for RPC commands.
/// Original: ManagePort = 53290 (confirmed from decompiled TeslaLauncherService.exe).
/// Each connection is wrapped in OFB encryption via NegotiateCryptoStreams
/// using the session key from SecureConfig (TeslaKeyStore.key).
/// </summary>
private const int CONSOLE_PORT = 53290;
/// <summary>
/// Named pipe connecting this service to TeslaLauncherAgent.exe.
/// Must match PIPE_NAME in TeslaLauncherAgent.cs.
/// </summary>
private const string PIPE_NAME = "TeslaLauncherIPC";
/// <summary>
/// Seconds to wait for the Agent to connect on the Named Pipe.
/// If the Agent isn't running (user not yet logged in), we return an error.
/// </summary>
private const int AGENT_CONNECT_TIMEOUT_MS = 3000;
// ---------------------------------------------------------------
/// <summary>Path to session key file (original KeyStore format).</summary>
private static readonly string KEY_FILE = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"TeslaLauncher", "TeslaKeyStore.key");
private readonly ILogger<TeslaLauncherService> _logger;
private TcpListener _listener;
private readonly List<Task> _clientTasks = new();
private readonly object _taskLock = new();
private byte[] _sessionKey;
// Install product — file transfer progress tracking.
// InitiateInstallProduct creates a Guid; the Console then streams the
// game archive on the SAME OFB connection. GetOutOfBandProgress polls
// this dictionary from a separate connection.
private readonly Dictionary<Guid, OutOfBandProgress> _installProgress = new();
private readonly object _installLock = new();
public TeslaLauncherService(ILogger<TeslaLauncherService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"Tesla Launcher Service starting on port {Port}", CONSOLE_PORT);
// ── Boot diagnostic — always written so every reboot is traceable ──
// Logs adapter state to podconf.log before IsMachineConfigured() decides,
// visible even if SecureConfig does not run.
var logPath = Path.Combine(AppContext.BaseDirectory, "podconf.log");
void StartupLog(string msg) => File.AppendAllText(logPath,
$"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
StartupLog("=== Service starting ===");
foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.NetworkInterfaceType != System.Net.NetworkInformation.NetworkInterfaceType.Ethernet) continue;
if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
try
{
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
StartupLog($" NIC [{nic.Name}] DHCP={ipv4.IsDhcpEnabled} Virtual={IsVirtualAdapter(nic)}");
}
catch { StartupLog($" NIC [{nic.Name}] (no IPv4 props)"); }
}
// ── First-boot secure configuration ──────────────────────────────
// If the Ethernet adapter is still on DHCP the cockpit has not yet
// been configured. Run PodSecureConfigurator here in Session 0:
// • broadcasts the RQST beacon (MAC + RequestId)
// • displays passphrase on COM2 / PlasmaIO
// • receives UDP RPLY with AES-encrypted network configuration
// • applies netsh IP settings and hostname
// • completes TCP handshake with Console + RSA key exchange
// • saves 32-byte session key to TeslaKeyStore.key
// After SecureConfig, fall through to the normal command listener —
// the original Service does the same (ExecutionMode after Configure).
if (!IsMachineConfigured())
{
StartupLog("IsMachineConfigured=false → running SecureConfig");
_logger.LogInformation("Machine is unconfigured (DHCP). Running SecureConfiguration.");
await Task.Run(() => RunSecureConfiguration(stoppingToken), stoppingToken);
// Load key that was saved during SecureConfig
_sessionKey = LoadSessionKey();
if (_sessionKey == null)
{
_logger.LogWarning("SecureConfig completed but no session key was saved. Cannot start command listener.");
return;
}
StartupLog("SecureConfig complete → starting command listener on port 53290");
}
else
{
StartupLog("IsMachineConfigured=true → loading session key");
_sessionKey = LoadSessionKey();
if (_sessionKey == null)
{
_logger.LogWarning("Machine is configured but TeslaKeyStore.key not found. Reboot with DHCP to re-run SecureConfig.");
StartupLog("ERROR: TeslaKeyStore.key not found — cannot start command listener");
return;
}
StartupLog($"Session key loaded ({_sessionKey.Length} bytes) → starting normal service");
}
_logger.LogInformation("Starting command listener on port {Port}.", CONSOLE_PORT);
try
{
_listener = new TcpListener(IPAddress.Any, CONSOLE_PORT);
_listener.Start();
_logger.LogInformation("Listening. Waiting for Console connections...");
// net48's TcpListener has no CancellationToken overload for accept;
// stop the listener on cancellation so the pending accept unblocks.
using var stopReg = stoppingToken.Register(() => { try { _listener.Stop(); } catch { } });
while (!stoppingToken.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync();
}
catch (OperationCanceledException) { break; }
catch (Exception) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "Error accepting Console connection");
continue;
}
_logger.LogInformation(
"Console connected from {EP}", client.Client.RemoteEndPoint);
// Handle each Console connection on its own thread.
// Frame reads are synchronous, so we offload to a
// thread-pool thread via Task.Run.
var task = Task.Run(
() => HandleConsoleClient(client, stoppingToken),
stoppingToken);
lock (_taskLock)
{
_clientTasks.Add(task);
_clientTasks.RemoveAll(t => t.IsCompleted);
}
}
}
finally
{
_listener?.Stop();
_logger.LogInformation("Tesla Launcher Service stopped.");
}
}
// ── Console Connection Handler ───────────────────────────────────
private async Task HandleConsoleClient(TcpClient tcpClient, CancellationToken ct)
{
using var client = tcpClient;
using var netStream = client.GetStream();
netStream.WriteTimeout = 10_000;
netStream.ReadTimeout = 30_000;
// ── OFB crypto negotiation ────────────────────────────────────
// Both sides exchange 16-byte IVs, then verify "CONF" handshake.
// Read Console's IV
var consoleIv = new byte[16];
try { ReadExactSync(netStream, consoleIv); }
catch (Exception ex)
{
_logger.LogWarning("Failed reading Console IV: {Msg}", ex.Message);
return;
}
// Generate and send our IV
var podIv = new byte[16];
using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(podIv);
netStream.Write(podIv, 0, 16);
netStream.Flush();
// Create OFB duplex stream — lives for the entire session
using var ofb = new OFBDuplexStream(netStream, _sessionKey,
writeIv: consoleIv, // pod→console (other's IV)
readIv: podIv); // console→pod (own IV)
// CONF handshake — verify both sides have the same session key
var confMsg = Encoding.UTF8.GetBytes("CONF");
ofb.Write(confMsg, 0, confMsg.Length);
ofb.Flush();
var confReply = new byte[4];
try { ReadExactSync(ofb, confReply); }
catch (Exception ex)
{
_logger.LogWarning("CONF read failed: {Msg}", ex.Message);
return;
}
if (!confReply.SequenceEqual(confMsg))
{
_logger.LogWarning("CONF mismatch — session key mismatch, dropping connection.");
return;
}
Stream rpcStream = ofb;
var diagLog = Path.Combine(AppContext.BaseDirectory, "podconf.log");
_logger.LogInformation("Console session started.");
try
{
while (!ct.IsCancellationRequested && client.Connected)
{
RpcRequest request;
try
{
request = PodRpc.ReadRequest(rpcStream);
}
catch (EndOfStreamException)
{
_logger.LogInformation("Console disconnected (EOF).");
break;
}
catch (IOException ex)
{
_logger.LogInformation("Console disconnected (I/O: {Msg}).", ex.Message);
break;
}
catch (Exception ex)
{
_logger.LogWarning("Request read error ({Type}): {Msg}",
ex.GetType().Name, ex.Message);
File.AppendAllText(diagLog,
$"[{DateTime.Now:HH:mm:ss}] REQ ERROR: {ex}{Environment.NewLine}");
break;
}
if (request == null) break;
var funcName = request.Method ?? "???";
IReadOnlyList<JsonElement> args = request.Args ?? new List<JsonElement>();
_logger.LogInformation("Command: {Func}({ArgCount} args)", funcName, args.Count);
var start = DateTime.UtcNow;
object resultObj = null;
string error = null;
try
{
resultObj = await DispatchCommandAsync(funcName, args, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing {Func}", funcName);
error = ex.Message;
File.AppendAllText(diagLog,
$"[{DateTime.Now:HH:mm:ss}] CMD {funcName} ERROR: {ex.Message}{Environment.NewLine}");
}
var dur = DateTime.UtcNow - start;
if (error == null)
{
File.AppendAllText(diagLog,
$"[{DateTime.Now:HH:mm:ss}] CMD {funcName} OK ({dur.TotalMilliseconds:F0}ms)" +
$"{(resultObj != null ? $" {resultObj}" : "")}{Environment.NewLine}");
}
try
{
PodRpc.WriteResponse(rpcStream, resultObj, error);
}
catch (IOException)
{
_logger.LogInformation("Console disconnected while writing response.");
break;
}
catch (Exception serEx)
{
// Result type not JSON-serializable — send error instead.
_logger.LogWarning("Serialize failed for {Func}: {Msg}",
funcName, serEx.Message);
try { PodRpc.WriteResponse(rpcStream, null, $"Pod error: {serEx.Message}"); }
catch { break; }
}
// ── File receive after InitiateInstallProduct ────────────
// The Console streams the game archive on this same OFB
// connection immediately after receiving the Guid response:
// 8-byte Int64 file size + raw file bytes
// Then closes the connection (FIN).
if (funcName == "InitiateInstallProduct"
&& error == null
&& resultObj is Guid installGuid)
{
ReceiveInstallFile(rpcStream, installGuid, diagLog);
break; // connection done after file transfer
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled error in Console handler");
File.AppendAllText(diagLog,
$"[{DateTime.Now:HH:mm:ss}] HANDLER ERROR: {ex.GetType().Name}: {ex.Message}{Environment.NewLine}" +
$"[{DateTime.Now:HH:mm:ss}] Inner: {ex.InnerException?.Message}{Environment.NewLine}");
}
_logger.LogInformation("Console session ended.");
}
// ── Command Dispatch ─────────────────────────────────────────────
// Arguments arrive as JSON elements (RpcRequest.Args); each case extracts
// the typed values it needs. The returned object is serialized straight
// into the RpcResponse.
private async Task<object> DispatchCommandAsync(
string functionName, IReadOnlyList<JsonElement> args, CancellationToken ct)
{
switch (functionName)
{
case "Ping":
// Echo back the DateTime parameter (Console measures round-trip)
return args.Count > 0 && args[0].ValueKind != JsonValueKind.Null
? args[0].GetDateTime()
: DateTime.Now;
case "GetLaunchableApps":
{
var json = await ForwardToAgentJsonAsync(functionName, args, ct);
var wire = FlatArrayToWire(json);
var pairs = new LaunchPair[wire.Length];
for (int i = 0; i < wire.Length; i++)
pairs[i] = wire[i].LaunchPair;
return pairs;
}
case "GetInstalledApps":
{
var json = await ForwardToAgentJsonAsync(functionName, args, ct);
return FlatArrayToWire(json);
}
case "GetLaunchedApps":
{
var json = await ForwardToAgentJsonAsync(functionName, args, ct);
return FlatLaunchedArrayToWire(json);
}
case "FullUpdate":
{
var json = await ForwardToAgentJsonAsync(functionName, args, ct);
if (json == null) return new FullUpdateData();
var flat = JsonSerializer.Deserialize<FlatFullUpdateData>(json);
if (flat == null) return new FullUpdateData();
var result2 = new FullUpdateData
{
InstalledApps = flat.InstalledApps != null
? Array.ConvertAll(flat.InstalledApps, FlatToWire)
: Array.Empty<LaunchData>(),
LaunchedApps = flat.LaunchedApps != null
? Array.ConvertAll(flat.LaunchedApps, FlatLaunchedToWire)
: Array.Empty<LaunchedAppData>(),
VolumeLevel = flat.VolumeLevel
};
return result2;
}
case "GetOutOfBandProgress":
{
// Handled directly in the Service — progress is tracked
// during file receive on the InitiateInstallProduct connection.
var diagPath = Path.Combine(AppContext.BaseDirectory, "podconf.log");
Guid progressId = Guid.Empty;
bool guidMatch = false;
if (args.Count > 0 && args[0].ValueKind == JsonValueKind.String
&& Guid.TryParse(args[0].GetString(), out var pg))
{
progressId = pg;
guidMatch = true;
}
if (guidMatch)
{
lock (_installLock)
{
if (_installProgress.TryGetValue(progressId, out var prog))
{
_logger.LogDebug(
"GetOutOfBandProgress({Id}): {Pct}% {Status} done={Done}",
progressId, prog.PercentComplete, prog.Status, prog.IsCompleted);
return prog;
}
}
File.AppendAllText(diagPath,
$"[{DateTime.Now:HH:mm:ss}] OOBP: Guid {progressId} NOT FOUND in _installProgress (keys={string.Join(",", GetProgressKeys())}){Environment.NewLine}");
}
else
{
File.AppendAllText(diagPath,
$"[{DateTime.Now:HH:mm:ss}] OOBP: arg[0] not a Guid (kind={(args.Count > 0 ? args[0].ValueKind.ToString() : "none")}){Environment.NewLine}");
}
return new OutOfBandProgress
{
PercentComplete = 0,
Status = "Unknown call ID",
IsCompleted = true
};
}
case "LaunchApp":
{
var json = await ForwardToAgentJsonAsync(functionName, args, ct);
if (json == null) return 0;
var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("ProcessId", out var pid))
return pid.GetInt32();
return 0;
}
case "get_VolumeLevel":
{
var json = await ForwardToAgentJsonAsync(functionName, args, ct);
return json != null ? JsonSerializer.Deserialize<float>(json) : 1.0f;
}
case "InitiateInstallProduct":
{
var callId = Guid.NewGuid();
lock (_installLock)
{
_installProgress[callId] = new OutOfBandProgress
{
PercentComplete = 0,
Status = "Waiting for file...",
IsCompleted = false
};
}
return callId;
}
case "InstallApp":
{
// Flatten the wire LaunchData into the Agent's flat JSON format.
var ld = args.Count > 0
? args[0].Deserialize<LaunchData>(PodRpc.JsonOptions)
: default;
var flat = new
{
LaunchKey = ld.LaunchPair.LaunchKey.ToString(),
DisplayName = ld.LaunchPair.DisplayName,
WorkingDirectory = ld.WorkingDirectory,
ExeFile = ld.ExeFile,
Arguments = ld.Arguments,
AutoRestart = ld.AutoRestart
};
var payloadJson = JsonSerializer.Serialize(new object[] { flat });
await ForwardToAgentCoreAsync(functionName, launchKey: null, payloadJson, ct);
return null;
}
case "KillApp":
case "KillAllOfType":
case "KillAllApps":
case "Shutdown":
case "ClearStore":
case "RemoveApp":
{
await ForwardToAgentJsonAsync(functionName, args, ct);
return null;
}
case "UninstallApp":
{
// The Agent kills + unregisters the app and returns the orphaned
// product directory to remove (or null). The physical cleanup
// (pre-uninstall.bat + delete) can exceed the Console's RPC timeout,
// so respond immediately and clean up on a background thread.
var json = await ForwardToAgentJsonAsync(functionName, args, ct);
if (json != null)
{
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("CleanupDir", out var cd)
&& cd.ValueKind == JsonValueKind.String)
{
var dir = cd.GetString();
var logPath = Path.Combine(AppContext.BaseDirectory, "podconf.log");
_ = Task.Run(() => CleanupProductDirectory(dir, logPath));
}
}
catch (Exception ex)
{
_logger.LogWarning("UninstallApp cleanup parse failed: {Msg}", ex.Message);
}
}
return null;
}
case "set_VolumeLevel":
await ForwardToAgentJsonAsync(functionName, args, ct);
return null;
default:
_logger.LogWarning("Unknown command: {Func}", functionName);
await ForwardToAgentJsonAsync(functionName, args, ct);
return null;
}
}
// ── Install Product File Receive ─────────────────────────────────
private const string GAMES_DIR = @"C:\Games";
private void ReceiveInstallFile(Stream stream, Guid callId, string logPath)
{
void Log(string msg) => File.AppendAllText(logPath,
$"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
string tempZip = null;
try
{
// ── Phase 1: Receive file ────────────────────────────────
var sizeBuf = new byte[8];
ReadExactSync(stream, sizeBuf);
long fileSize = BitConverter.ToInt64(sizeBuf, 0);
Log($"INSTALL {callId:N}: receiving {fileSize:N0} bytes...");
UpdateProgress(callId, 0, "Receiving file...");
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"TeslaLauncher");
Directory.CreateDirectory(dataDir);
tempZip = Path.Combine(dataDir, $"install_{callId:N}.zip");
using (var fs = File.Create(tempZip))
{
var buffer = new byte[65536];
long received = 0;
while (received < fileSize)
{
var toRead = (int)Math.Min(buffer.Length, fileSize - received);
var n = stream.Read(buffer, 0, toRead);
if (n == 0) throw new IOException("Connection closed during file transfer.");
fs.Write(buffer, 0, n);
received += n;
// Report receive progress as 050%
int pct = (int)(received * 50 / fileSize);
UpdateProgress(callId, pct, "Receiving file...");
}
}
Log($"INSTALL {callId:N}: file received ({fileSize:N0} bytes)");
// ── Phase 2: Extract to C:\Games\ ────────────────────────
UpdateProgress(callId, 50, "Extracting...");
Directory.CreateDirectory(GAMES_DIR);
using (var zip = ZipFile.OpenRead(tempZip))
{
int total = zip.Entries.Count;
int done = 0;
foreach (var entry in zip.Entries)
{
var destPath = Path.GetFullPath(
Path.Combine(GAMES_DIR, entry.FullName));
// Zip-slip protection
if (!destPath.StartsWith(GAMES_DIR, StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrEmpty(entry.Name))
{
// Directory entry
Directory.CreateDirectory(destPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath));
entry.ExtractToFile(destPath, overwrite: true);
}
done++;
// Report extract progress as 5095%
int pct = 50 + (done * 45 / Math.Max(total, 1));
UpdateProgress(callId, pct, "Extracting...");
}
}
Log($"INSTALL {callId:N}: extracted to {GAMES_DIR}");
// ── Phase 3: Run postinstall.bat if present ──────────────
var postInstall = Path.Combine(GAMES_DIR, "postinstall.bat");
if (File.Exists(postInstall))
{
UpdateProgress(callId, 96, "Running postinstall...");
Log($"INSTALL {callId:N}: running postinstall.bat");
try
{
using var proc = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo
{
FileName = postInstall,
WorkingDirectory = GAMES_DIR,
UseShellExecute = false,
CreateNoWindow = true
});
proc?.WaitForExit(60_000); // 60 s timeout
}
catch (Exception ex)
{
Log($"INSTALL {callId:N}: postinstall.bat error: {ex.Message}");
}
try { File.Delete(postInstall); } catch { }
Log($"INSTALL {callId:N}: postinstall.bat completed and removed");
}
// ── Done ─────────────────────────────────────────────────
// Console checks PercentComplete == 99 to break out of its
// 3-iteration install loop (InstallProductWorker in PodInfo).
// Using 100 causes the Console to retry all 3 times.
UpdateProgress(callId, 99, "Complete", isCompleted: true);
Log($"INSTALL {callId:N}: install complete");
}
catch (Exception ex)
{
UpdateProgress(callId, 0, $"Failed: {ex.Message}", isCompleted: true);
Log($"INSTALL {callId:N}: FAILED: {ex.Message}");
_logger.LogError(ex, "Install {Id} failed", callId);
}
finally
{
// Clean up temp zip
try { if (tempZip != null) File.Delete(tempZip); } catch { }
}
}
private string[] GetProgressKeys()
{
lock (_installLock)
{
return Array.ConvertAll(
new List<Guid>(_installProgress.Keys).ToArray(),
g => g.ToString());
}
}
// ── Product Uninstall Cleanup ────────────────────────────────────
// Runs as SYSTEM (the Service) on a background thread after UninstallApp,
// when the Agent reports the product directory is orphaned: run its
// pre-uninstall.bat if it ships one (e.g. RIOJoy removes ViGEmBus + config),
// then delete the C:\Games\<product> directory.
private void CleanupProductDirectory(string productDir, string logPath)
{
void Log(string msg) => File.AppendAllText(logPath,
$"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
if (string.IsNullOrWhiteSpace(productDir)) return;
string full;
try { full = Path.GetFullPath(productDir); } catch { return; }
// Safety: only ever remove a proper subdirectory of C:\Games — never
// C:\Games itself, never anything outside it.
var gamesRoot = Path.GetFullPath(GAMES_DIR).TrimEnd('\\');
var target = full.TrimEnd('\\');
if (!target.StartsWith(gamesRoot + "\\", StringComparison.OrdinalIgnoreCase)
|| target.Equals(gamesRoot, StringComparison.OrdinalIgnoreCase))
{
Log($"UNINSTALL: refusing to clean unsafe path '{full}'.");
return;
}
if (!Directory.Exists(full)) { Log($"UNINSTALL: {full} already absent."); return; }
// 1. Run pre-uninstall.bat if the product ships one (idempotent by design).
var preUninstall = Path.Combine(full, "pre-uninstall.bat");
if (File.Exists(preUninstall))
{
Log($"UNINSTALL: running {preUninstall}");
try
{
using var proc = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo
{
FileName = preUninstall,
WorkingDirectory = full,
UseShellExecute = false,
CreateNoWindow = true
});
proc?.WaitForExit(120_000);
Log($"UNINSTALL: pre-uninstall.bat exit code {(proc != null ? proc.ExitCode.ToString() : "?")}");
}
catch (Exception ex) { Log($"UNINSTALL: pre-uninstall.bat error: {ex.Message}"); }
}
// 2. Delete the product directory (retry briefly for lingering handles).
for (int attempt = 1; attempt <= 3; attempt++)
{
try
{
Directory.Delete(full, recursive: true);
Log($"UNINSTALL: removed {full}");
return;
}
catch (Exception ex)
{
if (attempt == 3) { Log($"UNINSTALL: could not remove {full}: {ex.Message}"); return; }
Thread.Sleep(1000);
}
}
}
// ── Flat ↔ Wire Conversion ────────────────────────────────────────
private class FlatLaunchData
{
public string LaunchKey { get; set; }
public string DisplayName { get; set; }
public string WorkingDirectory { get; set; }
public string ExeFile { get; set; }
public string Arguments { get; set; }
public bool AutoRestart { get; set; }
}
private class FlatLaunchedAppData
{
public string LaunchKey { get; set; }
public int ProcessId { get; set; }
}
private class FlatFullUpdateData
{
public FlatLaunchData[] InstalledApps { get; set; }
public FlatLaunchedAppData[] LaunchedApps { get; set; }
public float VolumeLevel { get; set; }
}
private static LaunchData FlatToWire(FlatLaunchData f)
{
return new LaunchData
{
LaunchPair = new LaunchPair
{
LaunchKey = Guid.TryParse(f.LaunchKey, out var g) ? g : Guid.Empty,
DisplayName = f.DisplayName
},
WorkingDirectory = f.WorkingDirectory,
ExeFile = f.ExeFile,
Arguments = f.Arguments,
AutoRestart = f.AutoRestart
};
}
private static LaunchedAppData FlatLaunchedToWire(FlatLaunchedAppData f)
{
return new LaunchedAppData
{
LaunchKey = Guid.TryParse(f.LaunchKey, out var g) ? g : Guid.Empty,
ProcessId = f.ProcessId
};
}
private static LaunchedAppData[] FlatLaunchedArrayToWire(string json)
{
if (json == null) return Array.Empty<LaunchedAppData>();
var flat = JsonSerializer.Deserialize<FlatLaunchedAppData[]>(json);
if (flat == null) return Array.Empty<LaunchedAppData>();
return Array.ConvertAll(flat, FlatLaunchedToWire);
}
private static LaunchData[] FlatArrayToWire(string json)
{
if (json == null) return Array.Empty<LaunchData>();
var flat = JsonSerializer.Deserialize<FlatLaunchData[]>(json);
if (flat == null) return Array.Empty<LaunchData>();
var result = new LaunchData[flat.Length];
for (int i = 0; i < flat.Length; i++)
result[i] = FlatToWire(flat[i]);
return result;
}
private void UpdateProgress(Guid callId, int pct, string status, bool isCompleted = false)
{
lock (_installLock)
{
_installProgress[callId] = new OutOfBandProgress
{
PercentComplete = pct,
Status = status,
IsCompleted = isCompleted
};
}
}
// ── Secure Configuration ─────────────────────────────────────────
private static bool IsVirtualAdapter(System.Net.NetworkInformation.NetworkInterface nic)
{
var desc = nic.Description ?? ""; var name = nic.Name ?? "";
string[] markers = { "Virtual", "Hyper-V", "VMware", "VirtualBox",
"Loopback", "Tunnel", "Miniport", "Wi-Fi Direct", "Bluetooth" };
foreach (var m in markers)
if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0) return true;
var mac = nic.GetPhysicalAddress().GetAddressBytes();
if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true;
return false;
}
private static bool IsMachineConfigured()
{
foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.NetworkInterfaceType != System.Net.NetworkInformation.NetworkInterfaceType.Ethernet) continue;
if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
if (IsVirtualAdapter(nic)) continue;
try
{
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
if (!ipv4.IsDhcpEnabled) return true; // static = configured
}
catch (System.Net.NetworkInformation.NetworkInformationException) { }
}
return false;
}
private void RunSecureConfiguration(CancellationToken ct)
{
var logPath = Path.Combine(AppContext.BaseDirectory, "podconf.log");
void Log(string msg) => File.AppendAllText(logPath,
$"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
try
{
using var configurator = new PodSecureConfigurator(
adapterName: null,
logger: msg => { Log(msg); _logger.LogInformation(msg); });
// 10-minute timeout — rebroadcasts automatically every 2 s
var config = configurator.Configure(timeoutMs: 600_000);
if (config == null)
{
_logger.LogWarning(
"SecureConfiguration timed out. Machine will need a reboot to retry.");
return;
}
_logger.LogInformation("SecureConfiguration complete.");
}
catch (Exception ex)
{
_logger.LogError(ex, "SecureConfiguration failed");
}
}
// ── Session Key ──────────────────────────────────────────────────
private static byte[] LoadSessionKey()
{
if (!File.Exists(KEY_FILE)) return null;
using var fs = File.Open(KEY_FILE, FileMode.Open, FileAccess.Read, FileShare.Read);
var len = fs.ReadByte();
if (len <= 0) return null;
var key = new byte[len];
int off = 0;
while (off < key.Length)
{
int n = fs.Read(key, off, key.Length - off);
if (n == 0) break;
off += n;
}
return off == key.Length ? key : null;
}
/// <summary>Reads exactly buf.Length bytes from stream; throws IOException on EOF.</summary>
private static void ReadExactSync(Stream stream, byte[] buf)
{
int off = 0;
while (off < buf.Length)
{
int n = stream.Read(buf, off, buf.Length - off);
if (n == 0) throw new IOException("Connection closed before all bytes received.");
off += n;
}
}
// ── Named Pipe IPC ───────────────────────────────────────────────
private Task<string> ForwardToAgentJsonAsync(
string functionName, IReadOnlyList<JsonElement> args, CancellationToken ct)
{
string launchKey = null;
if (args != null && args.Count > 0 && args[0].ValueKind == JsonValueKind.String
&& Guid.TryParse(args[0].GetString(), out var guid))
launchKey = guid.ToString();
string payloadJson = (args != null && args.Count > 0)
? JsonSerializer.Serialize(args)
: null;
return ForwardToAgentCoreAsync(functionName, launchKey, payloadJson, ct);
}
private async Task<string> ForwardToAgentCoreAsync(
string functionName, string launchKey, string payloadJson, CancellationToken ct)
{
var msg = new IpcMessage
{
Command = functionName.ToUpperInvariant(),
LaunchKey = launchKey,
PayloadJson = payloadJson
};
using var pipe = new NamedPipeClientStream(
".", PIPE_NAME, PipeDirection.InOut, PipeOptions.Asynchronous);
try
{
await pipe.ConnectAsync(AGENT_CONNECT_TIMEOUT_MS, ct);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Agent pipe timeout — is TeslaLauncherAgent.exe running?");
throw new Exception(
"Cockpit agent is not available. " +
"Ensure TeslaLauncherAgent.exe is running in the user session.");
}
// Send IpcMessage
var reqBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(msg));
await pipe.WriteAsync(BitConverter.GetBytes(reqBytes.Length), 0, 4, ct);
await pipe.WriteAsync(reqBytes, 0, reqBytes.Length, ct);
await pipe.FlushAsync(ct);
// Read IpcResponse
var lenBuf = new byte[4];
await ReadExactAsync(pipe, lenBuf, ct);
var resBuf = new byte[BitConverter.ToInt32(lenBuf, 0)];
await ReadExactAsync(pipe, resBuf, ct);
var response = JsonSerializer.Deserialize<IpcResponse>(
Encoding.UTF8.GetString(resBuf));
if (response == null)
throw new Exception("Null response from Agent.");
if (!response.Success)
throw new Exception(response.Message ?? "Agent returned failure.");
if (response.Data is System.Text.Json.JsonElement je)
return je.GetRawText();
return response.Data?.ToString();
}
private static async Task ReadExactAsync(Stream stream, byte[] buf, CancellationToken ct)
{
var off = 0;
while (off < buf.Length)
{
var n = await stream.ReadAsync(buf, off, buf.Length - off, ct);
if (n == 0) throw new IOException("Pipe closed before all bytes were read.");
off += n;
}
}
}
// ── Entry Point ──────────────────────────────────────────────────────
public class Program
{
public static void Main(string[] args)
{
Host.CreateDefaultBuilder(args)
.UseWindowsService(options =>
{
options.ServiceName = "Tesla Application Launcher";
})
.ConfigureServices(services =>
{
services.AddHostedService<TeslaLauncherService>();
})
.ConfigureLogging(logging =>
{
logging.AddEventLog(settings =>
{
settings.SourceName = "Tesla Application Launcher";
});
})
.Build()
.Run();
}
}
}