The update surface was a panel the user had to reason about: Check again, Rollback client, Stage launcher, Install client, Cancel, Close, plus an installed/available version table, a minimum-launcher-version sentence, and a "restart required" banner they had to act on. Reaching it meant knowing to press "Check for updates" in the header. Now: the feed is checked once at startup. If nothing is out of date, nothing appears. If something is, one dialog says what is new and offers Update or Not now. Launcher before client, deliberately. A client release can declare a minimum launcher version, so updating the launcher first is what makes the client update installable at all — and it means nobody is ever shown "install launcher X or newer before the client update", which is not a sentence a player should have to read. A launcher update now restarts into the new build by itself. That reuses the existing, proven handoff rather than inventing a second one: LauncherSelfUpdate Bootstrap.TryApplyStagedUpdateNowAsync starts the staged payload in helper mode against the CURRENT process, exactly as ordinary startup does, and the launcher then shuts down. Restarting by spawning a fresh copy of the current launcher and letting its startup notice the staged plan would look simpler and be wrong: the helper would wait on the new copy while the old one still held its own executable mapped, so the file replacement could fail. The staged-helper launch is extracted into one private method both paths call, so they cannot drift. Deleted: the header "Check for updates" button, OpenCommand, CheckCommand, InstallClientCommand, StageLauncherCommand, RollbackCommand, CloseCommand, the version table, IsLauncherMinimumBlocked/MinimumLauncherStatus, the restart banner, and LauncherUpdatePhase plumbing through the view model. NOT deleted — none of the safety changed: manifest validation, bounded verified download, safe ZIP extraction, versioned install with an atomic current.json switch, the update session barrier, and rollback all still live in AcDream.Launcher.Core/Updates. Rollback simply has no button; it remains reachable as Core API with its own tests. The complexity the user objected to was the panel, not the machinery underneath it. An unreachable feed stays silent. A friend with no internet must still reach their characters, so a failed startup check shows nothing at all rather than an error to dismiss. Tests: LauncherUpdateViewModelTests rewritten against the new surface (8 tests — nothing-to-do stays silent, client update installs, launcher update stages then restarts without touching the client, no-restart-seam fallback, silent offline, Not now, refused while a session runs, failed install reports why). Tests for the deleted commands are removed with them, not skipped. Launcher 59 passed, Launcher.Core 335 passed. Campaign LU slices LU2 and LU3, landed together because the new prompt replaces the old one in the same files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
146 lines
5.6 KiB
C#
146 lines
5.6 KiB
C#
using System.Reflection;
|
|
using AcDream.Launcher.Core.Installation;
|
|
using AcDream.Launcher.Core.Launching;
|
|
using AcDream.Launcher.Core.Orchestration;
|
|
using AcDream.Launcher.Core.Profiles;
|
|
using AcDream.Launcher.Core.Updates;
|
|
using AcDream.Launcher.ViewModels;
|
|
using AcDream.Platform;
|
|
using Avalonia;
|
|
using Avalonia.Controls.ApplicationLifetimes;
|
|
using Avalonia.Markup.Xaml;
|
|
|
|
namespace AcDream.Launcher;
|
|
|
|
public sealed partial class App : Application
|
|
{
|
|
private readonly LauncherStartupOptions? _startupOptions;
|
|
private LauncherOrchestrator? _orchestrator;
|
|
private LauncherWindowViewModel? _viewModel;
|
|
private LauncherUpdateComposition? _updateComposition;
|
|
|
|
public App()
|
|
{
|
|
}
|
|
|
|
internal App(LauncherStartupOptions startupOptions)
|
|
{
|
|
_startupOptions = startupOptions
|
|
?? throw new ArgumentNullException(nameof(startupOptions));
|
|
}
|
|
|
|
internal LauncherStartupOptions StartupOptions => _startupOptions
|
|
?? throw new InvalidOperationException(
|
|
"Launcher startup options were not supplied by the composition root.");
|
|
|
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
|
|
|
public override void OnFrameworkInitializationCompleted()
|
|
{
|
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
|
{
|
|
LauncherStartupOptions startupOptions = StartupOptions;
|
|
ApplicationPathSet paths = startupOptions.Paths;
|
|
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
|
string rid = LauncherRuntimeIdentity.DetectRid();
|
|
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
|
var installer = new LauncherInstaller(
|
|
paths,
|
|
Path.Combine(
|
|
AppContext.BaseDirectory,
|
|
"acdream-bake" + executableSuffix));
|
|
InstallRecordVerification verification;
|
|
try
|
|
{
|
|
// Hashing the package before constructing the orchestrator is
|
|
// intentional: no launch action is enabled until the persisted
|
|
// size/SHA/tool-version record has been verified.
|
|
verification = installer.LoadExistingAsync()
|
|
.GetAwaiter()
|
|
.GetResult();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
verification = new InstallRecordVerification(
|
|
InstallRecordVerificationState.Invalid,
|
|
null,
|
|
$"Client content verification failed: {ex.Message}");
|
|
}
|
|
|
|
LauncherUpdateComposition updates = LauncherUpdateComposition.Create(
|
|
paths,
|
|
rid,
|
|
GetLauncherVersion(),
|
|
AppContext.BaseDirectory,
|
|
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
|
|
== true,
|
|
updateManifestUri: startupOptions.UpdateManifestUri);
|
|
_updateComposition = updates;
|
|
|
|
_orchestrator = new LauncherOrchestrator(
|
|
profiles,
|
|
paths,
|
|
updates.Executables,
|
|
verification.Record,
|
|
installationStatus: verification.Status,
|
|
updateSessionBarrier: updates.Versions.Barrier);
|
|
// LU2: a launcher update installs and restarts by itself. The
|
|
// helper waits on THIS process id and cannot replace files the
|
|
// running launcher holds open, so applying and shutting down are
|
|
// one pair — see LauncherSelfUpdateBootstrap.TryApplyStagedUpdateNowAsync.
|
|
LauncherSelfUpdateManager? selfUpdates = updates.SelfUpdates;
|
|
Func<CancellationToken, Task<bool>>? applyLauncherUpdate =
|
|
selfUpdates is null
|
|
? null
|
|
: token => LauncherSelfUpdateBootstrap.TryApplyStagedUpdateNowAsync(
|
|
selfUpdates,
|
|
AppContext.BaseDirectory,
|
|
Environment.ProcessPath
|
|
?? throw new InvalidOperationException(
|
|
"The launcher executable path is unavailable."),
|
|
startupOptions.PublicArguments,
|
|
token);
|
|
|
|
_viewModel = new LauncherWindowViewModel(
|
|
_orchestrator,
|
|
new AvaloniaUiDispatcher(),
|
|
installer,
|
|
updates.Updater,
|
|
applyLauncherUpdate,
|
|
() => desktop.Shutdown());
|
|
_viewModel.Initialize();
|
|
|
|
desktop.MainWindow = new MainWindow
|
|
{
|
|
DataContext = _viewModel,
|
|
};
|
|
desktop.Exit += OnDesktopExit;
|
|
}
|
|
|
|
base.OnFrameworkInitializationCompleted();
|
|
}
|
|
|
|
private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
|
|
{
|
|
_viewModel?.Dispose();
|
|
_orchestrator?.Dispose();
|
|
_updateComposition?.Dispose();
|
|
_viewModel = null;
|
|
_orchestrator = null;
|
|
_updateComposition = null;
|
|
}
|
|
|
|
private static LauncherVersion GetLauncherVersion()
|
|
{
|
|
string? informationalVersion = typeof(App).Assembly
|
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
|
.InformationalVersion;
|
|
if (!LauncherVersion.TryParse(informationalVersion, out LauncherVersion? version))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Launcher informational version '{informationalVersion}' is not SemVer 2.0.");
|
|
}
|
|
|
|
return version;
|
|
}
|
|
}
|