feat(launcher): prepare Campaign LA11 user gate

This commit is contained in:
Erik 2026-08-14 23:09:08 +02:00
parent 09d84387a8
commit f881e5b467
24 changed files with 3538 additions and 58 deletions

View file

@ -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" />

View file

@ -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

View file

@ -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

View file

@ -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. |

View 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 AI 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 AH. 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 | 515 min |
| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 2060 min |
| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 14 min |
| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 1025 min |
| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 310 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 | 3590 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 DF. 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 AH
### 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 30180 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: 45200 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: 1015 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 13 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: 510 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: 510 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: 510 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: 510 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: 510 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: 1530 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: 60220 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
AI 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: 37 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.

View file

@ -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

View file

@ -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;
} }

View file

@ -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);

View file

@ -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

View 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);
}

View file

@ -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)

View file

@ -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. -->

View 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)
{
}
}

View file

@ -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>

View file

@ -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;
}

View file

@ -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>

View file

@ -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);
}

View file

@ -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>

View file

@ -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,

View file

@ -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]

View 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;
}
}

View 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"

View 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 }
}

View 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
}