feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
|
|
@ -329,11 +329,21 @@ src/
|
||||||
adjacent
|
adjacent
|
||||||
`.<pak>.acdream-bake.<guid:N>.tmp` files are
|
`.<pak>.acdream-bake.<guid:N>.tmp` files are
|
||||||
transaction-owned crash residue
|
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
|
-> references Platform only; no Avalonia or game-host dependency
|
||||||
|
|
||||||
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
|
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
|
||||||
ViewModels/ -> thin MVVM projection over Launcher.Core,
|
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
|
-> references Launcher.Core only (Platform transitively); it never owns
|
||||||
a second profile, process, status, or credential state graph
|
a second profile, process, status, or credential state graph
|
||||||
-> every per-RID publish composes the separately published self-contained
|
-> every per-RID publish composes the separately published self-contained
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,131 @@ gate (user): clean-profile first-run against real DATs.
|
||||||
fixture; rollback test; refusal-while-running test; self-update staging test;
|
fixture; rollback test; refusal-while-running test; self-update staging test;
|
||||||
suites green. Connected gate (user): staged-manifest update swap end-to-end.
|
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 may inject a loopback HTTP URI, but production artifacts and redirects
|
||||||
|
must use HTTPS. `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 and pointer
|
||||||
|
temporaries are transaction-owned by exact names and are removed under the
|
||||||
|
update lease. 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; 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`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"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 a next-start helper copied outside the target directory
|
||||||
|
atomically advances the plan to `applying` and fills `apply` with each path's
|
||||||
|
`hadOriginal` bit. It waits for the initiating launcher PID without invoking a
|
||||||
|
shell, moves originals into the transaction backup tree, then moves verified
|
||||||
|
staged files into place. It never opens a target with truncate/overwrite. On
|
||||||
|
success the plan becomes `awaitingConfirmation`; the new launcher confirms at
|
||||||
|
its first managed instruction, after which backup and plan cleanup is safe. 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).
|
||||||
|
All plan paths are re-derived/contained under the pinned data root except the
|
||||||
|
target directory, which must equal the actual launcher base directory.
|
||||||
|
|
||||||
## LA11 — closeout
|
## LA11 — closeout
|
||||||
|
|
||||||
- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md`
|
- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md`
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,18 @@ preview would be a deliberate divergence we are NOT taking.
|
||||||
- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips
|
- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips
|
||||||
are release assets; the launcher pins the repo/owner in its config.
|
are release assets; the launcher pins the repo/owner in its config.
|
||||||
|
|
||||||
|
The exact v1 manifest, extracted-version record, `current.json` activation
|
||||||
|
pointer, shared-session/exclusive-update OS lease, and durable self-update
|
||||||
|
plan 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
|
||||||
|
derive all mutable staging/backup paths from the application data root. 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
|
## 10. Testing
|
||||||
|
|
||||||
- **Launcher.Core unit tests** (new test project, registered in
|
- **Launcher.Core unit tests** (new test project, registered in
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,19 @@
|
||||||
using AcDream.Launcher.Core.Launching;
|
using AcDream.Launcher.Core.Launching;
|
||||||
using AcDream.Launcher.Core.Profiles;
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Core.Orchestration;
|
namespace AcDream.Launcher.Core.Orchestration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves and validates the co-deployed graphical/headless hosts. LA10 will
|
/// Resolves and validates the graphical/headless hosts. Production uses the
|
||||||
/// replace the directory lookup with its versioned-current resolver; until
|
/// verified <c>DataDirectory/app/current.json</c> resolver; the explicit-path
|
||||||
/// then a missing host disables the corresponding action instead of deferring
|
/// constructor remains the injectable test seam.
|
||||||
/// failure until process creation.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LauncherExecutableSet
|
public sealed class LauncherExecutableSet
|
||||||
{
|
{
|
||||||
private readonly Func<string, bool> _fileExists;
|
private readonly Func<string, bool> _fileExists;
|
||||||
private readonly Func<string, bool> _hasUnixExecutePermission;
|
private readonly Func<string, bool> _hasUnixExecutePermission;
|
||||||
|
private readonly Func<ExecutablePaths> _resolve;
|
||||||
|
|
||||||
public LauncherExecutableSet(
|
public LauncherExecutableSet(
|
||||||
string graphicalHostPath,
|
string graphicalHostPath,
|
||||||
|
|
@ -23,25 +24,50 @@ public sealed class LauncherExecutableSet
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath);
|
||||||
GraphicalHostPath = graphicalHostPath;
|
string graphical = graphicalHostPath;
|
||||||
HeadlessHostPath = headlessHostPath;
|
string headless = headlessHostPath;
|
||||||
WorkingDirectory = workingDirectory;
|
_resolve = () => new ExecutablePaths(graphical, headless, workingDirectory);
|
||||||
_fileExists = fileExists ?? File.Exists;
|
_fileExists = fileExists ?? File.Exists;
|
||||||
_hasUnixExecutePermission =
|
_hasUnixExecutePermission =
|
||||||
hasUnixExecutePermission ?? 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)
|
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
|
string path = mode == LaunchMode.Headless
|
||||||
? HeadlessHostPath
|
? paths.HeadlessHostPath
|
||||||
: GraphicalHostPath;
|
: paths.GraphicalHostPath;
|
||||||
string host = mode == LaunchMode.Headless
|
string host = mode == LaunchMode.Headless
|
||||||
? "headless host"
|
? "headless host"
|
||||||
: "graphical client";
|
: "graphical client";
|
||||||
|
|
@ -68,27 +94,27 @@ public sealed class LauncherExecutableSet
|
||||||
string configFilePath)
|
string configFilePath)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||||
RequireAvailable(mode);
|
ExecutablePaths paths = RequireAvailable(mode);
|
||||||
|
|
||||||
return mode == LaunchMode.Headless
|
return mode == LaunchMode.Headless
|
||||||
? new LauncherProcessSpec(
|
? new LauncherProcessSpec(
|
||||||
HeadlessHostPath,
|
paths.HeadlessHostPath,
|
||||||
["--config", configFilePath],
|
["--config", configFilePath],
|
||||||
WorkingDirectory)
|
paths.WorkingDirectory)
|
||||||
: new LauncherProcessSpec(
|
: new LauncherProcessSpec(
|
||||||
GraphicalHostPath,
|
paths.GraphicalHostPath,
|
||||||
["--session-config", configFilePath],
|
["--session-config", configFilePath],
|
||||||
WorkingDirectory);
|
paths.WorkingDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||||
RequireAvailable(LaunchMode.Headless);
|
ExecutablePaths paths = RequireAvailable(LaunchMode.Headless);
|
||||||
return new LauncherProcessSpec(
|
return new LauncherProcessSpec(
|
||||||
HeadlessHostPath,
|
paths.HeadlessHostPath,
|
||||||
["--config", configFilePath],
|
["--config", configFilePath],
|
||||||
WorkingDirectory);
|
paths.WorkingDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static LauncherExecutableSet FromDirectory(string directory)
|
public static LauncherExecutableSet FromDirectory(string directory)
|
||||||
|
|
@ -102,7 +128,28 @@ public sealed class LauncherExecutableSet
|
||||||
fullDirectory);
|
fullDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RequireAvailable(LaunchMode mode)
|
/// <summary>
|
||||||
|
/// Dynamic production resolver. The store cache is admitted only after a
|
||||||
|
/// strict startup/update verification, and a pointer swap changes the
|
||||||
|
/// binaries selected for the next session without replacing LA9 content.
|
||||||
|
/// </summary>
|
||||||
|
public static LauncherExecutableSet FromCurrentVersionStore(
|
||||||
|
ClientVersionStore store)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(store);
|
||||||
|
return new LauncherExecutableSet(() =>
|
||||||
|
{
|
||||||
|
ClientVersionResolution resolution = store.CachedResolution;
|
||||||
|
if (!resolution.IsVerified || resolution.Directory is null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(resolution.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FromDirectoryPaths(resolution.Directory);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExecutablePaths RequireAvailable(LaunchMode mode)
|
||||||
{
|
{
|
||||||
LauncherCapability capability = GetAvailability(mode);
|
LauncherCapability capability = GetAvailability(mode);
|
||||||
if (!capability.IsAvailable)
|
if (!capability.IsAvailable)
|
||||||
|
|
@ -110,6 +157,18 @@ public sealed class LauncherExecutableSet
|
||||||
throw new LauncherOperationException(
|
throw new LauncherOperationException(
|
||||||
capability.Reason ?? "The selected launcher host is unavailable.");
|
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)
|
private static bool HasUnixExecutePermission(string path)
|
||||||
|
|
@ -134,4 +193,9 @@ public sealed class LauncherExecutableSet
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record ExecutablePaths(
|
||||||
|
string GraphicalHostPath,
|
||||||
|
string HeadlessHostPath,
|
||||||
|
string? WorkingDirectory);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using AcDream.Launcher.Core.Launching;
|
using AcDream.Launcher.Core.Launching;
|
||||||
using AcDream.Launcher.Core.Profiles;
|
using AcDream.Launcher.Core.Profiles;
|
||||||
using AcDream.Launcher.Core.Status;
|
using AcDream.Launcher.Core.Status;
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
using AcDream.Platform;
|
using AcDream.Platform;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Core.Orchestration;
|
namespace AcDream.Launcher.Core.Orchestration;
|
||||||
|
|
@ -26,6 +27,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
private readonly ILauncherProcessSupervisorFactory _supervisorFactory;
|
private readonly ILauncherProcessSupervisorFactory _supervisorFactory;
|
||||||
private readonly IStatusEventSourceFactory _statusSourceFactory;
|
private readonly IStatusEventSourceFactory _statusSourceFactory;
|
||||||
private readonly Func<string> _sessionIdFactory;
|
private readonly Func<string> _sessionIdFactory;
|
||||||
|
private readonly UpdateSessionBarrier _updateSessionBarrier;
|
||||||
private readonly List<ManagedActivity> _activities = [];
|
private readonly List<ManagedActivity> _activities = [];
|
||||||
|
|
||||||
private LauncherInstallRecord? _installRecord;
|
private LauncherInstallRecord? _installRecord;
|
||||||
|
|
@ -42,7 +44,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
||||||
IStatusEventSourceFactory? statusSourceFactory = null,
|
IStatusEventSourceFactory? statusSourceFactory = null,
|
||||||
Func<string>? sessionIdFactory = null,
|
Func<string>? sessionIdFactory = null,
|
||||||
string? installationStatus = null)
|
string? installationStatus = null,
|
||||||
|
UpdateSessionBarrier? updateSessionBarrier = null)
|
||||||
{
|
{
|
||||||
_profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore));
|
_profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore));
|
||||||
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
|
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
|
||||||
|
|
@ -53,6 +56,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
_supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory();
|
_supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory();
|
||||||
_statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory();
|
_statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory();
|
||||||
_sessionIdFactory = sessionIdFactory ?? CreateSessionId;
|
_sessionIdFactory = sessionIdFactory ?? CreateSessionId;
|
||||||
|
_updateSessionBarrier = updateSessionBarrier
|
||||||
|
?? new UpdateSessionBarrier(paths.DataDirectory);
|
||||||
_installationStatus = installationStatus
|
_installationStatus = installationStatus
|
||||||
?? (installRecord is null
|
?? (installRecord is null
|
||||||
? FirstRunRequired
|
? FirstRunRequired
|
||||||
|
|
@ -629,10 +634,18 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
{
|
{
|
||||||
ILauncherProcessSupervisor? supervisor = null;
|
ILauncherProcessSupervisor? supervisor = null;
|
||||||
string? password = request.Password;
|
string? password = request.Password;
|
||||||
|
bool hostStarted = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
request.Cancellation.Token.ThrowIfCancellationRequested();
|
request.Cancellation.Token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
UpdateSessionBarrier.SessionLease sessionLease =
|
||||||
|
_updateSessionBarrier.AcquireSession();
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
request.Activity.UpdateSessionLease = sessionLease;
|
||||||
|
}
|
||||||
|
|
||||||
ComposedSessionConfig composed = request.IsProbe
|
ComposedSessionConfig composed = request.IsProbe
|
||||||
? _configService.ComposeProbeAndWrite(
|
? _configService.ComposeProbeAndWrite(
|
||||||
request.Server,
|
request.Server,
|
||||||
|
|
@ -674,6 +687,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
request.Activity.LaunchMode!.Value,
|
request.Activity.LaunchMode!.Value,
|
||||||
composed.ConfigFilePath);
|
composed.ConfigFilePath);
|
||||||
supervisor.Start(processSpec, password);
|
supervisor.Start(processSpec, password);
|
||||||
|
hostStarted = true;
|
||||||
|
|
||||||
request.Password = null;
|
request.Password = null;
|
||||||
password = null;
|
password = null;
|
||||||
|
|
@ -728,6 +742,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
request.Password = null;
|
request.Password = null;
|
||||||
|
if (!hostStarted)
|
||||||
|
{
|
||||||
|
ReleaseUpdateSessionLease(request.Activity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -735,6 +753,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
ManagedActivity activity,
|
ManagedActivity activity,
|
||||||
LauncherSessionState processState)
|
LauncherSessionState processState)
|
||||||
{
|
{
|
||||||
|
UpdateSessionBarrier.SessionLease? sessionLease = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
|
|
@ -774,10 +793,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
{
|
{
|
||||||
activity.Status = activity.HostTerminalStatus;
|
activity.Status = activity.HostTerminalStatus;
|
||||||
}
|
}
|
||||||
|
sessionLease = activity.UpdateSessionLease;
|
||||||
|
activity.UpdateSessionLease = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionLease?.Dispose();
|
||||||
RaiseStateChanged();
|
RaiseStateChanged();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|
@ -1192,6 +1214,15 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
activity.Supervisor.Dispose();
|
activity.Supervisor.Dispose();
|
||||||
activity.Supervisor = null;
|
activity.Supervisor = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ReleaseUpdateSessionLease(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReleaseUpdateSessionLease(ManagedActivity activity)
|
||||||
|
{
|
||||||
|
UpdateSessionBarrier.SessionLease? lease =
|
||||||
|
Interlocked.Exchange(ref activity.UpdateSessionLease, null);
|
||||||
|
lease?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ThrowIfDisposed()
|
private void ThrowIfDisposed()
|
||||||
|
|
@ -1252,6 +1283,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
|
|
||||||
public CancellationTokenSource? StartCancellation { get; set; }
|
public CancellationTokenSource? StartCancellation { get; set; }
|
||||||
|
|
||||||
|
public UpdateSessionBarrier.SessionLease? UpdateSessionLease;
|
||||||
|
|
||||||
public object StatusReadGate { get; } = new();
|
public object StatusReadGate { get; } = new();
|
||||||
|
|
||||||
public bool IsActive => State is not (
|
public bool IsActive => State is not (
|
||||||
|
|
|
||||||
84
src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs
Normal file
84
src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
973
src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs
Normal file
973
src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs
Normal file
|
|
@ -0,0 +1,973 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using AcDream.Launcher.Core.Integrity;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
public sealed record InstalledFileRecord(
|
||||||
|
string Path,
|
||||||
|
string Sha256,
|
||||||
|
long Size,
|
||||||
|
int UnixMode);
|
||||||
|
|
||||||
|
public sealed record ClientVersionRecord(
|
||||||
|
int SchemaVersion,
|
||||||
|
string Version,
|
||||||
|
string Rid,
|
||||||
|
string ArchiveSha256,
|
||||||
|
long ArchiveSize,
|
||||||
|
IReadOnlyList<InstalledFileRecord> Files)
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ClientActivationPointer(
|
||||||
|
int SchemaVersion,
|
||||||
|
string CurrentVersion,
|
||||||
|
string? PreviousVersion)
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ClientVersionState
|
||||||
|
{
|
||||||
|
Missing,
|
||||||
|
Verified,
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ClientVersionResolution(
|
||||||
|
ClientVersionState State,
|
||||||
|
string Status,
|
||||||
|
LauncherVersion? Version,
|
||||||
|
string? Directory,
|
||||||
|
string? PreviousVersion,
|
||||||
|
ClientVersionRecord? Record)
|
||||||
|
{
|
||||||
|
public bool IsVerified => State == ClientVersionState.Verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strict installed-version and activation-pointer authority. LA9's DAT/pak
|
||||||
|
/// record is intentionally not represented here.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClientVersionStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = false,
|
||||||
|
WriteIndented = true,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
MaxDepth = 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
|
||||||
|
private ClientVersionResolution _cached = new(
|
||||||
|
ClientVersionState.Missing,
|
||||||
|
"No versioned client is installed. Check for updates to install one.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
|
||||||
|
public ClientVersionStore(
|
||||||
|
ApplicationPathSet paths,
|
||||||
|
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
AppDirectory = Path.Combine(Path.GetFullPath(paths.DataDirectory), "app");
|
||||||
|
CurrentPointerPath = Path.Combine(AppDirectory, "current.json");
|
||||||
|
PreviousPointerPath = Path.Combine(AppDirectory, "current.previous.json");
|
||||||
|
Barrier = new UpdateSessionBarrier(paths.DataDirectory);
|
||||||
|
_computeSha256 = computeSha256
|
||||||
|
?? ((path, token) => FileIntegrity.ComputeSha256HexAsync(path, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AppDirectory { get; }
|
||||||
|
|
||||||
|
public string CurrentPointerPath { get; }
|
||||||
|
|
||||||
|
public string PreviousPointerPath { get; }
|
||||||
|
|
||||||
|
public UpdateSessionBarrier Barrier { get; }
|
||||||
|
|
||||||
|
public ClientVersionResolution CachedResolution
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetVersionDirectory(LauncherVersion version) =>
|
||||||
|
Path.Combine(AppDirectory, version.Value);
|
||||||
|
|
||||||
|
public static string GetMetadataPath(string versionDirectory) =>
|
||||||
|
Path.Combine(Path.GetFullPath(versionDirectory), "install.json");
|
||||||
|
|
||||||
|
public async Task<ClientVersionResolution> LoadAndRecoverAsync(
|
||||||
|
string rid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive();
|
||||||
|
return await LoadAndRecoverUnderLeaseAsync(rid, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (LauncherUpdateException ex) when (ex.InnerException is IOException)
|
||||||
|
{
|
||||||
|
// Another launcher may legitimately hold a shared session lease.
|
||||||
|
// Pointer publication is atomic and old versions are retained, so
|
||||||
|
// a read-only verification remains safe; mutation/recovery waits
|
||||||
|
// for the next startup without active sessions.
|
||||||
|
return await LoadCurrentReadOnlyAsync(rid, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClientVersionResolution> LoadCurrentReadOnlyAsync(
|
||||||
|
string rid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
RequireRid(rid);
|
||||||
|
PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
ClientVersionResolution resolution = current.Pointer is null
|
||||||
|
? (!File.Exists(CurrentPointerPath)
|
||||||
|
? new ClientVersionResolution(
|
||||||
|
ClientVersionState.Missing,
|
||||||
|
"No versioned client is installed. Check for updates to install one.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null)
|
||||||
|
: Invalid(current.Error ?? "The client activation pointer is invalid."))
|
||||||
|
: await ResolvePointerAsync(current.Pointer, rid, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
SetCached(resolution);
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<ClientVersionResolution> LoadAndRecoverUnderLeaseAsync(
|
||||||
|
string rid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
RequireRid(rid);
|
||||||
|
Directory.CreateDirectory(AppDirectory);
|
||||||
|
CleanupOwnedResidue();
|
||||||
|
|
||||||
|
PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (current.Pointer is not null)
|
||||||
|
{
|
||||||
|
ClientVersionResolution resolution = await ResolvePointerAsync(
|
||||||
|
current.Pointer,
|
||||||
|
rid,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
SetCached(resolution);
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
PointerRead previous = await ReadPointerAsync(PreviousPointerPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (previous.Pointer is not null)
|
||||||
|
{
|
||||||
|
ClientVersionResolution recovered = await ResolvePointerAsync(
|
||||||
|
previous.Pointer,
|
||||||
|
rid,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (recovered.IsVerified)
|
||||||
|
{
|
||||||
|
await WritePointerFileAsync(
|
||||||
|
CurrentPointerPath,
|
||||||
|
previous.Pointer,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
recovered = recovered with
|
||||||
|
{
|
||||||
|
Status = "Recovered the last valid client activation pointer.",
|
||||||
|
};
|
||||||
|
SetCached(recovered);
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientVersionResolution missingOrInvalid =
|
||||||
|
!File.Exists(CurrentPointerPath) && !File.Exists(PreviousPointerPath)
|
||||||
|
? new ClientVersionResolution(
|
||||||
|
ClientVersionState.Missing,
|
||||||
|
"No versioned client is installed. Check for updates to install one.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null)
|
||||||
|
: new ClientVersionResolution(
|
||||||
|
ClientVersionState.Invalid,
|
||||||
|
current.Error
|
||||||
|
?? previous.Error
|
||||||
|
?? "No valid client activation pointer could be recovered.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
SetCached(missingOrInvalid);
|
||||||
|
return missingOrInvalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<ClientVersionResolution> PromoteAndActivateUnderLeaseAsync(
|
||||||
|
string stagingDirectory,
|
||||||
|
LauncherVersion version,
|
||||||
|
string rid,
|
||||||
|
ReleaseArtifact artifact,
|
||||||
|
IReadOnlyList<ExtractedFileRecord> extractedFiles,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(stagingDirectory);
|
||||||
|
ArgumentNullException.ThrowIfNull(version);
|
||||||
|
ArgumentNullException.ThrowIfNull(artifact);
|
||||||
|
ArgumentNullException.ThrowIfNull(extractedFiles);
|
||||||
|
RequireRid(rid);
|
||||||
|
|
||||||
|
string staging = Path.GetFullPath(stagingDirectory);
|
||||||
|
RequireOwnedStagingPath(staging);
|
||||||
|
ValidateRequiredExecutables(extractedFiles, rid, launcherPayload: false);
|
||||||
|
if (extractedFiles.Any(file => string.Equals(
|
||||||
|
file.Path,
|
||||||
|
"install.json",
|
||||||
|
StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The client ZIP may not provide the launcher's install.json record.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var record = new ClientVersionRecord(
|
||||||
|
ClientVersionRecord.CurrentSchemaVersion,
|
||||||
|
version.Value,
|
||||||
|
rid,
|
||||||
|
artifact.Sha256.ToLowerInvariant(),
|
||||||
|
artifact.Size,
|
||||||
|
extractedFiles
|
||||||
|
.Select(file => new InstalledFileRecord(
|
||||||
|
file.Path,
|
||||||
|
file.Sha256,
|
||||||
|
file.Size,
|
||||||
|
file.UnixMode))
|
||||||
|
.OrderBy(file => file.Path, StringComparer.Ordinal)
|
||||||
|
.ToArray());
|
||||||
|
ValidateRecord(record, version, rid);
|
||||||
|
await AtomicJsonFile.WriteAsync(
|
||||||
|
GetMetadataPath(staging),
|
||||||
|
record,
|
||||||
|
SerializerOptions,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
ClientVersionResolution staged = await VerifyVersionDirectoryAsync(
|
||||||
|
staging,
|
||||||
|
version,
|
||||||
|
rid,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!staged.IsVerified)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(staged.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientActivationPointer? oldPointer = (await ReadPointerAsync(
|
||||||
|
CurrentPointerPath,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false)).Pointer;
|
||||||
|
string target = GetVersionDirectory(version);
|
||||||
|
if (Directory.Exists(target))
|
||||||
|
{
|
||||||
|
ClientVersionResolution existing = await VerifyVersionDirectoryAsync(
|
||||||
|
target,
|
||||||
|
version,
|
||||||
|
rid,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (existing.IsVerified
|
||||||
|
&& existing.Record is not null
|
||||||
|
&& string.Equals(
|
||||||
|
existing.Record.ArchiveSha256,
|
||||||
|
artifact.Sha256,
|
||||||
|
StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& existing.Record.ArchiveSize == artifact.Size)
|
||||||
|
{
|
||||||
|
SafeZipExtractor.TryDeleteDirectory(staging);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (oldPointer is not null
|
||||||
|
&& string.Equals(
|
||||||
|
oldPointer.CurrentVersion,
|
||||||
|
version.Value,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The active client version is corrupt and cannot be replaced in place. "
|
||||||
|
+ "Roll back before repairing it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string quarantine = Path.Combine(
|
||||||
|
AppDirectory,
|
||||||
|
$".client-corrupt-{Guid.NewGuid():N}");
|
||||||
|
Directory.Move(target, quarantine);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Move(staging, target);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Directory.Move(quarantine, target);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
SafeZipExtractor.TryDeleteDirectory(quarantine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Directory.Move(staging, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
string? previousVersion = oldPointer is null
|
||||||
|
|| string.Equals(
|
||||||
|
oldPointer.CurrentVersion,
|
||||||
|
version.Value,
|
||||||
|
StringComparison.Ordinal)
|
||||||
|
? oldPointer?.PreviousVersion
|
||||||
|
: oldPointer.CurrentVersion;
|
||||||
|
var pointer = new ClientActivationPointer(
|
||||||
|
ClientActivationPointer.CurrentSchemaVersion,
|
||||||
|
version.Value,
|
||||||
|
previousVersion);
|
||||||
|
await SavePointerAsync(pointer, cancellationToken).ConfigureAwait(false);
|
||||||
|
ClientVersionResolution resolution = await ResolvePointerAsync(
|
||||||
|
pointer,
|
||||||
|
rid,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!resolution.IsVerified)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(resolution.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
SetCached(resolution);
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClientVersionResolution> RollbackAsync(
|
||||||
|
string rid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive();
|
||||||
|
PointerRead read = await ReadPointerAsync(CurrentPointerPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
ClientActivationPointer pointer = read.Pointer
|
||||||
|
?? throw new LauncherUpdateException(
|
||||||
|
read.Error ?? "There is no active client version to roll back.");
|
||||||
|
if (string.IsNullOrEmpty(pointer.PreviousVersion))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"There is no previous client version available for rollback.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LauncherVersion previous = LauncherVersion.Parse(pointer.PreviousVersion);
|
||||||
|
ClientVersionResolution verified = await VerifyVersionDirectoryAsync(
|
||||||
|
GetVersionDirectory(previous),
|
||||||
|
previous,
|
||||||
|
rid,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!verified.IsVerified)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The previous client version cannot be activated: {verified.Status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var swapped = new ClientActivationPointer(
|
||||||
|
ClientActivationPointer.CurrentSchemaVersion,
|
||||||
|
previous.Value,
|
||||||
|
pointer.CurrentVersion);
|
||||||
|
await SavePointerAsync(swapped, cancellationToken).ConfigureAwait(false);
|
||||||
|
ClientVersionResolution resolution = await ResolvePointerAsync(
|
||||||
|
swapped,
|
||||||
|
rid,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
SetCached(resolution);
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string CreateClientStagingDirectory(Guid transactionId)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(AppDirectory);
|
||||||
|
return Path.Combine(AppDirectory, $".client-staging-{transactionId:N}");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void ValidateRequiredExecutables(
|
||||||
|
IReadOnlyList<ExtractedFileRecord> files,
|
||||||
|
string rid,
|
||||||
|
bool launcherPayload)
|
||||||
|
{
|
||||||
|
string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty;
|
||||||
|
string[] required = launcherPayload
|
||||||
|
? ["acdream-launcher" + suffix]
|
||||||
|
: ["AcDream.App" + suffix, "acdream-headless" + suffix];
|
||||||
|
foreach (string path in required)
|
||||||
|
{
|
||||||
|
ExtractedFileRecord? file = files.SingleOrDefault(candidate =>
|
||||||
|
string.Equals(candidate.Path, path, StringComparison.Ordinal));
|
||||||
|
if (file is null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release ZIP is missing required root executable '{path}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rid.StartsWith("linux-", StringComparison.Ordinal)
|
||||||
|
&& (file.UnixMode & (int)UnixFileMode.UserExecute) == 0)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The Linux release executable '{path}' lacks owner execute permission.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ClientVersionResolution> ResolvePointerAsync(
|
||||||
|
ClientActivationPointer pointer,
|
||||||
|
string rid,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? error = ValidatePointer(pointer);
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
return Invalid(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
LauncherVersion version = LauncherVersion.Parse(pointer.CurrentVersion);
|
||||||
|
ClientVersionResolution resolution = await VerifyVersionDirectoryAsync(
|
||||||
|
GetVersionDirectory(version),
|
||||||
|
version,
|
||||||
|
rid,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return resolution.IsVerified
|
||||||
|
? resolution with { PreviousVersion = pointer.PreviousVersion }
|
||||||
|
: resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ClientVersionResolution> VerifyVersionDirectoryAsync(
|
||||||
|
string directory,
|
||||||
|
LauncherVersion version,
|
||||||
|
string rid,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
return Invalid($"Client version {version} directory is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RejectReparseTree(directory);
|
||||||
|
ClientVersionRecord? record = await ReadStrictAsync<ClientVersionRecord>(
|
||||||
|
GetMetadataPath(directory),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (record is null)
|
||||||
|
{
|
||||||
|
return Invalid($"Client version {version} install.json is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string? contractError = ValidateRecord(record, version, rid);
|
||||||
|
if (contractError is not null)
|
||||||
|
{
|
||||||
|
return Invalid(contractError);
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] actualFiles = Directory.EnumerateFiles(
|
||||||
|
directory,
|
||||||
|
"*",
|
||||||
|
SearchOption.AllDirectories)
|
||||||
|
.Select(path => NormalizeRelative(directory, path))
|
||||||
|
.Where(path => !string.Equals(
|
||||||
|
path,
|
||||||
|
"install.json",
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(path => path, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
string[] recordedFiles = record.Files
|
||||||
|
.Select(file => file.Path)
|
||||||
|
.OrderBy(path => path, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (!actualFiles.SequenceEqual(recordedFiles, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
return Invalid(
|
||||||
|
$"Client version {version} contains missing or unrecorded files.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (InstalledFileRecord file in record.Files)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string path = ResolveContained(directory, file.Path);
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
if (!info.Exists || info.Length != file.Size)
|
||||||
|
{
|
||||||
|
return Invalid(
|
||||||
|
$"Client version {version} file '{file.Path}' size is corrupt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string sha256 = await _computeSha256(path, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!string.Equals(
|
||||||
|
sha256,
|
||||||
|
file.Sha256,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Invalid(
|
||||||
|
$"Client version {version} file '{file.Path}' SHA-256 is corrupt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsLinux()
|
||||||
|
&& ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode)
|
||||||
|
{
|
||||||
|
return Invalid(
|
||||||
|
$"Client version {version} file '{file.Path}' mode is corrupt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ClientVersionResolution(
|
||||||
|
ClientVersionState.Verified,
|
||||||
|
$"Client version {version} verified.",
|
||||||
|
version,
|
||||||
|
directory,
|
||||||
|
null,
|
||||||
|
record);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or JsonException
|
||||||
|
or NotSupportedException
|
||||||
|
or FormatException
|
||||||
|
or LauncherUpdateException)
|
||||||
|
{
|
||||||
|
return Invalid(
|
||||||
|
$"Client version {version} could not be verified: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SavePointerAsync(
|
||||||
|
ClientActivationPointer pointer,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? error = ValidatePointer(pointer);
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(CurrentPointerPath))
|
||||||
|
{
|
||||||
|
byte[] previous = await File.ReadAllBytesAsync(
|
||||||
|
CurrentPointerPath,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
PointerRead validPrevious = ParsePointer(previous);
|
||||||
|
if (validPrevious.Pointer is not null)
|
||||||
|
{
|
||||||
|
await AtomicJsonFile.WriteBytesAsync(
|
||||||
|
PreviousPointerPath,
|
||||||
|
previous,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await WritePointerFileAsync(CurrentPointerPath, pointer, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task WritePointerFileAsync(
|
||||||
|
string path,
|
||||||
|
ClientActivationPointer pointer,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
AtomicJsonFile.WriteAsync(path, pointer, SerializerOptions, cancellationToken);
|
||||||
|
|
||||||
|
private static async Task<PointerRead> ReadPointerAsync(
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return new PointerRead(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return ParsePointer(bytes);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return new PointerRead(null, $"Client pointer could not be read: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PointerRead ParsePointer(ReadOnlyMemory<byte> bytes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ClientActivationPointer? pointer = ParseStrict<ClientActivationPointer>(bytes.Span);
|
||||||
|
string? error = pointer is null
|
||||||
|
? "Client pointer is empty."
|
||||||
|
: ValidatePointer(pointer);
|
||||||
|
return error is null
|
||||||
|
? new PointerRead(pointer, null)
|
||||||
|
: new PointerRead(null, error);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is JsonException
|
||||||
|
or LauncherUpdateException
|
||||||
|
or FormatException)
|
||||||
|
{
|
||||||
|
return new PointerRead(null, $"Client pointer is invalid: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidatePointer(ClientActivationPointer pointer)
|
||||||
|
{
|
||||||
|
if (pointer.SchemaVersion != ClientActivationPointer.CurrentSchemaVersion)
|
||||||
|
{
|
||||||
|
return $"Client pointer schema version {pointer.SchemaVersion} is not supported.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!LauncherVersion.TryParse(pointer.CurrentVersion, out _))
|
||||||
|
{
|
||||||
|
return "Client pointer currentVersion is invalid.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointer.PreviousVersion is not null
|
||||||
|
&& (!LauncherVersion.TryParse(pointer.PreviousVersion, out _)
|
||||||
|
|| string.Equals(
|
||||||
|
pointer.PreviousVersion,
|
||||||
|
pointer.CurrentVersion,
|
||||||
|
StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
return "Client pointer previousVersion is invalid.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateRecord(
|
||||||
|
ClientVersionRecord record,
|
||||||
|
LauncherVersion version,
|
||||||
|
string rid)
|
||||||
|
{
|
||||||
|
if (record.SchemaVersion != ClientVersionRecord.CurrentSchemaVersion)
|
||||||
|
{
|
||||||
|
return $"Client install schema version {record.SchemaVersion} is not supported.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(record.Version, version.Value, StringComparison.Ordinal)
|
||||||
|
|| !LauncherVersion.TryParse(record.Version, out _))
|
||||||
|
{
|
||||||
|
return "Client install version does not match its directory.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(record.Rid, rid, StringComparison.Ordinal)
|
||||||
|
|| !LauncherRuntimeIdentity.IsValidRid(record.Rid))
|
||||||
|
{
|
||||||
|
return $"Client install RID does not match '{rid}'.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ReleaseManifestClient.IsSha256(record.ArchiveSha256)
|
||||||
|
|| record.ArchiveSize <= 0
|
||||||
|
|| record.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes)
|
||||||
|
{
|
||||||
|
return "Client install archive metadata is invalid.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.Files is null || record.Files.Count == 0)
|
||||||
|
{
|
||||||
|
return "Client install file list is empty.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
string? prior = null;
|
||||||
|
foreach (InstalledFileRecord file in record.Files)
|
||||||
|
{
|
||||||
|
if (!IsNormalizedRelative(file.Path)
|
||||||
|
|| !paths.Add(file.Path)
|
||||||
|
|| !ReleaseManifestClient.IsSha256(file.Sha256)
|
||||||
|
|| file.Size < 0
|
||||||
|
|| file.UnixMode is < 0 or > 0x1FF
|
||||||
|
|| (prior is not null
|
||||||
|
&& string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0))
|
||||||
|
{
|
||||||
|
return "Client install file metadata is invalid, duplicated, or unsorted.";
|
||||||
|
}
|
||||||
|
|
||||||
|
prior = file.Path;
|
||||||
|
}
|
||||||
|
|
||||||
|
string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty;
|
||||||
|
foreach (string required in new[]
|
||||||
|
{
|
||||||
|
"AcDream.App" + suffix,
|
||||||
|
"acdream-headless" + suffix,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if (!paths.Contains(required))
|
||||||
|
{
|
||||||
|
return $"Client install is missing '{required}'.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T?> ReadStrictAsync<T>(
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return ParseStrict<T>(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static T? ParseStrict<T>(
|
||||||
|
ReadOnlySpan<byte> bytes,
|
||||||
|
JsonSerializerOptions? serializerOptions = null)
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(
|
||||||
|
bytes.ToArray(),
|
||||||
|
new JsonDocumentOptions
|
||||||
|
{
|
||||||
|
AllowTrailingCommas = false,
|
||||||
|
CommentHandling = JsonCommentHandling.Disallow,
|
||||||
|
MaxDepth = 32,
|
||||||
|
});
|
||||||
|
RejectDuplicateProperties(document.RootElement, "$" );
|
||||||
|
return document.RootElement.Deserialize<T>(
|
||||||
|
serializerOptions ?? SerializerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RejectDuplicateProperties(JsonElement element, string path)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (JsonProperty property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (!names.Add(property.Name))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Duplicate JSON property '{path}.{property.Name}' is not allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RejectDuplicateProperties(property.Value, $"{path}.{property.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
int index = 0;
|
||||||
|
foreach (JsonElement item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
RejectDuplicateProperties(item, $"{path}[{index++}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupOwnedResidue()
|
||||||
|
{
|
||||||
|
foreach (string path in Directory.EnumerateDirectories(
|
||||||
|
AppDirectory,
|
||||||
|
".client-staging-*",
|
||||||
|
SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
string suffix = Path.GetFileName(path)[".client-staging-".Length..];
|
||||||
|
if (Guid.TryParseExact(suffix, "N", out _))
|
||||||
|
{
|
||||||
|
SafeZipExtractor.TryDeleteDirectory(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string path in Directory.EnumerateFiles(
|
||||||
|
AppDirectory,
|
||||||
|
".current*.tmp",
|
||||||
|
SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
string fileName = Path.GetFileName(path);
|
||||||
|
string[] parts = fileName.Split('.');
|
||||||
|
if (parts.Length >= 4
|
||||||
|
&& string.Equals(parts[^1], "tmp", StringComparison.Ordinal)
|
||||||
|
&& Guid.TryParseExact(parts[^2], "N", out _))
|
||||||
|
{
|
||||||
|
VerifiedArtifactDownloader.TryDelete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RequireOwnedStagingPath(string path)
|
||||||
|
{
|
||||||
|
string parent = Path.GetDirectoryName(path) ?? string.Empty;
|
||||||
|
string fileName = Path.GetFileName(path);
|
||||||
|
if (!PathsEqual(parent, AppDirectory)
|
||||||
|
|| !fileName.StartsWith(".client-staging-", StringComparison.Ordinal)
|
||||||
|
|| !Guid.TryParseExact(fileName[".client-staging-".Length..], "N", out _))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The client extraction path is not an owned LA10 staging directory.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void RejectReparseTree(string root)
|
||||||
|
{
|
||||||
|
if ((File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The client version directory is a reparse point.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending = new Stack<string>();
|
||||||
|
pending.Push(root);
|
||||||
|
while (pending.TryPop(out string? directory))
|
||||||
|
{
|
||||||
|
foreach (string path in Directory.EnumerateFileSystemEntries(
|
||||||
|
directory,
|
||||||
|
"*",
|
||||||
|
SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
FileAttributes attributes = File.GetAttributes(path);
|
||||||
|
if ((attributes & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Client install path '{NormalizeRelative(root, path)}' is a reparse point.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attributes & FileAttributes.Directory) != 0)
|
||||||
|
{
|
||||||
|
pending.Push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRelative(string root, string path) =>
|
||||||
|
Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||||
|
|
||||||
|
internal static bool IsNormalizedRelative(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)
|
||||||
|
|| path.Length > 512
|
||||||
|
|| path.IndexOf('\0') >= 0
|
||||||
|
|| path.Contains('\\', StringComparison.Ordinal)
|
||||||
|
|| path.Contains(':', StringComparison.Ordinal)
|
||||||
|
|| path.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
|| Path.IsPathRooted(path))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] parts = path.Split('/');
|
||||||
|
return parts.All(part =>
|
||||||
|
part.Length > 0
|
||||||
|
&& part is not ("." or "..")
|
||||||
|
&& !part.EndsWith(' ')
|
||||||
|
&& !part.EndsWith('.')
|
||||||
|
&& !part.Any(character =>
|
||||||
|
char.IsControl(character)
|
||||||
|
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|
||||||
|
&& !IsWindowsDeviceName(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ResolveContained(string root, string relative)
|
||||||
|
{
|
||||||
|
if (!IsNormalizedRelative(relative))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException($"Unsafe relative path '{relative}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string fullRoot = Path.GetFullPath(root);
|
||||||
|
string path = Path.GetFullPath(
|
||||||
|
Path.Combine(fullRoot, relative.Replace('/', Path.DirectorySeparatorChar)));
|
||||||
|
string prefix = Path.EndsInDirectorySeparator(fullRoot)
|
||||||
|
? fullRoot
|
||||||
|
: fullRoot + Path.DirectorySeparatorChar;
|
||||||
|
if (!path.StartsWith(
|
||||||
|
prefix,
|
||||||
|
OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException($"Path '{relative}' escaped its root.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWindowsDeviceName(string segment)
|
||||||
|
{
|
||||||
|
string stem = segment.Split('.')[0];
|
||||||
|
return stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (stem.Length == 4
|
||||||
|
&& (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
|
||||||
|
&& stem[3] is >= '1' and <= '9');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PathsEqual(string left, string right) =>
|
||||||
|
string.Equals(
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
|
||||||
|
OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static void RequireRid(string rid)
|
||||||
|
{
|
||||||
|
if (!LauncherRuntimeIdentity.IsValidRid(rid))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("RID is invalid.", nameof(rid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetCached(ClientVersionResolution resolution)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_cached = resolution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClientVersionResolution Invalid(string status) =>
|
||||||
|
new(ClientVersionState.Invalid, status, null, null, null, null);
|
||||||
|
|
||||||
|
private sealed record PointerRead(ClientActivationPointer? Pointer, string? Error);
|
||||||
|
}
|
||||||
33
src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs
Normal file
33
src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs
Normal 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 '-');
|
||||||
|
}
|
||||||
299
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs
Normal file
299
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
public sealed record SelfUpdateStartupResult(
|
||||||
|
bool ShouldExit,
|
||||||
|
int ExitCode,
|
||||||
|
string[] RemainingArguments);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process-level rename dance for launcher self-update. Every child argument
|
||||||
|
/// is passed through <see cref="ProcessStartInfo.ArgumentList"/> with
|
||||||
|
/// <c>UseShellExecute=false</c>; no path or PID is ever interpolated into a
|
||||||
|
/// shell command.
|
||||||
|
/// </summary>
|
||||||
|
public static class LauncherSelfUpdateBootstrap
|
||||||
|
{
|
||||||
|
public const string HelperArgument = "--acdream-self-update-helper-v1";
|
||||||
|
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
|
||||||
|
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
public static async Task<SelfUpdateStartupResult> HandleAsync(
|
||||||
|
string[] args,
|
||||||
|
LauncherSelfUpdateManager manager,
|
||||||
|
string launcherBaseDirectory,
|
||||||
|
string currentExecutablePath,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(args);
|
||||||
|
ArgumentNullException.ThrowIfNull(manager);
|
||||||
|
string baseDirectory = Path.TrimEndingDirectorySeparator(
|
||||||
|
Path.GetFullPath(launcherBaseDirectory));
|
||||||
|
string executable = Path.GetFullPath(currentExecutablePath);
|
||||||
|
|
||||||
|
if (args.Length > 0
|
||||||
|
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (args.Length != 4
|
||||||
|
|| !int.TryParse(
|
||||||
|
args[1],
|
||||||
|
System.Globalization.NumberStyles.None,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out int parentPid)
|
||||||
|
|| parentPid <= 0)
|
||||||
|
{
|
||||||
|
return new SelfUpdateStartupResult(true, 64, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
int exitCode = await RunHelperAsync(
|
||||||
|
manager,
|
||||||
|
parentPid,
|
||||||
|
args[2],
|
||||||
|
args[3],
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return new SelfUpdateStartupResult(true, exitCode, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.Length > 0
|
||||||
|
&& string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (args.Length != 2)
|
||||||
|
{
|
||||||
|
return new SelfUpdateStartupResult(true, 64, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.ConfirmAsync(
|
||||||
|
args[1],
|
||||||
|
baseDirectory,
|
||||||
|
executable,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return new SelfUpdateStartupResult(false, 0, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (plan is null)
|
||||||
|
{
|
||||||
|
return new SelfUpdateStartupResult(false, 0, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The pending self-update targets a different launcher directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||||
|
{
|
||||||
|
if (!manager.IsConfirmed(plan.TransactionId))
|
||||||
|
{
|
||||||
|
await manager.ConfirmAsync(
|
||||||
|
plan.TransactionId,
|
||||||
|
baseDirectory,
|
||||||
|
executable,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.CompleteConfirmedAsync(
|
||||||
|
plan.TransactionId,
|
||||||
|
baseDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return new SelfUpdateStartupResult(false, 0, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||||
|
? ".exe"
|
||||||
|
: string.Empty;
|
||||||
|
string expectedExecutable = ClientVersionStore.ResolveContained(
|
||||||
|
baseDirectory,
|
||||||
|
"acdream-launcher" + suffix);
|
||||||
|
if (!PathsEqual(executable, expectedExecutable))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"Self-update can start only from the published acdream-launcher executable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string helperPath = manager.GetHelperPath(plan.TransactionId);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!);
|
||||||
|
VerifiedArtifactDownloader.TryDelete(helperPath);
|
||||||
|
File.Copy(executable, helperPath, overwrite: false);
|
||||||
|
if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(
|
||||||
|
helperPath,
|
||||||
|
UnixFileMode.UserRead
|
||||||
|
| UnixFileMode.UserWrite
|
||||||
|
| UnixFileMode.UserExecute);
|
||||||
|
}
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo(helperPath)
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = manager.GetTransactionDirectory(plan.TransactionId),
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add(HelperArgument);
|
||||||
|
startInfo.ArgumentList.Add(
|
||||||
|
Environment.ProcessId.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
startInfo.ArgumentList.Add(baseDirectory);
|
||||||
|
startInfo.ArgumentList.Add(plan.TransactionId);
|
||||||
|
_ = Process.Start(startInfo)
|
||||||
|
?? throw new LauncherUpdateException(
|
||||||
|
"The launcher self-update helper could not be started.");
|
||||||
|
return new SelfUpdateStartupResult(true, 0, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> RunHelperAsync(
|
||||||
|
LauncherSelfUpdateManager manager,
|
||||||
|
int parentPid,
|
||||||
|
string targetDirectory,
|
||||||
|
string transactionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new LauncherUpdateException("The helper found no pending self-update.");
|
||||||
|
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The helper transaction does not match the pending self-update.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||||
|
? ".exe"
|
||||||
|
: string.Empty;
|
||||||
|
string launcherPath = ClientVersionStore.ResolveContained(
|
||||||
|
targetDirectory,
|
||||||
|
"acdream-launcher" + suffix);
|
||||||
|
var startInfo = new ProcessStartInfo(launcherPath)
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add(ConfirmArgument);
|
||||||
|
startInfo.ArgumentList.Add(transactionId);
|
||||||
|
|
||||||
|
Process? replacement = null;
|
||||||
|
UpdateSessionBarrier.ExclusiveLease? updateLease = null;
|
||||||
|
bool appliedByThisHelper = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
|
||||||
|
updateLease = manager.Barrier.AcquireExclusive();
|
||||||
|
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
appliedByThisHelper = true;
|
||||||
|
replacement = Process.Start(startInfo)
|
||||||
|
?? throw new LauncherUpdateException(
|
||||||
|
"The updated launcher could not be started.");
|
||||||
|
DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout;
|
||||||
|
while (!manager.IsConfirmed(transactionId))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
replacement.HasExited
|
||||||
|
? $"The updated launcher exited with code {replacement.ExitCode} "
|
||||||
|
+ "before confirming startup."
|
||||||
|
: "The updated launcher did not confirm startup in time.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.CompleteConfirmedAsync(
|
||||||
|
transactionId,
|
||||||
|
targetDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (replacement is { HasExited: false })
|
||||||
|
{
|
||||||
|
replacement.Kill(entireProcessTree: true);
|
||||||
|
await replacement.WaitForExitAsync(CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (appliedByThisHelper)
|
||||||
|
{
|
||||||
|
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (pending?.State == SelfUpdatePlanState.Applying)
|
||||||
|
{
|
||||||
|
_ = await manager.RecoverApplyingAsync(
|
||||||
|
targetDirectory,
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||||
|
{
|
||||||
|
_ = await manager.RollbackAwaitingConfirmationAsync(
|
||||||
|
targetDirectory,
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Do not start an executable from an ambiguous half-applied
|
||||||
|
// state. A subsequent startup replays the durable journal.
|
||||||
|
return 75;
|
||||||
|
}
|
||||||
|
|
||||||
|
var restored = new ProcessStartInfo(launcherPath)
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
||||||
|
};
|
||||||
|
_ = Process.Start(restored);
|
||||||
|
return 74;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
replacement?.Dispose();
|
||||||
|
updateLease?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForParentExitAsync(
|
||||||
|
int parentPid,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using Process parent = Process.GetProcessById(parentPid);
|
||||||
|
if (parent.Id == Environment.ProcessId)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The self-update helper cannot wait on itself.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await parent.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// The parent exited before the helper opened it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PathsEqual(string left, string right) =>
|
||||||
|
string.Equals(
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
|
||||||
|
OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal);
|
||||||
|
}
|
||||||
786
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs
Normal file
786
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs
Normal file
|
|
@ -0,0 +1,786 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
public enum SelfUpdatePlanState
|
||||||
|
{
|
||||||
|
Staged,
|
||||||
|
Applying,
|
||||||
|
AwaitingConfirmation,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SelfUpdateApplyEntry(string Path, bool HadOriginal);
|
||||||
|
|
||||||
|
public sealed record SelfUpdatePlan(
|
||||||
|
int SchemaVersion,
|
||||||
|
string TransactionId,
|
||||||
|
SelfUpdatePlanState State,
|
||||||
|
string Version,
|
||||||
|
string Rid,
|
||||||
|
string TargetDirectory,
|
||||||
|
string ArchiveSha256,
|
||||||
|
long ArchiveSize,
|
||||||
|
IReadOnlyList<InstalledFileRecord> Files,
|
||||||
|
IReadOnlyList<SelfUpdateApplyEntry>? Apply)
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SelfUpdateStageResult(
|
||||||
|
LauncherVersion Version,
|
||||||
|
string PendingPlanPath,
|
||||||
|
string Status);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Durable self-update transaction owner. It stages a verified launcher ZIP;
|
||||||
|
/// a separately copied helper performs move-only replacement after the parent
|
||||||
|
/// exits and can replay rollback after a crash at any file boundary.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LauncherSelfUpdateManager
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = false,
|
||||||
|
WriteIndented = true,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||||
|
MaxDepth = 32,
|
||||||
|
Converters = { new JsonStringEnumConverter<SelfUpdatePlanState>(
|
||||||
|
JsonNamingPolicy.CamelCase,
|
||||||
|
allowIntegerValues: false) },
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly VerifiedArtifactDownloader _downloader;
|
||||||
|
private readonly SafeZipExtractor _extractor;
|
||||||
|
|
||||||
|
public LauncherSelfUpdateManager(
|
||||||
|
ApplicationPathSet paths,
|
||||||
|
HttpClient httpClient,
|
||||||
|
SafeZipExtractor? extractor = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
RootDirectory = Path.Combine(
|
||||||
|
Path.GetFullPath(paths.DataDirectory),
|
||||||
|
"launcher-update");
|
||||||
|
TransactionsDirectory = Path.Combine(RootDirectory, "transactions");
|
||||||
|
PendingPlanPath = Path.Combine(RootDirectory, "pending.json");
|
||||||
|
Barrier = new UpdateSessionBarrier(paths.DataDirectory);
|
||||||
|
_downloader = new VerifiedArtifactDownloader(
|
||||||
|
httpClient ?? throw new ArgumentNullException(nameof(httpClient)));
|
||||||
|
_extractor = extractor ?? new SafeZipExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string RootDirectory { get; }
|
||||||
|
|
||||||
|
public string TransactionsDirectory { get; }
|
||||||
|
|
||||||
|
public string PendingPlanPath { get; }
|
||||||
|
|
||||||
|
public UpdateSessionBarrier Barrier { get; }
|
||||||
|
|
||||||
|
public async Task<SelfUpdateStageResult> StageAsync(
|
||||||
|
ReleaseManifest manifest,
|
||||||
|
string rid,
|
||||||
|
string targetDirectory,
|
||||||
|
IProgress<ArtifactDownloadProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
ReleaseArtifact artifact = manifest.RequireLauncher(rid);
|
||||||
|
return await StageAsync(
|
||||||
|
manifest.Version,
|
||||||
|
rid,
|
||||||
|
artifact,
|
||||||
|
targetDirectory,
|
||||||
|
progress,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<SelfUpdateStageResult> StageAsync(
|
||||||
|
LauncherVersion version,
|
||||||
|
string rid,
|
||||||
|
ReleaseArtifact artifact,
|
||||||
|
string targetDirectory,
|
||||||
|
IProgress<ArtifactDownloadProgress>? progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(version);
|
||||||
|
ArgumentNullException.ThrowIfNull(artifact);
|
||||||
|
if (!LauncherRuntimeIdentity.IsValidRid(rid))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("RID is invalid.", nameof(rid));
|
||||||
|
}
|
||||||
|
|
||||||
|
string target = NormalizeTargetDirectory(targetDirectory);
|
||||||
|
Directory.CreateDirectory(RootDirectory);
|
||||||
|
Directory.CreateDirectory(TransactionsDirectory);
|
||||||
|
SelfUpdatePlan? existing = await LoadPendingAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Launcher self-update {existing.Version} is already {existing.State}. "
|
||||||
|
+ "Restart the launcher to finish it before staging another.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string transactionId = Guid.NewGuid().ToString("N");
|
||||||
|
string transactionDirectory = GetTransactionDirectory(transactionId);
|
||||||
|
string payloadDirectory = GetPayloadDirectory(transactionId);
|
||||||
|
string archivePath = Path.Combine(transactionDirectory, "launcher.zip");
|
||||||
|
Directory.CreateDirectory(transactionDirectory);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = await _downloader.DownloadAsync(
|
||||||
|
artifact,
|
||||||
|
archivePath,
|
||||||
|
progress,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
IReadOnlyList<ExtractedFileRecord> extracted = await _extractor.ExtractAsync(
|
||||||
|
archivePath,
|
||||||
|
payloadDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
ClientVersionStore.ValidateRequiredExecutables(
|
||||||
|
extracted,
|
||||||
|
rid,
|
||||||
|
launcherPayload: true);
|
||||||
|
|
||||||
|
var plan = new SelfUpdatePlan(
|
||||||
|
SelfUpdatePlan.CurrentSchemaVersion,
|
||||||
|
transactionId,
|
||||||
|
SelfUpdatePlanState.Staged,
|
||||||
|
version.Value,
|
||||||
|
rid,
|
||||||
|
target,
|
||||||
|
artifact.Sha256.ToLowerInvariant(),
|
||||||
|
artifact.Size,
|
||||||
|
extracted.Select(file => new InstalledFileRecord(
|
||||||
|
file.Path,
|
||||||
|
file.Sha256,
|
||||||
|
file.Size,
|
||||||
|
file.UnixMode))
|
||||||
|
.OrderBy(file => file.Path, StringComparer.Ordinal)
|
||||||
|
.ToArray(),
|
||||||
|
null);
|
||||||
|
ValidatePlan(plan, target);
|
||||||
|
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||||
|
VerifiedArtifactDownloader.TryDelete(archivePath);
|
||||||
|
return new SelfUpdateStageResult(
|
||||||
|
version,
|
||||||
|
PendingPlanPath,
|
||||||
|
$"Launcher {version} is staged and will be applied on next start.");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (!File.Exists(PendingPlanPath))
|
||||||
|
{
|
||||||
|
SafeZipExtractor.TryDeleteDirectory(transactionDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SelfUpdatePlan?> LoadPendingAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(PendingPlanPath))
|
||||||
|
{
|
||||||
|
CleanupOwnedResidue(keepTransactionId: null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] bytes = await File.ReadAllBytesAsync(PendingPlanPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
SelfUpdatePlan? plan = ClientVersionStore.ParseStrict<SelfUpdatePlan>(
|
||||||
|
bytes,
|
||||||
|
SerializerOptions);
|
||||||
|
if (plan is null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The self-update plan is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidatePlan(plan, plan.TargetDirectory);
|
||||||
|
CleanupOwnedResidue(plan.TransactionId);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (LauncherUpdateException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or JsonException
|
||||||
|
or FormatException
|
||||||
|
or NotSupportedException)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The pending launcher self-update is invalid: {ex.Message}",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SelfUpdatePlan> ApplyPendingAsync(
|
||||||
|
string expectedTargetDirectory,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||||
|
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new LauncherUpdateException("There is no staged launcher self-update.");
|
||||||
|
ValidatePlan(plan, expectedTarget);
|
||||||
|
|
||||||
|
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||||
|
{
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.State == SelfUpdatePlanState.Applying)
|
||||||
|
{
|
||||||
|
plan = await RollbackApplyingAsync(plan, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||||
|
var apply = new List<SelfUpdateApplyEntry>(plan.Files.Count);
|
||||||
|
foreach (InstalledFileRecord file in plan.Files)
|
||||||
|
{
|
||||||
|
string targetPath = ClientVersionStore.ResolveContained(
|
||||||
|
expectedTarget,
|
||||||
|
file.Path);
|
||||||
|
if (Directory.Exists(targetPath))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Self-update target '{file.Path}' is unexpectedly a directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
apply.Add(new SelfUpdateApplyEntry(file.Path, File.Exists(targetPath)));
|
||||||
|
}
|
||||||
|
|
||||||
|
plan = plan with
|
||||||
|
{
|
||||||
|
State = SelfUpdatePlanState.Applying,
|
||||||
|
Apply = apply,
|
||||||
|
};
|
||||||
|
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
string payload = GetPayloadDirectory(plan.TransactionId);
|
||||||
|
string backup = GetBackupDirectory(plan.TransactionId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (SelfUpdateApplyEntry entry in plan.Apply)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path);
|
||||||
|
string targetPath = ClientVersionStore.ResolveContained(expectedTarget, entry.Path);
|
||||||
|
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
|
||||||
|
EnsureSafeParent(expectedTarget, targetPath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!);
|
||||||
|
if (entry.HadOriginal)
|
||||||
|
{
|
||||||
|
File.Move(targetPath, backupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(stagedPath, targetPath);
|
||||||
|
InstalledFileRecord file = plan.Files.Single(candidate =>
|
||||||
|
string.Equals(candidate.Path, entry.Path, StringComparison.Ordinal));
|
||||||
|
if (OperatingSystem.IsLinux() && file.UnixMode != 0)
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(targetPath, (UnixFileMode)file.UnixMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plan = plan with { State = SelfUpdatePlanState.AwaitingConfirmation };
|
||||||
|
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await RollbackApplyingAsync(plan, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SelfUpdatePlan> RecoverApplyingAsync(
|
||||||
|
string expectedTargetDirectory,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||||
|
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new LauncherUpdateException("There is no pending self-update.");
|
||||||
|
ValidatePlan(plan, expectedTarget);
|
||||||
|
return plan.State == SelfUpdatePlanState.Applying
|
||||||
|
? await RollbackApplyingAsync(plan, cancellationToken).ConfigureAwait(false)
|
||||||
|
: plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ConfirmAsync(
|
||||||
|
string transactionId,
|
||||||
|
string expectedTargetDirectory,
|
||||||
|
string currentExecutablePath,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new LauncherUpdateException("There is no self-update to confirm.");
|
||||||
|
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||||
|
ValidatePlan(plan, expectedTarget);
|
||||||
|
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|
||||||
|
|| plan.State != SelfUpdatePlanState.AwaitingConfirmation)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The running launcher does not match the pending confirmation plan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||||
|
? ".exe"
|
||||||
|
: string.Empty;
|
||||||
|
string expectedExecutable = ClientVersionStore.ResolveContained(
|
||||||
|
expectedTarget,
|
||||||
|
"acdream-launcher" + suffix);
|
||||||
|
if (!PathsEqual(expectedExecutable, currentExecutablePath))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"Only the newly installed launcher executable may confirm self-update.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await VerifyAppliedTargetsAsync(plan, expectedTarget, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
string confirmationPath = GetConfirmationPath(transactionId);
|
||||||
|
await AtomicJsonFile.WriteBytesAsync(
|
||||||
|
confirmationPath,
|
||||||
|
"confirmed"u8.ToArray(),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConfirmed(string transactionId) =>
|
||||||
|
File.Exists(GetConfirmationPath(transactionId));
|
||||||
|
|
||||||
|
public async Task CompleteConfirmedAsync(
|
||||||
|
string transactionId,
|
||||||
|
string expectedTargetDirectory,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new LauncherUpdateException("There is no self-update to complete.");
|
||||||
|
ValidatePlan(plan, NormalizeTargetDirectory(expectedTargetDirectory));
|
||||||
|
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|
||||||
|
|| plan.State != SelfUpdatePlanState.AwaitingConfirmation
|
||||||
|
|| !IsConfirmed(transactionId))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The self-update is not confirmed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(PendingPlanPath);
|
||||||
|
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SelfUpdatePlan> RollbackAwaitingConfirmationAsync(
|
||||||
|
string expectedTargetDirectory,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||||
|
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new LauncherUpdateException("There is no self-update to roll back.");
|
||||||
|
ValidatePlan(plan, expectedTarget);
|
||||||
|
if (plan.State != SelfUpdatePlanState.AwaitingConfirmation)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The pending self-update is not awaiting confirmation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
plan = plan with { State = SelfUpdatePlanState.Applying };
|
||||||
|
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||||
|
return await RollbackApplyingAsync(plan, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetTransactionDirectory(string transactionId)
|
||||||
|
{
|
||||||
|
RequireTransactionId(transactionId);
|
||||||
|
return Path.Combine(TransactionsDirectory, transactionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetPayloadDirectory(string transactionId) =>
|
||||||
|
Path.Combine(GetTransactionDirectory(transactionId), "payload");
|
||||||
|
|
||||||
|
public string GetBackupDirectory(string transactionId) =>
|
||||||
|
Path.Combine(GetTransactionDirectory(transactionId), "backup");
|
||||||
|
|
||||||
|
public string GetConfirmationPath(string transactionId) =>
|
||||||
|
Path.Combine(GetTransactionDirectory(transactionId), "confirmed");
|
||||||
|
|
||||||
|
public string GetHelperPath(string transactionId)
|
||||||
|
{
|
||||||
|
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||||
|
return Path.Combine(
|
||||||
|
GetTransactionDirectory(transactionId),
|
||||||
|
"acdream-self-update-helper" + suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SelfUpdatePlan> RollbackApplyingAsync(
|
||||||
|
SelfUpdatePlan plan,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (plan.State != SelfUpdatePlanState.Applying || plan.Apply is null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The self-update rollback journal is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string payload = GetPayloadDirectory(plan.TransactionId);
|
||||||
|
string backup = GetBackupDirectory(plan.TransactionId);
|
||||||
|
foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse())
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path);
|
||||||
|
string targetPath = ClientVersionStore.ResolveContained(
|
||||||
|
plan.TargetDirectory,
|
||||||
|
entry.Path);
|
||||||
|
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
|
||||||
|
if (!File.Exists(stagedPath) && File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!);
|
||||||
|
File.Move(targetPath, stagedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.HadOriginal && File.Exists(backupPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||||
|
File.Move(backupPath, targetPath);
|
||||||
|
}
|
||||||
|
else if (!entry.HadOriginal && File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
File.Delete(targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SafeZipExtractor.TryDeleteDirectory(backup);
|
||||||
|
plan = plan with
|
||||||
|
{
|
||||||
|
State = SelfUpdatePlanState.Staged,
|
||||||
|
Apply = null,
|
||||||
|
};
|
||||||
|
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||||
|
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task VerifyPayloadAsync(
|
||||||
|
SelfUpdatePlan plan,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string payload = GetPayloadDirectory(plan.TransactionId);
|
||||||
|
if (!Directory.Exists(payload))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The staged launcher payload is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientVersionStore.RejectReparseTree(payload);
|
||||||
|
string[] actual = Directory.EnumerateFiles(
|
||||||
|
payload,
|
||||||
|
"*",
|
||||||
|
SearchOption.AllDirectories)
|
||||||
|
.Select(path => Path.GetRelativePath(payload, path).Replace('\\', '/'))
|
||||||
|
.OrderBy(path => path, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
string[] expected = plan.Files
|
||||||
|
.Select(file => file.Path)
|
||||||
|
.OrderBy(path => path, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (!actual.SequenceEqual(expected, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The staged launcher contains missing or unrecorded files.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (InstalledFileRecord file in plan.Files)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string path = ClientVersionStore.ResolveContained(payload, file.Path);
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
if (!info.Exists || info.Length != file.Size)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Staged launcher file '{file.Path}' size is corrupt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync(
|
||||||
|
path,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Staged launcher file '{file.Path}' SHA-256 is corrupt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task VerifyAppliedTargetsAsync(
|
||||||
|
SelfUpdatePlan plan,
|
||||||
|
string targetDirectory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (InstalledFileRecord file in plan.Files)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string path = ClientVersionStore.ResolveContained(targetDirectory, file.Path);
|
||||||
|
EnsureSafeParent(targetDirectory, path);
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
if (!info.Exists
|
||||||
|
|| (info.Attributes & FileAttributes.ReparsePoint) != 0
|
||||||
|
|| info.Length != file.Size)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Applied launcher file '{file.Path}' is missing, linked, or corrupt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync(
|
||||||
|
path,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Applied launcher file '{file.Path}' SHA-256 is corrupt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsLinux()
|
||||||
|
&& ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Applied launcher file '{file.Path}' mode is corrupt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WritePlanAsync(
|
||||||
|
SelfUpdatePlan plan,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ValidatePlan(plan, plan.TargetDirectory);
|
||||||
|
await AtomicJsonFile.WriteAsync(
|
||||||
|
PendingPlanPath,
|
||||||
|
plan,
|
||||||
|
SerializerOptions,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidatePlan(SelfUpdatePlan plan, string expectedTargetDirectory)
|
||||||
|
{
|
||||||
|
if (plan.SchemaVersion != SelfUpdatePlan.CurrentSchemaVersion)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Self-update schema version {plan.SchemaVersion} is not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RequireTransactionId(plan.TransactionId);
|
||||||
|
if (!LauncherVersion.TryParse(plan.Version, out _)
|
||||||
|
|| !LauncherRuntimeIdentity.IsValidRid(plan.Rid)
|
||||||
|
|| !ReleaseManifestClient.IsSha256(plan.ArchiveSha256)
|
||||||
|
|| plan.ArchiveSize <= 0
|
||||||
|
|| plan.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The self-update plan metadata is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string target = NormalizeTargetDirectory(plan.TargetDirectory);
|
||||||
|
if (!PathsEqual(target, expectedTargetDirectory))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The self-update target does not match the running launcher directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.Files is null || plan.Files.Count == 0)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The self-update file list is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
string? prior = null;
|
||||||
|
foreach (InstalledFileRecord file in plan.Files)
|
||||||
|
{
|
||||||
|
if (!ClientVersionStore.IsNormalizedRelative(file.Path)
|
||||||
|
|| !paths.Add(file.Path)
|
||||||
|
|| !ReleaseManifestClient.IsSha256(file.Sha256)
|
||||||
|
|| file.Size < 0
|
||||||
|
|| file.UnixMode is < 0 or > 0x1FF
|
||||||
|
|| (prior is not null
|
||||||
|
&& string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The self-update file list is invalid, duplicated, or unsorted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
prior = file.Path;
|
||||||
|
}
|
||||||
|
|
||||||
|
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||||
|
? ".exe"
|
||||||
|
: string.Empty;
|
||||||
|
if (!paths.Contains("acdream-launcher" + suffix))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The self-update plan lacks the launcher root executable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.State == SelfUpdatePlanState.Staged && plan.Apply is not null
|
||||||
|
|| plan.State != SelfUpdatePlanState.Staged && plan.Apply is null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The self-update apply journal does not match its state.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.Apply is not null)
|
||||||
|
{
|
||||||
|
if (plan.Apply.Count != plan.Files.Count
|
||||||
|
|| !plan.Apply.Select(entry => entry.Path)
|
||||||
|
.SequenceEqual(plan.Files.Select(file => file.Path), StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The self-update apply journal does not match the file list.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string transactionDirectory = GetTransactionDirectory(plan.TransactionId);
|
||||||
|
if (!IsContained(TransactionsDirectory, transactionDirectory))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The self-update transaction path escaped.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeTargetDirectory(string targetDirectory)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory);
|
||||||
|
if (!Path.IsPathFullyQualified(targetDirectory))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The self-update target directory must be absolute.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string target = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetDirectory));
|
||||||
|
if (!Directory.Exists(target)
|
||||||
|
|| (File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The self-update target directory is missing or is a reparse point.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSafeParent(string root, string filePath)
|
||||||
|
{
|
||||||
|
string? parent = Path.GetDirectoryName(filePath);
|
||||||
|
if (parent is null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("A self-update target has no parent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(parent);
|
||||||
|
for (var directory = new DirectoryInfo(parent);
|
||||||
|
directory is not null && IsContained(root, directory.FullName);
|
||||||
|
directory = directory.Parent)
|
||||||
|
{
|
||||||
|
if ((directory.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Self-update target parent '{directory.FullName}' is a reparse point.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PathsEqual(directory.FullName, root))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsContained(string root, string path)
|
||||||
|
{
|
||||||
|
string fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
|
||||||
|
string fullPath = Path.GetFullPath(path);
|
||||||
|
return PathsEqual(fullRoot, fullPath)
|
||||||
|
|| fullPath.StartsWith(
|
||||||
|
fullRoot + Path.DirectorySeparatorChar,
|
||||||
|
OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PathsEqual(string left, string right) =>
|
||||||
|
string.Equals(
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
|
||||||
|
OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static void RequireTransactionId(string transactionId)
|
||||||
|
{
|
||||||
|
if (transactionId.Length != 32
|
||||||
|
|| !Guid.TryParseExact(transactionId, "N", out Guid parsed)
|
||||||
|
|| !string.Equals(parsed.ToString("N"), transactionId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The self-update transaction id is invalid.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupOwnedResidue(string? keepTransactionId)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(TransactionsDirectory))
|
||||||
|
{
|
||||||
|
foreach (string directory in Directory.EnumerateDirectories(
|
||||||
|
TransactionsDirectory,
|
||||||
|
"*",
|
||||||
|
SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
string name = Path.GetFileName(directory);
|
||||||
|
if (name.Length == 32
|
||||||
|
&& Guid.TryParseExact(name, "N", out Guid transaction)
|
||||||
|
&& string.Equals(
|
||||||
|
transaction.ToString("N"),
|
||||||
|
name,
|
||||||
|
StringComparison.Ordinal)
|
||||||
|
&& !string.Equals(name, keepTransactionId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
SafeZipExtractor.TryDeleteDirectory(directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(RootDirectory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string temporary in Directory.EnumerateFiles(
|
||||||
|
RootDirectory,
|
||||||
|
".pending.json.*.tmp",
|
||||||
|
SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
string name = Path.GetFileName(temporary);
|
||||||
|
string prefix = ".pending.json.";
|
||||||
|
string transaction = name[prefix.Length..^".tmp".Length];
|
||||||
|
if (Guid.TryParseExact(transaction, "N", out _))
|
||||||
|
{
|
||||||
|
VerifiedArtifactDownloader.TryDelete(temporary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
457
src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs
Normal file
457
src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs
Normal file
|
|
@ -0,0 +1,457 @@
|
||||||
|
namespace AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
public enum LauncherUpdatePhase
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Checking,
|
||||||
|
DownloadingClient,
|
||||||
|
ExtractingClient,
|
||||||
|
ActivatingClient,
|
||||||
|
DownloadingLauncher,
|
||||||
|
StagingLauncher,
|
||||||
|
RollingBack,
|
||||||
|
Completed,
|
||||||
|
Cancelled,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LauncherUpdateProgress(
|
||||||
|
LauncherUpdatePhase Phase,
|
||||||
|
string Status,
|
||||||
|
long Completed = 0,
|
||||||
|
long Total = 0)
|
||||||
|
{
|
||||||
|
public double Percent => Total <= 0
|
||||||
|
? 0
|
||||||
|
: Math.Clamp(Completed * 100d / Total, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LauncherUpdateCheckResult(
|
||||||
|
ReleaseManifest Manifest,
|
||||||
|
string Rid,
|
||||||
|
LauncherVersion LauncherVersion,
|
||||||
|
LauncherVersion? InstalledClientVersion,
|
||||||
|
bool IsClientUpdateAvailable,
|
||||||
|
bool IsLauncherUpdateAvailable,
|
||||||
|
bool IsLauncherMinimumSatisfied,
|
||||||
|
string Status);
|
||||||
|
|
||||||
|
public interface ILauncherUpdater
|
||||||
|
{
|
||||||
|
ClientVersionResolution CurrentClient { get; }
|
||||||
|
|
||||||
|
Task<ClientVersionResolution> InitializeAsync(
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<LauncherUpdateCheckResult> CheckAsync(
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ClientVersionResolution> InstallClientAsync(
|
||||||
|
LauncherUpdateCheckResult check,
|
||||||
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||||
|
LauncherUpdateCheckResult check,
|
||||||
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ClientVersionResolution> RollbackClientAsync(
|
||||||
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical LA10 update transaction. It holds the cross-process exclusive
|
||||||
|
/// barrier for recovery/download/extraction/promotion/pointer publication and
|
||||||
|
/// leaves LA9's verified DAT/pak record untouched.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LauncherUpdater : ILauncherUpdater
|
||||||
|
{
|
||||||
|
private readonly IReleaseManifestClient _manifestClient;
|
||||||
|
private readonly ClientVersionStore _versions;
|
||||||
|
private readonly LauncherSelfUpdateManager _selfUpdates;
|
||||||
|
private readonly VerifiedArtifactDownloader _downloader;
|
||||||
|
private readonly SafeZipExtractor _extractor;
|
||||||
|
private readonly LauncherVersion _launcherVersion;
|
||||||
|
private readonly string _rid;
|
||||||
|
private readonly string _launcherTargetDirectory;
|
||||||
|
private readonly Func<bool> _hasRunningSessions;
|
||||||
|
private readonly SemaphoreSlim _operationGate = new(1, 1);
|
||||||
|
|
||||||
|
public LauncherUpdater(
|
||||||
|
IReleaseManifestClient manifestClient,
|
||||||
|
HttpClient httpClient,
|
||||||
|
ClientVersionStore versions,
|
||||||
|
LauncherSelfUpdateManager selfUpdates,
|
||||||
|
LauncherVersion launcherVersion,
|
||||||
|
string rid,
|
||||||
|
string launcherTargetDirectory,
|
||||||
|
Func<bool>? hasRunningSessions = null,
|
||||||
|
SafeZipExtractor? extractor = null)
|
||||||
|
{
|
||||||
|
_manifestClient = manifestClient
|
||||||
|
?? throw new ArgumentNullException(nameof(manifestClient));
|
||||||
|
_versions = versions ?? throw new ArgumentNullException(nameof(versions));
|
||||||
|
_selfUpdates = selfUpdates ?? throw new ArgumentNullException(nameof(selfUpdates));
|
||||||
|
_launcherVersion = launcherVersion
|
||||||
|
?? throw new ArgumentNullException(nameof(launcherVersion));
|
||||||
|
if (!LauncherRuntimeIdentity.IsValidRid(rid))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("RID is invalid.", nameof(rid));
|
||||||
|
}
|
||||||
|
|
||||||
|
_rid = rid;
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(launcherTargetDirectory);
|
||||||
|
_launcherTargetDirectory = Path.GetFullPath(launcherTargetDirectory);
|
||||||
|
_hasRunningSessions = hasRunningSessions ?? (() => false);
|
||||||
|
_downloader = new VerifiedArtifactDownloader(
|
||||||
|
httpClient ?? throw new ArgumentNullException(nameof(httpClient)));
|
||||||
|
_extractor = extractor ?? new SafeZipExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClientVersionResolution CurrentClient => _versions.CachedResolution;
|
||||||
|
|
||||||
|
public Task<ClientVersionResolution> InitializeAsync(
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
_versions.LoadAndRecoverAsync(_rid, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<LauncherUpdateCheckResult> CheckAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ReleaseManifest manifest = await _manifestClient.FetchAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
_ = manifest.RequireClient(_rid);
|
||||||
|
_ = manifest.RequireLauncher(_rid);
|
||||||
|
ClientVersionResolution installed = _versions.CachedResolution;
|
||||||
|
LauncherVersion? installedVersion = installed.IsVerified
|
||||||
|
? installed.Version
|
||||||
|
: null;
|
||||||
|
bool clientAvailable = installedVersion is null
|
||||||
|
|| manifest.Version > installedVersion;
|
||||||
|
bool launcherAvailable = manifest.Version > _launcherVersion;
|
||||||
|
bool minimumSatisfied = _launcherVersion >= manifest.MinimumLauncherVersion;
|
||||||
|
string status = BuildCheckStatus(
|
||||||
|
manifest,
|
||||||
|
installedVersion,
|
||||||
|
clientAvailable,
|
||||||
|
launcherAvailable,
|
||||||
|
minimumSatisfied);
|
||||||
|
return new LauncherUpdateCheckResult(
|
||||||
|
manifest,
|
||||||
|
_rid,
|
||||||
|
_launcherVersion,
|
||||||
|
installedVersion,
|
||||||
|
clientAvailable,
|
||||||
|
launcherAvailable,
|
||||||
|
minimumSatisfied,
|
||||||
|
status);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClientVersionResolution> InstallClientAsync(
|
||||||
|
LauncherUpdateCheckResult check,
|
||||||
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(check);
|
||||||
|
ValidateCheck(check);
|
||||||
|
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RefuseRunningSessions();
|
||||||
|
using UpdateSessionBarrier.ExclusiveLease lease =
|
||||||
|
_versions.Barrier.AcquireExclusive();
|
||||||
|
RefuseRunningSessions();
|
||||||
|
ClientVersionResolution current = await _versions
|
||||||
|
.LoadAndRecoverUnderLeaseAsync(_rid, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!check.IsLauncherMinimumSatisfied)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Client {check.Manifest.Version} requires launcher "
|
||||||
|
+ $"{check.Manifest.MinimumLauncherVersion} or newer. "
|
||||||
|
+ "Stage the launcher update first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.IsVerified
|
||||||
|
&& current.Version is not null
|
||||||
|
&& current.Version >= check.Manifest.Version)
|
||||||
|
{
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.Completed,
|
||||||
|
$"Client {current.Version} is already current.",
|
||||||
|
1,
|
||||||
|
1);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseArtifact artifact = check.Manifest.RequireClient(_rid);
|
||||||
|
Guid transactionId = Guid.NewGuid();
|
||||||
|
string staging = _versions.CreateClientStagingDirectory(transactionId);
|
||||||
|
string archive = Path.Combine(
|
||||||
|
_versions.AppDirectory,
|
||||||
|
$".client-download-{transactionId:N}.zip");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.DownloadingClient,
|
||||||
|
$"Downloading client {check.Manifest.Version}...",
|
||||||
|
0,
|
||||||
|
artifact.Size);
|
||||||
|
var downloadProgress = new ForwardProgress<ArtifactDownloadProgress>(value =>
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.DownloadingClient,
|
||||||
|
$"Downloading client {check.Manifest.Version}: "
|
||||||
|
+ $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes",
|
||||||
|
value.BytesReceived,
|
||||||
|
value.TotalBytes));
|
||||||
|
_ = await _downloader.DownloadAsync(
|
||||||
|
artifact,
|
||||||
|
archive,
|
||||||
|
downloadProgress,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.ExtractingClient,
|
||||||
|
"Verifying paths and extracting the client archive...");
|
||||||
|
IReadOnlyList<ExtractedFileRecord> files = await _extractor.ExtractAsync(
|
||||||
|
archive,
|
||||||
|
staging,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.ActivatingClient,
|
||||||
|
$"Atomically activating client {check.Manifest.Version}...");
|
||||||
|
ClientVersionResolution result = await _versions
|
||||||
|
.PromoteAndActivateUnderLeaseAsync(
|
||||||
|
staging,
|
||||||
|
check.Manifest.Version,
|
||||||
|
_rid,
|
||||||
|
artifact,
|
||||||
|
files,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.Completed,
|
||||||
|
$"Client {check.Manifest.Version} installed and activated.",
|
||||||
|
1,
|
||||||
|
1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.Cancelled,
|
||||||
|
"Client update cancelled; the active version was not changed.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.Failed,
|
||||||
|
$"Client update failed: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
VerifiedArtifactDownloader.TryDelete(archive);
|
||||||
|
SafeZipExtractor.TryDeleteDirectory(staging);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||||
|
LauncherUpdateCheckResult check,
|
||||||
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(check);
|
||||||
|
ValidateCheck(check);
|
||||||
|
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RefuseRunningSessions();
|
||||||
|
using UpdateSessionBarrier.ExclusiveLease lease =
|
||||||
|
_versions.Barrier.AcquireExclusive();
|
||||||
|
RefuseRunningSessions();
|
||||||
|
if (check.Manifest.Version <= _launcherVersion)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Launcher {_launcherVersion} is already current.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.DownloadingLauncher,
|
||||||
|
$"Downloading launcher {check.Manifest.Version}...");
|
||||||
|
var downloadProgress = new ForwardProgress<ArtifactDownloadProgress>(value =>
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.DownloadingLauncher,
|
||||||
|
$"Downloading launcher {check.Manifest.Version}: "
|
||||||
|
+ $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes",
|
||||||
|
value.BytesReceived,
|
||||||
|
value.TotalBytes));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SelfUpdateStageResult result = await _selfUpdates.StageAsync(
|
||||||
|
check.Manifest,
|
||||||
|
_rid,
|
||||||
|
_launcherTargetDirectory,
|
||||||
|
downloadProgress,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.StagingLauncher,
|
||||||
|
result.Status,
|
||||||
|
1,
|
||||||
|
1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.Cancelled,
|
||||||
|
"Launcher update staging cancelled.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.Failed,
|
||||||
|
$"Launcher update staging failed: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClientVersionResolution> RollbackClientAsync(
|
||||||
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RefuseRunningSessions();
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.RollingBack,
|
||||||
|
"Verifying and activating the previous client version...");
|
||||||
|
ClientVersionResolution result = await _versions.RollbackAsync(
|
||||||
|
_rid,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
Report(
|
||||||
|
progress,
|
||||||
|
LauncherUpdatePhase.Completed,
|
||||||
|
$"Rolled back to client {result.Version}.",
|
||||||
|
1,
|
||||||
|
1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateCheck(LauncherUpdateCheckResult check)
|
||||||
|
{
|
||||||
|
if (!string.Equals(check.Rid, _rid, StringComparison.Ordinal)
|
||||||
|
|| !check.LauncherVersion.Equals(_launcherVersion))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The update check belongs to a different launcher runtime.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = check.Manifest.RequireClient(_rid);
|
||||||
|
_ = check.Manifest.RequireLauncher(_rid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefuseRunningSessions()
|
||||||
|
{
|
||||||
|
if (_hasRunningSessions())
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"Stop every launcher session before installing or rolling back an update.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCheckStatus(
|
||||||
|
ReleaseManifest manifest,
|
||||||
|
LauncherVersion? installed,
|
||||||
|
bool clientAvailable,
|
||||||
|
bool launcherAvailable,
|
||||||
|
bool minimumSatisfied)
|
||||||
|
{
|
||||||
|
if (!minimumSatisfied)
|
||||||
|
{
|
||||||
|
return $"Release {manifest.Version} requires launcher "
|
||||||
|
+ $"{manifest.MinimumLauncherVersion} or newer.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientAvailable && launcherAvailable)
|
||||||
|
{
|
||||||
|
return $"Client and launcher {manifest.Version} are available.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientAvailable)
|
||||||
|
{
|
||||||
|
return installed is null
|
||||||
|
? $"Client {manifest.Version} is available for installation."
|
||||||
|
: $"Client update {installed} -> {manifest.Version} is available.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (launcherAvailable)
|
||||||
|
{
|
||||||
|
return $"Launcher {manifest.Version} is available.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Client and launcher are up to date.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Report(
|
||||||
|
IProgress<LauncherUpdateProgress>? progress,
|
||||||
|
LauncherUpdatePhase phase,
|
||||||
|
string status,
|
||||||
|
long completed = 0,
|
||||||
|
long total = 0) =>
|
||||||
|
progress?.Report(new LauncherUpdateProgress(
|
||||||
|
phase,
|
||||||
|
status,
|
||||||
|
completed,
|
||||||
|
total));
|
||||||
|
|
||||||
|
private sealed class ForwardProgress<T>(Action<T> callback) : IProgress<T>
|
||||||
|
{
|
||||||
|
public void Report(T value) => callback(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/AcDream.Launcher.Core/Updates/LauncherVersion.cs
Normal file
199
src/AcDream.Launcher.Core/Updates/LauncherVersion.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs
Normal file
37
src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
300
src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs
Normal file
300
src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
public interface IReleaseManifestClient
|
||||||
|
{
|
||||||
|
Task<ReleaseManifest> FetchAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strict, bounded reader for the pinned GitHub Releases manifest. HTTP is
|
||||||
|
/// accepted only for a loopback fixture; production and artifact URLs are
|
||||||
|
/// HTTPS-only.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
|
{
|
||||||
|
public const string GitHubOwner = "eriknihlen";
|
||||||
|
public const string GitHubRepository = "acdream";
|
||||||
|
public const int MaximumManifestBytes = 1024 * 1024;
|
||||||
|
public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
public static Uri ProductionManifestUri { get; } = new(
|
||||||
|
$"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json");
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = false,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||||
|
MaxDepth = 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly bool _ownsHttpClient;
|
||||||
|
private readonly Uri _manifestUri;
|
||||||
|
|
||||||
|
public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient ?? new HttpClient();
|
||||||
|
_ownsHttpClient = httpClient is null;
|
||||||
|
_manifestUri = manifestUri ?? ProductionManifestUri;
|
||||||
|
RequireSecureOrLoopback(_manifestUri, "manifest");
|
||||||
|
if (_ownsHttpClient)
|
||||||
|
{
|
||||||
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ReleaseManifest> FetchAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||||
|
_manifestUri,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
Uri finalUri = response.RequestMessage?.RequestUri ?? _manifestUri;
|
||||||
|
RequireSecureOrLoopback(finalUri, "manifest redirect");
|
||||||
|
if (response.Content.Headers.ContentLength is long contentLength
|
||||||
|
&& contentLength > MaximumManifestBytes)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using Stream input = await response.Content
|
||||||
|
.ReadAsStreamAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
byte[] buffer = new byte[16 * 1024];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int read = await input.ReadAsync(buffer, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.Length + read > MaximumManifestBytes)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
output.Write(buffer, 0, read);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Parse(output.ToArray());
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (LauncherUpdateException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException
|
||||||
|
or IOException
|
||||||
|
or JsonException
|
||||||
|
or NotSupportedException)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release manifest could not be loaded: {ex.Message}",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ReleaseManifest Parse(ReadOnlySpan<byte> utf8)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(
|
||||||
|
utf8.ToArray(),
|
||||||
|
new JsonDocumentOptions
|
||||||
|
{
|
||||||
|
AllowTrailingCommas = false,
|
||||||
|
CommentHandling = JsonCommentHandling.Disallow,
|
||||||
|
MaxDepth = 16,
|
||||||
|
});
|
||||||
|
RejectDuplicateProperties(document.RootElement, "$" );
|
||||||
|
ManifestDocument? value = document.RootElement.Deserialize<ManifestDocument>(
|
||||||
|
SerializerOptions);
|
||||||
|
return Validate(value);
|
||||||
|
}
|
||||||
|
catch (LauncherUpdateException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is JsonException
|
||||||
|
or FormatException
|
||||||
|
or InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release manifest is invalid: {ex.Message}",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_ownsHttpClient)
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void RequireSecureOrLoopback(Uri uri, string description)
|
||||||
|
{
|
||||||
|
if (!uri.IsAbsoluteUri
|
||||||
|
|| (uri.Scheme != Uri.UriSchemeHttps
|
||||||
|
&& !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The {description} URI must use HTTPS (loopback HTTP is test-only).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReleaseManifest Validate(ManifestDocument? document)
|
||||||
|
{
|
||||||
|
if (document is null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The release manifest is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.SchemaVersion != ReleaseManifest.CurrentSchemaVersion)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Release manifest schema version {document.SchemaVersion} is not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LauncherVersion version = LauncherVersion.Parse(
|
||||||
|
document.Version
|
||||||
|
?? throw new LauncherUpdateException("The release version is missing."));
|
||||||
|
LauncherVersion minimum = LauncherVersion.Parse(
|
||||||
|
document.MinimumLauncherVersion
|
||||||
|
?? throw new LauncherUpdateException(
|
||||||
|
"The minimum launcher version is missing."));
|
||||||
|
if (minimum > version)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The minimum launcher version cannot exceed the release version.");
|
||||||
|
}
|
||||||
|
IReadOnlyDictionary<string, ReleaseArtifact> clients = ValidateArtifacts(
|
||||||
|
document.Clients,
|
||||||
|
"clients");
|
||||||
|
IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts(
|
||||||
|
document.Launchers,
|
||||||
|
"launchers");
|
||||||
|
return new ReleaseManifest(version, minimum, clients, launchers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts(
|
||||||
|
Dictionary<string, ArtifactDocument>? artifacts,
|
||||||
|
string field)
|
||||||
|
{
|
||||||
|
if (artifacts is null || artifacts.Count == 0)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException($"Manifest field '{field}' must not be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Dictionary<string, ReleaseArtifact>(StringComparer.Ordinal);
|
||||||
|
foreach ((string rid, ArtifactDocument value) in artifacts)
|
||||||
|
{
|
||||||
|
if (!LauncherRuntimeIdentity.IsValidRid(rid))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Manifest field '{field}' contains invalid RID '{rid}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Manifest payload '{field}.{rid}' is null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(value.Url, UriKind.Absolute, out Uri? uri))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Manifest payload '{field}.{rid}' has an invalid URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RequireSecureOrLoopback(uri, $"{field}.{rid} artifact");
|
||||||
|
if (!IsSha256(value.Sha256))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Manifest payload '{field}.{rid}' has an invalid SHA-256 digest.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Size <= 0 || value.Size > MaximumArtifactBytes)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Manifest payload '{field}.{rid}' has an invalid size.");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(
|
||||||
|
rid,
|
||||||
|
new ReleaseArtifact(uri, value.Sha256!.ToLowerInvariant(), value.Size));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsSha256(string? value) =>
|
||||||
|
value is { Length: 64 } && value.All(Uri.IsHexDigit);
|
||||||
|
|
||||||
|
private static void RejectDuplicateProperties(JsonElement element, string path)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (JsonProperty property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (!names.Add(property.Name))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Duplicate JSON property '{path}.{property.Name}' is not allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RejectDuplicateProperties(property.Value, $"{path}.{property.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
int index = 0;
|
||||||
|
foreach (JsonElement item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
RejectDuplicateProperties(item, $"{path}[{index++}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManifestDocument
|
||||||
|
{
|
||||||
|
public int SchemaVersion { get; init; }
|
||||||
|
|
||||||
|
public string? Version { get; init; }
|
||||||
|
|
||||||
|
public string? MinimumLauncherVersion { get; init; }
|
||||||
|
|
||||||
|
public Dictionary<string, ArtifactDocument>? Clients { get; init; }
|
||||||
|
|
||||||
|
public Dictionary<string, ArtifactDocument>? Launchers { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ArtifactDocument
|
||||||
|
{
|
||||||
|
public string? Url { get; init; }
|
||||||
|
|
||||||
|
public string? Sha256 { get; init; }
|
||||||
|
|
||||||
|
public long Size { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
482
src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs
Normal file
482
src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
using System.Buffers;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
public sealed record SafeZipExtractionLimits(
|
||||||
|
int MaximumEntries = 20_000,
|
||||||
|
long MaximumEntryBytes = 2L * 1024 * 1024 * 1024,
|
||||||
|
long MaximumTotalBytes = 8L * 1024 * 1024 * 1024,
|
||||||
|
double MaximumCompressionRatio = 200,
|
||||||
|
int MaximumRelativePathLength = 512);
|
||||||
|
|
||||||
|
public sealed record ExtractedFileRecord(
|
||||||
|
string Path,
|
||||||
|
string Sha256,
|
||||||
|
long Size,
|
||||||
|
int UnixMode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Portable ZIP extractor for release assets. The complete central-directory
|
||||||
|
/// shape is validated before the first output path is created.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SafeZipExtractor
|
||||||
|
{
|
||||||
|
private const int BufferSize = 128 * 1024;
|
||||||
|
private const int UnixTypeMask = 0xF000;
|
||||||
|
private const int UnixRegularFile = 0x8000;
|
||||||
|
private const int UnixDirectory = 0x4000;
|
||||||
|
private const int UnixPermissionMask = 0x1FF;
|
||||||
|
private readonly SafeZipExtractionLimits _limits;
|
||||||
|
|
||||||
|
public SafeZipExtractor(SafeZipExtractionLimits? limits = null)
|
||||||
|
{
|
||||||
|
_limits = limits ?? new SafeZipExtractionLimits();
|
||||||
|
if (_limits.MaximumEntries <= 0
|
||||||
|
|| _limits.MaximumEntryBytes <= 0
|
||||||
|
|| _limits.MaximumTotalBytes <= 0
|
||||||
|
|| _limits.MaximumCompressionRatio <= 0
|
||||||
|
|| _limits.MaximumRelativePathLength <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(limits),
|
||||||
|
"ZIP extraction limits must all be positive.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ExtractedFileRecord>> ExtractAsync(
|
||||||
|
string archivePath,
|
||||||
|
string destinationDirectory,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(archivePath);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory);
|
||||||
|
string archive = Path.GetFullPath(archivePath);
|
||||||
|
string destination = Path.GetFullPath(destinationDirectory);
|
||||||
|
|
||||||
|
if (Directory.Exists(destination)
|
||||||
|
&& Directory.EnumerateFileSystemEntries(destination).Any())
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The ZIP extraction destination must be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = new FileStream(
|
||||||
|
archive,
|
||||||
|
FileMode.Open,
|
||||||
|
FileAccess.Read,
|
||||||
|
FileShare.Read,
|
||||||
|
BufferSize,
|
||||||
|
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
|
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
|
||||||
|
IReadOnlyList<ValidatedEntry> entries = ValidateArchive(zip);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(destination);
|
||||||
|
RejectReparsePoint(destination, "extraction root");
|
||||||
|
foreach (string directory in entries
|
||||||
|
.SelectMany(entry => ParentPaths(entry.RelativePath))
|
||||||
|
.Concat(entries.Where(entry => entry.IsDirectory)
|
||||||
|
.Select(entry => entry.RelativePath))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(path => path.Count(character => character == '/'))
|
||||||
|
.ThenBy(path => path, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string directoryPath = ResolveContained(destination, directory);
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
RejectReparsePoint(directoryPath, $"directory '{directory}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = new List<ExtractedFileRecord>();
|
||||||
|
long actualTotal = 0;
|
||||||
|
foreach (ValidatedEntry entry in entries.Where(entry => !entry.IsDirectory))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string outputPath = ResolveContained(destination, entry.RelativePath);
|
||||||
|
EnsureParentsAreDirectories(destination, entry.RelativePath);
|
||||||
|
await using Stream input = entry.Entry.Open();
|
||||||
|
await using var output = new FileStream(
|
||||||
|
outputPath,
|
||||||
|
FileMode.CreateNew,
|
||||||
|
FileAccess.Write,
|
||||||
|
FileShare.None,
|
||||||
|
BufferSize,
|
||||||
|
FileOptions.Asynchronous
|
||||||
|
| FileOptions.SequentialScan
|
||||||
|
| FileOptions.WriteThrough);
|
||||||
|
using IncrementalHash hash = IncrementalHash.CreateHash(
|
||||||
|
HashAlgorithmName.SHA256);
|
||||||
|
byte[] buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||||
|
long actualEntry = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int read = await input.ReadAsync(
|
||||||
|
buffer.AsMemory(0, BufferSize),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
actualEntry = checked(actualEntry + read);
|
||||||
|
actualTotal = checked(actualTotal + read);
|
||||||
|
if (actualEntry > entry.Entry.Length
|
||||||
|
|| actualEntry > _limits.MaximumEntryBytes
|
||||||
|
|| actualTotal > _limits.MaximumTotalBytes)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP entry '{entry.RelativePath}' exceeded its declared limits.");
|
||||||
|
}
|
||||||
|
|
||||||
|
hash.AppendData(buffer, 0, read);
|
||||||
|
await output.WriteAsync(
|
||||||
|
buffer.AsMemory(0, read),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
output.Flush(flushToDisk: true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualEntry != entry.Entry.Length)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP entry '{entry.RelativePath}' length changed while extracting.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int unixMode = entry.UnixMode & UnixPermissionMask;
|
||||||
|
if (OperatingSystem.IsLinux() && unixMode != 0)
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(outputPath, (UnixFileMode)unixMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
files.Add(new ExtractedFileRecord(
|
||||||
|
entry.RelativePath,
|
||||||
|
Convert.ToHexStringLower(hash.GetHashAndReset()),
|
||||||
|
actualEntry,
|
||||||
|
unixMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
files.Sort((left, right) => string.Compare(
|
||||||
|
left.Path,
|
||||||
|
right.Path,
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
TryDeleteDirectory(destination);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (LauncherUpdateException)
|
||||||
|
{
|
||||||
|
TryDeleteDirectory(destination);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or InvalidDataException
|
||||||
|
or NotSupportedException
|
||||||
|
or CryptographicException)
|
||||||
|
{
|
||||||
|
TryDeleteDirectory(destination);
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release ZIP could not be extracted safely: {ex.Message}",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<ValidatedEntry> ValidateArchive(ZipArchive zip)
|
||||||
|
{
|
||||||
|
if (zip.Entries.Count == 0 || zip.Entries.Count > _limits.MaximumEntries)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP entry count {zip.Entries.Count} is outside the allowed range.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<ValidatedEntry>(zip.Entries.Count);
|
||||||
|
var explicitEntries = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var nodes = new Dictionary<string, PathNode>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
long totalLength = 0;
|
||||||
|
long totalCompressed = 0;
|
||||||
|
foreach (ZipArchiveEntry entry in zip.Entries)
|
||||||
|
{
|
||||||
|
string relative = NormalizeEntryPath(entry.FullName);
|
||||||
|
if (!explicitEntries.Add(relative))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP contains a duplicate/case-colliding entry '{relative}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int unixAttributes = entry.ExternalAttributes >> 16;
|
||||||
|
int unixType = unixAttributes & UnixTypeMask;
|
||||||
|
bool trailingDirectory = entry.FullName.EndsWith("/", StringComparison.Ordinal)
|
||||||
|
|| entry.FullName.EndsWith("\\", StringComparison.Ordinal);
|
||||||
|
bool isDirectory = trailingDirectory || unixType == UnixDirectory;
|
||||||
|
if ((entry.ExternalAttributes & (int)FileAttributes.ReparsePoint) != 0
|
||||||
|
|| unixType is not (0 or UnixRegularFile or UnixDirectory)
|
||||||
|
|| (unixType == UnixDirectory && !trailingDirectory)
|
||||||
|
|| (isDirectory && (entry.Length != 0 || entry.CompressedLength != 0)))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP entry '{relative}' is a symlink, reparse point, or unsupported type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
AddPathNodes(nodes, relative, isDirectory);
|
||||||
|
if (!isDirectory)
|
||||||
|
{
|
||||||
|
if (entry.Length < 0
|
||||||
|
|| entry.CompressedLength < 0
|
||||||
|
|| entry.Length > _limits.MaximumEntryBytes)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP entry '{relative}' exceeds the per-file limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
totalLength = checked(totalLength + entry.Length);
|
||||||
|
totalCompressed = checked(totalCompressed + entry.CompressedLength);
|
||||||
|
if (totalLength > _limits.MaximumTotalBytes
|
||||||
|
|| IsRatioExceeded(entry.Length, entry.CompressedLength))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP entry '{relative}' exceeds extraction size/ratio limits.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new ValidatedEntry(entry, relative, isDirectory, unixAttributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalLength > 0
|
||||||
|
&& (totalCompressed == 0 || IsRatioExceeded(totalLength, totalCompressed)))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"ZIP aggregate compression ratio exceeds the allowed limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string NormalizeEntryPath(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(name)
|
||||||
|
|| name.IndexOf('\0') >= 0
|
||||||
|
|| name.Contains(':', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("ZIP contains an empty, NUL, or ADS path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string normalized = name.Replace('\\', '/');
|
||||||
|
bool directory = normalized.EndsWith("/", StringComparison.Ordinal);
|
||||||
|
normalized = normalized.TrimEnd('/');
|
||||||
|
if (normalized.Length == 0
|
||||||
|
|| normalized.Length > _limits.MaximumRelativePathLength
|
||||||
|
|| normalized.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
|| Path.IsPathRooted(normalized))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException($"ZIP path '{name}' is rooted or too long.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] segments = normalized.Split('/');
|
||||||
|
foreach (string segment in segments)
|
||||||
|
{
|
||||||
|
if (segment.Length == 0
|
||||||
|
|| segment is "." or ".."
|
||||||
|
|| segment.EndsWith(' ')
|
||||||
|
|| segment.EndsWith('.')
|
||||||
|
|| segment.Any(character =>
|
||||||
|
char.IsControl(character)
|
||||||
|
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|
||||||
|
|| IsWindowsDeviceName(segment))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP path '{name}' contains an unsafe segment.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join('/', segments) + (directory ? "/" : string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddPathNodes(
|
||||||
|
Dictionary<string, PathNode> nodes,
|
||||||
|
string relative,
|
||||||
|
bool isDirectory)
|
||||||
|
{
|
||||||
|
string path = relative.TrimEnd('/');
|
||||||
|
string[] segments = path.Split('/');
|
||||||
|
string current = string.Empty;
|
||||||
|
for (int index = 0; index < segments.Length; index++)
|
||||||
|
{
|
||||||
|
current = current.Length == 0
|
||||||
|
? segments[index]
|
||||||
|
: current + "/" + segments[index];
|
||||||
|
bool nodeIsDirectory = index < segments.Length - 1 || isDirectory;
|
||||||
|
if (nodes.TryGetValue(current, out PathNode? existing))
|
||||||
|
{
|
||||||
|
if (!string.Equals(existing.Spelling, current, StringComparison.Ordinal)
|
||||||
|
|| (!existing.IsDirectory || !nodeIsDirectory))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP path '{relative}' collides with '{existing.Spelling}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.Add(current, new PathNode(current, nodeIsDirectory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsRatioExceeded(long expanded, long compressed) =>
|
||||||
|
expanded > 0
|
||||||
|
&& (compressed <= 0 || expanded / (double)compressed > _limits.MaximumCompressionRatio);
|
||||||
|
|
||||||
|
private static IEnumerable<string> ParentPaths(string relative)
|
||||||
|
{
|
||||||
|
string path = relative.TrimEnd('/');
|
||||||
|
int slash = path.IndexOf('/');
|
||||||
|
while (slash >= 0)
|
||||||
|
{
|
||||||
|
yield return path[..slash];
|
||||||
|
slash = path.IndexOf('/', slash + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveContained(string root, string relative)
|
||||||
|
{
|
||||||
|
string path = Path.GetFullPath(
|
||||||
|
Path.Combine(root, relative.TrimEnd('/').Replace('/', Path.DirectorySeparatorChar)));
|
||||||
|
string prefix = Path.EndsInDirectorySeparator(root)
|
||||||
|
? root
|
||||||
|
: root + Path.DirectorySeparatorChar;
|
||||||
|
if (!path.StartsWith(
|
||||||
|
prefix,
|
||||||
|
OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP path '{relative}' escaped the extraction directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureParentsAreDirectories(string root, string relative)
|
||||||
|
{
|
||||||
|
foreach (string parent in ParentPaths(relative))
|
||||||
|
{
|
||||||
|
string path = ResolveContained(root, parent);
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"ZIP parent '{parent}' is not a directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RejectReparsePoint(path, $"directory '{parent}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RejectReparsePoint(string path, string description)
|
||||||
|
{
|
||||||
|
if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The {description} is a reparse point.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWindowsDeviceName(string segment)
|
||||||
|
{
|
||||||
|
string stem = segment.Split('.')[0];
|
||||||
|
return stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (stem.Length == 4
|
||||||
|
&& (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
|
||||||
|
&& stem[3] is >= '1' and <= '9');
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void TryDeleteDirectory(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
DeleteDirectoryWithoutFollowingReparsePoints(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The exact random staging name is reclaimed under the update lease.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteDirectoryWithoutFollowingReparsePoints(string directory)
|
||||||
|
{
|
||||||
|
FileAttributes rootAttributes = File.GetAttributes(directory);
|
||||||
|
if ((rootAttributes & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
DeleteReparsePoint(directory);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string entry in Directory.EnumerateFileSystemEntries(
|
||||||
|
directory,
|
||||||
|
"*",
|
||||||
|
SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
FileAttributes attributes = File.GetAttributes(entry);
|
||||||
|
if ((attributes & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
DeleteReparsePoint(entry);
|
||||||
|
}
|
||||||
|
else if ((attributes & FileAttributes.Directory) != 0)
|
||||||
|
{
|
||||||
|
DeleteDirectoryWithoutFollowingReparsePoints(entry);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.Delete(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.Delete(directory, recursive: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteReparsePoint(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
Directory.Delete(path, recursive: false);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
Directory.Delete(path, recursive: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PathNode(string Spelling, bool IsDirectory);
|
||||||
|
|
||||||
|
private sealed record ValidatedEntry(
|
||||||
|
ZipArchiveEntry Entry,
|
||||||
|
string RelativePath,
|
||||||
|
bool IsDirectory,
|
||||||
|
int UnixMode);
|
||||||
|
}
|
||||||
85
src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs
Normal file
85
src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
namespace AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One portable OS-handle barrier shared by supervised sessions and held
|
||||||
|
/// exclusively by update/rollback/recovery transactions. File contents are
|
||||||
|
/// never authoritative.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateSessionBarrier
|
||||||
|
{
|
||||||
|
public const string LockFileName = ".update-session.lock";
|
||||||
|
|
||||||
|
private readonly string _lockPath;
|
||||||
|
|
||||||
|
public UpdateSessionBarrier(string dataDirectory)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||||
|
_lockPath = Path.Combine(
|
||||||
|
Path.GetFullPath(dataDirectory),
|
||||||
|
"app",
|
||||||
|
LockFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string LockPath => _lockPath;
|
||||||
|
|
||||||
|
public SessionLease AcquireSession()
|
||||||
|
{
|
||||||
|
FileStream stream = Open(FileShare.ReadWrite, "A client update is in progress.");
|
||||||
|
return new SessionLease(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExclusiveLease AcquireExclusive()
|
||||||
|
{
|
||||||
|
FileStream stream = Open(
|
||||||
|
FileShare.None,
|
||||||
|
"A launcher session or another update transaction is running. "
|
||||||
|
+ "Stop every launcher session before updating.");
|
||||||
|
return new ExclusiveLease(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileStream Open(FileShare share, string refusal)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(
|
||||||
|
Path.GetDirectoryName(_lockPath)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The update/session lock path has no parent directory."));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new FileStream(
|
||||||
|
_lockPath,
|
||||||
|
FileMode.OpenOrCreate,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
share,
|
||||||
|
bufferSize: 1,
|
||||||
|
FileOptions.None);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(refusal, ex);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The update/session lease could not be opened: {ex.Message}",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SessionLease : IDisposable
|
||||||
|
{
|
||||||
|
private FileStream? _stream;
|
||||||
|
|
||||||
|
internal SessionLease(FileStream stream) => _stream = stream;
|
||||||
|
|
||||||
|
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExclusiveLease : IDisposable
|
||||||
|
{
|
||||||
|
private FileStream? _stream;
|
||||||
|
|
||||||
|
internal ExclusiveLease(FileStream stream) => _stream = stream;
|
||||||
|
|
||||||
|
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs
Normal file
200
src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
public sealed record ArtifactDownloadProgress(long BytesReceived, long TotalBytes)
|
||||||
|
{
|
||||||
|
public double Percent => TotalBytes <= 0
|
||||||
|
? 0
|
||||||
|
: Math.Clamp(BytesReceived * 100d / TotalBytes, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record VerifiedArtifactDownload(
|
||||||
|
string FilePath,
|
||||||
|
long Size,
|
||||||
|
string Sha256);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams a bounded release asset directly to a caller-owned staging path,
|
||||||
|
/// computing SHA-256 during the write. A partial/cancelled/wrong artifact is
|
||||||
|
/// deleted before the call returns.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VerifiedArtifactDownloader
|
||||||
|
{
|
||||||
|
private const int BufferSize = 128 * 1024;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
public VerifiedArtifactDownloader(HttpClient httpClient)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VerifiedArtifactDownload> DownloadAsync(
|
||||||
|
ReleaseArtifact artifact,
|
||||||
|
string destinationPath,
|
||||||
|
IProgress<ArtifactDownloadProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(artifact);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
|
||||||
|
ReleaseManifestClient.RequireSecureOrLoopback(artifact.Url, "artifact");
|
||||||
|
if (artifact.Size <= 0
|
||||||
|
|| artifact.Size > ReleaseManifestClient.MaximumArtifactBytes
|
||||||
|
|| !ReleaseManifestClient.IsSha256(artifact.Sha256))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException("The requested artifact metadata is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string fullPath = Path.GetFullPath(destinationPath);
|
||||||
|
Directory.CreateDirectory(
|
||||||
|
Path.GetDirectoryName(fullPath)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The artifact staging path has no parent directory."));
|
||||||
|
|
||||||
|
bool ownsDestination = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||||
|
artifact.Url,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
ReleaseManifestClient.RequireSecureOrLoopback(
|
||||||
|
response.RequestMessage?.RequestUri ?? artifact.Url,
|
||||||
|
"artifact redirect");
|
||||||
|
if (response.Content.Headers.ContentLength is long contentLength
|
||||||
|
&& contentLength != artifact.Size)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Artifact size header mismatch: expected {artifact.Size}, "
|
||||||
|
+ $"received {contentLength}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.Content.Headers.ContentEncoding.Count != 0)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"Release artifact content encoding is not allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using Stream input = await response.Content
|
||||||
|
.ReadAsStreamAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await using var output = new FileStream(
|
||||||
|
fullPath,
|
||||||
|
FileMode.CreateNew,
|
||||||
|
FileAccess.Write,
|
||||||
|
FileShare.None,
|
||||||
|
BufferSize,
|
||||||
|
FileOptions.Asynchronous
|
||||||
|
| FileOptions.SequentialScan
|
||||||
|
| FileOptions.WriteThrough);
|
||||||
|
ownsDestination = true;
|
||||||
|
using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||||
|
byte[] buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||||
|
long received = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
progress?.Report(new ArtifactDownloadProgress(0, artifact.Size));
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int read = await input.ReadAsync(
|
||||||
|
buffer.AsMemory(0, BufferSize),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
received = checked(received + read);
|
||||||
|
if (received > artifact.Size)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Artifact exceeded its declared size of {artifact.Size} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
hash.AppendData(buffer, 0, read);
|
||||||
|
await output.WriteAsync(
|
||||||
|
buffer.AsMemory(0, read),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
progress?.Report(new ArtifactDownloadProgress(received, artifact.Size));
|
||||||
|
}
|
||||||
|
|
||||||
|
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
output.Flush(flushToDisk: true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (received != artifact.Size)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Artifact ended at {received} bytes; expected {artifact.Size}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string actualSha256 = Convert.ToHexStringLower(hash.GetHashAndReset());
|
||||||
|
if (!string.Equals(
|
||||||
|
actualSha256,
|
||||||
|
artifact.Sha256,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"Artifact SHA-256 does not match the release manifest.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VerifiedArtifactDownload(fullPath, received, actualSha256);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
if (ownsDestination)
|
||||||
|
{
|
||||||
|
TryDelete(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (LauncherUpdateException)
|
||||||
|
{
|
||||||
|
if (ownsDestination)
|
||||||
|
{
|
||||||
|
TryDelete(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException
|
||||||
|
or IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or CryptographicException)
|
||||||
|
{
|
||||||
|
if (ownsDestination)
|
||||||
|
{
|
||||||
|
TryDelete(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release artifact could not be downloaded: {ex.Message}",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void TryDelete(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The exact random staging name is reclaimed by startup recovery.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
using System.Reflection;
|
||||||
using AcDream.Launcher.Core.Installation;
|
using AcDream.Launcher.Core.Installation;
|
||||||
using AcDream.Launcher.Core.Launching;
|
using AcDream.Launcher.Core.Launching;
|
||||||
using AcDream.Launcher.Core.Orchestration;
|
using AcDream.Launcher.Core.Orchestration;
|
||||||
using AcDream.Launcher.Core.Profiles;
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
using AcDream.Launcher.ViewModels;
|
using AcDream.Launcher.ViewModels;
|
||||||
using AcDream.Platform;
|
using AcDream.Platform;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
|
@ -14,6 +16,8 @@ public sealed partial class App : Application
|
||||||
{
|
{
|
||||||
private LauncherOrchestrator? _orchestrator;
|
private LauncherOrchestrator? _orchestrator;
|
||||||
private LauncherWindowViewModel? _viewModel;
|
private LauncherWindowViewModel? _viewModel;
|
||||||
|
private HttpClient? _updateHttpClient;
|
||||||
|
private ReleaseManifestClient? _manifestClient;
|
||||||
|
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
|
@ -23,6 +27,7 @@ public sealed partial class App : Application
|
||||||
{
|
{
|
||||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
||||||
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
||||||
|
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||||
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||||
var installer = new LauncherInstaller(
|
var installer = new LauncherInstaller(
|
||||||
paths,
|
paths,
|
||||||
|
|
@ -47,16 +52,38 @@ public sealed partial class App : Application
|
||||||
$"Client content verification failed: {ex.Message}");
|
$"Client content verification failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var clientVersions = new ClientVersionStore(paths);
|
||||||
|
_ = clientVersions.LoadAndRecoverAsync(rid)
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
|
|
||||||
_orchestrator = new LauncherOrchestrator(
|
_orchestrator = new LauncherOrchestrator(
|
||||||
profiles,
|
profiles,
|
||||||
paths,
|
paths,
|
||||||
LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory),
|
LauncherExecutableSet.FromCurrentVersionStore(clientVersions),
|
||||||
verification.Record,
|
verification.Record,
|
||||||
installationStatus: verification.Status);
|
installationStatus: verification.Status,
|
||||||
|
updateSessionBarrier: clientVersions.Barrier);
|
||||||
|
_updateHttpClient = new HttpClient();
|
||||||
|
_updateHttpClient.Timeout = TimeSpan.FromSeconds(15);
|
||||||
|
_updateHttpClient.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||||
|
"acdream-launcher/1");
|
||||||
|
_manifestClient = new ReleaseManifestClient(_updateHttpClient);
|
||||||
|
var selfUpdates = new LauncherSelfUpdateManager(paths, _updateHttpClient);
|
||||||
|
var updater = new LauncherUpdater(
|
||||||
|
_manifestClient,
|
||||||
|
_updateHttpClient,
|
||||||
|
clientVersions,
|
||||||
|
selfUpdates,
|
||||||
|
GetLauncherVersion(),
|
||||||
|
rid,
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
() => _orchestrator.GetSnapshot().Sessions.Any(session => session.IsActive));
|
||||||
_viewModel = new LauncherWindowViewModel(
|
_viewModel = new LauncherWindowViewModel(
|
||||||
_orchestrator,
|
_orchestrator,
|
||||||
new AvaloniaUiDispatcher(),
|
new AvaloniaUiDispatcher(),
|
||||||
installer);
|
installer,
|
||||||
|
updater);
|
||||||
_viewModel.Initialize();
|
_viewModel.Initialize();
|
||||||
|
|
||||||
desktop.MainWindow = new MainWindow
|
desktop.MainWindow = new MainWindow
|
||||||
|
|
@ -73,7 +100,25 @@ public sealed partial class App : Application
|
||||||
{
|
{
|
||||||
_viewModel?.Dispose();
|
_viewModel?.Dispose();
|
||||||
_orchestrator?.Dispose();
|
_orchestrator?.Dispose();
|
||||||
|
_manifestClient?.Dispose();
|
||||||
|
_updateHttpClient?.Dispose();
|
||||||
_viewModel = null;
|
_viewModel = null;
|
||||||
_orchestrator = null;
|
_orchestrator = null;
|
||||||
|
_manifestClient = null;
|
||||||
|
_updateHttpClient = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LauncherVersion GetLauncherVersion()
|
||||||
|
{
|
||||||
|
string? informationalVersion = typeof(App).Assembly
|
||||||
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||||
|
.InformationalVersion;
|
||||||
|
if (!LauncherVersion.TryParse(informationalVersion, out LauncherVersion? version))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Launcher informational version '{informationalVersion}' is not SemVer 2.0.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return version;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
<Button Content="First-run setup"
|
<Button Content="First-run setup"
|
||||||
Command="{Binding FirstRunWizardShell.OpenCommand}" />
|
Command="{Binding FirstRunWizardShell.OpenCommand}" />
|
||||||
<Button Content="Check for updates"
|
<Button Content="Check for updates"
|
||||||
Command="{Binding UpdatePromptShell.OpenCommand}" />
|
Command="{Binding UpdatePrompt.OpenCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
@ -434,21 +434,72 @@
|
||||||
KeyboardNavigation.TabNavigation="Cycle"
|
KeyboardNavigation.TabNavigation="Cycle"
|
||||||
KeyDown="OnModalKeyDown"
|
KeyDown="OnModalKeyDown"
|
||||||
AutomationProperties.Name="Update prompt modal dialog"
|
AutomationProperties.Name="Update prompt modal dialog"
|
||||||
IsVisible="{Binding UpdatePromptShell.IsOpen}">
|
IsVisible="{Binding UpdatePrompt.IsOpen}">
|
||||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
<Border Classes="card" Width="640" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock Text="{Binding UpdatePromptShell.Title}" FontSize="24" FontWeight="Bold" />
|
<TextBlock Text="{Binding UpdatePrompt.Title}" FontSize="24" FontWeight="Bold" />
|
||||||
<TextBlock Text="{Binding UpdatePromptShell.Body}" TextWrapping="Wrap" />
|
<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">
|
<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>
|
</Border>
|
||||||
<Button x:Name="UpdateCloseButton"
|
<Border Background="#5B2630"
|
||||||
Content="Close"
|
Padding="10"
|
||||||
HorizontalAlignment="Right"
|
CornerRadius="5"
|
||||||
IsCancel="True"
|
IsVisible="{Binding UpdatePrompt.HasError}">
|
||||||
IsDefault="True"
|
<TextBlock Text="{Binding UpdatePrompt.Error}" TextWrapping="Wrap" />
|
||||||
AutomationProperties.Name="Close update prompt"
|
</Border>
|
||||||
Command="{Binding UpdatePromptShell.CloseCommand}" />
|
<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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ public sealed partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
FirstRunDatDirectoryTextBox.Focus();
|
FirstRunDatDirectoryTextBox.Focus();
|
||||||
}
|
}
|
||||||
else if (viewModel.UpdatePromptShell.IsOpen)
|
else if (viewModel.UpdatePrompt.IsOpen)
|
||||||
{
|
{
|
||||||
UpdateCloseButton.Focus();
|
UpdateCloseButton.Focus();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
|
using AcDream.Platform;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
|
||||||
namespace AcDream.Launcher;
|
namespace AcDream.Launcher;
|
||||||
|
|
@ -15,7 +17,34 @@ internal static class Program
|
||||||
return 0;
|
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() =>
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
|
|
|
||||||
520
src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs
Normal file
520
src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs
Normal file
|
|
@ -0,0 +1,520 @@
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.ViewModels;
|
||||||
|
|
||||||
|
public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILauncherUpdater _updater;
|
||||||
|
private readonly IUiDispatcher _dispatcher;
|
||||||
|
private readonly Action _onClientChanged;
|
||||||
|
private readonly Func<bool> _canOpen;
|
||||||
|
private readonly Func<bool> _canMutate;
|
||||||
|
private CancellationTokenSource? _cancellation;
|
||||||
|
private LauncherUpdateCheckResult? _check;
|
||||||
|
private bool _isOpen;
|
||||||
|
private bool _isBusy;
|
||||||
|
private bool _disposed;
|
||||||
|
private string _status = "No update check has run yet.";
|
||||||
|
private string? _error;
|
||||||
|
private LauncherUpdatePhase _phase = LauncherUpdatePhase.Idle;
|
||||||
|
private double _progressPercent;
|
||||||
|
private bool _isProgressIndeterminate;
|
||||||
|
private string? _launcherRestartStatus;
|
||||||
|
|
||||||
|
public LauncherUpdateViewModel(
|
||||||
|
ILauncherUpdater updater,
|
||||||
|
IUiDispatcher dispatcher,
|
||||||
|
Action onClientChanged,
|
||||||
|
Func<bool>? canOpen = null,
|
||||||
|
Func<bool>? canMutate = null)
|
||||||
|
{
|
||||||
|
_updater = updater ?? throw new ArgumentNullException(nameof(updater));
|
||||||
|
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||||
|
_onClientChanged = onClientChanged
|
||||||
|
?? throw new ArgumentNullException(nameof(onClientChanged));
|
||||||
|
_canOpen = canOpen ?? (() => true);
|
||||||
|
_canMutate = canMutate ?? (() => true);
|
||||||
|
|
||||||
|
OpenCommand = new AsyncRelayCommand(OpenAndCheckAsync, () => _canOpen() && !IsBusy);
|
||||||
|
CloseCommand = new RelayCommand(Close, () => !IsBusy);
|
||||||
|
CheckCommand = new AsyncRelayCommand(
|
||||||
|
() => CheckAsync(startup: false),
|
||||||
|
() => IsOpen && !IsBusy);
|
||||||
|
InstallClientCommand = new AsyncRelayCommand(
|
||||||
|
InstallClientAsync,
|
||||||
|
() => IsOpen
|
||||||
|
&& !IsBusy
|
||||||
|
&& _canMutate()
|
||||||
|
&& _check is
|
||||||
|
{
|
||||||
|
IsClientUpdateAvailable: true,
|
||||||
|
IsLauncherMinimumSatisfied: true,
|
||||||
|
});
|
||||||
|
StageLauncherCommand = new AsyncRelayCommand(
|
||||||
|
StageLauncherAsync,
|
||||||
|
() => IsOpen
|
||||||
|
&& !IsBusy
|
||||||
|
&& _canMutate()
|
||||||
|
&& !IsLauncherRestartRequired
|
||||||
|
&& _check is { IsLauncherUpdateAvailable: true });
|
||||||
|
RollbackCommand = new AsyncRelayCommand(
|
||||||
|
RollbackAsync,
|
||||||
|
() => IsOpen
|
||||||
|
&& !IsBusy
|
||||||
|
&& _canMutate()
|
||||||
|
&& !string.IsNullOrEmpty(_updater.CurrentClient.PreviousVersion));
|
||||||
|
CancelCommand = new RelayCommand(
|
||||||
|
() => _cancellation?.Cancel(),
|
||||||
|
() => IsBusy && _cancellation is not null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Title => "Client and launcher updates";
|
||||||
|
|
||||||
|
public string Body =>
|
||||||
|
"Releases are downloaded from the pinned eriknihlen/acdream GitHub feed. "
|
||||||
|
+ "Every archive is size/SHA-256 verified and safely extracted before "
|
||||||
|
+ "the active client pointer can change.";
|
||||||
|
|
||||||
|
public bool IsOpen
|
||||||
|
{
|
||||||
|
get => _isOpen;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isOpen, value))
|
||||||
|
{
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBusy
|
||||||
|
{
|
||||||
|
get => _isBusy;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isBusy, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CanClose));
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanClose => !IsBusy;
|
||||||
|
|
||||||
|
public string Status
|
||||||
|
{
|
||||||
|
get => _status;
|
||||||
|
private set => SetProperty(ref _status, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Error
|
||||||
|
{
|
||||||
|
get => _error;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _error, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasError => !string.IsNullOrWhiteSpace(Error);
|
||||||
|
|
||||||
|
public LauncherUpdatePhase Phase
|
||||||
|
{
|
||||||
|
get => _phase;
|
||||||
|
private set => SetProperty(ref _phase, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double ProgressPercent
|
||||||
|
{
|
||||||
|
get => _progressPercent;
|
||||||
|
private set => SetProperty(ref _progressPercent, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsProgressIndeterminate
|
||||||
|
{
|
||||||
|
get => _isProgressIndeterminate;
|
||||||
|
private set => SetProperty(ref _isProgressIndeterminate, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentClientVersion =>
|
||||||
|
_updater.CurrentClient.Version?.Value ?? "not installed";
|
||||||
|
|
||||||
|
public string AvailableVersion => _check?.Manifest.Version.Value ?? "not checked";
|
||||||
|
|
||||||
|
public string CurrentLauncherVersion =>
|
||||||
|
_check?.LauncherVersion.Value ?? "loading";
|
||||||
|
|
||||||
|
public bool IsClientUpdateAvailable => _check?.IsClientUpdateAvailable == true;
|
||||||
|
|
||||||
|
public bool IsLauncherUpdateAvailable => _check?.IsLauncherUpdateAvailable == true;
|
||||||
|
|
||||||
|
public bool IsLauncherRestartRequired =>
|
||||||
|
!string.IsNullOrWhiteSpace(_launcherRestartStatus);
|
||||||
|
|
||||||
|
public string LauncherRestartStatus => _launcherRestartStatus ?? string.Empty;
|
||||||
|
|
||||||
|
public bool IsLauncherMinimumBlocked => _check is
|
||||||
|
{
|
||||||
|
IsClientUpdateAvailable: true,
|
||||||
|
IsLauncherMinimumSatisfied: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public string MinimumLauncherStatus => _check is null
|
||||||
|
? string.Empty
|
||||||
|
: _check.IsLauncherMinimumSatisfied
|
||||||
|
? $"Launcher meets minimum {_check.Manifest.MinimumLauncherVersion}."
|
||||||
|
: $"Install launcher {_check.Manifest.MinimumLauncherVersion} or newer before the client update.";
|
||||||
|
|
||||||
|
public AsyncRelayCommand OpenCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand CloseCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand CheckCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand InstallClientCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand StageLauncherCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand RollbackCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand CancelCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launch-time polling is deliberately nonfatal: an offline or malformed
|
||||||
|
/// feed changes only this status and never prevents profile/session use.
|
||||||
|
/// </summary>
|
||||||
|
public async Task StartupCheckAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CheckAsync(startup: true).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// CheckAsync owns visible state and never lets startup polling
|
||||||
|
// escape into the Avalonia initialization transaction.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
if (!IsBusy)
|
||||||
|
{
|
||||||
|
IsOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void NotifyCommandStates()
|
||||||
|
{
|
||||||
|
OpenCommand.NotifyCanExecuteChanged();
|
||||||
|
CloseCommand.NotifyCanExecuteChanged();
|
||||||
|
CheckCommand.NotifyCanExecuteChanged();
|
||||||
|
InstallClientCommand.NotifyCanExecuteChanged();
|
||||||
|
StageLauncherCommand.NotifyCanExecuteChanged();
|
||||||
|
RollbackCommand.NotifyCanExecuteChanged();
|
||||||
|
CancelCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_cancellation?.Cancel();
|
||||||
|
_cancellation?.Dispose();
|
||||||
|
_cancellation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenAndCheckAsync()
|
||||||
|
{
|
||||||
|
IsOpen = true;
|
||||||
|
await CheckAsync(startup: false).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CheckAsync(bool startup)
|
||||||
|
{
|
||||||
|
if (IsBusy || _disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
_cancellation = cancellation;
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
Phase = LauncherUpdatePhase.Checking;
|
||||||
|
Status = "Checking the pinned GitHub release manifest...";
|
||||||
|
IsProgressIndeterminate = true;
|
||||||
|
ProgressPercent = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_check = await _updater.CheckAsync(cancellation.Token)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
Status = _check.Status;
|
||||||
|
Phase = LauncherUpdatePhase.Completed;
|
||||||
|
RefreshVersionProperties();
|
||||||
|
if (startup
|
||||||
|
&& (_check.IsClientUpdateAvailable
|
||||||
|
|| _check.IsLauncherUpdateAvailable))
|
||||||
|
{
|
||||||
|
IsOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Status = "Update check cancelled.";
|
||||||
|
Phase = LauncherUpdatePhase.Cancelled;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
string detail = SafeDisplayError(ex);
|
||||||
|
Status = startup
|
||||||
|
? $"Automatic update check unavailable; continuing offline. {detail}"
|
||||||
|
: "Update check failed.";
|
||||||
|
Error = startup ? null : detail;
|
||||||
|
Phase = LauncherUpdatePhase.Failed;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsProgressIndeterminate = false;
|
||||||
|
if (ReferenceEquals(_cancellation, cancellation))
|
||||||
|
{
|
||||||
|
_cancellation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task InstallClientAsync() => RunMutationAsync(
|
||||||
|
(check, progress, token) => _updater.InstallClientAsync(check, progress, token),
|
||||||
|
"Installing the client update...",
|
||||||
|
"Client update installed and activated.",
|
||||||
|
clientChanged: true);
|
||||||
|
|
||||||
|
private Task StageLauncherAsync() => RunMutationAsync(
|
||||||
|
async (check, progress, token) =>
|
||||||
|
{
|
||||||
|
SelfUpdateStageResult staged = await _updater
|
||||||
|
.StageLauncherAsync(check, progress, token)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
_launcherRestartStatus = staged.Status;
|
||||||
|
OnPropertyChanged(nameof(IsLauncherRestartRequired));
|
||||||
|
OnPropertyChanged(nameof(LauncherRestartStatus));
|
||||||
|
return _updater.CurrentClient;
|
||||||
|
},
|
||||||
|
"Staging the launcher update...",
|
||||||
|
"Launcher update staged; restart the launcher to apply it.",
|
||||||
|
clientChanged: false);
|
||||||
|
|
||||||
|
private async Task RollbackAsync()
|
||||||
|
{
|
||||||
|
if (IsBusy || _disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
_cancellation = cancellation;
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
Status = "Rolling back the client...";
|
||||||
|
IsProgressIndeterminate = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = new UiProgress<LauncherUpdateProgress>(
|
||||||
|
_dispatcher,
|
||||||
|
ApplyProgress);
|
||||||
|
_ = await _updater.RollbackClientAsync(progress, cancellation.Token)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
_onClientChanged();
|
||||||
|
RefreshVersionProperties();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Status = "Rollback cancelled; the active version was not changed.";
|
||||||
|
Phase = LauncherUpdatePhase.Cancelled;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = SafeDisplayError(ex);
|
||||||
|
Status = "Rollback failed.";
|
||||||
|
Phase = LauncherUpdatePhase.Failed;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsProgressIndeterminate = false;
|
||||||
|
if (ReferenceEquals(_cancellation, cancellation))
|
||||||
|
{
|
||||||
|
_cancellation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunMutationAsync(
|
||||||
|
Func<
|
||||||
|
LauncherUpdateCheckResult,
|
||||||
|
IProgress<LauncherUpdateProgress>,
|
||||||
|
CancellationToken,
|
||||||
|
Task<ClientVersionResolution>> operation,
|
||||||
|
string initialStatus,
|
||||||
|
string completedStatus,
|
||||||
|
bool clientChanged)
|
||||||
|
{
|
||||||
|
if (IsBusy || _disposed || _check is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
_cancellation = cancellation;
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
Status = initialStatus;
|
||||||
|
IsProgressIndeterminate = true;
|
||||||
|
ProgressPercent = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = new UiProgress<LauncherUpdateProgress>(
|
||||||
|
_dispatcher,
|
||||||
|
ApplyProgress);
|
||||||
|
_ = await operation(_check, progress, cancellation.Token)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
if (clientChanged)
|
||||||
|
{
|
||||||
|
_onClientChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshVersionProperties();
|
||||||
|
Phase = LauncherUpdatePhase.Completed;
|
||||||
|
Status = IsLauncherRestartRequired
|
||||||
|
? LauncherRestartStatus
|
||||||
|
: completedStatus;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_check = await _updater.CheckAsync(CancellationToken.None)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
if (!IsLauncherRestartRequired)
|
||||||
|
{
|
||||||
|
Status = _check.Status;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshVersionProperties();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Status += " Release status refresh is unavailable: "
|
||||||
|
+ SafeDisplayError(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Status = "Update operation cancelled; published state was not changed.";
|
||||||
|
Phase = LauncherUpdatePhase.Cancelled;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = SafeDisplayError(ex);
|
||||||
|
Status = "Update operation failed.";
|
||||||
|
Phase = LauncherUpdatePhase.Failed;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsProgressIndeterminate = false;
|
||||||
|
if (ReferenceEquals(_cancellation, cancellation))
|
||||||
|
{
|
||||||
|
_cancellation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyProgress(LauncherUpdateProgress value)
|
||||||
|
{
|
||||||
|
Phase = value.Phase;
|
||||||
|
Status = value.Status;
|
||||||
|
ProgressPercent = value.Percent;
|
||||||
|
IsProgressIndeterminate = value.Total <= 0
|
||||||
|
&& value.Phase is not (
|
||||||
|
LauncherUpdatePhase.Completed
|
||||||
|
or LauncherUpdatePhase.Cancelled
|
||||||
|
or LauncherUpdatePhase.Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshVersionProperties()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CurrentClientVersion));
|
||||||
|
OnPropertyChanged(nameof(AvailableVersion));
|
||||||
|
OnPropertyChanged(nameof(CurrentLauncherVersion));
|
||||||
|
OnPropertyChanged(nameof(IsClientUpdateAvailable));
|
||||||
|
OnPropertyChanged(nameof(IsLauncherUpdateAvailable));
|
||||||
|
OnPropertyChanged(nameof(IsLauncherRestartRequired));
|
||||||
|
OnPropertyChanged(nameof(LauncherRestartStatus));
|
||||||
|
OnPropertyChanged(nameof(IsLauncherMinimumBlocked));
|
||||||
|
OnPropertyChanged(nameof(MinimumLauncherStatus));
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeDisplayError(Exception exception) =>
|
||||||
|
string.IsNullOrWhiteSpace(exception.Message)
|
||||||
|
? "The update operation failed."
|
||||||
|
: exception.Message;
|
||||||
|
|
||||||
|
private sealed class UiProgress<T>(IUiDispatcher dispatcher, Action<T> callback)
|
||||||
|
: IProgress<T>
|
||||||
|
{
|
||||||
|
public void Report(T value) => dispatcher.Post(() => callback(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UnavailableLauncherUpdater : ILauncherUpdater
|
||||||
|
{
|
||||||
|
private static readonly ClientVersionResolution Missing = new(
|
||||||
|
ClientVersionState.Missing,
|
||||||
|
"Versioned client updater is unavailable.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
|
||||||
|
public ClientVersionResolution CurrentClient => Missing;
|
||||||
|
|
||||||
|
public Task<ClientVersionResolution> InitializeAsync(
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(Missing);
|
||||||
|
|
||||||
|
public Task<LauncherUpdateCheckResult> CheckAsync(
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromException<LauncherUpdateCheckResult>(
|
||||||
|
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||||
|
|
||||||
|
public Task<ClientVersionResolution> InstallClientAsync(
|
||||||
|
LauncherUpdateCheckResult check,
|
||||||
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromException<ClientVersionResolution>(
|
||||||
|
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||||
|
|
||||||
|
public Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||||
|
LauncherUpdateCheckResult check,
|
||||||
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromException<SelfUpdateStageResult>(
|
||||||
|
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||||
|
|
||||||
|
public Task<ClientVersionResolution> RollbackClientAsync(
|
||||||
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromException<ClientVersionResolution>(
|
||||||
|
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ using AcDream.Launcher.Core.Installation;
|
||||||
using AcDream.Launcher.Core.Launching;
|
using AcDream.Launcher.Core.Launching;
|
||||||
using AcDream.Launcher.Core.Orchestration;
|
using AcDream.Launcher.Core.Orchestration;
|
||||||
using AcDream.Launcher.Core.Profiles;
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
namespace AcDream.Launcher.ViewModels;
|
namespace AcDream.Launcher.ViewModels;
|
||||||
|
|
||||||
|
|
@ -25,7 +26,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
public LauncherWindowViewModel(
|
public LauncherWindowViewModel(
|
||||||
ILauncherOrchestrator orchestrator,
|
ILauncherOrchestrator orchestrator,
|
||||||
IUiDispatcher dispatcher,
|
IUiDispatcher dispatcher,
|
||||||
ILauncherInstaller? installer = null)
|
ILauncherInstaller? installer = null,
|
||||||
|
ILauncherUpdater? updater = null)
|
||||||
{
|
{
|
||||||
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||||
|
|
@ -38,17 +40,16 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
OnInstallCompleted,
|
OnInstallCompleted,
|
||||||
() => CanInteract,
|
() => CanInteract,
|
||||||
() => !IsBusy && Sessions.All(session => !session.IsActive));
|
() => !IsBusy && Sessions.All(session => !session.IsActive));
|
||||||
UpdatePromptShell = new LauncherShellViewModel(
|
UpdatePrompt = new LauncherUpdateViewModel(
|
||||||
"Client update",
|
updater ?? new UnavailableLauncherUpdater(),
|
||||||
"Review a signed release manifest, verify the downloaded archive, "
|
dispatcher,
|
||||||
+ "and atomically switch the installed client version. The updater "
|
OnClientVersionChanged,
|
||||||
+ "transaction lands in Campaign LA slice LA10.",
|
() => CanInteract,
|
||||||
"Updater shell ready — implementation arrives in LA10.",
|
() => !IsBusy && Sessions.All(session => !session.IsActive));
|
||||||
() => CanInteract);
|
|
||||||
|
|
||||||
EditorDialog.PropertyChanged += OnModalPropertyChanged;
|
EditorDialog.PropertyChanged += OnModalPropertyChanged;
|
||||||
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;
|
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;
|
||||||
UpdatePromptShell.PropertyChanged += OnModalPropertyChanged;
|
UpdatePrompt.PropertyChanged += OnModalPropertyChanged;
|
||||||
|
|
||||||
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => CanInteract);
|
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => CanInteract);
|
||||||
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
|
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
|
||||||
|
|
@ -92,7 +93,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
|
|
||||||
public FirstRunInstallerViewModel FirstRunWizardShell { get; }
|
public FirstRunInstallerViewModel FirstRunWizardShell { get; }
|
||||||
|
|
||||||
public LauncherShellViewModel UpdatePromptShell { get; }
|
public LauncherUpdateViewModel UpdatePrompt { get; }
|
||||||
|
|
||||||
public LauncherTreeNodeViewModel? SelectedNode
|
public LauncherTreeNodeViewModel? SelectedNode
|
||||||
{
|
{
|
||||||
|
|
@ -136,7 +137,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
public bool IsModalOpen =>
|
public bool IsModalOpen =>
|
||||||
EditorDialog.IsOpen
|
EditorDialog.IsOpen
|
||||||
|| FirstRunWizardShell.IsOpen
|
|| FirstRunWizardShell.IsOpen
|
||||||
|| UpdatePromptShell.IsOpen;
|
|| UpdatePrompt.IsOpen;
|
||||||
|
|
||||||
private bool CanInteract => !IsBusy && !IsModalOpen;
|
private bool CanInteract => !IsBusy && !IsModalOpen;
|
||||||
|
|
||||||
|
|
@ -300,6 +301,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
RefreshFromCore();
|
RefreshFromCore();
|
||||||
|
_ = UpdatePrompt.StartupCheckAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void PollStatus()
|
public void PollStatus()
|
||||||
|
|
@ -333,8 +335,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
|
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
|
||||||
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
|
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
|
||||||
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
|
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
|
||||||
UpdatePromptShell.PropertyChanged -= OnModalPropertyChanged;
|
UpdatePrompt.PropertyChanged -= OnModalPropertyChanged;
|
||||||
FirstRunWizardShell.Dispose();
|
FirstRunWizardShell.Dispose();
|
||||||
|
UpdatePrompt.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
||||||
|
|
@ -350,7 +353,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
{
|
{
|
||||||
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
|
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
|
||||||
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen)
|
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen)
|
||||||
&& e.PropertyName != nameof(FirstRunInstallerViewModel.IsOpen))
|
&& e.PropertyName != nameof(FirstRunInstallerViewModel.IsOpen)
|
||||||
|
&& e.PropertyName != nameof(LauncherUpdateViewModel.IsOpen))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -376,9 +380,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
{
|
{
|
||||||
FirstRunWizardShell.Close();
|
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();
|
RefreshFromCore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnClientVersionChanged()
|
||||||
|
{
|
||||||
|
OperationStatus = "Versioned client activation changed.";
|
||||||
|
LastError = null;
|
||||||
|
RefreshFromCore();
|
||||||
|
}
|
||||||
|
|
||||||
private void NotifyCommandStates()
|
private void NotifyCommandStates()
|
||||||
{
|
{
|
||||||
AddServerCommand.NotifyCanExecuteChanged();
|
AddServerCommand.NotifyCanExecuteChanged();
|
||||||
|
|
@ -996,7 +1007,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
session.NotifyCommandState();
|
session.NotifyCommandState();
|
||||||
}
|
}
|
||||||
FirstRunWizardShell.NotifyCommandStates();
|
FirstRunWizardShell.NotifyCommandStates();
|
||||||
UpdatePromptShell.NotifyCommandStates();
|
UpdatePrompt.NotifyCommandStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly record struct SelectionKey(
|
private readonly record struct SelectionKey(
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,41 @@ using System.Diagnostics;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using AcDream.Bake;
|
using AcDream.Bake;
|
||||||
using AcDream.Launcher.Core.Installation;
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
using AcDream.Platform;
|
using AcDream.Platform;
|
||||||
|
|
||||||
return args.FirstOrDefault() switch
|
return args.FirstOrDefault() switch
|
||||||
{
|
{
|
||||||
"hold-install-lease" => await HoldInstallLeaseAsync(args[1..]),
|
"hold-install-lease" => await HoldInstallLeaseAsync(args[1..]),
|
||||||
|
"hold-update-lease" => await HoldUpdateLeaseAsync(args[1..]),
|
||||||
"orphan-parent" => await RunOrphanParentAsync(args[1..]),
|
"orphan-parent" => await RunOrphanParentAsync(args[1..]),
|
||||||
"orphan-child" => RunOrphanChild(args[1..]),
|
"orphan-child" => RunOrphanChild(args[1..]),
|
||||||
_ => 2,
|
_ => 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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)
|
static async Task<int> HoldInstallLeaseAsync(string[] arguments)
|
||||||
{
|
{
|
||||||
if (arguments.Length != 3)
|
if (arguments.Length != 3)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using AcDream.Launcher.Core.Launching;
|
||||||
using AcDream.Launcher.Core.Orchestration;
|
using AcDream.Launcher.Core.Orchestration;
|
||||||
using AcDream.Launcher.Core.Profiles;
|
using AcDream.Launcher.Core.Profiles;
|
||||||
using AcDream.Launcher.Core.Status;
|
using AcDream.Launcher.Core.Status;
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
using AcDream.Platform;
|
using AcDream.Platform;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Core.Tests.Orchestration;
|
namespace AcDream.Launcher.Core.Tests.Orchestration;
|
||||||
|
|
@ -34,6 +35,26 @@ 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]
|
[Fact]
|
||||||
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
|
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
|
||||||
{
|
{
|
||||||
|
|
@ -527,7 +548,8 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
ILauncherSessionConfigService? configService = null,
|
ILauncherSessionConfigService? configService = null,
|
||||||
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
||||||
IStatusEventSourceFactory? statusSourceFactory = null,
|
IStatusEventSourceFactory? statusSourceFactory = null,
|
||||||
LauncherExecutableSet? executables = null)
|
LauncherExecutableSet? executables = null,
|
||||||
|
UpdateSessionBarrier? updateSessionBarrier = null)
|
||||||
{
|
{
|
||||||
string profilePath = Path.Combine(
|
string profilePath = Path.Combine(
|
||||||
_paths.ConfigDirectory,
|
_paths.ConfigDirectory,
|
||||||
|
|
@ -563,7 +585,8 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
configService,
|
configService,
|
||||||
supervisorFactory ?? new FakeSupervisorFactory(),
|
supervisorFactory ?? new FakeSupervisorFactory(),
|
||||||
statusSourceFactory ?? new QueueStatusSourceFactory(),
|
statusSourceFactory ?? new QueueStatusSourceFactory(),
|
||||||
() => $"s{Interlocked.Increment(ref nextSession)}");
|
() => $"s{Interlocked.Increment(ref nextSession)}",
|
||||||
|
updateSessionBarrier: updateSessionBarrier);
|
||||||
orchestrator.LoadProfiles();
|
orchestrator.LoadProfiles();
|
||||||
return orchestrator;
|
return orchestrator;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Updates;
|
||||||
|
|
||||||
|
public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions PlanOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true,
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new JsonStringEnumConverter<SelfUpdatePlanState>(
|
||||||
|
JsonNamingPolicy.CamelCase,
|
||||||
|
allowIntegerValues: false),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
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.Staged, rolledBack.State);
|
||||||
|
Assert.Null(rolledBack.Apply);
|
||||||
|
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 CrashDuringApplyingReplaysReverseJournalToStagedState()
|
||||||
|
{
|
||||||
|
using var harness = new Harness(_root);
|
||||||
|
_ = await harness.StageAsync();
|
||||||
|
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>(
|
||||||
|
await harness.Manager.LoadPendingAsync());
|
||||||
|
SelfUpdateApplyEntry[] apply = staged.Files
|
||||||
|
.Select(file => new SelfUpdateApplyEntry(
|
||||||
|
file.Path,
|
||||||
|
File.Exists(Path.Combine(
|
||||||
|
harness.Target,
|
||||||
|
file.Path.Replace('/', Path.DirectorySeparatorChar)))))
|
||||||
|
.ToArray();
|
||||||
|
SelfUpdatePlan applying = staged with
|
||||||
|
{
|
||||||
|
State = SelfUpdatePlanState.Applying,
|
||||||
|
Apply = apply,
|
||||||
|
};
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
harness.Manager.PendingPlanPath,
|
||||||
|
JsonSerializer.Serialize(applying, PlanOptions));
|
||||||
|
|
||||||
|
SelfUpdateApplyEntry first = apply[0];
|
||||||
|
string payload = Path.Combine(
|
||||||
|
harness.Manager.GetPayloadDirectory(staged.TransactionId),
|
||||||
|
first.Path.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
string target = Path.Combine(
|
||||||
|
harness.Target,
|
||||||
|
first.Path.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
string backup = Path.Combine(
|
||||||
|
harness.Manager.GetBackupDirectory(staged.TransactionId),
|
||||||
|
first.Path.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(backup)!);
|
||||||
|
File.Move(target, backup);
|
||||||
|
File.Move(payload, target);
|
||||||
|
|
||||||
|
SelfUpdatePlan recovered = await harness.Manager.RecoverApplyingAsync(harness.Target);
|
||||||
|
|
||||||
|
Assert.Equal(SelfUpdatePlanState.Staged, recovered.State);
|
||||||
|
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(staged.TransactionId),
|
||||||
|
harness.LauncherName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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.True(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||||
|
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Harness : IDisposable
|
||||||
|
{
|
||||||
|
private readonly LocalHttpFixture _server = new();
|
||||||
|
private readonly HttpClient _http = new();
|
||||||
|
private readonly byte[] _archive;
|
||||||
|
private readonly ReleaseArtifact _artifact;
|
||||||
|
|
||||||
|
public Harness(string 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 void Dispose()
|
||||||
|
{
|
||||||
|
_http.Dispose();
|
||||||
|
_server.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
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 = new ReleaseManifestClient(http, 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 = new ReleaseManifestClient(http, 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 = new ReleaseManifestClient(http, 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 = new ReleaseManifestClient(http, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
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 = new ReleaseManifestClient(http, 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("http://127.0.0.1", "http://example.test"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ValidJson() =>
|
||||||
|
"{\"schemaVersion\":1,\"version\":\"2.0.0\","
|
||||||
|
+ "\"minimumLauncherVersion\":\"1.0.0\","
|
||||||
|
+ "\"clients\":{\"win-x64\":{\"url\":\"http://127.0.0.1/client\","
|
||||||
|
+ $"\"sha256\":\"{new string('a', 64)}\",\"size\":12}},"
|
||||||
|
+ "\"launchers\":{\"win-x64\":{\"url\":\"https://example.test/launcher\","
|
||||||
|
+ $"\"sha256\":\"{new string('b', 64)}\",\"size\":12}}}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
|
||||||
|
: IProgress<ArtifactDownloadProgress>
|
||||||
|
{
|
||||||
|
public void Report(ArtifactDownloadProgress value) => callback(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
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("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")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
270
tests/AcDream.Launcher.Core.Tests/Updates/UpdateTestSupport.cs
Normal file
270
tests/AcDream.Launcher.Core.Tests/Updates/UpdateTestSupport.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
292
tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs
Normal file
292
tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,7 +34,7 @@ public sealed class LauncherWindowViewModelTests
|
||||||
|
|
||||||
Assert.True(viewModel.IsFirstRunRequired);
|
Assert.True(viewModel.IsFirstRunRequired);
|
||||||
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
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);
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||||
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
||||||
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
||||||
|
|
@ -306,7 +306,7 @@ public sealed class LauncherWindowViewModelTests
|
||||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||||
Assert.True(viewModel.IsModalOpen);
|
Assert.True(viewModel.IsModalOpen);
|
||||||
Assert.False(viewModel.AddServerCommand.CanExecute(null));
|
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));
|
Assert.False(Assert.Single(viewModel.Sessions).StopCommand.CanExecute(null));
|
||||||
|
|
||||||
// ICommand.Execute cannot bypass the modal gate.
|
// ICommand.Execute cannot bypass the modal gate.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue