merge: Campaign LA LA10 - updater review-closed

This commit is contained in:
Erik 2026-08-14 23:47:25 +02:00
commit da4fb3de19
40 changed files with 9778 additions and 68 deletions

View file

@ -339,11 +339,21 @@ src/
adjacent
`.<pak>.acdream-bake.<guid:N>.tmp` files are
transaction-owned crash residue
Updates/ -> pinned GitHub manifest + strict SemVer/RID
authority, bounded verified streaming download,
hardened ZIP extraction, immutable
`app/<version>/` installs, atomic `current.json`
activation/rollback, and durable next-start
launcher self-update journal; one OS-handle
shared-session/exclusive-update barrier spans
every launcher process
-> references Platform only; no Avalonia or game-host dependency
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
ViewModels/ -> thin MVVM projection over Launcher.Core,
including the first-run DAT/bake wizard
including the first-run DAT/bake wizard and
nonfatal startup/manual update state, actions,
progress, cancellation, rollback, and errors
-> references Launcher.Core only (Platform transitively); it never owns
a second profile, process, status, or credential state graph
-> every per-RID publish composes the separately published self-contained

View file

@ -471,13 +471,230 @@ gate (user): clean-profile first-run against real DATs.
verify, unpack to `DataDirectory/app/<version>/`, atomic `current.json`
pointer swap, refuse while any session runs, keep previous version for
one-step rollback.
- Launcher self-update: staged download + rename-dance on next start.
- Launcher self-update: staged download + target-local atomic replacement on
next start.
- Session-config composition targets `app/current`'s binaries.
**Acceptance:** manifest/download/verify/swap tests against a local HTTP
fixture; rollback test; refusal-while-running test; self-update staging test;
suites green. Connected gate (user): staged-manifest update swap end-to-end.
### Pinned updater contracts (v1, BINDING)
This section is the single source of truth for every LA10 feed and on-disk
shape. Readers use strict, case-sensitive `System.Text.Json` parsing, reject
unknown or duplicate properties, and reject unsupported schema versions
before doing network, extraction, or activation work.
The production feed is pinned to GitHub owner/repository
`eriknihlen/acdream`; the launcher reads
`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`.
Tests use a separate internal fixture constructor that may admit loopback HTTP;
that allowance never propagates to the production feed. Production manifest
and artifact URIs use HTTPS. Automatic redirects are disabled and every
redirect hop is validated before it is requested; redirect loops, a chain over
five hops, and any HTTPS-to-HTTP downgrade are rejected. `manifest.json` is:
```json
{
"schemaVersion": 1,
"version": "1.2.3",
"minimumLauncherVersion": "1.1.0",
"clients": {
"win-x64": {
"url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-client-win-x64.zip",
"sha256": "<64 hex characters>",
"size": 123
}
},
"launchers": {
"win-x64": {
"url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-launcher-win-x64.zip",
"sha256": "<64 hex characters>",
"size": 123
}
}
}
```
`version` and `minimumLauncherVersion` are strict SemVer 2.0 strings. Build
metadata is ignored for precedence; numeric identifiers are compared without
fixed-width integer overflow. RID keys are exact lowercase portable RIDs.
Both dictionaries are required and the running RID must have a client and a
launcher row. Artifact sizes are positive and capped by the launcher's
download limit; SHA-256 is exactly 64 hex characters. ZIP URLs are absolute.
Client ZIPs have the two host executables at their root
(`AcDream.App[.exe]`, `acdream-headless[.exe]`); launcher ZIPs have
`acdream-launcher[.exe]` at their root. No implicit wrapper directory exists.
Every extracted client version has
`DataDirectory/app/<version>/install.json`:
```json
{
"schemaVersion": 1,
"version": "1.2.3",
"rid": "win-x64",
"archiveSha256": "<64 hex characters>",
"archiveSize": 123,
"files": [
{ "path": "AcDream.App.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
]
}
```
Paths use `/`, are relative, normalized, unique under ordinal-ignore-case,
and sorted ordinally. `unixMode` contains only the portable permission bits
captured from the ZIP entry. Startup verifies every recorded regular file by
size/SHA, rejects unrecorded files/reparse points, and requires the two host
executables before admitting a version. Extraction uses a random sibling
directory under `DataDirectory/app/`; promotion to `<version>/` is one
same-volume directory rename.
`DataDirectory/app/current.json` is the only activation authority:
```json
{ "schemaVersion": 1, "currentVersion": "1.2.3", "previousVersion": "1.1.0" }
```
`previousVersion` is omitted for the first activation. Pointer writes are
write-through temporary-file + same-directory atomic rename. The last valid
pointer is also atomically preserved as `current.previous.json`; startup may
restore that exact backup only when `current.json` is missing/malformed and
the referenced version verifies. Orphan LA10 staging directories, download
archives, corrupt-version quarantine directories, and pointer temporaries are
transaction-owned by exact lowercase GUID names and are removed only under the
exclusive update lease; near-matching user names are preserved. A corrupt
installed version is never silently selected; the explicit one-step rollback
swaps the two verified pointer versions.
`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each
supervised launcher activity holds a shared OS handle from before executable
resolution until terminal process observation; launcher disposal requests
child termination and does not release that handle until the child is actually
observed terminal. An update/rollback holds the
exclusive handle for its entire recovery/download/extract/promote/pointer
transaction. Failure to acquire the exclusive handle is an immediate refusal,
not a wait behind a running session. The open handle, not lock-file contents,
owns the lease and therefore releases after process death.
Launcher self-update staging lives at
`DataDirectory/launcher-update/transactions/<transactionId>/` and the sole
durable authority is `DataDirectory/launcher-update/pending.json` (schema 3):
```json
{
"schemaVersion": 3,
"transactionId": "0123456789abcdef0123456789abcdef",
"state": "staged",
"version": "1.2.3",
"rid": "win-x64",
"targetDirectory": "<absolute current launcher directory>",
"archiveSha256": "<64 hex characters>",
"archiveSize": 123,
"files": [
{ "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
],
"apply": null
}
```
Before mutation the verified staged launcher becomes the next-start helper and
waits for the initiating launcher PID without invoking a shell. It first copies
the complete verified payload into the target-local
`.acdream-self-update-<transactionId>/incoming/` tree. The plan then advances
to `applying`; `apply` is an ordinally sorted union of new payload paths, the
owned metadata path, and obsolete paths from the previous ownership record:
```json
[
{
"path": "acdream-launcher.exe",
"operation": "install",
"hadOriginal": true,
"priorSha256": "<64 hex characters>",
"priorSize": 123,
"priorUnixMode": 0,
"replacementSha256": "<64 hex characters>",
"replacementSize": 456,
"replacementUnixMode": 0
},
{
"path": "new-support.dat",
"operation": "install",
"hadOriginal": false,
"priorSha256": null,
"priorSize": null,
"priorUnixMode": null,
"replacementSha256": "<64 hex characters>",
"replacementSize": 456,
"replacementUnixMode": 0
}
]
```
Every `hadOriginal` entry persists the exact pre-mutation SHA-256, length, and
Linux mode bits; a no-original entry has all three prior fields null. Every
install entry likewise persists the verified replacement metadata, while a
remove entry has all three replacement fields null. The journal is invalid
unless those fields agree with `hadOriginal` and `operation`.
Existing targets are replaced with one same-filesystem atomic replace whose
backup is also target-local. Previously absent noncanonical files use one
same-filesystem rename; obsolete owned files use one rename into backup. The
canonical launcher path therefore contains either the complete old file or the
complete new file at every durable crash boundary. Rollback first performs a
zero-mutation preflight of the complete target-local transaction and every
journal entry. It rejects reparse points, unsafe parents, unrecorded paths,
ambiguous file layouts, and any SHA-256/length/mode mismatch in a prior,
incoming, or discard file. Only a fully preflighted rollback may atomically
restore backups; newly created files move to target-local discard rather than
being deleted. The complete prior target set is then reverified before the
plan enters durable `rolledBack` state while retaining the journal. Retry is
allowed only after that prior set is reverified again and the plan returns to
`staged`. Thus rollback is atomic per file and idempotent after a process/power
loss. Any ambiguity preserves the applying plan and transaction evidence and
forbids launching the canonical path for manual recovery. Linux mode bits come
from the verified incoming file. A helper that cannot immediately
acquire the exclusive update lease defers the staged plan and exits without
restarting the old launcher, preventing restart loops.
Successful application writes strict target ownership metadata at
`<launcher directory>/launcher.install.json`:
```json
{
"schemaVersion": 1,
"version": "1.2.3",
"rid": "win-x64",
"files": [
{ "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
]
}
```
The archive may not supply that reserved metadata path. A prior valid record is
the only authority for obsolete-file removal; the first managed update does
not infer ownership of unrelated legacy files. On success the plan becomes
`awaitingConfirmation`; the new launcher confirms at its first managed
instruction, after which the helper releases its lease and the confirmed
launcher reclaims plan, data-transaction, and target-local residue. An
`applying` plan is rolled back before retry, and failure to start/confirm the
new launcher restores every original (and removes every no-original target).
The helper restarts the restored canonical launcher only after a fresh complete
verification of the retained `rolledBack` journal; rollback corruption or an
unsafe backup/discard tree exits without starting either launcher.
Reading `pending.json` never performs cleanup. Ordinary startup attempts the
exclusive lease without waiting and skips update cleanup entirely when another
session/staging transaction owns it. All plan paths are re-derived/contained
under pinned roots; the target directory must equal the actual launcher base
directory.
Every portable archive and persisted relative path rejects Windows device
segments on every host: `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`,
`CONOUT$`, `COM1`-`COM9`, `LPT1`-`LPT9`, and the Windows-equivalent superscript
forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions.
## LA11 — closeout
- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md`

View file

@ -298,11 +298,23 @@ preview would be a deliberate divergence we are NOT taking.
install to `DataDirectory/app/<version>/`; atomic pointer swap
(`current.json`); never while any session is running; keep the previous
version for one-step rollback.
- **Launcher self-update:** same feed; staged download; rename-dance swap
on next start (a running exe can't replace itself on Windows).
- **Launcher self-update:** same feed; staged download; target-local atomic
replacement on next start after the running process exits.
- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips
are release assets; the launcher pins the repo/owner in its config.
The exact v1 manifest, extracted-version record, `current.json` activation
pointer and launcher ownership record, shared-session/exclusive-update OS
lease, and durable self-update plan schema 3 are pinned in
`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater
contracts (v1, BINDING)**. That section is normative: implementations reject
unknown/duplicate fields and unsupported versions, use strict SemVer 2.0
precedence, verify bounded streamed downloads before safe ZIP extraction, and
use per-hop redirect validation plus same-filesystem atomic replacement. The
LA9 DAT/pak install record remains the sole content descriptor fed to session
configs; LA10 changes only which verified `app/current.json` client binaries
the process supervisor executes.
## 10. Testing
- **Launcher.Core unit tests** (new test project, registered in

View file

@ -44,6 +44,7 @@ public sealed class LauncherProcessSupervisorFactory(
/// </summary>
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
{
private static readonly TimeSpan DisposeStopTimeout = TimeSpan.FromSeconds(5);
private readonly ILauncherChildProcessFactory _factory;
private readonly object _gate = new();
private readonly Queue<LauncherSessionState> _pendingStateChanges = [];
@ -51,6 +52,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
private LauncherSessionState _state = LauncherSessionState.Starting;
private int? _exitCode;
private bool _publishingStateChanges;
private bool _disposed;
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
{
@ -100,6 +102,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
ILauncherChildProcess process;
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_process is not null)
{
throw new InvalidOperationException(
@ -201,6 +204,11 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
if (!process.WaitForExit(timeout) && !process.HasExited)
{
process.Kill();
if (!process.WaitForExit(Timeout.InfiniteTimeSpan) && !process.HasExited)
{
throw new InvalidOperationException(
"The launcher child could not be observed terminal after it was killed.");
}
}
}
@ -307,6 +315,23 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
public void Dispose()
{
ILauncherChildProcess? process;
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
process = _process;
}
if (process is { HasExited: false })
{
Stop(DisposeStopTimeout);
}
lock (_gate)
{
if (_process is not null)

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,35 @@ 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);
});
}
public static LauncherExecutableSet Unavailable(string reason)
{
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
return new LauncherExecutableSet(
() => throw new LauncherUpdateException(reason));
}
private ExecutablePaths RequireAvailable(LaunchMode mode)
{
LauncherCapability capability = GetAvailability(mode);
if (!capability.IsAvailable)
@ -110,6 +164,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 +200,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
@ -622,6 +627,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
}
request.Cancellation.Dispose();
request.Activity.StartCompleted.Set();
}
}
@ -629,10 +635,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 +688,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 +743,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
finally
{
request.Password = null;
if (!hostStarted)
{
ReleaseUpdateSessionLease(request.Activity);
}
}
}
@ -735,6 +754,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
ManagedActivity activity,
LauncherSessionState processState)
{
UpdateSessionBarrier.SessionLease? sessionLease = null;
try
{
lock (_gate)
@ -774,10 +794,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
{
activity.Status = activity.HostTerminalStatus;
}
sessionLease = activity.UpdateSessionLease;
activity.UpdateSessionLease = null;
break;
}
}
sessionLease?.Dispose();
RaiseStateChanged();
}
catch
@ -1179,11 +1202,16 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
private static void DisposeActivity(ManagedActivity activity)
{
activity.StartCancellation?.Cancel();
activity.StartCompleted.Wait();
activity.StartCancellation?.Dispose();
activity.StartCancellation = null;
if (activity.Supervisor is not null)
{
// Disposal is a process-lifetime transaction: the shared update
// lease remains held until Stop has observed the real child
// terminal (including the post-kill wait).
activity.Supervisor.Stop(TimeSpan.FromSeconds(5));
if (activity.SupervisorStateHandler is not null)
{
activity.Supervisor.StateChanged -= activity.SupervisorStateHandler;
@ -1192,6 +1220,16 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
activity.Supervisor.Dispose();
activity.Supervisor = null;
}
ReleaseUpdateSessionLease(activity);
activity.StartCompleted.Dispose();
}
private static void ReleaseUpdateSessionLease(ManagedActivity activity)
{
UpdateSessionBarrier.SessionLease? lease =
Interlocked.Exchange(ref activity.UpdateSessionLease, null);
lease?.Dispose();
}
private void ThrowIfDisposed()
@ -1252,6 +1290,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public CancellationTokenSource? StartCancellation { get; set; }
public UpdateSessionBarrier.SessionLease? UpdateSessionLease;
public ManualResetEventSlim StartCompleted { get; } = new(false);
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);
}
}
}

File diff suppressed because it is too large Load diff

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,418 @@
using System.Diagnostics;
namespace AcDream.Launcher.Core.Updates;
public sealed record SelfUpdateStartupResult(
bool ShouldExit,
int ExitCode,
string[] RemainingArguments);
/// <summary>
/// Process-level self-update bootstrap. Every child argument is passed through
/// <see cref="ProcessStartInfo.ArgumentList"/> with shell execution disabled.
/// </summary>
public static class LauncherSelfUpdateBootstrap
{
public const string HelperArgument = "--acdream-self-update-helper-v1";
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
internal const string DeferredArgument = "--acdream-self-update-deferred-v1";
internal const int DeferredLeaseExitCode = 73;
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
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], DeferredArgument, StringComparison.Ordinal))
{
return new SelfUpdateStartupResult(false, 0, args[1..]);
}
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],
args[4..],
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);
await FinishConfirmedCleanupAsync(
manager,
baseDirectory,
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(false, 0, args[2..]);
}
if (!manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? startupLease))
{
// A running session or another launcher is staging. Reading the
// plan is safe, but cleanup or starting a competing helper is not.
return new SelfUpdateStartupResult(false, 0, args);
}
using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
?? throw new InvalidOperationException("Exclusive startup lease is missing."))
{
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
_ = manager.CleanupOwnedResidueUnderLease(
plan,
baseDirectory,
lease);
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);
_ = manager.CleanupOwnedResidueUnderLease(
pending: null,
baseDirectory,
lease);
return new SelfUpdateStartupResult(false, 0, args);
}
string expectedExecutable = ClientVersionStore.ResolveContained(
baseDirectory,
GetLauncherFileName(plan.Rid));
if (!PathsEqual(executable, expectedExecutable))
{
throw new LauncherUpdateException(
"Self-update can start only from the published acdream-launcher executable.");
}
string helperPath = manager.GetStagedLauncherPath(plan);
var startInfo = new ProcessStartInfo(helperPath)
{
UseShellExecute = false,
WorkingDirectory = manager.GetPayloadDirectory(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);
foreach (string argument in args)
{
startInfo.ArgumentList.Add(argument);
}
_ = 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,
IReadOnlyList<string> publicArguments,
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.");
}
if (!PathsEqual(plan.TargetDirectory, targetDirectory))
{
throw new LauncherUpdateException(
"The helper target does not match the pending self-update.");
}
string launcherPath = ClientVersionStore.ResolveContained(
targetDirectory,
GetLauncherFileName(plan.Rid));
var startInfo = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
startInfo.ArgumentList.Add(ConfirmArgument);
startInfo.ArgumentList.Add(transactionId);
foreach (string argument in publicArguments)
{
startInfo.ArgumentList.Add(argument);
}
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
if (!manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? updateLease))
{
// Do not restart the canonical launcher: it would immediately see
// the same staged plan and create an unbounded helper loop.
return DeferredLeaseExitCode;
}
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
?? throw new InvalidOperationException("Exclusive update lease is missing."))
{
plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException(
"The helper found no pending self-update after acquiring the lease.");
if (!string.Equals(
plan.TransactionId,
transactionId,
StringComparison.Ordinal)
|| !PathsEqual(plan.TargetDirectory, targetDirectory))
{
throw new LauncherUpdateException(
"The pending self-update changed before the helper acquired its lease.");
}
_ = manager.CleanupOwnedResidueUnderLease(
plan,
targetDirectory,
lease);
Process? replacement = null;
try
{
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
.ConfigureAwait(false);
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
{
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
CancellationToken.None)
.ConfigureAwait(false);
SelfUpdatePlan? rollbackReceipt = pending?.State switch
{
SelfUpdatePlanState.Applying =>
await manager.RecoverApplyingAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false),
SelfUpdatePlanState.AwaitingConfirmation =>
await manager.RollbackAwaitingConfirmationAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false),
SelfUpdatePlanState.RolledBack => pending,
_ => null,
};
if (rollbackReceipt?.State != SelfUpdatePlanState.RolledBack)
{
return 75;
}
await manager.VerifyRestoredPriorAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
catch
{
// An ambiguous state must not start either executable.
return 75;
}
var restored = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
restored.ArgumentList.Add(DeferredArgument);
foreach (string argument in publicArguments)
{
restored.ArgumentList.Add(argument);
}
_ = Process.Start(restored);
return 74;
}
finally
{
replacement?.Dispose();
}
}
}
private static async Task FinishConfirmedCleanupAsync(
LauncherSelfUpdateManager manager,
string targetDirectory,
CancellationToken cancellationToken)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout;
do
{
cancellationToken.ThrowIfCancellationRequested();
if (manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? lease))
{
using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease
?? throw new InvalidOperationException(
"Exclusive cleanup lease is missing."))
{
SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
if (pending is
{
State: SelfUpdatePlanState.AwaitingConfirmation,
}
&& manager.IsConfirmed(pending.TransactionId))
{
await manager.CompleteConfirmedAsync(
pending.TransactionId,
targetDirectory,
cancellationToken)
.ConfigureAwait(false);
pending = null;
}
if (manager.CleanupOwnedResidueUnderLease(
pending,
targetDirectory,
acquiredLease))
{
return;
}
}
}
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
}
while (DateTimeOffset.UtcNow < deadline);
}
private static string GetLauncherFileName(string rid) =>
"acdream-launcher"
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
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);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,454 @@
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();
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,38 @@
namespace AcDream.Launcher.Core.Updates;
/// <summary>
/// Host-independent path rules for payloads that must remain safe when moved
/// between Linux and Windows. Windows device aliases are rejected on every
/// host so a release cannot verify on one platform and become ambiguous on
/// another.
/// </summary>
internal static class PortablePathRules
{
public static bool IsWindowsDeviceName(string segment)
{
ArgumentNullException.ThrowIfNull(segment);
string stem = segment.Split('.')[0];
if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("CLOCK$", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("CONIN$", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("CONOUT$", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (stem.Length != 4
|| (!stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
&& !stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)))
{
return false;
}
return stem[3] is >= '1' and <= '9'
or '\u00b9'
or '\u00b2'
or '\u00b3';
}
}

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,407 @@
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. Production
/// construction is HTTPS-only. The loopback HTTP allowance is available only
/// through an internal fixture factory and is never inferred from a URI.
/// Redirects are followed manually so every hop is checked before any bytes
/// cross that hop.
/// </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 const int MaximumRedirects = 5;
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 Uri _manifestUri;
private readonly bool _allowLoopbackHttp;
public ReleaseManifestClient(TimeSpan? timeout = null)
: this(
ProductionManifestUri,
allowLoopbackHttp: false,
CreateRedirectDisabledHandler(),
timeout)
{
}
private ReleaseManifestClient(
Uri manifestUri,
bool allowLoopbackHttp,
HttpMessageHandler handler,
TimeSpan? timeout)
{
ArgumentNullException.ThrowIfNull(manifestUri);
ArgumentNullException.ThrowIfNull(handler);
_manifestUri = manifestUri;
_allowLoopbackHttp = allowLoopbackHttp;
RequireTransport(_manifestUri, "manifest", _allowLoopbackHttp);
_httpClient = new HttpClient(handler, disposeHandler: true)
{
Timeout = timeout ?? TimeSpan.FromSeconds(15),
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
}
internal static ReleaseManifestClient CreateLoopbackFixture(
Uri manifestUri,
TimeSpan? timeout = null) => new(
manifestUri,
allowLoopbackHttp: true,
CreateRedirectDisabledHandler(),
timeout);
internal static ReleaseManifestClient CreateForTransportTest(
Uri manifestUri,
bool allowLoopbackHttp,
HttpMessageHandler handler) => new(
manifestUri,
allowLoopbackHttp,
handler,
TimeSpan.FromSeconds(15));
public async Task<ReleaseManifest> FetchAsync(
CancellationToken cancellationToken = default)
{
try
{
Uri current = _manifestUri;
var visited = new HashSet<string>(StringComparer.Ordinal);
for (int redirectCount = 0;;)
{
RequireTransport(current, "manifest redirect", _allowLoopbackHttp);
if (!visited.Add(current.AbsoluteUri))
{
throw new LauncherUpdateException(
"The release manifest redirect chain contains a loop.");
}
using var request = new HttpRequestMessage(HttpMethod.Get, current);
using HttpResponseMessage response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
if (IsRedirect(response.StatusCode))
{
if (redirectCount >= MaximumRedirects)
{
throw new LauncherUpdateException(
$"The release manifest exceeded {MaximumRedirects} redirects.");
}
Uri? location = response.Headers.Location;
if (location is null)
{
throw new LauncherUpdateException(
"The release manifest redirect has no Location header.");
}
Uri next = location.IsAbsoluteUri
? location
: new Uri(current, location);
RequireTransport(next, "manifest redirect", _allowLoopbackHttp);
current = next;
redirectCount++;
continue;
}
response.EnsureSuccessStatusCode();
return await ReadAndParseAsync(response, cancellationToken)
.ConfigureAwait(false);
}
}
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,
bool allowLoopbackHttpArtifacts = false)
{
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, allowLoopbackHttpArtifacts);
}
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() => _httpClient.Dispose();
internal static void RequireTransport(
Uri uri,
string description,
bool allowLoopbackHttp)
{
if (!uri.IsAbsoluteUri
|| (uri.Scheme != Uri.UriSchemeHttps
&& !(allowLoopbackHttp
&& uri.Scheme == Uri.UriSchemeHttp
&& uri.IsLoopback)))
{
throw new LauncherUpdateException(
$"The {description} URI must use HTTPS"
+ (allowLoopbackHttp ? " (or fixture-only loopback HTTP)." : "."));
}
}
internal static void RequireSecureOrLoopback(Uri uri, string description) =>
RequireTransport(uri, description, allowLoopbackHttp: true);
private async Task<ReleaseManifest> ReadAndParseAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
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(), _allowLoopbackHttp);
}
private static ReleaseManifest Validate(
ManifestDocument? document,
bool allowLoopbackHttpArtifacts)
{
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",
allowLoopbackHttpArtifacts);
IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts(
document.Launchers,
"launchers",
allowLoopbackHttpArtifacts);
return new ReleaseManifest(version, minimum, clients, launchers);
}
private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts(
Dictionary<string, ArtifactDocument>? artifacts,
string field,
bool allowLoopbackHttpArtifacts)
{
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.");
}
RequireTransport(
uri,
$"{field}.{rid} artifact",
allowLoopbackHttpArtifacts);
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 bool IsRedirect(HttpStatusCode statusCode) => statusCode is
HttpStatusCode.MovedPermanently
or HttpStatusCode.Found
or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect
or HttpStatusCode.PermanentRedirect;
private static HttpMessageHandler CreateRedirectDisabledHandler() =>
new HttpClientHandler
{
AllowAutoRedirect = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.None,
};
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,469 @@
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 '*')
|| PortablePathRules.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.");
}
}
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,140 @@
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(this, stream);
}
/// <summary>
/// Non-blocking startup probe. Contention is an expected "not now"
/// result; permission and path failures remain hard errors.
/// </summary>
public bool TryAcquireExclusive(out ExclusiveLease? lease)
{
Directory.CreateDirectory(
Path.GetDirectoryName(_lockPath)
?? throw new InvalidOperationException(
"The update/session lock path has no parent directory."));
try
{
lease = new ExclusiveLease(
this,
new FileStream(
_lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.None));
return true;
}
catch (IOException)
{
lease = null;
return false;
}
catch (UnauthorizedAccessException ex)
{
throw new LauncherUpdateException(
$"The update/session lease could not be opened: {ex.Message}",
ex);
}
}
internal void RequireOwned(ExclusiveLease lease)
{
ArgumentNullException.ThrowIfNull(lease);
if (!lease.IsHeldBy(this))
{
throw new LauncherUpdateException(
"The cleanup operation does not hold this update barrier's exclusive lease.");
}
}
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 readonly UpdateSessionBarrier _owner;
private FileStream? _stream;
internal ExclusiveLease(UpdateSessionBarrier owner, FileStream stream)
{
_owner = owner;
_stream = stream;
}
internal bool IsHeldBy(UpdateSessionBarrier owner) =>
ReferenceEquals(_owner, owner)
&& Volatile.Read(ref _stream) is not null;
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
}
}

View file

@ -0,0 +1,277 @@
using System.Buffers;
using System.Net;
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 SendWithValidatedRedirectsAsync(
artifact.Url,
cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
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);
}
}
private async Task<HttpResponseMessage> SendWithValidatedRedirectsAsync(
Uri initialUri,
CancellationToken cancellationToken)
{
bool allowLoopbackHttp = initialUri.Scheme == Uri.UriSchemeHttp
&& initialUri.IsLoopback;
Uri current = initialUri;
var visited = new HashSet<string>(StringComparer.Ordinal);
for (int redirectCount = 0;;)
{
ReleaseManifestClient.RequireTransport(
current,
"artifact redirect",
allowLoopbackHttp);
if (!visited.Add(current.AbsoluteUri))
{
throw new LauncherUpdateException(
"The release artifact redirect chain contains a loop.");
}
using var request = new HttpRequestMessage(HttpMethod.Get, current);
HttpResponseMessage response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
Uri effectiveUri = response.RequestMessage?.RequestUri ?? current;
if (!Uri.Equals(effectiveUri, current))
{
response.Dispose();
throw new LauncherUpdateException(
"The artifact HTTP transport followed an automatic redirect; "
+ "every redirect must be validated before it is requested.");
}
if (!IsRedirect(response.StatusCode))
{
return response;
}
try
{
if (redirectCount >= ReleaseManifestClient.MaximumRedirects)
{
throw new LauncherUpdateException(
$"The release artifact exceeded "
+ $"{ReleaseManifestClient.MaximumRedirects} redirects.");
}
Uri? location = response.Headers.Location;
if (location is null)
{
throw new LauncherUpdateException(
"The release artifact redirect has no Location header.");
}
Uri next = location.IsAbsoluteUri
? location
: new Uri(current, location);
ReleaseManifestClient.RequireTransport(
next,
"artifact redirect",
allowLoopbackHttp);
current = next;
redirectCount++;
}
finally
{
response.Dispose();
}
}
}
private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is
HttpStatusCode.MovedPermanently
or HttpStatusCode.Found
or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect
or HttpStatusCode.PermanentRedirect;
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

@ -14,6 +14,10 @@
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />

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,7 @@ public sealed partial class App : Application
{
private LauncherOrchestrator? _orchestrator;
private LauncherWindowViewModel? _viewModel;
private LauncherUpdateComposition? _updateComposition;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@ -23,6 +26,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 +51,27 @@ public sealed partial class App : Application
$"Client content verification failed: {ex.Message}");
}
LauncherUpdateComposition updates = LauncherUpdateComposition.Create(
paths,
rid,
GetLauncherVersion(),
AppContext.BaseDirectory,
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
== true);
_updateComposition = updates;
_orchestrator = new LauncherOrchestrator(
profiles,
paths,
LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory),
updates.Executables,
verification.Record,
installationStatus: verification.Status);
installationStatus: verification.Status,
updateSessionBarrier: updates.Versions.Barrier);
_viewModel = new LauncherWindowViewModel(
_orchestrator,
new AvaloniaUiDispatcher(),
installer);
installer,
updates.Updater);
_viewModel.Initialize();
desktop.MainWindow = new MainWindow
@ -73,7 +88,23 @@ public sealed partial class App : Application
{
_viewModel?.Dispose();
_orchestrator?.Dispose();
_updateComposition?.Dispose();
_viewModel = null;
_orchestrator = null;
_updateComposition = 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

@ -0,0 +1,128 @@
using System.Net;
using System.Security;
using System.Text.Json;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Updates;
using AcDream.Launcher.ViewModels;
using AcDream.Platform;
namespace AcDream.Launcher;
/// <summary>
/// Testable startup transaction for versioned-client/update services. Storage
/// failures produce a fail-closed executable resolver and an unavailable UI
/// projection; they do not abort profile/installer window construction.
/// </summary>
internal sealed class LauncherUpdateComposition : IDisposable
{
private readonly HttpClient? _artifactClient;
private readonly ReleaseManifestClient? _manifestClient;
private LauncherUpdateComposition(
ClientVersionStore versions,
LauncherExecutableSet executables,
ILauncherUpdater updater,
HttpClient? artifactClient,
ReleaseManifestClient? manifestClient)
{
Versions = versions;
Executables = executables;
Updater = updater;
_artifactClient = artifactClient;
_manifestClient = manifestClient;
}
public ClientVersionStore Versions { get; }
public LauncherExecutableSet Executables { get; }
public ILauncherUpdater Updater { get; }
public static LauncherUpdateComposition Create(
ApplicationPathSet paths,
string rid,
LauncherVersion launcherVersion,
string launcherTargetDirectory,
Func<bool> hasRunningSessions,
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(launcherVersion);
ArgumentNullException.ThrowIfNull(hasRunningSessions);
var versions = new ClientVersionStore(paths);
HttpClient? artifactClient = null;
ReleaseManifestClient? manifestClient = null;
try
{
_ = initialize is null
? versions.LoadAndRecoverAsync(rid).GetAwaiter().GetResult()
: initialize(versions, rid);
artifactClient = new HttpClient(
new HttpClientHandler
{
AllowAutoRedirect = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.None,
},
disposeHandler: true)
{
Timeout = TimeSpan.FromSeconds(15),
};
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15));
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
var updater = new LauncherUpdater(
manifestClient,
artifactClient,
versions,
selfUpdates,
launcherVersion,
rid,
launcherTargetDirectory,
hasRunningSessions);
return new LauncherUpdateComposition(
versions,
LauncherExecutableSet.FromCurrentVersionStore(versions),
updater,
artifactClient,
manifestClient);
}
catch (Exception ex) when (IsStorageFailure(ex))
{
manifestClient?.Dispose();
artifactClient?.Dispose();
string status = "Versioned client update storage is unavailable: "
+ (string.IsNullOrWhiteSpace(ex.Message)
? "the storage operation failed."
: ex.Message);
var resolution = new ClientVersionResolution(
ClientVersionState.Invalid,
status,
null,
null,
null,
null);
return new LauncherUpdateComposition(
versions,
LauncherExecutableSet.Unavailable(status),
new UnavailableLauncherUpdater(status, resolution),
artifactClient: null,
manifestClient: null);
}
}
public void Dispose()
{
_manifestClient?.Dispose();
_artifactClient?.Dispose();
}
private static bool IsStorageFailure(Exception exception) => exception is
IOException
or UnauthorizedAccessException
or SecurityException
or JsonException
or FormatException
or NotSupportedException
or LauncherUpdateException;
}

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,530 @@
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 readonly ClientVersionResolution _resolution;
private readonly string _status;
public UnavailableLauncherUpdater(
string status = "Versioned client updater is unavailable.",
ClientVersionResolution? resolution = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(status);
_status = status;
_resolution = resolution ?? new ClientVersionResolution(
ClientVersionState.Invalid,
status,
null,
null,
null,
null);
}
public ClientVersionResolution CurrentClient => _resolution;
public Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(_resolution);
public Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default) =>
Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException(_status));
public Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<ClientVersionResolution>(
new LauncherUpdateException(_status));
public Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<SelfUpdateStageResult>(
new LauncherUpdateException(_status));
public Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<ClientVersionResolution>(
new LauncherUpdateException(_status));
}

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(

View file

@ -2,16 +2,202 @@ using System.Diagnostics;
using System.Reflection;
using AcDream.Bake;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
return args.FirstOrDefault() switch
const string SelfUpdateDataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA";
const string SelfUpdateTargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET";
const string SelfUpdateHelperPidEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID";
string[] effectiveArgs = args;
string? selfUpdateData = Environment.GetEnvironmentVariable(SelfUpdateDataEnvironment);
string? selfUpdateTarget = Environment.GetEnvironmentVariable(SelfUpdateTargetEnvironment);
if (!string.IsNullOrWhiteSpace(selfUpdateData)
&& !string.IsNullOrWhiteSpace(selfUpdateTarget)
&& IsBootstrapInvocation(effectiveArgs))
{
"hold-install-lease" => await HoldInstallLeaseAsync(args[1..]),
"orphan-parent" => await RunOrphanParentAsync(args[1..]),
"orphan-child" => RunOrphanChild(args[1..]),
if (effectiveArgs[0] == LauncherSelfUpdateBootstrap.HelperArgument
&& Environment.GetEnvironmentVariable(SelfUpdateHelperPidEnvironment) is string helperPid
&& !string.IsNullOrWhiteSpace(helperPid))
{
File.WriteAllText(
Path.GetFullPath(helperPid),
Environment.ProcessId.ToString(
System.Globalization.CultureInfo.InvariantCulture));
}
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(selfUpdateData), http);
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
effectiveArgs,
manager,
Path.GetFullPath(selfUpdateTarget),
Path.GetFullPath(
Environment.ProcessPath
?? throw new InvalidOperationException("Process path is unavailable.")));
if (startup.ShouldExit)
{
return startup.ExitCode;
}
effectiveArgs = startup.RemainingArguments;
}
return effectiveArgs.FirstOrDefault() switch
{
"hold-install-lease" => await HoldInstallLeaseAsync(effectiveArgs[1..]),
"hold-update-lease" => await HoldUpdateLeaseAsync(effectiveArgs[1..]),
"orphan-parent" => await RunOrphanParentAsync(effectiveArgs[1..]),
"orphan-child" => RunOrphanChild(effectiveArgs[1..]),
"crash-self-update" => await CrashSelfUpdateAsync(effectiveArgs[1..]),
"stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]),
"bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]),
"canonical-probe" => CanonicalProbe(effectiveArgs[1..]),
_ => 2,
};
static bool IsBootstrapInvocation(string[] arguments) =>
arguments.Length > 0
&& arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument
or LauncherSelfUpdateBootstrap.ConfirmArgument
or LauncherSelfUpdateBootstrap.DeferredArgument
or "canonical-probe";
static ApplicationPathSet Paths(string dataDirectory)
{
string data = Path.GetFullPath(dataDirectory);
return new ApplicationPathSet(
Path.Combine(data, "fixture-config"),
data,
Path.Combine(data, "fixture-cache"),
null);
}
static async Task<int> CrashSelfUpdateAsync(string[] arguments)
{
if (arguments.Length != 4)
{
return 2;
}
string dataDirectory = Path.GetFullPath(arguments[0]);
string targetDirectory = Path.GetFullPath(arguments[1]);
string readyPath = Path.GetFullPath(arguments[2]);
string canonicalName = arguments[3];
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(
Paths(dataDirectory),
http,
null,
observation =>
{
if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
&& string.Equals(
observation.Path,
canonicalName,
StringComparison.Ordinal))
{
if (!File.Exists(Path.Combine(targetDirectory, canonicalName)))
{
throw new InvalidOperationException(
"The canonical launcher vanished at the apply boundary.");
}
File.WriteAllText(readyPath, Environment.ProcessId.ToString(
System.Globalization.CultureInfo.InvariantCulture));
Thread.Sleep(Timeout.Infinite);
}
});
using UpdateSessionBarrier.ExclusiveLease lease = manager.Barrier.AcquireExclusive();
_ = await manager.ApplyPendingAsync(targetDirectory);
return 0;
}
static async Task<int> StageSelfUpdateAsync(string[] arguments)
{
if (arguments.Length != 7
|| !long.TryParse(
arguments[6],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out long size))
{
return 2;
}
string dataDirectory = Path.GetFullPath(arguments[0]);
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(dataDirectory), http);
_ = await manager.StageAsync(
LauncherVersion.Parse(arguments[2]),
arguments[3],
new ReleaseArtifact(new Uri(arguments[4]), arguments[5], size),
Path.GetFullPath(arguments[1]),
progress: null,
CancellationToken.None);
return 0;
}
static async Task<int> BootstrapProbeAsync(string[] arguments)
{
if (arguments.Length != 4)
{
return 2;
}
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(arguments[0]), http);
SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync(
["ordinary"],
manager,
Path.GetFullPath(arguments[1]),
Path.GetFullPath(arguments[2]));
File.WriteAllText(
Path.GetFullPath(arguments[3]),
result.ShouldExit ? "exit" : string.Join("\n", result.RemainingArguments));
return result.ShouldExit ? 3 : 0;
}
static int CanonicalProbe(string[] arguments)
{
if (arguments.Length != 1)
{
return 2;
}
File.WriteAllText(
Path.GetFullPath(arguments[0]),
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
+ "|"
+ Path.GetFullPath(
Environment.ProcessPath
?? throw new InvalidOperationException("Process path is unavailable.")));
return 0;
}
static async Task<int> HoldUpdateLeaseAsync(string[] arguments)
{
if (arguments.Length != 4
|| arguments[0] is not ("session" or "exclusive"))
{
return 2;
}
var barrier = new UpdateSessionBarrier(Path.GetFullPath(arguments[1]));
using IDisposable lease = arguments[0] == "session"
? barrier.AcquireSession()
: barrier.AcquireExclusive();
string readyPath = Path.GetFullPath(arguments[2]);
string releasePath = Path.GetFullPath(arguments[3]);
File.WriteAllText(readyPath, arguments[0]);
while (!File.Exists(releasePath))
{
await Task.Delay(10);
}
return 0;
}
static async Task<int> HoldInstallLeaseAsync(string[] arguments)
{
if (arguments.Length != 3)

View file

@ -2,6 +2,7 @@ using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Status;
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Orchestration;
@ -34,6 +35,62 @@ public sealed class LauncherOrchestratorTests : IDisposable
}
}
[Fact]
public async Task RunningHostHoldsSharedUpdateLeaseUntilProcessTerminalState()
{
var supervisors = new FakeSupervisorFactory();
var barrier = new UpdateSessionBarrier(_paths.DataDirectory);
using LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors,
updateSessionBarrier: barrier);
_ = await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Gui);
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
Assert.Single(supervisors.Created).Exit(0);
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
}
[Fact]
public async Task DisposeKeepsUpdateLeaseUntilLiveChildIsObservedTerminal()
{
var supervisors = new BlockingStopSupervisorFactory();
var barrier = new UpdateSessionBarrier(_paths.DataDirectory);
LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors,
updateSessionBarrier: barrier);
try
{
_ = await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless);
BlockingStopSupervisor supervisor = Assert.Single(supervisors.Created);
Task disposal = Task.Run(orchestrator.Dispose);
Assert.True(supervisor.StopEntered.Wait(TimeSpan.FromSeconds(5)));
Assert.False(disposal.IsCompleted);
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
supervisor.AllowTerminal.Set();
await disposal.WaitAsync(TimeSpan.FromSeconds(5));
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
Assert.True(supervisor.Disposed);
}
finally
{
supervisors.AllowEveryStop();
orchestrator.Dispose();
}
}
[Fact]
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
{
@ -527,7 +584,8 @@ public sealed class LauncherOrchestratorTests : IDisposable
ILauncherSessionConfigService? configService = null,
ILauncherProcessSupervisorFactory? supervisorFactory = null,
IStatusEventSourceFactory? statusSourceFactory = null,
LauncherExecutableSet? executables = null)
LauncherExecutableSet? executables = null,
UpdateSessionBarrier? updateSessionBarrier = null)
{
string profilePath = Path.Combine(
_paths.ConfigDirectory,
@ -563,7 +621,8 @@ public sealed class LauncherOrchestratorTests : IDisposable
configService,
supervisorFactory ?? new FakeSupervisorFactory(),
statusSourceFactory ?? new QueueStatusSourceFactory(),
() => $"s{Interlocked.Increment(ref nextSession)}");
() => $"s{Interlocked.Increment(ref nextSession)}",
updateSessionBarrier: updateSessionBarrier);
orchestrator.LoadProfiles();
return orchestrator;
}
@ -733,6 +792,59 @@ public sealed class LauncherOrchestratorTests : IDisposable
}
}
private sealed class BlockingStopSupervisorFactory : ILauncherProcessSupervisorFactory
{
public List<BlockingStopSupervisor> Created { get; } = [];
public ILauncherProcessSupervisor Create()
{
var supervisor = new BlockingStopSupervisor();
Created.Add(supervisor);
return supervisor;
}
public void AllowEveryStop()
{
foreach (BlockingStopSupervisor supervisor in Created)
{
supervisor.AllowTerminal.Set();
}
}
}
private sealed class BlockingStopSupervisor : ILauncherProcessSupervisor
{
public ManualResetEventSlim StopEntered { get; } = new(false);
public ManualResetEventSlim AllowTerminal { get; } = new(false);
public LauncherSessionState State { get; private set; } =
LauncherSessionState.Starting;
public int? ExitCode { get; private set; }
public bool Disposed { get; private set; }
public event EventHandler<LauncherSessionState>? StateChanged;
public void Start(LauncherProcessSpec spec, string? password)
{
State = LauncherSessionState.Running;
StateChanged?.Invoke(this, State);
}
public void Stop(TimeSpan timeout)
{
StopEntered.Set();
AllowTerminal.Wait();
State = LauncherSessionState.Exited;
ExitCode = 0;
StateChanged?.Invoke(this, State);
}
public void Dispose() => Disposed = true;
}
private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory
{
public List<QueueStatusSource> Created { get; } = [];

View file

@ -0,0 +1,262 @@
using System.Text;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class ClientVersionStoreTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-client-version-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _rid;
public ClientVersionStoreTests()
{
_paths = UpdateTestData.Paths(_root);
_rid = LauncherRuntimeIdentity.DetectRid();
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task AtomicPromotionPublishesStrictPointerAndRetainsPreviousForRollback()
{
var store = new ClientVersionStore(_paths);
ClientVersionResolution first = await PromoteAsync(store, "1.0.0", "first");
ClientVersionResolution second = await PromoteAsync(store, "2.0.0", "second");
Assert.True(first.IsVerified);
Assert.True(second.IsVerified);
Assert.Equal("2.0.0", second.Version!.Value);
Assert.Equal("1.0.0", second.PreviousVersion);
Assert.True(Directory.Exists(store.GetVersionDirectory(LauncherVersion.Parse("1.0.0"))));
Assert.True(Directory.Exists(store.GetVersionDirectory(LauncherVersion.Parse("2.0.0"))));
Assert.Empty(Directory.EnumerateFileSystemEntries(
store.AppDirectory,
".client-staging-*",
SearchOption.TopDirectoryOnly));
string pointer = await File.ReadAllTextAsync(store.CurrentPointerPath);
Assert.Contains("\"schemaVersion\": 1", pointer, StringComparison.Ordinal);
Assert.Contains("\"currentVersion\": \"2.0.0\"", pointer, StringComparison.Ordinal);
Assert.DoesNotContain(".client-staging", pointer, StringComparison.Ordinal);
ClientVersionResolution rolledBack = await store.RollbackAsync(_rid);
Assert.Equal("1.0.0", rolledBack.Version!.Value);
Assert.Equal("2.0.0", rolledBack.PreviousVersion);
Assert.Equal("first-gui", await File.ReadAllTextAsync(
Path.Combine(rolledBack.Directory!, "AcDream.App" + ExecutableSuffix)));
}
[Fact]
public async Task TornCurrentPointerRecoversLastDurablePointerAndOwnedTempResidue()
{
var store = new ClientVersionStore(_paths);
_ = await PromoteAsync(store, "1.0.0", "first");
_ = await PromoteAsync(store, "2.0.0", "second");
string temp = Path.Combine(
store.AppDirectory,
$".current.json.{Guid.NewGuid():N}.tmp");
await File.WriteAllTextAsync(temp, "partial temp");
await File.WriteAllTextAsync(store.CurrentPointerPath, "{\"schemaVersion\":1,");
var recoveredStore = new ClientVersionStore(_paths);
ClientVersionResolution recovered = await recoveredStore.LoadAndRecoverAsync(_rid);
Assert.True(recovered.IsVerified);
Assert.Equal("1.0.0", recovered.Version!.Value);
Assert.Contains("Recovered", recovered.Status, StringComparison.Ordinal);
Assert.False(File.Exists(temp));
Assert.Contains("\"currentVersion\": \"1.0.0\"", await File.ReadAllTextAsync(
recoveredStore.CurrentPointerPath), StringComparison.Ordinal);
}
[Fact]
public async Task CorruptActiveInstallFailsClosedButVerifiedPreviousCanRollback()
{
var store = new ClientVersionStore(_paths);
_ = await PromoteAsync(store, "1.0.0", "first");
ClientVersionResolution second = await PromoteAsync(store, "2.0.0", "second");
string graphical = Path.Combine(second.Directory!, "AcDream.App" + ExecutableSuffix);
await File.WriteAllTextAsync(graphical, "tampered!!");
var restarted = new ClientVersionStore(_paths);
ClientVersionResolution invalid = await restarted.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, invalid.State);
Assert.Contains("corrupt", invalid.Status, StringComparison.OrdinalIgnoreCase);
ClientVersionResolution rolledBack = await restarted.RollbackAsync(_rid);
Assert.Equal("1.0.0", rolledBack.Version!.Value);
}
[Fact]
public async Task StrictPointerAndInstallRecordsRejectUnknownUnrecordedAndWrongRidState()
{
var store = new ClientVersionStore(_paths);
ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "strict");
await File.WriteAllTextAsync(
Path.Combine(installed.Directory!, "unrecorded.dll"),
"unexpected");
var restarted = new ClientVersionStore(_paths);
ClientVersionResolution unrecorded = await restarted.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, unrecorded.State);
Assert.Contains("unrecorded", unrecorded.Status, StringComparison.OrdinalIgnoreCase);
File.Delete(Path.Combine(installed.Directory!, "unrecorded.dll"));
string installPath = ClientVersionStore.GetMetadataPath(installed.Directory!);
string install = await File.ReadAllTextAsync(installPath);
await File.WriteAllTextAsync(
installPath,
install.Replace(
"\"schemaVersion\": 1",
"\"schemaVersion\": 1,\"schemaVersion\": 1",
StringComparison.Ordinal));
ClientVersionResolution duplicate = await restarted.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, duplicate.State);
Assert.Contains("Duplicate", duplicate.Status, StringComparison.Ordinal);
await File.WriteAllTextAsync(installPath, install);
string current = await File.ReadAllTextAsync(store.CurrentPointerPath);
await File.WriteAllTextAsync(
store.CurrentPointerPath,
current.TrimEnd().TrimEnd('}') + ",\"unknown\":true}");
ClientVersionResolution unknown = await restarted.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, unknown.State);
await File.WriteAllTextAsync(store.CurrentPointerPath, current);
string otherRid = _rid == "win-x64" ? "linux-x64" : "win-x64";
ClientVersionResolution wrongRid = await restarted.LoadAndRecoverAsync(otherRid);
Assert.Equal(ClientVersionState.Invalid, wrongRid.State);
Assert.Contains("RID", wrongRid.Status, StringComparison.Ordinal);
}
[Fact]
public async Task DynamicExecutableResolverTracksOnlyVerifiedCurrentVersion()
{
var store = new ClientVersionStore(_paths);
LauncherExecutableSet executables = LauncherExecutableSet.FromCurrentVersionStore(store);
Assert.False(executables.GetAvailability(LaunchMode.Gui).IsAvailable);
ClientVersionResolution first = await PromoteAsync(store, "1.0.0", "one");
Assert.Equal(first.Directory, executables.WorkingDirectory);
Assert.Equal(
Path.Combine(first.Directory!, "AcDream.App" + ExecutableSuffix),
executables.CreatePlaySpec(LaunchMode.Gui, "session.json").ExecutablePath);
ClientVersionResolution second = await PromoteAsync(store, "2.0.0", "two");
Assert.Equal(second.Directory, executables.WorkingDirectory);
Assert.Equal(
Path.Combine(second.Directory!, "acdream-headless" + ExecutableSuffix),
executables.CreateProbeSpec("session.json").ExecutablePath);
}
[Fact]
public async Task ExistingSemanticVersionCannotReplaceActiveContentInPlace()
{
var store = new ClientVersionStore(_paths);
_ = await PromoteAsync(store, "1.0.0", "original");
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
PromoteAsync(store, "1.0.0", "different"));
Assert.Contains("active client version", error.Message, StringComparison.OrdinalIgnoreCase);
ClientVersionResolution resolution = await store.LoadAndRecoverAsync(_rid);
Assert.Equal("original-gui", await File.ReadAllTextAsync(
Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix)));
}
[Fact]
public async Task LinuxTreatsNonCanonicalInstallJsonCasingAsUnrecordedContent()
{
if (!OperatingSystem.IsLinux())
{
return;
}
var store = new ClientVersionStore(_paths);
ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "linux-case");
await File.WriteAllTextAsync(
Path.Combine(installed.Directory!, "INSTALL.JSON"),
"must-not-be-hidden");
ClientVersionResolution resolution = await new ClientVersionStore(_paths)
.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, resolution.State);
Assert.Contains("unrecorded", resolution.Status, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ExclusiveStartupReclaimsOnlyCanonicalGuidOwnedResidue()
{
var store = new ClientVersionStore(_paths);
Directory.CreateDirectory(store.AppDirectory);
string id = Guid.NewGuid().ToString("N");
string nearId = id[..31] + "g";
string exactStaging = Path.Combine(store.AppDirectory, ".client-staging-" + id);
string exactCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + id);
string exactDownload = Path.Combine(
store.AppDirectory,
".client-download-" + id + ".zip");
string nearStaging = exactStaging + "-user";
string nearCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + nearId);
string nearDownload = Path.Combine(
store.AppDirectory,
".client-download-" + id + ".zip.user");
Directory.CreateDirectory(exactStaging);
Directory.CreateDirectory(exactCorrupt);
Directory.CreateDirectory(nearStaging);
Directory.CreateDirectory(nearCorrupt);
await File.WriteAllTextAsync(exactDownload, "owned");
await File.WriteAllTextAsync(nearDownload, "preserve");
_ = await store.LoadAndRecoverAsync(_rid);
Assert.False(Directory.Exists(exactStaging));
Assert.False(Directory.Exists(exactCorrupt));
Assert.False(File.Exists(exactDownload));
Assert.True(Directory.Exists(nearStaging));
Assert.True(Directory.Exists(nearCorrupt));
Assert.True(File.Exists(nearDownload));
}
private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
private async Task<ClientVersionResolution> PromoteAsync(
ClientVersionStore store,
string versionText,
string marker)
{
byte[] archive = UpdateTestData.ClientZip(_rid, marker);
string zipPath = Path.Combine(_root, $"{versionText}-{marker}.zip");
Directory.CreateDirectory(_root);
await File.WriteAllBytesAsync(zipPath, archive);
string staging = store.CreateClientStagingDirectory(Guid.NewGuid());
IReadOnlyList<ExtractedFileRecord> files = await new SafeZipExtractor()
.ExtractAsync(zipPath, staging);
using UpdateSessionBarrier.ExclusiveLease lease = store.Barrier.AcquireExclusive();
return await store.PromoteAndActivateUnderLeaseAsync(
staging,
LauncherVersion.Parse(versionText),
_rid,
new ReleaseArtifact(
new Uri($"https://example.test/{versionText}.zip"),
UpdateTestData.Sha256(archive),
archive.LongLength),
files);
}
}

View file

@ -0,0 +1,379 @@
using AcDream.Launcher.Core.Updates;
using System.Text.Json.Nodes;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class LauncherSelfUpdateManagerTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-self-update-tests",
"target & $(literal)-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task StartupWithoutPlanReclaimsOnlyExactOwnedResidue()
{
using var harness = new Harness(_root);
string orphan = harness.Manager.GetTransactionDirectory(Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(orphan);
await File.WriteAllTextAsync(Path.Combine(orphan, "partial"), "partial");
Directory.CreateDirectory(harness.Manager.RootDirectory);
string temporary = Path.Combine(
harness.Manager.RootDirectory,
$".pending.json.{Guid.NewGuid():N}.tmp");
string unrelated = Path.Combine(harness.Manager.RootDirectory, "pending.user.tmp");
await File.WriteAllTextAsync(temporary, "partial");
await File.WriteAllTextAsync(unrelated, "preserve");
Assert.Null(await harness.Manager.LoadPendingAsync());
using UpdateSessionBarrier.ExclusiveLease lease =
harness.Manager.Barrier.AcquireExclusive();
Assert.True(harness.Manager.CleanupOwnedResidueUnderLease(
pending: null,
harness.Target,
lease));
Assert.False(Directory.Exists(orphan));
Assert.False(File.Exists(temporary));
Assert.True(File.Exists(unrelated));
}
[Fact]
public async Task VerifiedStageIsDurableAndDoesNotTouchRunningTarget()
{
using var harness = new Harness(_root);
SelfUpdateStageResult result = await harness.StageAsync();
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(
await harness.Manager.LoadPendingAsync());
Assert.Equal("2.0.0", result.Version.Value);
Assert.Equal(SelfUpdatePlanState.Staged, plan.State);
Assert.Null(plan.Apply);
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("new-launcher", await File.ReadAllTextAsync(
Path.Combine(harness.Manager.GetPayloadDirectory(plan.TransactionId), harness.LauncherName)));
string pendingJson = await File.ReadAllTextAsync(result.PendingPlanPath);
Assert.Contains("\"state\": \"staged\"", pendingJson, StringComparison.Ordinal);
Assert.DoesNotContain("\"state\": \"Staged\"", pendingJson, StringComparison.Ordinal);
Assert.DoesNotContain("cmd", pendingJson,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ApplyConfirmAndCompletionUseMoveJournalAndRemoveTransaction()
{
using var harness = new Harness(_root);
_ = await harness.StageAsync();
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, applied.State);
Assert.NotNull(applied.Apply);
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("support-new-launcher", await File.ReadAllTextAsync(harness.SupportPath));
await harness.Manager.ConfirmAsync(
applied.TransactionId,
harness.Target,
harness.LauncherPath);
Assert.True(harness.Manager.IsConfirmed(applied.TransactionId));
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
Assert.False(Directory.Exists(
harness.Manager.GetTransactionDirectory(applied.TransactionId)));
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
}
[Fact]
public async Task AwaitingConfirmationRollbackRestoresEveryOldFileAndCanRetry()
{
using var harness = new Harness(_root);
_ = await harness.StageAsync();
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
SelfUpdatePlan rolledBack = await harness.Manager
.RollbackAwaitingConfirmationAsync(harness.Target);
Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State);
Assert.All(rolledBack.Apply!, entry =>
{
if (entry.HadOriginal)
{
Assert.Matches("^[0-9a-f]{64}$", entry.PriorSha256!);
Assert.NotNull(entry.PriorSize);
Assert.NotNull(entry.PriorUnixMode);
}
});
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target);
Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, retried.State);
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
}
[Fact]
public async Task PriorOwnershipRemovesObsoleteFilesAndRollbackRestoresThem()
{
using var harness = new Harness(_root);
byte[] firstArchive = UpdateTestData.CreateZip(
[
(harness.LauncherName, "launcher-v2"u8.ToArray(), 0x81ED),
("support.dat", "support-v2"u8.ToArray(), 0x81A4),
("obsolete.dll", "obsolete-v2"u8.ToArray(), 0x81A4),
]);
_ = await harness.StageAsync("2.0.0", firstArchive);
SelfUpdatePlan first = await harness.Manager.ApplyPendingAsync(harness.Target);
await harness.Manager.ConfirmAsync(
first.TransactionId,
harness.Target,
harness.LauncherPath);
await harness.Manager.CompleteConfirmedAsync(first.TransactionId, harness.Target);
string obsoletePath = Path.Combine(harness.Target, "obsolete.dll");
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
byte[] secondArchive = UpdateTestData.CreateZip(
[
(harness.LauncherName, "launcher-v3"u8.ToArray(), 0x81ED),
("support.dat", "support-v3"u8.ToArray(), 0x81A4),
]);
_ = await harness.StageAsync("3.0.0", secondArchive);
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
Assert.False(File.Exists(obsoletePath));
Assert.Contains(
applied.Apply!,
entry => entry.Path == "obsolete.dll"
&& entry.Operation == SelfUpdateApplyOperation.Remove
&& entry.HadOriginal);
SelfUpdatePlan rolledBack = await harness.Manager
.RollbackAwaitingConfirmationAsync(harness.Target);
Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State);
Assert.Equal("launcher-v2", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath));
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target);
await harness.Manager.ConfirmAsync(
retried.TransactionId,
harness.Target,
harness.LauncherPath);
await harness.Manager.CompleteConfirmedAsync(retried.TransactionId, harness.Target);
Assert.False(File.Exists(obsoletePath));
string ownership = await File.ReadAllTextAsync(Path.Combine(
harness.Target,
LauncherSelfUpdateManager.InstallRecordFileName));
Assert.DoesNotContain("obsolete.dll", ownership, StringComparison.Ordinal);
}
[Fact]
public async Task ApplyFailpointAfterCanonicalAtomicReplaceLeavesVerifiedRollbackReceipt()
{
using var harness = new Harness(_root);
_ = await harness.StageAsync();
LauncherSelfUpdateManager faulting = harness.CreateManagerWithObserver(observation =>
{
if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
&& string.Equals(
observation.Path,
harness.LauncherName,
StringComparison.Ordinal))
{
Assert.True(File.Exists(harness.LauncherPath));
throw new InvalidOperationException("failpoint");
}
});
InvalidOperationException failure = await Assert.ThrowsAsync<InvalidOperationException>(
() => faulting.ApplyPendingAsync(harness.Target));
SelfUpdatePlan recovered = Assert.IsType<SelfUpdatePlan>(
await harness.Manager.LoadPendingAsync());
Assert.Equal("failpoint", failure.Message);
Assert.Equal(SelfUpdatePlanState.RolledBack, recovered.State);
await harness.Manager.VerifyRestoredPriorAsync(harness.Target);
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine(
harness.Manager.GetPayloadDirectory(recovered.TransactionId),
harness.LauncherName)));
}
[Fact]
public async Task ConditionalPriorIntegrityFieldsAreStrictAndFailClosed()
{
using var harness = new Harness(_root);
_ = await harness.StageAsync();
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
SelfUpdateApplyEntry canonical = Assert.Single(
applied.Apply!,
entry => entry.Path == harness.LauncherName);
Assert.True(canonical.HadOriginal);
Assert.Matches("^[0-9a-f]{64}$", canonical.PriorSha256!);
Assert.NotNull(canonical.PriorSize);
Assert.NotNull(canonical.PriorUnixMode);
Assert.Matches("^[0-9a-f]{64}$", canonical.ReplacementSha256!);
JsonObject document = Assert.IsType<JsonObject>(JsonNode.Parse(
await File.ReadAllTextAsync(harness.Manager.PendingPlanPath)));
JsonArray apply = Assert.IsType<JsonArray>(document["apply"]);
JsonObject canonicalNode = Assert.IsType<JsonObject>(apply.Single(node =>
string.Equals(
node?["path"]?.GetValue<string>(),
harness.LauncherName,
StringComparison.Ordinal)));
canonicalNode["priorSha256"] = null;
await File.WriteAllTextAsync(
harness.Manager.PendingPlanPath,
document.ToJsonString());
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
harness.Manager.LoadPendingAsync());
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.True(Directory.Exists(
harness.Manager.GetTargetTransactionDirectory(applied)));
}
[Fact]
public async Task CorruptPayloadWrongTargetAndUnknownPlanFieldFailClosed()
{
using var harness = new Harness(_root);
_ = await harness.StageAsync();
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>(
await harness.Manager.LoadPendingAsync());
await File.WriteAllTextAsync(
Path.Combine(
harness.Manager.GetPayloadDirectory(staged.TransactionId),
harness.LauncherName),
"bad-payload!");
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
harness.Manager.ApplyPendingAsync(harness.Target));
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
string wrongTarget = Path.Combine(_root, "other-target");
Directory.CreateDirectory(wrongTarget);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
harness.Manager.ApplyPendingAsync(wrongTarget));
string json = await File.ReadAllTextAsync(harness.Manager.PendingPlanPath);
await File.WriteAllTextAsync(
harness.Manager.PendingPlanPath,
json.TrimEnd().TrimEnd('}') + ",\"unknown\":true}");
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
harness.Manager.LoadPendingAsync());
}
[Fact]
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
{
using var harness = new Harness(_root);
SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync(
["--literal", "argument with spaces & metacharacters"],
harness.Manager,
harness.Target,
harness.LauncherPath);
Assert.False(ordinary.ShouldExit);
Assert.Equal(["--literal", "argument with spaces & metacharacters"],
ordinary.RemainingArguments);
_ = await harness.StageAsync();
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
[LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId],
harness.Manager,
harness.Target,
harness.LauncherPath);
Assert.False(confirmation.ShouldExit);
Assert.Empty(confirmation.RemainingArguments);
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
}
private sealed class Harness : IDisposable
{
private readonly LocalHttpFixture _server = new();
private readonly HttpClient _http = new();
private readonly byte[] _archive;
private readonly ReleaseArtifact _artifact;
private readonly string _root;
public Harness(string root)
{
_root = root;
Target = Path.Combine(root, "published launcher");
Directory.CreateDirectory(Target);
Rid = LauncherRuntimeIdentity.DetectRid();
LauncherName = "acdream-launcher"
+ (Rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
LauncherPath = Path.Combine(Target, LauncherName);
SupportPath = Path.Combine(Target, "support.dat");
File.WriteAllText(LauncherPath, "old-launcher");
File.WriteAllText(SupportPath, "old-support");
_archive = UpdateTestData.LauncherZip(Rid, "new-launcher");
_server.Add("launcher.zip", _archive);
_artifact = new ReleaseArtifact(
_server.UriFor("launcher.zip"),
UpdateTestData.Sha256(_archive),
_archive.LongLength);
Manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(root), _http);
}
public string Target { get; }
public string Rid { get; }
public string LauncherName { get; }
public string LauncherPath { get; }
public string SupportPath { get; }
public LauncherSelfUpdateManager Manager { get; }
public Task<SelfUpdateStageResult> StageAsync() => Manager.StageAsync(
LauncherVersion.Parse("2.0.0"),
Rid,
_artifact,
Target,
progress: null,
CancellationToken.None);
public Task<SelfUpdateStageResult> StageAsync(string version, byte[] archive)
{
_server.Add("launcher.zip", archive);
return Manager.StageAsync(
LauncherVersion.Parse(version),
Rid,
new ReleaseArtifact(
_server.UriFor("launcher.zip"),
UpdateTestData.Sha256(archive),
archive.LongLength),
Target,
progress: null,
CancellationToken.None);
}
public LauncherSelfUpdateManager CreateManagerWithObserver(
Action<SelfUpdateApplyObservation> observer) =>
new(UpdateTestData.Paths(_root), _http, null, observer);
public void Dispose()
{
_http.Dispose();
_server.Dispose();
}
}
}

View file

@ -0,0 +1,673 @@
using System.Diagnostics;
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class LauncherSelfUpdateProcessTests : IDisposable
{
private const string FixtureBaseName =
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder";
private const string DataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA";
private const string TargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET";
private const string HelperPidEnvironment =
"ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID";
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-self-update-process-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically()
{
string data = Path.Combine(_root, "data");
string target = Path.Combine(_root, "launcher");
string ready = Path.Combine(_root, "crash.ready");
string launched = Path.Combine(_root, "replacement.ready");
string helperPidPath = Path.Combine(_root, "helper.pid");
Directory.CreateDirectory(_root);
string rid = LauncherRuntimeIdentity.DetectRid();
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
string oldHash = await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath);
using var server = new LocalHttpFixture();
server.Add("launcher.zip", prepared.NewArchive);
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
SelfUpdateStageResult staged = await manager.StageAsync(
LauncherVersion.Parse("2.0.0"),
rid,
new ReleaseArtifact(
server.UriFor("launcher.zip"),
UpdateTestData.Sha256(prepared.NewArchive),
prepared.NewArchive.LongLength),
target,
progress: null,
CancellationToken.None);
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
using Process crash = StartFixture(
["crash-self-update", data, target, ready, prepared.CanonicalName]);
try
{
await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20));
Assert.True(File.Exists(prepared.CanonicalPath));
crash.Kill(entireProcessTree: true);
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Assert.True(File.Exists(prepared.CanonicalPath));
string boundaryHash = await FileIntegrity.ComputeSha256HexAsync(
prepared.CanonicalPath);
Assert.Contains(boundaryHash, new[] { oldHash, prepared.NewCanonicalHash });
var environment = new Dictionary<string, string>
{
[DataEnvironment] = data,
[TargetEnvironment] = target,
[HelperPidEnvironment] = helperPidPath,
};
using Process canonical = StartProcess(
prepared.CanonicalPath,
["canonical-probe", launched],
environment);
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
Assert.Equal(0, canonical.ExitCode);
await WaitForFileAsync(launched, process: null, TimeSpan.FromSeconds(30));
await WaitUntilAsync(
() => !File.Exists(manager.PendingPlanPath),
TimeSpan.FromSeconds(30),
"The self-update journal did not converge.");
Assert.Equal(
prepared.NewCanonicalHash,
await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath));
Assert.True(File.Exists(Path.Combine(
target,
LauncherSelfUpdateManager.InstallRecordFileName)));
Assert.False(Directory.Exists(manager.GetTransactionDirectory(
plan.TransactionId)));
Assert.Empty(Directory.EnumerateDirectories(
target,
".acdream-self-update-*",
SearchOption.TopDirectoryOnly));
Assert.Null(await manager.LoadPendingAsync());
int replacementPid = ParsePid(await File.ReadAllTextAsync(launched));
int helperPid = int.Parse(
await File.ReadAllTextAsync(helperPidPath),
System.Globalization.CultureInfo.InvariantCulture);
await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10));
await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10));
if (OperatingSystem.IsLinux())
{
Assert.True(
(File.GetUnixFileMode(prepared.CanonicalPath)
& (UnixFileMode.UserExecute
| UnixFileMode.GroupExecute
| UnixFileMode.OtherExecute)) != 0);
}
}
finally
{
if (!crash.HasExited)
{
crash.Kill(entireProcessTree: true);
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
}
}
}
[Fact]
public async Task CorruptBackupAfterCanonicalCrashNeverLaunchesAndPreservesEvidence()
{
CrashedUpdate crashed = await PrepareKilledAfterCanonicalReplaceAsync();
string backupPath = Path.Combine(
crashed.Manager.GetTargetTransactionDirectory(crashed.Plan),
"backup",
crashed.Prepared.CanonicalName);
Assert.True(File.Exists(backupPath));
await File.WriteAllTextAsync(backupPath, "tampered rollback backup");
string tamperedHash = await FileIntegrity.ComputeSha256HexAsync(backupPath);
string launched = Path.Combine(_root, "corrupt-backup-launched");
string helperPidPath = Path.Combine(_root, "corrupt-backup-helper.pid");
using Process canonical = StartProcess(
crashed.Prepared.CanonicalPath,
["canonical-probe", launched],
BootstrapEnvironment(crashed, helperPidPath));
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
Assert.Equal(0, canonical.ExitCode);
await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
await WaitForProcessExitAsync(
int.Parse(
await File.ReadAllTextAsync(helperPidPath),
System.Globalization.CultureInfo.InvariantCulture),
TimeSpan.FromSeconds(20));
Assert.False(File.Exists(launched));
SelfUpdatePlan preserved = Assert.IsType<SelfUpdatePlan>(
await crashed.Manager.LoadPendingAsync());
Assert.Equal(SelfUpdatePlanState.Applying, preserved.State);
Assert.Equal(crashed.Plan.TransactionId, preserved.TransactionId);
Assert.True(Directory.Exists(
crashed.Manager.GetTargetTransactionDirectory(crashed.Plan)));
Assert.Equal(tamperedHash, await FileIntegrity.ComputeSha256HexAsync(backupPath));
Assert.Equal(
crashed.Prepared.NewCanonicalHash,
await FileIntegrity.ComputeSha256HexAsync(crashed.Prepared.CanonicalPath));
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
crashed.Manager.VerifyRestoredPriorAsync(crashed.Target));
}
[Fact]
public async Task BackupJunctionOrSymlinkAfterCanonicalCrashCannotMutateOutsideOrLaunch()
{
CrashedUpdate crashed = await PrepareKilledAfterCanonicalReplaceAsync();
string swap = crashed.Manager.GetTargetTransactionDirectory(crashed.Plan);
string backup = Path.Combine(swap, "backup");
string preservedBackup = Path.Combine(_root, "preserved-backup");
string outside = Path.Combine(_root, "outside-backup");
Directory.Move(backup, preservedBackup);
CopyDirectory(preservedBackup, outside);
string outsideCanonical = Path.Combine(
outside,
crashed.Prepared.CanonicalName);
string outsideHash = await FileIntegrity.ComputeSha256HexAsync(outsideCanonical);
CreateDirectoryLink(backup, outside);
string launched = Path.Combine(_root, "reparse-backup-launched");
string helperPidPath = Path.Combine(_root, "reparse-backup-helper.pid");
try
{
using Process canonical = StartProcess(
crashed.Prepared.CanonicalPath,
["canonical-probe", launched],
BootstrapEnvironment(crashed, helperPidPath));
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
Assert.Equal(0, canonical.ExitCode);
await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
await WaitForProcessExitAsync(
int.Parse(
await File.ReadAllTextAsync(helperPidPath),
System.Globalization.CultureInfo.InvariantCulture),
TimeSpan.FromSeconds(20));
Assert.False(File.Exists(launched));
Assert.True(File.Exists(outsideCanonical));
Assert.Equal(
outsideHash,
await FileIntegrity.ComputeSha256HexAsync(outsideCanonical));
Assert.True(
(File.GetAttributes(backup) & FileAttributes.ReparsePoint) != 0);
SelfUpdatePlan preserved = Assert.IsType<SelfUpdatePlan>(
await crashed.Manager.LoadPendingAsync());
Assert.Equal(SelfUpdatePlanState.Applying, preserved.State);
Assert.Equal(
crashed.Prepared.NewCanonicalHash,
await FileIntegrity.ComputeSha256HexAsync(
crashed.Prepared.CanonicalPath));
}
finally
{
try
{
if ((File.GetAttributes(backup) & FileAttributes.ReparsePoint) != 0)
{
Directory.Delete(backup);
}
}
catch (FileNotFoundException)
{
// The assertion above reports an unexpected missing link.
}
catch (DirectoryNotFoundException)
{
// The assertion above reports an unexpected missing link.
}
}
}
[Fact]
public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction()
{
string data = Path.Combine(_root, "data");
string target = Path.Combine(_root, "launcher");
string resultPath = Path.Combine(_root, "startup.result");
Directory.CreateDirectory(target);
string rid = LauncherRuntimeIdentity.DetectRid();
string canonicalName = LauncherName(rid);
string canonical = Path.Combine(target, canonicalName);
await File.WriteAllTextAsync(canonical, "running launcher");
byte[] archive = UpdateTestData.LauncherZip(
rid,
new string('s', 2 * 1024 * 1024));
using var server = new LocalHttpFixture();
server.Add(
"slow-launcher.zip",
archive,
chunkSize: 4096,
chunkDelay: TimeSpan.FromMilliseconds(2));
var observer = new LauncherSelfUpdateManager(
UpdateTestData.Paths(_root),
new HttpClient());
using Process staging = StartFixture(
[
"stage-self-update",
data,
target,
"2.0.0",
rid,
server.UriFor("slow-launcher.zip").AbsoluteUri,
UpdateTestData.Sha256(archive),
archive.LongLength.ToString(System.Globalization.CultureInfo.InvariantCulture),
]);
try
{
await WaitUntilAsync(
() => Directory.Exists(observer.TransactionsDirectory)
&& Directory.EnumerateDirectories(observer.TransactionsDirectory).Any(),
TimeSpan.FromSeconds(10),
"The slow staging transaction did not become visible.",
staging);
Assert.False(File.Exists(observer.PendingPlanPath));
string transaction = Assert.Single(
Directory.EnumerateDirectories(observer.TransactionsDirectory));
using Process startup = StartFixture(
["bootstrap-probe", data, target, canonical, resultPath]);
await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(0, startup.ExitCode);
Assert.Equal("ordinary", await File.ReadAllTextAsync(resultPath));
Assert.True(Directory.Exists(transaction));
Assert.False(File.Exists(observer.PendingPlanPath));
await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30));
Assert.Equal(0, staging.ExitCode);
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(
await observer.LoadPendingAsync());
Assert.Equal(SelfUpdatePlanState.Staged, plan.State);
Assert.True(Directory.Exists(transaction));
}
finally
{
if (!staging.HasExited)
{
staging.Kill(entireProcessTree: true);
await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
}
}
}
[Fact]
public async Task HelperDefersWithoutRestartWhenSharedSessionLeaseAppears()
{
string data = Path.Combine(_root, "data");
string target = Path.Combine(_root, "launcher");
string unexpectedLaunch = Path.Combine(_root, "unexpected-launch");
string helperPid = Path.Combine(_root, "helper.pid");
Directory.CreateDirectory(target);
string rid = LauncherRuntimeIdentity.DetectRid();
string canonical = Path.Combine(target, LauncherName(rid));
await File.WriteAllTextAsync(canonical, "old-launcher");
byte[] archive = UpdateTestData.LauncherZip(rid, "new-launcher");
using var server = new LocalHttpFixture();
server.Add("launcher.zip", archive);
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
_ = await manager.StageAsync(
LauncherVersion.Parse("2.0.0"),
rid,
new ReleaseArtifact(
server.UriFor("launcher.zip"),
UpdateTestData.Sha256(archive),
archive.LongLength),
target,
progress: null,
CancellationToken.None);
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
using UpdateSessionBarrier.SessionLease session = manager.Barrier.AcquireSession();
var environment = new Dictionary<string, string>
{
[DataEnvironment] = data,
[TargetEnvironment] = target,
[HelperPidEnvironment] = helperPid,
};
using Process helper = StartFixture(
[
LauncherSelfUpdateBootstrap.HelperArgument,
int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
target,
plan.TransactionId,
"canonical-probe",
unexpectedLaunch,
], environment);
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode);
Assert.True(File.Exists(helperPid));
Assert.False(File.Exists(unexpectedLaunch));
Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical));
SelfUpdatePlan deferred = Assert.IsType<SelfUpdatePlan>(
await manager.LoadPendingAsync());
Assert.Equal(SelfUpdatePlanState.Staged, deferred.State);
Assert.Equal(plan.TransactionId, deferred.TransactionId);
}
private PreparedLauncher PrepareLauncherClosure(string target, string rid)
{
string fixtureDirectory = GetFixtureDirectory();
string fixtureAppHost = Path.Combine(
fixtureDirectory,
FixtureBaseName + (OperatingSystem.IsWindows() ? ".exe" : string.Empty));
Assert.True(File.Exists(fixtureAppHost), $"Missing fixture apphost: {fixtureAppHost}");
Directory.CreateDirectory(target);
string canonicalName = LauncherName(rid);
string canonicalPath = Path.Combine(target, canonicalName);
var archiveEntries = new List<(string Name, byte[] Content, int? UnixAttributes)>();
foreach (string source in Directory.EnumerateFiles(
fixtureDirectory,
"*",
SearchOption.TopDirectoryOnly))
{
string sourceName = Path.GetFileName(source);
bool isAppHost = PathsEqual(source, fixtureAppHost);
string targetName = isAppHost ? canonicalName : sourceName;
byte[] oldContent = File.ReadAllBytes(source);
byte[] newContent = oldContent;
if (isAppHost)
{
oldContent = [.. oldContent, .. "-old"u8.ToArray()];
newContent = [.. newContent, .. "-new"u8.ToArray()];
}
string targetPath = Path.Combine(target, targetName);
File.WriteAllBytes(targetPath, oldContent);
int unixAttributes = 0x81A4;
if (OperatingSystem.IsLinux())
{
UnixFileMode mode = File.GetUnixFileMode(source);
if (isAppHost)
{
mode |= UnixFileMode.UserExecute;
}
File.SetUnixFileMode(targetPath, mode);
unixAttributes = 0x8000 | (int)mode;
}
else if (isAppHost)
{
unixAttributes = 0x81ED;
}
archiveEntries.Add((targetName, newContent, unixAttributes));
}
byte[] archive = UpdateTestData.CreateZip(archiveEntries);
byte[] newCanonical = Assert.Single(
archiveEntries,
entry => entry.Name == canonicalName).Content;
return new PreparedLauncher(
canonicalName,
canonicalPath,
archive,
UpdateTestData.Sha256(newCanonical));
}
private async Task<CrashedUpdate> PrepareKilledAfterCanonicalReplaceAsync()
{
string data = Path.Combine(_root, "data");
string target = Path.Combine(_root, "launcher");
string ready = Path.Combine(_root, "crash.ready");
Directory.CreateDirectory(_root);
string rid = LauncherRuntimeIdentity.DetectRid();
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
using var server = new LocalHttpFixture();
server.Add("launcher.zip", prepared.NewArchive);
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
_ = await manager.StageAsync(
LauncherVersion.Parse("2.0.0"),
rid,
new ReleaseArtifact(
server.UriFor("launcher.zip"),
UpdateTestData.Sha256(prepared.NewArchive),
prepared.NewArchive.LongLength),
target,
progress: null,
CancellationToken.None);
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
using Process crash = StartFixture(
["crash-self-update", data, target, ready, prepared.CanonicalName]);
await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20));
crash.Kill(entireProcessTree: true);
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(SelfUpdatePlanState.Applying,
Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync()).State);
return new CrashedUpdate(data, target, manager, plan, prepared);
}
private static Dictionary<string, string> BootstrapEnvironment(
CrashedUpdate crashed,
string helperPidPath) => new()
{
[DataEnvironment] = crashed.Data,
[TargetEnvironment] = crashed.Target,
[HelperPidEnvironment] = helperPidPath,
};
private static void CopyDirectory(string source, string destination)
{
Directory.CreateDirectory(destination);
foreach (string directory in Directory.EnumerateDirectories(
source,
"*",
SearchOption.AllDirectories))
{
Directory.CreateDirectory(Path.Combine(
destination,
Path.GetRelativePath(source, directory)));
}
foreach (string file in Directory.EnumerateFiles(
source,
"*",
SearchOption.AllDirectories))
{
string target = Path.Combine(destination, Path.GetRelativePath(source, file));
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
File.Copy(file, target);
if (OperatingSystem.IsLinux())
{
File.SetUnixFileMode(target, File.GetUnixFileMode(file));
}
}
}
private static void CreateDirectoryLink(string link, string target)
{
if (!OperatingSystem.IsWindows())
{
Directory.CreateSymbolicLink(link, target);
return;
}
var start = new ProcessStartInfo("cmd.exe")
{
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
start.ArgumentList.Add("/d");
start.ArgumentList.Add("/c");
start.ArgumentList.Add("mklink");
start.ArgumentList.Add("/J");
start.ArgumentList.Add(link);
start.ArgumentList.Add(target);
using Process process = Process.Start(start)
?? throw new InvalidOperationException("Could not create the test junction.");
process.WaitForExit();
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
"Could not create the test junction: "
+ process.StandardError.ReadToEnd()
+ process.StandardOutput.ReadToEnd());
}
}
private static Process StartFixture(
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string>? environment = null) =>
StartProcess("dotnet", [GetFixtureDllPath(), .. arguments], environment);
private static Process StartProcess(
string executable,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string>? environment = null)
{
var start = new ProcessStartInfo(executable)
{
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
foreach (string argument in arguments)
{
start.ArgumentList.Add(argument);
}
if (environment is not null)
{
foreach ((string name, string value) in environment)
{
start.Environment[name] = value;
}
}
return Process.Start(start)
?? throw new InvalidOperationException($"Could not start '{executable}'.");
}
private static async Task WaitForFileAsync(
string path,
Process? process,
TimeSpan timeout) =>
await WaitUntilAsync(
() => File.Exists(path),
timeout,
$"Timed out waiting for '{path}'.",
process);
private static async Task WaitUntilAsync(
Func<bool> condition,
TimeSpan timeout,
string failure,
Process? process = null)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout;
while (!condition())
{
if (process?.HasExited == true)
{
throw new InvalidOperationException(
$"{failure} Process exited {process.ExitCode}. stdout: "
+ await process.StandardOutput.ReadToEndAsync()
+ " stderr: "
+ await process.StandardError.ReadToEndAsync());
}
if (DateTimeOffset.UtcNow >= deadline)
{
throw new TimeoutException(failure);
}
await Task.Delay(20);
}
}
private static int ParsePid(string marker)
{
string value = marker.Split('|', 2)[0];
return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
}
private static async Task WaitForProcessExitAsync(int pid, TimeSpan timeout)
{
try
{
using Process process = Process.GetProcessById(pid);
await process.WaitForExitAsync().WaitAsync(timeout);
}
catch (ArgumentException)
{
// It exited before the test opened the process handle.
}
}
private static string LauncherName(string rid) =>
"acdream-launcher"
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
private static string GetFixtureDllPath() =>
Path.Combine(GetFixtureDirectory(), FixtureBaseName + ".dll");
private static string GetFixtureDirectory()
{
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent?.Name ?? "Release";
return Path.Combine(
FindRepositoryRoot(),
"tests",
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder",
"bin",
configuration,
"net10.0");
}
private static string FindRepositoryRoot()
{
for (var directory = new DirectoryInfo(AppContext.BaseDirectory);
directory is not null;
directory = directory.Parent)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
{
return directory.FullName;
}
}
throw new InvalidOperationException("Repository root was not found.");
}
private static bool PathsEqual(string left, string right) => string.Equals(
Path.GetFullPath(left),
Path.GetFullPath(right),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
private sealed record PreparedLauncher(
string CanonicalName,
string CanonicalPath,
byte[] NewArchive,
string NewCanonicalHash);
private sealed record CrashedUpdate(
string Data,
string Target,
LauncherSelfUpdateManager Manager,
SelfUpdatePlan Plan,
PreparedLauncher Prepared);
}

View file

@ -0,0 +1,215 @@
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class LauncherUpdaterIntegrationTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-updater-integration-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _rid = LauncherRuntimeIdentity.DetectRid();
public LauncherUpdaterIntegrationTests() => _paths = UpdateTestData.Paths(_root);
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task LocalHttpManifestDownloadExtractPromotionAndNextCheckAreCoherent()
{
using var server = new LocalHttpFixture();
byte[] client = UpdateTestData.ClientZip(_rid, "release-2");
byte[] launcher = UpdateTestData.LauncherZip(_rid, "release-2");
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
using var http = new HttpClient();
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater(
source,
http,
versions,
new LauncherSelfUpdateManager(_paths, http),
LauncherVersion.Parse("1.0.0"),
_rid,
Path.Combine(_root, "launcher"));
_ = await updater.InitializeAsync();
var progress = new List<LauncherUpdateProgress>();
LauncherUpdateCheckResult check = await updater.CheckAsync();
ClientVersionResolution installed = await updater.InstallClientAsync(
check,
new ImmediateProgress(progress.Add));
LauncherUpdateCheckResult after = await updater.CheckAsync();
Assert.True(check.IsClientUpdateAvailable);
Assert.True(check.IsLauncherUpdateAvailable);
Assert.True(check.IsLauncherMinimumSatisfied);
Assert.True(installed.IsVerified);
Assert.Equal("2.0.0", installed.Version!.Value);
Assert.False(after.IsClientUpdateAvailable);
Assert.True(after.IsLauncherUpdateAvailable);
Assert.Contains(progress, item => item.Phase == LauncherUpdatePhase.DownloadingClient);
Assert.Contains(progress, item => item.Phase == LauncherUpdatePhase.ExtractingClient);
Assert.Contains(progress, item => item.Phase == LauncherUpdatePhase.ActivatingClient);
Assert.Equal(LauncherUpdatePhase.Completed, progress[^1].Phase);
Assert.Empty(Directory.EnumerateFiles(
versions.AppDirectory,
".client-download-*",
SearchOption.TopDirectoryOnly));
}
[Fact]
public async Task MinimumLauncherGateAndMissingRidFailBeforePublication()
{
using var server = new LocalHttpFixture();
byte[] client = UpdateTestData.ClientZip(_rid);
byte[] launcher = UpdateTestData.LauncherZip(_rid);
ConfigureRelease(server, "3.0.0", "2.0.0", _rid, client, launcher);
using var http = new HttpClient();
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater(
source,
http,
versions,
new LauncherSelfUpdateManager(_paths, http),
LauncherVersion.Parse("1.0.0"),
_rid,
Path.Combine(_root, "launcher"));
_ = await updater.InitializeAsync();
LauncherUpdateCheckResult check = await updater.CheckAsync();
Assert.False(check.IsLauncherMinimumSatisfied);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
updater.InstallClientAsync(check));
Assert.False(File.Exists(versions.CurrentPointerPath));
string otherRid = _rid == "win-x64" ? "linux-x64" : "win-x64";
ConfigureRelease(server, "3.0.0", "1.0.0", otherRid,
UpdateTestData.ClientZip(otherRid), UpdateTestData.LauncherZip(otherRid));
LauncherUpdateException missingRid = await Assert.ThrowsAsync<LauncherUpdateException>(
() => updater.CheckAsync());
Assert.Contains(_rid, missingRid.Message, StringComparison.Ordinal);
}
[Fact]
public async Task RunningPredicateAndCrossProcessBarrierRefuseUpdateWithoutNetworkMutation()
{
using var server = new LocalHttpFixture();
byte[] client = UpdateTestData.ClientZip(_rid);
byte[] launcher = UpdateTestData.LauncherZip(_rid);
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
using var http = new HttpClient();
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
bool running = true;
var updater = new LauncherUpdater(
source,
http,
versions,
new LauncherSelfUpdateManager(_paths, http),
LauncherVersion.Parse("1.0.0"),
_rid,
Path.Combine(_root, "launcher"),
() => running);
_ = await updater.InitializeAsync();
LauncherUpdateCheckResult check = await updater.CheckAsync();
LauncherUpdateException local = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
updater.InstallClientAsync(check));
Assert.Contains("Stop every", local.Message, StringComparison.Ordinal);
Assert.False(File.Exists(versions.CurrentPointerPath));
running = false;
using UpdateSessionBarrier.SessionLease session = versions.Barrier.AcquireSession();
LauncherUpdateException shared = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
updater.InstallClientAsync(check));
Assert.Contains("session", shared.Message, StringComparison.OrdinalIgnoreCase);
Assert.False(File.Exists(versions.CurrentPointerPath));
}
[Fact]
public async Task CancelledSlowClientDownloadLeavesNoPointerOrStaging()
{
using var server = new LocalHttpFixture();
byte[] client = UpdateTestData.ClientZip(_rid, new string('x', 1_000_000));
byte[] launcher = UpdateTestData.LauncherZip(_rid);
server.Add("client.zip", client, chunkSize: 1024, chunkDelay: TimeSpan.FromMilliseconds(2));
server.Add("launcher.zip", launcher);
server.Add(
"manifest.json",
UpdateTestData.Manifest(
"2.0.0",
"1.0.0",
_rid,
server.UriFor("client.zip"),
client,
server.UriFor("launcher.zip"),
launcher),
contentType: "application/json");
using var http = new HttpClient();
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater(
source,
http,
versions,
new LauncherSelfUpdateManager(_paths, http),
LauncherVersion.Parse("1.0.0"),
_rid,
Path.Combine(_root, "launcher"));
_ = await updater.InitializeAsync();
LauncherUpdateCheckResult check = await updater.CheckAsync();
using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(30));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
updater.InstallClientAsync(check, cancellationToken: cancellation.Token));
Assert.False(File.Exists(versions.CurrentPointerPath));
Assert.Empty(Directory.EnumerateFileSystemEntries(
versions.AppDirectory,
".client-*",
SearchOption.TopDirectoryOnly));
}
private static void ConfigureRelease(
LocalHttpFixture server,
string version,
string minimum,
string rid,
byte[] client,
byte[] launcher)
{
server.Add("client.zip", client);
server.Add("launcher.zip", launcher);
server.Add(
"manifest.json",
UpdateTestData.Manifest(
version,
minimum,
rid,
server.UriFor("client.zip"),
client,
server.UriFor("launcher.zip"),
launcher),
contentType: "application/json");
}
private sealed class ImmediateProgress(Action<LauncherUpdateProgress> callback)
: IProgress<LauncherUpdateProgress>
{
public void Report(LauncherUpdateProgress value) => callback(value);
}
}

View file

@ -0,0 +1,373 @@
using System.Net;
using System.Text;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class LauncherVersionTests
{
[Theory]
[InlineData("1.0.0-alpha", "1.0.0-alpha.1")]
[InlineData("1.0.0-alpha.1", "1.0.0-alpha.beta")]
[InlineData("1.0.0-beta.11", "1.0.0-rc.1")]
[InlineData("1.0.0-rc.1", "1.0.0")]
[InlineData("1.9.999999999999999999999", "1.10.0")]
[InlineData("999999999999999999999.0.0", "1000000000000000000000.0.0")]
public void StrictSemVerOrdersWithoutNumericOverflow(string lower, string higher)
{
LauncherVersion left = LauncherVersion.Parse(lower);
LauncherVersion right = LauncherVersion.Parse(higher);
Assert.True(left < right);
Assert.True(right > left);
Assert.Equal(0, LauncherVersion.Parse(higher + "+build.7").CompareTo(right));
}
[Theory]
[InlineData("")]
[InlineData(" 1.0.0")]
[InlineData("1.0")]
[InlineData("01.0.0")]
[InlineData("1.0.0-01")]
[InlineData("1.0.0-")]
[InlineData("1.0.0+")]
[InlineData("v1.0.0")]
public void StrictSemVerRejectsAmbiguousVersions(string value) =>
Assert.False(LauncherVersion.TryParse(value, out _));
[Fact]
public void StrictSemVerIsBoundedAgainstManifestPathAmplification() =>
Assert.False(LauncherVersion.TryParse(
"1.0.0+" + new string('a', 129),
out _));
}
public sealed class ReleaseManifestClientTests
{
[Fact]
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
{
using var server = new LocalHttpFixture();
byte[] client = UpdateTestData.ClientZip("win-x64");
byte[] launcher = UpdateTestData.LauncherZip("win-x64");
server.Add("client.zip", client);
server.Add("launcher.zip", launcher);
server.Add(
"manifest.json",
UpdateTestData.Manifest(
"2.1.0",
"1.5.0",
"win-x64",
server.UriFor("client.zip"),
client,
server.UriFor("launcher.zip"),
launcher),
contentType: "application/json");
using var http = new HttpClient();
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
ReleaseManifest manifest = await source.FetchAsync();
Assert.Equal("2.1.0", manifest.Version.Value);
Assert.Equal(client.LongLength, manifest.RequireClient("win-x64").Size);
Assert.Equal("eriknihlen", ReleaseManifestClient.GitHubOwner);
Assert.Equal("acdream", ReleaseManifestClient.GitHubRepository);
Assert.Equal(
"https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json",
ReleaseManifestClient.ProductionManifestUri.AbsoluteUri);
}
[Theory]
[MemberData(nameof(InvalidManifests))]
public void RejectsWrongVersionRidHashSizeMinimumAndUnknownOrDuplicateFields(
string json)
{
Assert.Throws<LauncherUpdateException>(() =>
ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json)));
}
public static TheoryData<string> InvalidManifests => new()
{
"{}",
ValidJson().Replace("\"schemaVersion\":1", "\"schemaVersion\":2"),
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"02.0.0\""),
ValidJson().Replace("\"minimumLauncherVersion\":\"1.0.0\"", "\"minimumLauncherVersion\":\"3.0.0\""),
ValidJson().Replace("win-x64", "WIN_X64"),
ValidJson().Replace(new string('a', 64), "1234"),
ValidJson().Replace("\"size\":12", "\"size\":0"),
ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"),
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""),
ValidJson().Replace("https://example.test/client", "http://example.test/client"),
};
[Theory]
[InlineData("clients", "client")]
[InlineData("launchers", "launcher")]
public void ProductionManifestRejectsLoopbackHttpArtifacts(
string section,
string artifact)
{
string json = ValidJson().Replace(
$"https://example.test/{artifact}",
$"http://127.0.0.1/{artifact}",
StringComparison.Ordinal);
LauncherUpdateException error = Assert.Throws<LauncherUpdateException>(() =>
ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json)));
Assert.Contains(section, error.Message, StringComparison.Ordinal);
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
}
[Fact]
public async Task ProductionRedirectToLoopbackIsRejectedBeforePlaintextRequest()
{
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.Found,
new Uri("http://127.0.0.1/manifest.json")));
using var source = ReleaseManifestClient.CreateForTransportTest(
ReleaseManifestClient.ProductionManifestUri,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
Assert.Equal([ReleaseManifestClient.ProductionManifestUri], handler.Requests);
}
[Fact]
public async Task HttpsRedirectDowngradeIsRejectedBeforeIntermediateHop()
{
var start = new Uri("https://example.test/start");
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.TemporaryRedirect,
new Uri("http://example.test/plaintext-hop")));
using var source = ReleaseManifestClient.CreateForTransportTest(
start,
allowLoopbackHttp: false,
handler);
await Assert.ThrowsAsync<LauncherUpdateException>(() => source.FetchAsync());
Assert.Equal([start], handler.Requests);
}
[Fact]
public async Task RedirectLoopIsRejectedWithoutRepeatingARequest()
{
var first = new Uri("https://example.test/first");
var second = new Uri("https://example.test/second");
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.PermanentRedirect,
request.RequestUri == first ? second : first));
using var source = ReleaseManifestClient.CreateForTransportTest(
first,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("loop", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal([first, second], handler.Requests);
}
[Fact]
public async Task RedirectLimitRejectsBeforeRequestingTheSixthHop()
{
var start = new Uri("https://example.test/hop-0");
var handler = new SequenceHandler((_, index) => Redirect(
HttpStatusCode.Found,
new Uri($"https://example.test/hop-{index + 1}")));
using var source = ReleaseManifestClient.CreateForTransportTest(
start,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("5 redirects", error.Message, StringComparison.Ordinal);
Assert.Equal(6, handler.Requests.Count);
Assert.Equal(new Uri("https://example.test/hop-5"), handler.Requests[^1]);
}
private static string ValidJson() =>
$$$$"""
{"schemaVersion":1,"version":"2.0.0","minimumLauncherVersion":"1.0.0","clients":{"win-x64":{"url":"https://example.test/client","sha256":"{{{{new string('a', 64)}}}}","size":12}},"launchers":{"win-x64":{"url":"https://example.test/launcher","sha256":"{{{{new string('b', 64)}}}}","size":12}}}
""";
private static HttpResponseMessage Redirect(HttpStatusCode status, Uri location)
{
var response = new HttpResponseMessage(status);
response.Headers.Location = location;
return response;
}
private sealed class SequenceHandler(
Func<HttpRequestMessage, int, HttpResponseMessage> respond)
: HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Uri uri = request.RequestUri
?? throw new InvalidOperationException("Test request has no URI.");
int index = Requests.Count;
Requests.Add(uri);
return Task.FromResult(respond(request, index));
}
}
}
public sealed class VerifiedArtifactDownloaderTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-download-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task StreamsToStagingWithProgressAndExactDigest()
{
using var server = new LocalHttpFixture();
byte[] bytes = Enumerable.Range(0, 200_000).Select(value => (byte)value).ToArray();
server.Add("artifact", bytes, chunkSize: 4096);
using var http = new HttpClient();
var downloader = new VerifiedArtifactDownloader(http);
var progress = new List<ArtifactDownloadProgress>();
string destination = Path.Combine(_root, "artifact.zip");
VerifiedArtifactDownload result = await downloader.DownloadAsync(
new ReleaseArtifact(server.UriFor("artifact"), UpdateTestData.Sha256(bytes), bytes.LongLength),
destination,
new ImmediateProgress(progress.Add));
Assert.Equal(bytes.LongLength, result.Size);
Assert.Equal(UpdateTestData.Sha256(bytes), result.Sha256);
Assert.Equal(bytes, await File.ReadAllBytesAsync(destination));
Assert.Equal(0, progress[0].BytesReceived);
Assert.Equal(bytes.LongLength, progress[^1].BytesReceived);
}
[Theory]
[InlineData("short")]
[InlineData("header")]
[InlineData("hash")]
public async Task WrongSizeHeaderPartialBodyAndHashDeleteStaging(string failure)
{
using var server = new LocalHttpFixture();
byte[] bytes = Encoding.UTF8.GetBytes("verified bytes");
long expected = failure == "short" ? bytes.Length + 5 : bytes.Length;
long declared = failure == "header" ? bytes.Length + 1 : expected;
server.Add("artifact", bytes, declaredLength: declared);
using var http = new HttpClient();
var downloader = new VerifiedArtifactDownloader(http);
string destination = Path.Combine(_root, failure + ".zip");
string hash = failure == "hash" ? new string('0', 64) : UpdateTestData.Sha256(bytes);
await Assert.ThrowsAsync<LauncherUpdateException>(() => downloader.DownloadAsync(
new ReleaseArtifact(server.UriFor("artifact"), hash, expected),
destination));
Assert.False(File.Exists(destination));
}
[Fact]
public async Task CancellationDeletesPartialStaging()
{
using var server = new LocalHttpFixture();
byte[] bytes = new byte[2 * 1024 * 1024];
Random.Shared.NextBytes(bytes);
server.Add("slow", bytes, chunkSize: 1024, chunkDelay: TimeSpan.FromMilliseconds(3));
using var http = new HttpClient();
var downloader = new VerifiedArtifactDownloader(http);
string destination = Path.Combine(_root, "cancel.zip");
using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(40));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => downloader.DownloadAsync(
new ReleaseArtifact(server.UriFor("slow"), UpdateTestData.Sha256(bytes), bytes.LongLength),
destination,
cancellationToken: cancel.Token));
Assert.False(File.Exists(destination));
}
[Fact]
public async Task RefusesAndPreservesPreExistingCallerFile()
{
using var server = new LocalHttpFixture();
byte[] bytes = Encoding.UTF8.GetBytes("network");
server.Add("artifact", bytes);
using var http = new HttpClient();
var downloader = new VerifiedArtifactDownloader(http);
string destination = Path.Combine(_root, "already-owned.zip");
Directory.CreateDirectory(_root);
await File.WriteAllTextAsync(destination, "preserve");
await Assert.ThrowsAsync<LauncherUpdateException>(() => downloader.DownloadAsync(
new ReleaseArtifact(
server.UriFor("artifact"),
UpdateTestData.Sha256(bytes),
bytes.LongLength),
destination));
Assert.Equal("preserve", await File.ReadAllTextAsync(destination));
}
[Fact]
public async Task HttpsArtifactRedirectDowngradeIsRejectedBeforePlaintextHop()
{
var handler = new RedirectHandler();
using var http = new HttpClient(handler);
var downloader = new VerifiedArtifactDownloader(http);
string destination = Path.Combine(_root, "redirect.zip");
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
downloader.DownloadAsync(
new ReleaseArtifact(
new Uri("https://example.test/artifact"),
new string('a', 64),
12),
destination));
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
Assert.Equal([new Uri("https://example.test/artifact")], handler.Requests);
Assert.False(File.Exists(destination));
}
private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
: IProgress<ArtifactDownloadProgress>
{
public void Report(ArtifactDownloadProgress value) => callback(value);
}
private sealed class RedirectHandler : HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri!);
var response = new HttpResponseMessage(HttpStatusCode.Found);
response.Headers.Location = new Uri("http://example.test/plaintext");
return Task.FromResult(response);
}
}
}

View file

@ -0,0 +1,201 @@
using System.IO.Compression;
using System.Text;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class SafeZipExtractorTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-safe-zip-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task ExtractsPortableTreeWithHashesAndExecutableMode()
{
byte[] archive = UpdateTestData.CreateZip(
[
("bin/", [], 0x41ED),
("bin/client", Encoding.UTF8.GetBytes("client"), 0x81ED),
("data/value.txt", Encoding.UTF8.GetBytes("value"), 0x81A4),
]);
string zip = WriteArchive("valid.zip", archive);
string destination = Path.Combine(_root, "valid");
IReadOnlyList<ExtractedFileRecord> files = await new SafeZipExtractor()
.ExtractAsync(zip, destination);
Assert.Equal(["bin/client", "data/value.txt"], files.Select(file => file.Path));
Assert.Equal(0x1ED, files[0].UnixMode);
Assert.Equal(UpdateTestData.Sha256(Encoding.UTF8.GetBytes("client")), files[0].Sha256);
Assert.Equal("value", await File.ReadAllTextAsync(Path.Combine(destination, "data", "value.txt")));
}
[Theory]
[InlineData("../escape")]
[InlineData("a/../../escape")]
[InlineData("/rooted")]
[InlineData("C:/drive")]
[InlineData("file:stream")]
[InlineData("a//b")]
[InlineData("a/./b")]
[InlineData("CON")]
[InlineData("aux.txt")]
[InlineData("CLOCK$/value")]
[InlineData("CONIN$.txt")]
[InlineData("CONOUT$/value")]
[InlineData("COM¹.dll")]
[InlineData("com²/value")]
[InlineData("LPT³.log")]
[InlineData("trailing.")]
[InlineData("trailing ")]
public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry)
{
string zip = WriteArchive(
"unsafe-" + Guid.NewGuid().ToString("N") + ".zip",
UpdateTestData.CreateZip([(entry, Encoding.UTF8.GetBytes("bad"), 0x81A4)]));
string destination = Path.Combine(_root, "unsafe-" + Guid.NewGuid().ToString("N"));
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor().ExtractAsync(zip, destination));
Assert.False(Directory.Exists(destination));
Assert.False(File.Exists(Path.Combine(_root, "escape")));
}
[Theory]
[InlineData("CONIN$.txt")]
[InlineData("CONOUT$/child")]
[InlineData("COM¹.dll")]
[InlineData("LPT³/child")]
[InlineData("CLOCK$")]
public void VersionMetadataUsesTheSameCompletePortableDeviceRules(string path) =>
Assert.False(ClientVersionStore.IsNormalizedRelative(path));
[Fact]
public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction()
{
byte[][] archives =
[
UpdateTestData.CreateZip(
[
("Readme.txt", Encoding.UTF8.GetBytes("a"), 0x81A4),
("README.TXT", Encoding.UTF8.GetBytes("b"), 0x81A4),
]),
UpdateTestData.CreateZip(
[
("node", Encoding.UTF8.GetBytes("file"), 0x81A4),
("node/child", Encoding.UTF8.GetBytes("child"), 0x81A4),
]),
UpdateTestData.CreateZip(
[
("Folder/one", Encoding.UTF8.GetBytes("one"), 0x81A4),
("folder/two", Encoding.UTF8.GetBytes("two"), 0x81A4),
]),
];
foreach (byte[] archive in archives)
{
string id = Guid.NewGuid().ToString("N");
string zip = WriteArchive(id + ".zip", archive);
string destination = Path.Combine(_root, id);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor().ExtractAsync(zip, destination));
Assert.False(Directory.Exists(destination));
}
}
[Fact]
public async Task RejectsSymlinkAndReparseMetadata()
{
byte[] symlink = UpdateTestData.CreateZip(
[("link", Encoding.UTF8.GetBytes("../../outside"), 0xA1FF)]);
string zip = WriteArchive("symlink.zip", symlink);
string destination = Path.Combine(_root, "symlink");
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => new SafeZipExtractor().ExtractAsync(zip, destination));
Assert.Contains("symlink", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.False(Directory.Exists(destination));
}
[Fact]
public async Task RejectsEntryCountSizeTotalAndCompressionRatioBombs()
{
var cases = new (byte[] Archive, SafeZipExtractionLimits Limits)[]
{
(
UpdateTestData.CreateZip(
[
("one", [1], 0x81A4),
("two", [2], 0x81A4),
]),
new SafeZipExtractionLimits(MaximumEntries: 1)),
(
UpdateTestData.CreateZip([("large", new byte[8], 0x81A4)]),
new SafeZipExtractionLimits(MaximumEntryBytes: 7)),
(
UpdateTestData.CreateZip(
[
("one", new byte[6], 0x81A4),
("two", new byte[6], 0x81A4),
]),
new SafeZipExtractionLimits(MaximumTotalBytes: 10)),
(
UpdateTestData.CreateZip(
[("ratio", new byte[64 * 1024], 0x81A4)],
CompressionLevel.SmallestSize),
new SafeZipExtractionLimits(MaximumCompressionRatio: 2)),
};
foreach ((byte[] archive, SafeZipExtractionLimits limits) in cases)
{
string id = Guid.NewGuid().ToString("N");
string zip = WriteArchive(id + ".zip", archive);
string destination = Path.Combine(_root, id);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor(limits).ExtractAsync(zip, destination));
Assert.False(Directory.Exists(destination));
}
}
[Fact]
public async Task ExistingNonEmptyDestinationAndCancellationNeverPublishPartialTree()
{
string zip = WriteArchive(
"cancel.zip",
UpdateTestData.CreateZip([("large", new byte[1024 * 1024], 0x81A4)]));
string nonEmpty = Path.Combine(_root, "nonempty");
Directory.CreateDirectory(nonEmpty);
await File.WriteAllTextAsync(Path.Combine(nonEmpty, "owner"), "preserve");
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor().ExtractAsync(zip, nonEmpty));
Assert.Equal("preserve", await File.ReadAllTextAsync(Path.Combine(nonEmpty, "owner")));
string cancelled = Path.Combine(_root, "cancelled");
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new SafeZipExtractor().ExtractAsync(zip, cancelled, cancellation.Token));
Assert.False(Directory.Exists(cancelled));
}
private string WriteArchive(string name, byte[] content)
{
Directory.CreateDirectory(_root);
string path = Path.Combine(_root, name);
File.WriteAllBytes(path, content);
return path;
}
}

View file

@ -0,0 +1,147 @@
using System.Diagnostics;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class UpdateSessionBarrierTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-update-lease-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void SharedSessionsCoexistAndExcludeUpdateTransactions()
{
var barrier = new UpdateSessionBarrier(_root);
using UpdateSessionBarrier.SessionLease first = barrier.AcquireSession();
using UpdateSessionBarrier.SessionLease second = barrier.AcquireSession();
LauncherUpdateException blocked = Assert.Throws<LauncherUpdateException>(
barrier.AcquireExclusive);
Assert.Contains("session", blocked.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ExclusiveUpdaterExcludesSessionsAndConcurrentUpdater()
{
var barrier = new UpdateSessionBarrier(_root);
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
Assert.Throws<LauncherUpdateException>(barrier.AcquireSession);
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
}
[Theory]
[InlineData("session")]
[InlineData("exclusive")]
public async Task CrossProcessLeaseRefusesRacingLauncherAndReleasesCleanly(string mode)
{
string ready = Path.Combine(_root, mode + ".ready");
string release = Path.Combine(_root, mode + ".release");
Directory.CreateDirectory(_root);
string fixture = GetFixturePath();
Assert.True(File.Exists(fixture), $"Missing fixture: {fixture}");
var startInfo = new ProcessStartInfo("dotnet")
{
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
startInfo.ArgumentList.Add(fixture);
startInfo.ArgumentList.Add("hold-update-lease");
startInfo.ArgumentList.Add(mode);
startInfo.ArgumentList.Add(_root);
startInfo.ArgumentList.Add(ready);
startInfo.ArgumentList.Add(release);
using Process holder = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start update lease fixture.");
try
{
await WaitForFileAsync(ready, holder);
var barrier = new UpdateSessionBarrier(_root);
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
if (mode == "session")
{
using UpdateSessionBarrier.SessionLease peer = barrier.AcquireSession();
}
else
{
Assert.Throws<LauncherUpdateException>(barrier.AcquireSession);
}
await File.WriteAllTextAsync(release, "release");
await holder.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(0, holder.ExitCode);
using UpdateSessionBarrier.ExclusiveLease after = barrier.AcquireExclusive();
}
finally
{
if (!holder.HasExited)
{
holder.Kill(entireProcessTree: true);
await holder.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
}
}
}
private static async Task WaitForFileAsync(string path, Process process)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10);
while (!File.Exists(path))
{
if (process.HasExited)
{
throw new InvalidOperationException(
$"Lease fixture exited early with {process.ExitCode}: "
+ await process.StandardError.ReadToEndAsync());
}
if (DateTimeOffset.UtcNow >= deadline)
{
throw new TimeoutException("Lease fixture did not become ready.");
}
await Task.Delay(20);
}
}
private static string GetFixturePath()
{
string root = FindRepositoryRoot();
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent?.Name ?? "Release";
return Path.Combine(
root,
"tests",
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder",
"bin",
configuration,
"net10.0",
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.dll");
}
private static string FindRepositoryRoot()
{
for (var directory = new DirectoryInfo(AppContext.BaseDirectory);
directory is not null;
directory = directory.Parent)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
{
return directory.FullName;
}
}
throw new InvalidOperationException("Repository root was not found.");
}
}

View file

@ -0,0 +1,270 @@
using System.IO.Compression;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Updates;
internal sealed class LocalHttpFixture : IDisposable
{
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _stop = new();
private readonly Dictionary<string, Response> _responses =
new(StringComparer.Ordinal);
private readonly Task _server;
public LocalHttpFixture()
{
_listener.Start();
int port = ((IPEndPoint)_listener.LocalEndpoint).Port;
BaseUri = new Uri($"http://127.0.0.1:{port}/", UriKind.Absolute);
_server = ServeAsync();
}
public Uri BaseUri { get; }
public Uri UriFor(string relative) => new(BaseUri, relative.TrimStart('/'));
public void Add(
string path,
byte[] body,
long? declaredLength = null,
int chunkSize = int.MaxValue,
TimeSpan? chunkDelay = null,
string contentType = "application/octet-stream")
{
_responses[NormalizePath(path)] = new Response(
body,
declaredLength ?? body.LongLength,
chunkSize,
chunkDelay ?? TimeSpan.Zero,
contentType);
}
public void Dispose()
{
_stop.Cancel();
_listener.Stop();
try
{
_server.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException)
{
// Listener cancellation is the expected shutdown path.
}
_stop.Dispose();
}
private async Task ServeAsync()
{
while (!_stop.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync(_stop.Token);
}
catch (OperationCanceledException)
{
return;
}
catch (ObjectDisposedException)
{
return;
}
catch (SocketException) when (_stop.IsCancellationRequested)
{
return;
}
_ = Task.Run(() => RespondAsync(client), CancellationToken.None);
}
}
private async Task RespondAsync(TcpClient client)
{
using (client)
{
try
{
NetworkStream stream = client.GetStream();
using var reader = new StreamReader(
stream,
Encoding.ASCII,
detectEncodingFromByteOrderMarks: false,
bufferSize: 4096,
leaveOpen: true);
string? request = await reader.ReadLineAsync(_stop.Token);
while (!string.IsNullOrEmpty(await reader.ReadLineAsync(_stop.Token)))
{
}
string path = request?.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.ElementAtOrDefault(1) ?? "/";
if (!_responses.TryGetValue(NormalizePath(path), out Response? response))
{
await WriteHeaderAsync(stream, 404, 0, "text/plain");
return;
}
await WriteHeaderAsync(
stream,
200,
response.DeclaredLength,
response.ContentType);
int offset = 0;
while (offset < response.Body.Length)
{
int count = Math.Min(response.ChunkSize, response.Body.Length - offset);
await stream.WriteAsync(
response.Body.AsMemory(offset, count),
_stop.Token);
await stream.FlushAsync(_stop.Token);
offset += count;
if (offset < response.Body.Length && response.ChunkDelay > TimeSpan.Zero)
{
await Task.Delay(response.ChunkDelay, _stop.Token);
}
}
}
catch (Exception ex) when (ex is IOException
or OperationCanceledException
or ObjectDisposedException
or SocketException)
{
// The downloader cancellation/early-refusal tests close the socket.
}
}
}
private static async Task WriteHeaderAsync(
Stream stream,
int status,
long length,
string contentType)
{
string reason = status == 200 ? "OK" : "Not Found";
byte[] header = Encoding.ASCII.GetBytes(
$"HTTP/1.1 {status} {reason}\r\n"
+ $"Content-Length: {length}\r\n"
+ $"Content-Type: {contentType}\r\n"
+ "Connection: close\r\n\r\n");
await stream.WriteAsync(header);
await stream.FlushAsync();
}
private static string NormalizePath(string path)
{
int query = path.IndexOf('?', StringComparison.Ordinal);
string withoutQuery = query < 0 ? path : path[..query];
return "/" + withoutQuery.TrimStart('/');
}
private sealed record Response(
byte[] Body,
long DeclaredLength,
int ChunkSize,
TimeSpan ChunkDelay,
string ContentType);
}
internal static class UpdateTestData
{
public static ApplicationPathSet Paths(string root) => new(
Path.Combine(root, "config"),
Path.Combine(root, "data"),
Path.Combine(root, "cache"),
null);
public static byte[] CreateZip(
IEnumerable<(string Name, byte[] Content, int? UnixAttributes)> entries,
CompressionLevel compression = CompressionLevel.NoCompression)
{
using var output = new MemoryStream();
using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach ((string name, byte[] content, int? unixAttributes) in entries)
{
ZipArchiveEntry entry = archive.CreateEntry(name, compression);
if (unixAttributes is int attributes)
{
entry.ExternalAttributes = attributes << 16;
}
if (!name.EndsWith("/", StringComparison.Ordinal))
{
using Stream stream = entry.Open();
stream.Write(content);
}
}
}
return output.ToArray();
}
public static byte[] ClientZip(string rid, string marker = "client")
{
string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty;
const int executable = 0x81ED;
return CreateZip(
[
($"AcDream.App{suffix}", Encoding.UTF8.GetBytes(marker + "-gui"), executable),
($"acdream-headless{suffix}", Encoding.UTF8.GetBytes(marker + "-headless"), executable),
("assets/readme.txt", Encoding.UTF8.GetBytes(marker), 0x81A4),
]);
}
public static byte[] LauncherZip(string rid, string marker = "launcher")
{
string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty;
return CreateZip(
[
($"acdream-launcher{suffix}", Encoding.UTF8.GetBytes(marker), 0x81ED),
("support.dat", Encoding.UTF8.GetBytes("support-" + marker), 0x81A4),
]);
}
public static string Sha256(byte[] bytes) =>
Convert.ToHexStringLower(SHA256.HashData(bytes));
public static byte[] Manifest(
string version,
string minimum,
string rid,
Uri clientUri,
byte[] client,
Uri launcherUri,
byte[] launcher)
{
var value = new
{
schemaVersion = 1,
version,
minimumLauncherVersion = minimum,
clients = new Dictionary<string, object>
{
[rid] = new
{
url = clientUri.AbsoluteUri,
sha256 = Sha256(client),
size = client.LongLength,
},
},
launchers = new Dictionary<string, object>
{
[rid] = new
{
url = launcherUri.AbsoluteUri,
sha256 = Sha256(launcher),
size = launcher.LongLength,
},
},
};
return JsonSerializer.SerializeToUtf8Bytes(value);
}
}

View file

@ -0,0 +1,65 @@
using System.Text.Json;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher.Tests;
public sealed class LauncherUpdateCompositionTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-launcher-composition-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Theory]
[InlineData("io")]
[InlineData("permission")]
[InlineData("corrupt")]
public async Task StartupStorageFailureComposesUnavailableUpdaterWithoutThrowing(
string failure)
{
Directory.CreateDirectory(_root);
var paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
Exception exception = failure switch
{
"io" => new IOException("storage offline"),
"permission" => new UnauthorizedAccessException("storage denied"),
"corrupt" => new JsonException("pointer corrupt"),
_ => throw new InvalidOperationException("Unknown fixture failure."),
};
using LauncherUpdateComposition composition = LauncherUpdateComposition.Create(
paths,
LauncherRuntimeIdentity.DetectRid(),
LauncherVersion.Parse("1.0.0"),
_root,
() => false,
(_, _) => throw exception);
Assert.Equal(ClientVersionState.Invalid, composition.Updater.CurrentClient.State);
Assert.Contains(
exception.Message,
composition.Updater.CurrentClient.Status,
StringComparison.Ordinal);
LauncherCapability capability = composition.Executables.GetAvailability(LaunchMode.Gui);
Assert.False(capability.IsAvailable);
Assert.Contains(exception.Message, capability.Reason, StringComparison.Ordinal);
LauncherUpdateException updateError = await Assert.ThrowsAsync<LauncherUpdateException>(
() => composition.Updater.CheckAsync());
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal);
}
}

View file

@ -0,0 +1,292 @@
using AcDream.Launcher.Core.Updates;
using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
public sealed class LauncherUpdateViewModelTests
{
[Fact]
public async Task StartupPollingIsOfflineTolerantAndDoesNotOpenErrorModal()
{
var updater = new FakeUpdater
{
CheckHandler = _ => Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException("fixture offline")),
};
using var viewModel = Create(updater);
await viewModel.StartupCheckAsync();
Assert.False(viewModel.IsOpen);
Assert.False(viewModel.HasError);
Assert.Contains("continuing offline", viewModel.Status, StringComparison.OrdinalIgnoreCase);
Assert.Equal(LauncherUpdatePhase.Failed, viewModel.Phase);
}
[Fact]
public async Task StartupUpdateOpensModalAndClientInstallProjectsProgressAndRefreshesVersions()
{
var updater = new FakeUpdater();
int changed = 0;
using var viewModel = Create(updater, () => changed++);
await viewModel.StartupCheckAsync();
Assert.True(viewModel.IsOpen);
Assert.Equal("2.0.0", viewModel.AvailableVersion);
Assert.Equal("1.0.0", viewModel.CurrentClientVersion);
Assert.True(viewModel.InstallClientCommand.CanExecute(null));
await viewModel.InstallClientCommand.ExecuteAsync();
Assert.Equal(1, updater.InstallCalls);
Assert.Equal(1, changed);
Assert.Equal("2.0.0", viewModel.CurrentClientVersion);
Assert.False(viewModel.IsClientUpdateAvailable);
Assert.Equal(100, viewModel.ProgressPercent);
Assert.False(viewModel.HasError);
}
[Fact]
public async Task ManualCheckShowsErrorsAndCanRetrySuccessfully()
{
var updater = new FakeUpdater();
int calls = 0;
updater.CheckHandler = _ => ++calls == 1
? Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException("malformed fixture manifest"))
: Task.FromResult(updater.CreateCheck());
using var viewModel = Create(updater);
await viewModel.OpenCommand.ExecuteAsync();
Assert.True(viewModel.IsOpen);
Assert.True(viewModel.HasError);
Assert.Contains("malformed", viewModel.Error, StringComparison.Ordinal);
await viewModel.CheckCommand.ExecuteAsync();
Assert.False(viewModel.HasError);
Assert.Equal(2, calls);
}
[Fact]
public async Task MinimumLauncherGateDisablesClientButAllowsVerifiedSelfUpdateStage()
{
var updater = new FakeUpdater
{
MinimumSatisfied = false,
};
using var viewModel = Create(updater);
await viewModel.OpenCommand.ExecuteAsync();
Assert.True(viewModel.IsLauncherMinimumBlocked);
Assert.False(viewModel.InstallClientCommand.CanExecute(null));
Assert.True(viewModel.StageLauncherCommand.CanExecute(null));
await viewModel.StageLauncherCommand.ExecuteAsync();
Assert.Equal(1, updater.StageCalls);
Assert.True(viewModel.IsLauncherRestartRequired);
Assert.Contains("next start", viewModel.LauncherRestartStatus, StringComparison.Ordinal);
Assert.False(viewModel.StageLauncherCommand.CanExecute(null));
Assert.False(viewModel.HasError);
}
[Fact]
public async Task MutationPermissionDisablesInstallStageAndRollbackWhileSessionsRun()
{
var updater = new FakeUpdater();
using var viewModel = Create(updater, canMutate: () => false);
await viewModel.OpenCommand.ExecuteAsync();
Assert.False(viewModel.InstallClientCommand.CanExecute(null));
Assert.False(viewModel.StageLauncherCommand.CanExecute(null));
Assert.False(viewModel.RollbackCommand.CanExecute(null));
Assert.True(viewModel.CheckCommand.CanExecute(null));
}
[Fact]
public async Task CancellationAndRollbackHaveExplicitSafeTerminalStates()
{
var updater = new FakeUpdater();
updater.InstallHandler = async (progress, token) =>
{
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.DownloadingClient,
"downloading",
1,
100));
await Task.Delay(Timeout.InfiniteTimeSpan, token);
return updater.CurrentClient;
};
int changed = 0;
using var viewModel = Create(updater, () => changed++);
await viewModel.OpenCommand.ExecuteAsync();
Task install = viewModel.InstallClientCommand.ExecuteAsync();
await WaitUntilAsync(() => viewModel.IsBusy);
Assert.True(viewModel.CancelCommand.CanExecute(null));
viewModel.CancelCommand.Execute(null);
await install;
Assert.Equal(LauncherUpdatePhase.Cancelled, viewModel.Phase);
Assert.Contains("cancelled", viewModel.Status, StringComparison.OrdinalIgnoreCase);
Assert.False(viewModel.HasError);
await viewModel.RollbackCommand.ExecuteAsync();
Assert.Equal(1, updater.RollbackCalls);
Assert.Equal("0.9.0", viewModel.CurrentClientVersion);
Assert.Equal(1, changed);
}
private static LauncherUpdateViewModel Create(
FakeUpdater updater,
Action? changed = null,
Func<bool>? canMutate = null) => new(
updater,
new ImmediateUiDispatcher(),
changed ?? (() => { }),
canOpen: () => true,
canMutate: canMutate ?? (() => true));
private static async Task WaitUntilAsync(Func<bool> condition)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
while (!condition())
{
if (DateTimeOffset.UtcNow >= deadline)
{
throw new TimeoutException("View model did not enter the expected state.");
}
await Task.Delay(10);
}
}
private sealed class FakeUpdater : ILauncherUpdater
{
private static readonly LauncherVersion One = LauncherVersion.Parse("1.0.0");
private static readonly LauncherVersion Two = LauncherVersion.Parse("2.0.0");
private static readonly LauncherVersion NineTenths = LauncherVersion.Parse("0.9.0");
public FakeUpdater()
{
CurrentClient = Resolution(One, "0.9.0");
}
public ClientVersionResolution CurrentClient { get; private set; }
public bool MinimumSatisfied { get; init; } = true;
public int InstallCalls { get; private set; }
public int StageCalls { get; private set; }
public int RollbackCalls { get; private set; }
public Func<CancellationToken, Task<LauncherUpdateCheckResult>>? CheckHandler
{
get;
set;
}
public Func<
IProgress<LauncherUpdateProgress>?,
CancellationToken,
Task<ClientVersionResolution>>? InstallHandler { get; set; }
public Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default) => Task.FromResult(CurrentClient);
public Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default) =>
CheckHandler?.Invoke(cancellationToken) ?? Task.FromResult(CreateCheck());
public async Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
InstallCalls++;
if (InstallHandler is not null)
{
return await InstallHandler(progress, cancellationToken);
}
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.DownloadingClient,
"Downloading fixture.",
5,
10));
CurrentClient = Resolution(Two, "1.0.0");
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.Completed,
"Installed fixture.",
1,
1));
return CurrentClient;
}
public Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
StageCalls++;
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.StagingLauncher,
"Staged fixture.",
1,
1));
return Task.FromResult(new SelfUpdateStageResult(
Two,
"pending.json",
"Launcher staged for next start."));
}
public Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
RollbackCalls++;
CurrentClient = Resolution(NineTenths, "1.0.0");
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.Completed,
"Rolled back fixture.",
1,
1));
return Task.FromResult(CurrentClient);
}
public LauncherUpdateCheckResult CreateCheck()
{
bool available = CurrentClient.Version! < Two;
var artifact = new ReleaseArtifact(
new Uri("https://example.test/release.zip"),
new string('a', 64),
100);
var manifest = new ReleaseManifest(
Two,
MinimumSatisfied ? One : Two,
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact },
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact });
return new LauncherUpdateCheckResult(
manifest,
"win-x64",
One,
CurrentClient.Version,
available,
true,
MinimumSatisfied,
available ? "Fixture update available." : "Fixture is current.");
}
private static ClientVersionResolution Resolution(
LauncherVersion version,
string? previous) => new(
ClientVersionState.Verified,
"Fixture client verified.",
version,
Path.Combine("fixture", version.Value),
previous,
null);
}
}

View file

@ -34,7 +34,7 @@ public sealed class LauncherWindowViewModelTests
Assert.True(viewModel.IsFirstRunRequired);
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
Assert.Contains("LA10", viewModel.UpdatePromptShell.Body, StringComparison.Ordinal);
Assert.Contains("pinned eriknihlen/acdream", viewModel.UpdatePrompt.Body, StringComparison.Ordinal);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
@ -306,7 +306,7 @@ public sealed class LauncherWindowViewModelTests
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.IsModalOpen);
Assert.False(viewModel.AddServerCommand.CanExecute(null));
Assert.False(viewModel.UpdatePromptShell.OpenCommand.CanExecute(null));
Assert.False(viewModel.UpdatePrompt.OpenCommand.CanExecute(null));
Assert.False(Assert.Single(viewModel.Sessions).StopCommand.CanExecute(null));
// ICommand.Execute cannot bypass the modal gate.