XP11: whole suite on net40 — Console + vPOD run on XP SP3 through Win11

The Launcher's XP11 port (8730b9b) now extends to everything: one net40
flavor across Console, vPOD, Contract, and SecureConfig (Newtonsoft.Json
everywhere; the net48/System.Text.Json legs and their #if splits are gone
since nothing consumed them).

Console (net40, single TFM like the Launcher):
- The ~31 BinaryFormatter bitmap blobs in the .resx files became raw
  embedded files under assets/icons/ (extracted byte-faithfully via a
  serialization surrogate — the animated square_throbber.gif survives),
  loaded by Properties.Resources.EmbeddedBitmap/EmbeddedIcon. Reason:
  System.Resources.Extensions' DeserializingResourceReader is net461+
  and cannot load on net40. Strings stay in the .resx.
- IReadOnlyList -> IList in AppRegistry (net45+ interface).

vPOD (net40, single TFM):
- Zip extraction now shares the Launcher's MiniZip.cs (linked source), so
  the diff-test install round-trip exercises it against ZipArchive zips.
- RPC args as JTokens; LaunchApps.json persistence via Newtonsoft;
  Thread.VolatileRead instead of Volatile.Read.

Contract/SecureConfig: net40-only; Client/** (PodManagerConnection) now
ships in the one build. The Launcher package gains
TeslaSecureConfiguration.dll as a dependency of the client half.

Tests: the net48 xunit host loads the net40 assemblies (both CLR4), so
the suite exercises exactly what ships — 106/106 green. Also verified
live: net40 console provisioned, managed, and ran a full RP mission
against net40 vPOD (beacon/passphrase/RSA, 53290 RPC, egg load,
Run/Stop Mission).

Version: 4.11.4.3 across Launcher, Console, and vPOD (vPOD joins the
suite version line; was 1.0.0). Ship the dotNetFx40 redistributable in
Launcher/assets for XP-era pods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-11 21:01:34 -05:00
co-authored by Claude Fable 5
parent eefb8054e0
commit 91640dcbf2
61 changed files with 250 additions and 419 deletions
+26 -40
View File
@@ -2,12 +2,12 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Threading;
using Newtonsoft.Json.Linq;
using Tesla;
using Tesla.Launcher;
using Tesla.Net;
namespace VPod;
@@ -184,7 +184,7 @@ internal sealed class LauncherRpcServer
}
string method = request.Method ?? "???";
IReadOnlyList<JsonElement> args = request.Args ?? new List<JsonElement>();
List<JToken> args = request.Args ?? new List<JToken>();
// GetOutOfBandProgress is polled 4x/second during installs — don't log it.
if (method != "GetOutOfBandProgress" && method != "Ping")
{
@@ -222,16 +222,22 @@ internal sealed class LauncherRpcServer
}
}
// RPC args surface as Newtonsoft JTokens (the Contract is net40;
// System.Text.Json has no net40 target).
private static bool IsNull(JToken arg) => arg == null || arg.Type == JTokenType.Null;
private static T Arg<T>(JToken arg) => arg.ToObject<T>(PodRpc.JsonOptions);
/// <summary>Maps the console's method names (dispatch-by-name, including the
/// get_/set_ property accessors) onto the VirtualLauncher. Mirrors the real
/// service's DispatchCommandAsync.</summary>
private object Dispatch(string method, IReadOnlyList<JsonElement> args)
private object Dispatch(string method, List<JToken> args)
{
switch (method)
{
case "Ping":
return args.Count > 0 && args[0].ValueKind != JsonValueKind.Null
? mLauncher.Ping(args[0].GetDateTime())
return args.Count > 0 && !IsNull(args[0])
? mLauncher.Ping(Arg<DateTime>(args[0]))
: DateTime.Now;
case "GetInstalledApps":
@@ -247,32 +253,32 @@ internal sealed class LauncherRpcServer
return mLauncher.FullUpdate();
case "GetOutOfBandProgress":
return mLauncher.GetOutOfBandProgress(args[0].GetGuid());
return mLauncher.GetOutOfBandProgress(Arg<Guid>(args[0]));
case "InitiateInstallProduct":
return mLauncher.InitiateInstallProduct();
case "InstallApp":
mLauncher.InstallApp(args[0].Deserialize<LaunchData>(PodRpc.JsonOptions));
mLauncher.InstallApp(Arg<LaunchData>(args[0]));
return null;
case "UninstallApp":
mLauncher.UninstallApp(args[0].GetGuid());
mLauncher.UninstallApp(Arg<Guid>(args[0]));
return null;
case "RemoveApp":
mLauncher.RemoveApp(args[0].GetGuid());
mLauncher.RemoveApp(Arg<Guid>(args[0]));
return null;
case "LaunchApp":
return mLauncher.LaunchApp(args[0].GetGuid());
return mLauncher.LaunchApp(Arg<Guid>(args[0]));
case "KillApp":
mLauncher.KillApp(args[0].GetGuid(), args[1].GetInt32());
mLauncher.KillApp(Arg<Guid>(args[0]), Arg<int>(args[1]));
return null;
case "KillAllOfType":
mLauncher.KillAllOfType(args[0].GetGuid());
mLauncher.KillAllOfType(Arg<Guid>(args[0]));
return null;
case "KillAllApps":
@@ -280,7 +286,7 @@ internal sealed class LauncherRpcServer
return null;
case "Shutdown":
mLauncher.Shutdown(args[0].GetBoolean());
mLauncher.Shutdown(Arg<bool>(args[0]));
return null;
case "ClearStore":
@@ -291,7 +297,7 @@ internal sealed class LauncherRpcServer
return mLauncher.VolumeLevel;
case "set_VolumeLevel":
mLauncher.VolumeLevel = args[0].GetSingle();
mLauncher.VolumeLevel = Arg<float>(args[0]);
return null;
default:
@@ -336,31 +342,11 @@ internal sealed class LauncherRpcServer
mLauncher.UpdateProgress(callId, 50, "Extracting...");
string gamesRoot = Path.GetFullPath(mLauncher.GamesRoot);
Directory.CreateDirectory(gamesRoot);
using (ZipArchive zip = ZipFile.OpenRead(tempZip))
{
int total = zip.Entries.Count;
int done = 0;
foreach (ZipArchiveEntry entry in zip.Entries)
{
string destPath = Path.GetFullPath(Path.Combine(gamesRoot, entry.FullName));
// Zip-slip protection, as in the real service.
if (!destPath.StartsWith(gamesRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath));
entry.ExtractToFile(destPath, overwrite: true);
}
done++;
mLauncher.UpdateProgress(callId, 50 + done * 45 / Math.Max(total, 1), "Extracting...");
}
}
// The Launcher's own extractor (zip-slip protection included): net40
// has no ZipFile/ZipArchive, and sharing it keeps vPOD's extraction
// byte-identical to the real pod service.
MiniZip.ExtractToDirectory(tempZip, gamesRoot, (done, total) =>
mLauncher.UpdateProgress(callId, 50 + done * 45 / Math.Max(total, 1), "Extracting..."));
Log?.Invoke($"Install {callId:N}: extracted to {gamesRoot}");
// The real service runs (then deletes) a packaged postinstall.bat here.
+8 -5
View File
@@ -2,8 +2,10 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading;
// No System.Text.Json on net40 — persistence goes through Newtonsoft, which
// writes the same JSON shape (public fields, PascalCase; see PodRpcProtocol).
using Newtonsoft.Json;
using Tesla.Net;
namespace VPod;
@@ -356,7 +358,8 @@ internal sealed class VirtualLauncher
}
AppsChanged?.Invoke();
int generation = Volatile.Read(ref mWatchdogGeneration);
// Thread.VolatileRead, not Volatile.Read: the latter is net45+.
int generation = Thread.VolatileRead(ref mWatchdogGeneration);
if (!RealAutoRestart || !app.AutoRestart)
{
Log?.Invoke($"\"{name}\" (PID {pid}) exited on its own (no watchdog restart).");
@@ -367,7 +370,7 @@ internal sealed class VirtualLauncher
// Still wanted? The pod may have powered off/reprovisioned (generation),
// the mode or toggle flipped, or the app been uninstalled meanwhile.
if (generation != Volatile.Read(ref mWatchdogGeneration) || !RealLaunch || !RealAutoRestart)
if (generation != Thread.VolatileRead(ref mWatchdogGeneration) || !RealLaunch || !RealAutoRestart)
{
return;
}
@@ -584,7 +587,7 @@ internal sealed class VirtualLauncher
{
return;
}
LaunchData[] apps = JsonSerializer.Deserialize<LaunchData[]>(File.ReadAllText(LaunchAppsPath), PodRpc.JsonOptions);
LaunchData[] apps = JsonConvert.DeserializeObject<LaunchData[]>(File.ReadAllText(LaunchAppsPath));
if (apps != null)
{
mInstalledApps.AddRange(apps);
@@ -600,7 +603,7 @@ internal sealed class VirtualLauncher
{
try
{
File.WriteAllText(LaunchAppsPath, JsonSerializer.Serialize(mInstalledApps.ToArray(), PodRpc.JsonOptions));
File.WriteAllText(LaunchAppsPath, JsonConvert.SerializeObject(mInstalledApps.ToArray()));
}
catch (Exception ex)
{
+2 -1
View File
@@ -10,7 +10,8 @@ param([string]$Config = 'Release')
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$bin = Join-Path $root "bin\$Config\net48"
# net40 leg: the XP11 build that runs on XP SP3 pods through Windows 11.
$bin = Join-Path $root "bin\$Config\net40"
$distDir = Join-Path $root 'dist'
$stage = Join-Path $distDir 'vPOD' # -> C:\Games\vPOD on the pod
$zipPath = Join-Path $distDir 'vPOD.zip'
+25 -9
View File
@@ -10,26 +10,40 @@
and shows both on a live display. Deployable to a pod machine via the
console's Manage Site -> Install Product (see dist\ + Console\RedPlanet\Apps.xml).
net48 to match the rest of the suite and the vendored Munga Net.dll.
net40 (XP11): runs on XP SP3 through Windows 11, like the Launcher and the
Console — one flavor everywhere (Contract and SecureConfig included). The
differential tests' net48 host loads all of it fine (net40 and net48 are both
CLR4). The vendored Munga Net.dll is CLR2 pure-IL, so it loads anywhere.
WinForms comes via plain framework references: UseWindowsForms is not wired
up for net40, and all UI here is code-built (no designer).
-->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<UseWindowsForms>true</UseWindowsForms>
<TargetFramework>net48</TargetFramework>
<TargetFramework>net40</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>vPOD</AssemblyName>
<RootNamespace>VPod</RootNamespace>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<Version>1.0.0</Version>
<!-- Versioned with the suite since v4.11.4.3 (was its own 1.0.0 line). -->
<AssemblyVersion>4.11.4.3</AssemblyVersion>
<Version>4.11.4.3</Version>
<Product>vPOD</Product>
</PropertyGroup>
<ItemGroup>
<!-- net48 reference assemblies so this builds without a full targeting pack installed -->
<!-- .NET Framework reference assemblies so this builds without a full
targeting pack installed (resolves per-TFM, covers net40 and net48) -->
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Drawing" />
</ItemGroup>
<!-- JSON for LaunchApps.json persistence + RPC arg materialization: Newtonsoft,
matching the Contract (System.Text.Json has no net40 target). -->
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
@@ -39,9 +53,11 @@
</ItemGroup>
<ItemGroup>
<!-- Framework assemblies for extracting InstallProduct zips (virtual launcher) -->
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
<!-- InstallProduct zips are extracted with the Launcher's own MiniZip on BOTH
legs (ZipFile/ZipArchive are net45+, absent on net40): identical extraction
behavior to the real pod service, and the differential suite's install
round-trip exercises MiniZip against real ZipArchive-built archives. -->
<Compile Include="..\Launcher\MiniZip.cs" Link="MiniZip.cs" />
</ItemGroup>
<ItemGroup>