feat(launcher): complete Campaign LA11 pre-gate support

This commit is contained in:
Erik 2026-08-15 00:02:04 +02:00
parent f881e5b467
commit 134edabed2
19 changed files with 433 additions and 55 deletions

View file

@ -11,8 +11,9 @@ public interface IReleaseManifestClient
/// <summary>
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
/// construction is HTTPS-only. The loopback HTTP allowance is available only
/// through an internal fixture factory and is never inferred from a URI.
/// construction is pinned and HTTPS-only. The explicitly named process-local
/// feed factory independently revalidates its URI and can admit HTTP only for
/// the loopback operator fixture; it cannot change the production constructor.
/// Redirects are followed manually so every hop is checked before any bytes
/// cross that hop.
/// </summary>
@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
CreateRedirectDisabledHandler(),
timeout);
/// <summary>
/// Creates the explicit process-local feed seam used by the Campaign LA
/// isolated operator fixture. HTTPS stays HTTPS-only. HTTP is admitted
/// only for a loopback manifest, and never by the pinned production
/// constructor. Credential-bearing or mutable URI suffixes are rejected.
/// </summary>
public static ReleaseManifestClient CreateLocalUpdateFeedOverride(
Uri manifestUri,
TimeSpan? timeout = null)
{
ArgumentNullException.ThrowIfNull(manifestUri);
if (!string.IsNullOrEmpty(manifestUri.UserInfo)
|| !string.IsNullOrEmpty(manifestUri.Query)
|| !string.IsNullOrEmpty(manifestUri.Fragment))
{
throw new LauncherUpdateException(
"A process-local manifest URI cannot contain user information, "
+ "a query, or a fragment.");
}
bool allowLoopbackHttp = string.Equals(
manifestUri.Scheme,
Uri.UriSchemeHttp,
StringComparison.Ordinal)
&& manifestUri.IsLoopback;
if (!string.Equals(
manifestUri.Scheme,
Uri.UriSchemeHttps,
StringComparison.Ordinal)
&& !allowLoopbackHttp)
{
throw new LauncherUpdateException(
"A process-local manifest URI must use HTTPS "
+ "(loopback HTTP is fixture-only).");
}
return new ReleaseManifestClient(
manifestUri,
allowLoopbackHttp,
CreateRedirectDisabledHandler(),
timeout);
}
internal static ReleaseManifestClient CreateForTransportTest(
Uri manifestUri,
bool allowLoopbackHttp,

View file

@ -28,10 +28,6 @@
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
</ItemGroup>
<!-- Distribution composition only: do not add a Launcher -> Bake project
reference. A per-RID launcher publish explicitly publishes the GL-free
CLI as its own self-contained single file into the same directory. -->

View file

@ -14,17 +14,33 @@ 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)
{
ApplicationPathSet paths = ApplicationPathSet.Resolve();
LauncherStartupOptions startupOptions = StartupOptions;
ApplicationPathSet paths = startupOptions.Paths;
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
string rid = LauncherRuntimeIdentity.DetectRid();
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
@ -57,7 +73,8 @@ public sealed partial class App : Application
GetLauncherVersion(),
AppContext.BaseDirectory,
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
== true);
== true,
updateManifestUri: startupOptions.UpdateManifestUri);
_updateComposition = updates;
_orchestrator = new LauncherOrchestrator(

View file

@ -9,6 +9,7 @@ internal enum LauncherStartupMode
VerifyPublish,
SelfUpdateHelper,
SelfUpdateConfirmation,
SelfUpdateDeferred,
}
/// <summary>
@ -18,6 +19,12 @@ internal enum LauncherStartupMode
/// </summary>
internal sealed class LauncherStartupOptions
{
// This prefix is consumed only after LauncherSelfUpdateBootstrap has
// already decided to continue after a recovered rollback. Keep it local
// so the process-level bootstrap can remain internal to Launcher.Core.
private const string DeferredSelfUpdateArgument =
"--acdream-self-update-deferred-v1";
private readonly IReadOnlyList<string> _publicArguments;
private LauncherStartupOptions(
@ -138,6 +145,13 @@ internal sealed class LauncherStartupOptions
"The update manifest URI cannot contain user information.");
}
if (!string.IsNullOrEmpty(parsed.Query)
|| !string.IsNullOrEmpty(parsed.Fragment))
{
throw new LauncherStartupOptionsException(
"The update manifest URI cannot contain a query or fragment.");
}
updateManifestUri = parsed;
break;
default:
@ -199,6 +213,14 @@ internal sealed class LauncherStartupOptions
arguments.Count >= 2 ? 2 : arguments.Count);
}
if (string.Equals(
arguments[0],
DeferredSelfUpdateArgument,
StringComparison.Ordinal))
{
return (LauncherStartupMode.SelfUpdateDeferred, 1);
}
return (LauncherStartupMode.Desktop, 0);
}

View file

@ -22,12 +22,14 @@ internal sealed class LauncherUpdateComposition : IDisposable
ClientVersionStore versions,
LauncherExecutableSet executables,
ILauncherUpdater updater,
Uri updateManifestUri,
HttpClient? artifactClient,
ReleaseManifestClient? manifestClient)
{
Versions = versions;
Executables = executables;
Updater = updater;
UpdateManifestUri = updateManifestUri;
_artifactClient = artifactClient;
_manifestClient = manifestClient;
}
@ -38,17 +40,22 @@ internal sealed class LauncherUpdateComposition : IDisposable
public ILauncherUpdater Updater { get; }
internal Uri UpdateManifestUri { get; }
public static LauncherUpdateComposition Create(
ApplicationPathSet paths,
string rid,
LauncherVersion launcherVersion,
string launcherTargetDirectory,
Func<bool> hasRunningSessions,
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null)
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null,
Uri? updateManifestUri = null)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(launcherVersion);
ArgumentNullException.ThrowIfNull(hasRunningSessions);
Uri manifestUri = updateManifestUri
?? ReleaseManifestClient.ProductionManifestUri;
var versions = new ClientVersionStore(paths);
HttpClient? artifactClient = null;
ReleaseManifestClient? manifestClient = null;
@ -69,7 +76,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
Timeout = TimeSpan.FromSeconds(15),
};
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15));
manifestClient = CreateManifestClient(manifestUri);
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
var updater = new LauncherUpdater(
manifestClient,
@ -84,6 +91,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
versions,
LauncherExecutableSet.FromCurrentVersionStore(versions),
updater,
manifestUri,
artifactClient,
manifestClient);
}
@ -106,6 +114,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
versions,
LauncherExecutableSet.Unavailable(status),
new UnavailableLauncherUpdater(status, resolution),
manifestUri,
artifactClient: null,
manifestClient: null);
}
@ -117,6 +126,16 @@ internal sealed class LauncherUpdateComposition : IDisposable
_artifactClient?.Dispose();
}
private static ReleaseManifestClient CreateManifestClient(Uri manifestUri)
{
ArgumentNullException.ThrowIfNull(manifestUri);
return manifestUri == ReleaseManifestClient.ProductionManifestUri
? new ReleaseManifestClient(TimeSpan.FromSeconds(15))
: ReleaseManifestClient.CreateLocalUpdateFeedOverride(
manifestUri,
TimeSpan.FromSeconds(15));
}
private static bool IsStorageFailure(Exception exception) => exception is
IOException
or UnauthorizedAccessException

View file

@ -1,5 +1,4 @@
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
using Avalonia;
namespace AcDream.Launcher;
@ -9,19 +8,18 @@ internal static class Program
[STAThread]
public static int Main(string[] args)
{
if (args is ["--verify-publish"])
{
// A display-free execution probe for the packaged artifact. CI
// runs this with DOTNET_ROOT pointing at a missing directory; a
// framework-dependent publish cannot reach this return statement.
return 0;
}
try
{
ApplicationPathSet paths = ApplicationPathSet.Resolve();
LauncherStartupOptions options = LauncherStartupOptions.Parse(args);
if (options.Mode == LauncherStartupMode.VerifyPublish)
{
// A display-free execution probe for the packaged artifact.
// Parsing above deliberately never resolves user paths.
return 0;
}
using var httpClient = new HttpClient();
var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient);
var selfUpdates = new LauncherSelfUpdateManager(options.Paths, httpClient);
string executable = Environment.ProcessPath
?? throw new InvalidOperationException(
"The launcher executable path is unavailable.");
@ -37,8 +35,9 @@ internal static class Program
return startup.ExitCode;
}
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(
startup.RemainingArguments);
RequireUnchangedPublicArguments(options, startup);
return BuildAvaloniaApp(options).StartWithClassicDesktopLifetime([]);
}
catch (Exception ex)
{
@ -47,7 +46,25 @@ internal static class Program
}
}
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return AppBuilder.Configure(() => new App(options))
.UsePlatformDetect();
}
internal static void RequireUnchangedPublicArguments(
LauncherStartupOptions options,
SelfUpdateStartupResult startup)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(startup);
if (!startup.RemainingArguments.SequenceEqual(
options.PublicArguments,
StringComparer.Ordinal))
{
throw new InvalidOperationException(
"The self-update bootstrap changed validated launcher arguments.");
}
}
}