From 2a89b880e2c3dd91b98a0e9d600fc6058721b3d0 Mon Sep 17 00:00:00 2001 From: Nanako Tsai Date: Tue, 28 Jul 2026 01:11:53 +0800 Subject: [PATCH] feat(release): add reproducible preview artifacts Establish the Windows 0.1.0-preview.1 private artifact contract around Directory.Build.props. Keep TokenBar as the product identity, derive the App assembly, executable, and archive basename from TbProductName, and retain 0.1.0.0 as the numeric assembly, file, and manifest version. Pin .NET SDK 10.0.301, Rust 1.96.1, direct NuGet package versions, and commit the generated NuGet lock graphs. Make local and CI Cargo and NuGet entry points fail closed with --locked and --locked-mode while preserving the existing Platform and RID to Rust target mapping. Add scripts/build-app-artifact.ps1 for clean-checkout x64 and ARM64 self-contained publishing. The command validates toolchains, PE machines, WinUI PRI, XBF, assets, native byte equality, and version metadata, then emits a private unsigned ZIP, checksum, and sanitized evidence. CI uploads only the App checksum and evidence while retaining Smoke harness artifacts for three days. Add the opt-in --startup-smoke host gate. It requires a fresh sentinel parent, atomically records the PID, informational version, architecture, and tray-ready stage after tray and service construction, returns nonzero on every failure including a held single-instance mutex, and shuts down through the tray owner. Document the disposable-account, outbound-blocked runtime gate and keep Velopack, signing, installers, package-manager manifests, Syrtis renaming, and public release outside Phase 10. Verification: scripts/check.sh passed 309 FFI tests, 1,290 core tests with one ignored, 285 .NET tests, and the P/Invoke smoke. Windows locked x64 and ARM64 artifact, PE, and resource validation passed; ARM64 startup smoke preserved all production baselines; the x64 held-mutex startup-smoke E2E returned exit code 1 without a sentinel. actionlint, XML and JSON validation, git diff --check, and a fresh outcome verifier also passed. --- .github/workflows/ci.yml | 99 +++-- Directory.Build.props | 17 + README.md | 65 +++- docs/release.md | 182 +++++++++ global.json | 7 + rust-toolchain.toml | 3 + scripts/build-app-artifact.ps1 | 433 +++++++++++++++++++++ scripts/check.sh | 11 +- spike/D3DGraphSpike/D3DGraphSpike.csproj | 4 +- spike/D3DGraphSpike/packages.lock.json | 64 +++ src/Directory.Build.targets | 3 +- src/TokenBar.App/App.xaml.cs | 186 +++++++++ src/TokenBar.App/SettingsWindow.cs | 7 +- src/TokenBar.App/TokenBar.App.csproj | 13 +- src/TokenBar.App/app.manifest | 4 +- src/TokenBar.App/packages.lock.json | 273 +++++++++++++ src/TokenBar.Core.Tests/packages.lock.json | 113 ++++++ src/TokenBar.Core/packages.lock.json | 10 + src/TokenBar.CrossCheck/packages.lock.json | 16 + src/TokenBar.Interop/packages.lock.json | 6 + src/TokenBar.Smoke/TokenBar.Smoke.csproj | 1 + src/TokenBar.Smoke/packages.lock.json | 12 + 22 files changed, 1464 insertions(+), 65 deletions(-) create mode 100644 Directory.Build.props create mode 100644 docs/release.md create mode 100644 global.json create mode 100644 rust-toolchain.toml create mode 100644 scripts/build-app-artifact.ps1 create mode 100644 spike/D3DGraphSpike/packages.lock.json create mode 100644 src/TokenBar.App/packages.lock.json create mode 100644 src/TokenBar.Core.Tests/packages.lock.json create mode 100644 src/TokenBar.Core/packages.lock.json create mode 100644 src/TokenBar.CrossCheck/packages.lock.json create mode 100644 src/TokenBar.Interop/packages.lock.json create mode 100644 src/TokenBar.Smoke/packages.lock.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 455c094..d48e65c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: with: path: tokenbar-windows - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.96.1 - uses: Swatinem/rust-cache@v2 with: workspaces: tokenbar-mac -> target @@ -34,7 +36,7 @@ jobs: run: | ( cd "$TOKENBAR_MAC_CANONICAL" - cargo build --release + cargo build --release --locked ) rm -rf "$TOKENBAR_WINDOWS/crosscheck/swift-out" mkdir -p "$TOKENBAR_WINDOWS/crosscheck/swift-out" @@ -61,31 +63,56 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 - with: - name: crosscheck-swift-out - path: crosscheck/swift-out - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.96.1 - uses: Swatinem/rust-cache@v2 - uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.x + dotnet-version: 10.0.301 + # Run the clean-checkout artifact contract before any legacy tracked + # spike obj/ files can be refreshed by later restores. + - name: Phase 10 App artifact evidence (x64) + shell: pwsh + run: | + $root = Join-Path $env:RUNNER_TEMP "tokenbar-phase10-x64" + ./scripts/build-app-artifact.ps1 -Rid win-x64 -OutputRoot $root + - uses: actions/upload-artifact@v4 + with: + name: app-evidence-win-x64 + path: | + ${{ runner.temp }}/tokenbar-phase10-x64/*/evidence.json + ${{ runner.temp }}/tokenbar-phase10-x64/*/*.zip.sha256 + if-no-files-found: error + retention-days: 3 + - uses: actions/download-artifact@v4 + with: + name: crosscheck-swift-out + path: crosscheck/swift-out # Directory.Build.targets owns the only Platform/RID to Rust-target # mapping and produces the native source consumed by managed builds. + - name: restore locked NuGet graphs + run: | + dotnet restore src/TokenBar.slnx --locked-mode + dotnet restore src/TokenBar.App/TokenBar.App.csproj --locked-mode + dotnet restore spike/D3DGraphSpike/D3DGraphSpike.csproj --locked-mode + dotnet restore src/TokenBar.CrossCheck/TokenBar.CrossCheck.csproj --locked-mode + - name: cargo build native source (x64, locked) + run: cargo build --release --locked --target x86_64-pc-windows-msvc - name: build native DLL (x64 via shared mapping) - run: dotnet msbuild src/TokenBar.Smoke/TokenBar.Smoke.csproj -t:BuildTbNative -p:Configuration=Release -p:Platform=x64 -p:RuntimeIdentifier=win-x64 - - name: cargo test (workspace) - run: cargo test --workspace --release + run: dotnet msbuild src/TokenBar.Smoke/TokenBar.Smoke.csproj -t:BuildTbNative -p:Configuration=Release -p:Platform=x64 -p:RuntimeIdentifier=win-x64 -p:TbNativeCargoLocked=true -p:RestoreLockedMode=true + - name: cargo test (workspace, locked) + run: cargo test --workspace --release --locked - name: build Core.Tests (x64) - run: dotnet build src/TokenBar.Core.Tests/TokenBar.Core.Tests.csproj -c Release -p:Platform=x64 + run: dotnet build src/TokenBar.Core.Tests/TokenBar.Core.Tests.csproj -c Release -p:Platform=x64 --no-restore - name: test Core.Tests (x64) - run: dotnet test src/TokenBar.Core.Tests/TokenBar.Core.Tests.csproj -c Release -p:Platform=x64 --no-build + run: dotnet test src/TokenBar.Core.Tests/TokenBar.Core.Tests.csproj -c Release -p:Platform=x64 --no-build --no-restore - name: build WinUI App (x64) - run: dotnet build src/TokenBar.App/TokenBar.App.csproj -c Release -p:Platform=x64 -p:RuntimeIdentifier=win-x64 + run: dotnet build src/TokenBar.App/TokenBar.App.csproj -c Release -p:Platform=x64 -p:RuntimeIdentifier=win-x64 --no-restore - name: build Smoke (x64) - run: dotnet build src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -p:Platform=x64 + run: dotnet build src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -p:Platform=x64 --no-restore - name: P/Invoke smoke (all entry points; x64 runtime) - run: dotnet run --project src/TokenBar.Smoke -c Release -p:Platform=x64 --no-build + run: dotnet run --project src/TokenBar.Smoke -c Release -p:Platform=x64 --no-build --no-restore env: # Keep credential-bound agent usage and live pricing refreshes out of # deterministic CI; their decode paths are covered on dev machines. @@ -99,12 +126,12 @@ jobs: MSYS_NO_PATHCONV=1 tzutil.exe /s "Taipei Standard Time" TZ=Asia/Taipei dotnet run \ --project src/TokenBar.CrossCheck \ - -c Release -- \ + -c Release --no-restore -- \ crosscheck/fixtures \ crosscheck/csharp-out TZ=Asia/Taipei dotnet run \ --project src/TokenBar.CrossCheck \ - -c Release -- \ + -c Release --no-restore -- \ crosscheck/fixtures \ crosscheck/csharp-out \ provider-quota-pace-v3 @@ -196,12 +223,12 @@ jobs: $env:TB_SMOKE_MIN_MESSAGES = "3" $env:TB_SMOKE_EXPECT_CLIENT = "codex" $env:TB_SMOKE_SKIP_NETWORK = "1" - dotnet run --project src/TokenBar.Smoke -c Release -p:Platform=x64 --no-build + dotnet run --project src/TokenBar.Smoke -c Release -p:Platform=x64 --no-build --no-restore # Self-contained smoke bundle (runtime + native dll included): lets a # bare Windows verification box run the full entry-point sweep with # nothing installed. - name: publish smoke bundle (win-x64 self-contained) - run: dotnet publish src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -r win-x64 --self-contained -o out/smoke-win-x64 + run: dotnet publish src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -r win-x64 --self-contained -o out/smoke-win-x64 --no-restore # Execute the published file itself, with the same isolated fixture and # network skip as the x64 build smoke; this is the published-runtime gate. - name: run published smoke bundle (win-x64 runtime) @@ -262,6 +289,7 @@ jobs: with: name: smoke-win-x64 path: out/smoke-win-x64 + retention-days: 3 # Cross-build/package only. This x64 runner never executes ARM64 binaries; # real ARM64 runtime coverage remains a merge gate on the ARM64 VM. @@ -271,26 +299,41 @@ jobs: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: + toolchain: 1.96.1 targets: aarch64-pc-windows-msvc - uses: Swatinem/rust-cache@v2 with: key: arm64 - uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.x + dotnet-version: 10.0.301 + - name: Phase 10 App artifact evidence (ARM64) + shell: pwsh + run: | + $root = Join-Path $env:RUNNER_TEMP "tokenbar-phase10-arm64" + ./scripts/build-app-artifact.ps1 -Rid win-arm64 -OutputRoot $root + - uses: actions/upload-artifact@v4 + with: + name: app-evidence-win-arm64 + path: | + ${{ runner.temp }}/tokenbar-phase10-arm64/*/evidence.json + ${{ runner.temp }}/tokenbar-phase10-arm64/*/*.zip.sha256 + if-no-files-found: error + retention-days: 3 + - name: restore locked NuGet graphs (ARM64) + run: | + dotnet restore src/TokenBar.App/TokenBar.App.csproj --locked-mode + dotnet restore src/TokenBar.Smoke/TokenBar.Smoke.csproj --locked-mode + - name: cargo build native source (ARM64, locked) + run: cargo build --release --locked --target aarch64-pc-windows-msvc - name: build native DLL (ARM64 cross-build only) - run: dotnet msbuild src/TokenBar.Smoke/TokenBar.Smoke.csproj -t:BuildTbNative -p:Configuration=Release -p:Platform=ARM64 -p:RuntimeIdentifier=win-arm64 + run: dotnet msbuild src/TokenBar.Smoke/TokenBar.Smoke.csproj -t:BuildTbNative -p:Configuration=Release -p:Platform=ARM64 -p:RuntimeIdentifier=win-arm64 -p:TbNativeCargoLocked=true -p:RestoreLockedMode=true - name: build WinUI App (ARM64 cross-build only) - run: dotnet build src/TokenBar.App/TokenBar.App.csproj -c Release -p:Platform=ARM64 -p:RuntimeIdentifier=win-arm64 + run: dotnet build src/TokenBar.App/TokenBar.App.csproj -c Release -p:Platform=ARM64 -p:RuntimeIdentifier=win-arm64 --no-restore - name: publish Smoke bundle (win-arm64 cross-package validation) - run: dotnet publish src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -r win-arm64 --self-contained -o out/smoke-win-arm64 - - name: publish App bundle (win-arm64 cross-package validation) - run: dotnet publish src/TokenBar.App/TokenBar.App.csproj -c Release -p:Platform=ARM64 -r win-arm64 -o out/app-win-arm64 + run: dotnet publish src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -r win-arm64 --self-contained -o out/smoke-win-arm64 --no-restore - uses: actions/upload-artifact@v4 with: name: smoke-win-arm64 path: out/smoke-win-arm64 - - uses: actions/upload-artifact@v4 - with: - name: app-win-arm64 - path: out/app-win-arm64 + retention-days: 3 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1939d9b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,17 @@ + + + + TokenBar + 0.1.0-preview.1 + 0.1.0.0 + $(TbSemanticVersion) + $(TbSemanticVersion) + $(TbSemanticVersion) + $(TbAssemblyVersion) + $(TbAssemblyVersion) + false + true + + diff --git a/README.md b/README.md index 05e4959..fde22d0 100644 --- a/README.md +++ b/README.md @@ -30,25 +30,36 @@ Windows native packaging is opt-in for `TokenBar.App`, `TokenBar.Smoke`, and # macOS (inner loop — no Windows needed) scripts/check.sh -# Windows x64: build the explicit native source, then run the x64 gates +# Windows x64: restore the locked graph, build the explicit native source, then run the x64 gates +dotnet restore src/TokenBar.slnx --locked-mode +dotnet restore src/TokenBar.App/TokenBar.App.csproj --locked-mode .\scripts\dev.ps1 - -dotnet msbuild src/TokenBar.Smoke/TokenBar.Smoke.csproj -t:BuildTbNative -p:Configuration=Release -p:Platform=x64 -p:RuntimeIdentifier=win-x64 -dotnet build src/TokenBar.Core.Tests/TokenBar.Core.Tests.csproj -c Release -p:Platform=x64 -dotnet test src/TokenBar.Core.Tests/TokenBar.Core.Tests.csproj -c Release -p:Platform=x64 --no-build -dotnet build src/TokenBar.App/TokenBar.App.csproj -c Release -p:Platform=x64 -p:RuntimeIdentifier=win-x64 -dotnet build src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -p:Platform=x64 -dotnet run --project src/TokenBar.Smoke -c Release -p:Platform=x64 --no-build -dotnet publish src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -r win-x64 --self-contained -o out/smoke-win-x64 +dotnet msbuild src/TokenBar.Smoke/TokenBar.Smoke.csproj -t:BuildTbNative -p:Configuration=Release -p:Platform=x64 -p:RuntimeIdentifier=win-x64 -p:TbNativeCargoLocked=true -p:RestoreLockedMode=true +dotnet build src/TokenBar.Core.Tests/TokenBar.Core.Tests.csproj -c Release -p:Platform=x64 --no-restore +dotnet test src/TokenBar.Core.Tests/TokenBar.Core.Tests.csproj -c Release -p:Platform=x64 --no-build --no-restore +dotnet build src/TokenBar.App/TokenBar.App.csproj -c Release -p:Platform=x64 -p:RuntimeIdentifier=win-x64 --no-restore +dotnet build src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -p:Platform=x64 --no-restore +dotnet run --project src/TokenBar.Smoke -c Release -p:Platform=x64 --no-build --no-restore +dotnet publish src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -r win-x64 --self-contained -o out/smoke-win-x64 --no-restore ``` ARM64 is cross-build/package validation only on an x64 runner: ```bash -dotnet msbuild src/TokenBar.Smoke/TokenBar.Smoke.csproj -t:BuildTbNative -p:Configuration=Release -p:Platform=ARM64 -p:RuntimeIdentifier=win-arm64 -dotnet build src/TokenBar.App/TokenBar.App.csproj -c Release -p:Platform=ARM64 -p:RuntimeIdentifier=win-arm64 -dotnet publish src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -r win-arm64 --self-contained -o out/smoke-win-arm64 -dotnet publish src/TokenBar.App/TokenBar.App.csproj -c Release -p:Platform=ARM64 -r win-arm64 -o out/app-win-arm64 +dotnet restore src/TokenBar.App/TokenBar.App.csproj --locked-mode +dotnet restore src/TokenBar.Smoke/TokenBar.Smoke.csproj --locked-mode +dotnet msbuild src/TokenBar.Smoke/TokenBar.Smoke.csproj -t:BuildTbNative -p:Configuration=Release -p:Platform=ARM64 -p:RuntimeIdentifier=win-arm64 -p:TbNativeCargoLocked=true -p:RestoreLockedMode=true +dotnet build src/TokenBar.App/TokenBar.App.csproj -c Release -p:Platform=ARM64 -p:RuntimeIdentifier=win-arm64 --no-restore +dotnet publish src/TokenBar.Smoke/TokenBar.Smoke.csproj -c Release -r win-arm64 --self-contained -o out/smoke-win-arm64 --no-restore +``` + +The private, unsigned App package command performs the locked restore, native +build, publish, ZIP creation, and structure/version/PE/hash checks. Use a clean +Git checkout and a new empty output root on a Windows host or CI runner: + +```powershell +.\scripts\build-app-artifact.ps1 -Rid win-x64 -OutputRoot "$env:RUNNER_TEMP\tokenbar-phase10-x64" +.\scripts\build-app-artifact.ps1 -Rid win-arm64 -OutputRoot "$env:RUNNER_TEMP\tokenbar-phase10-arm64" ``` The cross-language CrossCheck stays platform-neutral and has no Platform/RID: @@ -61,20 +72,31 @@ TZ=Asia/Taipei dotnet run \ crosscheck/csharp-out ``` -CI publishes the ARM64 artifacts `smoke-win-arm64` and `app-win-arm64` without -executing those binaries on the x64 runner. A real ARM64 runtime on the Windows -VM remains the merge gate. +CI publishes only short-retention Smoke test-harness artifacts and sanitized +Phase 10 App evidence/checksums. App ZIP/EXE/DLL files are never uploaded. The +hosted runners perform structure, version, PE, and hash checks; they do not +claim an interactive WinUI startup gate. + +The M19-B1 real-ARM64 result is historical evidence from a separate Windows +ARM64 gate on 2026-07-27: 351 Rust tests, 12 provider-v3 CrossCheck cases, PE +checks, and synthetic WinUI startup were recorded in [the public issue +comment](https://github.com/Nanako0129/TokenBar/issues/45#issuecomment-5091092629). +That result does not satisfy or replace the Phase 10 published-artifact x64 and +ARM64 gates; any new startup-smoke result must use a disposable Windows +VM/account snapshot with production credentials absent and outbound network +blocked. -Prereqs: Rust (stable), .NET 10 SDK; on Windows the MSVC toolchain. +Prereqs: Rust `1.96.1`, .NET SDK `10.0.301`, PowerShell `7.0+`; on Windows the +MSVC toolchain. Windows CI runs the x64 Rust workspace tests, Core.Tests, WinUI App, and Smoke from their project roots (the solution has no x64 configuration), then executes both the all-entry-point and strict relocated-`CODEX_HOME` Smoke checks with network and host-profile isolation. It runs the no-Platform/RID CrossCheck, publishes and executes the self-contained `smoke-win-x64` bundle, and uploads -that artifact. The `arm64-cross` job only cross-builds and packages the native -DLL, Smoke bundle, and App bundle, uploading `smoke-win-arm64` and -`app-win-arm64`; it is not an ARM64 runtime test. The provider-pace branch +that test-harness artifact. The `arm64-cross` job only cross-builds and packages +the native DLL and Smoke bundle, and uploads the test-harness artifact plus +sanitized Phase 10 evidence/checksums; it is not an ARM64 runtime test. The provider-pace branch passed local command-equivalent x64 and ARM64 gates plus fresh review on 2026-07-19; GitHub PR #3 preserves its remote CI and review record. `cargo fmt` is not currently a repository CI gate; its existing workspace-wide formatting @@ -94,7 +116,8 @@ debt is tracked separately. | 7 | Settings + tray extras | 🔶 feature-complete (macOS parity) — settings store (`%APPDATA%\TokenBar\settings.json`, atomic, unit-tested) with the year filter, chart persistence, manual-refresh spinner; tray: seven modes with the value drawn into the icon (tooltip carries the full string), bars/ring/popsicle gauges (macOS geometry verbatim), cat/parrot animation (HICON-cached, ~0.5% of a core at idle), full context menu with live quota sources; Mica settings window (ten sections, live keys, autostart via HKCU Run honoring StartupApproved); flyout footer gear+Quit; in-flyout Ctrl-shortcut set. Global `RegisterHotKey` dropped: the macOS reference ships no global shortcut, so it's not a parity gap (parked as an optional Windows-only nicety in Phase 9). Verification: icon gallery + live tray screenshots + synthesized input on the x64 box; the non-quota Settings flow passed the 125% DPI interactive gate on 2026-07-17, including live 520→600 DIP flyout resizing, persistence, singleton hide/reopen, autostart restoration, and the 48ms entrance-animation race. The separate 150% pass also passed on 2026-07-17 in an isolated RDP session: both windows reported 144 DPI, 520→600 DIP mapped immediately to 780→900 physical px, and the persisted height survived a process restart. A 33-active-day synthetic fixture supported user-checked 3D hover/orbit/zoom/Fit/Reset, and the Flyout Acrylic was subsequently verified with loaded 3D content in both light and dark themes at 200% DPI | | 8 | 3D integration | ✅ 2026-07-17 — product card renders the real contribution grid with macOS-parity colors/lighting (sRGB-correct opaque faces), 4× MSAA, render-on-demand orbit/pan/zoom, persisted `tokenbar.orbit.v1`, Fit/Reset, custom ray-picked tooltip, and a persisted 2D/3D toggle. Real x64 checks include corrected pointer/DPI alignment, 2D↔3D in 6.7–20.1ms, a 241-frame drag trace, idle no-present, the 50-cycle lifecycle gate, and a retained 60-minute soak: 8230 cycles, `created=8230 released=8230 removed=0 errors=0`, 3600.7s elapsed, with private-memory and handle thresholds passing. Fresh review confirmed the lifecycle and cleanup result | | 9 | Polish + parity + vendor re-sync | 🔶 vendor portability and non-quota client tabs complete (2026-07-17), with all 11 Windows-only code-drift commits recorded in `SYNC.md`. **Provider pace v3 reconciliation completed 2026-07-19** against the exact macOS `1e00e7b` tree: Codex, Claude, Grok, Antigravity, and Copilot recurring percentage cards use stable `cardId`, opaque account scope, exact/observed duration, typed lifecycle states, and backend-owned coherent history; Windows adds CNG-backed installation identity, protected DACLs, reparse/file-ID checks, locking, capacity bounds, and atomic replacement. Strict C# decoding, `clientId|cardId` selection retention/migration, shared Dashboard/Settings row semantics, responsive Full layout, Classic/Off suppression, and typed learning/unavailable previews are active. Verification includes 275 .NET tests; the latest Windows x64 `tb_core_ffi` release suite with 300 tests; macOS and Windows x64 workspace/App/synthetic-smoke gates; ARM64 release build, 15 native security/history tests, 12 provider cases, and WinUI startup; Swift↔C# zero-difference checks across 12 provider-v3 and 116 legacy cases; light/dark responsive UI checks at 100%, 150%, and 200% DPI; sanitized production-profile preservation; and fresh focused/end-to-end verifiers. Windows runtime follow-ups resolve provider homes without `HOME`, hide and cache the Claude version probe, and discover/probe every Antigravity language server without visible console windows. Provider compatibility closure on 2026-07-20 adds Windows Antigravity OAuth-client artifact discovery, proves the installed scanner against Antigravity 2.3.1, accepts Grok's unified-billing schema as a separate non-recurring financial cap, and completes sanitized Codex/Grok/Antigravity live gates. Final review fixes unify Antigravity local/remote history scope through verified Google Email and bind the tray last-good gauge to its effective selection. This completes the pace contract only; broader quota-source ordering/visibility and new Agent-limits feature scope remain unopened · backlog: demo mode; optional Windows-only global hotkey to toggle the flyout (no macOS equivalent — needs a key-binding UI) | -| 10–12 | Releases → Velopack → winget/Scoop | — | +| 10 | Private unsigned App artifact contract | 🔶 `0.1.0-preview.1` contract implemented; clean CI and the Windows disposable-host startup gate remain required before any release claim. See [`docs/release.md`](docs/release.md) | +| 11–12 | Velopack → winget/Scoop | — | ### Provider runtime validation diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..9ae755f --- /dev/null +++ b/docs/release.md @@ -0,0 +1,182 @@ +# TokenBar Windows Release Contract + +## 目錄 + +- [文件目的](#文件目的) +- [Release boundary](#release-boundary) +- [Version and toolchain locks](#version-and-toolchain-locks) +- [Build command and outputs](#build-command-and-outputs) +- [Verification evidence](#verification-evidence) +- [Private startup gate](#private-startup-gate) +- [CI and authority gates](#ci-and-authority-gates) +- [Phase 11/12 non-goals](#phase-1112-non-goals) + +## 文件目的 + +這份文件定義 Windows App `0.1.0-preview.1` 的私有、未簽署 release-artifact +契約。它描述版本來源、鎖定的 dependency/toolchain、產物與 evidence,以及 +哪些檢查必須在獨立的 Windows 主機完成;它不是公開下載頁,也不授權簽署、 +上傳或套用 Velopack。 + +> **核心原則:** Hosted CI 可以證明結構、版本、PE architecture 與 hash, +> 但不能把 x64 runner 上的 cross-build 說成 ARM64 runtime 或互動式 WinUI +> startup gate。 + +## Release boundary + +Phase 10 的產物是 repo-owned PowerShell command 產生的 version/RID-labelled +App ZIP、SHA-256 checksum 與 sanitized JSON evidence。ZIP 保留在 private +runner/host output root;GitHub Actions 只上傳 evidence 與 checksum,絕不把 +App ZIP、EXE 或 DLL 當成 CI artifact 或 release asset。 + +```mermaid +flowchart TD + SOURCE["Source + version contract"] --> RESTORE["dotnet restore --locked-mode"] + RESTORE --> NATIVE["BuildTbNative + cargo --locked"] + NATIVE --> PUBLISH["dotnet publish for one RID"] + PUBLISH --> CHECK["Structure + version + PE + hash checks"] + CHECK --> PRIVATE["Private ZIP + checksum + evidence"] + PRIVATE --> HOST["Disposable Windows VM startup-smoke"] + PRIVATE --> CI["Hosted CI evidence only"] +``` + +`--startup-smoke` is opt-in. Launches without that flag follow the existing +tray-resident behavior exactly. + +## Version and toolchain locks + +`Directory.Build.props` is the authoritative version source. `app.manifest` keeps +a checked numeric literal, and the artifact command rejects drift between that +literal and the props file. + +| Contract item | Locked value | Authority or behavior | +|---|---|---| +| Product name | `TokenBar` | `TbProductName`; App assembly/executable and artifact basename derive from this property | +| Package SemVer | `0.1.0-preview.1` | `TbSemanticVersion`, `Version`, `PackageVersion` | +| InformationalVersion | `0.1.0-preview.1` | Exact About/sentinel value; source-revision suffix disabled | +| AssemblyVersion | `0.1.0.0` | Preview and future `0.1.0` stable use the same numeric identity | +| FileVersion | `0.1.0.0` | PE/file metadata | +| Win32 manifest version | `0.1.0.0` | `src/TokenBar.App/app.manifest`; verifier rejects drift | +| .NET SDK | `10.0.301` | [`global.json`](../global.json), roll-forward disabled | +| Rust toolchain | `1.96.1`, minimal profile | [`rust-toolchain.toml`](../rust-toolchain.toml) | +| NuGet graph | Lock files required | `RestorePackagesWithLockFile=true`; release restore uses `--locked-mode` | +| Cargo graph | `Cargo.lock` required | Native build uses `cargo ... --locked` | + +The App direct package versions are intentionally exact: Windows App SDK +`1.8.260710003`, Windows SDK BuildTools `10.0.28000.2526`, H.NotifyIcon.WinUI +`2.4.1`, and Vortice Direct3D11/D3DCompiler/DXGI `3.8.3`. + +## Build command and outputs + +Run from a clean Git checkout on Windows with a new, empty output root. The +command accepts exactly one RID and does not delete an existing directory. It +requires PowerShell `7.0+` in addition to the locked .NET and Rust toolchains; +dirty tracked or untracked files are rejected so `gitSha` identifies the exact +source. + +```powershell +.\scripts\build-app-artifact.ps1 ` + -Rid win-x64 ` + -OutputRoot "$env:RUNNER_TEMP\tokenbar-phase10-x64" + +.\scripts\build-app-artifact.ps1 ` + -Rid win-arm64 ` + -OutputRoot "$env:RUNNER_TEMP\tokenbar-phase10-arm64" +``` + +The command validates `Directory.Build.props` and `app.manifest`, checks exact +SDK/toolchain versions, restores the App and Smoke project graphs for the +selected RID in locked mode, runs Cargo with `--locked`, invokes the existing +`BuildTbNative` mapping/verifier with its own locked-Cargo switch, and publishes +the selected RID. The App project and packaging command both derive the current +assembly/executable and artifact basename from `TbProductName=TokenBar`; this is +a bounded future rename seam, not a Phase 10 product rename. The command then +checks the main EXE, `tb_core_ffi.dll`, PRI, XBF, all four animation asset +directories and their expected frames, PE machine values, version metadata, and +native source/publish byte equality. + +| Output | Purpose | Upload policy | +|---|---|---| +| `TokenBar-App-0.1.0-preview.1-win-.zip` | Private unsigned App package | Never uploaded by hosted CI | +| `TokenBar-App-0.1.0-preview.1-win-.zip.sha256` | ZIP SHA-256 and filename | Uploaded as sanitized CI evidence | +| `evidence.json` | Version, RID, toolchain, git SHA, inventory, hashes, gate boundary | Uploaded as sanitized CI evidence | +| `publish/` | Staging files used for checks and ZIP creation | Private command output only | + +ZIP byte-for-byte reproducibility is not claimed; every produced ZIP is +identified by its SHA-256 evidence. + +## Verification evidence + +`evidence.json` contains no absolute paths, user profile names, credentials or +secrets. Its inventory paths are relative to the publish root and each file has +its byte length and SHA-256. The record also includes the exact sanitized values +below. + +| Evidence field | Required value or shape | +|---|---| +| `gitSha` | 40-character checkout SHA | +| `dotnetVersion` | `10.0.301` | +| `rustc.release` | `1.96.1` | +| `rustc.commitHash` | Concrete rustc commit hash, not `unknown` | +| `rid` | `win-x64` or `win-arm64` | +| `semanticVersion` / `informationalVersion` | `0.1.0-preview.1` | +| `assemblyVersion` / `manifestVersion` | `0.1.0.0` | +| `artifact.sha256` | SHA-256 of the labelled ZIP | +| `inventory[].path` | Relative path with no drive or profile prefix | + +Missing lock files, unsupported RID, toolchain/version drift, wrong PE machine, +stale native bytes, missing WinUI assets, and evidence path leakage are hard +failures. The command does not fabricate a lock graph when restore inputs are +unavailable; the generated `packages.lock.json` files must be produced by an +authorized Windows restore and committed before release CI can pass. + +## Private startup gate + +The bounded probe is a separate host action after the published package has +passed structure checks: + +```powershell +TokenBar.App.exe --startup-smoke C:\phase10\sentinel.json +``` + +After `FlyoutWindow` and `TrayService` construction and one +`DispatcherQueue` turn, the App atomically creates the new sentinel with its +PID, exact informational version, process architecture and `tray-ready` stage. +Missing, invalid or already-existing paths are hard failures. The process sets +`ExitCode` and routes shutdown through `TrayService.QuitApp` so feed/timer/tray +and GDI cleanup runs before `Application.Exit`. + +The gate runs only in a separately provided disposable Windows VM/account +snapshot with production credentials absent and outbound network blocked. An +environment-variable reassignment or a different working directory alone is +not isolation. Hosted CI does not execute this probe and must not describe its +evidence as an interactive startup pass. + +The M19-B1 historical real-ARM64 result (2026-07-27: 351 Rust tests, 12 +provider-v3 CrossCheck cases, PE checks and synthetic WinUI startup) is recorded +in [the public issue comment](https://github.com/Nanako0129/TokenBar/issues/45#issuecomment-5091092629). +It is historical evidence only and does not satisfy or replace the Phase 10 +published-artifact x64/ARM64 gates. + +## CI and authority gates + +| Gate | Hosted CI responsibility | Required authority or host follow-up | +|---|---|---| +| Dependency restore | Restore every required project with `--locked-mode` | Commit generated lock files; do not hand-edit graph contents | +| Native/App build | Run exact SDK/Rust, `cargo --locked`, `BuildTbNative`, and both RIDs | Preserve `src/Directory.Build.targets` mapping and native verifier | +| Artifact command | Exercise the repo-owned command in runner temp for x64 and ARM64 | Keep ZIP/EXE/DLL private; upload only evidence/checksums | +| PE/version/hash | Check inventory, architecture, version mapping and stale-DLL equality | Treat any mismatch as a release blocker | +| Interactive startup | No hosted claim | Run the bounded probe on the disposable Windows VM/account snapshot | +| Public release | No CI publication or signing | Requires an explicit later authority decision | + +## Phase 11/12 non-goals + +Phase 10 does not add Velopack packaging, signing credentials, public App +uploads, winget/Scoop manifests, general logger or DI rewrites, settings +migrations, network-suppression seams, or a replacement for the existing native +build plumbing. Those are Phase 11/12 scope and remain unopened until a later +authority gate explicitly approves them. + +The release command is therefore a private, unsigned evidence producer. A +successful hosted job or historical ARM64 VM result alone is not permission to +publish a package, attach an App binary, or claim stable-release readiness. diff --git a/global.json b/global.json new file mode 100644 index 0000000..a5f940e --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.301", + "rollForward": "disable", + "allowPrerelease": false + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..54b8191 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.96.1" +profile = "minimal" diff --git a/scripts/build-app-artifact.ps1 b/scripts/build-app-artifact.ps1 new file mode 100644 index 0000000..deefa5c --- /dev/null +++ b/scripts/build-app-artifact.ps1 @@ -0,0 +1,433 @@ +#Requires -Version 7.0 + +<# +.SYNOPSIS + Build and verify one private, unsigned TokenBar App package. + +.DESCRIPTION + The command is intentionally fail-closed. It validates the repository-owned + version contract, restores the locked .NET graph, builds the RID-specific Rust + DLL with --locked plus the existing BuildTbNative target, publishes the WinUI + App, and emits a version/RID-labelled ZIP with sanitized evidence. It never + uploads or signs an artifact and never removes an existing caller path. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet("win-x64", "win-arm64")] + [string]$Rid, + + [Parameter(Mandatory = $true)] + [Alias("EvidenceRoot")] + [string]$OutputRoot +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$ExpectedSemanticVersion = "0.1.0-preview.1" +$ExpectedAssemblyVersion = "0.1.0.0" +$ExpectedDotnetVersion = "10.0.301" +$ExpectedRustVersion = "1.96.1" + +function Get-RepoProperty { + param( + [Parameter(Mandatory = $true)] + [xml]$Document, + [Parameter(Mandatory = $true)] + [string]$Name + ) + + foreach ($group in @($Document.Project.PropertyGroup)) { + $node = $group.SelectSingleNode($Name) + if ($null -ne $node) { + return $node.InnerText + } + } + + throw "Directory.Build.props is missing required property '$Name'." +} + +function Read-VersionContract { + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Version contract is missing: Directory.Build.props" + } + + try { + $document = [xml](Get-Content -LiteralPath $Path -Raw) + } + catch { + throw "Directory.Build.props is not valid XML: $($_.Exception.Message)" + } + + $semantic = Get-RepoProperty -Document $document -Name "TbSemanticVersion" + $assembly = Get-RepoProperty -Document $document -Name "TbAssemblyVersion" + $product = Get-RepoProperty -Document $document -Name "TbProductName" + $version = Get-RepoProperty -Document $document -Name "Version" + $package = Get-RepoProperty -Document $document -Name "PackageVersion" + $informational = Get-RepoProperty -Document $document -Name "InformationalVersion" + $assemblyProperty = Get-RepoProperty -Document $document -Name "AssemblyVersion" + $file = Get-RepoProperty -Document $document -Name "FileVersion" + $revision = Get-RepoProperty -Document $document -Name "IncludeSourceRevisionInInformationalVersion" + $lock = Get-RepoProperty -Document $document -Name "RestorePackagesWithLockFile" + + if ([string]::IsNullOrWhiteSpace($product)) { + throw "Directory.Build.props product-name contract is empty." + } + if ($semantic -ne $ExpectedSemanticVersion -or $assembly -ne $ExpectedAssemblyVersion) { + throw "Directory.Build.props version contract drifted: expected $ExpectedSemanticVersion / $ExpectedAssemblyVersion." + } + if ($version -ne '$(TbSemanticVersion)' -or $package -ne '$(TbSemanticVersion)' -or + $informational -ne '$(TbSemanticVersion)') { + throw "Directory.Build.props must derive Version, PackageVersion, and InformationalVersion from TbSemanticVersion." + } + if ($assemblyProperty -ne '$(TbAssemblyVersion)' -or $file -ne '$(TbAssemblyVersion)') { + throw "Directory.Build.props must derive AssemblyVersion and FileVersion from TbAssemblyVersion." + } + if ($revision -ne "false" -or $lock -ne "true") { + throw "Directory.Build.props must disable source-revision suffixes and enable NuGet lock files." + } + + return [pscustomobject]@{ + ProductName = $product + SemanticVersion = $semantic + AssemblyVersion = $assembly + Document = $document + } +} + +function Read-ManifestVersion { + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "App manifest is missing: $Path" + } + + try { + $manifest = [xml](Get-Content -LiteralPath $Path -Raw) + } + catch { + throw "App manifest is not valid XML: $($_.Exception.Message)" + } + + $identity = $manifest.SelectSingleNode("//*[local-name()='assemblyIdentity']") + if ($null -eq $identity -or [string]::IsNullOrWhiteSpace($identity.version)) { + throw "App manifest has no assemblyIdentity version." + } + + return [string]$identity.version +} + +function Invoke-Captured { + param( + [Parameter(Mandatory = $true)][string]$Command, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$FailureMessage + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$FailureMessage (exit code $LASTEXITCODE)." + } +} + +function Get-PeMachine { + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "PE file is missing: $Path" + } + + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 0x40 -or + [BitConverter]::ToUInt16($bytes, 0) -ne 0x5a4d) { + throw "Invalid DOS header: $Path" + } + + $peOffset = [BitConverter]::ToInt32($bytes, 0x3c) + if ($peOffset -lt 0 -or $peOffset + 6 -gt $bytes.Length -or + [BitConverter]::ToUInt32($bytes, $peOffset) -ne 0x00004550) { + throw "Invalid PE header: $Path" + } + + return [uint16][BitConverter]::ToUInt16($bytes, $peOffset + 4) +} + +function Get-RelativePath { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Path + ) + + return [System.IO.Path]::GetRelativePath($Root, $Path).Replace('\', '/') +} + +function Assert-NewOutputRoot { + param([Parameter(Mandatory = $true)][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "OutputRoot cannot be empty." + } + + $resolved = [System.IO.Path]::GetFullPath($Path) + if (Test-Path -LiteralPath $resolved) { + if (-not (Test-Path -LiteralPath $resolved -PathType Container)) { + throw "OutputRoot is not a directory: $Path" + } + + if (@(Get-ChildItem -LiteralPath $resolved -Force).Count -ne 0) { + throw "OutputRoot must be an existing empty directory or a new path: $Path" + } + } + else { + New-Item -ItemType Directory -Path $resolved -Force | Out-Null + } + + return $resolved +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$appProject = Join-Path $repoRoot "src\TokenBar.App\TokenBar.App.csproj" +$smokeProject = Join-Path $repoRoot "src\TokenBar.Smoke\TokenBar.Smoke.csproj" +$propsPath = Join-Path $repoRoot "Directory.Build.props" +$manifestPath = Join-Path $repoRoot "src\TokenBar.App\app.manifest" +$versionContract = Read-VersionContract -Path $propsPath +$manifestVersion = Read-ManifestVersion -Path $manifestPath +if ($manifestVersion -ne $versionContract.AssemblyVersion) { + throw "App manifest version '$manifestVersion' does not match Directory.Build.props numeric version '$($versionContract.AssemblyVersion)'." +} + +if ($Rid -eq "win-x64") { + $platform = "x64" + $rustTarget = "x86_64-pc-windows-msvc" + $expectedMachine = [uint16]0x8664 +} +else { + $platform = "ARM64" + $rustTarget = "aarch64-pc-windows-msvc" + $expectedMachine = [uint16]0xaa64 +} + +$outputRootResolved = Assert-NewOutputRoot -Path $OutputRoot +$appAssemblyName = "{0}.App" -f $versionContract.ProductName +$appExecutableName = "$appAssemblyName.exe" +$appPriName = "$appAssemblyName.pri" +$artifactName = "{0}-App-{1}-{2}" -f $versionContract.ProductName, $versionContract.SemanticVersion, $Rid +$artifactRoot = Join-Path $outputRootResolved $artifactName +if (Test-Path -LiteralPath $artifactRoot) { + throw "Artifact output already exists; choose a new OutputRoot: $artifactRoot" +} +New-Item -ItemType Directory -Path $artifactRoot -Force | Out-Null +$publishRoot = Join-Path $artifactRoot "publish" +New-Item -ItemType Directory -Path $publishRoot -Force | Out-Null + +Push-Location $repoRoot +try { + $dotnetVersion = ((& dotnet --version) | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $dotnetVersion -ne $ExpectedDotnetVersion) { + throw "dotnet SDK drifted: expected $ExpectedDotnetVersion, got '$dotnetVersion'." + } + + $rustVerbose = @(& rustc --version --verbose) + if ($LASTEXITCODE -ne 0) { + throw "rustc --version --verbose failed." + } + $rustRelease = ($rustVerbose | Where-Object { $_ -match '^release:\s*(.+)$' } | + Select-Object -First 1) -replace '^release:\s*', '' + $rustCommit = ($rustVerbose | Where-Object { $_ -match '^commit-hash:\s*(.+)$' } | + Select-Object -First 1) -replace '^commit-hash:\s*', '' + if ([string]::IsNullOrWhiteSpace($rustRelease) -or + [string]::IsNullOrWhiteSpace($rustCommit) -or $rustRelease -ne $ExpectedRustVersion -or + $rustCommit -eq "unknown") { + throw "rustc toolchain drifted: expected release $ExpectedRustVersion with a concrete commit hash." + } + + $gitStatus = @(& git status --porcelain=v1 --untracked-files=all) + if ($LASTEXITCODE -ne 0) { + throw "Unable to inspect git checkout state." + } + if ($gitStatus.Count -ne 0) { + throw "Artifact builds require a clean git checkout so gitSha identifies the exact source." + } + + $gitSha = ((& git rev-parse --verify HEAD) | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $gitSha -notmatch '^[0-9a-fA-F]{40}$') { + throw "Unable to resolve a sanitized 40-character git SHA." + } + + Invoke-Captured -Command "cargo" -Arguments @( + "build", "--release", "--locked", "--target", $rustTarget + ) -FailureMessage "Cargo locked build failed" + + foreach ($lockPath in @( + "src\TokenBar.App\packages.lock.json", + "src\TokenBar.Smoke\packages.lock.json", + "src\TokenBar.Core\packages.lock.json", + "src\TokenBar.Interop\packages.lock.json" + )) { + if (-not (Test-Path -LiteralPath (Join-Path $repoRoot $lockPath) -PathType Leaf)) { + throw "Missing generated NuGet lock file: $lockPath" + } + } + Invoke-Captured -Command "dotnet" -Arguments @( + "restore", $appProject, "--locked-mode" + ) -FailureMessage "Locked App restore failed" + Invoke-Captured -Command "dotnet" -Arguments @( + "restore", $smokeProject, "--locked-mode" + ) -FailureMessage "Locked Smoke restore failed" + + # Keep the existing repository-owned Platform/RID mapping and verifier in + # the build path. TbNativeCargoLocked makes that target's own Cargo + # invocation fail closed instead of relying on the preceding build. + Invoke-Captured -Command "dotnet" -Arguments @( + "msbuild", $smokeProject, "-t:BuildTbNative", "-p:Configuration=Release", + "-p:Platform=$platform", "-p:RuntimeIdentifier=$Rid", + "-p:TbNativeCargoLocked=true", "-p:RestoreLockedMode=true" + ) -FailureMessage "BuildTbNative failed" + + Invoke-Captured -Command "dotnet" -Arguments @( + "publish", $appProject, "-c", "Release", "-r", $Rid, "--self-contained", + "-p:Platform=$platform", "-p:RuntimeIdentifier=$Rid", "--no-restore", + "-o", $publishRoot + ) -FailureMessage "Locked App publish failed" +} +finally { + Pop-Location +} + +$exePath = Join-Path $publishRoot $appExecutableName +$nativePath = Join-Path $publishRoot "tb_core_ffi.dll" +$priPath = Join-Path $publishRoot $appPriName +if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) { + throw "Published App executable is missing: $appExecutableName" +} +if (-not (Test-Path -LiteralPath $nativePath -PathType Leaf)) { + throw "Published native DLL is missing: tb_core_ffi.dll" +} +if (-not (Test-Path -LiteralPath $priPath -PathType Leaf)) { + throw "Published PRI is missing: $appPriName" +} + +$xbfFiles = @(Get-ChildItem -LiteralPath $publishRoot -Recurse -File -Filter "*.xbf") +if ($xbfFiles.Count -eq 0) { + throw "Published WinUI resources contain no XBF files." +} + +$assetCounts = [ordered]@{ + "Assets/anim-cat2" = 5 + "Assets/anim-cat2-light" = 5 + "Assets/anim-parrot" = 10 + "Assets/anim-parrot-light" = 10 +} +foreach ($asset in $assetCounts.GetEnumerator()) { + $assetDirectory = Join-Path $publishRoot $asset.Key + if (-not (Test-Path -LiteralPath $assetDirectory -PathType Container)) { + throw "Required asset directory is missing: $($asset.Key)" + } + for ($index = 0; $index -lt $asset.Value; $index++) { + $assetPath = Join-Path $assetDirectory ("frame-{0:D2}.png" -f $index) + if (-not (Test-Path -LiteralPath $assetPath -PathType Leaf)) { + throw "Required asset file is missing: $($asset.Key)/frame-$('{0:D2}' -f $index).png" + } + } +} + +$exeMachine = Get-PeMachine -Path $exePath +$nativeMachine = Get-PeMachine -Path $nativePath +if ($exeMachine -ne $expectedMachine -or $nativeMachine -ne $expectedMachine) { + throw "PE architecture mismatch for ${Rid}: expected 0x$('{0:X4}' -f $expectedMachine), exe=0x$('{0:X4}' -f $exeMachine), native=0x$('{0:X4}' -f $nativeMachine)." +} + +$nativeSourcePath = Join-Path $repoRoot ("target\{0}\release\tb_core_ffi.dll" -f $rustTarget) +if (-not (Test-Path -LiteralPath $nativeSourcePath -PathType Leaf)) { + throw "RID-specific native source is missing: $rustTarget/tb_core_ffi.dll" +} +$nativeHash = (Get-FileHash -LiteralPath $nativePath -Algorithm SHA256).Hash.ToLowerInvariant() +$nativeSourceHash = (Get-FileHash -LiteralPath $nativeSourcePath -Algorithm SHA256).Hash.ToLowerInvariant() +if ($nativeHash -ne $nativeSourceHash) { + throw "Published native DLL does not match the BuildTbNative source bytes." +} + +$fileVersionInfo = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($exePath) +if ($fileVersionInfo.FileVersion -ne $ExpectedAssemblyVersion) { + throw "Published App FileVersion '$($fileVersionInfo.FileVersion)' does not match $ExpectedAssemblyVersion." +} +if ($fileVersionInfo.ProductVersion -ne $ExpectedSemanticVersion) { + throw "Published App ProductVersion '$($fileVersionInfo.ProductVersion)' does not match $ExpectedSemanticVersion." +} + +$inventory = @( + Get-ChildItem -LiteralPath $publishRoot -Recurse -File | Sort-Object FullName | + ForEach-Object { + $relative = Get-RelativePath -Root $publishRoot -Path $_.FullName + [pscustomobject]@{ + path = $relative + bytes = [int64]$_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + } +) + +$zipName = "$artifactName.zip" +$zipPath = Join-Path $artifactRoot $zipName +if (Test-Path -LiteralPath $zipPath) { + throw "Artifact ZIP already exists: $zipName" +} +[System.IO.Compression.ZipFile]::CreateFromDirectory( + $publishRoot, + $zipPath, + [System.IO.Compression.CompressionLevel]::Optimal, + $false) +$zipHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() +$hashName = "$zipName.sha256" +$hashPath = Join-Path $artifactRoot $hashName +Set-Content -LiteralPath $hashPath -Value "$zipHash $zipName" -Encoding ascii -NoNewline + +$evidence = [ordered]@{ + schema = "phase10-app-evidence.v1" + status = "structure-version-pe-hash" + productName = $versionContract.ProductName + semanticVersion = $versionContract.SemanticVersion + assemblyVersion = $versionContract.AssemblyVersion + informationalVersion = $ExpectedSemanticVersion + manifestVersion = $manifestVersion + rid = $Rid + platform = $platform + expectedPeMachine = ("0x{0:X4}" -f $expectedMachine) + gitSha = $gitSha.ToLowerInvariant() + dotnetVersion = $dotnetVersion + rustc = [ordered]@{ + release = $rustRelease + commitHash = $rustCommit + } + native = [ordered]@{ + target = $rustTarget + source = "target/$rustTarget/release/tb_core_ffi.dll" + sha256 = $nativeHash + } + artifact = [ordered]@{ + zip = $zipName + sha256 = $zipHash + bytes = [int64](Get-Item -LiteralPath $zipPath).Length + reproducibility = "not-claimed" + } + inventory = $inventory + startupSmoke = [ordered]@{ + command = "$appExecutableName --startup-smoke " + hostGate = "private disposable Windows VM/account snapshot with production credentials absent and outbound network blocked" + hostedCi = "structure/version/PE/hash checks only; no interactive WinUI startup claim" + } +} + +$evidenceJson = $evidence | ConvertTo-Json -Depth 10 +if ($evidenceJson -match '(?i)([A-Z]:[\\/]|/Users/|/home/|\\Users\\)') { + throw "Evidence contains an absolute path; refusing to write evidence.json." +} +$evidenceName = "evidence.json" +$evidencePath = Join-Path $artifactRoot $evidenceName +Set-Content -LiteralPath $evidencePath -Value $evidenceJson -Encoding utf8 + +Write-Output "Phase 10 App artifact verified: $zipName" +Write-Output "Evidence: $evidenceName; checksum: $hashName" diff --git a/scripts/check.sh b/scripts/check.sh index bfcf2d1..a17400b 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -8,14 +8,15 @@ set -euo pipefail cd "$(dirname "$0")/.." -cargo check --workspace -cargo test --workspace -cargo build --release +cargo check --workspace --locked +cargo test --workspace --locked +cargo build --release --locked if [[ "${WIN_CHECK:-0}" == "1" ]]; then - cargo check --workspace --target x86_64-pc-windows-msvc + cargo check --workspace --locked --target x86_64-pc-windows-msvc fi -dotnet build src/TokenBar.slnx +dotnet restore src/TokenBar.slnx --locked-mode +dotnet build src/TokenBar.slnx --no-restore dotnet test src/TokenBar.slnx --no-build dotnet run --project src/TokenBar.Smoke --no-build diff --git a/spike/D3DGraphSpike/D3DGraphSpike.csproj b/spike/D3DGraphSpike/D3DGraphSpike.csproj index 2a6f44d..b71c156 100644 --- a/spike/D3DGraphSpike/D3DGraphSpike.csproj +++ b/spike/D3DGraphSpike/D3DGraphSpike.csproj @@ -13,8 +13,8 @@ - - + + diff --git a/spike/D3DGraphSpike/packages.lock.json b/spike/D3DGraphSpike/packages.lock.json new file mode 100644 index 0000000..96594f4 --- /dev/null +++ b/spike/D3DGraphSpike/packages.lock.json @@ -0,0 +1,64 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Vortice.D3DCompiler": { + "type": "Direct", + "requested": "[3.8.3, )", + "resolved": "3.8.3", + "contentHash": "OmGPMhf/xnD8/HZ0ms1x7xSFyz+6e6mveCqhu0EioMFQYKJM35/ZVLLo+FIB5KpsrlqhM/jvYHYPAxu45YONGg==", + "dependencies": { + "SharpGen.Runtime": "2.4.2-beta", + "Vortice.DirectX": "3.8.3" + } + }, + "Vortice.Direct3D11": { + "type": "Direct", + "requested": "[3.8.3, )", + "resolved": "3.8.3", + "contentHash": "JgFUswu2mgrSwcd0KRF2AnfeE5VbMEgcicmxPD1g2yXyomPIHbnX3wldBNHPOzzD0URC1WJ/pO1ymfi3imZwvg==", + "dependencies": { + "SharpGen.Runtime": "2.4.2-beta", + "Vortice.DXGI": "3.8.3" + } + }, + "SharpGen.Runtime": { + "type": "Transitive", + "resolved": "2.4.2-beta", + "contentHash": "niTtWAUov1F7wZGAlVeEvW+Tnqvg5yTmhI7rxmYu2Utoyu2TbC9tWWpUpMs0PQg5lu5svoYpCAztNjxCqMFP/w==" + }, + "SharpGen.Runtime.COM": { + "type": "Transitive", + "resolved": "2.4.2-beta", + "contentHash": "9lxDFMiepqo3lJQzMSjECCIONeCc2f3Drokgj6HtrTLZcJSftNgcLbD6/iXYj2iphkwgrZb1oLsDOZxoFZkawQ==", + "dependencies": { + "SharpGen.Runtime": "2.4.2-beta" + } + }, + "Vortice.DirectX": { + "type": "Transitive", + "resolved": "3.8.3", + "contentHash": "nKwSavv0w5rDF7prSefqAVPDcTyqrfZxKFMVfttiXPfm62VdA/1d+b3PhCZpW4xO7jXczXDwjQdWgD1zGsvM8w==", + "dependencies": { + "SharpGen.Runtime": "2.4.2-beta", + "SharpGen.Runtime.COM": "2.4.2-beta", + "Vortice.Mathematics": "2.1.0" + } + }, + "Vortice.DXGI": { + "type": "Transitive", + "resolved": "3.8.3", + "contentHash": "MbAUnTeQ6WD99ctAdrj5CQO2I7oaax0Kt2eVdWSUj0bYP9Ed0726+s7dIHe4+tqhpmINW/dcPt0mNtc72o+E0w==", + "dependencies": { + "SharpGen.Runtime": "2.4.2-beta", + "Vortice.DirectX": "3.8.3" + } + }, + "Vortice.Mathematics": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "KK+SjNSuKdP+zbavekubi+vaeb1Jo4k87ZF6CMkVWjr6RjJwc8peVtmgzXXDIMy4ZC3ZISyk3llY3q65ISOkBQ==" + } + } + } +} \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 7ad1223..a061dbd 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -27,6 +27,7 @@ 0xAA64 $(TbNativeRepoDir)\target\$(TbNativeRustTarget)\release\tb_core_ffi.dll + --locked @@ -63,7 +64,7 @@ - diff --git a/src/TokenBar.App/App.xaml.cs b/src/TokenBar.App/App.xaml.cs index a53681c..e1ab9a8 100644 --- a/src/TokenBar.App/App.xaml.cs +++ b/src/TokenBar.App/App.xaml.cs @@ -1,3 +1,6 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json; using Microsoft.UI.Xaml; namespace TokenBar.App; @@ -9,6 +12,9 @@ public partial class App : Application private TrayService? _tray; private FlyoutWindow? _flyout; private bool _started; + private bool _startupSmokeRequested; + private string? _startupSmokePath; + private string? _startupSmokeError; public App() { @@ -22,6 +28,11 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) // startup we let it propagate so a broken launch stays visible. UnhandledException += OnUnhandledException; + var startupSmoke = ParseStartupSmokeArguments(); + _startupSmokeRequested = startupSmoke.Requested; + _startupSmokePath = startupSmoke.Path; + _startupSmokeError = startupSmoke.Error; + // Tray-resident app: no window shows at launch; the tray icon owns // the flyout's lifetime (mirrors the macOS NSStatusItem shell). // Single instance: a second launch (autostart + manual, say) exits @@ -29,6 +40,10 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) _singleInstance = new Mutex(true, @"Local\TokenBar.App.SingleInstance", out var isFirst); if (!isFirst) { + if (_startupSmokeRequested) + { + Environment.ExitCode = 1; + } DevLog.Write("launch: another instance is running, exiting"); Current.Exit(); return; @@ -67,6 +82,14 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) _started = true; + // The opt-in startup probe deliberately runs through one + // DispatcherQueue turn after both windows/services are constructed. + // The normal launch path is untouched when the flag is absent. + if (_startupSmokeRequested) + { + QueueStartupSmoke(); + } + // Dev-only 3D panel (Phase 8 Gate 0). --graph3d mounts and shows // it for manual inspection; --soak3d runs the device-lifecycle // soak (50 cycles, or --soak3d-minutes duration mode) and exits @@ -89,6 +112,169 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) } } + private static (bool Requested, string? Path, string? Error) + ParseStartupSmokeArguments() + { + var args = Environment.GetCommandLineArgs(); + var indexes = args + .Select((value, index) => (value, index)) + .Where(item => string.Equals( + item.value, "--startup-smoke", StringComparison.Ordinal)) + .Select(item => item.index) + .ToList(); + if (indexes.Count == 0) + { + return (false, null, null); + } + + if (indexes.Count != 1) + { + return (true, null, "--startup-smoke must appear exactly once"); + } + + var index = indexes[0]; + if (index + 1 >= args.Length) + { + return (true, null, "--startup-smoke requires a sentinel path"); + } + + var path = args[index + 1]; + if (string.IsNullOrWhiteSpace(path) + || path.StartsWith("--", StringComparison.Ordinal)) + { + return (true, null, "--startup-smoke requires a non-empty sentinel path"); + } + + return (true, path, null); + } + + private void QueueStartupSmoke() + { + if (_flyout is null || _tray is null) + { + FailStartupSmoke("startup services were not constructed"); + return; + } + + if (!_flyout.DispatcherQueue.TryEnqueue(() => + CompleteStartupSmoke(_startupSmokePath, _startupSmokeError))) + { + FailStartupSmoke("DispatcherQueue rejected the startup probe"); + } + } + + private void CompleteStartupSmoke(string? path, string? parseError) + { + try + { + if (parseError is not null) + { + throw new InvalidOperationException(parseError); + } + + if (path is null) + { + throw new InvalidOperationException( + "--startup-smoke sentinel path is missing"); + } + + WriteStartupSmokeSentinel(path); + Environment.ExitCode = 0; + DevLog.Write("startup-smoke: success stage=tray-ready"); + } + catch (Exception ex) + { + Environment.ExitCode = 1; + DevLog.Write($"startup-smoke: FAILED {ex.Message}"); + } + finally + { + // Route shutdown through the existing tray owner so feed/timers, + // tray resources, and owned GDI handles are released first. + TrayService.QuitApp?.Invoke(); + } + } + + private void FailStartupSmoke(string message) + { + Environment.ExitCode = 1; + DevLog.Write($"startup-smoke: FAILED {message}"); + TrayService.QuitApp?.Invoke(); + } + + private static void WriteStartupSmokeSentinel(string path) + { + var fullPath = Path.GetFullPath(path); + if (File.Exists(fullPath) || Directory.Exists(fullPath)) + { + throw new IOException($"sentinel path already exists: {path}"); + } + + var parent = Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(parent) || !Directory.Exists(parent)) + { + throw new DirectoryNotFoundException( + $"sentinel parent directory does not exist: {path}"); + } + + var version = typeof(App).Assembly + .GetCustomAttribute() + ?.InformationalVersion; + if (string.IsNullOrEmpty(version)) + { + throw new InvalidOperationException( + "assembly informational version is missing"); + } + + var sentinel = new StartupSmokeSentinel( + Environment.ProcessId, + version, + RuntimeInformation.ProcessArchitecture.ToString(), + "tray-ready"); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + // Serialize and flush beside the destination, then atomically rename + // without overwrite. A verifier can therefore treat appearance of the + // final path as a complete record, while a racing existing destination + // remains a hard failure. + var tempPath = Path.Combine( + parent, + $".{Path.GetFileName(fullPath)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + try + { + using (var stream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, sentinel, options); + stream.Flush(flushToDisk: true); + } + + File.Move(tempPath, fullPath, overwrite: false); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + private sealed record StartupSmokeSentinel( + int Pid, + string InformationalVersion, + string ProcessArchitecture, + string Stage); + private void OnUnhandledException( object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e) { diff --git a/src/TokenBar.App/SettingsWindow.cs b/src/TokenBar.App/SettingsWindow.cs index 1ec8285..79ceeff 100644 --- a/src/TokenBar.App/SettingsWindow.cs +++ b/src/TokenBar.App/SettingsWindow.cs @@ -2,6 +2,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using System.Reflection; using TokenBar.Core; using TokenBar.Interop; // TokenBar.Core.Grid (the contribution-grid builder) collides with the XAML @@ -369,7 +370,11 @@ private void Rebuild() var about = new StackPanel { Spacing = 4 }; about.Children.Add(Ui.Row( Ui.Text("Version", 12), - Ui.Dim(typeof(SettingsWindow).Assembly.GetName().Version?.ToString(3) ?? "dev", 12))); + Ui.Dim( + typeof(SettingsWindow).Assembly + .GetCustomAttribute() + ?.InformationalVersion ?? "dev", + 12))); about.Children.Add(Hint( "Parsing engine vendored from tokscale by junhoyeo; menu-bar " + "concept from handlecusion's tokcat.")); diff --git a/src/TokenBar.App/TokenBar.App.csproj b/src/TokenBar.App/TokenBar.App.csproj index efd3b57..336480c 100644 --- a/src/TokenBar.App/TokenBar.App.csproj +++ b/src/TokenBar.App/TokenBar.App.csproj @@ -15,20 +15,21 @@ true enable enable + $(TbProductName).App TokenBar.App app.manifest - - - + + + - - - + + + diff --git a/src/TokenBar.App/app.manifest b/src/TokenBar.App/app.manifest index bd16b28..2e14c50 100644 --- a/src/TokenBar.App/app.manifest +++ b/src/TokenBar.App/app.manifest @@ -1,6 +1,8 @@ - + +