feat(launcher): prepare Campaign LA11 user gate
This commit is contained in:
parent
09d84387a8
commit
f881e5b467
24 changed files with 3538 additions and 58 deletions
|
|
@ -28,6 +28,8 @@
|
||||||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
|
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj" />
|
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj" />
|
||||||
|
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj" />
|
||||||
|
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj" />
|
||||||
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
|
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -26,53 +26,41 @@ What does NOT go here:
|
||||||
|
|
||||||
## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host
|
## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** IN-PROGRESS — the isolated process-group implementation and real
|
||||||
|
Windows fixtures are complete; the LA11 connected acceptance row remains
|
||||||
|
required before closure.
|
||||||
**Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE
|
**Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE
|
||||||
account session stuck for several minutes — a documented project landmine;
|
account session stuck for several minutes — a documented project landmine;
|
||||||
see CLAUDE.md "Logout-before-reconnect")
|
see CLAUDE.md "Logout-before-reconnect")
|
||||||
**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3)
|
**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3)
|
||||||
**Component:** Launcher.Core / process supervision
|
**Component:** Launcher.Core / process supervision
|
||||||
|
|
||||||
**Description.** `LauncherProcessSupervisor.Stop` now attempts a graceful
|
**Implementation checkpoint.** `LauncherProcessSupervisor.Stop` attempts
|
||||||
stop signal (`ILauncherChildProcess.TryRequestGracefulStop`) BEFORE
|
`ILauncherChildProcess.TryRequestGracefulStop` before `CloseMainWindow` and
|
||||||
`CloseMainWindow`. On Linux this sends `SIGINT` via a `libc` P/Invoke
|
the timeout/kill fallback. Linux retains its K4-proven targeted `SIGINT`.
|
||||||
(`kill(pid, 2)`), which the K4-proven headless host already turns into an
|
On Windows, console-capable launcher specs now use a narrow no-shell
|
||||||
ACE-confirmed graceful logout. On Windows there is no equivalent today for a
|
`CreateProcessW` seam with `CREATE_NEW_PROCESS_GROUP`, a suspended start, and
|
||||||
console process with no message-pump window: `CloseMainWindow` is a no-op
|
an explicit inherited-handle list that preserves only redirected stdin plus
|
||||||
for a console host (there is no `HWND` to target), and
|
stdout/stderr. A consoleless Avalonia parent briefly allocates and hides a
|
||||||
`GenerateConsoleCtrlEvent` cannot usefully target an arbitrary child process
|
console for the creation transaction, detaches after the new group inherits
|
||||||
today — Windows delivers console control events to every process attached
|
it, and later attaches only long enough to send
|
||||||
to the SAME console as the calling process, so an unscoped call would also
|
`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)`. Each such
|
||||||
signal the launcher itself (and anything else sharing that console), not
|
child is therefore both the root of its own process group and, for the normal
|
||||||
just the intended child. `TryRequestGracefulStop` therefore returns `false`
|
Explorer-launched case, attached to its own console. Graphical children opt
|
||||||
on Windows unconditionally, and `Stop` degrades straight to `CloseMainWindow`
|
out and retain the ordinary `Process`/`WM_CLOSE` path.
|
||||||
(still a no-op for a console child) and then the timeout-driven `Kill()` —
|
|
||||||
exactly the hard-kill behavior this finding was written to describe, just
|
|
||||||
with a documented (rather than silent) gap.
|
|
||||||
|
|
||||||
**Known fix direction (not yet implemented).** Spawn the Windows child with
|
Two real Windows fixture gates cover both a console parent and a consoleless
|
||||||
the `CREATE_NEW_PROCESS_GROUP` creation flag (available via a native
|
WinExe parent. They prove exact complex argv, redirected stdin, receipt of a
|
||||||
`CreateProcess` call or by setting it on the `ProcessStartInfo`/`Process`
|
targeted CTRL_BREAK marker, exit code 0 before timeout, no supervisor `Kill`,
|
||||||
plumbing in `SystemChildProcess`) so the child gets its own console process
|
and a sibling process group that remains running until it receives its own
|
||||||
group, detached from the launcher's own group. Then
|
targeted break. Safe-handle cleanup, early-failure termination, and the
|
||||||
`TryRequestGracefulStop` on Windows calls
|
Linux SIGINT gate remain covered by the Launcher.Core suite.
|
||||||
`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)` —
|
|
||||||
`CTRL_BREAK` (unlike `CTRL_C`) can target a specific process group ID and,
|
|
||||||
unlike `CTRL_CLOSE`/`CTRL_LOGOFF`/`CTRL_SHUTDOWN`, is deliverable to a
|
|
||||||
process that has installed no console-control handler at all (the default
|
|
||||||
CRT handler treats it as a terminating signal, so `AcDream.Headless` doesn't
|
|
||||||
strictly need new code to receive SOME form of shutdown from it) — though
|
|
||||||
wiring a real `SetConsoleCtrlHandler` handler that routes `CTRL_BREAK` into
|
|
||||||
the same graceful-shutdown path K4 already built for Linux SIGINT is the
|
|
||||||
better long-term target, so a Windows headless launch gets the identical
|
|
||||||
ACE-confirmed graceful logout instead of just "exits somehow."
|
|
||||||
|
|
||||||
**Acceptance for closing this issue:** `SystemChildProcess` spawns Windows
|
**Acceptance for closing this issue:** automated process-group and targeted-
|
||||||
children with `CREATE_NEW_PROCESS_GROUP`; `TryRequestGracefulStop` sends
|
signal coverage is complete. Keep the issue IN-PROGRESS until the LA11 live
|
||||||
`CTRL_BREAK_EVENT` to that child's process group on Windows; a live
|
connected row proves `AcDream.Headless` exits gracefully and ACE clears the
|
||||||
connected gate proves `AcDream.Headless` exits gracefully (ACE clears the
|
session immediately (not after the ~3-minute stale-session window) when
|
||||||
session immediately, not after the ~3-minute stale-session window) when
|
stopped through `LauncherProcessSupervisor.Stop` on Windows, matching the
|
||||||
stopped via `LauncherProcessSupervisor.Stop` on Windows, matching the
|
|
||||||
Linux SIGINT behavior.
|
Linux SIGINT behavior.
|
||||||
|
|
||||||
## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click
|
## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click
|
||||||
|
|
|
||||||
|
|
@ -312,7 +312,10 @@ src/
|
||||||
|
|
||||||
AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner
|
AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner
|
||||||
Profiles/ -> sole credential/profile document + CRUD owner
|
Profiles/ -> sole credential/profile document + CRUD owner
|
||||||
Launching/ -> config composition and supervised process seams
|
Launching/ -> config composition and supervised process seams;
|
||||||
|
Windows console hosts are no-shell, redirected-
|
||||||
|
stdin process-group leaders receiving targeted
|
||||||
|
CTRL_BREAK, while Linux hosts receive SIGINT
|
||||||
Status/ -> incremental host-status parsing/tailing
|
Status/ -> incremental host-status parsing/tailing
|
||||||
Orchestration/ -> immutable UI snapshots, typed actions,
|
Orchestration/ -> immutable UI snapshots, typed actions,
|
||||||
capability gates, and running-session lifetime
|
capability gates, and running-session lifetime
|
||||||
|
|
|
||||||
|
|
@ -697,7 +697,10 @@ forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions.
|
||||||
|
|
||||||
## LA11 — closeout
|
## LA11 — closeout
|
||||||
|
|
||||||
- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md`
|
- One exact operator script
|
||||||
|
`docs/research/2026-08-14-campaign-la-test-script.md`, fronted by the
|
||||||
|
connection-free `tools/run-campaign-la-preflight.ps1` and followed by
|
||||||
|
serial user rows,
|
||||||
covering: all three launch modes vs local ACE, probe round-trip ×2 (no
|
covering: all three launch modes vs local ACE, probe round-trip ×2 (no
|
||||||
lingering session), char-select visual matrix + delete flow, login-commands
|
lingering session), char-select visual matrix + delete flow, login-commands
|
||||||
+ plugin behavior on both hosts, add-server/add-account purely in UI,
|
+ plugin behavior on both hosts, add-server/add-account purely in UI,
|
||||||
|
|
@ -729,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
|
||||||
| LA8 | — | | | |
|
| LA8 | — | | | |
|
||||||
| LA9 | — | | | |
|
| LA9 | — | | | |
|
||||||
| LA10 | — | | | |
|
| LA10 | — | | | |
|
||||||
| LA11 | — | | | |
|
| 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. |
|
||||||
|
|
|
||||||
571
docs/research/2026-08-14-campaign-la-test-script.md
Normal file
571
docs/research/2026-08-14-campaign-la-test-script.md
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
# Campaign LA11 — automated preflight and connected user gate
|
||||||
|
|
||||||
|
**Status:** implementation checkpoint only. Run this script after the reviewed
|
||||||
|
LA10/LA11 commits are integrated and the campaign branch is clean. Campaign LA,
|
||||||
|
the Linux graphical client, and issue #397 remain open until the user records a
|
||||||
|
verdict for every applicable row below.
|
||||||
|
|
||||||
|
This is the single Campaign LA operator script. The automated section is
|
||||||
|
display-free and connection-free. Rows A–I are deliberately manual and serial:
|
||||||
|
they use real retail DATs, a local ACE server, user-entered credentials, and
|
||||||
|
visual judgment that automation cannot supply.
|
||||||
|
|
||||||
|
## 1. Safety boundary and required inputs
|
||||||
|
|
||||||
|
Use placeholders throughout; never paste a password into a terminal, this
|
||||||
|
document, a screenshot, or a gate report.
|
||||||
|
|
||||||
|
- `<ABSOLUTE_REPOSITORY_ROOT>`: a clean Campaign LA worktree at the exact commit
|
||||||
|
under test.
|
||||||
|
- `<ABSOLUTE_RETAIL_DAT_DIRECTORY>`: a read-only source containing
|
||||||
|
`client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and
|
||||||
|
`client_local_English.dat`.
|
||||||
|
- `<ACE_PORT>`, `<LA11_SERVER>`, and `<LA11_ACCOUNT>`: a local ACE endpoint and
|
||||||
|
account. Enter the account password only in the launcher's masked Password
|
||||||
|
field. The launcher intentionally stores it as plaintext in the **isolated**
|
||||||
|
`launcher-profiles.json`; children receive it through redirected stdin.
|
||||||
|
- `<OBSERVER_CHARACTER>`: a second user-controlled character that can observe a
|
||||||
|
private `/tell` from each play mode.
|
||||||
|
- `<DISPOSABLE_CHARACTER>`: a server-operator-approved disposable character.
|
||||||
|
Never substitute a primary character. If none exists, provision one with the
|
||||||
|
local server's normal admin procedure before row G.
|
||||||
|
- Windows 11 x64, PowerShell 7, .NET 10 SDK, a local ACE server, and a supported
|
||||||
|
Vulkan Windows machine for rows A–H. Ubuntu x64 with PowerShell 7 and a Linux
|
||||||
|
desktop/WSLg is required for row I. The Avalonia launcher is supported on
|
||||||
|
Linux; `gui` and `guiSelect` **client** actions must remain disabled with the
|
||||||
|
Modern Runtime Slice-L explanation.
|
||||||
|
|
||||||
|
Close every unrelated `AcDream.App`, `acdream-headless`, and acdream launcher
|
||||||
|
before starting. Do not run another acdream gate in parallel. All generated
|
||||||
|
files must stay below one new gate directory; the canonical `%APPDATA%`,
|
||||||
|
`%LOCALAPPDATA%`, and XDG acdream roots are out of scope.
|
||||||
|
|
||||||
|
## 2. Automated preflight — no UI, connection, credential, or bake
|
||||||
|
|
||||||
|
In PowerShell 7 on Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$Repo = [IO.Path]::GetFullPath('<ABSOLUTE_REPOSITORY_ROOT>')
|
||||||
|
$Stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
|
||||||
|
$Gate = Join-Path $Repo "logs/campaign-la-user-gate-$Stamp"
|
||||||
|
$Preflight = Join-Path $Gate 'automated-preflight'
|
||||||
|
New-Item -ItemType Directory -Path $Gate | Out-Null
|
||||||
|
|
||||||
|
pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
|
||||||
|
-Repository $Repo `
|
||||||
|
-OutputDirectory $Preflight
|
||||||
|
|
||||||
|
$Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw |
|
||||||
|
ConvertFrom-Json
|
||||||
|
if (-not $Report.success -or $Report.dirty) {
|
||||||
|
throw 'Stop: automated preflight failed or recorded a dirty worktree.'
|
||||||
|
}
|
||||||
|
if ($Report.head -cne (git -C $Repo rev-parse HEAD).Trim()) {
|
||||||
|
throw 'Stop: preflight HEAD does not equal the current HEAD.'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The expected matrix is:
|
||||||
|
|
||||||
|
| Platform | Automated command group | Required result | Typical time |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| 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 | 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.
|
||||||
|
|
||||||
|
### Optional installed-DAT read-only row
|
||||||
|
|
||||||
|
This is not a bake and must not replace row A. Add the switches below only when
|
||||||
|
the DAT directory may be read by tests:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
|
||||||
|
-Repository $Repo `
|
||||||
|
-OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') `
|
||||||
|
-IncludeInstalledDat `
|
||||||
|
-InstalledDatDirectory '<ABSOLUTE_RETAIL_DAT_DIRECTORY>'
|
||||||
|
```
|
||||||
|
|
||||||
|
The mandatory installed-DAT result is
|
||||||
|
`CharacterManagementLiveDatTests` with both `ACDREAM_PROBE_LIVE_MOUNT=1` and
|
||||||
|
`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.
|
||||||
|
|
||||||
|
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
|
||||||
|
`wsl.exe` as the Linux row.
|
||||||
|
|
||||||
|
## 3. Prepare the deterministic local A/B feed
|
||||||
|
|
||||||
|
Build distinct, version-stamped payloads so the staged launcher really changes
|
||||||
|
from A to B. These commands write only below `$Gate` (normal project `obj/bin`
|
||||||
|
incremental outputs are the already-authorized build outputs):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$VersionA = '1.0.1-la11.a'
|
||||||
|
$VersionB = '1.0.1-la11.b'
|
||||||
|
$Payloads = Join-Path $Gate 'update-payloads'
|
||||||
|
$Fixture = Join-Path $Gate 'update-fixture'
|
||||||
|
|
||||||
|
function Publish-LaRelease([string]$Version, [string]$Label) {
|
||||||
|
$ClientWin = Join-Path $Payloads "$Label/client-win-x64"
|
||||||
|
$LauncherWin = Join-Path $Payloads "$Label/launcher-win-x64"
|
||||||
|
$ClientLinux = Join-Path $Payloads "$Label/client-linux-x64"
|
||||||
|
$LauncherLinux = Join-Path $Payloads "$Label/launcher-linux-x64"
|
||||||
|
|
||||||
|
dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') `
|
||||||
|
-c Release -r win-x64 --self-contained true -p:Version=$Version `
|
||||||
|
-o $ClientWin --nologo
|
||||||
|
if ($LASTEXITCODE) { throw "App win-x64 publish failed: $Label" }
|
||||||
|
dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') `
|
||||||
|
-c Release -r win-x64 --self-contained true -p:Version=$Version `
|
||||||
|
-o $ClientWin --nologo
|
||||||
|
if ($LASTEXITCODE) { throw "Headless win-x64 publish failed: $Label" }
|
||||||
|
dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') `
|
||||||
|
-c Release -r win-x64 --self-contained true -p:PublishSingleFile=true `
|
||||||
|
-p:Version=$Version -o $LauncherWin --nologo
|
||||||
|
if ($LASTEXITCODE) { throw "Launcher win-x64 publish failed: $Label" }
|
||||||
|
|
||||||
|
dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') `
|
||||||
|
-c Release -r linux-x64 --self-contained true -p:Version=$Version `
|
||||||
|
-o $ClientLinux --nologo
|
||||||
|
if ($LASTEXITCODE) { throw "App linux-x64 publish failed: $Label" }
|
||||||
|
dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') `
|
||||||
|
-c Release -r linux-x64 --self-contained true -p:Version=$Version `
|
||||||
|
-o $ClientLinux --nologo
|
||||||
|
if ($LASTEXITCODE) { throw "Headless linux-x64 publish failed: $Label" }
|
||||||
|
dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') `
|
||||||
|
-c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true `
|
||||||
|
-p:Version=$Version -o $LauncherLinux --nologo
|
||||||
|
if ($LASTEXITCODE) { throw "Launcher linux-x64 publish failed: $Label" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Publish-LaRelease $VersionA 'A'
|
||||||
|
Publish-LaRelease $VersionB 'B'
|
||||||
|
|
||||||
|
pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1') `
|
||||||
|
-OutputDirectory $Fixture `
|
||||||
|
-ClientWinX64DirectoryA (Join-Path $Payloads 'A/client-win-x64') `
|
||||||
|
-LauncherWinX64DirectoryA (Join-Path $Payloads 'A/launcher-win-x64') `
|
||||||
|
-ClientLinuxX64DirectoryA (Join-Path $Payloads 'A/client-linux-x64') `
|
||||||
|
-LauncherLinuxX64DirectoryA (Join-Path $Payloads 'A/launcher-linux-x64') `
|
||||||
|
-ClientWinX64DirectoryB (Join-Path $Payloads 'B/client-win-x64') `
|
||||||
|
-LauncherWinX64DirectoryB (Join-Path $Payloads 'B/launcher-win-x64') `
|
||||||
|
-ClientLinuxX64DirectoryB (Join-Path $Payloads 'B/client-linux-x64') `
|
||||||
|
-LauncherLinuxX64DirectoryB (Join-Path $Payloads 'B/launcher-linux-x64')
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper rejects nonempty output, invalid or non-monotonic versions, missing
|
||||||
|
root executables, 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.
|
||||||
|
|
||||||
|
Start the Windows loopback server without a shell or visible helper window:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$ServerInfo = [Diagnostics.ProcessStartInfo]::new()
|
||||||
|
$ServerInfo.FileName = (Get-Command pwsh).Source
|
||||||
|
$ServerInfo.UseShellExecute = $false
|
||||||
|
$ServerInfo.CreateNoWindow = $true
|
||||||
|
foreach ($Value in @(
|
||||||
|
'-NoProfile', '-File', (Join-Path $Fixture 'serve-fixture.ps1'),
|
||||||
|
'-Root', $Fixture, '-Port', '43119')) {
|
||||||
|
$ServerInfo.ArgumentList.Add($Value)
|
||||||
|
}
|
||||||
|
$FixtureServer = [Diagnostics.Process]::Start($ServerInfo)
|
||||||
|
$ManifestUri = 'http://127.0.0.1:43119/manifest.json'
|
||||||
|
if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionA) {
|
||||||
|
throw 'Stop: local fixture did not begin on release A.'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Windows isolated launcher command and evidence rule
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$WinRoot = Join-Path $Gate 'windows-roots'
|
||||||
|
$WinConfig = Join-Path $WinRoot 'config'
|
||||||
|
$WinData = Join-Path $WinRoot 'data'
|
||||||
|
$WinCache = Join-Path $WinRoot 'cache'
|
||||||
|
$Evidence = Join-Path $Gate 'evidence'
|
||||||
|
New-Item -ItemType Directory -Path $Evidence | Out-Null
|
||||||
|
|
||||||
|
$LauncherA = Join-Path $Payloads 'A/launcher-win-x64/acdream-launcher.exe'
|
||||||
|
$LauncherArguments = @(
|
||||||
|
'--config-dir', $WinConfig,
|
||||||
|
'--data-dir', $WinData,
|
||||||
|
'--cache-dir', $WinCache,
|
||||||
|
'--update-manifest-uri', $ManifestUri)
|
||||||
|
& $LauncherA @LauncherArguments
|
||||||
|
```
|
||||||
|
|
||||||
|
All four options are process-local. The three roots are an indivisible set;
|
||||||
|
the local feed reaches only the updater and is never persisted. A self-update
|
||||||
|
must preserve the same validated suffix through helper and confirmation
|
||||||
|
restarts. The launcher, profiles, installer, current-version store, updater,
|
||||||
|
session composer, and orchestrator must all use this one exact path set.
|
||||||
|
|
||||||
|
For every play/probe row, copy the session id shown in the launcher's Sessions
|
||||||
|
list into `<SESSION_ID>`, then run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$Status = Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/status.jsonl'
|
||||||
|
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
|
||||||
|
-StatusFile $Status `
|
||||||
|
-Mode '<probe|guiSelect|gui|headless>' `
|
||||||
|
-ExpectedSessionId '<SESSION_ID>' `
|
||||||
|
-ReportPath (Join-Path $Evidence '<ROW>-status.validation.json')
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact
|
||||||
|
v1 fields **and property order**, one session id, UTC monotonic timestamps,
|
||||||
|
mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login
|
||||||
|
command failure, credential redaction, and no surviving App/Headless process.
|
||||||
|
Its report contains event names and a hash, not account, character, command, or
|
||||||
|
error payloads. Keep raw `session.json`/`status.jsonl` local; never upload them.
|
||||||
|
|
||||||
|
## 5. Serial Windows user rows A–H
|
||||||
|
|
||||||
|
### A — isolated first run, real DAT bake, and release-A client baseline
|
||||||
|
|
||||||
|
1. Confirm the launcher opens First-run setup and all launch buttons are
|
||||||
|
unavailable. Save a redacted screenshot as `A-first-run-required.png`.
|
||||||
|
2. Enter `<ABSOLUTE_RETAIL_DAT_DIRECTORY>` in the wizard, select a sensible
|
||||||
|
worker count, and click **Validate**. Confirm all four DATs pass.
|
||||||
|
3. Click **Build and install**. Do not cancel or close the launcher. The real
|
||||||
|
bake may take 30–180 minutes. Confirm every phase reaches **Completed** and
|
||||||
|
the status says `Client content installed and verified. Launch is enabled.`
|
||||||
|
4. Open **Check for updates**. Confirm available release A, click **Install
|
||||||
|
client**, and wait for `Client update installed and activated.` Do not stage
|
||||||
|
launcher A; the test launcher already has version A.
|
||||||
|
5. Confirm these exact isolated artifacts exist and no `.previous-install`
|
||||||
|
remains after success:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$RequiredA = @(
|
||||||
|
(Join-Path $WinData 'install.json'),
|
||||||
|
(Join-Path $WinData 'pak/acdream.pak'),
|
||||||
|
(Join-Path $WinData 'app/current.json'))
|
||||||
|
foreach ($Path in $RequiredA) {
|
||||||
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing $Path" }
|
||||||
|
}
|
||||||
|
$RequiredA | ForEach-Object {
|
||||||
|
$Item = Get-Item -LiteralPath $_
|
||||||
|
[ordered]@{
|
||||||
|
name = $Item.Name
|
||||||
|
size = $Item.Length
|
||||||
|
sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'A-install-hashes.json')
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not copy `install.json` into shared evidence because it records the local DAT
|
||||||
|
path. Expected time: 45–200 minutes including bake.
|
||||||
|
|
||||||
|
### B — server/account CRUD entirely through the UI
|
||||||
|
|
||||||
|
1. Add `<LA11_TEMP_SERVER>` at `127.0.0.1:<UNUSED_LOCAL_PORT>`, edit its name
|
||||||
|
and port, then remove it. Confirm Cancel/Escape makes no mutation.
|
||||||
|
2. Add `<LA11_SERVER>` at `127.0.0.1:<ACE_PORT>`.
|
||||||
|
3. Under it add `<LA11_TEMP_ACCOUNT>` with a user-invented throwaway field
|
||||||
|
value, edit its account name/value, then remove it. Do not reuse a real
|
||||||
|
password for this temporary row.
|
||||||
|
4. Add `<LA11_ACCOUNT>` and enter its real password only in the masked field.
|
||||||
|
5. Close and reopen the launcher with the **same** `$LauncherArguments`. Confirm
|
||||||
|
only the real server/account persisted. Save redacted before/reopen images as
|
||||||
|
`B-crud-before-reopen.png` and `B-crud-after-reopen.png`.
|
||||||
|
6. Record only the profile file's size/hash, never its contents:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$Profile = Join-Path $WinConfig 'launcher-profiles.json'
|
||||||
|
$Item = Get-Item -LiteralPath $Profile
|
||||||
|
[ordered]@{
|
||||||
|
size = $Item.Length
|
||||||
|
sha256 = (Get-FileHash -LiteralPath $Profile -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'B-profile-hash.json')
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected time: 10–15 minutes.
|
||||||
|
|
||||||
|
### C — live character probe twice, no stale ACE session
|
||||||
|
|
||||||
|
1. Select `<LA11_ACCOUNT>`, click **Refresh characters**, and wait for the
|
||||||
|
probe row to finish. Confirm the roster appears without entering world.
|
||||||
|
2. Run the validator in `probe` mode for its session id. Confirm its exact
|
||||||
|
event order is `started, connected, characterList, disconnected, exited`,
|
||||||
|
with no `enteredWorld`, terminal code 0, and terminal reason `probe`.
|
||||||
|
3. In the ACE console/session administration view, confirm the account is no
|
||||||
|
longer logged in. Save a redacted `C-probe-1-ace-cleared.png`.
|
||||||
|
4. Repeat steps 1–3 immediately, producing a different session id,
|
||||||
|
`C-probe-2-status.validation.json`, and `C-probe-2-ace-cleared.png`.
|
||||||
|
5. Confirm Refresh is re-enabled and no `acdream-headless` process remains.
|
||||||
|
|
||||||
|
Expected time: 5–10 minutes. A stale ACE account or timeout is a gate failure;
|
||||||
|
do not wait three minutes and call the next attempt a pass.
|
||||||
|
|
||||||
|
### D — `guiSelect`, retail character screen, plugin, and login command
|
||||||
|
|
||||||
|
1. Select one non-disposable roster character. Set its mode to `guiSelect`,
|
||||||
|
Plugins to exactly `acdream.smoke`, and its one login command to
|
||||||
|
`/tell <OBSERVER_CHARACTER>, LA11-D-<UNIQUE_NONSECRET_NONCE>`. Save settings.
|
||||||
|
2. Click **GUI — character select**. Confirm the flat retail character list,
|
||||||
|
selection highlight, Enter button, Delete/Restore swap state, dialogs, and
|
||||||
|
absence of any invented rotating 3D preview. Save redacted
|
||||||
|
`D-character-select.png`.
|
||||||
|
3. Select the configured character and enter world. Confirm the observer gets
|
||||||
|
the exact D nonce once. Save `D-observer-tell.png` with names redacted.
|
||||||
|
4. Click **Stop** in the launcher. Confirm the game closes gracefully and ACE
|
||||||
|
releases the account. Validate `guiSelect` with
|
||||||
|
`-ExpectedPlugin acdream.smoke`.
|
||||||
|
|
||||||
|
Expected time: 5–10 minutes.
|
||||||
|
|
||||||
|
### E — direct `gui`, plugin, and login command
|
||||||
|
|
||||||
|
1. Change the same character to `gui`, retain `acdream.smoke`, and change the
|
||||||
|
command nonce to `LA11-E-<UNIQUE_NONSECRET_NONCE>`.
|
||||||
|
2. Click **GUI — enter world**. Confirm it selects the exact cached character,
|
||||||
|
reaches the world, loads the plugin once, and the observer gets the E nonce
|
||||||
|
once.
|
||||||
|
3. Stop from the launcher, confirm ACE logout, and validate `gui` with the
|
||||||
|
expected plugin. Save `E-world.png`, `E-observer-tell.png`, and
|
||||||
|
`E-status.validation.json` with identifying text redacted.
|
||||||
|
|
||||||
|
Expected time: 5–10 minutes.
|
||||||
|
|
||||||
|
### F — headless, plugin/login command, and connected #397 acceptance
|
||||||
|
|
||||||
|
1. Change the same character to `headless`, retain `acdream.smoke`, and use
|
||||||
|
`LA11-F-<UNIQUE_NONSECRET_NONCE>`.
|
||||||
|
2. Click **Headless**. Confirm `pluginLoaded(acdream.smoke)`, `enteredWorld`,
|
||||||
|
and the observer's single exact F nonce.
|
||||||
|
3. Click **Stop** once. On Windows this must target that child's distinct
|
||||||
|
process group with `CTRL_BREAK`; it must reach `disconnected` then
|
||||||
|
`exited(code:0, reason:graceful)` before the timeout, without a hard kill.
|
||||||
|
ACE must release the account immediately and the launcher must stay open.
|
||||||
|
4. Validate `headless` with the expected plugin and save
|
||||||
|
`F-status.validation.json` plus redacted ACE-clear evidence.
|
||||||
|
|
||||||
|
The real automated fixture separately proves complex argv and redirected stdin
|
||||||
|
survive native `CreateProcessW`, the target receives `CTRL_BREAK`, a sibling
|
||||||
|
process group receives nothing, exit 0 precedes timeout, and `Kill` is never
|
||||||
|
called. This connected row proves the actual ACE graceful-logout half. Issue
|
||||||
|
#397 remains open if either half is missing. Expected time: 5–10 minutes.
|
||||||
|
|
||||||
|
### G — disposable delete and restore
|
||||||
|
|
||||||
|
1. Launch `guiSelect` for `<DISPOSABLE_CHARACTER>`. Do not enter world.
|
||||||
|
2. Confirm ordinary selection enables Enter/Delete and disables Restore. Click
|
||||||
|
Delete, inspect the retail confirmation dialog, cancel once, and confirm no
|
||||||
|
state change.
|
||||||
|
3. Delete again and confirm. Verify the wait dialog, greyed roster row/countdown,
|
||||||
|
disabled Enter/Delete, and enabled Restore. Save `G-deleted.png`.
|
||||||
|
4. Click Restore and confirm the same GUID returns to ordinary state with
|
||||||
|
Enter/Delete enabled and Restore disabled. Save `G-restored.png`.
|
||||||
|
5. Close through launcher **Stop**, confirm graceful terminal status and ACE
|
||||||
|
release. Validate with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
|
||||||
|
-StatusFile (Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/status.jsonl') `
|
||||||
|
-Mode guiSelect `
|
||||||
|
-ExpectNoEnteredWorld `
|
||||||
|
-ExpectedSessionId '<SESSION_ID>' `
|
||||||
|
-ExpectedPlugin acdream.smoke `
|
||||||
|
-ReportPath (Join-Path $Evidence 'G-status.validation.json')
|
||||||
|
```
|
||||||
|
|
||||||
|
If restore fails, stop the row, preserve evidence, and restore only that
|
||||||
|
disposable character with the server's normal admin recovery. Never continue
|
||||||
|
with another character. Expected time: 5–10 minutes.
|
||||||
|
|
||||||
|
### H — local A→B client update, active-session refusal, rollback, self-update
|
||||||
|
|
||||||
|
1. Record release A's `app/current.json`. Start one headless session and wait
|
||||||
|
for `enteredWorld`.
|
||||||
|
2. Switch the fixture atomically to B:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File (Join-Path $Fixture 'set-active-release.ps1') `
|
||||||
|
-Release B -Root $Fixture
|
||||||
|
if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionB) {
|
||||||
|
throw 'Stop: fixture did not switch to B.'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open **Check for updates** and **Check again**. While the session is active,
|
||||||
|
confirm Install client, Rollback client, and Stage launcher are disabled or
|
||||||
|
refuse without changing `app/current.json`. Save `H-active-refusal.png`.
|
||||||
|
4. Stop the headless session and validate its graceful status. Install client
|
||||||
|
B. Confirm `app/current.json` names B, A is previous, all installed-file
|
||||||
|
hashes verify, and new sessions resolve from the B directory.
|
||||||
|
5. Click **Rollback client**. Confirm A becomes current and B becomes previous.
|
||||||
|
Check again and install B once more, leaving B current. Save sanitized copies
|
||||||
|
of the three pointer states as `H-pointer-a.json`, `H-pointer-b.json`, and
|
||||||
|
`H-pointer-rollback-a.json`; they contain no credentials.
|
||||||
|
6. Click **Stage launcher**. Confirm restart is required, then close the
|
||||||
|
launcher normally. The copied helper must apply B and restart the launcher
|
||||||
|
with the same config/data/cache/feed suffix.
|
||||||
|
7. Confirm profiles, install record, and update state still come from the
|
||||||
|
isolated roots; `campaign-la-fixture-release.txt` beside the relaunched
|
||||||
|
executable says `release=B`; `launcher-update/pending.json` is gone; and no
|
||||||
|
transaction backup remains. Check again and confirm launcher B is current.
|
||||||
|
Save `H-self-update-confirmed.png` and a hash-only post-state inventory.
|
||||||
|
|
||||||
|
Never edit a manifest to force this row and never point the launcher at a
|
||||||
|
non-loopback HTTP endpoint. Expected time: 15–30 minutes.
|
||||||
|
|
||||||
|
## 6. Row I — native Ubuntu/WSL launcher, XDG-shaped isolated roots
|
||||||
|
|
||||||
|
Stop the Windows launcher and fixture server only after every Windows session
|
||||||
|
is terminal:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
if (-not $FixtureServer.HasExited) {
|
||||||
|
$FixtureServer.Kill()
|
||||||
|
$FixtureServer.WaitForExit()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In a native Ubuntu/WSL PowerShell 7 terminal, set Linux paths. The repository
|
||||||
|
and fixture may be read from a mounted Windows path, but roots must live on the
|
||||||
|
Linux filesystem. Run the generated server natively so its `127.0.0.1` URLs
|
||||||
|
cannot escape the Linux environment:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$RepoLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_REPOSITORY_PATH>')
|
||||||
|
$FixtureLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_FIXTURE_PATH>')
|
||||||
|
$PayloadsLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_PAYLOADS_PATH>')
|
||||||
|
$LinuxGate = [IO.Path]::GetFullPath('<NEW_ABSOLUTE_LINUX_GATE_ROOT>')
|
||||||
|
$env:XDG_CONFIG_HOME = Join-Path $LinuxGate 'xdg-config-home'
|
||||||
|
$env:XDG_DATA_HOME = Join-Path $LinuxGate 'xdg-data-home'
|
||||||
|
$env:XDG_CACHE_HOME = Join-Path $LinuxGate 'xdg-cache-home'
|
||||||
|
$LinuxConfig = Join-Path $env:XDG_CONFIG_HOME 'acdream'
|
||||||
|
$LinuxData = Join-Path $env:XDG_DATA_HOME 'acdream'
|
||||||
|
$LinuxCache = Join-Path $env:XDG_CACHE_HOME 'acdream'
|
||||||
|
$LinuxEvidence = Join-Path $LinuxGate 'evidence'
|
||||||
|
New-Item -ItemType Directory -Path $LinuxEvidence | Out-Null
|
||||||
|
|
||||||
|
pwsh -NoProfile -File (Join-Path $FixtureLinux 'set-active-release.ps1') `
|
||||||
|
-Release A -Root $FixtureLinux
|
||||||
|
```
|
||||||
|
|
||||||
|
Start `serve-fixture.ps1 -Root $FixtureLinux -Port 43119` in a dedicated native
|
||||||
|
terminal and leave it running. In another terminal:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$LauncherLinuxA = Join-Path $PayloadsLinux 'A/launcher-linux-x64/acdream-launcher'
|
||||||
|
& $LauncherLinuxA `
|
||||||
|
--config-dir $LinuxConfig `
|
||||||
|
--data-dir $LinuxData `
|
||||||
|
--cache-dir $LinuxCache `
|
||||||
|
--update-manifest-uri 'http://127.0.0.1:43119/manifest.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
Complete this exact serial matrix:
|
||||||
|
|
||||||
|
1. **Manual-DAT first run:** enter `<ABSOLUTE_LINUX_RETAIL_DAT_DIRECTORY>`;
|
||||||
|
auto-detection may be empty by design. Validate, bake to
|
||||||
|
`$LinuxData/pak/acdream.pak`, verify, then install release-A client.
|
||||||
|
2. **CRUD:** add/edit/remove a temporary server and account entirely in the
|
||||||
|
launcher, then add the real Linux-reachable ACE profile. Enter its password
|
||||||
|
only in the masked field. Restart and confirm persistence. Run
|
||||||
|
`stat -c '%a' "$LinuxConfig/launcher-profiles.json"`; the exact result must
|
||||||
|
be `600`.
|
||||||
|
3. **Probe twice:** run Refresh twice, validate both status streams in `probe`
|
||||||
|
mode with native `pwsh`, and confirm ACE clears the account after each.
|
||||||
|
4. **Platform posture:** confirm GUI and GUI-select client buttons are disabled
|
||||||
|
and show the explicit Modern Runtime Slice-L message. Do not bypass this
|
||||||
|
disablement and do not claim a Linux graphical-client gate.
|
||||||
|
5. **Headless:** configure `acdream.smoke` and
|
||||||
|
`/tell <OBSERVER_CHARACTER>, LA11-I-<UNIQUE_NONSECRET_NONCE>`, launch, observe
|
||||||
|
the tell, click Stop, and validate `headless` + expected plugin. Native Linux
|
||||||
|
sends SIGINT and must reach graceful terminal status with no process leak.
|
||||||
|
6. **Update:** switch the native fixture to B, prove update actions refuse while
|
||||||
|
a headless session is active, stop it gracefully, install B, rollback to A,
|
||||||
|
reinstall B, stage launcher B, and close normally. Confirm the relaunched
|
||||||
|
binary's B marker, preserved explicit roots/feed, cleaned pending journal,
|
||||||
|
and executable owner bits on App, Headless, Launcher, and Bake.
|
||||||
|
|
||||||
|
Copy only redacted screenshots, validation reports, pointer JSON, hashes, and
|
||||||
|
file-mode results into `$LinuxEvidence`. Keep the Linux profile and raw session
|
||||||
|
files local. Expected time: 60–220 minutes, dominated by the real bake.
|
||||||
|
|
||||||
|
## 7. Evidence, redaction, verdict, and cleanup
|
||||||
|
|
||||||
|
Expected evidence tree:
|
||||||
|
|
||||||
|
```text
|
||||||
|
logs/campaign-la-user-gate-<timestamp>/
|
||||||
|
automated-preflight/report.json
|
||||||
|
automated-preflight/commands/*.log
|
||||||
|
automated-preflight/publish/{win-x64,linux-x64}/...
|
||||||
|
update-fixture/fixture-report.json
|
||||||
|
update-fixture/{A,B}/manifest.json
|
||||||
|
evidence/A-install-hashes.json
|
||||||
|
evidence/B-*.png
|
||||||
|
evidence/C-probe-{1,2}-status.validation.json
|
||||||
|
evidence/D-*.png + D-status.validation.json
|
||||||
|
evidence/E-*.png + E-status.validation.json
|
||||||
|
evidence/F-*.png + F-status.validation.json
|
||||||
|
evidence/G-*.png + G-status.validation.json
|
||||||
|
evidence/H-*.png + H-pointer-*.json
|
||||||
|
evidence/I-*.png + I-status.validation.json + I-modes.txt
|
||||||
|
verdict.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Before sharing evidence:
|
||||||
|
|
||||||
|
- remove or mask account names, character names, DAT paths, hostnames other than
|
||||||
|
loopback, and server-admin identifiers from screenshots;
|
||||||
|
- never copy `launcher-profiles.json`, raw session configs/status streams,
|
||||||
|
stdout/stderr that may contain user text, or environment values;
|
||||||
|
- search the shareable evidence for the exact user-entered password and any
|
||||||
|
gate-only sentinel secret; the match count must be zero;
|
||||||
|
- retain SHA-256 and sizes so local raw artifacts remain auditable.
|
||||||
|
|
||||||
|
No additional raw child/plugin diagnostic sink is required: `pluginLoaded`,
|
||||||
|
the strict terminal status, the observer's redacted tell evidence, and the
|
||||||
|
automated targeted-signal fixture cover the acceptance questions without
|
||||||
|
capturing credentials or arbitrary chat.
|
||||||
|
|
||||||
|
Create `verdict.json` manually with schema version 1, exact tested HEAD, rows
|
||||||
|
A–I as `pass`, `fail`, or `notApplicable`, a short redacted note per row, and
|
||||||
|
the user's overall verdict. Row I is not applicable only when no native
|
||||||
|
Ubuntu/WSL desktop is available; it blocks Campaign LA shipping under the
|
||||||
|
current Linux requirement, so it cannot be silently omitted.
|
||||||
|
|
||||||
|
Cleanup is graceful-first and serial:
|
||||||
|
|
||||||
|
1. Restore `<DISPOSABLE_CHARACTER>` and verify it is ordinary before closing
|
||||||
|
its session.
|
||||||
|
2. Stop every launcher session once; require a passing validator and ACE-clear
|
||||||
|
observation. If a child survives the timeout, record the gate failure and
|
||||||
|
its PID before any emergency termination.
|
||||||
|
3. Close each launcher normally, then stop only the fixture-server process
|
||||||
|
created above. Do not kill ACE as a substitute for logout evidence.
|
||||||
|
4. Leave update pointers on B or roll the **isolated** client back to A through
|
||||||
|
the UI; never edit pointers or journals by hand.
|
||||||
|
5. Remove the real account through the isolated launcher UI. After review,
|
||||||
|
delete only the explicitly recorded `$WinConfig`/`$LinuxConfig` gate roots
|
||||||
|
that held plaintext passwords, or change the test account password. Do not
|
||||||
|
recursively delete a computed, empty, canonical, home, repository, or XDG
|
||||||
|
parent path.
|
||||||
|
6. Preserve the redacted evidence and reports. The large isolated pak/payload
|
||||||
|
trees may be removed only after resolving and checking their full paths are
|
||||||
|
descendants of the recorded gate roots.
|
||||||
|
|
||||||
|
Estimated total: 3–7 hours, primarily the two real DAT bakes and full serial
|
||||||
|
test suites. A failure stops the current row; restore/stop/collect evidence,
|
||||||
|
then diagnose before advancing. Do not mark LA11, Campaign LA, or #397 shipped
|
||||||
|
until the user accepts the complete applicable matrix.
|
||||||
|
|
@ -331,7 +331,9 @@ the process supervisor executes.
|
||||||
Campaign V); visuals settle at the user gate.
|
Campaign V); visuals settle at the user gate.
|
||||||
- **Headless plugin host:** fixture plugin in the Headless suite
|
- **Headless plugin host:** fixture plugin in the Headless suite
|
||||||
(load, capability flag, teardown).
|
(load, capability flag, teardown).
|
||||||
- **Connected gates (user-driven):** every launch mode against local ACE
|
- **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
|
||||||
(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
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,10 @@ public interface ILauncherChildProcess : IDisposable
|
||||||
/// documented project landmine; see CLAUDE.md
|
/// documented project landmine; see CLAUDE.md
|
||||||
/// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved
|
/// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved
|
||||||
/// the headless host's SIGINT handler produces an ACE-confirmed
|
/// the headless host's SIGINT handler produces an ACE-confirmed
|
||||||
/// graceful logout). On Windows there is no reliable cross-console
|
/// graceful logout). On Windows, console-capable children are started
|
||||||
/// mechanism for an arbitrary no-window child process today — see
|
/// as distinct process-group leaders and receive a targeted
|
||||||
/// <c>docs/ISSUES.md</c> for the tracked gap and fix direction; this
|
/// CTRL_BREAK_EVENT. Returns true only when the signal was actually
|
||||||
/// returns false there. Returns true only when the signal was
|
/// delivered; never throws.
|
||||||
/// actually delivered; never throws.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool TryRequestGracefulStop();
|
bool TryRequestGracefulStop();
|
||||||
|
|
||||||
|
|
@ -72,7 +71,9 @@ public interface ILauncherChildProcessFactory
|
||||||
public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory
|
public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory
|
||||||
{
|
{
|
||||||
public ILauncherChildProcess Create(LauncherProcessSpec spec) =>
|
public ILauncherChildProcess Create(LauncherProcessSpec spec) =>
|
||||||
new SystemChildProcess(spec);
|
OperatingSystem.IsWindows() && spec.SupportsConsoleGracefulStop
|
||||||
|
? new WindowsSystemChildProcess(spec)
|
||||||
|
: new SystemChildProcess(spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed partial class SystemChildProcess : ILauncherChildProcess
|
internal sealed partial class SystemChildProcess : ILauncherChildProcess
|
||||||
|
|
@ -86,11 +87,13 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
|
||||||
private static partial int kill(int pid, int sig);
|
private static partial int kill(int pid, int sig);
|
||||||
|
|
||||||
private readonly Process _process;
|
private readonly Process _process;
|
||||||
|
private readonly bool _supportsConsoleGracefulStop;
|
||||||
private bool _raisingEnabled;
|
private bool _raisingEnabled;
|
||||||
|
|
||||||
internal SystemChildProcess(LauncherProcessSpec spec)
|
internal SystemChildProcess(LauncherProcessSpec spec)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(spec);
|
ArgumentNullException.ThrowIfNull(spec);
|
||||||
|
_supportsConsoleGracefulStop = spec.SupportsConsoleGracefulStop;
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
var startInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
|
|
@ -130,11 +133,11 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
|
||||||
|
|
||||||
public bool TryRequestGracefulStop()
|
public bool TryRequestGracefulStop()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsLinux())
|
if (!OperatingSystem.IsLinux() || !_supportsConsoleGracefulStop)
|
||||||
{
|
{
|
||||||
// No reliable cross-console mechanism exists for an
|
// Windows console-capable children use
|
||||||
// arbitrary no-window Windows child process — tracked gap,
|
// WindowsSystemChildProcess. Graphical/non-console children
|
||||||
// see docs/ISSUES.md.
|
// deliberately retain the Process/WM_CLOSE path.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,13 @@ namespace AcDream.Launcher.Core.Launching;
|
||||||
/// Deliberately carries no credential field — the password is a separate
|
/// Deliberately carries no credential field — the password is a separate
|
||||||
/// transient parameter to <see cref="LauncherProcessSupervisor.Start"/>
|
/// transient parameter to <see cref="LauncherProcessSupervisor.Start"/>
|
||||||
/// that flows only to the child's stdin, never into this spec, an
|
/// that flows only to the child's stdin, never into this spec, an
|
||||||
/// argument list, or a process environment.
|
/// argument list, or a process environment. Console-capable specs set
|
||||||
|
/// <paramref name="SupportsConsoleGracefulStop"/> so Windows starts them
|
||||||
|
/// as isolated process-group leaders for targeted CTRL_BREAK_EVENT and
|
||||||
|
/// Linux sends SIGINT; graphical specs leave it false and use WM_CLOSE.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record LauncherProcessSpec(
|
public sealed record LauncherProcessSpec(
|
||||||
string ExecutablePath,
|
string ExecutablePath,
|
||||||
IReadOnlyList<string> Arguments,
|
IReadOnlyList<string> Arguments,
|
||||||
string? WorkingDirectory = null);
|
string? WorkingDirectory = null,
|
||||||
|
bool SupportsConsoleGracefulStop = true);
|
||||||
|
|
|
||||||
|
|
@ -171,8 +171,8 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Requests a graceful stop — first
|
/// Requests a graceful stop — first
|
||||||
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/> (SIGINT
|
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/> (SIGINT
|
||||||
/// on Linux; a no-op on Windows today, see
|
/// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console
|
||||||
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/>'s docs),
|
/// children),
|
||||||
/// then <see cref="ILauncherChildProcess.CloseMainWindow"/> — falling
|
/// then <see cref="ILauncherChildProcess.CloseMainWindow"/> — falling
|
||||||
/// back to <see cref="ILauncherChildProcess.Kill"/> if the process has
|
/// back to <see cref="ILauncherChildProcess.Kill"/> if the process has
|
||||||
/// not exited within <paramref name="timeout"/>. A no-op if
|
/// not exited within <paramref name="timeout"/>. A no-op if
|
||||||
|
|
|
||||||
843
src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
Normal file
843
src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
Normal file
|
|
@ -0,0 +1,843 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Launching;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows launcher child created without a shell as a true console process-
|
||||||
|
/// group leader. The native start is deliberately narrow: it exists only
|
||||||
|
/// because <see cref="ProcessStartInfo"/> does not expose
|
||||||
|
/// CREATE_NEW_PROCESS_GROUP while the launcher must retain redirected stdin.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WindowsSystemChildProcess : ILauncherChildProcess
|
||||||
|
{
|
||||||
|
private readonly LauncherProcessSpec _spec;
|
||||||
|
private readonly IWindowsConsoleControl _consoleControl;
|
||||||
|
private Process? _process;
|
||||||
|
private TextWriter? _standardInput;
|
||||||
|
private int _processGroupId;
|
||||||
|
private bool _raisingEnabled;
|
||||||
|
|
||||||
|
internal WindowsSystemChildProcess(
|
||||||
|
LauncherProcessSpec spec,
|
||||||
|
IWindowsConsoleControl? consoleControl = null)
|
||||||
|
{
|
||||||
|
_spec = spec ?? throw new ArgumentNullException(nameof(spec));
|
||||||
|
_consoleControl = consoleControl ?? WindowsConsoleControl.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasExited => RequireProcess().HasExited;
|
||||||
|
|
||||||
|
public int ExitCode => RequireProcess().ExitCode;
|
||||||
|
|
||||||
|
public TextWriter StandardInput => _standardInput
|
||||||
|
?? throw new InvalidOperationException("The child process has not started.");
|
||||||
|
|
||||||
|
public event EventHandler? Exited;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_process is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The child process already started.");
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowsProcessStartResult started = WindowsProcessNative.Start(_spec);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_process = Process.GetProcessById(started.ProcessId);
|
||||||
|
_process.EnableRaisingEvents = true;
|
||||||
|
_process.Exited += OnExited;
|
||||||
|
_raisingEnabled = true;
|
||||||
|
_standardInput = started.TakeStandardInput();
|
||||||
|
_processGroupId = started.ProcessId;
|
||||||
|
started.Resume();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
started.Terminate();
|
||||||
|
_standardInput?.Dispose();
|
||||||
|
_standardInput = null;
|
||||||
|
if (_process is not null)
|
||||||
|
{
|
||||||
|
if (_raisingEnabled)
|
||||||
|
{
|
||||||
|
_process.Exited -= OnExited;
|
||||||
|
}
|
||||||
|
|
||||||
|
_process.Dispose();
|
||||||
|
_process = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
started.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRequestGracefulStop()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_spec.SupportsConsoleGracefulStop
|
||||||
|
|| _process is not { HasExited: false } process
|
||||||
|
|| _processGroupId <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _consoleControl.TrySendBreak(process.Id, _processGroupId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The process may have exited between the state check and the
|
||||||
|
// control request. Graceful-stop attempts never escape Stop().
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CloseMainWindow() => RequireProcess().CloseMainWindow();
|
||||||
|
|
||||||
|
public void Kill() => RequireProcess().Kill(entireProcessTree: true);
|
||||||
|
|
||||||
|
public bool WaitForExit(TimeSpan timeout) => RequireProcess().WaitForExit(timeout);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_standardInput?.Dispose();
|
||||||
|
_standardInput = null;
|
||||||
|
if (_process is not null)
|
||||||
|
{
|
||||||
|
if (_raisingEnabled)
|
||||||
|
{
|
||||||
|
_process.Exited -= OnExited;
|
||||||
|
}
|
||||||
|
|
||||||
|
_process.Dispose();
|
||||||
|
_process = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Process RequireProcess() => _process
|
||||||
|
?? throw new InvalidOperationException("The child process has not started.");
|
||||||
|
|
||||||
|
private void OnExited(object? sender, EventArgs e) =>
|
||||||
|
Exited?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IWindowsConsoleControl
|
||||||
|
{
|
||||||
|
bool TrySendBreak(int childProcessId, int childProcessGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class WindowsConsoleControl : IWindowsConsoleControl
|
||||||
|
{
|
||||||
|
private const uint CtrlBreakEvent = 1;
|
||||||
|
|
||||||
|
internal static WindowsConsoleControl Instance { get; } = new();
|
||||||
|
|
||||||
|
private WindowsConsoleControl()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TrySendBreak(int childProcessId, int childProcessGroupId)
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()
|
||||||
|
|| childProcessId <= 0
|
||||||
|
|| childProcessGroupId <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (WindowsConsoleSynchronization.Gate)
|
||||||
|
{
|
||||||
|
bool attachedHere = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
uint[] processes = new uint[1];
|
||||||
|
if (Native.GetConsoleProcessList(processes, 1) == 0)
|
||||||
|
{
|
||||||
|
if (!Native.AttachConsole((uint)childProcessId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachedHere = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Native.GenerateConsoleCtrlEvent(
|
||||||
|
CtrlBreakEvent,
|
||||||
|
(uint)childProcessGroupId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (attachedHere)
|
||||||
|
{
|
||||||
|
_ = Native.FreeConsole();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class Native
|
||||||
|
{
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
internal static extern bool AttachConsole(uint processId);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
internal static extern bool FreeConsole();
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
internal static extern bool GenerateConsoleCtrlEvent(
|
||||||
|
uint controlEvent,
|
||||||
|
uint processGroupId);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static extern uint GetConsoleProcessList(
|
||||||
|
[Out] uint[] processList,
|
||||||
|
uint processCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A process can be attached to only one console. Child creation and targeted
|
||||||
|
/// control-event attachment therefore share one process-wide gate.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WindowsConsoleSynchronization
|
||||||
|
{
|
||||||
|
internal static object Gate { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class WindowsProcessStartResult : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SafeKernelHandle _processHandle;
|
||||||
|
private readonly SafeKernelHandle _threadHandle;
|
||||||
|
private SafeFileHandle? _standardInput;
|
||||||
|
private bool _resumed;
|
||||||
|
|
||||||
|
internal WindowsProcessStartResult(
|
||||||
|
int processId,
|
||||||
|
SafeKernelHandle processHandle,
|
||||||
|
SafeKernelHandle threadHandle,
|
||||||
|
SafeFileHandle standardInput)
|
||||||
|
{
|
||||||
|
ProcessId = processId;
|
||||||
|
_processHandle = processHandle;
|
||||||
|
_threadHandle = threadHandle;
|
||||||
|
_standardInput = standardInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal int ProcessId { get; }
|
||||||
|
|
||||||
|
internal TextWriter TakeStandardInput()
|
||||||
|
{
|
||||||
|
SafeFileHandle handle = _standardInput
|
||||||
|
?? throw new InvalidOperationException("Standard input was already claimed.");
|
||||||
|
var stream = new FileStream(handle, FileAccess.Write, 4096, isAsync: false);
|
||||||
|
_standardInput = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new StreamWriter(
|
||||||
|
stream,
|
||||||
|
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
|
||||||
|
{
|
||||||
|
AutoFlush = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
stream.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Resume()
|
||||||
|
{
|
||||||
|
if (WindowsProcessNative.ResumeThread(_threadHandle) == uint.MaxValue)
|
||||||
|
{
|
||||||
|
throw new Win32Exception(Marshal.GetLastWin32Error(),
|
||||||
|
"The Windows launcher child could not be resumed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_resumed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Terminate()
|
||||||
|
{
|
||||||
|
if (!_processHandle.IsInvalid)
|
||||||
|
{
|
||||||
|
_ = WindowsProcessNative.TerminateProcess(_processHandle, 74);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_resumed)
|
||||||
|
{
|
||||||
|
Terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_standardInput?.Dispose();
|
||||||
|
_threadHandle.Dispose();
|
||||||
|
_processHandle.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class WindowsProcessNative
|
||||||
|
{
|
||||||
|
private const uint CreateSuspended = 0x00000004;
|
||||||
|
private const uint CreateNewProcessGroup = 0x00000200;
|
||||||
|
private const uint ExtendedStartupInfoPresent = 0x00080000;
|
||||||
|
private const uint StartfUseStdHandles = 0x00000100;
|
||||||
|
private const short SwHide = 0;
|
||||||
|
private const uint HandleFlagInherit = 0x00000001;
|
||||||
|
private const uint DuplicateSameAccess = 0x00000002;
|
||||||
|
private const uint GenericWrite = 0x40000000;
|
||||||
|
private const uint FileShareRead = 0x00000001;
|
||||||
|
private const uint FileShareWrite = 0x00000002;
|
||||||
|
private const uint OpenExisting = 3;
|
||||||
|
private const uint FileAttributeNormal = 0x00000080;
|
||||||
|
private const int StdOutputHandle = -11;
|
||||||
|
private const int StdErrorHandle = -12;
|
||||||
|
private static readonly IntPtr ProcThreadAttributeHandleList = new(0x00020002);
|
||||||
|
|
||||||
|
internal static WindowsProcessStartResult Start(LauncherProcessSpec spec)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(spec.ExecutablePath);
|
||||||
|
ArgumentNullException.ThrowIfNull(spec.Arguments);
|
||||||
|
|
||||||
|
lock (WindowsConsoleSynchronization.Gate)
|
||||||
|
{
|
||||||
|
bool allocatedConsole = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// An Avalonia launcher started from Explorer has no console.
|
||||||
|
// CREATE_NEW_PROCESS_GROUP alone does not allocate one, and a
|
||||||
|
// console-less group cannot receive GenerateConsoleCtrlEvent.
|
||||||
|
// Allocate one only for the creation transaction, hide it,
|
||||||
|
// let the group leader inherit it, then detach the launcher.
|
||||||
|
// Each such child consequently owns a distinct console as
|
||||||
|
// well as a distinct process group.
|
||||||
|
if (!HasConsole())
|
||||||
|
{
|
||||||
|
if (!AllocConsole())
|
||||||
|
{
|
||||||
|
throw new Win32Exception(Marshal.GetLastWin32Error(),
|
||||||
|
"The Windows launcher could not allocate the child console.");
|
||||||
|
}
|
||||||
|
|
||||||
|
allocatedConsole = true;
|
||||||
|
IntPtr consoleWindow = GetConsoleWindow();
|
||||||
|
if (consoleWindow != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
_ = ShowWindow(consoleWindow, SwHide);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return StartCore(spec);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (allocatedConsole)
|
||||||
|
{
|
||||||
|
_ = FreeConsole();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WindowsProcessStartResult StartCore(LauncherProcessSpec spec)
|
||||||
|
{
|
||||||
|
SafeFileHandle? parentInput = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using SafeFileHandle childInput = CreateChildInputPipe(
|
||||||
|
out SafeFileHandle createdParentInput);
|
||||||
|
parentInput = createdParentInput;
|
||||||
|
using SafeKernelHandle childOutput = DuplicateOrOpenNull(StdOutputHandle);
|
||||||
|
using SafeKernelHandle childError = DuplicateOrOpenNull(StdErrorHandle);
|
||||||
|
using var attributes = new ProcessThreadAttributeList(
|
||||||
|
childInput.DangerousGetHandle(),
|
||||||
|
childOutput.DangerousGetHandle(),
|
||||||
|
childError.DangerousGetHandle());
|
||||||
|
|
||||||
|
var startup = new StartupInfoEx
|
||||||
|
{
|
||||||
|
StartupInfo = new StartupInfo
|
||||||
|
{
|
||||||
|
Size = Marshal.SizeOf<StartupInfoEx>(),
|
||||||
|
Flags = StartfUseStdHandles,
|
||||||
|
StandardInput = childInput.DangerousGetHandle(),
|
||||||
|
StandardOutput = childOutput.DangerousGetHandle(),
|
||||||
|
StandardError = childError.DangerousGetHandle(),
|
||||||
|
},
|
||||||
|
AttributeList = attributes.Pointer,
|
||||||
|
};
|
||||||
|
string executable = ResolveExecutable(spec.ExecutablePath);
|
||||||
|
string commandLineText = BuildCommandLine(executable, spec.Arguments);
|
||||||
|
var commandLine = new StringBuilder(commandLineText, commandLineText.Length + 1);
|
||||||
|
string? workingDirectory = string.IsNullOrWhiteSpace(spec.WorkingDirectory)
|
||||||
|
? null
|
||||||
|
: Path.GetFullPath(spec.WorkingDirectory);
|
||||||
|
|
||||||
|
if (!CreateProcessW(
|
||||||
|
executable,
|
||||||
|
commandLine,
|
||||||
|
IntPtr.Zero,
|
||||||
|
IntPtr.Zero,
|
||||||
|
inheritHandles: true,
|
||||||
|
CreateSuspended | CreateNewProcessGroup | ExtendedStartupInfoPresent,
|
||||||
|
IntPtr.Zero,
|
||||||
|
workingDirectory,
|
||||||
|
ref startup,
|
||||||
|
out ProcessInformation information))
|
||||||
|
{
|
||||||
|
throw new Win32Exception(Marshal.GetLastWin32Error(),
|
||||||
|
"The Windows launcher child could not be created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var processHandle = new SafeKernelHandle(
|
||||||
|
information.Process,
|
||||||
|
ownsHandle: true);
|
||||||
|
var threadHandle = new SafeKernelHandle(
|
||||||
|
information.Thread,
|
||||||
|
ownsHandle: true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = new WindowsProcessStartResult(
|
||||||
|
checked((int)information.ProcessId),
|
||||||
|
processHandle,
|
||||||
|
threadHandle,
|
||||||
|
parentInput);
|
||||||
|
parentInput = null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_ = TerminateProcess(processHandle, 74);
|
||||||
|
threadHandle.Dispose();
|
||||||
|
processHandle.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
parentInput?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasConsole()
|
||||||
|
{
|
||||||
|
uint[] processes = new uint[1];
|
||||||
|
return GetConsoleProcessList(processes, 1) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static uint ResumeThread(SafeKernelHandle thread) =>
|
||||||
|
NativeResumeThread(thread);
|
||||||
|
|
||||||
|
internal static bool TerminateProcess(SafeKernelHandle process, uint exitCode) =>
|
||||||
|
NativeTerminateProcess(process, exitCode);
|
||||||
|
|
||||||
|
internal static string BuildCommandLine(
|
||||||
|
string executable,
|
||||||
|
IReadOnlyList<string> arguments)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
AppendQuotedArgument(builder, executable);
|
||||||
|
foreach (string argument in arguments)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(argument);
|
||||||
|
builder.Append(' ');
|
||||||
|
AppendQuotedArgument(builder, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendQuotedArgument(StringBuilder builder, string value)
|
||||||
|
{
|
||||||
|
builder.Append('"');
|
||||||
|
int backslashes = 0;
|
||||||
|
foreach (char character in value)
|
||||||
|
{
|
||||||
|
if (character == '\\')
|
||||||
|
{
|
||||||
|
backslashes++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character == '"')
|
||||||
|
{
|
||||||
|
builder.Append('\\', backslashes * 2 + 1);
|
||||||
|
builder.Append('"');
|
||||||
|
backslashes = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('\\', backslashes);
|
||||||
|
backslashes = 0;
|
||||||
|
builder.Append(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('\\', backslashes * 2);
|
||||||
|
builder.Append('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SafeFileHandle CreateChildInputPipe(out SafeFileHandle parentInput)
|
||||||
|
{
|
||||||
|
var security = new SecurityAttributes
|
||||||
|
{
|
||||||
|
Length = Marshal.SizeOf<SecurityAttributes>(),
|
||||||
|
InheritHandle = true,
|
||||||
|
};
|
||||||
|
if (!CreatePipe(out IntPtr read, out IntPtr write, ref security, 0))
|
||||||
|
{
|
||||||
|
throw new Win32Exception(Marshal.GetLastWin32Error(),
|
||||||
|
"The launcher child stdin pipe could not be created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var child = new SafeFileHandle(read, ownsHandle: true);
|
||||||
|
parentInput = new SafeFileHandle(write, ownsHandle: true);
|
||||||
|
if (!SetHandleInformation(
|
||||||
|
parentInput,
|
||||||
|
HandleFlagInherit,
|
||||||
|
0))
|
||||||
|
{
|
||||||
|
int error = Marshal.GetLastWin32Error();
|
||||||
|
child.Dispose();
|
||||||
|
parentInput.Dispose();
|
||||||
|
throw new Win32Exception(error,
|
||||||
|
"The launcher child stdin pipe could not be isolated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SafeKernelHandle DuplicateOrOpenNull(int standardHandle)
|
||||||
|
{
|
||||||
|
IntPtr source = GetStdHandle(standardHandle);
|
||||||
|
if (source != IntPtr.Zero && source != new IntPtr(-1))
|
||||||
|
{
|
||||||
|
IntPtr current = GetCurrentProcess();
|
||||||
|
if (DuplicateHandle(
|
||||||
|
current,
|
||||||
|
source,
|
||||||
|
current,
|
||||||
|
out IntPtr duplicate,
|
||||||
|
0,
|
||||||
|
inheritHandle: true,
|
||||||
|
DuplicateSameAccess))
|
||||||
|
{
|
||||||
|
return new SafeKernelHandle(duplicate, ownsHandle: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IntPtr nul = CreateFileW(
|
||||||
|
"NUL",
|
||||||
|
GenericWrite,
|
||||||
|
FileShareRead | FileShareWrite,
|
||||||
|
IntPtr.Zero,
|
||||||
|
OpenExisting,
|
||||||
|
FileAttributeNormal,
|
||||||
|
IntPtr.Zero);
|
||||||
|
if (nul == IntPtr.Zero || nul == new IntPtr(-1))
|
||||||
|
{
|
||||||
|
throw new Win32Exception(Marshal.GetLastWin32Error(),
|
||||||
|
"The launcher child fallback output handle could not be opened.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var handle = new SafeKernelHandle(nul, ownsHandle: true);
|
||||||
|
if (!SetHandleInformation(handle, HandleFlagInherit, HandleFlagInherit))
|
||||||
|
{
|
||||||
|
int error = Marshal.GetLastWin32Error();
|
||||||
|
handle.Dispose();
|
||||||
|
throw new Win32Exception(error,
|
||||||
|
"The launcher child fallback output handle could not be inherited.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveExecutable(string executable)
|
||||||
|
{
|
||||||
|
if (Path.IsPathFullyQualified(executable))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(executable);
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = new StringBuilder(32_768);
|
||||||
|
uint length = SearchPathW(
|
||||||
|
null,
|
||||||
|
executable,
|
||||||
|
null,
|
||||||
|
(uint)buffer.Capacity,
|
||||||
|
buffer,
|
||||||
|
IntPtr.Zero);
|
||||||
|
if (length == 0 || length >= buffer.Capacity)
|
||||||
|
{
|
||||||
|
throw new Win32Exception(Marshal.GetLastWin32Error(),
|
||||||
|
$"Launcher child executable '{executable}' was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(buffer.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProcessThreadAttributeList : IDisposable
|
||||||
|
{
|
||||||
|
private IntPtr _pointer;
|
||||||
|
private IntPtr _handles;
|
||||||
|
private bool _initialized;
|
||||||
|
|
||||||
|
internal ProcessThreadAttributeList(params IntPtr[] handles)
|
||||||
|
{
|
||||||
|
nuint size = 0;
|
||||||
|
_ = InitializeProcThreadAttributeList(
|
||||||
|
IntPtr.Zero,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
ref size);
|
||||||
|
_pointer = Marshal.AllocHGlobal(checked((nint)size));
|
||||||
|
if (!InitializeProcThreadAttributeList(_pointer, 1, 0, ref size))
|
||||||
|
{
|
||||||
|
int error = Marshal.GetLastWin32Error();
|
||||||
|
Dispose();
|
||||||
|
throw new Win32Exception(error,
|
||||||
|
"The launcher child handle list could not be initialized.");
|
||||||
|
}
|
||||||
|
_initialized = true;
|
||||||
|
|
||||||
|
_handles = Marshal.AllocHGlobal(handles.Length * IntPtr.Size);
|
||||||
|
for (int index = 0; index < handles.Length; index++)
|
||||||
|
{
|
||||||
|
Marshal.WriteIntPtr(_handles, index * IntPtr.Size, handles[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!UpdateProcThreadAttribute(
|
||||||
|
_pointer,
|
||||||
|
0,
|
||||||
|
ProcThreadAttributeHandleList,
|
||||||
|
_handles,
|
||||||
|
checked((nuint)(handles.Length * IntPtr.Size)),
|
||||||
|
IntPtr.Zero,
|
||||||
|
IntPtr.Zero))
|
||||||
|
{
|
||||||
|
int error = Marshal.GetLastWin32Error();
|
||||||
|
Dispose();
|
||||||
|
throw new Win32Exception(error,
|
||||||
|
"The launcher child inherited-handle list could not be set.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IntPtr Pointer => _pointer;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_pointer != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
if (_initialized)
|
||||||
|
{
|
||||||
|
DeleteProcThreadAttributeList(_pointer);
|
||||||
|
_initialized = false;
|
||||||
|
}
|
||||||
|
Marshal.FreeHGlobal(_pointer);
|
||||||
|
_pointer = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_handles != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(_handles);
|
||||||
|
_handles = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct SecurityAttributes
|
||||||
|
{
|
||||||
|
internal int Length;
|
||||||
|
internal IntPtr SecurityDescriptor;
|
||||||
|
[MarshalAs(UnmanagedType.Bool)] internal bool InheritHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct StartupInfo
|
||||||
|
{
|
||||||
|
internal int Size;
|
||||||
|
internal string? Reserved;
|
||||||
|
internal string? Desktop;
|
||||||
|
internal string? Title;
|
||||||
|
internal int X;
|
||||||
|
internal int Y;
|
||||||
|
internal int XSize;
|
||||||
|
internal int YSize;
|
||||||
|
internal int XCountChars;
|
||||||
|
internal int YCountChars;
|
||||||
|
internal int FillAttribute;
|
||||||
|
internal uint Flags;
|
||||||
|
internal short ShowWindow;
|
||||||
|
internal short Reserved2Size;
|
||||||
|
internal IntPtr Reserved2;
|
||||||
|
internal IntPtr StandardInput;
|
||||||
|
internal IntPtr StandardOutput;
|
||||||
|
internal IntPtr StandardError;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct StartupInfoEx
|
||||||
|
{
|
||||||
|
internal StartupInfo StartupInfo;
|
||||||
|
internal IntPtr AttributeList;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct ProcessInformation
|
||||||
|
{
|
||||||
|
internal IntPtr Process;
|
||||||
|
internal IntPtr Thread;
|
||||||
|
internal uint ProcessId;
|
||||||
|
internal uint ThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool CreateProcessW(
|
||||||
|
string applicationName,
|
||||||
|
StringBuilder commandLine,
|
||||||
|
IntPtr processAttributes,
|
||||||
|
IntPtr threadAttributes,
|
||||||
|
[MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
|
||||||
|
uint creationFlags,
|
||||||
|
IntPtr environment,
|
||||||
|
string? currentDirectory,
|
||||||
|
ref StartupInfoEx startupInfo,
|
||||||
|
out ProcessInformation processInformation);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool CreatePipe(
|
||||||
|
out IntPtr readPipe,
|
||||||
|
out IntPtr writePipe,
|
||||||
|
ref SecurityAttributes pipeAttributes,
|
||||||
|
uint size);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool SetHandleInformation(
|
||||||
|
SafeHandle handle,
|
||||||
|
uint mask,
|
||||||
|
uint flags);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool AllocConsole();
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool FreeConsole();
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern uint GetConsoleProcessList(
|
||||||
|
[Out] uint[] processList,
|
||||||
|
uint processCount);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
private static extern IntPtr GetConsoleWindow();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool ShowWindow(IntPtr window, int commandShow);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern IntPtr GetStdHandle(int standardHandle);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
private static extern IntPtr GetCurrentProcess();
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool DuplicateHandle(
|
||||||
|
IntPtr sourceProcess,
|
||||||
|
IntPtr sourceHandle,
|
||||||
|
IntPtr targetProcess,
|
||||||
|
out IntPtr targetHandle,
|
||||||
|
uint desiredAccess,
|
||||||
|
[MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
|
||||||
|
uint options);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern IntPtr CreateFileW(
|
||||||
|
string fileName,
|
||||||
|
uint desiredAccess,
|
||||||
|
uint shareMode,
|
||||||
|
IntPtr securityAttributes,
|
||||||
|
uint creationDisposition,
|
||||||
|
uint flagsAndAttributes,
|
||||||
|
IntPtr templateFile);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern uint SearchPathW(
|
||||||
|
string? path,
|
||||||
|
string fileName,
|
||||||
|
string? extension,
|
||||||
|
uint bufferLength,
|
||||||
|
StringBuilder buffer,
|
||||||
|
IntPtr filePart);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool InitializeProcThreadAttributeList(
|
||||||
|
IntPtr attributeList,
|
||||||
|
int attributeCount,
|
||||||
|
int flags,
|
||||||
|
ref nuint size);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool UpdateProcThreadAttribute(
|
||||||
|
IntPtr attributeList,
|
||||||
|
uint flags,
|
||||||
|
IntPtr attribute,
|
||||||
|
IntPtr value,
|
||||||
|
nuint size,
|
||||||
|
IntPtr previousValue,
|
||||||
|
IntPtr returnSize);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
private static extern void DeleteProcThreadAttributeList(IntPtr attributeList);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", EntryPoint = "ResumeThread", SetLastError = true)]
|
||||||
|
private static extern uint NativeResumeThread(SafeKernelHandle thread);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", EntryPoint = "TerminateProcess", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool NativeTerminateProcess(
|
||||||
|
SafeKernelHandle process,
|
||||||
|
uint exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SafeKernelHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||||
|
{
|
||||||
|
internal SafeKernelHandle(IntPtr handle, bool ownsHandle)
|
||||||
|
: base(ownsHandle)
|
||||||
|
{
|
||||||
|
SetHandle(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override bool ReleaseHandle() => CloseHandle(handle);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool CloseHandle(IntPtr handle);
|
||||||
|
}
|
||||||
|
|
@ -104,7 +104,8 @@ public sealed class LauncherExecutableSet
|
||||||
: new LauncherProcessSpec(
|
: new LauncherProcessSpec(
|
||||||
paths.GraphicalHostPath,
|
paths.GraphicalHostPath,
|
||||||
["--session-config", configFilePath],
|
["--session-config", configFilePath],
|
||||||
paths.WorkingDirectory);
|
paths.WorkingDirectory,
|
||||||
|
SupportsConsoleGracefulStop: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@
|
||||||
<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. -->
|
||||||
|
|
|
||||||
248
src/AcDream.Launcher/LauncherStartupOptions.cs
Normal file
248
src/AcDream.Launcher/LauncherStartupOptions.cs
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher;
|
||||||
|
|
||||||
|
internal enum LauncherStartupMode
|
||||||
|
{
|
||||||
|
Desktop,
|
||||||
|
VerifyPublish,
|
||||||
|
SelfUpdateHelper,
|
||||||
|
SelfUpdateConfirmation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable, process-local launcher inputs. Parsing happens before any
|
||||||
|
/// launcher owner is constructed so every owner receives the same exact path
|
||||||
|
/// set and the test-feed URI can reach only the updater composition.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class LauncherStartupOptions
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<string> _publicArguments;
|
||||||
|
|
||||||
|
private LauncherStartupOptions(
|
||||||
|
LauncherStartupMode mode,
|
||||||
|
ApplicationPathSet paths,
|
||||||
|
Uri updateManifestUri,
|
||||||
|
IReadOnlyList<string> publicArguments)
|
||||||
|
{
|
||||||
|
Mode = mode;
|
||||||
|
Paths = paths;
|
||||||
|
UpdateManifestUri = updateManifestUri;
|
||||||
|
_publicArguments = Array.AsReadOnly(publicArguments.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal LauncherStartupMode Mode { get; }
|
||||||
|
|
||||||
|
internal ApplicationPathSet Paths { get; }
|
||||||
|
|
||||||
|
internal Uri UpdateManifestUri { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The validated public option suffix. LA10 passes this suffix through its
|
||||||
|
/// helper and confirmation processes so an isolated self-update cannot
|
||||||
|
/// fall back to canonical user roots or the production feed.
|
||||||
|
/// </summary>
|
||||||
|
internal IReadOnlyList<string> PublicArguments => _publicArguments;
|
||||||
|
|
||||||
|
internal static LauncherStartupOptions Parse(
|
||||||
|
IReadOnlyList<string> arguments,
|
||||||
|
Func<ApplicationPathSet>? resolveDefaultPaths = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(arguments);
|
||||||
|
resolveDefaultPaths ??= () => ApplicationPathSet.Resolve();
|
||||||
|
|
||||||
|
(LauncherStartupMode mode, int publicStart) = ReadMode(arguments);
|
||||||
|
string[] publicArguments = arguments.Skip(publicStart).ToArray();
|
||||||
|
|
||||||
|
if (publicArguments.Contains("--verify-publish", StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (mode != LauncherStartupMode.Desktop
|
||||||
|
|| publicArguments.Length != 1
|
||||||
|
|| !string.Equals(
|
||||||
|
publicArguments[0],
|
||||||
|
"--verify-publish",
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
"--verify-publish must be the only launcher argument.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LauncherStartupOptions(
|
||||||
|
LauncherStartupMode.VerifyPublish,
|
||||||
|
// The publish probe returns before this value is observed. A
|
||||||
|
// non-resolving sentinel keeps the probe display- and
|
||||||
|
// user-profile-free even under a deliberately broken runtime.
|
||||||
|
new ApplicationPathSet(string.Empty, string.Empty, string.Empty, null),
|
||||||
|
ReleaseManifestClient.ProductionManifestUri,
|
||||||
|
publicArguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
string? configDirectory = null;
|
||||||
|
string? dataDirectory = null;
|
||||||
|
string? cacheDirectory = null;
|
||||||
|
Uri? updateManifestUri = null;
|
||||||
|
|
||||||
|
for (int index = 0; index < publicArguments.Length; index += 2)
|
||||||
|
{
|
||||||
|
string name = publicArguments[index];
|
||||||
|
if (index + 1 >= publicArguments.Length
|
||||||
|
|| publicArguments[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
$"Launcher option '{name}' requires a value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string value = publicArguments[index + 1];
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
$"Launcher option '{name}' requires a non-empty value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (name)
|
||||||
|
{
|
||||||
|
case "--config-dir":
|
||||||
|
SetDirectoryOnce(ref configDirectory, value, name);
|
||||||
|
break;
|
||||||
|
case "--data-dir":
|
||||||
|
SetDirectoryOnce(ref dataDirectory, value, name);
|
||||||
|
break;
|
||||||
|
case "--cache-dir":
|
||||||
|
SetDirectoryOnce(ref cacheDirectory, value, name);
|
||||||
|
break;
|
||||||
|
case "--update-manifest-uri":
|
||||||
|
if (updateManifestUri is not null)
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
"Launcher options cannot be repeated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed))
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
"--update-manifest-uri must be an absolute URI.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.Scheme != Uri.UriSchemeHttps
|
||||||
|
&& !(parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback))
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
"The update manifest URI must use HTTPS "
|
||||||
|
+ "(loopback HTTP is test-only).");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(parsed.UserInfo))
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
"The update manifest URI cannot contain user information.");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateManifestUri = parsed;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
$"Unknown launcher option '{name}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int suppliedRoots = new[] { configDirectory, dataDirectory, cacheDirectory }
|
||||||
|
.Count(path => path is not null);
|
||||||
|
if (suppliedRoots is > 0 and < 3)
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
"--config-dir, --data-dir, and --cache-dir must be supplied together.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationPathSet paths = suppliedRoots == 3
|
||||||
|
? new ApplicationPathSet(
|
||||||
|
configDirectory!,
|
||||||
|
dataDirectory!,
|
||||||
|
cacheDirectory!,
|
||||||
|
LegacyConfigDirectory: null)
|
||||||
|
: resolveDefaultPaths();
|
||||||
|
return new LauncherStartupOptions(
|
||||||
|
mode,
|
||||||
|
paths,
|
||||||
|
updateManifestUri ?? ReleaseManifestClient.ProductionManifestUri,
|
||||||
|
publicArguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (LauncherStartupMode Mode, int PublicStart) ReadMode(
|
||||||
|
IReadOnlyList<string> arguments)
|
||||||
|
{
|
||||||
|
if (arguments.Count == 0)
|
||||||
|
{
|
||||||
|
return (LauncherStartupMode.Desktop, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(
|
||||||
|
arguments[0],
|
||||||
|
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
// Malformed internal invocations are rejected by the bootstrap
|
||||||
|
// with EX_USAGE. Do not reinterpret their operands as public
|
||||||
|
// options while resolving the manager they need to report that.
|
||||||
|
return (
|
||||||
|
LauncherStartupMode.SelfUpdateHelper,
|
||||||
|
arguments.Count >= 4 ? 4 : arguments.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(
|
||||||
|
arguments[0],
|
||||||
|
LauncherSelfUpdateBootstrap.ConfirmArgument,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
LauncherStartupMode.SelfUpdateConfirmation,
|
||||||
|
arguments.Count >= 2 ? 2 : arguments.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (LauncherStartupMode.Desktop, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetDirectoryOnce(
|
||||||
|
ref string? destination,
|
||||||
|
string value,
|
||||||
|
string option)
|
||||||
|
{
|
||||||
|
if (destination is not null)
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
"Launcher options cannot be repeated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Path.IsPathFullyQualified(value))
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
$"Launcher option '{option}' must be an absolute path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
destination = Path.TrimEndingDirectorySeparator(Path.GetFullPath(value));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException
|
||||||
|
or IOException
|
||||||
|
or NotSupportedException)
|
||||||
|
{
|
||||||
|
throw new LauncherStartupOptionsException(
|
||||||
|
$"Launcher option '{option}' is not a valid absolute path.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class LauncherStartupOptionsException : Exception
|
||||||
|
{
|
||||||
|
internal LauncherStartupOptionsException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal LauncherStartupOptionsException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
if (args.Length < 4
|
||||||
|
|| args[0] != "wait-for-break"
|
||||||
|
|| string.IsNullOrWhiteSpace(args[1])
|
||||||
|
|| string.IsNullOrWhiteSpace(args[2]))
|
||||||
|
{
|
||||||
|
return 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
string readyPath = Path.GetFullPath(args[1]);
|
||||||
|
string breakPath = Path.GetFullPath(args[2]);
|
||||||
|
string label = args[3];
|
||||||
|
string[] payloadArguments = args.Skip(4).ToArray();
|
||||||
|
using var stopped = new ManualResetEventSlim(false);
|
||||||
|
ConsoleCancelEventHandler handler = (_, eventArgs) =>
|
||||||
|
{
|
||||||
|
if (eventArgs.SpecialKey != ConsoleSpecialKey.ControlBreak)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventArgs.Cancel = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(breakPath, label);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stopped.Set();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Console.CancelKeyPress += handler;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string stdin = Console.In.ReadToEnd();
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(readyPath)!);
|
||||||
|
File.WriteAllText(
|
||||||
|
readyPath,
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
processId = Environment.ProcessId,
|
||||||
|
label,
|
||||||
|
arguments = payloadArguments,
|
||||||
|
stdinLength = stdin.Length,
|
||||||
|
stdinLineCount = stdin.Count(character => character == '\n'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return stopped.Wait(TimeSpan.FromSeconds(30)) ? 0 : 75;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.CancelKeyPress -= handler;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using AcDream.Launcher.Core.Launching;
|
||||||
|
|
||||||
|
if (args.Length != 3)
|
||||||
|
{
|
||||||
|
return 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
string resultPath = Path.GetFullPath(args[0]);
|
||||||
|
string dotnetPath = args[1];
|
||||||
|
string childAssembly = Path.GetFullPath(args[2]);
|
||||||
|
string root = Path.Combine(
|
||||||
|
Path.GetDirectoryName(resultPath)!,
|
||||||
|
"consoleless-children");
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
string targetReady = Path.Combine(root, "target.ready.json");
|
||||||
|
string targetBreak = Path.Combine(root, "target.break");
|
||||||
|
string siblingReady = Path.Combine(root, "sibling.ready.json");
|
||||||
|
string siblingBreak = Path.Combine(root, "sibling.break");
|
||||||
|
|
||||||
|
bool parentHadConsoleBefore = HasConsole();
|
||||||
|
using var target = new LauncherProcessSupervisor();
|
||||||
|
using var sibling = new LauncherProcessSupervisor();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
target.Start(
|
||||||
|
Spec(dotnetPath, childAssembly, targetReady, targetBreak, "target"),
|
||||||
|
"stdin-from-consoleless-parent");
|
||||||
|
sibling.Start(
|
||||||
|
Spec(dotnetPath, childAssembly, siblingReady, siblingBreak, "sibling"),
|
||||||
|
password: null);
|
||||||
|
WaitForFile(targetReady, target);
|
||||||
|
WaitForFile(siblingReady, sibling);
|
||||||
|
bool parentHadConsoleAfterStarts = HasConsole();
|
||||||
|
|
||||||
|
target.Stop(TimeSpan.FromSeconds(10));
|
||||||
|
bool siblingUnaffected = sibling.State != LauncherSessionState.Exited
|
||||||
|
&& !File.Exists(siblingBreak);
|
||||||
|
sibling.Stop(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
|
||||||
|
File.WriteAllText(
|
||||||
|
resultPath,
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
parentHadConsoleBefore,
|
||||||
|
parentHadConsoleAfterStarts,
|
||||||
|
targetExitCode = target.ExitCode,
|
||||||
|
siblingExitCode = sibling.ExitCode,
|
||||||
|
targetBreakObserved = File.Exists(targetBreak),
|
||||||
|
siblingBreakObserved = File.Exists(siblingBreak),
|
||||||
|
siblingUnaffected,
|
||||||
|
}));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
|
||||||
|
File.WriteAllText(
|
||||||
|
resultPath,
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
parentHadConsoleBefore,
|
||||||
|
error = error.GetType().Name + ": " + error.Message,
|
||||||
|
}));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ForceStop(target);
|
||||||
|
ForceStop(sibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LauncherProcessSpec Spec(
|
||||||
|
string dotnetPath,
|
||||||
|
string childAssembly,
|
||||||
|
string ready,
|
||||||
|
string breakMarker,
|
||||||
|
string label) =>
|
||||||
|
new(
|
||||||
|
dotnetPath,
|
||||||
|
[
|
||||||
|
childAssembly,
|
||||||
|
"wait-for-break",
|
||||||
|
ready,
|
||||||
|
breakMarker,
|
||||||
|
label,
|
||||||
|
"argument with spaces",
|
||||||
|
]);
|
||||||
|
|
||||||
|
static void WaitForFile(string path, LauncherProcessSupervisor supervisor)
|
||||||
|
{
|
||||||
|
DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||||
|
while (!File.Exists(path))
|
||||||
|
{
|
||||||
|
if (supervisor.State == LauncherSessionState.Exited)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Child exited early with {supervisor.ExitCode}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.UtcNow >= deadline)
|
||||||
|
{
|
||||||
|
throw new TimeoutException("Child did not become ready.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ForceStop(LauncherProcessSupervisor supervisor)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
supervisor.Stop(TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool HasConsole()
|
||||||
|
{
|
||||||
|
uint[] processes = new uint[1];
|
||||||
|
return Native.GetConsoleProcessList(processes, 1) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static partial class Native
|
||||||
|
{
|
||||||
|
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static partial uint GetConsoleProcessList(
|
||||||
|
[Out] uint[] processList,
|
||||||
|
uint processCount);
|
||||||
|
}
|
||||||
|
|
@ -25,5 +25,17 @@
|
||||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
</ProjectReference>
|
</ProjectReference>
|
||||||
|
<!-- Build ordering only. The Windows CTRL_BREAK gate launches this
|
||||||
|
console fixture in two independent native process groups. -->
|
||||||
|
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild\AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj">
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
|
</ProjectReference>
|
||||||
|
<!-- Build ordering only. This WinExe fixture starts with no console and
|
||||||
|
proves the production Avalonia-parent CTRL_BREAK path. -->
|
||||||
|
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent\AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj">
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
|
</ProjectReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using AcDream.Launcher.Core.Launching;
|
using AcDream.Launcher.Core.Launching;
|
||||||
|
|
||||||
|
|
@ -6,6 +8,27 @@ namespace AcDream.Launcher.Core.Tests.Launching;
|
||||||
|
|
||||||
public sealed class LauncherProcessSupervisorTests
|
public sealed class LauncherProcessSupervisorTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void WindowsFactoryUsesNativeProcessGroupsOnlyForConsoleChildren()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var factory = new SystemChildProcessFactory();
|
||||||
|
using ILauncherChildProcess console = factory.Create(
|
||||||
|
new LauncherProcessSpec("headless.exe", []));
|
||||||
|
using ILauncherChildProcess graphical = factory.Create(
|
||||||
|
new LauncherProcessSpec(
|
||||||
|
"graphical.exe",
|
||||||
|
[],
|
||||||
|
SupportsConsoleGracefulStop: false));
|
||||||
|
|
||||||
|
Assert.IsType<WindowsSystemChildProcess>(console);
|
||||||
|
Assert.IsType<SystemChildProcess>(graphical);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning()
|
public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning()
|
||||||
{
|
{
|
||||||
|
|
@ -197,6 +220,158 @@ public sealed class LauncherProcessSupervisorTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WindowsCtrlBreakStopsOnlyTheTargetProcessGroupWithoutKill()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"acdream-la11-ctrl-break",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
var targetFactory = new RecordingRealChildProcessFactory();
|
||||||
|
var siblingFactory = new RecordingRealChildProcessFactory();
|
||||||
|
using var target = new LauncherProcessSupervisor(targetFactory);
|
||||||
|
using var sibling = new LauncherProcessSupervisor(siblingFactory);
|
||||||
|
string targetReady = Path.Combine(root, "target.ready.json");
|
||||||
|
string targetBreak = Path.Combine(root, "target.break");
|
||||||
|
string siblingReady = Path.Combine(root, "sibling.ready.json");
|
||||||
|
string siblingBreak = Path.Combine(root, "sibling.break");
|
||||||
|
string[] exactArguments =
|
||||||
|
[
|
||||||
|
"plain",
|
||||||
|
"contains spaces",
|
||||||
|
"quoted-\"value",
|
||||||
|
"ends-with-backslash\\",
|
||||||
|
string.Empty,
|
||||||
|
];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
target.Start(
|
||||||
|
ConsoleFixtureSpec(
|
||||||
|
targetReady,
|
||||||
|
targetBreak,
|
||||||
|
"target",
|
||||||
|
exactArguments),
|
||||||
|
"fixture-input");
|
||||||
|
sibling.Start(
|
||||||
|
ConsoleFixtureSpec(
|
||||||
|
siblingReady,
|
||||||
|
siblingBreak,
|
||||||
|
"sibling",
|
||||||
|
["sibling"]),
|
||||||
|
password: null);
|
||||||
|
await WaitForFileAsync(targetReady, targetFactory.LastCreated!);
|
||||||
|
await WaitForFileAsync(siblingReady, siblingFactory.LastCreated!);
|
||||||
|
|
||||||
|
using (JsonDocument ready = JsonDocument.Parse(
|
||||||
|
await File.ReadAllTextAsync(targetReady)))
|
||||||
|
{
|
||||||
|
string[] observed = ready.RootElement
|
||||||
|
.GetProperty("arguments")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(value => value.GetString()!)
|
||||||
|
.ToArray();
|
||||||
|
Assert.Equal(exactArguments, observed);
|
||||||
|
Assert.Equal(
|
||||||
|
"fixture-input\n".Length,
|
||||||
|
ready.RootElement.GetProperty("stdinLength").GetInt32());
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
ready.RootElement.GetProperty("stdinLineCount").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
target.Stop(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
Assert.Equal(0, target.ExitCode);
|
||||||
|
Assert.True(File.Exists(targetBreak),
|
||||||
|
"the target fixture did not observe CTRL_BREAK");
|
||||||
|
Assert.Equal(0, targetFactory.LastCreated!.KillCallCount);
|
||||||
|
Assert.False(siblingFactory.LastCreated!.HasExited);
|
||||||
|
Assert.False(File.Exists(siblingBreak),
|
||||||
|
"CTRL_BREAK spilled into the sibling process group");
|
||||||
|
|
||||||
|
sibling.Stop(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.Equal(0, sibling.ExitCode);
|
||||||
|
Assert.True(File.Exists(siblingBreak));
|
||||||
|
Assert.Equal(0, siblingFactory.LastCreated!.KillCallCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
targetFactory.LastCreated?.ForceCleanup();
|
||||||
|
siblingFactory.LastCreated?.ForceCleanup();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WindowsConsolelessParentStillTargetsDistinctChildProcessGroups()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"acdream-la11-consoleless-parent",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
string resultPath = Path.Combine(root, "result.json");
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = GetConsolelessParentFixturePath(),
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add(resultPath);
|
||||||
|
startInfo.ArgumentList.Add(FindDotnetExecutable());
|
||||||
|
startInfo.ArgumentList.Add(GetConsoleFixturePath());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using Process process = Process.Start(startInfo)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The consoleless supervisor fixture did not start.");
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(40));
|
||||||
|
await process.WaitForExitAsync(timeout.Token);
|
||||||
|
|
||||||
|
Assert.True(File.Exists(resultPath),
|
||||||
|
"the consoleless supervisor fixture did not write its result");
|
||||||
|
using JsonDocument result = JsonDocument.Parse(
|
||||||
|
await File.ReadAllTextAsync(resultPath));
|
||||||
|
Assert.Equal(0, process.ExitCode);
|
||||||
|
Assert.False(result.RootElement.GetProperty("parentHadConsoleBefore").GetBoolean());
|
||||||
|
Assert.False(result.RootElement.GetProperty("parentHadConsoleAfterStarts").GetBoolean());
|
||||||
|
Assert.Equal(0, result.RootElement.GetProperty("targetExitCode").GetInt32());
|
||||||
|
Assert.Equal(0, result.RootElement.GetProperty("siblingExitCode").GetInt32());
|
||||||
|
Assert.True(result.RootElement.GetProperty("targetBreakObserved").GetBoolean());
|
||||||
|
Assert.True(result.RootElement.GetProperty("siblingBreakObserved").GetBoolean());
|
||||||
|
Assert.True(result.RootElement.GetProperty("siblingUnaffected").GetBoolean());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted()
|
public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted()
|
||||||
{
|
{
|
||||||
|
|
@ -371,6 +546,22 @@ public sealed class LauncherProcessSupervisorTests
|
||||||
private static LauncherProcessSpec Spec() =>
|
private static LauncherProcessSpec Spec() =>
|
||||||
new("fake-host", ["--session-config", "session.json"]);
|
new("fake-host", ["--session-config", "session.json"]);
|
||||||
|
|
||||||
|
private static LauncherProcessSpec ConsoleFixtureSpec(
|
||||||
|
string ready,
|
||||||
|
string breakMarker,
|
||||||
|
string label,
|
||||||
|
IReadOnlyList<string> exactArguments) =>
|
||||||
|
new(
|
||||||
|
FindDotnetExecutable(),
|
||||||
|
[
|
||||||
|
GetConsoleFixturePath(),
|
||||||
|
"wait-for-break",
|
||||||
|
ready,
|
||||||
|
breakMarker,
|
||||||
|
label,
|
||||||
|
.. exactArguments,
|
||||||
|
]);
|
||||||
|
|
||||||
private static string FindDotnetExecutable() =>
|
private static string FindDotnetExecutable() =>
|
||||||
// PATH-based resolution: .NET Core's Process.Start searches PATH
|
// PATH-based resolution: .NET Core's Process.Start searches PATH
|
||||||
// for a bare filename when UseShellExecute is false, on both
|
// for a bare filename when UseShellExecute is false, on both
|
||||||
|
|
@ -378,6 +569,133 @@ public sealed class LauncherProcessSupervisorTests
|
||||||
// because this test is itself running under `dotnet test`.
|
// because this test is itself running under `dotnet test`.
|
||||||
OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";
|
OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";
|
||||||
|
|
||||||
|
private static string GetConsoleFixturePath()
|
||||||
|
{
|
||||||
|
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||||
|
.Parent?.Name ?? "Release";
|
||||||
|
return Path.Combine(
|
||||||
|
FindRepositoryRoot(),
|
||||||
|
"tests",
|
||||||
|
"AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild",
|
||||||
|
"bin",
|
||||||
|
configuration,
|
||||||
|
"net10.0",
|
||||||
|
"AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.dll");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetConsolelessParentFixturePath()
|
||||||
|
{
|
||||||
|
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||||
|
.Parent?.Name ?? "Release";
|
||||||
|
return Path.Combine(
|
||||||
|
FindRepositoryRoot(),
|
||||||
|
"tests",
|
||||||
|
"AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent",
|
||||||
|
"bin",
|
||||||
|
configuration,
|
||||||
|
"net10.0",
|
||||||
|
"AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
for (var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
directory is not null;
|
||||||
|
directory = directory.Parent)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||||
|
{
|
||||||
|
return directory.FullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Repository root was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForFileAsync(
|
||||||
|
string path,
|
||||||
|
RecordingChildProcess child)
|
||||||
|
{
|
||||||
|
DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10);
|
||||||
|
while (!File.Exists(path))
|
||||||
|
{
|
||||||
|
if (child.HasExited)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Console fixture exited early with {child.ExitCode}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTimeOffset.UtcNow >= deadline)
|
||||||
|
{
|
||||||
|
throw new TimeoutException("Console fixture did not become ready.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingRealChildProcessFactory : ILauncherChildProcessFactory
|
||||||
|
{
|
||||||
|
private readonly SystemChildProcessFactory _inner = new();
|
||||||
|
|
||||||
|
internal RecordingChildProcess? LastCreated { get; private set; }
|
||||||
|
|
||||||
|
public ILauncherChildProcess Create(LauncherProcessSpec spec)
|
||||||
|
{
|
||||||
|
LastCreated = new RecordingChildProcess(_inner.Create(spec));
|
||||||
|
return LastCreated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingChildProcess(ILauncherChildProcess inner)
|
||||||
|
: ILauncherChildProcess
|
||||||
|
{
|
||||||
|
public int KillCallCount { get; private set; }
|
||||||
|
|
||||||
|
public bool HasExited => inner.HasExited;
|
||||||
|
|
||||||
|
public int ExitCode => inner.ExitCode;
|
||||||
|
|
||||||
|
public TextWriter StandardInput => inner.StandardInput;
|
||||||
|
|
||||||
|
public event EventHandler? Exited
|
||||||
|
{
|
||||||
|
add => inner.Exited += value;
|
||||||
|
remove => inner.Exited -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start() => inner.Start();
|
||||||
|
|
||||||
|
public bool TryRequestGracefulStop() => inner.TryRequestGracefulStop();
|
||||||
|
|
||||||
|
public bool CloseMainWindow() => inner.CloseMainWindow();
|
||||||
|
|
||||||
|
public void Kill()
|
||||||
|
{
|
||||||
|
KillCallCount++;
|
||||||
|
inner.Kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool WaitForExit(TimeSpan timeout) => inner.WaitForExit(timeout);
|
||||||
|
|
||||||
|
public void ForceCleanup()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!HasExited)
|
||||||
|
{
|
||||||
|
inner.Kill();
|
||||||
|
_ = inner.WaitForExit(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => inner.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeChildProcessFactory(
|
private sealed class FakeChildProcessFactory(
|
||||||
bool exitsWithinStopTimeout,
|
bool exitsWithinStopTimeout,
|
||||||
bool exitDuringStart = false,
|
bool exitDuringStart = false,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,16 @@ public sealed class LauncherExecutableSetTests : IDisposable
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
headless,
|
headless,
|
||||||
set.CreateProbeSpec("session.json").ExecutablePath);
|
set.CreateProbeSpec("session.json").ExecutablePath);
|
||||||
|
Assert.False(
|
||||||
|
set.CreatePlaySpec(LaunchMode.Gui, "session.json")
|
||||||
|
.SupportsConsoleGracefulStop);
|
||||||
|
Assert.False(
|
||||||
|
set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json")
|
||||||
|
.SupportsConsoleGracefulStop);
|
||||||
|
Assert.True(
|
||||||
|
set.CreatePlaySpec(LaunchMode.Headless, "session.json")
|
||||||
|
.SupportsConsoleGracefulStop);
|
||||||
|
Assert.True(set.CreateProbeSpec("session.json").SupportsConsoleGracefulStop);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
182
tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
Normal file
182
tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
using AcDream.Launcher.Core.Updates;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Tests;
|
||||||
|
|
||||||
|
public sealed class LauncherStartupOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ExplicitIsolationRootsAreNormalizedAndNeverResolveCanonicalPaths()
|
||||||
|
{
|
||||||
|
string root = Path.Combine(Path.GetTempPath(), "acdream-la11 options", "..", "isolation");
|
||||||
|
string config = Path.Combine(root, "config") + Path.DirectorySeparatorChar;
|
||||||
|
string data = Path.Combine(root, "data", ".", "state");
|
||||||
|
string cache = Path.Combine(root, "cache") + Path.DirectorySeparatorChar;
|
||||||
|
bool defaultResolverCalled = false;
|
||||||
|
|
||||||
|
LauncherStartupOptions options = LauncherStartupOptions.Parse(
|
||||||
|
[
|
||||||
|
"--config-dir", config,
|
||||||
|
"--data-dir", data,
|
||||||
|
"--cache-dir", cache,
|
||||||
|
"--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
|
||||||
|
],
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
defaultResolverCalled = true;
|
||||||
|
throw new InvalidOperationException("canonical path resolver was touched");
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.False(defaultResolverCalled);
|
||||||
|
Assert.Equal(
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(config)),
|
||||||
|
options.Paths.ConfigDirectory);
|
||||||
|
Assert.Equal(
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(data)),
|
||||||
|
options.Paths.DataDirectory);
|
||||||
|
Assert.Equal(
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(cache)),
|
||||||
|
options.Paths.CacheDirectory);
|
||||||
|
Assert.Null(options.Paths.LegacyConfigDirectory);
|
||||||
|
Assert.Equal(
|
||||||
|
"http://127.0.0.1:43119/manifest.json",
|
||||||
|
options.UpdateManifestUri.AbsoluteUri);
|
||||||
|
Assert.Equal(LauncherStartupMode.Desktop, options.Mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoOverridesResolveDefaultsExactlyOnce()
|
||||||
|
{
|
||||||
|
var expected = new ApplicationPathSet("config", "data", "cache", "legacy");
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
LauncherStartupOptions options = LauncherStartupOptions.Parse(
|
||||||
|
[],
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
return expected;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Same(expected, options.Paths);
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.Equal(ReleaseManifestClient.ProductionManifestUri, options.UpdateManifestUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyPublishIsExclusiveAndDoesNotResolvePaths()
|
||||||
|
{
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
LauncherStartupOptions options = LauncherStartupOptions.Parse(
|
||||||
|
["--verify-publish"],
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
throw new InvalidOperationException();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(LauncherStartupMode.VerifyPublish, options.Mode);
|
||||||
|
Assert.Equal(0, calls);
|
||||||
|
Assert.Throws<LauncherStartupOptionsException>(() =>
|
||||||
|
LauncherStartupOptions.Parse(
|
||||||
|
["--verify-publish", "--cache-dir", Path.GetTempPath()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(InvalidArguments))]
|
||||||
|
public void RejectsInvalidPublicArguments(string[] arguments)
|
||||||
|
{
|
||||||
|
Assert.Throws<LauncherStartupOptionsException>(() =>
|
||||||
|
LauncherStartupOptions.Parse(
|
||||||
|
arguments,
|
||||||
|
() => new ApplicationPathSet("c", "d", "x", null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://updates.example.test/manifest.json")]
|
||||||
|
[InlineData("http://localhost:8123/manifest.json")]
|
||||||
|
[InlineData("http://[::1]:8123/manifest.json")]
|
||||||
|
public void AcceptsHttpsAndLoopbackHttpFeeds(string value)
|
||||||
|
{
|
||||||
|
LauncherStartupOptions options = LauncherStartupOptions.Parse(
|
||||||
|
["--update-manifest-uri", value],
|
||||||
|
() => new ApplicationPathSet("c", "d", "x", null));
|
||||||
|
|
||||||
|
Assert.Equal(new Uri(value), options.UpdateManifestUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelfUpdatePrefixesRetainOnlyTheValidatedPublicSuffix()
|
||||||
|
{
|
||||||
|
string root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11-self"));
|
||||||
|
string[] suffix =
|
||||||
|
[
|
||||||
|
"--config-dir", Path.Combine(root, "config"),
|
||||||
|
"--data-dir", Path.Combine(root, "data"),
|
||||||
|
"--cache-dir", Path.Combine(root, "cache"),
|
||||||
|
"--update-manifest-uri", "http://localhost:8123/manifest.json",
|
||||||
|
];
|
||||||
|
string[] helper =
|
||||||
|
[
|
||||||
|
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||||
|
"123",
|
||||||
|
root,
|
||||||
|
"0123456789abcdef0123456789abcdef",
|
||||||
|
.. suffix,
|
||||||
|
];
|
||||||
|
string[] confirmation =
|
||||||
|
[
|
||||||
|
LauncherSelfUpdateBootstrap.ConfirmArgument,
|
||||||
|
"0123456789abcdef0123456789abcdef",
|
||||||
|
.. suffix,
|
||||||
|
];
|
||||||
|
|
||||||
|
LauncherStartupOptions helperOptions = LauncherStartupOptions.Parse(helper);
|
||||||
|
LauncherStartupOptions confirmationOptions =
|
||||||
|
LauncherStartupOptions.Parse(confirmation);
|
||||||
|
|
||||||
|
Assert.Equal(LauncherStartupMode.SelfUpdateHelper, helperOptions.Mode);
|
||||||
|
Assert.Equal(
|
||||||
|
LauncherStartupMode.SelfUpdateConfirmation,
|
||||||
|
confirmationOptions.Mode);
|
||||||
|
Assert.Equal(suffix, helperOptions.PublicArguments);
|
||||||
|
Assert.Equal(suffix, confirmationOptions.PublicArguments);
|
||||||
|
Assert.Equal(helperOptions.Paths, confirmationOptions.Paths);
|
||||||
|
Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<string[]> InvalidArguments()
|
||||||
|
{
|
||||||
|
string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11"));
|
||||||
|
var data = new TheoryData<string[]>();
|
||||||
|
data.Add(["--unknown", "value"]);
|
||||||
|
data.Add(["--config-dir"]);
|
||||||
|
data.Add(["--config-dir", "relative"]);
|
||||||
|
data.Add(["--config-dir", absolute]);
|
||||||
|
data.Add(
|
||||||
|
[
|
||||||
|
"--config-dir", absolute,
|
||||||
|
"--data-dir", absolute,
|
||||||
|
]);
|
||||||
|
data.Add(
|
||||||
|
[
|
||||||
|
"--config-dir", absolute,
|
||||||
|
"--data-dir", absolute,
|
||||||
|
"--cache-dir", absolute,
|
||||||
|
"--cache-dir", absolute,
|
||||||
|
]);
|
||||||
|
data.Add(
|
||||||
|
["--update-manifest-uri", "http://updates.example.test/manifest.json"]);
|
||||||
|
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", "not-a-uri"]);
|
||||||
|
data.Add(
|
||||||
|
[
|
||||||
|
"--update-manifest-uri", "https://example.test/a",
|
||||||
|
"--update-manifest-uri", "https://example.test/b",
|
||||||
|
]);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
333
tools/new-campaign-la-update-fixture.ps1
Normal file
333
tools/new-campaign-la-update-fixture.ps1
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Creates deterministic isolated Campaign LA A/B update feeds.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Packages caller-supplied published client and launcher roots for win-x64
|
||||||
|
and linux-x64, adds a deterministic release marker, calculates the exact
|
||||||
|
LA10 SHA-256/size manifest fields, and emits a loopback-only static server
|
||||||
|
plus a local A/B selector. It never downloads, connects, edits a payload
|
||||||
|
source, or writes outside -OutputDirectory.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$OutputDirectory,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA,
|
||||||
|
[Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA,
|
||||||
|
[Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB,
|
||||||
|
[Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB,
|
||||||
|
[Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB,
|
||||||
|
[string]$VersionA = '1.0.1-la11.a',
|
||||||
|
[string]$VersionB = '1.0.1-la11.b',
|
||||||
|
[string]$MinimumLauncherVersion = '1.0.0',
|
||||||
|
[int]$Port = 43119,
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||||
|
throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.'
|
||||||
|
}
|
||||||
|
if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
|
||||||
|
throw '-OutputDirectory must be absolute.'
|
||||||
|
}
|
||||||
|
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
||||||
|
[IO.Path]::GetFullPath($OutputDirectory))
|
||||||
|
if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' }
|
||||||
|
$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$'
|
||||||
|
if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or
|
||||||
|
$MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) {
|
||||||
|
throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.'
|
||||||
|
}
|
||||||
|
$parsedVersionA = [semver]$VersionA
|
||||||
|
$parsedVersionB = [semver]$VersionB
|
||||||
|
$parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion
|
||||||
|
if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) {
|
||||||
|
throw 'VersionB must be newer than VersionA.'
|
||||||
|
}
|
||||||
|
if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) {
|
||||||
|
throw 'MinimumLauncherVersion must not be newer than VersionA.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$sources = [ordered]@{
|
||||||
|
'A-client-win-x64' = $ClientWinX64DirectoryA
|
||||||
|
'A-launcher-win-x64' = $LauncherWinX64DirectoryA
|
||||||
|
'A-client-linux-x64' = $ClientLinuxX64DirectoryA
|
||||||
|
'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA
|
||||||
|
'B-client-win-x64' = $ClientWinX64DirectoryB
|
||||||
|
'B-launcher-win-x64' = $LauncherWinX64DirectoryB
|
||||||
|
'B-client-linux-x64' = $ClientLinuxX64DirectoryB
|
||||||
|
'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB
|
||||||
|
}
|
||||||
|
foreach ($key in @($sources.Keys)) {
|
||||||
|
$source = [string]$sources[$key]
|
||||||
|
if (-not [IO.Path]::IsPathFullyQualified($source)) {
|
||||||
|
throw "Payload source '$key' must be absolute."
|
||||||
|
}
|
||||||
|
$source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source))
|
||||||
|
$sources[$key] = $source
|
||||||
|
if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) {
|
||||||
|
throw "Payload source '$key' does not exist: $source"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Require-PayloadFile([string]$Key, [string]$Name) {
|
||||||
|
if ($DryRun) { return }
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) {
|
||||||
|
throw "Payload source '$Key' is missing root file '$Name'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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-client-linux-x64" 'AcDream.App'
|
||||||
|
Require-PayloadFile "$release-client-linux-x64" 'acdream-headless'
|
||||||
|
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $OutputDirectory) {
|
||||||
|
if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) {
|
||||||
|
throw '-OutputDirectory must not already contain files.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { $null = New-Item -ItemType Directory -Path $OutputDirectory }
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
$plan = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
kind = 'campaign-la-update-fixture-plan'
|
||||||
|
outputDirectory = $OutputDirectory
|
||||||
|
versions = @($VersionA, $VersionB)
|
||||||
|
minimumLauncherVersion = $MinimumLauncherVersion
|
||||||
|
port = $Port
|
||||||
|
sources = $sources
|
||||||
|
writesOutsideOutputDirectory = $false
|
||||||
|
externalNetwork = $false
|
||||||
|
}
|
||||||
|
$plan | ConvertTo-Json -Depth 5 |
|
||||||
|
Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM
|
||||||
|
Write-Host "Campaign LA update fixture dry run: $OutputDirectory"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.IO.Compression
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
|
||||||
|
|
||||||
|
function New-DeterministicZip(
|
||||||
|
[string]$SourceDirectory,
|
||||||
|
[string]$Destination,
|
||||||
|
[string]$ReleaseLabel,
|
||||||
|
[string]$PayloadKind,
|
||||||
|
[string]$Rid) {
|
||||||
|
$destinationDirectory = Split-Path -Parent $Destination
|
||||||
|
$null = New-Item -ItemType Directory -Force -Path $destinationDirectory
|
||||||
|
$stream = [IO.FileStream]::new(
|
||||||
|
$Destination,
|
||||||
|
[IO.FileMode]::CreateNew,
|
||||||
|
[IO.FileAccess]::ReadWrite,
|
||||||
|
[IO.FileShare]::None)
|
||||||
|
try {
|
||||||
|
$archive = [IO.Compression.ZipArchive]::new(
|
||||||
|
$stream,
|
||||||
|
[IO.Compression.ZipArchiveMode]::Create,
|
||||||
|
$true,
|
||||||
|
[Text.Encoding]::UTF8)
|
||||||
|
try {
|
||||||
|
$files = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse |
|
||||||
|
Sort-Object { [IO.Path]::GetRelativePath($SourceDirectory, $_.FullName).Replace('\', '/') })
|
||||||
|
foreach ($file in $files) {
|
||||||
|
if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||||
|
throw "Payload contains a reparse point: $($file.FullName)"
|
||||||
|
}
|
||||||
|
$relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/')
|
||||||
|
if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or
|
||||||
|
[IO.Path]::IsPathRooted($relative)) {
|
||||||
|
throw "Payload path escaped its root: $relative"
|
||||||
|
}
|
||||||
|
$entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal)
|
||||||
|
$entry.LastWriteTime = $fixedTimestamp
|
||||||
|
$executable = $relative -ceq 'AcDream.App' -or
|
||||||
|
$relative -ceq 'acdream-headless' -or
|
||||||
|
$relative -ceq 'acdream-launcher' -or
|
||||||
|
$relative.EndsWith('.sh', [StringComparison]::Ordinal)
|
||||||
|
$mode = if ($executable) { 0x81ED } else { 0x81A4 }
|
||||||
|
$entry.ExternalAttributes = $mode -shl 16
|
||||||
|
$input = [IO.File]::OpenRead($file.FullName)
|
||||||
|
$output = $entry.Open()
|
||||||
|
try { $input.CopyTo($output) }
|
||||||
|
finally { $output.Dispose(); $input.Dispose() }
|
||||||
|
}
|
||||||
|
$marker = $archive.CreateEntry(
|
||||||
|
'campaign-la-fixture-release.txt',
|
||||||
|
[IO.Compression.CompressionLevel]::Optimal)
|
||||||
|
$marker.LastWriteTime = $fixedTimestamp
|
||||||
|
$marker.ExternalAttributes = 0x81A4 -shl 16
|
||||||
|
$writer = [IO.StreamWriter]::new(
|
||||||
|
$marker.Open(),
|
||||||
|
[Text.UTF8Encoding]::new($false))
|
||||||
|
try {
|
||||||
|
$writer.NewLine = "`n"
|
||||||
|
$writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n")
|
||||||
|
}
|
||||||
|
finally { $writer.Dispose() }
|
||||||
|
}
|
||||||
|
finally { $archive.Dispose() }
|
||||||
|
}
|
||||||
|
finally { $stream.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Artifact([string]$Path, [string]$Url) {
|
||||||
|
$item = Get-Item -LiteralPath $Path
|
||||||
|
return [ordered]@{
|
||||||
|
url = $Url
|
||||||
|
sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
size = $item.Length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseDefinitions = @(
|
||||||
|
[pscustomobject]@{ Label = 'A'; Version = $VersionA },
|
||||||
|
[pscustomobject]@{ Label = 'B'; Version = $VersionB }
|
||||||
|
)
|
||||||
|
foreach ($release in $releaseDefinitions) {
|
||||||
|
$releaseRoot = Join-Path $OutputDirectory $release.Label
|
||||||
|
foreach ($rid in @('win-x64', 'linux-x64')) {
|
||||||
|
New-DeterministicZip `
|
||||||
|
$sources["$($release.Label)-client-$rid"] `
|
||||||
|
(Join-Path $releaseRoot "client-$rid.zip") `
|
||||||
|
$release.Label 'client' $rid
|
||||||
|
New-DeterministicZip `
|
||||||
|
$sources["$($release.Label)-launcher-$rid"] `
|
||||||
|
(Join-Path $releaseRoot "launcher-$rid.zip") `
|
||||||
|
$release.Label 'launcher' $rid
|
||||||
|
}
|
||||||
|
$baseUri = "http://127.0.0.1:$Port/$($release.Label)"
|
||||||
|
$manifest = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
version = $release.Version
|
||||||
|
minimumLauncherVersion = $MinimumLauncherVersion
|
||||||
|
clients = [ordered]@{
|
||||||
|
'win-x64' = Get-Artifact `
|
||||||
|
(Join-Path $releaseRoot 'client-win-x64.zip') `
|
||||||
|
"$baseUri/client-win-x64.zip"
|
||||||
|
'linux-x64' = Get-Artifact `
|
||||||
|
(Join-Path $releaseRoot 'client-linux-x64.zip') `
|
||||||
|
"$baseUri/client-linux-x64.zip"
|
||||||
|
}
|
||||||
|
launchers = [ordered]@{
|
||||||
|
'win-x64' = Get-Artifact `
|
||||||
|
(Join-Path $releaseRoot 'launcher-win-x64.zip') `
|
||||||
|
"$baseUri/launcher-win-x64.zip"
|
||||||
|
'linux-x64' = Get-Artifact `
|
||||||
|
(Join-Path $releaseRoot 'launcher-linux-x64.zip') `
|
||||||
|
"$baseUri/launcher-linux-x64.zip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$manifest | ConvertTo-Json -Depth 8 -Compress |
|
||||||
|
Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM
|
||||||
|
}
|
||||||
|
Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') `
|
||||||
|
-Value 'A' -Encoding ascii -NoNewline
|
||||||
|
|
||||||
|
$server = @'
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Root,
|
||||||
|
[Parameter(Mandatory = $true)][int]$Port
|
||||||
|
)
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
|
||||||
|
$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"
|
||||||
|
try {
|
||||||
|
while ($listener.IsListening) {
|
||||||
|
$context = $listener.GetContext()
|
||||||
|
try {
|
||||||
|
if ($context.Request.HttpMethod -cne 'GET') {
|
||||||
|
$context.Response.StatusCode = 405
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/'))
|
||||||
|
if ($relative -ceq 'manifest.json') {
|
||||||
|
$active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim()
|
||||||
|
if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' }
|
||||||
|
$relative = "$active/manifest.json"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) {
|
||||||
|
$context.Response.StatusCode = 404
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$path = [IO.Path]::GetFullPath((Join-Path $Root $relative))
|
||||||
|
if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or
|
||||||
|
-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||||
|
$context.Response.StatusCode = 404
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) {
|
||||||
|
'application/json'
|
||||||
|
} else { 'application/zip' }
|
||||||
|
$context.Response.StatusCode = 200
|
||||||
|
$context.Response.Headers['Cache-Control'] = 'no-store'
|
||||||
|
$context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length
|
||||||
|
$input = [IO.File]::OpenRead($path)
|
||||||
|
try { $input.CopyTo($context.Response.OutputStream) }
|
||||||
|
finally { $input.Dispose() }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$context.Response.StatusCode = 500
|
||||||
|
Write-Error $_
|
||||||
|
}
|
||||||
|
finally { $context.Response.Close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { $listener.Close() }
|
||||||
|
'@
|
||||||
|
$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM
|
||||||
|
|
||||||
|
$selector = @'
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release,
|
||||||
|
[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
|
||||||
|
Write-Host "Campaign LA fixture active release: $Release"
|
||||||
|
'@
|
||||||
|
$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM
|
||||||
|
|
||||||
|
$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
|
||||||
|
Where-Object { $_.Name -ne 'fixture-report.json' } |
|
||||||
|
Sort-Object FullName |
|
||||||
|
ForEach-Object {
|
||||||
|
[ordered]@{
|
||||||
|
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
|
||||||
|
size = $_.Length
|
||||||
|
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
$report = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
kind = 'campaign-la-update-fixture'
|
||||||
|
versions = [ordered]@{ A = $VersionA; B = $VersionB }
|
||||||
|
minimumLauncherVersion = $MinimumLauncherVersion
|
||||||
|
manifestUri = "http://127.0.0.1:$Port/manifest.json"
|
||||||
|
loopbackOnly = $true
|
||||||
|
initialRelease = 'A'
|
||||||
|
sourceDirectories = $sources
|
||||||
|
artifacts = $inventory
|
||||||
|
}
|
||||||
|
$report | ConvertTo-Json -Depth 8 |
|
||||||
|
Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM
|
||||||
|
Write-Host "Campaign LA update fixture: $OutputDirectory"
|
||||||
394
tools/run-campaign-la-preflight.ps1
Normal file
394
tools/run-campaign-la-preflight.ps1
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Campaign LA11 display-free, connection-free automated preflight.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Runs the exact Release and portability ladder used before the launcher
|
||||||
|
user gate. It never starts App/Headless in connected mode, never opens a
|
||||||
|
window, never reads credentials, and never bakes retail DATs. All logs and
|
||||||
|
publishes are contained beneath one logs/campaign-la-gate-<timestamp>
|
||||||
|
directory. Use -DryRun to emit the complete command matrix without
|
||||||
|
executing it.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
||||||
|
[string]$OutputDirectory,
|
||||||
|
[switch]$DryRun,
|
||||||
|
[switch]$IncludeInstalledDat,
|
||||||
|
[string]$InstalledDatDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||||
|
throw 'Campaign LA preflight requires PowerShell 7 or newer.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$Repository = [IO.Path]::TrimEndingDirectorySeparator(
|
||||||
|
[IO.Path]::GetFullPath($Repository))
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) {
|
||||||
|
throw "Repository does not contain AcDream.slnx: $Repository"
|
||||||
|
}
|
||||||
|
if ($IncludeInstalledDat) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or
|
||||||
|
-not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) {
|
||||||
|
throw '-IncludeInstalledDat requires an absolute -InstalledDatDirectory.'
|
||||||
|
}
|
||||||
|
$InstalledDatDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
||||||
|
[IO.Path]::GetFullPath($InstalledDatDirectory))
|
||||||
|
foreach ($file in @(
|
||||||
|
'client_portal.dat',
|
||||||
|
'client_cell_1.dat',
|
||||||
|
'client_highres.dat',
|
||||||
|
'client_local_English.dat')) {
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $InstalledDatDirectory $file) -PathType Leaf)) {
|
||||||
|
throw "Installed DAT directory is missing $file."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
|
||||||
|
$OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp"
|
||||||
|
}
|
||||||
|
elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
|
||||||
|
$OutputDirectory = Join-Path $Repository $OutputDirectory
|
||||||
|
}
|
||||||
|
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
||||||
|
[IO.Path]::GetFullPath($OutputDirectory))
|
||||||
|
$logsDirectory = Join-Path $OutputDirectory 'commands'
|
||||||
|
$publishDirectory = Join-Path $OutputDirectory 'publish'
|
||||||
|
$null = New-Item -ItemType Directory -Force -Path $logsDirectory
|
||||||
|
|
||||||
|
$commandResults = [Collections.Generic.List[object]]::new()
|
||||||
|
$failures = [Collections.Generic.List[string]]::new()
|
||||||
|
$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)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
|
||||||
|
'$1<redacted>$2')
|
||||||
|
return $protected
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-Command([string]$FilePath, [string[]]$Arguments) {
|
||||||
|
$parts = [Collections.Generic.List[string]]::new()
|
||||||
|
$parts.Add($FilePath)
|
||||||
|
foreach ($argument in $Arguments) {
|
||||||
|
if ($argument -match '[\s"]') {
|
||||||
|
$parts.Add('"' + $argument.Replace('"', '\"') + '"')
|
||||||
|
}
|
||||||
|
else { $parts.Add($argument) }
|
||||||
|
}
|
||||||
|
return $parts -join ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-PlannedCommand(
|
||||||
|
[string]$Name,
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[Collections.IDictionary]$Environment = @{}) {
|
||||||
|
$commandResults.Add([ordered]@{
|
||||||
|
name = $Name
|
||||||
|
command = Format-Command $FilePath $Arguments
|
||||||
|
status = 'planned'
|
||||||
|
startedUtc = $null
|
||||||
|
durationSeconds = 0
|
||||||
|
exitCode = $null
|
||||||
|
stdout = $null
|
||||||
|
stderr = $null
|
||||||
|
environmentKeys = @($Environment.Keys | Sort-Object)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-GateCommand(
|
||||||
|
[string]$Name,
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[Collections.IDictionary]$Environment = @{}) {
|
||||||
|
if ($DryRun) {
|
||||||
|
Add-PlannedCommand $Name $FilePath $Arguments $Environment
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeName = $Name -replace '[^A-Za-z0-9_.-]', '-'
|
||||||
|
$stdoutRelative = "commands/$safeName.out.log"
|
||||||
|
$stderrRelative = "commands/$safeName.err.log"
|
||||||
|
$stdoutPath = Join-Path $OutputDirectory $stdoutRelative
|
||||||
|
$stderrPath = Join-Path $OutputDirectory $stderrRelative
|
||||||
|
$begin = [DateTime]::UtcNow
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$exitCode = 74
|
||||||
|
try {
|
||||||
|
$startInfo = [Diagnostics.ProcessStartInfo]::new()
|
||||||
|
$startInfo.FileName = $FilePath
|
||||||
|
$startInfo.WorkingDirectory = $Repository
|
||||||
|
$startInfo.UseShellExecute = $false
|
||||||
|
$startInfo.CreateNoWindow = $true
|
||||||
|
$startInfo.RedirectStandardOutput = $true
|
||||||
|
$startInfo.RedirectStandardError = $true
|
||||||
|
foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) }
|
||||||
|
foreach ($entry in $Environment.GetEnumerator()) {
|
||||||
|
$startInfo.Environment[[string]$entry.Key] = [string]$entry.Value
|
||||||
|
}
|
||||||
|
$process = [Diagnostics.Process]::new()
|
||||||
|
$process.StartInfo = $startInfo
|
||||||
|
if (-not $process.Start()) { throw "Could not start $FilePath." }
|
||||||
|
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||||
|
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||||
|
$process.WaitForExit()
|
||||||
|
$stdout = $stdoutTask.GetAwaiter().GetResult()
|
||||||
|
$stderr = $stderrTask.GetAwaiter().GetResult()
|
||||||
|
$exitCode = $process.ExitCode
|
||||||
|
$process.Dispose()
|
||||||
|
[IO.File]::WriteAllText($stdoutPath, (Protect-Text $stdout))
|
||||||
|
[IO.File]::WriteAllText($stderrPath, (Protect-Text $stderr))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
[IO.File]::WriteAllText($stderrPath, (Protect-Text ($_ | Out-String)))
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$watch.Stop()
|
||||||
|
$commandResults.Add([ordered]@{
|
||||||
|
name = $Name
|
||||||
|
command = Format-Command $FilePath $Arguments
|
||||||
|
status = if ($exitCode -eq 0) { 'passed' } else { 'failed' }
|
||||||
|
startedUtc = $begin.ToString('O')
|
||||||
|
durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
|
||||||
|
exitCode = $exitCode
|
||||||
|
stdout = $stdoutRelative
|
||||||
|
stderr = $stderrRelative
|
||||||
|
environmentKeys = @($Environment.Keys | Sort-Object)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
throw "Preflight command '$Name' failed with exit code $exitCode."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-InternalCheck([string]$Name, [scriptblock]$Action) {
|
||||||
|
if ($DryRun) {
|
||||||
|
$commandResults.Add([ordered]@{
|
||||||
|
name = $Name; command = '<internal contract check>'; status = 'planned'
|
||||||
|
startedUtc = $null; durationSeconds = 0; exitCode = $null
|
||||||
|
stdout = $null; stderr = $null; environmentKeys = @()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$begin = [DateTime]::UtcNow
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$exitCode = 0
|
||||||
|
try { & $Action }
|
||||||
|
catch { $exitCode = 1; throw }
|
||||||
|
finally {
|
||||||
|
$watch.Stop()
|
||||||
|
$commandResults.Add([ordered]@{
|
||||||
|
name = $Name; command = '<internal contract check>'
|
||||||
|
status = if ($exitCode -eq 0) { 'passed' } else { 'failed' }
|
||||||
|
startedUtc = $begin.ToString('O')
|
||||||
|
durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
|
||||||
|
exitCode = $exitCode; stdout = $null; stderr = $null; environmentKeys = @()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-DotNet([string]$Name, [string[]]$Arguments) {
|
||||||
|
Invoke-GateCommand $Name 'dotnet' $Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
$portableBuildProjects = @(
|
||||||
|
'src/AcDream.Platform/AcDream.Platform.csproj',
|
||||||
|
'src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj',
|
||||||
|
'src/AcDream.Bake/AcDream.Bake.csproj',
|
||||||
|
'src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj',
|
||||||
|
'src/AcDream.Core/AcDream.Core.csproj',
|
||||||
|
'src/AcDream.Core.Net/AcDream.Core.Net.csproj',
|
||||||
|
'src/AcDream.Content/AcDream.Content.csproj',
|
||||||
|
'src/AcDream.Runtime/AcDream.Runtime.csproj',
|
||||||
|
'src/AcDream.Headless/AcDream.Headless.csproj'
|
||||||
|
)
|
||||||
|
$portableTestProjects = @(
|
||||||
|
'tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj',
|
||||||
|
'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj',
|
||||||
|
'tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj',
|
||||||
|
'tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj',
|
||||||
|
'tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj',
|
||||||
|
'tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj',
|
||||||
|
'tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj'
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoke-DotNet 'release-build' @(
|
||||||
|
'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1')
|
||||||
|
Invoke-DotNet 'release-tests-serial' @(
|
||||||
|
'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1',
|
||||||
|
'--', 'RunConfiguration.MaxCpuCount=1')
|
||||||
|
Invoke-DotNet 'focused-launcher-updater-core' @(
|
||||||
|
'test', 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj',
|
||||||
|
'-c', 'Release', '--no-build', '--nologo',
|
||||||
|
'--filter', 'FullyQualifiedName~Updates')
|
||||||
|
Invoke-DotNet 'focused-launcher-updater-ui' @(
|
||||||
|
'test', 'tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj',
|
||||||
|
'-c', 'Release', '--no-build', '--nologo',
|
||||||
|
'--filter', 'FullyQualifiedName~LauncherUpdateViewModelTests|FullyQualifiedName~LauncherStartupOptionsTests')
|
||||||
|
|
||||||
|
foreach ($project in $portableBuildProjects) {
|
||||||
|
$leaf = [IO.Path]::GetFileNameWithoutExtension($project)
|
||||||
|
Invoke-DotNet "portable-build-$leaf" @(
|
||||||
|
'build', $project, '-c', 'Release', '--no-restore', '--nologo', '-m:1')
|
||||||
|
}
|
||||||
|
foreach ($project in $portableTestProjects) {
|
||||||
|
$leaf = [IO.Path]::GetFileNameWithoutExtension($project)
|
||||||
|
Invoke-DotNet "portable-test-$leaf" @(
|
||||||
|
'test', $project, '-c', 'Release', '--no-build', '--nologo',
|
||||||
|
'--', 'RunConfiguration.MaxCpuCount=1')
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rid in @('win-x64', 'linux-x64')) {
|
||||||
|
$destination = Join-Path $publishDirectory $rid
|
||||||
|
Invoke-DotNet "publish-launcher-$rid" @(
|
||||||
|
'publish', 'src/AcDream.Launcher/AcDream.Launcher.csproj',
|
||||||
|
'-c', 'Release', '-r', $rid, '--self-contained', 'true',
|
||||||
|
'-p:PublishSingleFile=true', '-o', $destination, '--nologo')
|
||||||
|
Add-InternalCheck "publish-contract-$rid" {
|
||||||
|
$suffix = if ($rid.StartsWith('win-', [StringComparison]::Ordinal)) { '.exe' } else { '' }
|
||||||
|
foreach ($name in @("acdream-launcher$suffix", "acdream-bake$suffix")) {
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $destination $name) -PathType Leaf)) {
|
||||||
|
throw "$rid publish is missing $name."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath (Join-Path $destination 'acdream-launcher.dll')) {
|
||||||
|
throw "$rid launcher publish is not single-file."
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath (Join-Path $destination 'acdream-bake.dll')) {
|
||||||
|
throw "$rid bake publish is not single-file."
|
||||||
|
}
|
||||||
|
if (-not $IsWindows -and $rid -eq 'linux-x64') {
|
||||||
|
$mode = [IO.File]::GetUnixFileMode((Join-Path $destination 'acdream-launcher'))
|
||||||
|
if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) {
|
||||||
|
throw 'linux-x64 launcher is not executable.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$nativeRid = if ($IsWindows) { 'win-x64' } else { 'linux-x64' }
|
||||||
|
$nativeSuffix = if ($IsWindows) { '.exe' } else { '' }
|
||||||
|
$nativeRoot = Join-Path $publishDirectory $nativeRid
|
||||||
|
$bogusRoot = if ($IsWindows) { 'Z:\definitely-not-installed' } else { '/definitely-not-installed' }
|
||||||
|
$bogusEnvironment = @{
|
||||||
|
DOTNET_ROOT = $bogusRoot
|
||||||
|
DOTNET_ROOT_X64 = $bogusRoot
|
||||||
|
DOTNET_MULTILEVEL_LOOKUP = '0'
|
||||||
|
}
|
||||||
|
Invoke-GateCommand 'native-launcher-bogus-dotnet-root' `
|
||||||
|
(Join-Path $nativeRoot "acdream-launcher$nativeSuffix") `
|
||||||
|
@('--verify-publish') $bogusEnvironment
|
||||||
|
Invoke-GateCommand 'native-bake-bogus-dotnet-root' `
|
||||||
|
(Join-Path $nativeRoot "acdream-bake$nativeSuffix") `
|
||||||
|
@('--help') $bogusEnvironment
|
||||||
|
|
||||||
|
if ($IncludeInstalledDat) {
|
||||||
|
$datEnvironment = @{
|
||||||
|
ACDREAM_DAT_DIR = $InstalledDatDirectory
|
||||||
|
ACDREAM_PROBE_LIVE_MOUNT = '1'
|
||||||
|
}
|
||||||
|
$datResults = Join-Path $OutputDirectory 'installed-dat-results'
|
||||||
|
Invoke-GateCommand 'installed-dat-character-management-readonly' 'dotnet' @(
|
||||||
|
'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj',
|
||||||
|
'-c', 'Release', '--no-build', '--nologo',
|
||||||
|
'--filter', 'FullyQualifiedName~CharacterManagementLiveDatTests',
|
||||||
|
'--results-directory', $datResults,
|
||||||
|
'--logger', 'trx;LogFileName=character-management.trx') $datEnvironment
|
||||||
|
Add-InternalCheck 'installed-dat-character-management-require-pass' {
|
||||||
|
$trx = Join-Path $datResults 'character-management.trx'
|
||||||
|
if (-not (Test-Path -LiteralPath $trx -PathType Leaf)) {
|
||||||
|
throw 'CharacterManagementLiveDatTests did not produce a TRX result.'
|
||||||
|
}
|
||||||
|
[xml]$result = Get-Content -LiteralPath $trx -Raw
|
||||||
|
$outcomes = @($result.TestRun.Results.UnitTestResult | ForEach-Object { $_.outcome })
|
||||||
|
if ($outcomes.Count -eq 0 -or $outcomes -ccontains 'NotExecuted' -or
|
||||||
|
@($outcomes | Where-Object { $_ -cne 'Passed' }).Count -gt 0) {
|
||||||
|
throw "CharacterManagementLiveDatTests must pass (not skip): $($outcomes -join ',')."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Invoke-GateCommand 'installed-dat-action-map-readonly' 'dotnet' @(
|
||||||
|
'test', 'tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj',
|
||||||
|
'-c', 'Release', '--no-build', '--nologo',
|
||||||
|
'--filter', 'FullyQualifiedName~RetailActionMapReader_LiveDatTests') $datEnvironment
|
||||||
|
Invoke-GateCommand 'installed-dat-portal-assets-readonly' 'dotnet' @(
|
||||||
|
'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj',
|
||||||
|
'-c', 'Release', '--no-build', '--nologo',
|
||||||
|
'--filter', 'FullyQualifiedName~PortalTunnelAssetTests.InstalledDat_ResolvesRetailPortalSetupAndAnimation') $datEnvironment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$failures.Add((Protect-Text ($_ | Out-String)).Trim())
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$finishedUtc = [DateTime]::UtcNow
|
||||||
|
$head = (& git -C $Repository rev-parse HEAD).Trim()
|
||||||
|
$dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all)
|
||||||
|
$artifacts = @()
|
||||||
|
if (-not $DryRun) {
|
||||||
|
$artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
|
||||||
|
Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } |
|
||||||
|
Sort-Object FullName |
|
||||||
|
ForEach-Object {
|
||||||
|
[ordered]@{
|
||||||
|
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
|
||||||
|
size = $_.Length
|
||||||
|
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
$failedCommands = @($commandResults | Where-Object { $_.status -eq 'failed' })
|
||||||
|
$report = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
kind = 'campaign-la-automated-preflight'
|
||||||
|
dryRun = [bool]$DryRun
|
||||||
|
success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0)
|
||||||
|
repository = $Repository
|
||||||
|
head = $head
|
||||||
|
dirty = ($dirtyLines.Count -gt 0)
|
||||||
|
dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ })
|
||||||
|
platform = [ordered]@{
|
||||||
|
os = [Runtime.InteropServices.RuntimeInformation]::OSDescription
|
||||||
|
architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
|
||||||
|
processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()
|
||||||
|
rid = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier
|
||||||
|
framework = [Runtime.InteropServices.RuntimeInformation]::FrameworkDescription
|
||||||
|
powershell = $PSVersionTable.PSVersion.ToString()
|
||||||
|
}
|
||||||
|
startedUtc = $startedUtc.ToString('O')
|
||||||
|
finishedUtc = $finishedUtc.ToString('O')
|
||||||
|
durationSeconds = [Math]::Round(($finishedUtc - $startedUtc).TotalSeconds, 3)
|
||||||
|
installedDatIncluded = [bool]$IncludeInstalledDat
|
||||||
|
commands = @($commandResults)
|
||||||
|
failures = @($failures)
|
||||||
|
redaction = [ordered]@{
|
||||||
|
applied = $true
|
||||||
|
environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')
|
||||||
|
credentialArgumentsAllowed = $false
|
||||||
|
}
|
||||||
|
artifacts = $artifacts
|
||||||
|
}
|
||||||
|
$reportPath = Join-Path $OutputDirectory 'report.json'
|
||||||
|
$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8NoBOM
|
||||||
|
Write-Host "Campaign LA preflight report: $reportPath"
|
||||||
|
if (-not $report.success) { exit 1 }
|
||||||
|
}
|
||||||
346
tools/test-campaign-la-session-status.ps1
Normal file
346
tools/test-campaign-la-session-status.ps1
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Strict Campaign LA v1 session-status and terminal-process validator.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Validates exact JSONL property sets and property order, lifecycle order for
|
||||||
|
probe/guiSelect/gui/headless, terminal semantics, plugin expectations,
|
||||||
|
credential redaction, and absence of launcher child-process leaks. The
|
||||||
|
report contains hashes and event names only; it does not copy account,
|
||||||
|
character, command, plugin-error, or other payload text.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$StatusFile,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode,
|
||||||
|
[string]$ExpectedSessionId,
|
||||||
|
[string[]]$ExpectedPlugin = @(),
|
||||||
|
[switch]$ExpectNoEnteredWorld,
|
||||||
|
[switch]$AllowPluginFailure,
|
||||||
|
[switch]$AllowLoginCommandFailure,
|
||||||
|
[switch]$AllowLauncherChildren,
|
||||||
|
[string[]]$ForbiddenEnvironmentVariable = @(
|
||||||
|
'ACDREAM_TEST_PASS',
|
||||||
|
'ACDREAM_LA_GATE_SECRET'),
|
||||||
|
[string]$ReportPath,
|
||||||
|
[int]$ProcessExitWaitSeconds = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||||
|
throw 'Campaign LA status validation requires PowerShell 7 or newer.'
|
||||||
|
}
|
||||||
|
if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') {
|
||||||
|
throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.'
|
||||||
|
}
|
||||||
|
if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
|
||||||
|
$StatusFile = [IO.Path]::GetFullPath($StatusFile)
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
|
||||||
|
throw "Status file does not exist: $StatusFile"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ReportPath)) {
|
||||||
|
$ReportPath = "$StatusFile.validation.json"
|
||||||
|
}
|
||||||
|
elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) {
|
||||||
|
$ReportPath = [IO.Path]::GetFullPath($ReportPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
$exactFields = @{
|
||||||
|
started = @('v', 'e', 't', 'sessionId')
|
||||||
|
connected = @('v', 'e', 't', 'sessionId')
|
||||||
|
characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters')
|
||||||
|
enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName')
|
||||||
|
pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin')
|
||||||
|
pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error')
|
||||||
|
loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error')
|
||||||
|
disconnected = @('v', 'e', 't', 'sessionId', 'reason')
|
||||||
|
exited = @('v', 'e', 't', 'sessionId', 'code', 'reason')
|
||||||
|
}
|
||||||
|
$failures = [Collections.Generic.List[string]]::new()
|
||||||
|
$eventNames = [Collections.Generic.List[string]]::new()
|
||||||
|
$loadedPlugins = [Collections.Generic.List[string]]::new()
|
||||||
|
$sessionId = $null
|
||||||
|
$previousTimestamp = [DateTimeOffset]::MinValue
|
||||||
|
$terminalSeen = $false
|
||||||
|
|
||||||
|
function Get-Properties([Text.Json.JsonElement]$Element) {
|
||||||
|
$properties = [Collections.Generic.List[object]]::new()
|
||||||
|
foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) }
|
||||||
|
return @($properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-String(
|
||||||
|
[Text.Json.JsonElement]$Root,
|
||||||
|
[string]$Name,
|
||||||
|
[bool]$AllowEmpty = $false) {
|
||||||
|
$value = $Root.GetProperty($Name)
|
||||||
|
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) {
|
||||||
|
throw "field '$Name' is not a string"
|
||||||
|
}
|
||||||
|
$text = $value.GetString()
|
||||||
|
if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) {
|
||||||
|
throw "field '$Name' is empty"
|
||||||
|
}
|
||||||
|
return $text
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) {
|
||||||
|
$value = $Root.GetProperty($Name)
|
||||||
|
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) {
|
||||||
|
throw "field '$Name' is not a number"
|
||||||
|
}
|
||||||
|
return $value.GetInt32()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) {
|
||||||
|
$value = $Root.GetProperty($Name)
|
||||||
|
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) {
|
||||||
|
throw "field '$Name' is not a number"
|
||||||
|
}
|
||||||
|
return $value.GetUInt32()
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = @(Get-Content -LiteralPath $StatusFile)
|
||||||
|
if ($lines.Count -eq 0) { $failures.Add('status stream is empty') }
|
||||||
|
for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
|
||||||
|
$lineNumber = $lineIndex + 1
|
||||||
|
$line = $lines[$lineIndex]
|
||||||
|
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||||
|
$failures.Add("line $lineNumber is empty")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
foreach ($variable in $ForbiddenEnvironmentVariable) {
|
||||||
|
$secret = [Environment]::GetEnvironmentVariable($variable)
|
||||||
|
if (-not [string]::IsNullOrEmpty($secret) -and
|
||||||
|
$line.Contains($secret, [StringComparison]::Ordinal)) {
|
||||||
|
$failures.Add("line $lineNumber contains the value of forbidden environment variable $variable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') {
|
||||||
|
$failures.Add("line $lineNumber contains a credential-like JSON field")
|
||||||
|
}
|
||||||
|
|
||||||
|
$document = $null
|
||||||
|
try {
|
||||||
|
$document = [Text.Json.JsonDocument]::Parse($line)
|
||||||
|
$root = $document.RootElement
|
||||||
|
if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
|
||||||
|
throw 'root is not an object'
|
||||||
|
}
|
||||||
|
$properties = @(Get-Properties $root)
|
||||||
|
$names = @($properties | ForEach-Object { $_.Name })
|
||||||
|
if (@($names | Sort-Object -Unique).Count -ne $names.Count) {
|
||||||
|
throw 'object contains duplicate fields'
|
||||||
|
}
|
||||||
|
$eventName = Assert-String $root 'e'
|
||||||
|
if (-not $exactFields.ContainsKey($eventName)) {
|
||||||
|
throw "event '$eventName' is not in the v1 vocabulary"
|
||||||
|
}
|
||||||
|
$expected = $exactFields[$eventName]
|
||||||
|
if ($names.Count -ne $expected.Count -or
|
||||||
|
[string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) {
|
||||||
|
throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'"
|
||||||
|
}
|
||||||
|
if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' }
|
||||||
|
$timestampText = Assert-String $root 't'
|
||||||
|
$timestamp = [DateTimeOffset]::MinValue
|
||||||
|
if (-not [DateTimeOffset]::TryParseExact(
|
||||||
|
$timestampText,
|
||||||
|
'O',
|
||||||
|
[Globalization.CultureInfo]::InvariantCulture,
|
||||||
|
[Globalization.DateTimeStyles]::RoundtripKind,
|
||||||
|
[ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) {
|
||||||
|
throw 'field t is not an exact UTC round-trip timestamp'
|
||||||
|
}
|
||||||
|
if ($timestamp -lt $previousTimestamp) {
|
||||||
|
throw 'timestamp order moved backwards'
|
||||||
|
}
|
||||||
|
$previousTimestamp = $timestamp
|
||||||
|
$lineSessionId = Assert-String $root 'sessionId'
|
||||||
|
if ($null -eq $sessionId) { $sessionId = $lineSessionId }
|
||||||
|
if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' }
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and
|
||||||
|
$lineSessionId -cne $ExpectedSessionId) {
|
||||||
|
throw 'sessionId does not match -ExpectedSessionId'
|
||||||
|
}
|
||||||
|
if ($terminalSeen) { throw 'an event appears after terminal exited' }
|
||||||
|
|
||||||
|
switch ($eventName) {
|
||||||
|
'characterList' {
|
||||||
|
$null = Assert-String $root 'accountName' $true
|
||||||
|
$slotCount = Assert-Int32 $root 'slotCount'
|
||||||
|
if ($slotCount -lt 0) { throw 'slotCount is negative' }
|
||||||
|
$characters = $root.GetProperty('characters')
|
||||||
|
if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) {
|
||||||
|
throw 'characters is not an array'
|
||||||
|
}
|
||||||
|
foreach ($character in $characters.EnumerateArray()) {
|
||||||
|
if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
|
||||||
|
throw 'a character is not an object'
|
||||||
|
}
|
||||||
|
$characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name })
|
||||||
|
$characterExpected = @('id', 'name', 'secondsGreyedOut')
|
||||||
|
if ([string]::Join("`n", $characterNames) -cne
|
||||||
|
[string]::Join("`n", $characterExpected)) {
|
||||||
|
throw 'a character fields/order is not id,name,secondsGreyedOut'
|
||||||
|
}
|
||||||
|
$null = Assert-UInt32 $character 'id'
|
||||||
|
$null = Assert-String $character 'name'
|
||||||
|
$null = Assert-UInt32 $character 'secondsGreyedOut'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'enteredWorld' {
|
||||||
|
$null = Assert-UInt32 $root 'characterId'
|
||||||
|
$null = Assert-String $root 'characterName'
|
||||||
|
}
|
||||||
|
'pluginLoaded' {
|
||||||
|
$loadedPlugins.Add((Assert-String $root 'plugin'))
|
||||||
|
}
|
||||||
|
'pluginFailed' {
|
||||||
|
$null = Assert-String $root 'plugin'
|
||||||
|
$null = Assert-String $root 'error'
|
||||||
|
if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' }
|
||||||
|
}
|
||||||
|
'loginCommandFailed' {
|
||||||
|
if ((Assert-Int32 $root 'commandIndex') -lt 0) {
|
||||||
|
throw 'commandIndex is negative'
|
||||||
|
}
|
||||||
|
$null = Assert-String $root 'command' $true
|
||||||
|
$null = Assert-String $root 'error'
|
||||||
|
if (-not $AllowLoginCommandFailure) {
|
||||||
|
throw 'loginCommandFailed is not allowed for this row'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'disconnected' { $null = Assert-String $root 'reason' }
|
||||||
|
'exited' {
|
||||||
|
$code = Assert-Int32 $root 'code'
|
||||||
|
$reason = Assert-String $root 'reason'
|
||||||
|
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'"
|
||||||
|
}
|
||||||
|
$terminalSeen = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$eventNames.Add($eventName)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$failures.Add("line ${lineNumber}: $($_.Exception.Message)")
|
||||||
|
}
|
||||||
|
finally { if ($null -ne $document) { $document.Dispose() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Require-Count([string]$EventName, [int]$Count) {
|
||||||
|
$actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count
|
||||||
|
if ($actual -ne $Count) {
|
||||||
|
$failures.Add("event '$EventName' count is $actual, expected $Count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function First-Index([string]$EventName) {
|
||||||
|
for ($index = 0; $index -lt $eventNames.Count; $index++) {
|
||||||
|
if ($eventNames[$index] -ceq $EventName) { return $index }
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
Require-Count 'started' 1
|
||||||
|
Require-Count 'connected' 1
|
||||||
|
Require-Count 'characterList' 1
|
||||||
|
Require-Count 'disconnected' 1
|
||||||
|
Require-Count 'exited' 1
|
||||||
|
$expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld
|
||||||
|
Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 })
|
||||||
|
if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') {
|
||||||
|
$failures.Add('started is not the first event')
|
||||||
|
}
|
||||||
|
if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') {
|
||||||
|
$failures.Add('exited is not the final event')
|
||||||
|
}
|
||||||
|
$orderedRequired = if (-not $expectEnteredWorld) {
|
||||||
|
@('started', 'connected', 'characterList', 'disconnected', 'exited')
|
||||||
|
} else {
|
||||||
|
@('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited')
|
||||||
|
}
|
||||||
|
$last = -1
|
||||||
|
foreach ($name in $orderedRequired) {
|
||||||
|
$next = First-Index $name
|
||||||
|
if ($next -ge 0 -and $next -le $last) {
|
||||||
|
$failures.Add("event '$name' is out of lifecycle order")
|
||||||
|
}
|
||||||
|
$last = $next
|
||||||
|
}
|
||||||
|
$connectedIndex = First-Index 'connected'
|
||||||
|
foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) {
|
||||||
|
if ($eventNames.Count -eq 0) { break }
|
||||||
|
if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and
|
||||||
|
($index -le 0 -or $index -ge $connectedIndex)) {
|
||||||
|
$failures.Add("plugin event at index $index is outside started-to-connected startup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($plugin in $ExpectedPlugin) {
|
||||||
|
if (-not ($loadedPlugins -ccontains $plugin)) {
|
||||||
|
$failures.Add("expected plugin '$plugin' did not emit pluginLoaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique)
|
||||||
|
$loadedPluginSet = @($loadedPlugins | Sort-Object -Unique)
|
||||||
|
if ($loadedPlugins.Count -ne $loadedPluginSet.Count) {
|
||||||
|
$failures.Add('a plugin emitted pluginLoaded more than once')
|
||||||
|
}
|
||||||
|
if ([string]::Join("`n", $loadedPluginSet) -cne
|
||||||
|
[string]::Join("`n", $expectedPluginSet)) {
|
||||||
|
$failures.Add(
|
||||||
|
"loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)")
|
||||||
|
}
|
||||||
|
$enteredWorldIndex = First-Index 'enteredWorld'
|
||||||
|
for ($index = 0; $index -lt $eventNames.Count; $index++) {
|
||||||
|
if ($eventNames[$index] -ceq 'loginCommandFailed' -and
|
||||||
|
($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) {
|
||||||
|
$failures.Add("loginCommandFailed at index $index did not follow enteredWorld")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $AllowLauncherChildren) {
|
||||||
|
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
|
||||||
|
do {
|
||||||
|
$children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue)
|
||||||
|
if ($children.Count -eq 0) { break }
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
} while ([DateTime]::UtcNow -lt $deadline)
|
||||||
|
if ($children.Count -gt 0) {
|
||||||
|
$failures.Add(
|
||||||
|
"launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reportDirectory = Split-Path -Parent $ReportPath
|
||||||
|
if (-not [string]::IsNullOrEmpty($reportDirectory)) {
|
||||||
|
$null = New-Item -ItemType Directory -Force -Path $reportDirectory
|
||||||
|
}
|
||||||
|
$report = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
kind = 'campaign-la-session-status-validation'
|
||||||
|
success = ($failures.Count -eq 0)
|
||||||
|
mode = $Mode
|
||||||
|
enteredWorldExpected = $expectEnteredWorld
|
||||||
|
statusFile = [IO.Path]::GetFileName($StatusFile)
|
||||||
|
statusSize = (Get-Item -LiteralPath $StatusFile).Length
|
||||||
|
statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
lineCount = $lines.Count
|
||||||
|
eventNames = @($eventNames)
|
||||||
|
loadedPluginCount = $loadedPlugins.Count
|
||||||
|
terminalObserved = $terminalSeen
|
||||||
|
launcherChildrenAllowed = [bool]$AllowLauncherChildren
|
||||||
|
failures = @($failures)
|
||||||
|
validatedUtc = [DateTime]::UtcNow.ToString('O')
|
||||||
|
}
|
||||||
|
$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM
|
||||||
|
Write-Host "Campaign LA status validation report: $ReportPath"
|
||||||
|
if (-not $report.success) {
|
||||||
|
$failures | ForEach-Object { Write-Error $_ }
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue