Files
TeslaSuite/Contract/PodRpcProtocol.cs
T
CydandClaude Fable 5 8730b9b966 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>
2026-07-10 18:59:06 -05:00

184 lines
8.0 KiB
C#

// =============================================================================
// Tesla.Contract — Pod RPC protocol (Console <-> Launcher Service)
// =============================================================================
// The framed, JSON-based replacement for the old OFB-encrypted BinaryFormatter
// RPC. Runs ON TOP of the same OFB-encrypted stream (NegotiateCryptoStreams is
// unchanged); only the per-message serialization changed.
//
// Wire format, after the OFB/CONF handshake:
// request : [4-byte little-endian length][UTF-8 JSON RpcRequest]
// response: [4-byte little-endian length][UTF-8 JSON RpcResponse]
// The InitiateInstallProduct out-of-band file transfer (8-byte length + raw
// bytes) is unchanged and still follows its response frame on the same stream.
//
// Dispatch is by method NAME (RpcRequest.Method) — the old serialized-MethodBase
// + SerializationBinder + MethodInfoProxy machinery is gone. Both ends share this
// 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
{
/// <summary>One RPC call: a method name plus its arguments as JSON elements.</summary>
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
}
/// <summary>Framing + (de)serialization shared by the Console client and the
/// Launcher Service. Synchronous to match the existing stream usage.</summary>
public static class PodRpc
{
/// <summary>Upper bound on a single JSON frame (the bulk install payload is
/// 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 ──────────────────────────────────────────────────────────
public static void WriteFrame(Stream stream, byte[] payload)
{
if (payload.Length > MaxFrameBytes)
throw new IOException($"RPC frame too large ({payload.Length} bytes).");
var len = BitConverter.GetBytes(payload.Length); // little-endian on x86/x64
stream.Write(len, 0, 4);
stream.Write(payload, 0, payload.Length);
stream.Flush();
}
public static byte[] ReadFrame(Stream stream)
{
var lenBuf = ReadExact(stream, 4);
int len = BitConverter.ToInt32(lenBuf, 0);
if (len < 0 || len > MaxFrameBytes)
throw new IOException($"RPC frame length out of range ({len}).");
return ReadExact(stream, len);
}
private static byte[] ReadExact(Stream stream, int count)
{
var buf = new byte[count];
int off = 0;
while (off < count)
{
int n = stream.Read(buf, off, count - off);
if (n == 0) throw new EndOfStreamException("Connection closed mid-frame.");
off += n;
}
return buf;
}
// ── 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>() };
if (args != null)
foreach (var a in args)
req.Args.Add(JsonSerializer.SerializeToElement(a, JsonOptions));
WriteFrame(stream, JsonSerializer.SerializeToUtf8Bytes(req, JsonOptions));
}
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
{
// Always a valid element (JSON null when there is no result): a
// default(JsonElement) is ValueKind.Undefined and is not serializable.
Result = JsonSerializer.SerializeToElement(error == null ? result : null, JsonOptions),
Error = error,
};
WriteFrame(stream, JsonSerializer.SerializeToUtf8Bytes(resp, JsonOptions));
}
public static RpcResponse ReadResponse(Stream stream)
=> JsonSerializer.Deserialize<RpcResponse>(ReadFrame(stream), JsonOptions);
#endif
}
}