diff --git a/AcDream.slnx b/AcDream.slnx
index 6080bb7f..b003cfca 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -28,6 +28,8 @@
+
+
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 881ed834..421731ab 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -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
-**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
account session stuck for several minutes — a documented project landmine;
see CLAUDE.md "Logout-before-reconnect")
**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3)
**Component:** Launcher.Core / process supervision
-**Description.** `LauncherProcessSupervisor.Stop` now attempts a graceful
-stop signal (`ILauncherChildProcess.TryRequestGracefulStop`) BEFORE
-`CloseMainWindow`. On Linux this sends `SIGINT` via a `libc` P/Invoke
-(`kill(pid, 2)`), which the K4-proven headless host already turns into an
-ACE-confirmed graceful logout. On Windows there is no equivalent today for a
-console process with no message-pump window: `CloseMainWindow` is a no-op
-for a console host (there is no `HWND` to target), and
-`GenerateConsoleCtrlEvent` cannot usefully target an arbitrary child process
-today — Windows delivers console control events to every process attached
-to the SAME console as the calling process, so an unscoped call would also
-signal the launcher itself (and anything else sharing that console), not
-just the intended child. `TryRequestGracefulStop` therefore returns `false`
-on Windows unconditionally, and `Stop` degrades straight to `CloseMainWindow`
-(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.
+**Implementation checkpoint.** `LauncherProcessSupervisor.Stop` attempts
+`ILauncherChildProcess.TryRequestGracefulStop` before `CloseMainWindow` and
+the timeout/kill fallback. Linux retains its K4-proven targeted `SIGINT`.
+On Windows, console-capable launcher specs now use a narrow no-shell
+`CreateProcessW` seam with `CREATE_NEW_PROCESS_GROUP`, a suspended start, and
+an explicit inherited-handle list that preserves only redirected stdin plus
+stdout/stderr. A consoleless Avalonia parent briefly allocates and hides a
+console for the creation transaction, detaches after the new group inherits
+it, and later attaches only long enough to send
+`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)`. Each such
+child is therefore both the root of its own process group and, for the normal
+Explorer-launched case, attached to its own console. Graphical children opt
+out and retain the ordinary `Process`/`WM_CLOSE` path.
-**Known fix direction (not yet implemented).** Spawn the Windows child with
-the `CREATE_NEW_PROCESS_GROUP` creation flag (available via a native
-`CreateProcess` call or by setting it on the `ProcessStartInfo`/`Process`
-plumbing in `SystemChildProcess`) so the child gets its own console process
-group, detached from the launcher's own group. Then
-`TryRequestGracefulStop` on Windows calls
-`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."
+Two real Windows fixture gates cover both a console parent and a consoleless
+WinExe parent. They prove exact complex argv, redirected stdin, receipt of a
+targeted CTRL_BREAK marker, exit code 0 before timeout, no supervisor `Kill`,
+and a sibling process group that remains running until it receives its own
+targeted break. Safe-handle cleanup, early-failure termination, and the
+Linux SIGINT gate remain covered by the Launcher.Core suite.
-**Acceptance for closing this issue:** `SystemChildProcess` spawns Windows
-children with `CREATE_NEW_PROCESS_GROUP`; `TryRequestGracefulStop` sends
-`CTRL_BREAK_EVENT` to that child's process group on Windows; a live
-connected gate proves `AcDream.Headless` exits gracefully (ACE clears the
-session immediately, not after the ~3-minute stale-session window) when
-stopped via `LauncherProcessSupervisor.Stop` on Windows, matching the
+**Acceptance for closing this issue:** automated process-group and targeted-
+signal coverage is complete. Keep the issue IN-PROGRESS until the LA11 live
+connected row proves `AcDream.Headless` exits gracefully and ACE clears the
+session immediately (not after the ~3-minute stale-session window) when
+stopped through `LauncherProcessSupervisor.Stop` on Windows, matching the
Linux SIGINT behavior.
## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 1d1f44de..c909820d 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -312,7 +312,10 @@ src/
AcDream.Launcher.Core/ BCL-only launcher state/orchestration 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
Orchestration/ -> immutable UI snapshots, typed actions,
capability gates, and running-session lifetime
diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md
index 180d320f..76fd65bc 100644
--- a/docs/plans/2026-08-14-launcher-campaign.md
+++ b/docs/plans/2026-08-14-launcher-campaign.md
@@ -697,7 +697,10 @@ forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions.
## 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
lingering session), char-select visual matrix + delete flow, login-commands
+ 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 | — | | | |
| LA9 | — | | | |
| 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. |
diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md
new file mode 100644
index 00000000..664a569e
--- /dev/null
+++ b/docs/research/2026-08-14-campaign-la-test-script.md
@@ -0,0 +1,571 @@
+# Campaign LA11 — automated preflight and connected user gate
+
+**Status:** implementation checkpoint only. Run this script after the reviewed
+LA10/LA11 commits are integrated and the campaign branch is clean. Campaign LA,
+the Linux graphical client, and issue #397 remain open until the user records a
+verdict for every applicable row below.
+
+This is the single Campaign LA operator script. The automated section is
+display-free and connection-free. Rows A–I are deliberately manual and serial:
+they use real retail DATs, a local ACE server, user-entered credentials, and
+visual judgment that automation cannot supply.
+
+## 1. Safety boundary and required inputs
+
+Use placeholders throughout; never paste a password into a terminal, this
+document, a screenshot, or a gate report.
+
+- ``: a clean Campaign LA worktree at the exact commit
+ under test.
+- ``: a read-only source containing
+ `client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and
+ `client_local_English.dat`.
+- ``, ``, and ``: 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.
+- ``: a second user-controlled character that can observe a
+ private `/tell` from each play mode.
+- ``: a server-operator-approved disposable character.
+ Never substitute a primary character. If none exists, provision one with the
+ local server's normal admin procedure before row G.
+- Windows 11 x64, PowerShell 7, .NET 10 SDK, a local ACE server, and a supported
+ Vulkan Windows machine for rows A–H. Ubuntu x64 with PowerShell 7 and a Linux
+ desktop/WSLg is required for row I. The Avalonia launcher is supported on
+ Linux; `gui` and `guiSelect` **client** actions must remain disabled with the
+ Modern Runtime Slice-L explanation.
+
+Close every unrelated `AcDream.App`, `acdream-headless`, and acdream launcher
+before starting. Do not run another acdream gate in parallel. All generated
+files must stay below one new gate directory; the canonical `%APPDATA%`,
+`%LOCALAPPDATA%`, and XDG acdream roots are out of scope.
+
+## 2. Automated preflight — no UI, connection, credential, or bake
+
+In PowerShell 7 on Windows:
+
+```powershell
+$Repo = [IO.Path]::GetFullPath('')
+$Stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
+$Gate = Join-Path $Repo "logs/campaign-la-user-gate-$Stamp"
+$Preflight = Join-Path $Gate 'automated-preflight'
+New-Item -ItemType Directory -Path $Gate | Out-Null
+
+pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
+ -Repository $Repo `
+ -OutputDirectory $Preflight
+
+$Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw |
+ ConvertFrom-Json
+if (-not $Report.success -or $Report.dirty) {
+ throw 'Stop: automated preflight failed or recorded a dirty worktree.'
+}
+if ($Report.head -cne (git -C $Repo rev-parse HEAD).Trim()) {
+ throw 'Stop: preflight HEAD does not equal the current HEAD.'
+}
+```
+
+The expected matrix is:
+
+| Platform | Automated command group | Required result | Typical time |
+|---|---|---|---:|
+| Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 5–15 min |
+| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min |
+| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min |
+| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 10–25 min |
+| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min |
+| Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min |
+| Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min |
+
+`report.json` records the tested HEAD/dirty state, OS/RID, exact commands,
+durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight
+plans 26 commands. It never launches App or Headless in connected mode and
+never reads a credential.
+
+### Optional installed-DAT read-only row
+
+This is not a bake and must not replace row A. Add the switches below only when
+the DAT directory may be read by tests:
+
+```powershell
+pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
+ -Repository $Repo `
+ -OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') `
+ -IncludeInstalledDat `
+ -InstalledDatDirectory ''
+```
+
+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 ``, then run:
+
+```powershell
+$Status = Join-Path $WinCache 'launcher/sessions//status.jsonl'
+pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
+ -StatusFile $Status `
+ -Mode '' `
+ -ExpectedSessionId '' `
+ -ReportPath (Join-Path $Evidence '-status.validation.json')
+```
+
+Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact
+v1 fields **and property order**, one session id, UTC monotonic timestamps,
+mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login
+command failure, credential redaction, and no surviving App/Headless process.
+Its report contains event names and a hash, not account, character, command, or
+error payloads. Keep raw `session.json`/`status.jsonl` local; never upload them.
+
+## 5. Serial Windows user rows A–H
+
+### A — isolated first run, real DAT bake, and release-A client baseline
+
+1. Confirm the launcher opens First-run setup and all launch buttons are
+ unavailable. Save a redacted screenshot as `A-first-run-required.png`.
+2. Enter `` in the wizard, select a sensible
+ worker count, and click **Validate**. Confirm all four DATs pass.
+3. Click **Build and install**. Do not cancel or close the launcher. The real
+ bake may take 30–180 minutes. Confirm every phase reaches **Completed** and
+ the status says `Client content installed and verified. Launch is enabled.`
+4. Open **Check for updates**. Confirm available release A, click **Install
+ client**, and wait for `Client update installed and activated.` Do not stage
+ launcher A; the test launcher already has version A.
+5. Confirm these exact isolated artifacts exist and no `.previous-install`
+ remains after success:
+
+```powershell
+$RequiredA = @(
+ (Join-Path $WinData 'install.json'),
+ (Join-Path $WinData 'pak/acdream.pak'),
+ (Join-Path $WinData 'app/current.json'))
+foreach ($Path in $RequiredA) {
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing $Path" }
+}
+$RequiredA | ForEach-Object {
+ $Item = Get-Item -LiteralPath $_
+ [ordered]@{
+ name = $Item.Name
+ size = $Item.Length
+ sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant()
+ }
+} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'A-install-hashes.json')
+```
+
+Do not copy `install.json` into shared evidence because it records the local DAT
+path. Expected time: 45–200 minutes including bake.
+
+### B — server/account CRUD entirely through the UI
+
+1. Add `` at `127.0.0.1:`, edit its name
+ and port, then remove it. Confirm Cancel/Escape makes no mutation.
+2. Add `` at `127.0.0.1:`.
+3. Under it add `` 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 `` and enter its real password only in the masked field.
+5. Close and reopen the launcher with the **same** `$LauncherArguments`. Confirm
+ only the real server/account persisted. Save redacted before/reopen images as
+ `B-crud-before-reopen.png` and `B-crud-after-reopen.png`.
+6. Record only the profile file's size/hash, never its contents:
+
+```powershell
+$Profile = Join-Path $WinConfig 'launcher-profiles.json'
+$Item = Get-Item -LiteralPath $Profile
+[ordered]@{
+ size = $Item.Length
+ sha256 = (Get-FileHash -LiteralPath $Profile -Algorithm SHA256).Hash.ToLowerInvariant()
+} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'B-profile-hash.json')
+```
+
+Expected time: 10–15 minutes.
+
+### C — live character probe twice, no stale ACE session
+
+1. Select ``, click **Refresh characters**, and wait for the
+ probe row to finish. Confirm the roster appears without entering world.
+2. Run the validator in `probe` mode for its session id. Confirm its exact
+ event order is `started, connected, characterList, disconnected, exited`,
+ with no `enteredWorld`, terminal code 0, and terminal reason `probe`.
+3. In the ACE console/session administration view, confirm the account is no
+ longer logged in. Save a redacted `C-probe-1-ace-cleared.png`.
+4. Repeat steps 1–3 immediately, producing a different session id,
+ `C-probe-2-status.validation.json`, and `C-probe-2-ace-cleared.png`.
+5. Confirm Refresh is re-enabled and no `acdream-headless` process remains.
+
+Expected time: 5–10 minutes. A stale ACE account or timeout is a gate failure;
+do not wait three minutes and call the next attempt a pass.
+
+### D — `guiSelect`, retail character screen, plugin, and login command
+
+1. Select one non-disposable roster character. Set its mode to `guiSelect`,
+ Plugins to exactly `acdream.smoke`, and its one login command to
+ `/tell , LA11-D-`. Save settings.
+2. Click **GUI — character select**. Confirm the flat retail character list,
+ selection highlight, Enter button, Delete/Restore swap state, dialogs, and
+ absence of any invented rotating 3D preview. Save redacted
+ `D-character-select.png`.
+3. Select the configured character and enter world. Confirm the observer gets
+ the exact D nonce once. Save `D-observer-tell.png` with names redacted.
+4. Click **Stop** in the launcher. Confirm the game closes gracefully and ACE
+ releases the account. Validate `guiSelect` with
+ `-ExpectedPlugin acdream.smoke`.
+
+Expected time: 5–10 minutes.
+
+### E — direct `gui`, plugin, and login command
+
+1. Change the same character to `gui`, retain `acdream.smoke`, and change the
+ command nonce to `LA11-E-`.
+2. Click **GUI — enter world**. Confirm it selects the exact cached character,
+ reaches the world, loads the plugin once, and the observer gets the E nonce
+ once.
+3. Stop from the launcher, confirm ACE logout, and validate `gui` with the
+ expected plugin. Save `E-world.png`, `E-observer-tell.png`, and
+ `E-status.validation.json` with identifying text redacted.
+
+Expected time: 5–10 minutes.
+
+### F — headless, plugin/login command, and connected #397 acceptance
+
+1. Change the same character to `headless`, retain `acdream.smoke`, and use
+ `LA11-F-`.
+2. Click **Headless**. Confirm `pluginLoaded(acdream.smoke)`, `enteredWorld`,
+ and the observer's single exact F nonce.
+3. Click **Stop** once. On Windows this must target that child's distinct
+ process group with `CTRL_BREAK`; it must reach `disconnected` then
+ `exited(code:0, reason:graceful)` before the timeout, without a hard kill.
+ ACE must release the account immediately and the launcher must stay open.
+4. Validate `headless` with the expected plugin and save
+ `F-status.validation.json` plus redacted ACE-clear evidence.
+
+The real automated fixture separately proves complex argv and redirected stdin
+survive native `CreateProcessW`, the target receives `CTRL_BREAK`, a sibling
+process group receives nothing, exit 0 precedes timeout, and `Kill` is never
+called. This connected row proves the actual ACE graceful-logout half. Issue
+#397 remains open if either half is missing. Expected time: 5–10 minutes.
+
+### G — disposable delete and restore
+
+1. Launch `guiSelect` for ``. 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//status.jsonl') `
+ -Mode guiSelect `
+ -ExpectNoEnteredWorld `
+ -ExpectedSessionId '' `
+ -ExpectedPlugin acdream.smoke `
+ -ReportPath (Join-Path $Evidence 'G-status.validation.json')
+```
+
+If restore fails, stop the row, preserve evidence, and restore only that
+disposable character with the server's normal admin recovery. Never continue
+with another character. Expected time: 5–10 minutes.
+
+### H — local A→B client update, active-session refusal, rollback, self-update
+
+1. Record release A's `app/current.json`. Start one headless session and wait
+ for `enteredWorld`.
+2. Switch the fixture atomically to B:
+
+```powershell
+pwsh -NoProfile -File (Join-Path $Fixture 'set-active-release.ps1') `
+ -Release B -Root $Fixture
+if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionB) {
+ throw 'Stop: fixture did not switch to B.'
+}
+```
+
+3. Open **Check for updates** and **Check again**. While the session is active,
+ confirm Install client, Rollback client, and Stage launcher are disabled or
+ refuse without changing `app/current.json`. Save `H-active-refusal.png`.
+4. Stop the headless session and validate its graceful status. Install client
+ B. Confirm `app/current.json` names B, A is previous, all installed-file
+ hashes verify, and new sessions resolve from the B directory.
+5. Click **Rollback client**. Confirm A becomes current and B becomes previous.
+ Check again and install B once more, leaving B current. Save sanitized copies
+ of the three pointer states as `H-pointer-a.json`, `H-pointer-b.json`, and
+ `H-pointer-rollback-a.json`; they contain no credentials.
+6. Click **Stage launcher**. Confirm restart is required, then close the
+ launcher normally. The copied helper must apply B and restart the launcher
+ with the same config/data/cache/feed suffix.
+7. Confirm profiles, install record, and update state still come from the
+ isolated roots; `campaign-la-fixture-release.txt` beside the relaunched
+ executable says `release=B`; `launcher-update/pending.json` is gone; and no
+ transaction backup remains. Check again and confirm launcher B is current.
+ Save `H-self-update-confirmed.png` and a hash-only post-state inventory.
+
+Never edit a manifest to force this row and never point the launcher at a
+non-loopback HTTP endpoint. Expected time: 15–30 minutes.
+
+## 6. Row I — native Ubuntu/WSL launcher, XDG-shaped isolated roots
+
+Stop the Windows launcher and fixture server only after every Windows session
+is terminal:
+
+```powershell
+if (-not $FixtureServer.HasExited) {
+ $FixtureServer.Kill()
+ $FixtureServer.WaitForExit()
+}
+```
+
+In a native Ubuntu/WSL PowerShell 7 terminal, set Linux paths. The repository
+and fixture may be read from a mounted Windows path, but roots must live on the
+Linux filesystem. Run the generated server natively so its `127.0.0.1` URLs
+cannot escape the Linux environment:
+
+```powershell
+$RepoLinux = [IO.Path]::GetFullPath('')
+$FixtureLinux = [IO.Path]::GetFullPath('')
+$PayloadsLinux = [IO.Path]::GetFullPath('')
+$LinuxGate = [IO.Path]::GetFullPath('')
+$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 ``;
+ 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 , LA11-I-`, launch, observe
+ the tell, click Stop, and validate `headless` + expected plugin. Native Linux
+ sends SIGINT and must reach graceful terminal status with no process leak.
+6. **Update:** switch the native fixture to B, prove update actions refuse while
+ a headless session is active, stop it gracefully, install B, rollback to A,
+ reinstall B, stage launcher B, and close normally. Confirm the relaunched
+ binary's B marker, preserved explicit roots/feed, cleaned pending journal,
+ and executable owner bits on App, Headless, Launcher, and Bake.
+
+Copy only redacted screenshots, validation reports, pointer JSON, hashes, and
+file-mode results into `$LinuxEvidence`. Keep the Linux profile and raw session
+files local. Expected time: 60–220 minutes, dominated by the real bake.
+
+## 7. Evidence, redaction, verdict, and cleanup
+
+Expected evidence tree:
+
+```text
+logs/campaign-la-user-gate-/
+ automated-preflight/report.json
+ automated-preflight/commands/*.log
+ automated-preflight/publish/{win-x64,linux-x64}/...
+ update-fixture/fixture-report.json
+ update-fixture/{A,B}/manifest.json
+ evidence/A-install-hashes.json
+ evidence/B-*.png
+ evidence/C-probe-{1,2}-status.validation.json
+ evidence/D-*.png + D-status.validation.json
+ evidence/E-*.png + E-status.validation.json
+ evidence/F-*.png + F-status.validation.json
+ evidence/G-*.png + G-status.validation.json
+ evidence/H-*.png + H-pointer-*.json
+ evidence/I-*.png + I-status.validation.json + I-modes.txt
+ verdict.json
+```
+
+Before sharing evidence:
+
+- remove or mask account names, character names, DAT paths, hostnames other than
+ loopback, and server-admin identifiers from screenshots;
+- never copy `launcher-profiles.json`, raw session configs/status streams,
+ stdout/stderr that may contain user text, or environment values;
+- search the shareable evidence for the exact user-entered password and any
+ gate-only sentinel secret; the match count must be zero;
+- retain SHA-256 and sizes so local raw artifacts remain auditable.
+
+No additional raw child/plugin diagnostic sink is required: `pluginLoaded`,
+the strict terminal status, the observer's redacted tell evidence, and the
+automated targeted-signal fixture cover the acceptance questions without
+capturing credentials or arbitrary chat.
+
+Create `verdict.json` manually with schema version 1, exact tested HEAD, rows
+A–I as `pass`, `fail`, or `notApplicable`, a short redacted note per row, and
+the user's overall verdict. Row I is not applicable only when no native
+Ubuntu/WSL desktop is available; it blocks Campaign LA shipping under the
+current Linux requirement, so it cannot be silently omitted.
+
+Cleanup is graceful-first and serial:
+
+1. Restore `` and verify it is ordinary before closing
+ its session.
+2. Stop every launcher session once; require a passing validator and ACE-clear
+ observation. If a child survives the timeout, record the gate failure and
+ its PID before any emergency termination.
+3. Close each launcher normally, then stop only the fixture-server process
+ created above. Do not kill ACE as a substitute for logout evidence.
+4. Leave update pointers on B or roll the **isolated** client back to A through
+ the UI; never edit pointers or journals by hand.
+5. Remove the real account through the isolated launcher UI. After review,
+ delete only the explicitly recorded `$WinConfig`/`$LinuxConfig` gate roots
+ that held plaintext passwords, or change the test account password. Do not
+ recursively delete a computed, empty, canonical, home, repository, or XDG
+ parent path.
+6. Preserve the redacted evidence and reports. The large isolated pak/payload
+ trees may be removed only after resolving and checking their full paths are
+ descendants of the recorded gate roots.
+
+Estimated total: 3–7 hours, primarily the two real DAT bakes and full serial
+test suites. A failure stops the current row; restore/stop/collect evidence,
+then diagnose before advancing. Do not mark LA11, Campaign LA, or #397 shipped
+until the user accepts the complete applicable matrix.
diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
index db477433..dbd662b3 100644
--- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
+++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
@@ -331,7 +331,9 @@ the process supervisor executes.
Campaign V); visuals settle at the user gate.
- **Headless plugin host:** fixture plugin in the Headless suite
(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 →
refresh → roster appears, and repeated probes leaving no stale ACE
session), clean-profile first-run wizard end-to-end, staged-manifest
diff --git a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs
index 872c0130..b8816ab7 100644
--- a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs
+++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs
@@ -41,11 +41,10 @@ public interface ILauncherChildProcess : IDisposable
/// documented project landmine; see CLAUDE.md
/// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved
/// the headless host's SIGINT handler produces an ACE-confirmed
- /// graceful logout). On Windows there is no reliable cross-console
- /// mechanism for an arbitrary no-window child process today — see
- /// docs/ISSUES.md for the tracked gap and fix direction; this
- /// returns false there. Returns true only when the signal was
- /// actually delivered; never throws.
+ /// graceful logout). On Windows, console-capable children are started
+ /// as distinct process-group leaders and receive a targeted
+ /// CTRL_BREAK_EVENT. Returns true only when the signal was actually
+ /// delivered; never throws.
///
bool TryRequestGracefulStop();
@@ -72,7 +71,9 @@ public interface ILauncherChildProcessFactory
public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory
{
public ILauncherChildProcess Create(LauncherProcessSpec spec) =>
- new SystemChildProcess(spec);
+ OperatingSystem.IsWindows() && spec.SupportsConsoleGracefulStop
+ ? new WindowsSystemChildProcess(spec)
+ : new SystemChildProcess(spec);
}
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 readonly Process _process;
+ private readonly bool _supportsConsoleGracefulStop;
private bool _raisingEnabled;
internal SystemChildProcess(LauncherProcessSpec spec)
{
ArgumentNullException.ThrowIfNull(spec);
+ _supportsConsoleGracefulStop = spec.SupportsConsoleGracefulStop;
var startInfo = new ProcessStartInfo
{
@@ -130,11 +133,11 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
public bool TryRequestGracefulStop()
{
- if (!OperatingSystem.IsLinux())
+ if (!OperatingSystem.IsLinux() || !_supportsConsoleGracefulStop)
{
- // No reliable cross-console mechanism exists for an
- // arbitrary no-window Windows child process — tracked gap,
- // see docs/ISSUES.md.
+ // Windows console-capable children use
+ // WindowsSystemChildProcess. Graphical/non-console children
+ // deliberately retain the Process/WM_CLOSE path.
return false;
}
diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs
index 11b59dee..599a932d 100644
--- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs
+++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs
@@ -7,9 +7,13 @@ namespace AcDream.Launcher.Core.Launching;
/// Deliberately carries no credential field — the password is a separate
/// transient parameter to
/// 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
+/// 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.
///
public sealed record LauncherProcessSpec(
string ExecutablePath,
IReadOnlyList Arguments,
- string? WorkingDirectory = null);
+ string? WorkingDirectory = null,
+ bool SupportsConsoleGracefulStop = true);
diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
index 3a4c2923..252847c5 100644
--- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
+++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
@@ -171,8 +171,8 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
///
/// Requests a graceful stop — first
/// (SIGINT
- /// on Linux; a no-op on Windows today, see
- /// 's docs),
+ /// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console
+ /// children),
/// then — falling
/// back to if the process has
/// not exited within . A no-op if
diff --git a/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
new file mode 100644
index 00000000..00138c30
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
@@ -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;
+
+///
+/// Windows launcher child created without a shell as a true console process-
+/// group leader. The native start is deliberately narrow: it exists only
+/// because does not expose
+/// CREATE_NEW_PROCESS_GROUP while the launcher must retain redirected stdin.
+///
+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);
+ }
+}
+
+///
+/// A process can be attached to only one console. Child creation and targeted
+/// control-event attachment therefore share one process-wide gate.
+///
+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(),
+ 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 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(),
+ 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);
+}
diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs
index 4c3ffe16..5a98b795 100644
--- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs
+++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs
@@ -104,7 +104,8 @@ public sealed class LauncherExecutableSet
: new LauncherProcessSpec(
paths.GraphicalHostPath,
["--session-config", configFilePath],
- paths.WorkingDirectory);
+ paths.WorkingDirectory,
+ SupportsConsoleGracefulStop: false);
}
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj
index 350260b2..1b02f993 100644
--- a/src/AcDream.Launcher/AcDream.Launcher.csproj
+++ b/src/AcDream.Launcher/AcDream.Launcher.csproj
@@ -28,6 +28,10 @@
+
+
+
+
diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs
new file mode 100644
index 00000000..1f1c9164
--- /dev/null
+++ b/src/AcDream.Launcher/LauncherStartupOptions.cs
@@ -0,0 +1,248 @@
+using AcDream.Launcher.Core.Updates;
+using AcDream.Platform;
+
+namespace AcDream.Launcher;
+
+internal enum LauncherStartupMode
+{
+ Desktop,
+ VerifyPublish,
+ SelfUpdateHelper,
+ SelfUpdateConfirmation,
+}
+
+///
+/// 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.
+///
+internal sealed class LauncherStartupOptions
+{
+ private readonly IReadOnlyList _publicArguments;
+
+ private LauncherStartupOptions(
+ LauncherStartupMode mode,
+ ApplicationPathSet paths,
+ Uri updateManifestUri,
+ IReadOnlyList publicArguments)
+ {
+ Mode = mode;
+ Paths = paths;
+ UpdateManifestUri = updateManifestUri;
+ _publicArguments = Array.AsReadOnly(publicArguments.ToArray());
+ }
+
+ internal LauncherStartupMode Mode { get; }
+
+ internal ApplicationPathSet Paths { get; }
+
+ internal Uri UpdateManifestUri { get; }
+
+ ///
+ /// 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.
+ ///
+ internal IReadOnlyList PublicArguments => _publicArguments;
+
+ internal static LauncherStartupOptions Parse(
+ IReadOnlyList arguments,
+ Func? 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 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)
+ {
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj
new file mode 100644
index 00000000..e5c9a2c7
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj
@@ -0,0 +1,10 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ latest
+ true
+
+
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs
new file mode 100644
index 00000000..dc047cdf
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs
@@ -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;
+}
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj
new file mode 100644
index 00000000..441bc38a
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj
@@ -0,0 +1,14 @@
+
+
+ WinExe
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+
+
+
+
+
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs
new file mode 100644
index 00000000..22ad9230
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs
@@ -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);
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
index 78fa3e4f..f1677cc9 100644
--- a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
+++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
@@ -25,5 +25,17 @@
false
true
+
+
+ false
+ true
+
+
+
+ false
+ true
+
diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
index 4ec1431e..ca55ff60 100644
--- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
@@ -1,4 +1,6 @@
using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Text.Json;
using System.Threading;
using AcDream.Launcher.Core.Launching;
@@ -6,6 +8,27 @@ namespace AcDream.Launcher.Core.Tests.Launching;
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(console);
+ Assert.IsType(graphical);
+ }
+
[Fact]
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]
public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted()
{
@@ -371,6 +546,22 @@ public sealed class LauncherProcessSupervisorTests
private static LauncherProcessSpec Spec() =>
new("fake-host", ["--session-config", "session.json"]);
+ private static LauncherProcessSpec ConsoleFixtureSpec(
+ string ready,
+ string breakMarker,
+ string label,
+ IReadOnlyList exactArguments) =>
+ new(
+ FindDotnetExecutable(),
+ [
+ GetConsoleFixturePath(),
+ "wait-for-break",
+ ready,
+ breakMarker,
+ label,
+ .. exactArguments,
+ ]);
+
private static string FindDotnetExecutable() =>
// PATH-based resolution: .NET Core's Process.Start searches PATH
// 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`.
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(
bool exitsWithinStopTimeout,
bool exitDuringStart = false,
diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs
index 3d146ffd..a8442f65 100644
--- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs
@@ -44,6 +44,16 @@ public sealed class LauncherExecutableSetTests : IDisposable
Assert.Equal(
headless,
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]
diff --git a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
new file mode 100644
index 00000000..ff3bb24c
--- /dev/null
+++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
@@ -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(() =>
+ LauncherStartupOptions.Parse(
+ ["--verify-publish", "--cache-dir", Path.GetTempPath()]));
+ }
+
+ [Theory]
+ [MemberData(nameof(InvalidArguments))]
+ public void RejectsInvalidPublicArguments(string[] arguments)
+ {
+ Assert.Throws(() =>
+ 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 InvalidArguments()
+ {
+ string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11"));
+ var data = new TheoryData();
+ 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;
+ }
+}
diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1
new file mode 100644
index 00000000..98d129eb
--- /dev/null
+++ b/tools/new-campaign-la-update-fixture.ps1
@@ -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"
diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1
new file mode 100644
index 00000000..0a676df1
--- /dev/null
+++ b/tools/run-campaign-la-preflight.ps1
@@ -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-
+ 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, '', [StringComparison]::Ordinal)
+ }
+ }
+ $protected = [Text.RegularExpressions.Regex]::Replace(
+ $protected,
+ '(?i)(--password|-password)(\s+|=)([^\s"'']+)',
+ '$1$2')
+ $protected = [Text.RegularExpressions.Regex]::Replace(
+ $protected,
+ '(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
+ '$1$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 = ''; 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 = ''
+ 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 }
+}
diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1
new file mode 100644
index 00000000..834ff031
--- /dev/null
+++ b/tools/test-campaign-la-session-status.ps1
@@ -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
+}