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);
}

View file

@ -1,6 +1,7 @@
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Status;
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Orchestration;
@ -26,6 +27,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
private readonly ILauncherProcessSupervisorFactory _supervisorFactory;
private readonly IStatusEventSourceFactory _statusSourceFactory;
private readonly Func<string> _sessionIdFactory;
private readonly UpdateSessionBarrier _updateSessionBarrier;
private readonly List<ManagedActivity> _activities = [];
private LauncherInstallRecord? _installRecord;
@ -42,7 +44,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
ILauncherProcessSupervisorFactory? supervisorFactory = null,
IStatusEventSourceFactory? statusSourceFactory = null,
Func<string>? sessionIdFactory = null,
string? installationStatus = null)
string? installationStatus = null,
UpdateSessionBarrier? updateSessionBarrier = null)
{
_profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore));
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
@ -53,6 +56,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
_supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory();
_statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory();
_sessionIdFactory = sessionIdFactory ?? CreateSessionId;
_updateSessionBarrier = updateSessionBarrier
?? new UpdateSessionBarrier(paths.DataDirectory);
_installationStatus = installationStatus
?? (installRecord is null
? FirstRunRequired
@ -629,10 +634,18 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
{
ILauncherProcessSupervisor? supervisor = null;
string? password = request.Password;
bool hostStarted = false;
try
{
request.Cancellation.Token.ThrowIfCancellationRequested();
UpdateSessionBarrier.SessionLease sessionLease =
_updateSessionBarrier.AcquireSession();
lock (_gate)
{
request.Activity.UpdateSessionLease = sessionLease;
}
ComposedSessionConfig composed = request.IsProbe
? _configService.ComposeProbeAndWrite(
request.Server,
@ -674,6 +687,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
request.Activity.LaunchMode!.Value,
composed.ConfigFilePath);
supervisor.Start(processSpec, password);
hostStarted = true;
request.Password = null;
password = null;
@ -728,6 +742,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
finally
{
request.Password = null;
if (!hostStarted)
{
ReleaseUpdateSessionLease(request.Activity);
}
}
}
@ -735,6 +753,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
ManagedActivity activity,
LauncherSessionState processState)
{
UpdateSessionBarrier.SessionLease? sessionLease = null;
try
{
lock (_gate)
@ -774,10 +793,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
{
activity.Status = activity.HostTerminalStatus;
}
sessionLease = activity.UpdateSessionLease;
activity.UpdateSessionLease = null;
break;
}
}
sessionLease?.Dispose();
RaiseStateChanged();
}
catch
@ -1192,6 +1214,15 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
activity.Supervisor.Dispose();
activity.Supervisor = null;
}
ReleaseUpdateSessionLease(activity);
}
private static void ReleaseUpdateSessionLease(ManagedActivity activity)
{
UpdateSessionBarrier.SessionLease? lease =
Interlocked.Exchange(ref activity.UpdateSessionLease, null);
lease?.Dispose();
}
private void ThrowIfDisposed()
@ -1252,6 +1283,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public CancellationTokenSource? StartCancellation { get; set; }
public UpdateSessionBarrier.SessionLease? UpdateSessionLease;
public object StatusReadGate { get; } = new();
public bool IsActive => State is not (

View file

@ -0,0 +1,84 @@
using System.Text.Json;
namespace AcDream.Launcher.Core.Updates;
internal static class AtomicJsonFile
{
internal static async Task WriteAsync<T>(
string path,
T value,
JsonSerializerOptions options,
CancellationToken cancellationToken = default)
{
string fullPath = Path.GetFullPath(path);
string directory = Path.GetDirectoryName(fullPath)
?? throw new InvalidOperationException("The JSON path has no parent directory.");
Directory.CreateDirectory(directory);
string temporaryPath = Path.Combine(
directory,
$".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp");
try
{
await using (var stream = new FileStream(
temporaryPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
16 * 1024,
FileOptions.Asynchronous | FileOptions.WriteThrough))
{
await JsonSerializer.SerializeAsync(
stream,
value,
options,
cancellationToken)
.ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
stream.Flush(flushToDisk: true);
}
cancellationToken.ThrowIfCancellationRequested();
File.Move(temporaryPath, fullPath, overwrite: true);
}
finally
{
VerifiedArtifactDownloader.TryDelete(temporaryPath);
}
}
internal static async Task WriteBytesAsync(
string path,
ReadOnlyMemory<byte> bytes,
CancellationToken cancellationToken = default)
{
string fullPath = Path.GetFullPath(path);
string directory = Path.GetDirectoryName(fullPath)
?? throw new InvalidOperationException("The file path has no parent directory.");
Directory.CreateDirectory(directory);
string temporaryPath = Path.Combine(
directory,
$".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp");
try
{
await using (var stream = new FileStream(
temporaryPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
16 * 1024,
FileOptions.Asynchronous | FileOptions.WriteThrough))
{
await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
stream.Flush(flushToDisk: true);
}
cancellationToken.ThrowIfCancellationRequested();
File.Move(temporaryPath, fullPath, overwrite: true);
}
finally
{
VerifiedArtifactDownloader.TryDelete(temporaryPath);
}
}
}

View file

@ -0,0 +1,973 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Launcher.Core.Integrity;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Updates;
public sealed record InstalledFileRecord(
string Path,
string Sha256,
long Size,
int UnixMode);
public sealed record ClientVersionRecord(
int SchemaVersion,
string Version,
string Rid,
string ArchiveSha256,
long ArchiveSize,
IReadOnlyList<InstalledFileRecord> Files)
{
public const int CurrentSchemaVersion = 1;
}
public sealed record ClientActivationPointer(
int SchemaVersion,
string CurrentVersion,
string? PreviousVersion)
{
public const int CurrentSchemaVersion = 1;
}
public enum ClientVersionState
{
Missing,
Verified,
Invalid,
}
public sealed record ClientVersionResolution(
ClientVersionState State,
string Status,
LauncherVersion? Version,
string? Directory,
string? PreviousVersion,
ClientVersionRecord? Record)
{
public bool IsVerified => State == ClientVersionState.Verified;
}
/// <summary>
/// Strict installed-version and activation-pointer authority. LA9's DAT/pak
/// record is intentionally not represented here.
/// </summary>
public sealed class ClientVersionStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = false,
WriteIndented = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
MaxDepth = 32,
};
private readonly object _gate = new();
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
private ClientVersionResolution _cached = new(
ClientVersionState.Missing,
"No versioned client is installed. Check for updates to install one.",
null,
null,
null,
null);
public ClientVersionStore(
ApplicationPathSet paths,
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
{
ArgumentNullException.ThrowIfNull(paths);
AppDirectory = Path.Combine(Path.GetFullPath(paths.DataDirectory), "app");
CurrentPointerPath = Path.Combine(AppDirectory, "current.json");
PreviousPointerPath = Path.Combine(AppDirectory, "current.previous.json");
Barrier = new UpdateSessionBarrier(paths.DataDirectory);
_computeSha256 = computeSha256
?? ((path, token) => FileIntegrity.ComputeSha256HexAsync(path, token));
}
public string AppDirectory { get; }
public string CurrentPointerPath { get; }
public string PreviousPointerPath { get; }
public UpdateSessionBarrier Barrier { get; }
public ClientVersionResolution CachedResolution
{
get
{
lock (_gate)
{
return _cached;
}
}
}
public string GetVersionDirectory(LauncherVersion version) =>
Path.Combine(AppDirectory, version.Value);
public static string GetMetadataPath(string versionDirectory) =>
Path.Combine(Path.GetFullPath(versionDirectory), "install.json");
public async Task<ClientVersionResolution> LoadAndRecoverAsync(
string rid,
CancellationToken cancellationToken = default)
{
try
{
using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive();
return await LoadAndRecoverUnderLeaseAsync(rid, cancellationToken)
.ConfigureAwait(false);
}
catch (LauncherUpdateException ex) when (ex.InnerException is IOException)
{
// Another launcher may legitimately hold a shared session lease.
// Pointer publication is atomic and old versions are retained, so
// a read-only verification remains safe; mutation/recovery waits
// for the next startup without active sessions.
return await LoadCurrentReadOnlyAsync(rid, cancellationToken)
.ConfigureAwait(false);
}
}
public async Task<ClientVersionResolution> LoadCurrentReadOnlyAsync(
string rid,
CancellationToken cancellationToken = default)
{
RequireRid(rid);
PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken)
.ConfigureAwait(false);
ClientVersionResolution resolution = current.Pointer is null
? (!File.Exists(CurrentPointerPath)
? new ClientVersionResolution(
ClientVersionState.Missing,
"No versioned client is installed. Check for updates to install one.",
null,
null,
null,
null)
: Invalid(current.Error ?? "The client activation pointer is invalid."))
: await ResolvePointerAsync(current.Pointer, rid, cancellationToken)
.ConfigureAwait(false);
SetCached(resolution);
return resolution;
}
internal async Task<ClientVersionResolution> LoadAndRecoverUnderLeaseAsync(
string rid,
CancellationToken cancellationToken = default)
{
RequireRid(rid);
Directory.CreateDirectory(AppDirectory);
CleanupOwnedResidue();
PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken)
.ConfigureAwait(false);
if (current.Pointer is not null)
{
ClientVersionResolution resolution = await ResolvePointerAsync(
current.Pointer,
rid,
cancellationToken)
.ConfigureAwait(false);
SetCached(resolution);
return resolution;
}
PointerRead previous = await ReadPointerAsync(PreviousPointerPath, cancellationToken)
.ConfigureAwait(false);
if (previous.Pointer is not null)
{
ClientVersionResolution recovered = await ResolvePointerAsync(
previous.Pointer,
rid,
cancellationToken)
.ConfigureAwait(false);
if (recovered.IsVerified)
{
await WritePointerFileAsync(
CurrentPointerPath,
previous.Pointer,
cancellationToken)
.ConfigureAwait(false);
recovered = recovered with
{
Status = "Recovered the last valid client activation pointer.",
};
SetCached(recovered);
return recovered;
}
}
ClientVersionResolution missingOrInvalid =
!File.Exists(CurrentPointerPath) && !File.Exists(PreviousPointerPath)
? new ClientVersionResolution(
ClientVersionState.Missing,
"No versioned client is installed. Check for updates to install one.",
null,
null,
null,
null)
: new ClientVersionResolution(
ClientVersionState.Invalid,
current.Error
?? previous.Error
?? "No valid client activation pointer could be recovered.",
null,
null,
null,
null);
SetCached(missingOrInvalid);
return missingOrInvalid;
}
internal async Task<ClientVersionResolution> PromoteAndActivateUnderLeaseAsync(
string stagingDirectory,
LauncherVersion version,
string rid,
ReleaseArtifact artifact,
IReadOnlyList<ExtractedFileRecord> extractedFiles,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(stagingDirectory);
ArgumentNullException.ThrowIfNull(version);
ArgumentNullException.ThrowIfNull(artifact);
ArgumentNullException.ThrowIfNull(extractedFiles);
RequireRid(rid);
string staging = Path.GetFullPath(stagingDirectory);
RequireOwnedStagingPath(staging);
ValidateRequiredExecutables(extractedFiles, rid, launcherPayload: false);
if (extractedFiles.Any(file => string.Equals(
file.Path,
"install.json",
StringComparison.OrdinalIgnoreCase)))
{
throw new LauncherUpdateException(
"The client ZIP may not provide the launcher's install.json record.");
}
var record = new ClientVersionRecord(
ClientVersionRecord.CurrentSchemaVersion,
version.Value,
rid,
artifact.Sha256.ToLowerInvariant(),
artifact.Size,
extractedFiles
.Select(file => new InstalledFileRecord(
file.Path,
file.Sha256,
file.Size,
file.UnixMode))
.OrderBy(file => file.Path, StringComparer.Ordinal)
.ToArray());
ValidateRecord(record, version, rid);
await AtomicJsonFile.WriteAsync(
GetMetadataPath(staging),
record,
SerializerOptions,
cancellationToken)
.ConfigureAwait(false);
ClientVersionResolution staged = await VerifyVersionDirectoryAsync(
staging,
version,
rid,
cancellationToken)
.ConfigureAwait(false);
if (!staged.IsVerified)
{
throw new LauncherUpdateException(staged.Status);
}
ClientActivationPointer? oldPointer = (await ReadPointerAsync(
CurrentPointerPath,
cancellationToken)
.ConfigureAwait(false)).Pointer;
string target = GetVersionDirectory(version);
if (Directory.Exists(target))
{
ClientVersionResolution existing = await VerifyVersionDirectoryAsync(
target,
version,
rid,
cancellationToken)
.ConfigureAwait(false);
if (existing.IsVerified
&& existing.Record is not null
&& string.Equals(
existing.Record.ArchiveSha256,
artifact.Sha256,
StringComparison.OrdinalIgnoreCase)
&& existing.Record.ArchiveSize == artifact.Size)
{
SafeZipExtractor.TryDeleteDirectory(staging);
}
else
{
if (oldPointer is not null
&& string.Equals(
oldPointer.CurrentVersion,
version.Value,
StringComparison.Ordinal))
{
throw new LauncherUpdateException(
"The active client version is corrupt and cannot be replaced in place. "
+ "Roll back before repairing it.");
}
string quarantine = Path.Combine(
AppDirectory,
$".client-corrupt-{Guid.NewGuid():N}");
Directory.Move(target, quarantine);
try
{
Directory.Move(staging, target);
}
catch
{
Directory.Move(quarantine, target);
throw;
}
SafeZipExtractor.TryDeleteDirectory(quarantine);
}
}
else
{
Directory.Move(staging, target);
}
string? previousVersion = oldPointer is null
|| string.Equals(
oldPointer.CurrentVersion,
version.Value,
StringComparison.Ordinal)
? oldPointer?.PreviousVersion
: oldPointer.CurrentVersion;
var pointer = new ClientActivationPointer(
ClientActivationPointer.CurrentSchemaVersion,
version.Value,
previousVersion);
await SavePointerAsync(pointer, cancellationToken).ConfigureAwait(false);
ClientVersionResolution resolution = await ResolvePointerAsync(
pointer,
rid,
cancellationToken)
.ConfigureAwait(false);
if (!resolution.IsVerified)
{
throw new LauncherUpdateException(resolution.Status);
}
SetCached(resolution);
return resolution;
}
public async Task<ClientVersionResolution> RollbackAsync(
string rid,
CancellationToken cancellationToken = default)
{
using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive();
PointerRead read = await ReadPointerAsync(CurrentPointerPath, cancellationToken)
.ConfigureAwait(false);
ClientActivationPointer pointer = read.Pointer
?? throw new LauncherUpdateException(
read.Error ?? "There is no active client version to roll back.");
if (string.IsNullOrEmpty(pointer.PreviousVersion))
{
throw new LauncherUpdateException(
"There is no previous client version available for rollback.");
}
LauncherVersion previous = LauncherVersion.Parse(pointer.PreviousVersion);
ClientVersionResolution verified = await VerifyVersionDirectoryAsync(
GetVersionDirectory(previous),
previous,
rid,
cancellationToken)
.ConfigureAwait(false);
if (!verified.IsVerified)
{
throw new LauncherUpdateException(
$"The previous client version cannot be activated: {verified.Status}");
}
var swapped = new ClientActivationPointer(
ClientActivationPointer.CurrentSchemaVersion,
previous.Value,
pointer.CurrentVersion);
await SavePointerAsync(swapped, cancellationToken).ConfigureAwait(false);
ClientVersionResolution resolution = await ResolvePointerAsync(
swapped,
rid,
cancellationToken)
.ConfigureAwait(false);
SetCached(resolution);
return resolution;
}
internal string CreateClientStagingDirectory(Guid transactionId)
{
Directory.CreateDirectory(AppDirectory);
return Path.Combine(AppDirectory, $".client-staging-{transactionId:N}");
}
internal static void ValidateRequiredExecutables(
IReadOnlyList<ExtractedFileRecord> files,
string rid,
bool launcherPayload)
{
string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty;
string[] required = launcherPayload
? ["acdream-launcher" + suffix]
: ["AcDream.App" + suffix, "acdream-headless" + suffix];
foreach (string path in required)
{
ExtractedFileRecord? file = files.SingleOrDefault(candidate =>
string.Equals(candidate.Path, path, StringComparison.Ordinal));
if (file is null)
{
throw new LauncherUpdateException(
$"The release ZIP is missing required root executable '{path}'.");
}
if (rid.StartsWith("linux-", StringComparison.Ordinal)
&& (file.UnixMode & (int)UnixFileMode.UserExecute) == 0)
{
throw new LauncherUpdateException(
$"The Linux release executable '{path}' lacks owner execute permission.");
}
}
}
private async Task<ClientVersionResolution> ResolvePointerAsync(
ClientActivationPointer pointer,
string rid,
CancellationToken cancellationToken)
{
string? error = ValidatePointer(pointer);
if (error is not null)
{
return Invalid(error);
}
LauncherVersion version = LauncherVersion.Parse(pointer.CurrentVersion);
ClientVersionResolution resolution = await VerifyVersionDirectoryAsync(
GetVersionDirectory(version),
version,
rid,
cancellationToken)
.ConfigureAwait(false);
return resolution.IsVerified
? resolution with { PreviousVersion = pointer.PreviousVersion }
: resolution;
}
private async Task<ClientVersionResolution> VerifyVersionDirectoryAsync(
string directory,
LauncherVersion version,
string rid,
CancellationToken cancellationToken)
{
if (!Directory.Exists(directory))
{
return Invalid($"Client version {version} directory is missing.");
}
try
{
RejectReparseTree(directory);
ClientVersionRecord? record = await ReadStrictAsync<ClientVersionRecord>(
GetMetadataPath(directory),
cancellationToken)
.ConfigureAwait(false);
if (record is null)
{
return Invalid($"Client version {version} install.json is missing.");
}
string? contractError = ValidateRecord(record, version, rid);
if (contractError is not null)
{
return Invalid(contractError);
}
string[] actualFiles = Directory.EnumerateFiles(
directory,
"*",
SearchOption.AllDirectories)
.Select(path => NormalizeRelative(directory, path))
.Where(path => !string.Equals(
path,
"install.json",
StringComparison.OrdinalIgnoreCase))
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
string[] recordedFiles = record.Files
.Select(file => file.Path)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
if (!actualFiles.SequenceEqual(recordedFiles, StringComparer.Ordinal))
{
return Invalid(
$"Client version {version} contains missing or unrecorded files.");
}
foreach (InstalledFileRecord file in record.Files)
{
cancellationToken.ThrowIfCancellationRequested();
string path = ResolveContained(directory, file.Path);
var info = new FileInfo(path);
if (!info.Exists || info.Length != file.Size)
{
return Invalid(
$"Client version {version} file '{file.Path}' size is corrupt.");
}
string sha256 = await _computeSha256(path, cancellationToken)
.ConfigureAwait(false);
if (!string.Equals(
sha256,
file.Sha256,
StringComparison.OrdinalIgnoreCase))
{
return Invalid(
$"Client version {version} file '{file.Path}' SHA-256 is corrupt.");
}
if (OperatingSystem.IsLinux()
&& ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode)
{
return Invalid(
$"Client version {version} file '{file.Path}' mode is corrupt.");
}
}
return new ClientVersionResolution(
ClientVersionState.Verified,
$"Client version {version} verified.",
version,
directory,
null,
record);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or JsonException
or NotSupportedException
or FormatException
or LauncherUpdateException)
{
return Invalid(
$"Client version {version} could not be verified: {ex.Message}");
}
}
private async Task SavePointerAsync(
ClientActivationPointer pointer,
CancellationToken cancellationToken)
{
string? error = ValidatePointer(pointer);
if (error is not null)
{
throw new LauncherUpdateException(error);
}
if (File.Exists(CurrentPointerPath))
{
byte[] previous = await File.ReadAllBytesAsync(
CurrentPointerPath,
cancellationToken)
.ConfigureAwait(false);
PointerRead validPrevious = ParsePointer(previous);
if (validPrevious.Pointer is not null)
{
await AtomicJsonFile.WriteBytesAsync(
PreviousPointerPath,
previous,
cancellationToken)
.ConfigureAwait(false);
}
}
await WritePointerFileAsync(CurrentPointerPath, pointer, cancellationToken)
.ConfigureAwait(false);
}
private static Task WritePointerFileAsync(
string path,
ClientActivationPointer pointer,
CancellationToken cancellationToken) =>
AtomicJsonFile.WriteAsync(path, pointer, SerializerOptions, cancellationToken);
private static async Task<PointerRead> ReadPointerAsync(
string path,
CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return new PointerRead(null, null);
}
try
{
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken)
.ConfigureAwait(false);
return ParsePointer(bytes);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return new PointerRead(null, $"Client pointer could not be read: {ex.Message}");
}
}
private static PointerRead ParsePointer(ReadOnlyMemory<byte> bytes)
{
try
{
ClientActivationPointer? pointer = ParseStrict<ClientActivationPointer>(bytes.Span);
string? error = pointer is null
? "Client pointer is empty."
: ValidatePointer(pointer);
return error is null
? new PointerRead(pointer, null)
: new PointerRead(null, error);
}
catch (Exception ex) when (ex is JsonException
or LauncherUpdateException
or FormatException)
{
return new PointerRead(null, $"Client pointer is invalid: {ex.Message}");
}
}
private static string? ValidatePointer(ClientActivationPointer pointer)
{
if (pointer.SchemaVersion != ClientActivationPointer.CurrentSchemaVersion)
{
return $"Client pointer schema version {pointer.SchemaVersion} is not supported.";
}
if (!LauncherVersion.TryParse(pointer.CurrentVersion, out _))
{
return "Client pointer currentVersion is invalid.";
}
if (pointer.PreviousVersion is not null
&& (!LauncherVersion.TryParse(pointer.PreviousVersion, out _)
|| string.Equals(
pointer.PreviousVersion,
pointer.CurrentVersion,
StringComparison.Ordinal)))
{
return "Client pointer previousVersion is invalid.";
}
return null;
}
private static string? ValidateRecord(
ClientVersionRecord record,
LauncherVersion version,
string rid)
{
if (record.SchemaVersion != ClientVersionRecord.CurrentSchemaVersion)
{
return $"Client install schema version {record.SchemaVersion} is not supported.";
}
if (!string.Equals(record.Version, version.Value, StringComparison.Ordinal)
|| !LauncherVersion.TryParse(record.Version, out _))
{
return "Client install version does not match its directory.";
}
if (!string.Equals(record.Rid, rid, StringComparison.Ordinal)
|| !LauncherRuntimeIdentity.IsValidRid(record.Rid))
{
return $"Client install RID does not match '{rid}'.";
}
if (!ReleaseManifestClient.IsSha256(record.ArchiveSha256)
|| record.ArchiveSize <= 0
|| record.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes)
{
return "Client install archive metadata is invalid.";
}
if (record.Files is null || record.Files.Count == 0)
{
return "Client install file list is empty.";
}
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string? prior = null;
foreach (InstalledFileRecord file in record.Files)
{
if (!IsNormalizedRelative(file.Path)
|| !paths.Add(file.Path)
|| !ReleaseManifestClient.IsSha256(file.Sha256)
|| file.Size < 0
|| file.UnixMode is < 0 or > 0x1FF
|| (prior is not null
&& string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0))
{
return "Client install file metadata is invalid, duplicated, or unsorted.";
}
prior = file.Path;
}
string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty;
foreach (string required in new[]
{
"AcDream.App" + suffix,
"acdream-headless" + suffix,
})
{
if (!paths.Contains(required))
{
return $"Client install is missing '{required}'.";
}
}
return null;
}
private static async Task<T?> ReadStrictAsync<T>(
string path,
CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return default;
}
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken)
.ConfigureAwait(false);
return ParseStrict<T>(bytes);
}
internal static T? ParseStrict<T>(
ReadOnlySpan<byte> bytes,
JsonSerializerOptions? serializerOptions = null)
{
using JsonDocument document = JsonDocument.Parse(
bytes.ToArray(),
new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 32,
});
RejectDuplicateProperties(document.RootElement, "$" );
return document.RootElement.Deserialize<T>(
serializerOptions ?? SerializerOptions);
}
private static void RejectDuplicateProperties(JsonElement element, string path)
{
if (element.ValueKind == JsonValueKind.Object)
{
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (JsonProperty property in element.EnumerateObject())
{
if (!names.Add(property.Name))
{
throw new LauncherUpdateException(
$"Duplicate JSON property '{path}.{property.Name}' is not allowed.");
}
RejectDuplicateProperties(property.Value, $"{path}.{property.Name}");
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
int index = 0;
foreach (JsonElement item in element.EnumerateArray())
{
RejectDuplicateProperties(item, $"{path}[{index++}]");
}
}
}
private void CleanupOwnedResidue()
{
foreach (string path in Directory.EnumerateDirectories(
AppDirectory,
".client-staging-*",
SearchOption.TopDirectoryOnly))
{
string suffix = Path.GetFileName(path)[".client-staging-".Length..];
if (Guid.TryParseExact(suffix, "N", out _))
{
SafeZipExtractor.TryDeleteDirectory(path);
}
}
foreach (string path in Directory.EnumerateFiles(
AppDirectory,
".current*.tmp",
SearchOption.TopDirectoryOnly))
{
string fileName = Path.GetFileName(path);
string[] parts = fileName.Split('.');
if (parts.Length >= 4
&& string.Equals(parts[^1], "tmp", StringComparison.Ordinal)
&& Guid.TryParseExact(parts[^2], "N", out _))
{
VerifiedArtifactDownloader.TryDelete(path);
}
}
}
private void RequireOwnedStagingPath(string path)
{
string parent = Path.GetDirectoryName(path) ?? string.Empty;
string fileName = Path.GetFileName(path);
if (!PathsEqual(parent, AppDirectory)
|| !fileName.StartsWith(".client-staging-", StringComparison.Ordinal)
|| !Guid.TryParseExact(fileName[".client-staging-".Length..], "N", out _))
{
throw new LauncherUpdateException(
"The client extraction path is not an owned LA10 staging directory.");
}
}
internal static void RejectReparseTree(string root)
{
if ((File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0)
{
throw new LauncherUpdateException("The client version directory is a reparse point.");
}
var pending = new Stack<string>();
pending.Push(root);
while (pending.TryPop(out string? directory))
{
foreach (string path in Directory.EnumerateFileSystemEntries(
directory,
"*",
SearchOption.TopDirectoryOnly))
{
FileAttributes attributes = File.GetAttributes(path);
if ((attributes & FileAttributes.ReparsePoint) != 0)
{
throw new LauncherUpdateException(
$"Client install path '{NormalizeRelative(root, path)}' is a reparse point.");
}
if ((attributes & FileAttributes.Directory) != 0)
{
pending.Push(path);
}
}
}
}
private static string NormalizeRelative(string root, string path) =>
Path.GetRelativePath(root, path).Replace('\\', '/');
internal static bool IsNormalizedRelative(string? path)
{
if (string.IsNullOrEmpty(path)
|| path.Length > 512
|| path.IndexOf('\0') >= 0
|| path.Contains('\\', StringComparison.Ordinal)
|| path.Contains(':', StringComparison.Ordinal)
|| path.StartsWith("/", StringComparison.Ordinal)
|| Path.IsPathRooted(path))
{
return false;
}
string[] parts = path.Split('/');
return parts.All(part =>
part.Length > 0
&& part is not ("." or "..")
&& !part.EndsWith(' ')
&& !part.EndsWith('.')
&& !part.Any(character =>
char.IsControl(character)
|| character is '<' or '>' or '"' or '|' or '?' or '*')
&& !IsWindowsDeviceName(part));
}
internal static string ResolveContained(string root, string relative)
{
if (!IsNormalizedRelative(relative))
{
throw new LauncherUpdateException($"Unsafe relative path '{relative}'.");
}
string fullRoot = Path.GetFullPath(root);
string path = Path.GetFullPath(
Path.Combine(fullRoot, relative.Replace('/', Path.DirectorySeparatorChar)));
string prefix = Path.EndsInDirectorySeparator(fullRoot)
? fullRoot
: fullRoot + Path.DirectorySeparatorChar;
if (!path.StartsWith(
prefix,
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal))
{
throw new LauncherUpdateException($"Path '{relative}' escaped its root.");
}
return path;
}
private static bool IsWindowsDeviceName(string segment)
{
string stem = segment.Split('.')[0];
return stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|| (stem.Length == 4
&& (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|| stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
&& stem[3] is >= '1' and <= '9');
}
private static bool PathsEqual(string left, string right) =>
string.Equals(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
private static void RequireRid(string rid)
{
if (!LauncherRuntimeIdentity.IsValidRid(rid))
{
throw new ArgumentException("RID is invalid.", nameof(rid));
}
}
private void SetCached(ClientVersionResolution resolution)
{
lock (_gate)
{
_cached = resolution;
}
}
private static ClientVersionResolution Invalid(string status) =>
new(ClientVersionState.Invalid, status, null, null, null, null);
private sealed record PointerRead(ClientActivationPointer? Pointer, string? Error);
}

View file

@ -0,0 +1,33 @@
using System.Runtime.InteropServices;
namespace AcDream.Launcher.Core.Updates;
public static class LauncherRuntimeIdentity
{
public static string DetectRid()
{
string os = OperatingSystem.IsWindows()
? "win"
: OperatingSystem.IsLinux()
? "linux"
: throw new PlatformNotSupportedException(
"The launcher updater supports Windows and Linux only.");
string architecture = RuntimeInformation.ProcessArchitecture switch
{
Architecture.X64 => "x64",
Architecture.Arm64 => "arm64",
_ => throw new PlatformNotSupportedException(
$"The launcher updater does not support {RuntimeInformation.ProcessArchitecture}."),
};
return $"{os}-{architecture}";
}
internal static bool IsValidRid(string? rid) =>
!string.IsNullOrEmpty(rid)
&& rid.Length <= 64
&& rid[0] is >= 'a' and <= 'z'
&& rid.All(character =>
character is >= 'a' and <= 'z'
or >= '0' and <= '9'
or '-');
}

View file

@ -0,0 +1,299 @@
using System.Diagnostics;
namespace AcDream.Launcher.Core.Updates;
public sealed record SelfUpdateStartupResult(
bool ShouldExit,
int ExitCode,
string[] RemainingArguments);
/// <summary>
/// Process-level rename dance for launcher self-update. Every child argument
/// is passed through <see cref="ProcessStartInfo.ArgumentList"/> with
/// <c>UseShellExecute=false</c>; no path or PID is ever interpolated into a
/// shell command.
/// </summary>
public static class LauncherSelfUpdateBootstrap
{
public const string HelperArgument = "--acdream-self-update-helper-v1";
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
public static async Task<SelfUpdateStartupResult> HandleAsync(
string[] args,
LauncherSelfUpdateManager manager,
string launcherBaseDirectory,
string currentExecutablePath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(manager);
string baseDirectory = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(launcherBaseDirectory));
string executable = Path.GetFullPath(currentExecutablePath);
if (args.Length > 0
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
{
if (args.Length != 4
|| !int.TryParse(
args[1],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int parentPid)
|| parentPid <= 0)
{
return new SelfUpdateStartupResult(true, 64, []);
}
int exitCode = await RunHelperAsync(
manager,
parentPid,
args[2],
args[3],
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(true, exitCode, []);
}
if (args.Length > 0
&& string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal))
{
if (args.Length != 2)
{
return new SelfUpdateStartupResult(true, 64, []);
}
await manager.ConfirmAsync(
args[1],
baseDirectory,
executable,
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(false, 0, []);
}
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
if (plan is null)
{
return new SelfUpdateStartupResult(false, 0, args);
}
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
{
throw new LauncherUpdateException(
"The pending self-update targets a different launcher directory.");
}
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
{
if (!manager.IsConfirmed(plan.TransactionId))
{
await manager.ConfirmAsync(
plan.TransactionId,
baseDirectory,
executable,
cancellationToken)
.ConfigureAwait(false);
}
await manager.CompleteConfirmedAsync(
plan.TransactionId,
baseDirectory,
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(false, 0, args);
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
string expectedExecutable = ClientVersionStore.ResolveContained(
baseDirectory,
"acdream-launcher" + suffix);
if (!PathsEqual(executable, expectedExecutable))
{
throw new LauncherUpdateException(
"Self-update can start only from the published acdream-launcher executable.");
}
string helperPath = manager.GetHelperPath(plan.TransactionId);
Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!);
VerifiedArtifactDownloader.TryDelete(helperPath);
File.Copy(executable, helperPath, overwrite: false);
if (OperatingSystem.IsLinux())
{
File.SetUnixFileMode(
helperPath,
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute);
}
var startInfo = new ProcessStartInfo(helperPath)
{
UseShellExecute = false,
WorkingDirectory = manager.GetTransactionDirectory(plan.TransactionId),
};
startInfo.ArgumentList.Add(HelperArgument);
startInfo.ArgumentList.Add(
Environment.ProcessId.ToString(
System.Globalization.CultureInfo.InvariantCulture));
startInfo.ArgumentList.Add(baseDirectory);
startInfo.ArgumentList.Add(plan.TransactionId);
_ = Process.Start(startInfo)
?? throw new LauncherUpdateException(
"The launcher self-update helper could not be started.");
return new SelfUpdateStartupResult(true, 0, []);
}
private static async Task<int> RunHelperAsync(
LauncherSelfUpdateManager manager,
int parentPid,
string targetDirectory,
string transactionId,
CancellationToken cancellationToken)
{
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("The helper found no pending self-update.");
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
{
throw new LauncherUpdateException(
"The helper transaction does not match the pending self-update.");
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
string launcherPath = ClientVersionStore.ResolveContained(
targetDirectory,
"acdream-launcher" + suffix);
var startInfo = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
startInfo.ArgumentList.Add(ConfirmArgument);
startInfo.ArgumentList.Add(transactionId);
Process? replacement = null;
UpdateSessionBarrier.ExclusiveLease? updateLease = null;
bool appliedByThisHelper = false;
try
{
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
updateLease = manager.Barrier.AcquireExclusive();
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
.ConfigureAwait(false);
appliedByThisHelper = true;
replacement = Process.Start(startInfo)
?? throw new LauncherUpdateException(
"The updated launcher could not be started.");
DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout;
while (!manager.IsConfirmed(transactionId))
{
cancellationToken.ThrowIfCancellationRequested();
if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
{
throw new LauncherUpdateException(
replacement.HasExited
? $"The updated launcher exited with code {replacement.ExitCode} "
+ "before confirming startup."
: "The updated launcher did not confirm startup in time.");
}
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
await manager.CompleteConfirmedAsync(
transactionId,
targetDirectory,
cancellationToken)
.ConfigureAwait(false);
return 0;
}
catch
{
if (replacement is { HasExited: false })
{
replacement.Kill(entireProcessTree: true);
await replacement.WaitForExitAsync(CancellationToken.None)
.ConfigureAwait(false);
}
try
{
if (appliedByThisHelper)
{
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
CancellationToken.None)
.ConfigureAwait(false);
if (pending?.State == SelfUpdatePlanState.Applying)
{
_ = await manager.RecoverApplyingAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
{
_ = await manager.RollbackAwaitingConfirmationAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
}
}
catch
{
// Do not start an executable from an ambiguous half-applied
// state. A subsequent startup replays the durable journal.
return 75;
}
var restored = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
_ = Process.Start(restored);
return 74;
}
finally
{
replacement?.Dispose();
updateLease?.Dispose();
}
}
private static async Task WaitForParentExitAsync(
int parentPid,
CancellationToken cancellationToken)
{
try
{
using Process parent = Process.GetProcessById(parentPid);
if (parent.Id == Environment.ProcessId)
{
throw new LauncherUpdateException(
"The self-update helper cannot wait on itself.");
}
await parent.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
catch (ArgumentException)
{
// The parent exited before the helper opened it.
}
}
private static bool PathsEqual(string left, string right) =>
string.Equals(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
}

View file

@ -0,0 +1,786 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Updates;
public enum SelfUpdatePlanState
{
Staged,
Applying,
AwaitingConfirmation,
}
public sealed record SelfUpdateApplyEntry(string Path, bool HadOriginal);
public sealed record SelfUpdatePlan(
int SchemaVersion,
string TransactionId,
SelfUpdatePlanState State,
string Version,
string Rid,
string TargetDirectory,
string ArchiveSha256,
long ArchiveSize,
IReadOnlyList<InstalledFileRecord> Files,
IReadOnlyList<SelfUpdateApplyEntry>? Apply)
{
public const int CurrentSchemaVersion = 1;
}
public sealed record SelfUpdateStageResult(
LauncherVersion Version,
string PendingPlanPath,
string Status);
/// <summary>
/// Durable self-update transaction owner. It stages a verified launcher ZIP;
/// a separately copied helper performs move-only replacement after the parent
/// exits and can replay rollback after a crash at any file boundary.
/// </summary>
public sealed class LauncherSelfUpdateManager
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = false,
WriteIndented = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
MaxDepth = 32,
Converters = { new JsonStringEnumConverter<SelfUpdatePlanState>(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false) },
};
private readonly VerifiedArtifactDownloader _downloader;
private readonly SafeZipExtractor _extractor;
public LauncherSelfUpdateManager(
ApplicationPathSet paths,
HttpClient httpClient,
SafeZipExtractor? extractor = null)
{
ArgumentNullException.ThrowIfNull(paths);
RootDirectory = Path.Combine(
Path.GetFullPath(paths.DataDirectory),
"launcher-update");
TransactionsDirectory = Path.Combine(RootDirectory, "transactions");
PendingPlanPath = Path.Combine(RootDirectory, "pending.json");
Barrier = new UpdateSessionBarrier(paths.DataDirectory);
_downloader = new VerifiedArtifactDownloader(
httpClient ?? throw new ArgumentNullException(nameof(httpClient)));
_extractor = extractor ?? new SafeZipExtractor();
}
public string RootDirectory { get; }
public string TransactionsDirectory { get; }
public string PendingPlanPath { get; }
public UpdateSessionBarrier Barrier { get; }
public async Task<SelfUpdateStageResult> StageAsync(
ReleaseManifest manifest,
string rid,
string targetDirectory,
IProgress<ArtifactDownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
ReleaseArtifact artifact = manifest.RequireLauncher(rid);
return await StageAsync(
manifest.Version,
rid,
artifact,
targetDirectory,
progress,
cancellationToken)
.ConfigureAwait(false);
}
internal async Task<SelfUpdateStageResult> StageAsync(
LauncherVersion version,
string rid,
ReleaseArtifact artifact,
string targetDirectory,
IProgress<ArtifactDownloadProgress>? progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(version);
ArgumentNullException.ThrowIfNull(artifact);
if (!LauncherRuntimeIdentity.IsValidRid(rid))
{
throw new ArgumentException("RID is invalid.", nameof(rid));
}
string target = NormalizeTargetDirectory(targetDirectory);
Directory.CreateDirectory(RootDirectory);
Directory.CreateDirectory(TransactionsDirectory);
SelfUpdatePlan? existing = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
throw new LauncherUpdateException(
$"Launcher self-update {existing.Version} is already {existing.State}. "
+ "Restart the launcher to finish it before staging another.");
}
string transactionId = Guid.NewGuid().ToString("N");
string transactionDirectory = GetTransactionDirectory(transactionId);
string payloadDirectory = GetPayloadDirectory(transactionId);
string archivePath = Path.Combine(transactionDirectory, "launcher.zip");
Directory.CreateDirectory(transactionDirectory);
try
{
_ = await _downloader.DownloadAsync(
artifact,
archivePath,
progress,
cancellationToken)
.ConfigureAwait(false);
IReadOnlyList<ExtractedFileRecord> extracted = await _extractor.ExtractAsync(
archivePath,
payloadDirectory,
cancellationToken)
.ConfigureAwait(false);
ClientVersionStore.ValidateRequiredExecutables(
extracted,
rid,
launcherPayload: true);
var plan = new SelfUpdatePlan(
SelfUpdatePlan.CurrentSchemaVersion,
transactionId,
SelfUpdatePlanState.Staged,
version.Value,
rid,
target,
artifact.Sha256.ToLowerInvariant(),
artifact.Size,
extracted.Select(file => new InstalledFileRecord(
file.Path,
file.Sha256,
file.Size,
file.UnixMode))
.OrderBy(file => file.Path, StringComparer.Ordinal)
.ToArray(),
null);
ValidatePlan(plan, target);
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
VerifiedArtifactDownloader.TryDelete(archivePath);
return new SelfUpdateStageResult(
version,
PendingPlanPath,
$"Launcher {version} is staged and will be applied on next start.");
}
catch
{
if (!File.Exists(PendingPlanPath))
{
SafeZipExtractor.TryDeleteDirectory(transactionDirectory);
}
throw;
}
}
public async Task<SelfUpdatePlan?> LoadPendingAsync(
CancellationToken cancellationToken = default)
{
if (!File.Exists(PendingPlanPath))
{
CleanupOwnedResidue(keepTransactionId: null);
return null;
}
try
{
byte[] bytes = await File.ReadAllBytesAsync(PendingPlanPath, cancellationToken)
.ConfigureAwait(false);
SelfUpdatePlan? plan = ClientVersionStore.ParseStrict<SelfUpdatePlan>(
bytes,
SerializerOptions);
if (plan is null)
{
throw new LauncherUpdateException("The self-update plan is empty.");
}
ValidatePlan(plan, plan.TargetDirectory);
CleanupOwnedResidue(plan.TransactionId);
return plan;
}
catch (OperationCanceledException)
{
throw;
}
catch (LauncherUpdateException)
{
throw;
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or JsonException
or FormatException
or NotSupportedException)
{
throw new LauncherUpdateException(
$"The pending launcher self-update is invalid: {ex.Message}",
ex);
}
}
public async Task<SelfUpdatePlan> ApplyPendingAsync(
string expectedTargetDirectory,
CancellationToken cancellationToken = default)
{
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no staged launcher self-update.");
ValidatePlan(plan, expectedTarget);
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
{
return plan;
}
if (plan.State == SelfUpdatePlanState.Applying)
{
plan = await RollbackApplyingAsync(plan, cancellationToken)
.ConfigureAwait(false);
}
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
var apply = new List<SelfUpdateApplyEntry>(plan.Files.Count);
foreach (InstalledFileRecord file in plan.Files)
{
string targetPath = ClientVersionStore.ResolveContained(
expectedTarget,
file.Path);
if (Directory.Exists(targetPath))
{
throw new LauncherUpdateException(
$"Self-update target '{file.Path}' is unexpectedly a directory.");
}
apply.Add(new SelfUpdateApplyEntry(file.Path, File.Exists(targetPath)));
}
plan = plan with
{
State = SelfUpdatePlanState.Applying,
Apply = apply,
};
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
string payload = GetPayloadDirectory(plan.TransactionId);
string backup = GetBackupDirectory(plan.TransactionId);
try
{
foreach (SelfUpdateApplyEntry entry in plan.Apply)
{
cancellationToken.ThrowIfCancellationRequested();
string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path);
string targetPath = ClientVersionStore.ResolveContained(expectedTarget, entry.Path);
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
EnsureSafeParent(expectedTarget, targetPath);
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!);
if (entry.HadOriginal)
{
File.Move(targetPath, backupPath);
}
File.Move(stagedPath, targetPath);
InstalledFileRecord file = plan.Files.Single(candidate =>
string.Equals(candidate.Path, entry.Path, StringComparison.Ordinal));
if (OperatingSystem.IsLinux() && file.UnixMode != 0)
{
File.SetUnixFileMode(targetPath, (UnixFileMode)file.UnixMode);
}
}
plan = plan with { State = SelfUpdatePlanState.AwaitingConfirmation };
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
return plan;
}
catch
{
await RollbackApplyingAsync(plan, CancellationToken.None)
.ConfigureAwait(false);
throw;
}
}
public async Task<SelfUpdatePlan> RecoverApplyingAsync(
string expectedTargetDirectory,
CancellationToken cancellationToken = default)
{
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no pending self-update.");
ValidatePlan(plan, expectedTarget);
return plan.State == SelfUpdatePlanState.Applying
? await RollbackApplyingAsync(plan, cancellationToken).ConfigureAwait(false)
: plan;
}
public async Task ConfirmAsync(
string transactionId,
string expectedTargetDirectory,
string currentExecutablePath,
CancellationToken cancellationToken = default)
{
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no self-update to confirm.");
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
ValidatePlan(plan, expectedTarget);
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|| plan.State != SelfUpdatePlanState.AwaitingConfirmation)
{
throw new LauncherUpdateException(
"The running launcher does not match the pending confirmation plan.");
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
string expectedExecutable = ClientVersionStore.ResolveContained(
expectedTarget,
"acdream-launcher" + suffix);
if (!PathsEqual(expectedExecutable, currentExecutablePath))
{
throw new LauncherUpdateException(
"Only the newly installed launcher executable may confirm self-update.");
}
await VerifyAppliedTargetsAsync(plan, expectedTarget, cancellationToken)
.ConfigureAwait(false);
string confirmationPath = GetConfirmationPath(transactionId);
await AtomicJsonFile.WriteBytesAsync(
confirmationPath,
"confirmed"u8.ToArray(),
cancellationToken)
.ConfigureAwait(false);
}
public bool IsConfirmed(string transactionId) =>
File.Exists(GetConfirmationPath(transactionId));
public async Task CompleteConfirmedAsync(
string transactionId,
string expectedTargetDirectory,
CancellationToken cancellationToken = default)
{
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no self-update to complete.");
ValidatePlan(plan, NormalizeTargetDirectory(expectedTargetDirectory));
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|| plan.State != SelfUpdatePlanState.AwaitingConfirmation
|| !IsConfirmed(transactionId))
{
throw new LauncherUpdateException("The self-update is not confirmed.");
}
File.Delete(PendingPlanPath);
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
}
public async Task<SelfUpdatePlan> RollbackAwaitingConfirmationAsync(
string expectedTargetDirectory,
CancellationToken cancellationToken = default)
{
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no self-update to roll back.");
ValidatePlan(plan, expectedTarget);
if (plan.State != SelfUpdatePlanState.AwaitingConfirmation)
{
throw new LauncherUpdateException(
"The pending self-update is not awaiting confirmation.");
}
plan = plan with { State = SelfUpdatePlanState.Applying };
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
return await RollbackApplyingAsync(plan, cancellationToken)
.ConfigureAwait(false);
}
public string GetTransactionDirectory(string transactionId)
{
RequireTransactionId(transactionId);
return Path.Combine(TransactionsDirectory, transactionId);
}
public string GetPayloadDirectory(string transactionId) =>
Path.Combine(GetTransactionDirectory(transactionId), "payload");
public string GetBackupDirectory(string transactionId) =>
Path.Combine(GetTransactionDirectory(transactionId), "backup");
public string GetConfirmationPath(string transactionId) =>
Path.Combine(GetTransactionDirectory(transactionId), "confirmed");
public string GetHelperPath(string transactionId)
{
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
return Path.Combine(
GetTransactionDirectory(transactionId),
"acdream-self-update-helper" + suffix);
}
private async Task<SelfUpdatePlan> RollbackApplyingAsync(
SelfUpdatePlan plan,
CancellationToken cancellationToken)
{
if (plan.State != SelfUpdatePlanState.Applying || plan.Apply is null)
{
throw new LauncherUpdateException("The self-update rollback journal is missing.");
}
string payload = GetPayloadDirectory(plan.TransactionId);
string backup = GetBackupDirectory(plan.TransactionId);
foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse())
{
cancellationToken.ThrowIfCancellationRequested();
string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path);
string targetPath = ClientVersionStore.ResolveContained(
plan.TargetDirectory,
entry.Path);
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
if (!File.Exists(stagedPath) && File.Exists(targetPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!);
File.Move(targetPath, stagedPath);
}
if (entry.HadOriginal && File.Exists(backupPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
File.Move(backupPath, targetPath);
}
else if (!entry.HadOriginal && File.Exists(targetPath))
{
File.Delete(targetPath);
}
}
SafeZipExtractor.TryDeleteDirectory(backup);
plan = plan with
{
State = SelfUpdatePlanState.Staged,
Apply = null,
};
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
return plan;
}
private async Task VerifyPayloadAsync(
SelfUpdatePlan plan,
CancellationToken cancellationToken)
{
string payload = GetPayloadDirectory(plan.TransactionId);
if (!Directory.Exists(payload))
{
throw new LauncherUpdateException("The staged launcher payload is missing.");
}
ClientVersionStore.RejectReparseTree(payload);
string[] actual = Directory.EnumerateFiles(
payload,
"*",
SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(payload, path).Replace('\\', '/'))
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
string[] expected = plan.Files
.Select(file => file.Path)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
if (!actual.SequenceEqual(expected, StringComparer.Ordinal))
{
throw new LauncherUpdateException(
"The staged launcher contains missing or unrecorded files.");
}
foreach (InstalledFileRecord file in plan.Files)
{
cancellationToken.ThrowIfCancellationRequested();
string path = ClientVersionStore.ResolveContained(payload, file.Path);
var info = new FileInfo(path);
if (!info.Exists || info.Length != file.Size)
{
throw new LauncherUpdateException(
$"Staged launcher file '{file.Path}' size is corrupt.");
}
string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync(
path,
cancellationToken)
.ConfigureAwait(false);
if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new LauncherUpdateException(
$"Staged launcher file '{file.Path}' SHA-256 is corrupt.");
}
}
}
private static async Task VerifyAppliedTargetsAsync(
SelfUpdatePlan plan,
string targetDirectory,
CancellationToken cancellationToken)
{
foreach (InstalledFileRecord file in plan.Files)
{
cancellationToken.ThrowIfCancellationRequested();
string path = ClientVersionStore.ResolveContained(targetDirectory, file.Path);
EnsureSafeParent(targetDirectory, path);
var info = new FileInfo(path);
if (!info.Exists
|| (info.Attributes & FileAttributes.ReparsePoint) != 0
|| info.Length != file.Size)
{
throw new LauncherUpdateException(
$"Applied launcher file '{file.Path}' is missing, linked, or corrupt.");
}
string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync(
path,
cancellationToken)
.ConfigureAwait(false);
if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new LauncherUpdateException(
$"Applied launcher file '{file.Path}' SHA-256 is corrupt.");
}
if (OperatingSystem.IsLinux()
&& ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode)
{
throw new LauncherUpdateException(
$"Applied launcher file '{file.Path}' mode is corrupt.");
}
}
}
private async Task WritePlanAsync(
SelfUpdatePlan plan,
CancellationToken cancellationToken)
{
ValidatePlan(plan, plan.TargetDirectory);
await AtomicJsonFile.WriteAsync(
PendingPlanPath,
plan,
SerializerOptions,
cancellationToken)
.ConfigureAwait(false);
}
private void ValidatePlan(SelfUpdatePlan plan, string expectedTargetDirectory)
{
if (plan.SchemaVersion != SelfUpdatePlan.CurrentSchemaVersion)
{
throw new LauncherUpdateException(
$"Self-update schema version {plan.SchemaVersion} is not supported.");
}
RequireTransactionId(plan.TransactionId);
if (!LauncherVersion.TryParse(plan.Version, out _)
|| !LauncherRuntimeIdentity.IsValidRid(plan.Rid)
|| !ReleaseManifestClient.IsSha256(plan.ArchiveSha256)
|| plan.ArchiveSize <= 0
|| plan.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes)
{
throw new LauncherUpdateException("The self-update plan metadata is invalid.");
}
string target = NormalizeTargetDirectory(plan.TargetDirectory);
if (!PathsEqual(target, expectedTargetDirectory))
{
throw new LauncherUpdateException(
"The self-update target does not match the running launcher directory.");
}
if (plan.Files is null || plan.Files.Count == 0)
{
throw new LauncherUpdateException("The self-update file list is empty.");
}
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string? prior = null;
foreach (InstalledFileRecord file in plan.Files)
{
if (!ClientVersionStore.IsNormalizedRelative(file.Path)
|| !paths.Add(file.Path)
|| !ReleaseManifestClient.IsSha256(file.Sha256)
|| file.Size < 0
|| file.UnixMode is < 0 or > 0x1FF
|| (prior is not null
&& string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0))
{
throw new LauncherUpdateException(
"The self-update file list is invalid, duplicated, or unsorted.");
}
prior = file.Path;
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
if (!paths.Contains("acdream-launcher" + suffix))
{
throw new LauncherUpdateException(
"The self-update plan lacks the launcher root executable.");
}
if (plan.State == SelfUpdatePlanState.Staged && plan.Apply is not null
|| plan.State != SelfUpdatePlanState.Staged && plan.Apply is null)
{
throw new LauncherUpdateException(
"The self-update apply journal does not match its state.");
}
if (plan.Apply is not null)
{
if (plan.Apply.Count != plan.Files.Count
|| !plan.Apply.Select(entry => entry.Path)
.SequenceEqual(plan.Files.Select(file => file.Path), StringComparer.Ordinal))
{
throw new LauncherUpdateException(
"The self-update apply journal does not match the file list.");
}
}
string transactionDirectory = GetTransactionDirectory(plan.TransactionId);
if (!IsContained(TransactionsDirectory, transactionDirectory))
{
throw new LauncherUpdateException("The self-update transaction path escaped.");
}
}
private static string NormalizeTargetDirectory(string targetDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory);
if (!Path.IsPathFullyQualified(targetDirectory))
{
throw new LauncherUpdateException(
"The self-update target directory must be absolute.");
}
string target = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetDirectory));
if (!Directory.Exists(target)
|| (File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0)
{
throw new LauncherUpdateException(
"The self-update target directory is missing or is a reparse point.");
}
return target;
}
private static void EnsureSafeParent(string root, string filePath)
{
string? parent = Path.GetDirectoryName(filePath);
if (parent is null)
{
throw new LauncherUpdateException("A self-update target has no parent.");
}
Directory.CreateDirectory(parent);
for (var directory = new DirectoryInfo(parent);
directory is not null && IsContained(root, directory.FullName);
directory = directory.Parent)
{
if ((directory.Attributes & FileAttributes.ReparsePoint) != 0)
{
throw new LauncherUpdateException(
$"Self-update target parent '{directory.FullName}' is a reparse point.");
}
if (PathsEqual(directory.FullName, root))
{
break;
}
}
}
private static bool IsContained(string root, string path)
{
string fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
string fullPath = Path.GetFullPath(path);
return PathsEqual(fullRoot, fullPath)
|| fullPath.StartsWith(
fullRoot + Path.DirectorySeparatorChar,
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
}
private static bool PathsEqual(string left, string right) =>
string.Equals(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
private static void RequireTransactionId(string transactionId)
{
if (transactionId.Length != 32
|| !Guid.TryParseExact(transactionId, "N", out Guid parsed)
|| !string.Equals(parsed.ToString("N"), transactionId, StringComparison.Ordinal))
{
throw new LauncherUpdateException("The self-update transaction id is invalid.");
}
}
private void CleanupOwnedResidue(string? keepTransactionId)
{
if (Directory.Exists(TransactionsDirectory))
{
foreach (string directory in Directory.EnumerateDirectories(
TransactionsDirectory,
"*",
SearchOption.TopDirectoryOnly))
{
string name = Path.GetFileName(directory);
if (name.Length == 32
&& Guid.TryParseExact(name, "N", out Guid transaction)
&& string.Equals(
transaction.ToString("N"),
name,
StringComparison.Ordinal)
&& !string.Equals(name, keepTransactionId, StringComparison.Ordinal))
{
SafeZipExtractor.TryDeleteDirectory(directory);
}
}
}
if (!Directory.Exists(RootDirectory))
{
return;
}
foreach (string temporary in Directory.EnumerateFiles(
RootDirectory,
".pending.json.*.tmp",
SearchOption.TopDirectoryOnly))
{
string name = Path.GetFileName(temporary);
string prefix = ".pending.json.";
string transaction = name[prefix.Length..^".tmp".Length];
if (Guid.TryParseExact(transaction, "N", out _))
{
VerifiedArtifactDownloader.TryDelete(temporary);
}
}
}
}

View file

@ -0,0 +1,457 @@
namespace AcDream.Launcher.Core.Updates;
public enum LauncherUpdatePhase
{
Idle,
Checking,
DownloadingClient,
ExtractingClient,
ActivatingClient,
DownloadingLauncher,
StagingLauncher,
RollingBack,
Completed,
Cancelled,
Failed,
}
public sealed record LauncherUpdateProgress(
LauncherUpdatePhase Phase,
string Status,
long Completed = 0,
long Total = 0)
{
public double Percent => Total <= 0
? 0
: Math.Clamp(Completed * 100d / Total, 0, 100);
}
public sealed record LauncherUpdateCheckResult(
ReleaseManifest Manifest,
string Rid,
LauncherVersion LauncherVersion,
LauncherVersion? InstalledClientVersion,
bool IsClientUpdateAvailable,
bool IsLauncherUpdateAvailable,
bool IsLauncherMinimumSatisfied,
string Status);
public interface ILauncherUpdater
{
ClientVersionResolution CurrentClient { get; }
Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default);
Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default);
Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default);
Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default);
Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Canonical LA10 update transaction. It holds the cross-process exclusive
/// barrier for recovery/download/extraction/promotion/pointer publication and
/// leaves LA9's verified DAT/pak record untouched.
/// </summary>
public sealed class LauncherUpdater : ILauncherUpdater
{
private readonly IReleaseManifestClient _manifestClient;
private readonly ClientVersionStore _versions;
private readonly LauncherSelfUpdateManager _selfUpdates;
private readonly VerifiedArtifactDownloader _downloader;
private readonly SafeZipExtractor _extractor;
private readonly LauncherVersion _launcherVersion;
private readonly string _rid;
private readonly string _launcherTargetDirectory;
private readonly Func<bool> _hasRunningSessions;
private readonly SemaphoreSlim _operationGate = new(1, 1);
public LauncherUpdater(
IReleaseManifestClient manifestClient,
HttpClient httpClient,
ClientVersionStore versions,
LauncherSelfUpdateManager selfUpdates,
LauncherVersion launcherVersion,
string rid,
string launcherTargetDirectory,
Func<bool>? hasRunningSessions = null,
SafeZipExtractor? extractor = null)
{
_manifestClient = manifestClient
?? throw new ArgumentNullException(nameof(manifestClient));
_versions = versions ?? throw new ArgumentNullException(nameof(versions));
_selfUpdates = selfUpdates ?? throw new ArgumentNullException(nameof(selfUpdates));
_launcherVersion = launcherVersion
?? throw new ArgumentNullException(nameof(launcherVersion));
if (!LauncherRuntimeIdentity.IsValidRid(rid))
{
throw new ArgumentException("RID is invalid.", nameof(rid));
}
_rid = rid;
ArgumentException.ThrowIfNullOrWhiteSpace(launcherTargetDirectory);
_launcherTargetDirectory = Path.GetFullPath(launcherTargetDirectory);
_hasRunningSessions = hasRunningSessions ?? (() => false);
_downloader = new VerifiedArtifactDownloader(
httpClient ?? throw new ArgumentNullException(nameof(httpClient)));
_extractor = extractor ?? new SafeZipExtractor();
}
public ClientVersionResolution CurrentClient => _versions.CachedResolution;
public Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default) =>
_versions.LoadAndRecoverAsync(_rid, cancellationToken);
public async Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default)
{
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ReleaseManifest manifest = await _manifestClient.FetchAsync(cancellationToken)
.ConfigureAwait(false);
_ = manifest.RequireClient(_rid);
_ = manifest.RequireLauncher(_rid);
ClientVersionResolution installed = _versions.CachedResolution;
LauncherVersion? installedVersion = installed.IsVerified
? installed.Version
: null;
bool clientAvailable = installedVersion is null
|| manifest.Version > installedVersion;
bool launcherAvailable = manifest.Version > _launcherVersion;
bool minimumSatisfied = _launcherVersion >= manifest.MinimumLauncherVersion;
string status = BuildCheckStatus(
manifest,
installedVersion,
clientAvailable,
launcherAvailable,
minimumSatisfied);
return new LauncherUpdateCheckResult(
manifest,
_rid,
_launcherVersion,
installedVersion,
clientAvailable,
launcherAvailable,
minimumSatisfied,
status);
}
finally
{
_operationGate.Release();
}
}
public async Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(check);
ValidateCheck(check);
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
RefuseRunningSessions();
using UpdateSessionBarrier.ExclusiveLease lease =
_versions.Barrier.AcquireExclusive();
RefuseRunningSessions();
ClientVersionResolution current = await _versions
.LoadAndRecoverUnderLeaseAsync(_rid, cancellationToken)
.ConfigureAwait(false);
if (!check.IsLauncherMinimumSatisfied)
{
throw new LauncherUpdateException(
$"Client {check.Manifest.Version} requires launcher "
+ $"{check.Manifest.MinimumLauncherVersion} or newer. "
+ "Stage the launcher update first.");
}
if (current.IsVerified
&& current.Version is not null
&& current.Version >= check.Manifest.Version)
{
Report(
progress,
LauncherUpdatePhase.Completed,
$"Client {current.Version} is already current.",
1,
1);
return current;
}
ReleaseArtifact artifact = check.Manifest.RequireClient(_rid);
Guid transactionId = Guid.NewGuid();
string staging = _versions.CreateClientStagingDirectory(transactionId);
string archive = Path.Combine(
_versions.AppDirectory,
$".client-download-{transactionId:N}.zip");
try
{
Report(
progress,
LauncherUpdatePhase.DownloadingClient,
$"Downloading client {check.Manifest.Version}...",
0,
artifact.Size);
var downloadProgress = new ForwardProgress<ArtifactDownloadProgress>(value =>
Report(
progress,
LauncherUpdatePhase.DownloadingClient,
$"Downloading client {check.Manifest.Version}: "
+ $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes",
value.BytesReceived,
value.TotalBytes));
_ = await _downloader.DownloadAsync(
artifact,
archive,
downloadProgress,
cancellationToken)
.ConfigureAwait(false);
Report(
progress,
LauncherUpdatePhase.ExtractingClient,
"Verifying paths and extracting the client archive...");
IReadOnlyList<ExtractedFileRecord> files = await _extractor.ExtractAsync(
archive,
staging,
cancellationToken)
.ConfigureAwait(false);
Report(
progress,
LauncherUpdatePhase.ActivatingClient,
$"Atomically activating client {check.Manifest.Version}...");
ClientVersionResolution result = await _versions
.PromoteAndActivateUnderLeaseAsync(
staging,
check.Manifest.Version,
_rid,
artifact,
files,
cancellationToken)
.ConfigureAwait(false);
Report(
progress,
LauncherUpdatePhase.Completed,
$"Client {check.Manifest.Version} installed and activated.",
1,
1);
return result;
}
catch (OperationCanceledException)
{
Report(
progress,
LauncherUpdatePhase.Cancelled,
"Client update cancelled; the active version was not changed.");
throw;
}
catch (Exception ex)
{
Report(
progress,
LauncherUpdatePhase.Failed,
$"Client update failed: {ex.Message}");
throw;
}
finally
{
VerifiedArtifactDownloader.TryDelete(archive);
SafeZipExtractor.TryDeleteDirectory(staging);
}
}
finally
{
_operationGate.Release();
}
}
public async Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(check);
ValidateCheck(check);
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
RefuseRunningSessions();
using UpdateSessionBarrier.ExclusiveLease lease =
_versions.Barrier.AcquireExclusive();
RefuseRunningSessions();
if (check.Manifest.Version <= _launcherVersion)
{
throw new LauncherUpdateException(
$"Launcher {_launcherVersion} is already current.");
}
Report(
progress,
LauncherUpdatePhase.DownloadingLauncher,
$"Downloading launcher {check.Manifest.Version}...");
var downloadProgress = new ForwardProgress<ArtifactDownloadProgress>(value =>
Report(
progress,
LauncherUpdatePhase.DownloadingLauncher,
$"Downloading launcher {check.Manifest.Version}: "
+ $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes",
value.BytesReceived,
value.TotalBytes));
try
{
SelfUpdateStageResult result = await _selfUpdates.StageAsync(
check.Manifest,
_rid,
_launcherTargetDirectory,
downloadProgress,
cancellationToken)
.ConfigureAwait(false);
Report(
progress,
LauncherUpdatePhase.StagingLauncher,
result.Status,
1,
1);
return result;
}
catch (OperationCanceledException)
{
Report(
progress,
LauncherUpdatePhase.Cancelled,
"Launcher update staging cancelled.");
throw;
}
catch (Exception ex)
{
Report(
progress,
LauncherUpdatePhase.Failed,
$"Launcher update staging failed: {ex.Message}");
throw;
}
}
finally
{
_operationGate.Release();
}
}
public async Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
RefuseRunningSessions();
Report(
progress,
LauncherUpdatePhase.RollingBack,
"Verifying and activating the previous client version...");
ClientVersionResolution result = await _versions.RollbackAsync(
_rid,
cancellationToken)
.ConfigureAwait(false);
Report(
progress,
LauncherUpdatePhase.Completed,
$"Rolled back to client {result.Version}.",
1,
1);
return result;
}
finally
{
_operationGate.Release();
}
}
private void ValidateCheck(LauncherUpdateCheckResult check)
{
if (!string.Equals(check.Rid, _rid, StringComparison.Ordinal)
|| !check.LauncherVersion.Equals(_launcherVersion))
{
throw new LauncherUpdateException(
"The update check belongs to a different launcher runtime.");
}
_ = check.Manifest.RequireClient(_rid);
_ = check.Manifest.RequireLauncher(_rid);
}
private void RefuseRunningSessions()
{
if (_hasRunningSessions())
{
throw new LauncherUpdateException(
"Stop every launcher session before installing or rolling back an update.");
}
}
private static string BuildCheckStatus(
ReleaseManifest manifest,
LauncherVersion? installed,
bool clientAvailable,
bool launcherAvailable,
bool minimumSatisfied)
{
if (!minimumSatisfied)
{
return $"Release {manifest.Version} requires launcher "
+ $"{manifest.MinimumLauncherVersion} or newer.";
}
if (clientAvailable && launcherAvailable)
{
return $"Client and launcher {manifest.Version} are available.";
}
if (clientAvailable)
{
return installed is null
? $"Client {manifest.Version} is available for installation."
: $"Client update {installed} -> {manifest.Version} is available.";
}
if (launcherAvailable)
{
return $"Launcher {manifest.Version} is available.";
}
return "Client and launcher are up to date.";
}
private static void Report(
IProgress<LauncherUpdateProgress>? progress,
LauncherUpdatePhase phase,
string status,
long completed = 0,
long total = 0) =>
progress?.Report(new LauncherUpdateProgress(
phase,
status,
completed,
total));
private sealed class ForwardProgress<T>(Action<T> callback) : IProgress<T>
{
public void Report(T value) => callback(value);
}
}

View file

@ -0,0 +1,199 @@
using System.Diagnostics.CodeAnalysis;
namespace AcDream.Launcher.Core.Updates;
/// <summary>
/// Strict SemVer 2.0 value used by the release feed, client pointer, and
/// self-update plan. Numeric identifiers are compared as digit strings so a
/// maliciously large identifier cannot overflow a fixed-width integer.
/// </summary>
public sealed class LauncherVersion : IComparable<LauncherVersion>, IEquatable<LauncherVersion>
{
private readonly string[] _core;
private readonly string[] _preRelease;
private LauncherVersion(
string value,
string[] core,
string[] preRelease)
{
Value = value;
_core = core;
_preRelease = preRelease;
}
public string Value { get; }
public bool IsPreRelease => _preRelease.Length != 0;
public static LauncherVersion Parse(string value)
{
if (!TryParse(value, out LauncherVersion? version))
{
throw new FormatException($"'{value}' is not a strict SemVer 2.0 version.");
}
return version;
}
public static bool TryParse(
string? value,
[NotNullWhen(true)] out LauncherVersion? version)
{
version = null;
if (string.IsNullOrEmpty(value)
|| value.Length > 128
|| !string.Equals(value, value.Trim(), StringComparison.Ordinal))
{
return false;
}
string precedence = value;
int plus = value.IndexOf('+', StringComparison.Ordinal);
if (plus >= 0)
{
if (plus == value.Length - 1
|| value.IndexOf('+', plus + 1) >= 0
|| !ValidIdentifiers(value[(plus + 1)..], numericLeadingZeroRule: false))
{
return false;
}
precedence = value[..plus];
}
string coreText = precedence;
string[] preRelease = [];
int dash = precedence.IndexOf('-', StringComparison.Ordinal);
if (dash >= 0)
{
if (dash == precedence.Length - 1
|| !ValidIdentifiers(precedence[(dash + 1)..], numericLeadingZeroRule: true))
{
return false;
}
coreText = precedence[..dash];
preRelease = precedence[(dash + 1)..].Split('.');
}
string[] core = coreText.Split('.');
if (core.Length != 3 || core.Any(part => !ValidCoreNumber(part)))
{
return false;
}
version = new LauncherVersion(value, core, preRelease);
return true;
}
public int CompareTo(LauncherVersion? other)
{
if (other is null)
{
return 1;
}
for (int index = 0; index < _core.Length; index++)
{
int comparison = CompareNumeric(_core[index], other._core[index]);
if (comparison != 0)
{
return comparison;
}
}
if (_preRelease.Length == 0 || other._preRelease.Length == 0)
{
return _preRelease.Length == other._preRelease.Length
? 0
: _preRelease.Length == 0 ? 1 : -1;
}
int shared = Math.Min(_preRelease.Length, other._preRelease.Length);
for (int index = 0; index < shared; index++)
{
string left = _preRelease[index];
string right = other._preRelease[index];
bool leftNumeric = IsDigits(left);
bool rightNumeric = IsDigits(right);
int comparison = leftNumeric && rightNumeric
? CompareNumeric(left, right)
: leftNumeric != rightNumeric
? leftNumeric ? -1 : 1
: string.Compare(left, right, StringComparison.Ordinal);
if (comparison != 0)
{
return comparison;
}
}
return _preRelease.Length.CompareTo(other._preRelease.Length);
}
public bool Equals(LauncherVersion? other) =>
other is not null && CompareTo(other) == 0;
public override bool Equals(object? obj) => Equals(obj as LauncherVersion);
public override int GetHashCode()
{
var hash = new HashCode();
foreach (string part in _core)
{
hash.Add(part, StringComparer.Ordinal);
}
hash.Add(_preRelease.Length);
foreach (string part in _preRelease)
{
hash.Add(part, StringComparer.Ordinal);
}
return hash.ToHashCode();
}
public override string ToString() => Value;
public static bool operator >(LauncherVersion left, LauncherVersion right) =>
left.CompareTo(right) > 0;
public static bool operator <(LauncherVersion left, LauncherVersion right) =>
left.CompareTo(right) < 0;
public static bool operator >=(LauncherVersion left, LauncherVersion right) =>
left.CompareTo(right) >= 0;
public static bool operator <=(LauncherVersion left, LauncherVersion right) =>
left.CompareTo(right) <= 0;
private static bool ValidCoreNumber(string value) =>
IsDigits(value) && (value.Length == 1 || value[0] != '0');
private static bool ValidIdentifiers(string value, bool numericLeadingZeroRule)
{
string[] identifiers = value.Split('.');
return identifiers.All(identifier =>
identifier.Length > 0
&& identifier.All(character =>
character is >= '0' and <= '9'
or >= 'A' and <= 'Z'
or >= 'a' and <= 'z'
or '-')
&& (!numericLeadingZeroRule
|| !IsDigits(identifier)
|| identifier.Length == 1
|| identifier[0] != '0'));
}
private static bool IsDigits(string value) =>
value.Length > 0 && value.All(character => character is >= '0' and <= '9');
private static int CompareNumeric(string left, string right)
{
int length = left.Length.CompareTo(right.Length);
return length != 0
? length
: string.Compare(left, right, StringComparison.Ordinal);
}
}

View file

@ -0,0 +1,37 @@
namespace AcDream.Launcher.Core.Updates;
public sealed record ReleaseArtifact(Uri Url, string Sha256, long Size);
public sealed record ReleaseManifest(
LauncherVersion Version,
LauncherVersion MinimumLauncherVersion,
IReadOnlyDictionary<string, ReleaseArtifact> Clients,
IReadOnlyDictionary<string, ReleaseArtifact> Launchers)
{
public const int CurrentSchemaVersion = 1;
public ReleaseArtifact RequireClient(string rid) =>
Clients.TryGetValue(rid, out ReleaseArtifact? artifact)
? artifact
: throw new LauncherUpdateException(
$"Release {Version} has no client payload for RID '{rid}'.");
public ReleaseArtifact RequireLauncher(string rid) =>
Launchers.TryGetValue(rid, out ReleaseArtifact? artifact)
? artifact
: throw new LauncherUpdateException(
$"Release {Version} has no launcher payload for RID '{rid}'.");
}
public sealed class LauncherUpdateException : Exception
{
public LauncherUpdateException(string message)
: base(message)
{
}
public LauncherUpdateException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,300 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AcDream.Launcher.Core.Updates;
public interface IReleaseManifestClient
{
Task<ReleaseManifest> FetchAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Strict, bounded reader for the pinned GitHub Releases manifest. HTTP is
/// accepted only for a loopback fixture; production and artifact URLs are
/// HTTPS-only.
/// </summary>
public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
{
public const string GitHubOwner = "eriknihlen";
public const string GitHubRepository = "acdream";
public const int MaximumManifestBytes = 1024 * 1024;
public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024;
public static Uri ProductionManifestUri { get; } = new(
$"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json");
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = false,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
MaxDepth = 16,
};
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly Uri _manifestUri;
public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null)
{
_httpClient = httpClient ?? new HttpClient();
_ownsHttpClient = httpClient is null;
_manifestUri = manifestUri ?? ProductionManifestUri;
RequireSecureOrLoopback(_manifestUri, "manifest");
if (_ownsHttpClient)
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
}
}
public async Task<ReleaseManifest> FetchAsync(
CancellationToken cancellationToken = default)
{
try
{
using HttpResponseMessage response = await _httpClient.GetAsync(
_manifestUri,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
Uri finalUri = response.RequestMessage?.RequestUri ?? _manifestUri;
RequireSecureOrLoopback(finalUri, "manifest redirect");
if (response.Content.Headers.ContentLength is long contentLength
&& contentLength > MaximumManifestBytes)
{
throw new LauncherUpdateException(
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
}
await using Stream input = await response.Content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
using var output = new MemoryStream();
byte[] buffer = new byte[16 * 1024];
while (true)
{
int read = await input.ReadAsync(buffer, cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
if (output.Length + read > MaximumManifestBytes)
{
throw new LauncherUpdateException(
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
}
output.Write(buffer, 0, read);
}
return Parse(output.ToArray());
}
catch (OperationCanceledException)
{
throw;
}
catch (LauncherUpdateException)
{
throw;
}
catch (Exception ex) when (ex is HttpRequestException
or IOException
or JsonException
or NotSupportedException)
{
throw new LauncherUpdateException(
$"The release manifest could not be loaded: {ex.Message}",
ex);
}
}
internal static ReleaseManifest Parse(ReadOnlySpan<byte> utf8)
{
try
{
using JsonDocument document = JsonDocument.Parse(
utf8.ToArray(),
new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 16,
});
RejectDuplicateProperties(document.RootElement, "$" );
ManifestDocument? value = document.RootElement.Deserialize<ManifestDocument>(
SerializerOptions);
return Validate(value);
}
catch (LauncherUpdateException)
{
throw;
}
catch (Exception ex) when (ex is JsonException
or FormatException
or InvalidOperationException)
{
throw new LauncherUpdateException(
$"The release manifest is invalid: {ex.Message}",
ex);
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
internal static void RequireSecureOrLoopback(Uri uri, string description)
{
if (!uri.IsAbsoluteUri
|| (uri.Scheme != Uri.UriSchemeHttps
&& !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
{
throw new LauncherUpdateException(
$"The {description} URI must use HTTPS (loopback HTTP is test-only).");
}
}
private static ReleaseManifest Validate(ManifestDocument? document)
{
if (document is null)
{
throw new LauncherUpdateException("The release manifest is empty.");
}
if (document.SchemaVersion != ReleaseManifest.CurrentSchemaVersion)
{
throw new LauncherUpdateException(
$"Release manifest schema version {document.SchemaVersion} is not supported.");
}
LauncherVersion version = LauncherVersion.Parse(
document.Version
?? throw new LauncherUpdateException("The release version is missing."));
LauncherVersion minimum = LauncherVersion.Parse(
document.MinimumLauncherVersion
?? throw new LauncherUpdateException(
"The minimum launcher version is missing."));
if (minimum > version)
{
throw new LauncherUpdateException(
"The minimum launcher version cannot exceed the release version.");
}
IReadOnlyDictionary<string, ReleaseArtifact> clients = ValidateArtifacts(
document.Clients,
"clients");
IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts(
document.Launchers,
"launchers");
return new ReleaseManifest(version, minimum, clients, launchers);
}
private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts(
Dictionary<string, ArtifactDocument>? artifacts,
string field)
{
if (artifacts is null || artifacts.Count == 0)
{
throw new LauncherUpdateException($"Manifest field '{field}' must not be empty.");
}
var result = new Dictionary<string, ReleaseArtifact>(StringComparer.Ordinal);
foreach ((string rid, ArtifactDocument value) in artifacts)
{
if (!LauncherRuntimeIdentity.IsValidRid(rid))
{
throw new LauncherUpdateException(
$"Manifest field '{field}' contains invalid RID '{rid}'.");
}
if (value is null)
{
throw new LauncherUpdateException(
$"Manifest payload '{field}.{rid}' is null.");
}
if (!Uri.TryCreate(value.Url, UriKind.Absolute, out Uri? uri))
{
throw new LauncherUpdateException(
$"Manifest payload '{field}.{rid}' has an invalid URL.");
}
RequireSecureOrLoopback(uri, $"{field}.{rid} artifact");
if (!IsSha256(value.Sha256))
{
throw new LauncherUpdateException(
$"Manifest payload '{field}.{rid}' has an invalid SHA-256 digest.");
}
if (value.Size <= 0 || value.Size > MaximumArtifactBytes)
{
throw new LauncherUpdateException(
$"Manifest payload '{field}.{rid}' has an invalid size.");
}
result.Add(
rid,
new ReleaseArtifact(uri, value.Sha256!.ToLowerInvariant(), value.Size));
}
return result;
}
internal static bool IsSha256(string? value) =>
value is { Length: 64 } && value.All(Uri.IsHexDigit);
private static void RejectDuplicateProperties(JsonElement element, string path)
{
if (element.ValueKind == JsonValueKind.Object)
{
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (JsonProperty property in element.EnumerateObject())
{
if (!names.Add(property.Name))
{
throw new LauncherUpdateException(
$"Duplicate JSON property '{path}.{property.Name}' is not allowed.");
}
RejectDuplicateProperties(property.Value, $"{path}.{property.Name}");
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
int index = 0;
foreach (JsonElement item in element.EnumerateArray())
{
RejectDuplicateProperties(item, $"{path}[{index++}]");
}
}
}
private sealed class ManifestDocument
{
public int SchemaVersion { get; init; }
public string? Version { get; init; }
public string? MinimumLauncherVersion { get; init; }
public Dictionary<string, ArtifactDocument>? Clients { get; init; }
public Dictionary<string, ArtifactDocument>? Launchers { get; init; }
}
private sealed class ArtifactDocument
{
public string? Url { get; init; }
public string? Sha256 { get; init; }
public long Size { get; init; }
}
}

View file

@ -0,0 +1,482 @@
using System.Buffers;
using System.IO.Compression;
using System.Security.Cryptography;
namespace AcDream.Launcher.Core.Updates;
public sealed record SafeZipExtractionLimits(
int MaximumEntries = 20_000,
long MaximumEntryBytes = 2L * 1024 * 1024 * 1024,
long MaximumTotalBytes = 8L * 1024 * 1024 * 1024,
double MaximumCompressionRatio = 200,
int MaximumRelativePathLength = 512);
public sealed record ExtractedFileRecord(
string Path,
string Sha256,
long Size,
int UnixMode);
/// <summary>
/// Portable ZIP extractor for release assets. The complete central-directory
/// shape is validated before the first output path is created.
/// </summary>
public sealed class SafeZipExtractor
{
private const int BufferSize = 128 * 1024;
private const int UnixTypeMask = 0xF000;
private const int UnixRegularFile = 0x8000;
private const int UnixDirectory = 0x4000;
private const int UnixPermissionMask = 0x1FF;
private readonly SafeZipExtractionLimits _limits;
public SafeZipExtractor(SafeZipExtractionLimits? limits = null)
{
_limits = limits ?? new SafeZipExtractionLimits();
if (_limits.MaximumEntries <= 0
|| _limits.MaximumEntryBytes <= 0
|| _limits.MaximumTotalBytes <= 0
|| _limits.MaximumCompressionRatio <= 0
|| _limits.MaximumRelativePathLength <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(limits),
"ZIP extraction limits must all be positive.");
}
}
public async Task<IReadOnlyList<ExtractedFileRecord>> ExtractAsync(
string archivePath,
string destinationDirectory,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(archivePath);
ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory);
string archive = Path.GetFullPath(archivePath);
string destination = Path.GetFullPath(destinationDirectory);
if (Directory.Exists(destination)
&& Directory.EnumerateFileSystemEntries(destination).Any())
{
throw new LauncherUpdateException(
"The ZIP extraction destination must be empty.");
}
try
{
await using var stream = new FileStream(
archive,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
IReadOnlyList<ValidatedEntry> entries = ValidateArchive(zip);
Directory.CreateDirectory(destination);
RejectReparsePoint(destination, "extraction root");
foreach (string directory in entries
.SelectMany(entry => ParentPaths(entry.RelativePath))
.Concat(entries.Where(entry => entry.IsDirectory)
.Select(entry => entry.RelativePath))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(path => path.Count(character => character == '/'))
.ThenBy(path => path, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
string directoryPath = ResolveContained(destination, directory);
Directory.CreateDirectory(directoryPath);
RejectReparsePoint(directoryPath, $"directory '{directory}'");
}
var files = new List<ExtractedFileRecord>();
long actualTotal = 0;
foreach (ValidatedEntry entry in entries.Where(entry => !entry.IsDirectory))
{
cancellationToken.ThrowIfCancellationRequested();
string outputPath = ResolveContained(destination, entry.RelativePath);
EnsureParentsAreDirectories(destination, entry.RelativePath);
await using Stream input = entry.Entry.Open();
await using var output = new FileStream(
outputPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
BufferSize,
FileOptions.Asynchronous
| FileOptions.SequentialScan
| FileOptions.WriteThrough);
using IncrementalHash hash = IncrementalHash.CreateHash(
HashAlgorithmName.SHA256);
byte[] buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
long actualEntry = 0;
try
{
while (true)
{
int read = await input.ReadAsync(
buffer.AsMemory(0, BufferSize),
cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
actualEntry = checked(actualEntry + read);
actualTotal = checked(actualTotal + read);
if (actualEntry > entry.Entry.Length
|| actualEntry > _limits.MaximumEntryBytes
|| actualTotal > _limits.MaximumTotalBytes)
{
throw new LauncherUpdateException(
$"ZIP entry '{entry.RelativePath}' exceeded its declared limits.");
}
hash.AppendData(buffer, 0, read);
await output.WriteAsync(
buffer.AsMemory(0, read),
cancellationToken)
.ConfigureAwait(false);
}
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
output.Flush(flushToDisk: true);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}
if (actualEntry != entry.Entry.Length)
{
throw new LauncherUpdateException(
$"ZIP entry '{entry.RelativePath}' length changed while extracting.");
}
int unixMode = entry.UnixMode & UnixPermissionMask;
if (OperatingSystem.IsLinux() && unixMode != 0)
{
File.SetUnixFileMode(outputPath, (UnixFileMode)unixMode);
}
files.Add(new ExtractedFileRecord(
entry.RelativePath,
Convert.ToHexStringLower(hash.GetHashAndReset()),
actualEntry,
unixMode));
}
files.Sort((left, right) => string.Compare(
left.Path,
right.Path,
StringComparison.Ordinal));
return files;
}
catch (OperationCanceledException)
{
TryDeleteDirectory(destination);
throw;
}
catch (LauncherUpdateException)
{
TryDeleteDirectory(destination);
throw;
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or InvalidDataException
or NotSupportedException
or CryptographicException)
{
TryDeleteDirectory(destination);
throw new LauncherUpdateException(
$"The release ZIP could not be extracted safely: {ex.Message}",
ex);
}
}
private IReadOnlyList<ValidatedEntry> ValidateArchive(ZipArchive zip)
{
if (zip.Entries.Count == 0 || zip.Entries.Count > _limits.MaximumEntries)
{
throw new LauncherUpdateException(
$"ZIP entry count {zip.Entries.Count} is outside the allowed range.");
}
var result = new List<ValidatedEntry>(zip.Entries.Count);
var explicitEntries = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var nodes = new Dictionary<string, PathNode>(StringComparer.OrdinalIgnoreCase);
long totalLength = 0;
long totalCompressed = 0;
foreach (ZipArchiveEntry entry in zip.Entries)
{
string relative = NormalizeEntryPath(entry.FullName);
if (!explicitEntries.Add(relative))
{
throw new LauncherUpdateException(
$"ZIP contains a duplicate/case-colliding entry '{relative}'.");
}
int unixAttributes = entry.ExternalAttributes >> 16;
int unixType = unixAttributes & UnixTypeMask;
bool trailingDirectory = entry.FullName.EndsWith("/", StringComparison.Ordinal)
|| entry.FullName.EndsWith("\\", StringComparison.Ordinal);
bool isDirectory = trailingDirectory || unixType == UnixDirectory;
if ((entry.ExternalAttributes & (int)FileAttributes.ReparsePoint) != 0
|| unixType is not (0 or UnixRegularFile or UnixDirectory)
|| (unixType == UnixDirectory && !trailingDirectory)
|| (isDirectory && (entry.Length != 0 || entry.CompressedLength != 0)))
{
throw new LauncherUpdateException(
$"ZIP entry '{relative}' is a symlink, reparse point, or unsupported type.");
}
AddPathNodes(nodes, relative, isDirectory);
if (!isDirectory)
{
if (entry.Length < 0
|| entry.CompressedLength < 0
|| entry.Length > _limits.MaximumEntryBytes)
{
throw new LauncherUpdateException(
$"ZIP entry '{relative}' exceeds the per-file limit.");
}
totalLength = checked(totalLength + entry.Length);
totalCompressed = checked(totalCompressed + entry.CompressedLength);
if (totalLength > _limits.MaximumTotalBytes
|| IsRatioExceeded(entry.Length, entry.CompressedLength))
{
throw new LauncherUpdateException(
$"ZIP entry '{relative}' exceeds extraction size/ratio limits.");
}
}
result.Add(new ValidatedEntry(entry, relative, isDirectory, unixAttributes));
}
if (totalLength > 0
&& (totalCompressed == 0 || IsRatioExceeded(totalLength, totalCompressed)))
{
throw new LauncherUpdateException(
"ZIP aggregate compression ratio exceeds the allowed limit.");
}
return result;
}
private string NormalizeEntryPath(string name)
{
if (string.IsNullOrEmpty(name)
|| name.IndexOf('\0') >= 0
|| name.Contains(':', StringComparison.Ordinal))
{
throw new LauncherUpdateException("ZIP contains an empty, NUL, or ADS path.");
}
string normalized = name.Replace('\\', '/');
bool directory = normalized.EndsWith("/", StringComparison.Ordinal);
normalized = normalized.TrimEnd('/');
if (normalized.Length == 0
|| normalized.Length > _limits.MaximumRelativePathLength
|| normalized.StartsWith("/", StringComparison.Ordinal)
|| Path.IsPathRooted(normalized))
{
throw new LauncherUpdateException($"ZIP path '{name}' is rooted or too long.");
}
string[] segments = normalized.Split('/');
foreach (string segment in segments)
{
if (segment.Length == 0
|| segment is "." or ".."
|| segment.EndsWith(' ')
|| segment.EndsWith('.')
|| segment.Any(character =>
char.IsControl(character)
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|| IsWindowsDeviceName(segment))
{
throw new LauncherUpdateException(
$"ZIP path '{name}' contains an unsafe segment.");
}
}
return string.Join('/', segments) + (directory ? "/" : string.Empty);
}
private static void AddPathNodes(
Dictionary<string, PathNode> nodes,
string relative,
bool isDirectory)
{
string path = relative.TrimEnd('/');
string[] segments = path.Split('/');
string current = string.Empty;
for (int index = 0; index < segments.Length; index++)
{
current = current.Length == 0
? segments[index]
: current + "/" + segments[index];
bool nodeIsDirectory = index < segments.Length - 1 || isDirectory;
if (nodes.TryGetValue(current, out PathNode? existing))
{
if (!string.Equals(existing.Spelling, current, StringComparison.Ordinal)
|| (!existing.IsDirectory || !nodeIsDirectory))
{
throw new LauncherUpdateException(
$"ZIP path '{relative}' collides with '{existing.Spelling}'.");
}
continue;
}
nodes.Add(current, new PathNode(current, nodeIsDirectory));
}
}
private bool IsRatioExceeded(long expanded, long compressed) =>
expanded > 0
&& (compressed <= 0 || expanded / (double)compressed > _limits.MaximumCompressionRatio);
private static IEnumerable<string> ParentPaths(string relative)
{
string path = relative.TrimEnd('/');
int slash = path.IndexOf('/');
while (slash >= 0)
{
yield return path[..slash];
slash = path.IndexOf('/', slash + 1);
}
}
private static string ResolveContained(string root, string relative)
{
string path = Path.GetFullPath(
Path.Combine(root, relative.TrimEnd('/').Replace('/', Path.DirectorySeparatorChar)));
string prefix = Path.EndsInDirectorySeparator(root)
? root
: root + Path.DirectorySeparatorChar;
if (!path.StartsWith(
prefix,
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal))
{
throw new LauncherUpdateException(
$"ZIP path '{relative}' escaped the extraction directory.");
}
return path;
}
private static void EnsureParentsAreDirectories(string root, string relative)
{
foreach (string parent in ParentPaths(relative))
{
string path = ResolveContained(root, parent);
if (!Directory.Exists(path))
{
throw new LauncherUpdateException(
$"ZIP parent '{parent}' is not a directory.");
}
RejectReparsePoint(path, $"directory '{parent}'");
}
}
private static void RejectReparsePoint(string path, string description)
{
if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
{
throw new LauncherUpdateException(
$"The {description} is a reparse point.");
}
}
private static bool IsWindowsDeviceName(string segment)
{
string stem = segment.Split('.')[0];
return stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|| (stem.Length == 4
&& (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|| stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
&& stem[3] is >= '1' and <= '9');
}
internal static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
DeleteDirectoryWithoutFollowingReparsePoints(path);
}
}
catch
{
// The exact random staging name is reclaimed under the update lease.
}
}
private static void DeleteDirectoryWithoutFollowingReparsePoints(string directory)
{
FileAttributes rootAttributes = File.GetAttributes(directory);
if ((rootAttributes & FileAttributes.ReparsePoint) != 0)
{
DeleteReparsePoint(directory);
return;
}
foreach (string entry in Directory.EnumerateFileSystemEntries(
directory,
"*",
SearchOption.TopDirectoryOnly))
{
FileAttributes attributes = File.GetAttributes(entry);
if ((attributes & FileAttributes.ReparsePoint) != 0)
{
DeleteReparsePoint(entry);
}
else if ((attributes & FileAttributes.Directory) != 0)
{
DeleteDirectoryWithoutFollowingReparsePoints(entry);
}
else
{
File.Delete(entry);
}
}
Directory.Delete(directory, recursive: false);
}
private static void DeleteReparsePoint(string path)
{
try
{
File.Delete(path);
}
catch (UnauthorizedAccessException)
{
Directory.Delete(path, recursive: false);
}
catch (IOException)
{
Directory.Delete(path, recursive: false);
}
}
private sealed record PathNode(string Spelling, bool IsDirectory);
private sealed record ValidatedEntry(
ZipArchiveEntry Entry,
string RelativePath,
bool IsDirectory,
int UnixMode);
}

View file

@ -0,0 +1,85 @@
namespace AcDream.Launcher.Core.Updates;
/// <summary>
/// One portable OS-handle barrier shared by supervised sessions and held
/// exclusively by update/rollback/recovery transactions. File contents are
/// never authoritative.
/// </summary>
public sealed class UpdateSessionBarrier
{
public const string LockFileName = ".update-session.lock";
private readonly string _lockPath;
public UpdateSessionBarrier(string dataDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
_lockPath = Path.Combine(
Path.GetFullPath(dataDirectory),
"app",
LockFileName);
}
public string LockPath => _lockPath;
public SessionLease AcquireSession()
{
FileStream stream = Open(FileShare.ReadWrite, "A client update is in progress.");
return new SessionLease(stream);
}
public ExclusiveLease AcquireExclusive()
{
FileStream stream = Open(
FileShare.None,
"A launcher session or another update transaction is running. "
+ "Stop every launcher session before updating.");
return new ExclusiveLease(stream);
}
private FileStream Open(FileShare share, string refusal)
{
Directory.CreateDirectory(
Path.GetDirectoryName(_lockPath)
?? throw new InvalidOperationException(
"The update/session lock path has no parent directory."));
try
{
return new FileStream(
_lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
share,
bufferSize: 1,
FileOptions.None);
}
catch (IOException ex)
{
throw new LauncherUpdateException(refusal, ex);
}
catch (UnauthorizedAccessException ex)
{
throw new LauncherUpdateException(
$"The update/session lease could not be opened: {ex.Message}",
ex);
}
}
public sealed class SessionLease : IDisposable
{
private FileStream? _stream;
internal SessionLease(FileStream stream) => _stream = stream;
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
}
public sealed class ExclusiveLease : IDisposable
{
private FileStream? _stream;
internal ExclusiveLease(FileStream stream) => _stream = stream;
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
}
}

View file

@ -0,0 +1,200 @@
using System.Buffers;
using System.Security.Cryptography;
namespace AcDream.Launcher.Core.Updates;
public sealed record ArtifactDownloadProgress(long BytesReceived, long TotalBytes)
{
public double Percent => TotalBytes <= 0
? 0
: Math.Clamp(BytesReceived * 100d / TotalBytes, 0, 100);
}
public sealed record VerifiedArtifactDownload(
string FilePath,
long Size,
string Sha256);
/// <summary>
/// Streams a bounded release asset directly to a caller-owned staging path,
/// computing SHA-256 during the write. A partial/cancelled/wrong artifact is
/// deleted before the call returns.
/// </summary>
public sealed class VerifiedArtifactDownloader
{
private const int BufferSize = 128 * 1024;
private readonly HttpClient _httpClient;
public VerifiedArtifactDownloader(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public async Task<VerifiedArtifactDownload> DownloadAsync(
ReleaseArtifact artifact,
string destinationPath,
IProgress<ArtifactDownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(artifact);
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
ReleaseManifestClient.RequireSecureOrLoopback(artifact.Url, "artifact");
if (artifact.Size <= 0
|| artifact.Size > ReleaseManifestClient.MaximumArtifactBytes
|| !ReleaseManifestClient.IsSha256(artifact.Sha256))
{
throw new LauncherUpdateException("The requested artifact metadata is invalid.");
}
string fullPath = Path.GetFullPath(destinationPath);
Directory.CreateDirectory(
Path.GetDirectoryName(fullPath)
?? throw new InvalidOperationException(
"The artifact staging path has no parent directory."));
bool ownsDestination = false;
try
{
using HttpResponseMessage response = await _httpClient.GetAsync(
artifact.Url,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
ReleaseManifestClient.RequireSecureOrLoopback(
response.RequestMessage?.RequestUri ?? artifact.Url,
"artifact redirect");
if (response.Content.Headers.ContentLength is long contentLength
&& contentLength != artifact.Size)
{
throw new LauncherUpdateException(
$"Artifact size header mismatch: expected {artifact.Size}, "
+ $"received {contentLength}.");
}
if (response.Content.Headers.ContentEncoding.Count != 0)
{
throw new LauncherUpdateException(
"Release artifact content encoding is not allowed.");
}
await using Stream input = await response.Content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
await using var output = new FileStream(
fullPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
BufferSize,
FileOptions.Asynchronous
| FileOptions.SequentialScan
| FileOptions.WriteThrough);
ownsDestination = true;
using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
byte[] buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
long received = 0;
try
{
progress?.Report(new ArtifactDownloadProgress(0, artifact.Size));
while (true)
{
int read = await input.ReadAsync(
buffer.AsMemory(0, BufferSize),
cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
received = checked(received + read);
if (received > artifact.Size)
{
throw new LauncherUpdateException(
$"Artifact exceeded its declared size of {artifact.Size} bytes.");
}
hash.AppendData(buffer, 0, read);
await output.WriteAsync(
buffer.AsMemory(0, read),
cancellationToken)
.ConfigureAwait(false);
progress?.Report(new ArtifactDownloadProgress(received, artifact.Size));
}
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
output.Flush(flushToDisk: true);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}
if (received != artifact.Size)
{
throw new LauncherUpdateException(
$"Artifact ended at {received} bytes; expected {artifact.Size}.");
}
string actualSha256 = Convert.ToHexStringLower(hash.GetHashAndReset());
if (!string.Equals(
actualSha256,
artifact.Sha256,
StringComparison.OrdinalIgnoreCase))
{
throw new LauncherUpdateException(
"Artifact SHA-256 does not match the release manifest.");
}
return new VerifiedArtifactDownload(fullPath, received, actualSha256);
}
catch (OperationCanceledException)
{
if (ownsDestination)
{
TryDelete(fullPath);
}
throw;
}
catch (LauncherUpdateException)
{
if (ownsDestination)
{
TryDelete(fullPath);
}
throw;
}
catch (Exception ex) when (ex is HttpRequestException
or IOException
or UnauthorizedAccessException
or CryptographicException)
{
if (ownsDestination)
{
TryDelete(fullPath);
}
throw new LauncherUpdateException(
$"The release artifact could not be downloaded: {ex.Message}",
ex);
}
}
internal static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// The exact random staging name is reclaimed by startup recovery.
}
}
}

View file

@ -1,7 +1,9 @@
using System.Reflection;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Updates;
using AcDream.Launcher.ViewModels;
using AcDream.Platform;
using Avalonia;
@ -14,6 +16,8 @@ public sealed partial class App : Application
{
private LauncherOrchestrator? _orchestrator;
private LauncherWindowViewModel? _viewModel;
private HttpClient? _updateHttpClient;
private ReleaseManifestClient? _manifestClient;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@ -23,6 +27,7 @@ public sealed partial class App : Application
{
ApplicationPathSet paths = ApplicationPathSet.Resolve();
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
string rid = LauncherRuntimeIdentity.DetectRid();
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
var installer = new LauncherInstaller(
paths,
@ -47,16 +52,38 @@ public sealed partial class App : Application
$"Client content verification failed: {ex.Message}");
}
var clientVersions = new ClientVersionStore(paths);
_ = clientVersions.LoadAndRecoverAsync(rid)
.GetAwaiter()
.GetResult();
_orchestrator = new LauncherOrchestrator(
profiles,
paths,
LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory),
LauncherExecutableSet.FromCurrentVersionStore(clientVersions),
verification.Record,
installationStatus: verification.Status);
installationStatus: verification.Status,
updateSessionBarrier: clientVersions.Barrier);
_updateHttpClient = new HttpClient();
_updateHttpClient.Timeout = TimeSpan.FromSeconds(15);
_updateHttpClient.DefaultRequestHeaders.UserAgent.ParseAdd(
"acdream-launcher/1");
_manifestClient = new ReleaseManifestClient(_updateHttpClient);
var selfUpdates = new LauncherSelfUpdateManager(paths, _updateHttpClient);
var updater = new LauncherUpdater(
_manifestClient,
_updateHttpClient,
clientVersions,
selfUpdates,
GetLauncherVersion(),
rid,
AppContext.BaseDirectory,
() => _orchestrator.GetSnapshot().Sessions.Any(session => session.IsActive));
_viewModel = new LauncherWindowViewModel(
_orchestrator,
new AvaloniaUiDispatcher(),
installer);
installer,
updater);
_viewModel.Initialize();
desktop.MainWindow = new MainWindow
@ -73,7 +100,25 @@ public sealed partial class App : Application
{
_viewModel?.Dispose();
_orchestrator?.Dispose();
_manifestClient?.Dispose();
_updateHttpClient?.Dispose();
_viewModel = null;
_orchestrator = null;
_manifestClient = null;
_updateHttpClient = null;
}
private static LauncherVersion GetLauncherVersion()
{
string? informationalVersion = typeof(App).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
if (!LauncherVersion.TryParse(informationalVersion, out LauncherVersion? version))
{
throw new InvalidOperationException(
$"Launcher informational version '{informationalVersion}' is not SemVer 2.0.");
}
return version;
}
}

View file

@ -45,7 +45,7 @@
<Button Content="First-run setup"
Command="{Binding FirstRunWizardShell.OpenCommand}" />
<Button Content="Check for updates"
Command="{Binding UpdatePromptShell.OpenCommand}" />
Command="{Binding UpdatePrompt.OpenCommand}" />
</StackPanel>
</Grid>
</Border>
@ -434,21 +434,72 @@
KeyboardNavigation.TabNavigation="Cycle"
KeyDown="OnModalKeyDown"
AutomationProperties.Name="Update prompt modal dialog"
IsVisible="{Binding UpdatePromptShell.IsOpen}">
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
IsVisible="{Binding UpdatePrompt.IsOpen}">
<Border Classes="card" Width="640" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="12">
<TextBlock Text="{Binding UpdatePromptShell.Title}" FontSize="24" FontWeight="Bold" />
<TextBlock Text="{Binding UpdatePromptShell.Body}" TextWrapping="Wrap" />
<TextBlock Text="{Binding UpdatePrompt.Title}" FontSize="24" FontWeight="Bold" />
<TextBlock Text="{Binding UpdatePrompt.Body}" TextWrapping="Wrap" />
<Grid ColumnDefinitions="*,*" ColumnSpacing="16">
<StackPanel>
<TextBlock Text="Installed client" Classes="muted" />
<TextBlock Text="{Binding UpdatePrompt.CurrentClientVersion}" FontWeight="SemiBold" />
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="Available release" Classes="muted" />
<TextBlock Text="{Binding UpdatePrompt.AvailableVersion}" FontWeight="SemiBold" />
</StackPanel>
</Grid>
<TextBlock Text="{Binding UpdatePrompt.MinimumLauncherStatus}"
TextWrapping="Wrap"
Classes="muted" />
<Border Background="#24344B" Padding="10" CornerRadius="5">
<TextBlock Text="{Binding UpdatePromptShell.Status}" TextWrapping="Wrap" />
<StackPanel Spacing="8">
<TextBlock Text="{Binding UpdatePrompt.Status}" TextWrapping="Wrap" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding UpdatePrompt.ProgressPercent}"
IsIndeterminate="{Binding UpdatePrompt.IsProgressIndeterminate}"
IsVisible="{Binding UpdatePrompt.IsBusy}" />
</StackPanel>
</Border>
<Button x:Name="UpdateCloseButton"
Content="Close"
HorizontalAlignment="Right"
IsCancel="True"
IsDefault="True"
AutomationProperties.Name="Close update prompt"
Command="{Binding UpdatePromptShell.CloseCommand}" />
<Border Background="#5B2630"
Padding="10"
CornerRadius="5"
IsVisible="{Binding UpdatePrompt.HasError}">
<TextBlock Text="{Binding UpdatePrompt.Error}" TextWrapping="Wrap" />
</Border>
<Border Background="#365B32"
Padding="10"
CornerRadius="5"
IsVisible="{Binding UpdatePrompt.IsLauncherRestartRequired}">
<TextBlock Text="{Binding UpdatePrompt.LauncherRestartStatus}"
TextWrapping="Wrap"
FontWeight="SemiBold" />
</Border>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Check again"
AutomationProperties.Name="Check for updates now"
Command="{Binding UpdatePrompt.CheckCommand}" />
<Button Content="Rollback client"
AutomationProperties.Name="Rollback client version"
Command="{Binding UpdatePrompt.RollbackCommand}" />
<Button Content="Stage launcher"
AutomationProperties.Name="Stage launcher self-update"
Command="{Binding UpdatePrompt.StageLauncherCommand}" />
<Button Content="Install client"
Classes="primary"
IsDefault="True"
AutomationProperties.Name="Install client update"
Command="{Binding UpdatePrompt.InstallClientCommand}" />
<Button Content="Cancel"
AutomationProperties.Name="Cancel update operation"
Command="{Binding UpdatePrompt.CancelCommand}" />
<Button x:Name="UpdateCloseButton"
Content="Close"
IsCancel="True"
AutomationProperties.Name="Close update prompt"
Command="{Binding UpdatePrompt.CloseCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Border>

View file

@ -120,7 +120,7 @@ public sealed partial class MainWindow : Window
{
FirstRunDatDirectoryTextBox.Focus();
}
else if (viewModel.UpdatePromptShell.IsOpen)
else if (viewModel.UpdatePrompt.IsOpen)
{
UpdateCloseButton.Focus();
}

View file

@ -1,3 +1,5 @@
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
using Avalonia;
namespace AcDream.Launcher;
@ -15,7 +17,34 @@ internal static class Program
return 0;
}
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
try
{
ApplicationPathSet paths = ApplicationPathSet.Resolve();
using var httpClient = new HttpClient();
var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient);
string executable = Environment.ProcessPath
?? throw new InvalidOperationException(
"The launcher executable path is unavailable.");
SelfUpdateStartupResult startup = LauncherSelfUpdateBootstrap.HandleAsync(
args,
selfUpdates,
AppContext.BaseDirectory,
executable)
.GetAwaiter()
.GetResult();
if (startup.ShouldExit)
{
return startup.ExitCode;
}
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(
startup.RemainingArguments);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Launcher startup failed safely: {ex.Message}");
return 74;
}
}
public static AppBuilder BuildAvaloniaApp() =>

View file

@ -0,0 +1,520 @@
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.ViewModels;
public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
{
private readonly ILauncherUpdater _updater;
private readonly IUiDispatcher _dispatcher;
private readonly Action _onClientChanged;
private readonly Func<bool> _canOpen;
private readonly Func<bool> _canMutate;
private CancellationTokenSource? _cancellation;
private LauncherUpdateCheckResult? _check;
private bool _isOpen;
private bool _isBusy;
private bool _disposed;
private string _status = "No update check has run yet.";
private string? _error;
private LauncherUpdatePhase _phase = LauncherUpdatePhase.Idle;
private double _progressPercent;
private bool _isProgressIndeterminate;
private string? _launcherRestartStatus;
public LauncherUpdateViewModel(
ILauncherUpdater updater,
IUiDispatcher dispatcher,
Action onClientChanged,
Func<bool>? canOpen = null,
Func<bool>? canMutate = null)
{
_updater = updater ?? throw new ArgumentNullException(nameof(updater));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_onClientChanged = onClientChanged
?? throw new ArgumentNullException(nameof(onClientChanged));
_canOpen = canOpen ?? (() => true);
_canMutate = canMutate ?? (() => true);
OpenCommand = new AsyncRelayCommand(OpenAndCheckAsync, () => _canOpen() && !IsBusy);
CloseCommand = new RelayCommand(Close, () => !IsBusy);
CheckCommand = new AsyncRelayCommand(
() => CheckAsync(startup: false),
() => IsOpen && !IsBusy);
InstallClientCommand = new AsyncRelayCommand(
InstallClientAsync,
() => IsOpen
&& !IsBusy
&& _canMutate()
&& _check is
{
IsClientUpdateAvailable: true,
IsLauncherMinimumSatisfied: true,
});
StageLauncherCommand = new AsyncRelayCommand(
StageLauncherAsync,
() => IsOpen
&& !IsBusy
&& _canMutate()
&& !IsLauncherRestartRequired
&& _check is { IsLauncherUpdateAvailable: true });
RollbackCommand = new AsyncRelayCommand(
RollbackAsync,
() => IsOpen
&& !IsBusy
&& _canMutate()
&& !string.IsNullOrEmpty(_updater.CurrentClient.PreviousVersion));
CancelCommand = new RelayCommand(
() => _cancellation?.Cancel(),
() => IsBusy && _cancellation is not null);
}
public string Title => "Client and launcher updates";
public string Body =>
"Releases are downloaded from the pinned eriknihlen/acdream GitHub feed. "
+ "Every archive is size/SHA-256 verified and safely extracted before "
+ "the active client pointer can change.";
public bool IsOpen
{
get => _isOpen;
private set
{
if (SetProperty(ref _isOpen, value))
{
NotifyCommandStates();
}
}
}
public bool IsBusy
{
get => _isBusy;
private set
{
if (SetProperty(ref _isBusy, value))
{
OnPropertyChanged(nameof(CanClose));
NotifyCommandStates();
}
}
}
public bool CanClose => !IsBusy;
public string Status
{
get => _status;
private set => SetProperty(ref _status, value);
}
public string? Error
{
get => _error;
private set
{
if (SetProperty(ref _error, value))
{
OnPropertyChanged(nameof(HasError));
}
}
}
public bool HasError => !string.IsNullOrWhiteSpace(Error);
public LauncherUpdatePhase Phase
{
get => _phase;
private set => SetProperty(ref _phase, value);
}
public double ProgressPercent
{
get => _progressPercent;
private set => SetProperty(ref _progressPercent, value);
}
public bool IsProgressIndeterminate
{
get => _isProgressIndeterminate;
private set => SetProperty(ref _isProgressIndeterminate, value);
}
public string CurrentClientVersion =>
_updater.CurrentClient.Version?.Value ?? "not installed";
public string AvailableVersion => _check?.Manifest.Version.Value ?? "not checked";
public string CurrentLauncherVersion =>
_check?.LauncherVersion.Value ?? "loading";
public bool IsClientUpdateAvailable => _check?.IsClientUpdateAvailable == true;
public bool IsLauncherUpdateAvailable => _check?.IsLauncherUpdateAvailable == true;
public bool IsLauncherRestartRequired =>
!string.IsNullOrWhiteSpace(_launcherRestartStatus);
public string LauncherRestartStatus => _launcherRestartStatus ?? string.Empty;
public bool IsLauncherMinimumBlocked => _check is
{
IsClientUpdateAvailable: true,
IsLauncherMinimumSatisfied: false,
};
public string MinimumLauncherStatus => _check is null
? string.Empty
: _check.IsLauncherMinimumSatisfied
? $"Launcher meets minimum {_check.Manifest.MinimumLauncherVersion}."
: $"Install launcher {_check.Manifest.MinimumLauncherVersion} or newer before the client update.";
public AsyncRelayCommand OpenCommand { get; }
public RelayCommand CloseCommand { get; }
public AsyncRelayCommand CheckCommand { get; }
public AsyncRelayCommand InstallClientCommand { get; }
public AsyncRelayCommand StageLauncherCommand { get; }
public AsyncRelayCommand RollbackCommand { get; }
public RelayCommand CancelCommand { get; }
/// <summary>
/// Launch-time polling is deliberately nonfatal: an offline or malformed
/// feed changes only this status and never prevents profile/session use.
/// </summary>
public async Task StartupCheckAsync()
{
try
{
await CheckAsync(startup: true).ConfigureAwait(true);
}
catch
{
// CheckAsync owns visible state and never lets startup polling
// escape into the Avalonia initialization transaction.
}
}
public void Close()
{
if (!IsBusy)
{
IsOpen = false;
}
}
public void NotifyCommandStates()
{
OpenCommand.NotifyCanExecuteChanged();
CloseCommand.NotifyCanExecuteChanged();
CheckCommand.NotifyCanExecuteChanged();
InstallClientCommand.NotifyCanExecuteChanged();
StageLauncherCommand.NotifyCanExecuteChanged();
RollbackCommand.NotifyCanExecuteChanged();
CancelCommand.NotifyCanExecuteChanged();
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_cancellation?.Cancel();
_cancellation?.Dispose();
_cancellation = null;
}
private async Task OpenAndCheckAsync()
{
IsOpen = true;
await CheckAsync(startup: false).ConfigureAwait(true);
}
private async Task CheckAsync(bool startup)
{
if (IsBusy || _disposed)
{
return;
}
using var cancellation = new CancellationTokenSource();
_cancellation = cancellation;
IsBusy = true;
Error = null;
Phase = LauncherUpdatePhase.Checking;
Status = "Checking the pinned GitHub release manifest...";
IsProgressIndeterminate = true;
ProgressPercent = 0;
try
{
_check = await _updater.CheckAsync(cancellation.Token)
.ConfigureAwait(true);
Status = _check.Status;
Phase = LauncherUpdatePhase.Completed;
RefreshVersionProperties();
if (startup
&& (_check.IsClientUpdateAvailable
|| _check.IsLauncherUpdateAvailable))
{
IsOpen = true;
}
}
catch (OperationCanceledException)
{
Status = "Update check cancelled.";
Phase = LauncherUpdatePhase.Cancelled;
}
catch (Exception ex)
{
string detail = SafeDisplayError(ex);
Status = startup
? $"Automatic update check unavailable; continuing offline. {detail}"
: "Update check failed.";
Error = startup ? null : detail;
Phase = LauncherUpdatePhase.Failed;
}
finally
{
IsProgressIndeterminate = false;
if (ReferenceEquals(_cancellation, cancellation))
{
_cancellation = null;
}
IsBusy = false;
}
}
private Task InstallClientAsync() => RunMutationAsync(
(check, progress, token) => _updater.InstallClientAsync(check, progress, token),
"Installing the client update...",
"Client update installed and activated.",
clientChanged: true);
private Task StageLauncherAsync() => RunMutationAsync(
async (check, progress, token) =>
{
SelfUpdateStageResult staged = await _updater
.StageLauncherAsync(check, progress, token)
.ConfigureAwait(true);
_launcherRestartStatus = staged.Status;
OnPropertyChanged(nameof(IsLauncherRestartRequired));
OnPropertyChanged(nameof(LauncherRestartStatus));
return _updater.CurrentClient;
},
"Staging the launcher update...",
"Launcher update staged; restart the launcher to apply it.",
clientChanged: false);
private async Task RollbackAsync()
{
if (IsBusy || _disposed)
{
return;
}
using var cancellation = new CancellationTokenSource();
_cancellation = cancellation;
IsBusy = true;
Error = null;
Status = "Rolling back the client...";
IsProgressIndeterminate = true;
try
{
var progress = new UiProgress<LauncherUpdateProgress>(
_dispatcher,
ApplyProgress);
_ = await _updater.RollbackClientAsync(progress, cancellation.Token)
.ConfigureAwait(true);
_onClientChanged();
RefreshVersionProperties();
}
catch (OperationCanceledException)
{
Status = "Rollback cancelled; the active version was not changed.";
Phase = LauncherUpdatePhase.Cancelled;
}
catch (Exception ex)
{
Error = SafeDisplayError(ex);
Status = "Rollback failed.";
Phase = LauncherUpdatePhase.Failed;
}
finally
{
IsProgressIndeterminate = false;
if (ReferenceEquals(_cancellation, cancellation))
{
_cancellation = null;
}
IsBusy = false;
}
}
private async Task RunMutationAsync(
Func<
LauncherUpdateCheckResult,
IProgress<LauncherUpdateProgress>,
CancellationToken,
Task<ClientVersionResolution>> operation,
string initialStatus,
string completedStatus,
bool clientChanged)
{
if (IsBusy || _disposed || _check is null)
{
return;
}
using var cancellation = new CancellationTokenSource();
_cancellation = cancellation;
IsBusy = true;
Error = null;
Status = initialStatus;
IsProgressIndeterminate = true;
ProgressPercent = 0;
try
{
var progress = new UiProgress<LauncherUpdateProgress>(
_dispatcher,
ApplyProgress);
_ = await operation(_check, progress, cancellation.Token)
.ConfigureAwait(true);
if (clientChanged)
{
_onClientChanged();
}
RefreshVersionProperties();
Phase = LauncherUpdatePhase.Completed;
Status = IsLauncherRestartRequired
? LauncherRestartStatus
: completedStatus;
try
{
_check = await _updater.CheckAsync(CancellationToken.None)
.ConfigureAwait(true);
if (!IsLauncherRestartRequired)
{
Status = _check.Status;
}
RefreshVersionProperties();
}
catch (Exception ex)
{
Status += " Release status refresh is unavailable: "
+ SafeDisplayError(ex);
}
}
catch (OperationCanceledException)
{
Status = "Update operation cancelled; published state was not changed.";
Phase = LauncherUpdatePhase.Cancelled;
}
catch (Exception ex)
{
Error = SafeDisplayError(ex);
Status = "Update operation failed.";
Phase = LauncherUpdatePhase.Failed;
}
finally
{
IsProgressIndeterminate = false;
if (ReferenceEquals(_cancellation, cancellation))
{
_cancellation = null;
}
IsBusy = false;
}
}
private void ApplyProgress(LauncherUpdateProgress value)
{
Phase = value.Phase;
Status = value.Status;
ProgressPercent = value.Percent;
IsProgressIndeterminate = value.Total <= 0
&& value.Phase is not (
LauncherUpdatePhase.Completed
or LauncherUpdatePhase.Cancelled
or LauncherUpdatePhase.Failed);
}
private void RefreshVersionProperties()
{
OnPropertyChanged(nameof(CurrentClientVersion));
OnPropertyChanged(nameof(AvailableVersion));
OnPropertyChanged(nameof(CurrentLauncherVersion));
OnPropertyChanged(nameof(IsClientUpdateAvailable));
OnPropertyChanged(nameof(IsLauncherUpdateAvailable));
OnPropertyChanged(nameof(IsLauncherRestartRequired));
OnPropertyChanged(nameof(LauncherRestartStatus));
OnPropertyChanged(nameof(IsLauncherMinimumBlocked));
OnPropertyChanged(nameof(MinimumLauncherStatus));
NotifyCommandStates();
}
private static string SafeDisplayError(Exception exception) =>
string.IsNullOrWhiteSpace(exception.Message)
? "The update operation failed."
: exception.Message;
private sealed class UiProgress<T>(IUiDispatcher dispatcher, Action<T> callback)
: IProgress<T>
{
public void Report(T value) => dispatcher.Post(() => callback(value));
}
}
internal sealed class UnavailableLauncherUpdater : ILauncherUpdater
{
private static readonly ClientVersionResolution Missing = new(
ClientVersionState.Missing,
"Versioned client updater is unavailable.",
null,
null,
null,
null);
public ClientVersionResolution CurrentClient => Missing;
public Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(Missing);
public Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default) =>
Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException("Versioned client updater is unavailable."));
public Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<ClientVersionResolution>(
new LauncherUpdateException("Versioned client updater is unavailable."));
public Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<SelfUpdateStageResult>(
new LauncherUpdateException("Versioned client updater is unavailable."));
public Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<ClientVersionResolution>(
new LauncherUpdateException("Versioned client updater is unavailable."));
}

View file

@ -4,6 +4,7 @@ using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.ViewModels;
@ -25,7 +26,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public LauncherWindowViewModel(
ILauncherOrchestrator orchestrator,
IUiDispatcher dispatcher,
ILauncherInstaller? installer = null)
ILauncherInstaller? installer = null,
ILauncherUpdater? updater = null)
{
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
@ -38,17 +40,16 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
OnInstallCompleted,
() => CanInteract,
() => !IsBusy && Sessions.All(session => !session.IsActive));
UpdatePromptShell = new LauncherShellViewModel(
"Client update",
"Review a signed release manifest, verify the downloaded archive, "
+ "and atomically switch the installed client version. The updater "
+ "transaction lands in Campaign LA slice LA10.",
"Updater shell ready — implementation arrives in LA10.",
() => CanInteract);
UpdatePrompt = new LauncherUpdateViewModel(
updater ?? new UnavailableLauncherUpdater(),
dispatcher,
OnClientVersionChanged,
() => CanInteract,
() => !IsBusy && Sessions.All(session => !session.IsActive));
EditorDialog.PropertyChanged += OnModalPropertyChanged;
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;
UpdatePromptShell.PropertyChanged += OnModalPropertyChanged;
UpdatePrompt.PropertyChanged += OnModalPropertyChanged;
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => CanInteract);
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
@ -92,7 +93,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public FirstRunInstallerViewModel FirstRunWizardShell { get; }
public LauncherShellViewModel UpdatePromptShell { get; }
public LauncherUpdateViewModel UpdatePrompt { get; }
public LauncherTreeNodeViewModel? SelectedNode
{
@ -136,7 +137,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public bool IsModalOpen =>
EditorDialog.IsOpen
|| FirstRunWizardShell.IsOpen
|| UpdatePromptShell.IsOpen;
|| UpdatePrompt.IsOpen;
private bool CanInteract => !IsBusy && !IsModalOpen;
@ -300,6 +301,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
}
RefreshFromCore();
_ = UpdatePrompt.StartupCheckAsync();
}
public void PollStatus()
@ -333,8 +335,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
UpdatePromptShell.PropertyChanged -= OnModalPropertyChanged;
UpdatePrompt.PropertyChanged -= OnModalPropertyChanged;
FirstRunWizardShell.Dispose();
UpdatePrompt.Dispose();
}
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
@ -350,7 +353,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
{
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen)
&& e.PropertyName != nameof(FirstRunInstallerViewModel.IsOpen))
&& e.PropertyName != nameof(FirstRunInstallerViewModel.IsOpen)
&& e.PropertyName != nameof(LauncherUpdateViewModel.IsOpen))
{
return;
}
@ -376,9 +380,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
{
FirstRunWizardShell.Close();
}
else if (UpdatePromptShell.IsOpen)
else if (UpdatePrompt.IsOpen)
{
UpdatePromptShell.IsOpen = false;
UpdatePrompt.Close();
}
}
@ -976,6 +980,13 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
RefreshFromCore();
}
private void OnClientVersionChanged()
{
OperationStatus = "Versioned client activation changed.";
LastError = null;
RefreshFromCore();
}
private void NotifyCommandStates()
{
AddServerCommand.NotifyCanExecuteChanged();
@ -996,7 +1007,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
session.NotifyCommandState();
}
FirstRunWizardShell.NotifyCommandStates();
UpdatePromptShell.NotifyCommandStates();
UpdatePrompt.NotifyCommandStates();
}
private readonly record struct SelectionKey(