feat(launcher): implement verified atomic updates

This commit is contained in:
Erik 2026-08-14 22:09:34 +02:00
parent 2198a0cc8e
commit 2d2a5b5046
34 changed files with 6755 additions and 61 deletions

View file

@ -1,18 +1,19 @@
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Orchestration;
/// <summary>
/// Resolves and validates the co-deployed graphical/headless hosts. LA10 will
/// replace the directory lookup with its versioned-current resolver; until
/// then a missing host disables the corresponding action instead of deferring
/// failure until process creation.
/// Resolves and validates the graphical/headless hosts. Production uses the
/// verified <c>DataDirectory/app/current.json</c> resolver; the explicit-path
/// constructor remains the injectable test seam.
/// </summary>
public sealed class LauncherExecutableSet
{
private readonly Func<string, bool> _fileExists;
private readonly Func<string, bool> _hasUnixExecutePermission;
private readonly Func<ExecutablePaths> _resolve;
public LauncherExecutableSet(
string graphicalHostPath,
@ -23,25 +24,50 @@ public sealed class LauncherExecutableSet
{
ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath);
ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath);
GraphicalHostPath = graphicalHostPath;
HeadlessHostPath = headlessHostPath;
WorkingDirectory = workingDirectory;
string graphical = graphicalHostPath;
string headless = headlessHostPath;
_resolve = () => new ExecutablePaths(graphical, headless, workingDirectory);
_fileExists = fileExists ?? File.Exists;
_hasUnixExecutePermission =
hasUnixExecutePermission ?? HasUnixExecutePermission;
}
public string GraphicalHostPath { get; }
private LauncherExecutableSet(
Func<ExecutablePaths> resolve,
Func<string, bool>? fileExists = null,
Func<string, bool>? hasUnixExecutePermission = null)
{
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
_fileExists = fileExists ?? File.Exists;
_hasUnixExecutePermission =
hasUnixExecutePermission ?? HasUnixExecutePermission;
}
public string HeadlessHostPath { get; }
public string GraphicalHostPath => _resolve().GraphicalHostPath;
public string? WorkingDirectory { get; }
public string HeadlessHostPath => _resolve().HeadlessHostPath;
public string? WorkingDirectory => _resolve().WorkingDirectory;
public LauncherCapability GetAvailability(LaunchMode mode)
{
ExecutablePaths paths;
try
{
paths = _resolve();
}
catch (Exception ex) when (ex is LauncherUpdateException
or InvalidOperationException
or IOException
or UnauthorizedAccessException)
{
return LauncherCapability.Unavailable(
$"The active versioned client is unavailable: {ex.Message}");
}
string path = mode == LaunchMode.Headless
? HeadlessHostPath
: GraphicalHostPath;
? paths.HeadlessHostPath
: paths.GraphicalHostPath;
string host = mode == LaunchMode.Headless
? "headless host"
: "graphical client";
@ -68,27 +94,27 @@ public sealed class LauncherExecutableSet
string configFilePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
RequireAvailable(mode);
ExecutablePaths paths = RequireAvailable(mode);
return mode == LaunchMode.Headless
? new LauncherProcessSpec(
HeadlessHostPath,
paths.HeadlessHostPath,
["--config", configFilePath],
WorkingDirectory)
paths.WorkingDirectory)
: new LauncherProcessSpec(
GraphicalHostPath,
paths.GraphicalHostPath,
["--session-config", configFilePath],
WorkingDirectory);
paths.WorkingDirectory);
}
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
RequireAvailable(LaunchMode.Headless);
ExecutablePaths paths = RequireAvailable(LaunchMode.Headless);
return new LauncherProcessSpec(
HeadlessHostPath,
paths.HeadlessHostPath,
["--config", configFilePath],
WorkingDirectory);
paths.WorkingDirectory);
}
public static LauncherExecutableSet FromDirectory(string directory)
@ -102,7 +128,28 @@ public sealed class LauncherExecutableSet
fullDirectory);
}
private void RequireAvailable(LaunchMode mode)
/// <summary>
/// Dynamic production resolver. The store cache is admitted only after a
/// strict startup/update verification, and a pointer swap changes the
/// binaries selected for the next session without replacing LA9 content.
/// </summary>
public static LauncherExecutableSet FromCurrentVersionStore(
ClientVersionStore store)
{
ArgumentNullException.ThrowIfNull(store);
return new LauncherExecutableSet(() =>
{
ClientVersionResolution resolution = store.CachedResolution;
if (!resolution.IsVerified || resolution.Directory is null)
{
throw new LauncherUpdateException(resolution.Status);
}
return FromDirectoryPaths(resolution.Directory);
});
}
private ExecutablePaths RequireAvailable(LaunchMode mode)
{
LauncherCapability capability = GetAvailability(mode);
if (!capability.IsAvailable)
@ -110,6 +157,18 @@ public sealed class LauncherExecutableSet
throw new LauncherOperationException(
capability.Reason ?? "The selected launcher host is unavailable.");
}
return _resolve();
}
private static ExecutablePaths FromDirectoryPaths(string directory)
{
string fullDirectory = Path.GetFullPath(directory);
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
return new ExecutablePaths(
Path.Combine(fullDirectory, "AcDream.App" + executableSuffix),
Path.Combine(fullDirectory, "acdream-headless" + executableSuffix),
fullDirectory);
}
private static bool HasUnixExecutePermission(string path)
@ -134,4 +193,9 @@ public sealed class LauncherExecutableSet
return false;
}
}
private sealed record ExecutablePaths(
string GraphicalHostPath,
string HeadlessHostPath,
string? WorkingDirectory);
}