feat(launcher): complete Campaign LA11 pre-gate support
This commit is contained in:
parent
f881e5b467
commit
134edabed2
19 changed files with 433 additions and 55 deletions
|
|
@ -343,6 +343,14 @@ src/
|
|||
-> references Platform only; no Avalonia or game-host dependency
|
||||
|
||||
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
|
||||
Startup/Program -> one immutable process-local option graph before
|
||||
owner construction; config/data/cache require
|
||||
three absolute normalized roots and one exact
|
||||
`ApplicationPathSet` reaches profiles, installer,
|
||||
versions/updater, sessions, cache, orchestration
|
||||
-> manifest override reaches only update composition,
|
||||
is never persisted, and permits HTTP only for a
|
||||
loopback fixture; production remains pinned HTTPS
|
||||
ViewModels/ -> thin MVVM projection over Launcher.Core,
|
||||
including the first-run DAT/bake wizard and
|
||||
nonfatal startup/manual update state, actions,
|
||||
|
|
|
|||
|
|
@ -732,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
|
|||
| LA8 | — | | | |
|
||||
| LA9 | — | | | |
|
||||
| LA10 | — | | | |
|
||||
| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing, Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. Launcher startup composition awaits the LA10 review-fix rebase; no connected row has run and the campaign is not shipped. |
|
||||
| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing and one exact launcher path graph are composed on the reviewed LA10 updater base; Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. No connected row has run and the campaign is not shipped. |
|
||||
|
|
|
|||
|
|
@ -72,15 +72,18 @@ The expected matrix is:
|
|||
| Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 5–15 min |
|
||||
| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min |
|
||||
| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min |
|
||||
| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 10–25 min |
|
||||
| Windows | canonical portable project build/test closure plus Headless `--help` and empty-config `validate` from `headless-portability.yml` | every project/CLI row exits 0 | 10–25 min |
|
||||
| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min |
|
||||
| Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min |
|
||||
| Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min |
|
||||
|
||||
`report.json` records the tested HEAD/dirty state, OS/RID, exact commands,
|
||||
durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight
|
||||
plans 26 commands. It never launches App or Headless in connected mode and
|
||||
never reads a credential.
|
||||
plans 30 rows. It never launches App or Headless in connected mode and
|
||||
never reads a credential. Every child starts with all inherited `ACDREAM_*`
|
||||
variables removed, so a developer shell cannot accidentally enable live,
|
||||
installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional
|
||||
row below adds the two named DAT variables back for its three exact tests.
|
||||
|
||||
### Optional installed-DAT read-only row
|
||||
|
||||
|
|
@ -100,7 +103,7 @@ The mandatory installed-DAT result is
|
|||
`ACDREAM_DAT_DIR` set inside the child environment. The helper reads the TRX
|
||||
and fails if the test skipped or did anything other than pass. The action-map
|
||||
and portal-asset probes are additional coverage, never substitutes. Expected
|
||||
matrix size: 30 rows.
|
||||
matrix size: 34 rows.
|
||||
|
||||
On Ubuntu/WSL, invoke the same script with native `pwsh`, a Linux repository
|
||||
path, and a Linux output path. Do not treat a Windows-hosted run over
|
||||
|
|
@ -167,9 +170,12 @@ pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1
|
|||
```
|
||||
|
||||
The helper rejects nonempty output, invalid or non-monotonic versions, missing
|
||||
root executables, and nonabsolute inputs. It writes fixed-timestamp sorted ZIPs,
|
||||
root executables (including the co-deployed Bake CLI), and nonabsolute inputs.
|
||||
It writes fixed-timestamp sorted ZIPs,
|
||||
the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only
|
||||
server, and an A/B selector. It does not download or mutate payload sources.
|
||||
server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B
|
||||
selector. Both generated helpers reject a `-Root` other than their own fixture
|
||||
directory. The generator does not download or mutate payload sources.
|
||||
|
||||
Start the Windows loopback server without a shell or visible helper window:
|
||||
|
||||
|
|
|
|||
|
|
@ -333,7 +333,10 @@ the process supervisor executes.
|
|||
(load, capability flag, teardown).
|
||||
- **Connected gates (user-driven):** execute the exact serial matrix in
|
||||
`docs/research/2026-08-14-campaign-la-test-script.md` only after its
|
||||
connection-free automated preflight passes. Cover every launch mode against local ACE
|
||||
connection-free automated preflight passes. The launcher uses one immutable
|
||||
process-local config/data/cache path set for the whole matrix; the local feed
|
||||
URI reaches only updater composition and is never persisted. Cover every
|
||||
launch mode against local ACE
|
||||
(gui / guiSelect / headless), the character probe (fresh account →
|
||||
refresh → roster appears, and repeated probes leaving no stale ACE
|
||||
session), clean-profile first-run wizard end-to-end, staged-manifest
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. -->
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
try
|
||||
{
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,18 +160,23 @@ static async Task<int> BootstrapProbeAsync(string[] arguments)
|
|||
|
||||
static int CanonicalProbe(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 1)
|
||||
if (arguments.Length < 1)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
string suffix = arguments.Length == 1
|
||||
? string.Empty
|
||||
: Environment.NewLine
|
||||
+ string.Join(Environment.NewLine, arguments[1..]);
|
||||
File.WriteAllText(
|
||||
Path.GetFullPath(arguments[0]),
|
||||
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
+ "|"
|
||||
+ Path.GetFullPath(
|
||||
Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Process path is unavailable.")));
|
||||
?? throw new InvalidOperationException("Process path is unavailable."))
|
||||
+ suffix);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -279,25 +279,43 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
string[] publicArguments =
|
||||
[
|
||||
"--config-dir", Path.Combine(_root, "config with spaces"),
|
||||
"--data-dir", Path.Combine(_root, "data & literal"),
|
||||
"--cache-dir", Path.Combine(_root, "cache"),
|
||||
"--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
|
||||
];
|
||||
SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["--literal", "argument with spaces & metacharacters"],
|
||||
publicArguments,
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
Assert.False(ordinary.ShouldExit);
|
||||
Assert.Equal(["--literal", "argument with spaces & metacharacters"],
|
||||
ordinary.RemainingArguments);
|
||||
Assert.Equal(publicArguments, ordinary.RemainingArguments);
|
||||
|
||||
SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[LauncherSelfUpdateBootstrap.DeferredArgument, .. publicArguments],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
Assert.False(deferred.ShouldExit);
|
||||
Assert.Equal(publicArguments, deferred.RemainingArguments);
|
||||
|
||||
_ = await harness.StageAsync();
|
||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId],
|
||||
[
|
||||
LauncherSelfUpdateBootstrap.ConfirmArgument,
|
||||
applied.TransactionId,
|
||||
.. publicArguments,
|
||||
],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
|
||||
Assert.False(confirmation.ShouldExit);
|
||||
Assert.Empty(confirmation.RemainingArguments);
|
||||
Assert.Equal(publicArguments, confirmation.RemainingArguments);
|
||||
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
|
||||
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
string ready = Path.Combine(_root, "crash.ready");
|
||||
string launched = Path.Combine(_root, "replacement.ready");
|
||||
string helperPidPath = Path.Combine(_root, "helper.pid");
|
||||
string[] processLocalSuffix =
|
||||
[
|
||||
"--config-dir", Path.Combine(_root, "isolated config"),
|
||||
"--data-dir", Path.Combine(_root, "isolated data"),
|
||||
"--cache-dir", Path.Combine(_root, "isolated cache"),
|
||||
"--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
|
||||
];
|
||||
Directory.CreateDirectory(_root);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
||||
|
|
@ -76,7 +83,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
};
|
||||
using Process canonical = StartProcess(
|
||||
prepared.CanonicalPath,
|
||||
["canonical-probe", launched],
|
||||
["canonical-probe", launched, .. processLocalSuffix],
|
||||
environment);
|
||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(0, canonical.ExitCode);
|
||||
|
|
@ -100,7 +107,12 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
SearchOption.TopDirectoryOnly));
|
||||
Assert.Null(await manager.LoadPendingAsync());
|
||||
|
||||
int replacementPid = ParsePid(await File.ReadAllTextAsync(launched));
|
||||
string launchMarker = await File.ReadAllTextAsync(launched);
|
||||
Assert.EndsWith(
|
||||
Environment.NewLine + string.Join(Environment.NewLine, processLocalSuffix),
|
||||
launchMarker,
|
||||
StringComparison.Ordinal);
|
||||
int replacementPid = ParsePid(launchMarker);
|
||||
int helperPid = int.Parse(
|
||||
await File.ReadAllTextAsync(helperPidPath),
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,26 @@ public sealed class LauncherVersionTests
|
|||
|
||||
public sealed class ReleaseManifestClientTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("https://updates.example.test/manifest.json")]
|
||||
[InlineData("http://127.0.0.1:43119/manifest.json")]
|
||||
[InlineData("http://localhost:43119/manifest.json")]
|
||||
public void LocalUpdateFeedOverrideAcceptsOnlySecureOrLoopbackFeeds(string value)
|
||||
{
|
||||
using ReleaseManifestClient source =
|
||||
ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://updates.example.test/manifest.json")]
|
||||
[InlineData("file:///tmp/manifest.json")]
|
||||
[InlineData("https://user:secret@updates.example.test/manifest.json")]
|
||||
[InlineData("https://updates.example.test/manifest.json?token=secret")]
|
||||
[InlineData("https://updates.example.test/manifest.json#fragment")]
|
||||
public void LocalUpdateFeedOverrideRejectsRemoteHttpAndCredentialLikeUris(string value) =>
|
||||
Assert.Throws<LauncherUpdateException>(() =>
|
||||
ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value)));
|
||||
|
||||
[Fact]
|
||||
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -146,6 +146,78 @@ public sealed class LauncherStartupOptionsTests
|
|||
Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredSelfUpdateRestartRetainsIsolationWithoutResolvingDefaults()
|
||||
{
|
||||
string root = Path.GetFullPath(
|
||||
Path.Combine(Path.GetTempPath(), "acdream-la11-deferred"));
|
||||
string[] suffix =
|
||||
[
|
||||
"--config-dir", Path.Combine(root, "config"),
|
||||
"--data-dir", Path.Combine(root, "data"),
|
||||
"--cache-dir", Path.Combine(root, "cache"),
|
||||
"--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
|
||||
];
|
||||
|
||||
LauncherStartupOptions options = LauncherStartupOptions.Parse(
|
||||
["--acdream-self-update-deferred-v1", .. suffix],
|
||||
() => throw new InvalidOperationException(
|
||||
"canonical path resolver was touched"));
|
||||
|
||||
Assert.Equal(LauncherStartupMode.SelfUpdateDeferred, options.Mode);
|
||||
Assert.Equal(suffix, options.PublicArguments);
|
||||
Assert.Equal(Path.Combine(root, "config"), options.Paths.ConfigDirectory);
|
||||
Assert.Equal(Path.Combine(root, "data"), options.Paths.DataDirectory);
|
||||
Assert.Equal(Path.Combine(root, "cache"), options.Paths.CacheDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AvaloniaCompositionRetainsTheExactParsedOptionsAndPathSet()
|
||||
{
|
||||
string root = Path.GetFullPath(
|
||||
Path.Combine(Path.GetTempPath(), "acdream-la11-app-composition"));
|
||||
LauncherStartupOptions options = LauncherStartupOptions.Parse(
|
||||
[
|
||||
"--config-dir", Path.Combine(root, "config"),
|
||||
"--data-dir", Path.Combine(root, "data"),
|
||||
"--cache-dir", Path.Combine(root, "cache"),
|
||||
"--update-manifest-uri", "https://updates.example.test/manifest.json",
|
||||
],
|
||||
() => throw new InvalidOperationException(
|
||||
"canonical path resolver was touched"));
|
||||
|
||||
var app = new App(options);
|
||||
|
||||
Assert.Same(options, app.StartupOptions);
|
||||
Assert.Same(options.Paths, app.StartupOptions.Paths);
|
||||
Assert.Equal(
|
||||
new Uri("https://updates.example.test/manifest.json"),
|
||||
app.StartupOptions.UpdateManifestUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompositionRejectsAnyBootstrapArgumentDrift()
|
||||
{
|
||||
var paths = new ApplicationPathSet("config", "data", "cache", null);
|
||||
LauncherStartupOptions options = LauncherStartupOptions.Parse(
|
||||
["--update-manifest-uri", "https://updates.example.test/manifest.json"],
|
||||
() => paths);
|
||||
|
||||
Program.RequireUnchangedPublicArguments(
|
||||
options,
|
||||
new SelfUpdateStartupResult(
|
||||
false,
|
||||
0,
|
||||
options.PublicArguments.ToArray()));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
Program.RequireUnchangedPublicArguments(
|
||||
options,
|
||||
new SelfUpdateStartupResult(
|
||||
false,
|
||||
0,
|
||||
["--update-manifest-uri", "https://other.example.test/manifest.json"])));
|
||||
}
|
||||
|
||||
public static TheoryData<string[]> InvalidArguments()
|
||||
{
|
||||
string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11"));
|
||||
|
|
@ -171,6 +243,10 @@ public sealed class LauncherStartupOptionsTests
|
|||
data.Add(["--update-manifest-uri", "file:///tmp/manifest.json"]);
|
||||
data.Add(
|
||||
["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]);
|
||||
data.Add(
|
||||
["--update-manifest-uri", "https://example.test/manifest.json?token=secret"]);
|
||||
data.Add(
|
||||
["--update-manifest-uri", "https://example.test/manifest.json#fragment"]);
|
||||
data.Add(["--update-manifest-uri", "not-a-uri"]);
|
||||
data.Add(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -62,4 +62,44 @@ public sealed class LauncherUpdateCompositionTests : IDisposable
|
|||
() => composition.Updater.CheckAsync());
|
||||
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://updates.example.test/manifest.json")]
|
||||
[InlineData("http://127.0.0.1:43119/manifest.json")]
|
||||
public void ProcessLocalManifestOverrideReachesOnlyUpdateComposition(string value)
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
var paths = new ApplicationPathSet(
|
||||
Path.Combine(_root, "config"),
|
||||
Path.Combine(_root, "data"),
|
||||
Path.Combine(_root, "cache"),
|
||||
null);
|
||||
var manifestUri = new Uri(value);
|
||||
|
||||
using LauncherUpdateComposition composition = LauncherUpdateComposition.Create(
|
||||
paths,
|
||||
LauncherRuntimeIdentity.DetectRid(),
|
||||
LauncherVersion.Parse("1.0.0"),
|
||||
_root,
|
||||
() => false,
|
||||
initialize: (_, _) => new ClientVersionResolution(
|
||||
ClientVersionState.Missing,
|
||||
"No client version is installed.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
updateManifestUri: manifestUri);
|
||||
|
||||
Assert.Same(manifestUri, composition.UpdateManifestUri);
|
||||
Assert.Equal(
|
||||
Path.Combine(paths.DataDirectory, "app"),
|
||||
composition.Versions.AppDirectory);
|
||||
Assert.False(File.Exists(
|
||||
Path.Combine(paths.ConfigDirectory, "launcher-profiles.json")));
|
||||
Assert.Empty(Directory.EnumerateFiles(
|
||||
_root,
|
||||
"*",
|
||||
SearchOption.AllDirectories));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,9 +85,11 @@ foreach ($release in @('A', 'B')) {
|
|||
Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe'
|
||||
Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe'
|
||||
Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe'
|
||||
Require-PayloadFile "$release-launcher-win-x64" 'acdream-bake.exe'
|
||||
Require-PayloadFile "$release-client-linux-x64" 'AcDream.App'
|
||||
Require-PayloadFile "$release-client-linux-x64" 'acdream-headless'
|
||||
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher'
|
||||
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-bake'
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $OutputDirectory) {
|
||||
|
|
@ -155,6 +157,7 @@ function New-DeterministicZip(
|
|||
$executable = $relative -ceq 'AcDream.App' -or
|
||||
$relative -ceq 'acdream-headless' -or
|
||||
$relative -ceq 'acdream-launcher' -or
|
||||
$relative -ceq 'acdream-bake' -or
|
||||
$relative.EndsWith('.sh', [StringComparison]::Ordinal)
|
||||
$mode = if ($executable) { 0x81ED } else { 0x81A4 }
|
||||
$entry.ExternalAttributes = $mode -shl 16
|
||||
|
|
@ -239,16 +242,26 @@ $server = @'
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Root,
|
||||
[Parameter(Mandatory = $true)][int]$Port
|
||||
[Parameter(Mandatory = $true)][int]$Port,
|
||||
[ValidateRange(0, 1000000)][int]$MaximumRequests = 0
|
||||
)
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator(
|
||||
[IO.Path]::GetFullPath($PSScriptRoot))
|
||||
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
|
||||
$pathComparison = if ($IsWindows) {
|
||||
[StringComparison]::OrdinalIgnoreCase
|
||||
} else { [StringComparison]::Ordinal }
|
||||
if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) {
|
||||
throw '-Root must be the directory containing serve-fixture.ps1.'
|
||||
}
|
||||
$prefix = "http://127.0.0.1:$Port/"
|
||||
$listener = [Net.HttpListener]::new()
|
||||
$listener.Prefixes.Add($prefix)
|
||||
$listener.Start()
|
||||
Write-Host "Campaign LA fixture listening on $prefix"
|
||||
$servedRequests = 0
|
||||
try {
|
||||
while ($listener.IsListening) {
|
||||
$context = $listener.GetContext()
|
||||
|
|
@ -287,7 +300,13 @@ try {
|
|||
$context.Response.StatusCode = 500
|
||||
Write-Error $_
|
||||
}
|
||||
finally { $context.Response.Close() }
|
||||
finally {
|
||||
$context.Response.Close()
|
||||
$servedRequests++
|
||||
}
|
||||
if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { $listener.Close() }
|
||||
|
|
@ -301,8 +320,24 @@ param(
|
|||
[string]$Root = $PSScriptRoot
|
||||
)
|
||||
Set-StrictMode -Version Latest
|
||||
$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt'
|
||||
Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline
|
||||
$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator(
|
||||
[IO.Path]::GetFullPath($PSScriptRoot))
|
||||
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
|
||||
$pathComparison = if ($IsWindows) {
|
||||
[StringComparison]::OrdinalIgnoreCase
|
||||
} else { [StringComparison]::Ordinal }
|
||||
if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) {
|
||||
throw '-Root must be the directory containing set-active-release.ps1.'
|
||||
}
|
||||
$path = Join-Path $Root 'active-release.txt'
|
||||
$temporary = "$path.$([Guid]::NewGuid().ToString('N')).tmp"
|
||||
try {
|
||||
[IO.File]::WriteAllText($temporary, $Release, [Text.Encoding]::ASCII)
|
||||
[IO.File]::Move($temporary, $path, $true)
|
||||
}
|
||||
finally {
|
||||
if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) }
|
||||
}
|
||||
Write-Host "Campaign LA fixture active release: $Release"
|
||||
'@
|
||||
$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM
|
||||
|
|
|
|||
|
|
@ -68,16 +68,22 @@ $startedUtc = [DateTime]::UtcNow
|
|||
function Protect-Text([string]$Text) {
|
||||
if ($null -eq $Text) { return '' }
|
||||
$protected = $Text
|
||||
foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name)
|
||||
if (-not [string]::IsNullOrEmpty($value)) {
|
||||
$protected = $protected.Replace($value, '<redacted>', [StringComparison]::Ordinal)
|
||||
}
|
||||
}
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
|
||||
'$1$2<redacted>')
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)\b(password|passwd|secret|token|credential|api[_-]?key)(\s*[:=]\s*)([^\s,;]+)',
|
||||
'$1$2<redacted>')
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)(https?://)[^/\s:@]+:[^@\s/]+@',
|
||||
'$1<redacted>@')
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)([?&](?:token|secret|password|credential|api[_-]?key)=)[^&\s]+',
|
||||
'$1<redacted>')
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
|
||||
|
|
@ -142,6 +148,11 @@ function Invoke-GateCommand(
|
|||
$startInfo.RedirectStandardOutput = $true
|
||||
$startInfo.RedirectStandardError = $true
|
||||
foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) }
|
||||
foreach ($key in @($startInfo.Environment.Keys)) {
|
||||
if ($key.StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$startInfo.Environment.Remove($key)
|
||||
}
|
||||
}
|
||||
foreach ($entry in $Environment.GetEnumerator()) {
|
||||
$startInfo.Environment[[string]$entry.Key] = [string]$entry.Value
|
||||
}
|
||||
|
|
@ -258,6 +269,34 @@ try {
|
|||
'--', 'RunConfiguration.MaxCpuCount=1')
|
||||
}
|
||||
|
||||
$headlessValidationConfig = Join-Path $OutputDirectory 'headless-k0.json'
|
||||
Add-InternalCheck 'portable-headless-write-empty-config' {
|
||||
[IO.File]::WriteAllText(
|
||||
$headlessValidationConfig,
|
||||
'{"version":1,"sessions":[]}',
|
||||
[Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
Invoke-DotNet 'portable-headless-help-no-connect' @(
|
||||
'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj',
|
||||
'-c', 'Release', '--no-build', '--', '--help')
|
||||
Invoke-DotNet 'portable-headless-validate-empty-no-connect' @(
|
||||
'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj',
|
||||
'-c', 'Release', '--no-build', '--',
|
||||
'validate', '--config', $headlessValidationConfig)
|
||||
Add-InternalCheck 'portable-headless-native-permission' {
|
||||
if (-not $IsWindows) {
|
||||
$headlessExecutable = Join-Path `
|
||||
$Repository 'src/AcDream.Headless/bin/Release/net10.0/acdream-headless'
|
||||
if (-not (Test-Path -LiteralPath $headlessExecutable -PathType Leaf)) {
|
||||
throw 'The native Headless build output is missing.'
|
||||
}
|
||||
$mode = [IO.File]::GetUnixFileMode($headlessExecutable)
|
||||
if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) {
|
||||
throw 'The native Headless build output is not executable.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rid in @('win-x64', 'linux-x64')) {
|
||||
$destination = Join-Path $publishDirectory $rid
|
||||
Invoke-DotNet "publish-launcher-$rid" @(
|
||||
|
|
@ -382,7 +421,8 @@ finally {
|
|||
failures = @($failures)
|
||||
redaction = [ordered]@{
|
||||
applied = $true
|
||||
environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')
|
||||
inheritedAcdreamEnvironmentCleared = $true
|
||||
inheritedEnvironmentValuesRead = $false
|
||||
credentialArgumentsAllowed = $false
|
||||
}
|
||||
artifacts = $artifacts
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
|
|||
}
|
||||
$eventName = Assert-String $root 'e'
|
||||
if (-not $exactFields.ContainsKey($eventName)) {
|
||||
throw "event '$eventName' is not in the v1 vocabulary"
|
||||
throw 'event name is not in the v1 vocabulary'
|
||||
}
|
||||
$expected = $exactFields[$eventName]
|
||||
if ($names.Count -ne $expected.Count -or
|
||||
|
|
@ -221,7 +221,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
|
|||
if ($code -ne 0) { throw "terminal exit code is $code, expected 0" }
|
||||
$expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' }
|
||||
if ($reason -cne $expectedReason) {
|
||||
throw "terminal reason is '$reason', expected '$expectedReason'"
|
||||
throw "terminal reason does not match mode '$Mode'"
|
||||
}
|
||||
$terminalSeen = $true
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue