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>
1047 lines
45 KiB
C#
1047 lines
45 KiB
C#
// =============================================================================
|
||
// 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 0–50%
|
||
int pct = (int)(received * 50 / fileSize);
|
||
UpdateProgress(callId, pct, "Receiving file...");
|
||
}
|
||
}
|
||
|
||
Log($"INSTALL {callId:N}: file received ({fileSize:N0} bytes)");
|
||
|
||
// ── Phase 2: Extract to C:\Games\ ────────────────────────
|
||
UpdateProgress(callId, 50, "Extracting...");
|
||
Directory.CreateDirectory(GAMES_DIR);
|
||
|
||
using (var zip = ZipFile.OpenRead(tempZip))
|
||
{
|
||
int total = zip.Entries.Count;
|
||
int done = 0;
|
||
|
||
foreach (var entry in zip.Entries)
|
||
{
|
||
var destPath = Path.GetFullPath(
|
||
Path.Combine(GAMES_DIR, entry.FullName));
|
||
|
||
// Zip-slip protection
|
||
if (!destPath.StartsWith(GAMES_DIR, StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
|
||
if (string.IsNullOrEmpty(entry.Name))
|
||
{
|
||
// Directory entry
|
||
Directory.CreateDirectory(destPath);
|
||
}
|
||
else
|
||
{
|
||
Directory.CreateDirectory(Path.GetDirectoryName(destPath));
|
||
entry.ExtractToFile(destPath, overwrite: true);
|
||
}
|
||
|
||
done++;
|
||
// Report extract progress as 50–95%
|
||
int pct = 50 + (done * 45 / Math.Max(total, 1));
|
||
UpdateProgress(callId, pct, "Extracting...");
|
||
}
|
||
}
|
||
|
||
Log($"INSTALL {callId:N}: extracted to {GAMES_DIR}");
|
||
|
||
// ── Phase 3: Run postinstall.bat if present ──────────────
|
||
var postInstall = Path.Combine(GAMES_DIR, "postinstall.bat");
|
||
if (File.Exists(postInstall))
|
||
{
|
||
UpdateProgress(callId, 96, "Running postinstall...");
|
||
Log($"INSTALL {callId:N}: running postinstall.bat");
|
||
|
||
try
|
||
{
|
||
using var proc = System.Diagnostics.Process.Start(
|
||
new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = postInstall,
|
||
WorkingDirectory = GAMES_DIR,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true
|
||
});
|
||
proc?.WaitForExit(60_000); // 60 s timeout
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log($"INSTALL {callId:N}: postinstall.bat error: {ex.Message}");
|
||
}
|
||
|
||
try { File.Delete(postInstall); } catch { }
|
||
Log($"INSTALL {callId:N}: postinstall.bat completed and removed");
|
||
}
|
||
|
||
// ── Done ─────────────────────────────────────────────────
|
||
// Console checks PercentComplete == 99 to break out of its
|
||
// 3-iteration install loop (InstallProductWorker in PodInfo).
|
||
// Using 100 causes the Console to retry all 3 times.
|
||
UpdateProgress(callId, 99, "Complete", isCompleted: true);
|
||
Log($"INSTALL {callId:N}: install complete");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UpdateProgress(callId, 0, $"Failed: {ex.Message}", isCompleted: true);
|
||
Log($"INSTALL {callId:N}: FAILED: {ex.Message}");
|
||
_logger.LogError(ex, "Install {Id} failed", callId);
|
||
}
|
||
finally
|
||
{
|
||
// Clean up temp zip
|
||
try { if (tempZip != null) File.Delete(tempZip); } catch { }
|
||
}
|
||
}
|
||
|
||
private string[] GetProgressKeys()
|
||
{
|
||
lock (_installLock)
|
||
{
|
||
return Array.ConvertAll(
|
||
new List<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();
|
||
}
|
||
}
|
||
}
|