fix(launcher): harden updater crash recovery

This commit is contained in:
Erik 2026-08-14 23:12:15 +02:00
parent 2d2a5b5046
commit 1955ca8ab5
27 changed files with 2714 additions and 544 deletions

View file

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

View file

@ -149,6 +149,13 @@ public sealed class LauncherExecutableSet
});
}
public static LauncherExecutableSet Unavailable(string reason)
{
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
return new LauncherExecutableSet(
() => throw new LauncherUpdateException(reason));
}
private ExecutablePaths RequireAvailable(LaunchMode mode)
{
LauncherCapability capability = GetAvailability(mode);

View file

@ -627,6 +627,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
}
request.Cancellation.Dispose();
request.Activity.StartCompleted.Set();
}
}
@ -1201,11 +1202,16 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
private static void DisposeActivity(ManagedActivity activity)
{
activity.StartCancellation?.Cancel();
activity.StartCompleted.Wait();
activity.StartCancellation?.Dispose();
activity.StartCancellation = null;
if (activity.Supervisor is not null)
{
// Disposal is a process-lifetime transaction: the shared update
// lease remains held until Stop has observed the real child
// terminal (including the post-kill wait).
activity.Supervisor.Stop(TimeSpan.FromSeconds(5));
if (activity.SupervisorStateHandler is not null)
{
activity.Supervisor.StateChanged -= activity.SupervisorStateHandler;
@ -1216,6 +1222,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
}
ReleaseUpdateSessionLease(activity);
activity.StartCompleted.Dispose();
}
private static void ReleaseUpdateSessionLease(ManagedActivity activity)
@ -1285,6 +1292,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public UpdateSessionBarrier.SessionLease? UpdateSessionLease;
public ManualResetEventSlim StartCompleted { get; } = new(false);
public object StatusReadGate { get; } = new();
public bool IsActive => State is not (

View file

@ -503,7 +503,9 @@ public sealed class ClientVersionStore
.Where(path => !string.Equals(
path,
"install.json",
StringComparison.OrdinalIgnoreCase))
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal))
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
string[] recordedFiles = record.Files
@ -809,13 +811,43 @@ public sealed class ClientVersionStore
".client-staging-*",
SearchOption.TopDirectoryOnly))
{
string suffix = Path.GetFileName(path)[".client-staging-".Length..];
if (Guid.TryParseExact(suffix, "N", out _))
if (HasCanonicalGuidName(
Path.GetFileName(path),
".client-staging-",
string.Empty))
{
SafeZipExtractor.TryDeleteDirectory(path);
}
}
foreach (string path in Directory.EnumerateDirectories(
AppDirectory,
".client-corrupt-*",
SearchOption.TopDirectoryOnly))
{
if (HasCanonicalGuidName(
Path.GetFileName(path),
".client-corrupt-",
string.Empty))
{
SafeZipExtractor.TryDeleteDirectory(path);
}
}
foreach (string path in Directory.EnumerateFiles(
AppDirectory,
".client-download-*.zip",
SearchOption.TopDirectoryOnly))
{
if (HasCanonicalGuidName(
Path.GetFileName(path),
".client-download-",
".zip"))
{
VerifiedArtifactDownloader.TryDelete(path);
}
}
foreach (string path in Directory.EnumerateFiles(
AppDirectory,
".current*.tmp",
@ -825,20 +857,43 @@ public sealed class ClientVersionStore
string[] parts = fileName.Split('.');
if (parts.Length >= 4
&& string.Equals(parts[^1], "tmp", StringComparison.Ordinal)
&& Guid.TryParseExact(parts[^2], "N", out _))
&& Guid.TryParseExact(parts[^2], "N", out Guid parsed)
&& string.Equals(
parsed.ToString("N"),
parts[^2],
StringComparison.Ordinal))
{
VerifiedArtifactDownloader.TryDelete(path);
}
}
}
private static bool HasCanonicalGuidName(
string fileName,
string prefix,
string suffix)
{
if (!fileName.StartsWith(prefix, StringComparison.Ordinal)
|| !fileName.EndsWith(suffix, StringComparison.Ordinal)
|| fileName.Length != prefix.Length + 32 + suffix.Length)
{
return false;
}
string value = fileName.Substring(prefix.Length, 32);
return Guid.TryParseExact(value, "N", out Guid parsed)
&& string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal);
}
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 _))
|| !HasCanonicalGuidName(
fileName,
".client-staging-",
string.Empty))
{
throw new LauncherUpdateException(
"The client extraction path is not an owned LA10 staging directory.");
@ -901,7 +956,7 @@ public sealed class ClientVersionStore
&& !part.Any(character =>
char.IsControl(character)
|| character is '<' or '>' or '"' or '|' or '?' or '*')
&& !IsWindowsDeviceName(part));
&& !PortablePathRules.IsWindowsDeviceName(part));
}
internal static string ResolveContained(string root, string relative)
@ -929,19 +984,6 @@ public sealed class ClientVersionStore
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)),

View file

@ -8,16 +8,17 @@ public sealed record SelfUpdateStartupResult(
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.
/// Process-level self-update bootstrap. Every child argument is passed through
/// <see cref="ProcessStartInfo.ArgumentList"/> with shell execution disabled.
/// </summary>
public static class LauncherSelfUpdateBootstrap
{
public const string HelperArgument = "--acdream-self-update-helper-v1";
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
internal const string DeferredArgument = "--acdream-self-update-deferred-v1";
internal const int DeferredLeaseExitCode = 73;
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
public static async Task<SelfUpdateStartupResult> HandleAsync(
string[] args,
@ -32,10 +33,16 @@ public static class LauncherSelfUpdateBootstrap
Path.GetFullPath(launcherBaseDirectory));
string executable = Path.GetFullPath(currentExecutablePath);
if (args.Length > 0
&& string.Equals(args[0], DeferredArgument, StringComparison.Ordinal))
{
return new SelfUpdateStartupResult(false, 0, args[1..]);
}
if (args.Length > 0
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
{
if (args.Length != 4
if (args.Length < 4
|| !int.TryParse(
args[1],
System.Globalization.NumberStyles.None,
@ -51,6 +58,7 @@ public static class LauncherSelfUpdateBootstrap
parentPid,
args[2],
args[3],
args[4..],
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(true, exitCode, []);
@ -59,7 +67,7 @@ public static class LauncherSelfUpdateBootstrap
if (args.Length > 0
&& string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal))
{
if (args.Length != 2)
if (args.Length < 2)
{
return new SelfUpdateStartupResult(true, 64, []);
}
@ -70,82 +78,97 @@ public static class LauncherSelfUpdateBootstrap
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,
await FinishConfirmedCleanupAsync(
manager,
baseDirectory,
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(false, 0, args[2..]);
}
if (!manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? startupLease))
{
// A running session or another launcher is staging. Reading the
// plan is safe, but cleanup or starting a competing helper is not.
return new SelfUpdateStartupResult(false, 0, args);
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
string expectedExecutable = ClientVersionStore.ResolveContained(
baseDirectory,
"acdream-launcher" + suffix);
if (!PathsEqual(executable, expectedExecutable))
using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
?? throw new InvalidOperationException("Exclusive startup lease is missing."))
{
throw new LauncherUpdateException(
"Self-update can start only from the published acdream-launcher executable.");
}
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
_ = manager.CleanupOwnedResidueUnderLease(
plan,
baseDirectory,
lease);
if (plan is null)
{
return new SelfUpdateStartupResult(false, 0, args);
}
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);
}
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
{
throw new LauncherUpdateException(
"The pending self-update targets a different launcher directory.");
}
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, []);
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
{
if (!manager.IsConfirmed(plan.TransactionId))
{
await manager.ConfirmAsync(
plan.TransactionId,
baseDirectory,
executable,
cancellationToken)
.ConfigureAwait(false);
}
await manager.CompleteConfirmedAsync(
plan.TransactionId,
baseDirectory,
cancellationToken)
.ConfigureAwait(false);
_ = manager.CleanupOwnedResidueUnderLease(
pending: null,
baseDirectory,
lease);
return new SelfUpdateStartupResult(false, 0, args);
}
string expectedExecutable = ClientVersionStore.ResolveContained(
baseDirectory,
GetLauncherFileName(plan.Rid));
if (!PathsEqual(executable, expectedExecutable))
{
throw new LauncherUpdateException(
"Self-update can start only from the published acdream-launcher executable.");
}
string helperPath = manager.GetStagedLauncherPath(plan);
var startInfo = new ProcessStartInfo(helperPath)
{
UseShellExecute = false,
WorkingDirectory = manager.GetPayloadDirectory(plan.TransactionId),
};
startInfo.ArgumentList.Add(HelperArgument);
startInfo.ArgumentList.Add(
Environment.ProcessId.ToString(
System.Globalization.CultureInfo.InvariantCulture));
startInfo.ArgumentList.Add(baseDirectory);
startInfo.ArgumentList.Add(plan.TransactionId);
foreach (string argument in args)
{
startInfo.ArgumentList.Add(argument);
}
_ = Process.Start(startInfo)
?? throw new LauncherUpdateException(
"The launcher self-update helper could not be started.");
return new SelfUpdateStartupResult(true, 0, []);
}
}
private static async Task<int> RunHelperAsync(
@ -153,6 +176,7 @@ public static class LauncherSelfUpdateBootstrap
int parentPid,
string targetDirectory,
string transactionId,
IReadOnlyList<string> publicArguments,
CancellationToken cancellationToken)
{
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
@ -164,12 +188,15 @@ public static class LauncherSelfUpdateBootstrap
"The helper transaction does not match the pending self-update.");
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
if (!PathsEqual(plan.TargetDirectory, targetDirectory))
{
throw new LauncherUpdateException(
"The helper target does not match the pending self-update.");
}
string launcherPath = ClientVersionStore.ResolveContained(
targetDirectory,
"acdream-launcher" + suffix);
GetLauncherFileName(plan.Rid));
var startInfo = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
@ -177,97 +204,184 @@ public static class LauncherSelfUpdateBootstrap
};
startInfo.ArgumentList.Add(ConfirmArgument);
startInfo.ArgumentList.Add(transactionId);
Process? replacement = null;
UpdateSessionBarrier.ExclusiveLease? updateLease = null;
bool appliedByThisHelper = false;
try
foreach (string argument in publicArguments)
{
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;
startInfo.ArgumentList.Add(argument);
}
catch
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
if (!manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? updateLease))
{
if (replacement is { HasExited: false })
// Do not restart the canonical launcher: it would immediately see
// the same staged plan and create an unbounded helper loop.
return DeferredLeaseExitCode;
}
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
?? throw new InvalidOperationException("Exclusive update lease is missing."))
{
plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException(
"The helper found no pending self-update after acquiring the lease.");
if (!string.Equals(
plan.TransactionId,
transactionId,
StringComparison.Ordinal)
|| !PathsEqual(plan.TargetDirectory, targetDirectory))
{
replacement.Kill(entireProcessTree: true);
await replacement.WaitForExitAsync(CancellationToken.None)
.ConfigureAwait(false);
throw new LauncherUpdateException(
"The pending self-update changed before the helper acquired its lease.");
}
_ = manager.CleanupOwnedResidueUnderLease(
plan,
targetDirectory,
lease);
Process? replacement = null;
bool appliedByThisHelper = false;
try
{
if (appliedByThisHelper)
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))
{
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
CancellationToken.None)
.ConfigureAwait(false);
if (pending?.State == SelfUpdatePlanState.Applying)
cancellationToken.ThrowIfCancellationRequested();
if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
{
_ = await manager.RecoverApplyingAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
{
_ = await manager.RollbackAwaitingConfirmationAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
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
{
// Do not start an executable from an ambiguous half-applied
// state. A subsequent startup replays the durable journal.
return 75;
}
if (replacement is { HasExited: false })
{
replacement.Kill(entireProcessTree: true);
await replacement.WaitForExitAsync(CancellationToken.None)
.ConfigureAwait(false);
}
var restored = new ProcessStartInfo(launcherPath)
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
{
// An ambiguous state must not start either executable.
return 75;
}
var restored = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
restored.ArgumentList.Add(DeferredArgument);
foreach (string argument in publicArguments)
{
restored.ArgumentList.Add(argument);
}
_ = Process.Start(restored);
return 74;
}
finally
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
_ = Process.Start(restored);
return 74;
}
finally
{
replacement?.Dispose();
updateLease?.Dispose();
replacement?.Dispose();
}
}
}
private static async Task FinishConfirmedCleanupAsync(
LauncherSelfUpdateManager manager,
string targetDirectory,
CancellationToken cancellationToken)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout;
do
{
cancellationToken.ThrowIfCancellationRequested();
if (manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? lease))
{
using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease
?? throw new InvalidOperationException(
"Exclusive cleanup lease is missing."))
{
SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
if (pending is
{
State: SelfUpdatePlanState.AwaitingConfirmation,
}
&& manager.IsConfirmed(pending.TransactionId))
{
await manager.CompleteConfirmedAsync(
pending.TransactionId,
targetDirectory,
cancellationToken)
.ConfigureAwait(false);
pending = null;
}
if (manager.CleanupOwnedResidueUnderLease(
pending,
targetDirectory,
acquiredLease))
{
return;
}
}
}
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
}
while (DateTimeOffset.UtcNow < deadline);
}
private static string GetLauncherFileName(string rid) =>
"acdream-launcher"
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
private static async Task WaitForParentExitAsync(
int parentPid,
CancellationToken cancellationToken)

View file

@ -292,9 +292,6 @@ public sealed class LauncherUpdater : ILauncherUpdater
try
{
RefuseRunningSessions();
using UpdateSessionBarrier.ExclusiveLease lease =
_versions.Barrier.AcquireExclusive();
RefuseRunningSessions();
if (check.Manifest.Version <= _launcherVersion)
{
throw new LauncherUpdateException(

View file

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

View file

@ -10,9 +10,11 @@ public interface IReleaseManifestClient
}
/// <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.
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
/// construction is HTTPS-only. The loopback HTTP allowance is available only
/// through an internal fixture factory and is never inferred from a URI.
/// Redirects are followed manually so every hop is checked before any bytes
/// cross that hop.
/// </summary>
public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
{
@ -20,6 +22,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
public const string GitHubRepository = "acdream";
public const int MaximumManifestBytes = 1024 * 1024;
public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024;
public const int MaximumRedirects = 5;
public static Uri ProductionManifestUri { get; } = new(
$"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json");
@ -33,65 +36,103 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
};
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly Uri _manifestUri;
private readonly bool _allowLoopbackHttp;
public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null)
public ReleaseManifestClient(TimeSpan? timeout = null)
: this(
ProductionManifestUri,
allowLoopbackHttp: false,
CreateRedirectDisabledHandler(),
timeout)
{
_httpClient = httpClient ?? new HttpClient();
_ownsHttpClient = httpClient is null;
_manifestUri = manifestUri ?? ProductionManifestUri;
RequireSecureOrLoopback(_manifestUri, "manifest");
if (_ownsHttpClient)
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
}
}
private ReleaseManifestClient(
Uri manifestUri,
bool allowLoopbackHttp,
HttpMessageHandler handler,
TimeSpan? timeout)
{
ArgumentNullException.ThrowIfNull(manifestUri);
ArgumentNullException.ThrowIfNull(handler);
_manifestUri = manifestUri;
_allowLoopbackHttp = allowLoopbackHttp;
RequireTransport(_manifestUri, "manifest", _allowLoopbackHttp);
_httpClient = new HttpClient(handler, disposeHandler: true)
{
Timeout = timeout ?? TimeSpan.FromSeconds(15),
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
}
internal static ReleaseManifestClient CreateLoopbackFixture(
Uri manifestUri,
TimeSpan? timeout = null) => new(
manifestUri,
allowLoopbackHttp: true,
CreateRedirectDisabledHandler(),
timeout);
internal static ReleaseManifestClient CreateForTransportTest(
Uri manifestUri,
bool allowLoopbackHttp,
HttpMessageHandler handler) => new(
manifestUri,
allowLoopbackHttp,
handler,
TimeSpan.FromSeconds(15));
public async Task<ReleaseManifest> FetchAsync(
CancellationToken cancellationToken = default)
{
try
{
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)
Uri current = _manifestUri;
var visited = new HashSet<string>(StringComparer.Ordinal);
for (int redirectCount = 0;;)
{
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)
RequireTransport(current, "manifest redirect", _allowLoopbackHttp);
if (!visited.Add(current.AbsoluteUri))
{
throw new LauncherUpdateException(
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
"The release manifest redirect chain contains a loop.");
}
output.Write(buffer, 0, read);
}
using var request = new HttpRequestMessage(HttpMethod.Get, current);
using HttpResponseMessage response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
if (IsRedirect(response.StatusCode))
{
if (redirectCount >= MaximumRedirects)
{
throw new LauncherUpdateException(
$"The release manifest exceeded {MaximumRedirects} redirects.");
}
return Parse(output.ToArray());
Uri? location = response.Headers.Location;
if (location is null)
{
throw new LauncherUpdateException(
"The release manifest redirect has no Location header.");
}
Uri next = location.IsAbsoluteUri
? location
: new Uri(current, location);
RequireTransport(next, "manifest redirect", _allowLoopbackHttp);
current = next;
redirectCount++;
continue;
}
response.EnsureSuccessStatusCode();
return await ReadAndParseAsync(response, cancellationToken)
.ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
@ -112,7 +153,9 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
}
}
internal static ReleaseManifest Parse(ReadOnlySpan<byte> utf8)
internal static ReleaseManifest Parse(
ReadOnlySpan<byte> utf8,
bool allowLoopbackHttpArtifacts = false)
{
try
{
@ -127,7 +170,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
RejectDuplicateProperties(document.RootElement, "$" );
ManifestDocument? value = document.RootElement.Deserialize<ManifestDocument>(
SerializerOptions);
return Validate(value);
return Validate(value, allowLoopbackHttpArtifacts);
}
catch (LauncherUpdateException)
{
@ -143,26 +186,68 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
public void Dispose() => _httpClient.Dispose();
internal static void RequireSecureOrLoopback(Uri uri, string description)
internal static void RequireTransport(
Uri uri,
string description,
bool allowLoopbackHttp)
{
if (!uri.IsAbsoluteUri
|| (uri.Scheme != Uri.UriSchemeHttps
&& !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
&& !(allowLoopbackHttp
&& uri.Scheme == Uri.UriSchemeHttp
&& uri.IsLoopback)))
{
throw new LauncherUpdateException(
$"The {description} URI must use HTTPS (loopback HTTP is test-only).");
$"The {description} URI must use HTTPS"
+ (allowLoopbackHttp ? " (or fixture-only loopback HTTP)." : "."));
}
}
private static ReleaseManifest Validate(ManifestDocument? document)
internal static void RequireSecureOrLoopback(Uri uri, string description) =>
RequireTransport(uri, description, allowLoopbackHttp: true);
private async Task<ReleaseManifest> ReadAndParseAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
if (response.Content.Headers.ContentLength is long contentLength
&& contentLength > MaximumManifestBytes)
{
throw new LauncherUpdateException(
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
}
await using Stream input = await response.Content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
using var output = new MemoryStream();
byte[] buffer = new byte[16 * 1024];
while (true)
{
int read = await input.ReadAsync(buffer, cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
if (output.Length + read > MaximumManifestBytes)
{
throw new LauncherUpdateException(
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
}
output.Write(buffer, 0, read);
}
return Parse(output.ToArray(), _allowLoopbackHttp);
}
private static ReleaseManifest Validate(
ManifestDocument? document,
bool allowLoopbackHttpArtifacts)
{
if (document is null)
{
@ -187,18 +272,22 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
throw new LauncherUpdateException(
"The minimum launcher version cannot exceed the release version.");
}
IReadOnlyDictionary<string, ReleaseArtifact> clients = ValidateArtifacts(
document.Clients,
"clients");
"clients",
allowLoopbackHttpArtifacts);
IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts(
document.Launchers,
"launchers");
"launchers",
allowLoopbackHttpArtifacts);
return new ReleaseManifest(version, minimum, clients, launchers);
}
private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts(
Dictionary<string, ArtifactDocument>? artifacts,
string field)
string field,
bool allowLoopbackHttpArtifacts)
{
if (artifacts is null || artifacts.Count == 0)
{
@ -226,7 +315,10 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
$"Manifest payload '{field}.{rid}' has an invalid URL.");
}
RequireSecureOrLoopback(uri, $"{field}.{rid} artifact");
RequireTransport(
uri,
$"{field}.{rid} artifact",
allowLoopbackHttpArtifacts);
if (!IsSha256(value.Sha256))
{
throw new LauncherUpdateException(
@ -250,6 +342,21 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
internal static bool IsSha256(string? value) =>
value is { Length: 64 } && value.All(Uri.IsHexDigit);
private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is
HttpStatusCode.MovedPermanently
or HttpStatusCode.Found
or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect
or HttpStatusCode.PermanentRedirect;
private static HttpMessageHandler CreateRedirectDisabledHandler() =>
new HttpClientHandler
{
AllowAutoRedirect = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.None,
};
private static void RejectDuplicateProperties(JsonElement element, string path)
{
if (element.ValueKind == JsonValueKind.Object)

View file

@ -297,7 +297,7 @@ public sealed class SafeZipExtractor
|| segment.Any(character =>
char.IsControl(character)
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|| IsWindowsDeviceName(segment))
|| PortablePathRules.IsWindowsDeviceName(segment))
{
throw new LauncherUpdateException(
$"ZIP path '{name}' contains an unsafe segment.");
@ -396,19 +396,6 @@ public sealed class SafeZipExtractor
}
}
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

View file

@ -34,7 +34,53 @@ public sealed class UpdateSessionBarrier
FileShare.None,
"A launcher session or another update transaction is running. "
+ "Stop every launcher session before updating.");
return new ExclusiveLease(stream);
return new ExclusiveLease(this, stream);
}
/// <summary>
/// Non-blocking startup probe. Contention is an expected "not now"
/// result; permission and path failures remain hard errors.
/// </summary>
public bool TryAcquireExclusive(out ExclusiveLease? lease)
{
Directory.CreateDirectory(
Path.GetDirectoryName(_lockPath)
?? throw new InvalidOperationException(
"The update/session lock path has no parent directory."));
try
{
lease = new ExclusiveLease(
this,
new FileStream(
_lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.None));
return true;
}
catch (IOException)
{
lease = null;
return false;
}
catch (UnauthorizedAccessException ex)
{
throw new LauncherUpdateException(
$"The update/session lease could not be opened: {ex.Message}",
ex);
}
}
internal void RequireOwned(ExclusiveLease lease)
{
ArgumentNullException.ThrowIfNull(lease);
if (!lease.IsHeldBy(this))
{
throw new LauncherUpdateException(
"The cleanup operation does not hold this update barrier's exclusive lease.");
}
}
private FileStream Open(FileShare share, string refusal)
@ -76,9 +122,18 @@ public sealed class UpdateSessionBarrier
public sealed class ExclusiveLease : IDisposable
{
private readonly UpdateSessionBarrier _owner;
private FileStream? _stream;
internal ExclusiveLease(FileStream stream) => _stream = stream;
internal ExclusiveLease(UpdateSessionBarrier owner, FileStream stream)
{
_owner = owner;
_stream = stream;
}
internal bool IsHeldBy(UpdateSessionBarrier owner) =>
ReferenceEquals(_owner, owner)
&& Volatile.Read(ref _stream) is not null;
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
}

View file

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

View file

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

View file

@ -16,8 +16,7 @@ public sealed partial class App : Application
{
private LauncherOrchestrator? _orchestrator;
private LauncherWindowViewModel? _viewModel;
private HttpClient? _updateHttpClient;
private ReleaseManifestClient? _manifestClient;
private LauncherUpdateComposition? _updateComposition;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@ -52,38 +51,27 @@ public sealed partial class App : Application
$"Client content verification failed: {ex.Message}");
}
var clientVersions = new ClientVersionStore(paths);
_ = clientVersions.LoadAndRecoverAsync(rid)
.GetAwaiter()
.GetResult();
LauncherUpdateComposition updates = LauncherUpdateComposition.Create(
paths,
rid,
GetLauncherVersion(),
AppContext.BaseDirectory,
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
== true);
_updateComposition = updates;
_orchestrator = new LauncherOrchestrator(
profiles,
paths,
LauncherExecutableSet.FromCurrentVersionStore(clientVersions),
updates.Executables,
verification.Record,
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));
updateSessionBarrier: updates.Versions.Barrier);
_viewModel = new LauncherWindowViewModel(
_orchestrator,
new AvaloniaUiDispatcher(),
installer,
updater);
updates.Updater);
_viewModel.Initialize();
desktop.MainWindow = new MainWindow
@ -100,12 +88,10 @@ public sealed partial class App : Application
{
_viewModel?.Dispose();
_orchestrator?.Dispose();
_manifestClient?.Dispose();
_updateHttpClient?.Dispose();
_updateComposition?.Dispose();
_viewModel = null;
_orchestrator = null;
_manifestClient = null;
_updateHttpClient = null;
_updateComposition = null;
}
private static LauncherVersion GetLauncherVersion()

View file

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

View file

@ -479,42 +479,52 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
internal sealed class UnavailableLauncherUpdater : ILauncherUpdater
{
private static readonly ClientVersionResolution Missing = new(
ClientVersionState.Missing,
"Versioned client updater is unavailable.",
null,
null,
null,
null);
private readonly ClientVersionResolution _resolution;
private readonly string _status;
public ClientVersionResolution CurrentClient => Missing;
public UnavailableLauncherUpdater(
string status = "Versioned client updater is unavailable.",
ClientVersionResolution? resolution = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(status);
_status = status;
_resolution = resolution ?? new ClientVersionResolution(
ClientVersionState.Invalid,
status,
null,
null,
null,
null);
}
public ClientVersionResolution CurrentClient => _resolution;
public Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(Missing);
Task.FromResult(_resolution);
public Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default) =>
Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException("Versioned client updater is unavailable."));
new LauncherUpdateException(_status));
public Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<ClientVersionResolution>(
new LauncherUpdateException("Versioned client updater is unavailable."));
new LauncherUpdateException(_status));
public Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<SelfUpdateStageResult>(
new LauncherUpdateException("Versioned client updater is unavailable."));
new LauncherUpdateException(_status));
public Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<ClientVersionResolution>(
new LauncherUpdateException("Versioned client updater is unavailable."));
new LauncherUpdateException(_status));
}