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
|
-> references Platform only; no Avalonia or game-host dependency
|
||||||
|
|
||||||
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
|
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
|
||||||
|
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,
|
ViewModels/ -> thin MVVM projection over Launcher.Core,
|
||||||
including the first-run DAT/bake wizard and
|
including the first-run DAT/bake wizard and
|
||||||
nonfatal startup/manual update state, actions,
|
nonfatal startup/manual update state, actions,
|
||||||
|
|
|
||||||
|
|
@ -732,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
|
||||||
| LA8 | — | | | |
|
| LA8 | — | | | |
|
||||||
| LA9 | — | | | |
|
| LA9 | — | | | |
|
||||||
| LA10 | — | | | |
|
| 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 | 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 | 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 | 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 | 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 |
|
| 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 |
|
| 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,
|
`report.json` records the tested HEAD/dirty state, OS/RID, exact commands,
|
||||||
durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight
|
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
|
plans 30 rows. It never launches App or Headless in connected mode and
|
||||||
never reads a credential.
|
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
|
### 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
|
`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 fails if the test skipped or did anything other than pass. The action-map
|
||||||
and portal-asset probes are additional coverage, never substitutes. Expected
|
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
|
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
|
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
|
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
|
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:
|
Start the Windows loopback server without a shell or visible helper window:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,10 @@ the process supervisor executes.
|
||||||
(load, capability flag, teardown).
|
(load, capability flag, teardown).
|
||||||
- **Connected gates (user-driven):** execute the exact serial matrix in
|
- **Connected gates (user-driven):** execute the exact serial matrix in
|
||||||
`docs/research/2026-08-14-campaign-la-test-script.md` only after its
|
`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 →
|
(gui / guiSelect / headless), the character probe (fresh account →
|
||||||
refresh → roster appears, and repeated probes leaving no stale ACE
|
refresh → roster appears, and repeated probes leaving no stale ACE
|
||||||
session), clean-profile first-run wizard end-to-end, staged-manifest
|
session), clean-profile first-run wizard end-to-end, staged-manifest
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ public interface IReleaseManifestClient
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
|
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
|
||||||
/// construction is HTTPS-only. The loopback HTTP allowance is available only
|
/// construction is pinned and HTTPS-only. The explicitly named process-local
|
||||||
/// through an internal fixture factory and is never inferred from a URI.
|
/// 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
|
/// Redirects are followed manually so every hop is checked before any bytes
|
||||||
/// cross that hop.
|
/// cross that hop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
CreateRedirectDisabledHandler(),
|
CreateRedirectDisabledHandler(),
|
||||||
timeout);
|
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(
|
internal static ReleaseManifestClient CreateForTransportTest(
|
||||||
Uri manifestUri,
|
Uri manifestUri,
|
||||||
bool allowLoopbackHttp,
|
bool allowLoopbackHttp,
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,6 @@
|
||||||
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- Distribution composition only: do not add a Launcher -> Bake project
|
<!-- Distribution composition only: do not add a Launcher -> Bake project
|
||||||
reference. A per-RID launcher publish explicitly publishes the GL-free
|
reference. A per-RID launcher publish explicitly publishes the GL-free
|
||||||
CLI as its own self-contained single file into the same directory. -->
|
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
|
public sealed partial class App : Application
|
||||||
{
|
{
|
||||||
|
private readonly LauncherStartupOptions? _startupOptions;
|
||||||
private LauncherOrchestrator? _orchestrator;
|
private LauncherOrchestrator? _orchestrator;
|
||||||
private LauncherWindowViewModel? _viewModel;
|
private LauncherWindowViewModel? _viewModel;
|
||||||
private LauncherUpdateComposition? _updateComposition;
|
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 Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
LauncherStartupOptions startupOptions = StartupOptions;
|
||||||
|
ApplicationPathSet paths = startupOptions.Paths;
|
||||||
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
||||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||||
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||||
|
|
@ -57,7 +73,8 @@ public sealed partial class App : Application
|
||||||
GetLauncherVersion(),
|
GetLauncherVersion(),
|
||||||
AppContext.BaseDirectory,
|
AppContext.BaseDirectory,
|
||||||
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
|
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
|
||||||
== true);
|
== true,
|
||||||
|
updateManifestUri: startupOptions.UpdateManifestUri);
|
||||||
_updateComposition = updates;
|
_updateComposition = updates;
|
||||||
|
|
||||||
_orchestrator = new LauncherOrchestrator(
|
_orchestrator = new LauncherOrchestrator(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ internal enum LauncherStartupMode
|
||||||
VerifyPublish,
|
VerifyPublish,
|
||||||
SelfUpdateHelper,
|
SelfUpdateHelper,
|
||||||
SelfUpdateConfirmation,
|
SelfUpdateConfirmation,
|
||||||
|
SelfUpdateDeferred,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -18,6 +19,12 @@ internal enum LauncherStartupMode
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class LauncherStartupOptions
|
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 readonly IReadOnlyList<string> _publicArguments;
|
||||||
|
|
||||||
private LauncherStartupOptions(
|
private LauncherStartupOptions(
|
||||||
|
|
@ -138,6 +145,13 @@ internal sealed class LauncherStartupOptions
|
||||||
"The update manifest URI cannot contain user information.");
|
"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;
|
updateManifestUri = parsed;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|
@ -199,6 +213,14 @@ internal sealed class LauncherStartupOptions
|
||||||
arguments.Count >= 2 ? 2 : arguments.Count);
|
arguments.Count >= 2 ? 2 : arguments.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.Equals(
|
||||||
|
arguments[0],
|
||||||
|
DeferredSelfUpdateArgument,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return (LauncherStartupMode.SelfUpdateDeferred, 1);
|
||||||
|
}
|
||||||
|
|
||||||
return (LauncherStartupMode.Desktop, 0);
|
return (LauncherStartupMode.Desktop, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,14 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
ClientVersionStore versions,
|
ClientVersionStore versions,
|
||||||
LauncherExecutableSet executables,
|
LauncherExecutableSet executables,
|
||||||
ILauncherUpdater updater,
|
ILauncherUpdater updater,
|
||||||
|
Uri updateManifestUri,
|
||||||
HttpClient? artifactClient,
|
HttpClient? artifactClient,
|
||||||
ReleaseManifestClient? manifestClient)
|
ReleaseManifestClient? manifestClient)
|
||||||
{
|
{
|
||||||
Versions = versions;
|
Versions = versions;
|
||||||
Executables = executables;
|
Executables = executables;
|
||||||
Updater = updater;
|
Updater = updater;
|
||||||
|
UpdateManifestUri = updateManifestUri;
|
||||||
_artifactClient = artifactClient;
|
_artifactClient = artifactClient;
|
||||||
_manifestClient = manifestClient;
|
_manifestClient = manifestClient;
|
||||||
}
|
}
|
||||||
|
|
@ -38,17 +40,22 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
|
|
||||||
public ILauncherUpdater Updater { get; }
|
public ILauncherUpdater Updater { get; }
|
||||||
|
|
||||||
|
internal Uri UpdateManifestUri { get; }
|
||||||
|
|
||||||
public static LauncherUpdateComposition Create(
|
public static LauncherUpdateComposition Create(
|
||||||
ApplicationPathSet paths,
|
ApplicationPathSet paths,
|
||||||
string rid,
|
string rid,
|
||||||
LauncherVersion launcherVersion,
|
LauncherVersion launcherVersion,
|
||||||
string launcherTargetDirectory,
|
string launcherTargetDirectory,
|
||||||
Func<bool> hasRunningSessions,
|
Func<bool> hasRunningSessions,
|
||||||
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null)
|
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null,
|
||||||
|
Uri? updateManifestUri = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
ArgumentNullException.ThrowIfNull(launcherVersion);
|
ArgumentNullException.ThrowIfNull(launcherVersion);
|
||||||
ArgumentNullException.ThrowIfNull(hasRunningSessions);
|
ArgumentNullException.ThrowIfNull(hasRunningSessions);
|
||||||
|
Uri manifestUri = updateManifestUri
|
||||||
|
?? ReleaseManifestClient.ProductionManifestUri;
|
||||||
var versions = new ClientVersionStore(paths);
|
var versions = new ClientVersionStore(paths);
|
||||||
HttpClient? artifactClient = null;
|
HttpClient? artifactClient = null;
|
||||||
ReleaseManifestClient? manifestClient = null;
|
ReleaseManifestClient? manifestClient = null;
|
||||||
|
|
@ -69,7 +76,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
Timeout = TimeSpan.FromSeconds(15),
|
Timeout = TimeSpan.FromSeconds(15),
|
||||||
};
|
};
|
||||||
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
||||||
manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15));
|
manifestClient = CreateManifestClient(manifestUri);
|
||||||
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
|
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
|
||||||
var updater = new LauncherUpdater(
|
var updater = new LauncherUpdater(
|
||||||
manifestClient,
|
manifestClient,
|
||||||
|
|
@ -84,6 +91,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
versions,
|
versions,
|
||||||
LauncherExecutableSet.FromCurrentVersionStore(versions),
|
LauncherExecutableSet.FromCurrentVersionStore(versions),
|
||||||
updater,
|
updater,
|
||||||
|
manifestUri,
|
||||||
artifactClient,
|
artifactClient,
|
||||||
manifestClient);
|
manifestClient);
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +114,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
versions,
|
versions,
|
||||||
LauncherExecutableSet.Unavailable(status),
|
LauncherExecutableSet.Unavailable(status),
|
||||||
new UnavailableLauncherUpdater(status, resolution),
|
new UnavailableLauncherUpdater(status, resolution),
|
||||||
|
manifestUri,
|
||||||
artifactClient: null,
|
artifactClient: null,
|
||||||
manifestClient: null);
|
manifestClient: null);
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +126,16 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
_artifactClient?.Dispose();
|
_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
|
private static bool IsStorageFailure(Exception exception) => exception is
|
||||||
IOException
|
IOException
|
||||||
or UnauthorizedAccessException
|
or UnauthorizedAccessException
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using AcDream.Launcher.Core.Updates;
|
using AcDream.Launcher.Core.Updates;
|
||||||
using AcDream.Platform;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
|
||||||
namespace AcDream.Launcher;
|
namespace AcDream.Launcher;
|
||||||
|
|
@ -9,19 +8,18 @@ internal static class Program
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static int Main(string[] args)
|
public static int Main(string[] args)
|
||||||
{
|
{
|
||||||
if (args is ["--verify-publish"])
|
try
|
||||||
{
|
{
|
||||||
// A display-free execution probe for the packaged artifact. CI
|
LauncherStartupOptions options = LauncherStartupOptions.Parse(args);
|
||||||
// runs this with DOTNET_ROOT pointing at a missing directory; a
|
if (options.Mode == LauncherStartupMode.VerifyPublish)
|
||||||
// framework-dependent publish cannot reach this return statement.
|
{
|
||||||
|
// A display-free execution probe for the packaged artifact.
|
||||||
|
// Parsing above deliberately never resolves user paths.
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
|
||||||
using var httpClient = new HttpClient();
|
using var httpClient = new HttpClient();
|
||||||
var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient);
|
var selfUpdates = new LauncherSelfUpdateManager(options.Paths, httpClient);
|
||||||
string executable = Environment.ProcessPath
|
string executable = Environment.ProcessPath
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"The launcher executable path is unavailable.");
|
"The launcher executable path is unavailable.");
|
||||||
|
|
@ -37,8 +35,9 @@ internal static class Program
|
||||||
return startup.ExitCode;
|
return startup.ExitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(
|
RequireUnchangedPublicArguments(options, startup);
|
||||||
startup.RemainingArguments);
|
|
||||||
|
return BuildAvaloniaApp(options).StartWithClassicDesktopLifetime([]);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -47,7 +46,25 @@ internal static class Program
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AppBuilder BuildAvaloniaApp() =>
|
internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options)
|
||||||
AppBuilder.Configure<App>()
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
return AppBuilder.Configure(() => new App(options))
|
||||||
.UsePlatformDetect();
|
.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)
|
static int CanonicalProbe(string[] arguments)
|
||||||
{
|
{
|
||||||
if (arguments.Length != 1)
|
if (arguments.Length < 1)
|
||||||
{
|
{
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string suffix = arguments.Length == 1
|
||||||
|
? string.Empty
|
||||||
|
: Environment.NewLine
|
||||||
|
+ string.Join(Environment.NewLine, arguments[1..]);
|
||||||
File.WriteAllText(
|
File.WriteAllText(
|
||||||
Path.GetFullPath(arguments[0]),
|
Path.GetFullPath(arguments[0]),
|
||||||
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||||
+ "|"
|
+ "|"
|
||||||
+ Path.GetFullPath(
|
+ Path.GetFullPath(
|
||||||
Environment.ProcessPath
|
Environment.ProcessPath
|
||||||
?? throw new InvalidOperationException("Process path is unavailable.")));
|
?? throw new InvalidOperationException("Process path is unavailable."))
|
||||||
|
+ suffix);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -279,25 +279,43 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
|
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
|
||||||
{
|
{
|
||||||
using var harness = new Harness(_root);
|
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(
|
SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
["--literal", "argument with spaces & metacharacters"],
|
publicArguments,
|
||||||
harness.Manager,
|
harness.Manager,
|
||||||
harness.Target,
|
harness.Target,
|
||||||
harness.LauncherPath);
|
harness.LauncherPath);
|
||||||
Assert.False(ordinary.ShouldExit);
|
Assert.False(ordinary.ShouldExit);
|
||||||
Assert.Equal(["--literal", "argument with spaces & metacharacters"],
|
Assert.Equal(publicArguments, ordinary.RemainingArguments);
|
||||||
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();
|
_ = await harness.StageAsync();
|
||||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||||
SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
|
SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
[LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId],
|
[
|
||||||
|
LauncherSelfUpdateBootstrap.ConfirmArgument,
|
||||||
|
applied.TransactionId,
|
||||||
|
.. publicArguments,
|
||||||
|
],
|
||||||
harness.Manager,
|
harness.Manager,
|
||||||
harness.Target,
|
harness.Target,
|
||||||
harness.LauncherPath);
|
harness.LauncherPath);
|
||||||
|
|
||||||
Assert.False(confirmation.ShouldExit);
|
Assert.False(confirmation.ShouldExit);
|
||||||
Assert.Empty(confirmation.RemainingArguments);
|
Assert.Equal(publicArguments, confirmation.RemainingArguments);
|
||||||
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
|
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
|
||||||
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
|
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,13 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
string ready = Path.Combine(_root, "crash.ready");
|
string ready = Path.Combine(_root, "crash.ready");
|
||||||
string launched = Path.Combine(_root, "replacement.ready");
|
string launched = Path.Combine(_root, "replacement.ready");
|
||||||
string helperPidPath = Path.Combine(_root, "helper.pid");
|
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);
|
Directory.CreateDirectory(_root);
|
||||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||||
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
||||||
|
|
@ -76,7 +83,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
};
|
};
|
||||||
using Process canonical = StartProcess(
|
using Process canonical = StartProcess(
|
||||||
prepared.CanonicalPath,
|
prepared.CanonicalPath,
|
||||||
["canonical-probe", launched],
|
["canonical-probe", launched, .. processLocalSuffix],
|
||||||
environment);
|
environment);
|
||||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||||
Assert.Equal(0, canonical.ExitCode);
|
Assert.Equal(0, canonical.ExitCode);
|
||||||
|
|
@ -100,7 +107,12 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
SearchOption.TopDirectoryOnly));
|
SearchOption.TopDirectoryOnly));
|
||||||
Assert.Null(await manager.LoadPendingAsync());
|
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(
|
int helperPid = int.Parse(
|
||||||
await File.ReadAllTextAsync(helperPidPath),
|
await File.ReadAllTextAsync(helperPidPath),
|
||||||
System.Globalization.CultureInfo.InvariantCulture);
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,26 @@ public sealed class LauncherVersionTests
|
||||||
|
|
||||||
public sealed class ReleaseManifestClientTests
|
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]
|
[Fact]
|
||||||
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
|
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,78 @@ public sealed class LauncherStartupOptionsTests
|
||||||
Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri);
|
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()
|
public static TheoryData<string[]> InvalidArguments()
|
||||||
{
|
{
|
||||||
string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11"));
|
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", "file:///tmp/manifest.json"]);
|
||||||
data.Add(
|
data.Add(
|
||||||
["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]);
|
["--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(["--update-manifest-uri", "not-a-uri"]);
|
||||||
data.Add(
|
data.Add(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -62,4 +62,44 @@ public sealed class LauncherUpdateCompositionTests : IDisposable
|
||||||
() => composition.Updater.CheckAsync());
|
() => composition.Updater.CheckAsync());
|
||||||
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal);
|
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.App.exe'
|
||||||
Require-PayloadFile "$release-client-win-x64" 'acdream-headless.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-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.App'
|
||||||
Require-PayloadFile "$release-client-linux-x64" 'acdream-headless'
|
Require-PayloadFile "$release-client-linux-x64" 'acdream-headless'
|
||||||
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher'
|
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher'
|
||||||
|
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-bake'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Test-Path -LiteralPath $OutputDirectory) {
|
if (Test-Path -LiteralPath $OutputDirectory) {
|
||||||
|
|
@ -155,6 +157,7 @@ function New-DeterministicZip(
|
||||||
$executable = $relative -ceq 'AcDream.App' -or
|
$executable = $relative -ceq 'AcDream.App' -or
|
||||||
$relative -ceq 'acdream-headless' -or
|
$relative -ceq 'acdream-headless' -or
|
||||||
$relative -ceq 'acdream-launcher' -or
|
$relative -ceq 'acdream-launcher' -or
|
||||||
|
$relative -ceq 'acdream-bake' -or
|
||||||
$relative.EndsWith('.sh', [StringComparison]::Ordinal)
|
$relative.EndsWith('.sh', [StringComparison]::Ordinal)
|
||||||
$mode = if ($executable) { 0x81ED } else { 0x81A4 }
|
$mode = if ($executable) { 0x81ED } else { 0x81A4 }
|
||||||
$entry.ExternalAttributes = $mode -shl 16
|
$entry.ExternalAttributes = $mode -shl 16
|
||||||
|
|
@ -239,16 +242,26 @@ $server = @'
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)][string]$Root,
|
[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
|
Set-StrictMode -Version Latest
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator(
|
||||||
|
[IO.Path]::GetFullPath($PSScriptRoot))
|
||||||
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
|
$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/"
|
$prefix = "http://127.0.0.1:$Port/"
|
||||||
$listener = [Net.HttpListener]::new()
|
$listener = [Net.HttpListener]::new()
|
||||||
$listener.Prefixes.Add($prefix)
|
$listener.Prefixes.Add($prefix)
|
||||||
$listener.Start()
|
$listener.Start()
|
||||||
Write-Host "Campaign LA fixture listening on $prefix"
|
Write-Host "Campaign LA fixture listening on $prefix"
|
||||||
|
$servedRequests = 0
|
||||||
try {
|
try {
|
||||||
while ($listener.IsListening) {
|
while ($listener.IsListening) {
|
||||||
$context = $listener.GetContext()
|
$context = $listener.GetContext()
|
||||||
|
|
@ -287,7 +300,13 @@ try {
|
||||||
$context.Response.StatusCode = 500
|
$context.Response.StatusCode = 500
|
||||||
Write-Error $_
|
Write-Error $_
|
||||||
}
|
}
|
||||||
finally { $context.Response.Close() }
|
finally {
|
||||||
|
$context.Response.Close()
|
||||||
|
$servedRequests++
|
||||||
|
}
|
||||||
|
if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally { $listener.Close() }
|
finally { $listener.Close() }
|
||||||
|
|
@ -301,8 +320,24 @@ param(
|
||||||
[string]$Root = $PSScriptRoot
|
[string]$Root = $PSScriptRoot
|
||||||
)
|
)
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt'
|
$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator(
|
||||||
Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline
|
[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"
|
Write-Host "Campaign LA fixture active release: $Release"
|
||||||
'@
|
'@
|
||||||
$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM
|
$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) {
|
function Protect-Text([string]$Text) {
|
||||||
if ($null -eq $Text) { return '' }
|
if ($null -eq $Text) { return '' }
|
||||||
$protected = $Text
|
$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 = [Text.RegularExpressions.Regex]::Replace(
|
||||||
$protected,
|
$protected,
|
||||||
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
|
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
|
||||||
'$1$2<redacted>')
|
'$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 = [Text.RegularExpressions.Regex]::Replace(
|
||||||
$protected,
|
$protected,
|
||||||
'(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
|
'(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
|
||||||
|
|
@ -142,6 +148,11 @@ function Invoke-GateCommand(
|
||||||
$startInfo.RedirectStandardOutput = $true
|
$startInfo.RedirectStandardOutput = $true
|
||||||
$startInfo.RedirectStandardError = $true
|
$startInfo.RedirectStandardError = $true
|
||||||
foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) }
|
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()) {
|
foreach ($entry in $Environment.GetEnumerator()) {
|
||||||
$startInfo.Environment[[string]$entry.Key] = [string]$entry.Value
|
$startInfo.Environment[[string]$entry.Key] = [string]$entry.Value
|
||||||
}
|
}
|
||||||
|
|
@ -258,6 +269,34 @@ try {
|
||||||
'--', 'RunConfiguration.MaxCpuCount=1')
|
'--', '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')) {
|
foreach ($rid in @('win-x64', 'linux-x64')) {
|
||||||
$destination = Join-Path $publishDirectory $rid
|
$destination = Join-Path $publishDirectory $rid
|
||||||
Invoke-DotNet "publish-launcher-$rid" @(
|
Invoke-DotNet "publish-launcher-$rid" @(
|
||||||
|
|
@ -382,7 +421,8 @@ finally {
|
||||||
failures = @($failures)
|
failures = @($failures)
|
||||||
redaction = [ordered]@{
|
redaction = [ordered]@{
|
||||||
applied = $true
|
applied = $true
|
||||||
environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')
|
inheritedAcdreamEnvironmentCleared = $true
|
||||||
|
inheritedEnvironmentValuesRead = $false
|
||||||
credentialArgumentsAllowed = $false
|
credentialArgumentsAllowed = $false
|
||||||
}
|
}
|
||||||
artifacts = $artifacts
|
artifacts = $artifacts
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
|
||||||
}
|
}
|
||||||
$eventName = Assert-String $root 'e'
|
$eventName = Assert-String $root 'e'
|
||||||
if (-not $exactFields.ContainsKey($eventName)) {
|
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]
|
$expected = $exactFields[$eventName]
|
||||||
if ($names.Count -ne $expected.Count -or
|
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" }
|
if ($code -ne 0) { throw "terminal exit code is $code, expected 0" }
|
||||||
$expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' }
|
$expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' }
|
||||||
if ($reason -cne $expectedReason) {
|
if ($reason -cne $expectedReason) {
|
||||||
throw "terminal reason is '$reason', expected '$expectedReason'"
|
throw "terminal reason does not match mode '$Mode'"
|
||||||
}
|
}
|
||||||
$terminalSeen = $true
|
$terminalSeen = $true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue