Compare commits

..
32 Commits
Author SHA1 Message Date
james.maes a695996091 Merge pull request 'fix(auth): sign-out ends the identity-provider session; sign-in sends max_age=0' (#18) from fix/signout-ends-provider-session into main 2026-11-09 20:46:23 +00:00
james.maesandClaude Sonnet 5.5 5d4b7f43ce fix(auth): sign-out ends the identity-provider session; sign-in sends max_age=0
Sign out only ended the QQQ session, so a retained provider session silently
signed the next person in. The companion now asks the service to log out with
the registered callback; the server's provider end-session data is checked
against the advertised issuer and the provider's own discovery, then opened in
the browser. Browser sign-in also sends max_age=0 beside prompt=login. The
command line still ends only the QQQ session and says so.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:42:43 -06:00
james.maes adf885beaf Merge pull request 'docs(changelog): restore the 1.1.1 heading; record 1.2.1 in TODO' (#15) from docs/changelog-1.1.1-heading into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Failing after 1m54s
QRun-IO/ci-policy: native / dotnet-0 (push) Successful in 20m4s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Failing after 2m30s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 8s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 13s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 30s
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 22s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 33s
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Successful in 19m50s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Successful in 12s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 23:23:40 +00:00
james.maes c4b14963d3 Merge pull request 'docs(handoff): point the Forge printing cohort at QBit 1.1.2 on QQQ RC.22 from the Gitea registry' (#16) from docs/forge-handoff-qbit-112-20261106 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 22s
QRun-IO/ci-policy: native / dotnet-0 (push) Failing after 9m42s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 45s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Failing after 8s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 25s
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 20s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 11s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Failing after 9m19s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Failing after 20s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 20:59:13 +00:00
james.maesandClaude Fable 5.1 a586ea7bf8 docs(handoff): point the Forge printing cohort at QBit 1.1.2 on QQQ RC.22 from the Gitea registry
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 24s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 29s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 10m50s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 36s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 11s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 24s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 11m22s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 26s
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 1m11s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Same code as 1.1.1, certified on 4.1.0-RC.22 (Forge's core pin) and published by the
shared pipeline; exact tag, builds, registry coordinate and verified hashes recorded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 14:45:48 -06:00
james.maes 9906002d65 Merge pull request 'docs(handoff): point the Forge printing cohort at QBit 1.1.1' (#14) from docs/forge-handoff-qbit-111-20261106 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 32s
QRun-IO/ci-policy: native / dotnet-0 (push) Successful in 14m1s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 1m18s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 20s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 10s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 38s
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Successful in 13m46s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 16:50:07 +00:00
james.maesandClaude Fable 5.1 f7a9f79ab5 docs(changelog): restore the 1.1.1 heading; record 1.2.1 in TODO
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 31s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 20s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 12m20s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 26s
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 18s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 12m40s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 45s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 57s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
The editions branch folded the published 1.1.1 release's browser sign-in
fix into the 1.2.0 CHANGELOG entry. Put the `## 1.1.1` heading back so the
changelog matches the published releases. Refresh docs/TODO.md: 1.2.1 is
the current stable, the v1.2.0 sign-job failure is closed by PR 12, and
the nonce section is historical. Docs only; no source or workflow change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 10:41:24 -06:00
james.maesandClaude Fable 5.1 da43fa2918 docs(handoff): point the Forge printing cohort at QBit 1.1.1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 46s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 26s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 13m57s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 46s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 14s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 19s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 14m19s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 40s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 1m24s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
1.1.1 supersedes 1.1.0 (realtime upgrade header-case fix, no contract change).
Exact tag, run, registry coordinates and verified JAR/POM hashes recorded for the
Forge agent; pairing matrix updated to name 1.1.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 10:34:31 -06:00
james.maes b5831302d7 Merge pull request 'docs(release): record 1.2.1 as current stable; Forge handoff' (#13) from docs/release-1.2.1-20261106 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Successful in 14m12s
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 28s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 7s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 14s
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 12s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Successful in 13m46s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Successful in 19s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 14s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 14:09:03 +00:00
james.maesandClaude Fable 5.1 22b942b5e8 docs(readme): current stable 1.2.1 with the 1.1.0 server and protocol cohort
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 10s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 12m12s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 12m29s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 41s
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 07:54:09 -06:00
james.maesandClaude Fable 5.1 40872ab002 docs(release): record 1.2.1 as the current stable release and add the Forge handoff
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 12s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 10m41s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 10m51s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 23s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 07:31:00 -06:00
james.maes 3b9d75d18f Merge pull request 'fix(release): hashtable splat for the per-edition sign job; 1.2.1' (#12) from fix/release-edition-splat-20261105 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Successful in 13m52s
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 7s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 24s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 5s
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 11s
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 9s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Successful in 13m41s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 11s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 13:08:06 +00:00
james.maesandClaude Fable 5.1 6404922e1e fix(release): splat the edition as a hashtable so the per-edition sign job can publish; 1.2.1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 19s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 17s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 13m47s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 24s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 14m0s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 17s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 47s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
The stable release workflow passed "-Edition gg-forge" to Build-Installer.ps1
through an array splat. PowerShell binds array-splatted elements positionally,
so the two strings landed in CertificateThumbprint and TimestampServer and the
PublishOnly guard refused the GG Forge sign job (run 42128511730); the generic
job only survived because its array was empty. A hashtable splat binds by name.
Tag v1.2.0 produced no signed installer and stays immutable, so the product
moves to 1.2.1 with the same source otherwise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 17:32:15 -06:00
james.maes c8ea97e54d Merge pull request 'feat: client editions with fully branded builds, installers and update assets' (#10) from feat/client-editions-20261105 into main
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 10s
QRun-IO/ci-policy: native / dotnet-0 (push) Successful in 12m35s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 1m53s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 17s
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Successful in 12m17s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Successful in 15s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
Reviewed-on: #10
2026-11-05 23:14:48 +00:00
james.maesandClaude Fable 5.1 73e36c61a3 docs: session state for the printer-approval work across protocol, QBit and client
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 24s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 19s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 20s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 18s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 50s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 28s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 46s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 16:13:38 -06:00
james.maesandClaude Fable 5.1 f1ec4040ce test(core): read the shared PRINTERS fixture; pin protocol fixtures to 1.1.0 (ac835f8)
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 13m40s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 13m52s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 36s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 15:47:05 -06:00
james.maesandClaude Fable 5.1 86c7a5e6d3 feat(realtime): negotiate printer-approval-v1 and take approval from the server's PRINTERS frame
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Canceled after 6m59s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Canceled after 6m59s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 24s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Printing extension 2.4 (QBit 1.1.0, protocol 1.1.0): when discovery advertises a
stable 2.4-or-newer printing extension the HELLO carries printer-approval-v1 and
the server's PRINTERS frame (every printer in the latest accepted inventory with
its approval, pushed after each inventory and on every approval change) becomes
the authoritative per-printer server state; the operator catalog is not read on
such a server. Older servers keep the 2.3 catalog fallback and an unchanged wire.
The capability is re-evaluated on every socket, the reading is reset with the
connection, and the companion's tooltip says whether approval is pushed or polled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 15:40:11 -06:00
james.maesandClaude Fable 5.1 d401146985 fix(tests): var for apparent types in the catalog test; document printer approval and the proposed device-facing extension
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 13m41s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 13m53s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 10s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 23s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 15:17:26 -06:00
james.maesandClaude Fable 5.1 887c58cd31 feat(printers): show each shared printer's approval on the connected server
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Failing after 4m37s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Failing after 8s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Failing after 4m49s
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 3s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 24s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
The device routes never report approval, so the service reads the server's
operator printer catalog after each accepted inventory and about once a minute
while connected, keeps the per-device approved set in memory (reset with the
connection), and the snapshot carries APPROVED / AWAITING_APPROVAL / NOT_SHARED
/ NOT_REPORTED (with the reason) / NOT_CHECKED per printer plus the last reading.
The Printers page shows it in a new Server column, the summary line and the
expanded details, named for the connected server. The session client admits the
catalog read (GET operator/printers) and nothing else from the operator API.

Also: a Clear logs button on the shared activity log viewer, the connection
dialog's result banner pinned above the scrolling form with a waiting state
during browser and password sign-in, and a service sign-in reply that no longer
repeats itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 15:09:34 -06:00
james.maesandClaude Fable 5.1 ac582e7bff fix(ui): widen the connection dialog and group it into three short steps
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 9m11s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 9m21s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 24s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
The content dialog's default width clipped the sign-in form. The dialog now allows
760 px, disables horizontal overflow, wraps the certificate checkbox label, folds the
long certificate explanation into a one-line caption with the detail on hover, groups
the form into Server, Sign in and New pairing steps with caption-style guidance, and
puts the sign-in actions on one row. The layout harness captures the dialog in
edition-capture mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 14:40:07 -06:00
james.maesandClaude Fable 5.1 548d831d2c docs: session state for 2026-11-05 (1.1.1 released, PR 10 pending Gitea runner)
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 10m41s
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 10m52s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 25s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 12:38:17 -06:00
james.maesandClaude Fable 5.1 c59814d713 feat(auth): send the sign-in nonce under OPTIONAL and the discovery capability; shared-CI packaging entrypoint
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Canceled after 48s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Canceled after 48s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 15s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 27s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
The exchange carries the attempt's own nonce as expectedNonce when the grant policy
is REQUIRED or OPTIONAL, or when discovery advertises nativeSessionNonceValidation
and the grant states no policy; legacy servers keep the unchanged body and other
policy values are still refused before a browser opens. The decision is logged
without the nonce. build/Build-CiRelease.ps1 delegates to Package-Release.ps1 so
the shared Gitea .NET CI packages the generic and every edition installer through
the one packaging path. Docs, CHANGELOG and TODO updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 12:37:35 -06:00
james.maes 0b9ded7bf7 Merge remote-tracking branch 'origin/main' into feat/client-editions-20261105
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 9m11s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 9m26s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 12s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 32s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
2026-11-05 11:49:28 -06:00
james.maesandClaude Fable 5.1 397b60026a feat: edition options for server wording and About sections
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Failing after 6m13s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 17s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Failing after 8s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Failing after 6m22s
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 3m12s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
An edition descriptor may set serverName (what user-facing text calls the server;
the generic build keeps QQQ), and aboutMascot/aboutLicense (default true) to leave
the Query mascot and License sections out of the About dialog while the header and
Open source components stay. The generic build is unchanged. About composition moves
to AboutContent so the window and the layout harness render the same dialog. The
gg-forge edition sets serverName to GG Forge and turns both sections off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 11:29:11 -06:00
james.maes abc3d0d66b Merge pull request 'fix(auth): support nonce-required native session grants' (#11) from fix/native-login-required-nonce into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 19s
QRun-IO/ci-policy: native / dotnet-0 (push) Successful in 8m18s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 2m22s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (push) Successful in 7s
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 23s
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 14s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 16s
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Successful in 7m43s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Successful in 11s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-05 17:26:33 +00:00
james.maesandClaude Fable 5.1 4ee980eb76 feat: edition builds ignore installer-written labels; harness captures edition identity
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Canceled after 11m52s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 16s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Canceled after 11m51s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 19s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 1m11s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
RuntimeBranding.LoadMachine takes includeInstallerLabels; an edition build skips the
DisplayName/CompanyName the installer persisted so labels from an earlier generic install
cannot relabel the edition, while the machine branding file still applies on top. The
companion's log is typed through a new IAgentLog interface so the layout harness can
supply its per-user log; the harness answers get-server-trust and, with
QQQ_LAYOUT_CAPTURE_EDITION=1, captures the splash, window, Settings page and About
dialog with the build's real identity as evidence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 11:17:12 -06:00
james.maesandClaude Sonnet 5.5 443f6429b3 fix(auth): honor required nonce validation for native sign-in
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 20s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 14s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 13m45s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 27s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 13m58s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 24s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 1m18s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Retain the authorization nonce and send it as expectedNonce only when the
semantic authentication descriptor requires validation. Preserve legacy
exchange bodies and refuse unknown policies without opening the browser.

Add regression coverage and fail verification when no tests execute.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 11:01:43 -06:00
james.maesandClaude Fable 5.1 9d52231d7e test: satisfy IDE0005/IDE0007 in EditionTests
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 16s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 10m45s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 26s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 11m0s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 24s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 1m6s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 10:55:51 -06:00
james.maesandClaude Fable 5.1 f8b85ddd83 feat: client editions with fully branded builds, installers and update assets
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Failing after 3m11s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Canceled after 8s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Canceled after 4m31s
QRun-IO/ci-policy: native / native-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Canceled after 23s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
A folder under editions/<id> (descriptor, SVG mark, ICO icon, optional logo) and
Build-Installer.ps1 -Edition <id> produce a fully branded build and installer: the
client's mark and icon on the splash, About, window, taskbar and Start menu; the
client's product, company and file description in every executable; the client's
product name, manufacturer and service display name in the MSI, forced over any
stored labels; the MSI and release asset named after the edition's asset prefix;
and the installed updater following that asset on the shared feed, so a client
install only ever sees its own edition.

CI builds every edition's unsigned installer. The stable release workflow lists
the editions, signs and publishes one installer per edition beside the generic
one through a matrix sign job and an assemble job that re-verifies every
signature and writes a single SHA256SUMS. Package-Release.ps1 includes every
edition. First edition: gg-forge (GG Forge Print Agent by Greater Goods).
Version 1.2.0. No wire change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 10:51:36 -06:00
james.maes f954f11243 Merge pull request 'fix: let the browser-launched handler reach the elevated companion's callback inbox' (#9) from fix/browser-callback-inbox-20261105 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 11s
QRun-IO/ci-policy: native / dotnet-0 (push) Failing after 8m32s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 54s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Failing after 8s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (push) Successful in 7s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 22s
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 15s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 18s
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Failing after 7m43s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Failing after 18s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-05 16:24:43 +00:00
james.maesandClaude Fable 5.1 cd7c0f7e1f test: assert the live inbox pipe is owned by the user, not the token's default owner
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 16s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 23m13s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 27s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 19s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 23m54s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 27s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 45s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 09:59:59 -06:00
james.maesandClaude Fable 5.1 e63cf02b06 fix: let the browser-launched handler reach the elevated companion's callback inbox
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 24s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 15s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Canceled after 2m37s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 28s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Canceled after 2m36s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 29s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 49s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
PipeOptions.CurrentUserOnly keyed the inbox to the elevated token's default owner
(Administrators), so the unelevated protocol handler was refused and no browser sign-in
could complete. The inbox now carries an explicit descriptor owned by and restricted to the
user's SID; the handler verifies the companion by process image instead of the owner check.
Adds inbox security and round-trip tests. Version 1.1.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 09:57:32 -06:00
90 changed files with 2992 additions and 180 deletions

No files matched your search

+20 -10
View File
@@ -12,10 +12,10 @@ on:
inputs:
product-version:
type: string
default: '1.1.0'
default: '1.2.1'
release-version:
type: string
default: '1.1.0'
default: '1.2.1'
permissions:
contents: read
@@ -42,7 +42,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: QRun-IO/qqq-print-protocol
ref: 42c0d249f81395f00408ec0f92638dcbbf5deb03
ref: ac835f8b9396b641b525669249fdb4a1e2a50c99
path: qqq-print-protocol
persist-credentials: false
- name: Set up .NET
@@ -59,19 +59,29 @@ jobs:
./build/Test-PackagingInputs.ps1
- name: Build unsigned prerelease MSI
env:
PRODUCT_VERSION: ${{ inputs.product-version || '1.1.0' }}
RELEASE_VERSION: ${{ inputs.release-version || '1.1.0' }}
PRODUCT_VERSION: ${{ inputs.product-version || '1.2.1' }}
RELEASE_VERSION: ${{ inputs.release-version || '1.2.1' }}
run: |
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# One fully branded installer per client edition (editions/<id>), named after its asset prefix.
$installers = @('QqqPrintAgent')
foreach ($folder in Get-ChildItem -LiteralPath editions -Directory | Sort-Object Name) {
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -Edition $folder.Name
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installers += (Get-Content -LiteralPath (Join-Path $folder.FullName 'edition.json') -Raw | ConvertFrom-Json).assetPrefix
}
New-Item -ItemType Directory -Path artifacts/release -Force | Out-Null
$source = "artifacts/installer/QqqPrintAgent-$env:PRODUCT_VERSION-x64.msi"
$name = "QqqPrintAgent-v$env:RELEASE_VERSION-x64.msi"
Copy-Item -LiteralPath $source -Destination "artifacts/release/$name"
Remove-Item -LiteralPath artifacts/release/SHA256SUMS -Force -ErrorAction SilentlyContinue
foreach ($prefix in $installers) {
$source = "artifacts/installer/$prefix-$env:PRODUCT_VERSION-x64.msi"
$name = "$prefix-v$env:RELEASE_VERSION-x64.msi"
Copy-Item -LiteralPath $source -Destination "artifacts/release/$name"
$hash = (Get-FileHash -LiteralPath "artifacts/release/$name" -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $name" | Add-Content artifacts/release/SHA256SUMS -Encoding ascii
}
Copy-Item -LiteralPath build/Install-Branded.ps1 -Destination artifacts/release/Install-Branded.ps1
Copy-Item -LiteralPath docs/branding.example.json -Destination artifacts/release/branding.example.json
$hash = (Get-FileHash -LiteralPath "artifacts/release/$name" -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $name" | Set-Content artifacts/release/SHA256SUMS -Encoding ascii
foreach ($file in @('Install-Branded.ps1', 'branding.example.json')) {
$hash = (Get-FileHash -LiteralPath "artifacts/release/$file" -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $file" | Add-Content artifacts/release/SHA256SUMS -Encoding ascii
+91 -9
View File
@@ -84,12 +84,38 @@ jobs:
# Order: verify configuration, locked restore and packaging checks, publish payload, stage owned files,
# sign, accept only the exact publisher, build MSI from the signed payload, sign and verify the MSI,
# then assemble the four release files with checksums computed after signing.
# Lists the client editions (editions/<id>) so the signing job runs once for the generic build and once per edition.
editions:
needs: version
if: needs.version.outputs.stable == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
list: ${{ steps.list.outputs.list }}
steps:
- name: Checkout tagged source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: List editions
id: list
shell: bash
run: |
list=$(find editions -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort | jq -R . | jq -sc '[""] + .')
echo "list=$list" >> "$GITHUB_OUTPUT"
echo "Editions: $list"
sign:
if: needs.version.outputs.stable == 'true'
needs: [version, verify, security, codeql, scripts]
needs: [version, verify, security, codeql, scripts, editions]
runs-on: windows-latest
timeout-minutes: 45
environment: release
strategy:
fail-fast: true
matrix:
edition: ${{ fromJson(needs.editions.outputs.list) }}
permissions:
contents: read
id-token: write # Azure OIDC for the signing identity; no secret or exported key is stored
@@ -99,6 +125,7 @@ jobs:
env:
PRODUCT_VERSION: ${{ needs.version.outputs.product }}
RELEASE_VERSION: ${{ needs.version.outputs.release }}
EDITION: ${{ matrix.edition }}
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
@@ -124,8 +151,25 @@ jobs:
./build/Test-WindowsSdkInventory.ps1 -VerifyPackages
./build/Test-BrandedInstaller.ps1
./build/Test-PackagingInputs.ps1
- name: Resolve the edition
run: |
$args = @()
$prefix = 'QqqPrintAgent'
if ($env:EDITION) {
$args = @('-Edition', $env:EDITION)
$prefix = (Get-Content -LiteralPath "editions/$env:EDITION/edition.json" -Raw | ConvertFrom-Json).assetPrefix
}
if ($prefix -cnotmatch '^[A-Za-z][A-Za-z0-9]{1,39}$') { throw 'Invalid edition asset prefix.' }
"EDITION_ARGS=$($args -join ' ')" | Add-Content -LiteralPath $env:GITHUB_ENV
"MSI_PATH=artifacts/installer/$prefix-$env:PRODUCT_VERSION-x64.msi" | Add-Content -LiteralPath $env:GITHUB_ENV
"ARTIFACT_NAME=windows-x64-installer-signed-$(if ($env:EDITION) { $env:EDITION } else { 'generic' })" | Add-Content -LiteralPath $env:GITHUB_ENV
Write-Output "Edition: '$env:EDITION' -> $prefix"
- name: Publish and check the payload
run: ./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -PublishOnly -PayloadDirectory "$env:RUNNER_TEMP/payload"
run: |
# Hashtable splat: an array splat binds "-Edition gg-forge" positionally (to CertificateThumbprint/TimestampServer) and trips the PublishOnly guard.
$editionArgs = @{}
if ($env:EDITION) { $editionArgs['Edition'] = $env:EDITION }
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -PublishOnly -PayloadDirectory "$env:RUNNER_TEMP/payload" @editionArgs
- name: Stage owned binaries
run: ./build/Sign-Release.ps1 -Operation Stage -Kind Payload -Root "$env:RUNNER_TEMP/payload" -Stage "$env:RUNNER_TEMP/sign-payload"
# The pinned signing action's own installer runs `dotnet tool install --add-source`, which the repository's
@@ -169,9 +213,13 @@ jobs:
- name: Accept signed binaries for the exact publisher
run: ./build/Sign-Release.ps1 -Operation Apply -Kind Payload -Root "$env:RUNNER_TEMP/payload" -Stage "$env:RUNNER_TEMP/sign-payload" -ExpectedSubject $env:ARTIFACT_SIGNING_SUBJECT
- name: Build MSI from the signed payload
run: ./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -SkipPublish -PayloadDirectory "$env:RUNNER_TEMP/payload"
run: |
# Hashtable splat: an array splat binds "-Edition gg-forge" positionally (to CertificateThumbprint/TimestampServer) and trips the PublishOnly guard.
$editionArgs = @{}
if ($env:EDITION) { $editionArgs['Edition'] = $env:EDITION }
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -SkipPublish -PayloadDirectory "$env:RUNNER_TEMP/payload" @editionArgs
- name: Stage MSI
run: ./build/Sign-Release.ps1 -Operation Stage -Kind Msi -Root "artifacts/installer/QqqPrintAgent-$env:PRODUCT_VERSION-x64.msi" -Stage "$env:RUNNER_TEMP/sign-msi"
run: ./build/Sign-Release.ps1 -Operation Stage -Kind Msi -Root $env:MSI_PATH -Stage "$env:RUNNER_TEMP/sign-msi"
- name: Sign MSI
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
with:
@@ -185,10 +233,44 @@ jobs:
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Accept signed MSI for the exact publisher
run: ./build/Sign-Release.ps1 -Operation Apply -Kind Msi -Root "artifacts/installer/QqqPrintAgent-$env:PRODUCT_VERSION-x64.msi" -Stage "$env:RUNNER_TEMP/sign-msi" -ExpectedSubject $env:ARTIFACT_SIGNING_SUBJECT
run: ./build/Sign-Release.ps1 -Operation Apply -Kind Msi -Root $env:MSI_PATH -Stage "$env:RUNNER_TEMP/sign-msi" -ExpectedSubject $env:ARTIFACT_SIGNING_SUBJECT
- name: Upload the signed installer of this edition
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.MSI_PATH }}
if-no-files-found: error
retention-days: 14
# Gathers the signed generic and edition installers, verifies each signature again, and writes the release
# files with one SHA256SUMS computed after signing. Nothing is built or signed here.
assemble:
if: needs.version.outputs.stable == 'true'
needs: [version, sign]
runs-on: windows-latest
timeout-minutes: 15
permissions:
contents: read
defaults:
run:
shell: pwsh
steps:
- name: Checkout tagged source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download the signed installers
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: windows-x64-installer-signed-*
path: artifacts/installer
merge-multiple: true
- name: Assemble signed release files and checksums
env:
PRODUCT_VERSION: ${{ needs.version.outputs.product }}
RELEASE_VERSION: ${{ needs.version.outputs.release }}
ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT }}
run: ./build/Package-Release.ps1 -AssembleOnly -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -ExpectedPublisherSubject $env:ARTIFACT_SIGNING_SUBJECT
- name: Upload signed installer
- name: Upload signed installers
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: windows-x64-installer-signed
@@ -196,12 +278,12 @@ jobs:
if-no-files-found: error
retention-days: 14
publish:
needs: [version, verify, security, codeql, scripts, sign]
# Every gate must pass; the signing job may be skipped only for an unsigned prerelease tag.
needs: [version, verify, security, codeql, scripts, sign, assemble]
# Every gate must pass; the signing and assembly jobs may be skipped only for an unsigned prerelease tag.
if: >-
${{ !cancelled() && needs.version.result == 'success' && needs.verify.result == 'success'
&& needs.security.result == 'success' && needs.codeql.result == 'success' && needs.scripts.result == 'success'
&& (needs.version.outputs.stable != 'true' && needs.sign.result == 'skipped' || needs.version.outputs.stable == 'true' && needs.sign.result == 'success') }}
&& (needs.version.outputs.stable != 'true' && needs.sign.result == 'skipped' && needs.assemble.result == 'skipped' || needs.version.outputs.stable == 'true' && needs.sign.result == 'success' && needs.assemble.result == 'success') }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
+60
View File
@@ -1,5 +1,65 @@
# Changelog
## Unreleased
Sign-out now ends the identity provider's browser session as well as the QQQ session, and every browser
sign-in sends `max_age=0` beside `prompt=login`, so the next sign-in always asks for credentials (the provider
must stay invisible to users). The companion's Sign out asks the service to log out with the application's
registered callback; when the server advertises `nativeSessionLogout` (UI API 1.10 or later) and lists that
callback, the server returns the provider's end-session data, Core checks it against the issuer QQQ advertises
and the provider's own discovery (HTTPS end-session endpoint on the issuer's origin, exact callback, bounded
hint), and the companion opens it in the browser and waits for the state-matched return. A server without the
capability gets the ordinary logout, and the log says the provider session was not ended. The command-line
`sign-out` cannot open the companion's callback inbox, so it still ends only the QQQ session and says so.
No change to pairing, history or the printing protocol.
## 1.2.1
Release-pipeline fix only; same source as 1.2.0 otherwise. The stable release workflow's per-edition
sign job passed the edition to `Build-Installer.ps1` through an array splat, which PowerShell binds
positionally, so `-Edition gg-forge` landed in the signing-certificate parameters and the GG Forge
sign job refused to publish; tag `v1.2.0` therefore produced no signed installer and must not be
moved or reused. The workflow now splats a hashtable. No application or wire change.
## 1.2.0
Client editions. A folder under `editions/<id>` (descriptor, SVG mark, ICO icon, optional logo) and
`Build-Installer.ps1 -Edition <id>` produce a fully branded build and installer: the client's mark and
icon on the splash, About, window, taskbar and Start menu; the client's product, company and file
description in every executable; the client's product name, manufacturer and service display name in
the MSI, forced over any stored labels; the MSI and release asset named after the edition's asset
prefix; and the installed updater following that asset on the shared feed. CI builds every edition,
and the stable release workflow signs and publishes one installer per edition beside the generic one
with a single checksum list. The first edition is `gg-forge` (GG Forge Print Agent by Greater Goods).
Deployment branding through `Install-Branded.ps1` is unchanged and still applies on top of an edition.
Native browser sign-in sends the attempt's nonce as `expectedNonce` when the grant policy is REQUIRED or
OPTIONAL or when discovery advertises `nativeSessionNonceValidation`; legacy servers keep the unchanged
exchange body. `build/Build-CiRelease.ps1` is the shared-CI packaging entrypoint and delegates to
`Package-Release.ps1`.
Printer approval per server. On a printing 2.4 server the client advertises `printer-approval-v1` and
takes approval from the server's `PRINTERS` frame, pushed after each accepted inventory and whenever an
administrator approves or disables one of this device's printers (protocol 1.1.0, QBit 1.1.0). On a 2.3
server the service instead reads the operator printer catalog after each accepted inventory and about once
a minute while connected. Either way the snapshot carries a per-printer server state:
approved, awaiting approval, not reported (with the reason) or not checked. The Printers page shows it in a
new Server column, in the summary line and in the expanded details, named for the connected server, so a
printer shared before a server change is never mistaken for one the new server approved. The reading is
reset with the connection. The activity log viewer has a Clear logs button, the connection dialog keeps its
result banner above the form and shows a waiting state during browser sign-in, and the service's sign-in reply
no longer repeats itself. No wire change.
## 1.1.1
Browser sign-in fix. The companion runs elevated, and its callback inbox pipe was keyed with
PipeOptions.CurrentUserOnly to the elevated token's default owner (the Administrators group), so
the protocol handler the browser launches as the ordinary user was refused with "Access to the
path is denied" and no browser sign-in could ever complete. The inbox is now created with an
explicit descriptor owned by and restricted to the user's own SID, and the handler verifies the
companion by process image instead of the owner comparison. A protocol-handler trace in the
per-user profile, copied into the activity log when a browser sign-in does not complete, makes
the hand-off visible. No wire change.
## 1.1.0
Diagnostics for new accounts and servers. The session client now narrates every step of discovery,
+7 -2
View File
@@ -60,14 +60,19 @@
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
CLI's version output show as a short hash. -->
<PropertyGroup>
<VersionPrefix>1.1.0</VersionPrefix>
<VersionPrefix>1.2.1</VersionPrefix>
<VersionSuffix></VersionSuffix>
</PropertyGroup>
<!-- Product metadata stamped into every assembly. -->
<!-- Product metadata stamped into every assembly. A client edition build (build/Build-Installer.ps1 -Edition)
passes BrandProduct and BrandCompany, so each executable's product, company and file description carry the
client's brand; author and copyright stay with the source. -->
<PropertyGroup>
<Authors>James Maes</Authors>
<Product>QQQ Print Agent</Product>
<Product Condition="'$(BrandProduct)' != ''">$(BrandProduct)</Product>
<AssemblyTitle Condition="'$(BrandProduct)' != ''">$(BrandProduct)</AssemblyTitle>
<Company Condition="'$(BrandCompany)' != ''">$(BrandCompany)</Company>
<Copyright>Copyright (c) 2026 James Maes</Copyright>
<RepositoryUrl>https://github.com/QRun-IO/qqq-print-agent</RepositoryUrl>
<RepositoryType>git</RepositoryType>
+5 -5
View File
@@ -8,13 +8,13 @@
Print from **QQQ 4.1+** to local Windows printers without opening inbound network ports. The Windows service opens an authenticated two-way WebSocket to QQQ for commands, status and job notifications. Documents download separately over HTTPS, so a large transfer does not block the control connection. There is no job/control polling fallback.
**Current stable release: [1.0.1](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1).** The service, companion, CLI and MSI are signed with Azure Artifact Signing. The matching server release is [qbit-printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), using QQQ **4.1.0-RC.21** for native session login, with [protocol 1.0.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.0.0). Product versions are separate from native printing API **2.3**, wire payload **v1** and WebSocket subprotocol `qqq-printing.v1`.
**Current stable release: [1.2.1](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** (generic and GG Forge edition installers). The service, companion, CLI and MSI are signed with Azure Artifact Signing. The matching server release is [qbit-printing 1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0), using QQQ **4.1.0-RC.21** for native session login, with [protocol 1.1.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0); 1.2.1 also works unchanged against qbit-printing 1.0.0. Product versions are separate from native printing API **2.4** (2.3 compatible), wire payload **v1** and WebSocket subprotocol `qqq-printing.v1`.
The setup/status application uses the same [KofTwentyTwo AppKit](https://git.kof22.com/KofTwentyTwo/AppKit) foundation as GCLO and MarkdownViewr.Windows. QQQ's mascot Query appears in About and branded warehouse test documents.
## Install and connect
1. Download `QqqPrintAgent-v1.0.1-x64.msi` and `SHA256SUMS` from the [stable release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1). Check the checksum and Authenticode signature as described in [Installation](docs/INSTALLATION.md).
1. Download `QqqPrintAgent-v1.2.1-x64.msi` (or a client edition such as `GgForgePrintAgent-v1.2.1-x64.msi`; one edition per PC) and `SHA256SUMS` from the [stable release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1). Check the checksum and Authenticode signature as described in [Installation](docs/INSTALLATION.md).
2. Install with administrator rights. The MSI installs the automatic Windows service and adds **QQQ Print Agent** under **Print Connectors** in Start.
3. Open the app and accept its administrator prompt. Choose **Settings → Server connection → Sign in / Connection settings**, enter the QQQ HTTPS server address and select **Discover sign-in options**. Sign in using the advertised username/password or browser flow.
4. For a new device, create an enrollment code in QQQ under **Printing → Print devices → Actions → Enroll a print device**, then enter it in the client. Existing pairings are reused or explicitly associated with the signed-in account; routine upgrades do not need a replacement pairing.
@@ -55,7 +55,7 @@ The client reports the Windows queue's machine-default media as a revision-bound
## Requirements and limits
| Area | Stable 1.0.1 behavior |
| Area | Stable 1.2.1 behavior |
| --- | --- |
| Server | QQQ 4.1+; recommended cohort QQQ 4.1.0-RC.21 and qbit-printing 1.0.0, native printing API 2.3 |
| Windows | x64; Windows 11 used for hardware acceptance; API target Windows 10 build 19041+ |
@@ -87,7 +87,7 @@ git clone https://git.kof22.com/QRun-IO/qqq-print-protocol.git
git -C qqq-print-protocol checkout 42c0d249f81395f00408ec0f92638dcbbf5deb03
Set-Location qqq-print-agent
./build/Verify.ps1
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
./build/Build-Installer.ps1 -ProductVersion 1.2.1 -ReleaseVersion 1.2.1
```
Run verification only on a permitted Windows test host; host-specific restrictions remain in force. Local packaging is unsigned unless explicitly signed; stable release signing runs through the protected workflow. Neither command installs the service or sends a physical print.
@@ -96,7 +96,7 @@ Run verification only on a permitted Windows test host; host-specific restrictio
## Release evidence
The signed 1.0.1 release passed its protected build, test, security, CodeQL, script and signing workflow. Published checksums, signatures and provenance were verified. The local 1.0.1 MSI upgrade completed successfully on 2026-11-03 with matching app/service/CLI versions and a running service. Dated hardware and earlier upgrade evidence remains in [Acceptance](docs/ACCEPTANCE.md); current release status is in [Release readiness](docs/RELEASE-READINESS.md). Earlier test results retain their original version and scope.
The signed 1.2.1 release passed its protected build, test, security, CodeQL, script and per-edition signing workflow (run 42223525626); checksums, Authenticode signatures and provenance attestations were verified on the published generic and GG Forge installers, and the GG Forge installer was upgraded in place on the development PC with pairing, session and settings preserved. The earlier signed 1.0.1 release passed its protected build, test, security, CodeQL, script and signing workflow. Published checksums, signatures and provenance were verified. The local 1.0.1 MSI upgrade completed successfully on 2026-11-03 with matching app/service/CLI versions and a running service. Dated hardware and earlier upgrade evidence remains in [Acceptance](docs/ACCEPTANCE.md); current release status is in [Release readiness](docs/RELEASE-READINESS.md). Earlier test results retain their original version and scope.
## License and acknowledgments
+24
View File
@@ -0,0 +1,24 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Repository-owned CI packaging entrypoint for the shared Gitea .NET CI.
.DESCRIPTION
The shared workflow runs this after verification with only the typed versions. It delegates to Package-Release.ps1,
the single packaging path that CI, the release workflow and local packaging share: SDK inventory, branded-installer
dry run, packaging-input checks, the unsigned generic MSI and one unsigned MSI per client edition, and the release
bundle with SHA256SUMS. It takes no certificate, timestamp server or payload input and never signs or installs anything.
#>
[CmdletBinding()]
param(
[ValidatePattern('^\d+\.\d+\.\d+$')]
[string] $ProductVersion = '1.2.1',
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
[string] $ReleaseVersion = '1.2.1'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
& (Join-Path $PSScriptRoot 'Package-Release.ps1') -ProductVersion $ProductVersion -ReleaseVersion $ReleaseVersion
+65 -5
View File
@@ -12,7 +12,11 @@ param(
[switch] $SkipPublish,
[switch] $PublishOnly,
[string] $CertificateThumbprint,
[string] $TimestampServer
[string] $TimestampServer,
# A client edition folder name under editions/ (see editions/README.md): the build ships that client's mark, icon and
# names in every executable and the installer, and names the MSI after the edition's asset prefix.
[ValidatePattern('^[a-z0-9][a-z0-9-]{0,39}$')]
[string] $Edition
)
$ErrorActionPreference = 'Stop'
@@ -42,6 +46,36 @@ if(-not $releaseMatch.Success -or $revision -gt 65535 -or ($releaseMatch.Groups[
throw 'Unsupported ReleaseVersion: use X.Y.Z-rc.N (N 1..65534) or stable X.Y.Z so the numeric file version orders upgrades.'
}
$fileVersion = "$ProductVersion.$revision"
$brand = $null
if($Edition)
{
$editionRoot = Join-Path $projectRoot "editions\$Edition"
$editionFile = Join-Path $editionRoot 'edition.json'
if(-not (Test-Path -LiteralPath $editionFile -PathType Leaf)) { throw "Unknown edition '$Edition': $editionFile does not exist." }
$brand = Get-Content -LiteralPath $editionFile -Raw | ConvertFrom-Json
foreach($required in @('id', 'displayName', 'companyName', 'assetPrefix'))
{
if(-not ($brand.PSObject.Properties.Name -contains $required) -or [string]::IsNullOrWhiteSpace($brand.$required)) { throw "edition.json is missing '$required'." }
}
if($brand.id -cne $Edition) { throw "edition.json id '$($brand.id)' must equal the folder name '$Edition'." }
if($brand.assetPrefix -cnotmatch '^[A-Za-z][A-Za-z0-9]{1,39}$') { throw 'assetPrefix must be 2 to 40 letters or digits starting with a letter.' }
foreach($label in @($brand.displayName, $brand.companyName))
{
if($label.Length -gt 180 -or $label -match '[\x00-\x1F;=]') { throw 'Edition names must be at most 180 characters without control characters, semicolons or equals signs.' }
}
foreach($asset in @('app-icon.svg', 'app.ico'))
{
if(-not (Test-Path -LiteralPath (Join-Path $editionRoot $asset) -PathType Leaf)) { throw "Edition asset missing: editions\$Edition\$asset" }
}
}
$msiBaseName = if($brand) { $brand.assetPrefix } else { 'QqqPrintAgent' }
$brandPublishArgs = @()
$brandInstallerArgs = @()
if($brand)
{
$brandPublishArgs = @("-p:BrandEdition=$Edition", "-p:BrandProduct=$($brand.displayName)", "-p:BrandCompany=$($brand.companyName)")
$brandInstallerArgs = @("-p:BrandName=$($brand.displayName)", "-p:BrandCompany=$($brand.companyName)", '-p:BrandForced=1', "-p:MsiBaseName=$msiBaseName")
}
if($SkipPublish -and -not $PayloadDirectory)
{
throw 'SkipPublish requires an explicit reviewed PayloadDirectory.'
@@ -70,9 +104,31 @@ if(-not $SkipPublish)
}
foreach($entry in $projects.GetEnumerator())
{
& dotnet publish (Join-Path $projectRoot $entry.Value) --configuration Release --runtime win-x64 --self-contained true --output (Join-Path $payloadRoot $entry.Key) '-p:Platform=x64' '-p:RestoreLockedMode=true' "-p:Version=$ReleaseVersion" "-p:FileVersion=$fileVersion"
& dotnet publish (Join-Path $projectRoot $entry.Value) --configuration Release --runtime win-x64 --self-contained true --output (Join-Path $payloadRoot $entry.Key) '-p:Platform=x64' '-p:RestoreLockedMode=true' "-p:Version=$ReleaseVersion" "-p:FileVersion=$fileVersion" @brandPublishArgs
if($LASTEXITCODE -ne 0) { throw "Publishing $($entry.Key) failed." }
}
if($brand)
{
# The companion carries the descriptor through its project; the service and CLI read the same file beside their executables.
foreach($target in @('service', 'cli'))
{
Copy-Item -LiteralPath (Join-Path $projectRoot "editions\$Edition\edition.json") -Destination (Join-Path $payloadRoot "$target\edition.json") -Force
}
}
}
if($brand)
{
foreach($target in @('app', 'service', 'cli'))
{
if(-not (Test-Path -LiteralPath (Join-Path $payloadRoot "$target\edition.json") -PathType Leaf)) { throw "The payload does not carry edition '$Edition' in $target; publish it with the same -Edition." }
}
foreach($asset in @('app\Assets\Brand\app-icon.svg', 'app\Assets\Brand\app.ico'))
{
if((Get-FileHash -LiteralPath (Join-Path $payloadRoot $asset) -Algorithm SHA256).Hash -cne (Get-FileHash -LiteralPath (Join-Path $projectRoot "editions\$Edition\$(Split-Path $asset -Leaf)") -Algorithm SHA256).Hash)
{
throw "The published $asset is not edition '$Edition' artwork."
}
}
}
$executables = @(
@@ -88,6 +144,10 @@ foreach($executable in $executables)
{
throw "Published executable version does not match ReleaseVersion: $executable"
}
if($brand -and (Get-Item -LiteralPath $executable).VersionInfo.ProductName -cne $brand.displayName)
{
throw "Published executable does not carry edition '$Edition' product metadata: $executable"
}
if((Get-Item -LiteralPath $executable).VersionInfo.FileVersion -cne $fileVersion)
{
throw "Published executable file version does not match $fileVersion; Windows Installer would not replace an older equal file: $executable"
@@ -128,12 +188,12 @@ if($CertificateThumbprint)
}
# WiX incremental inputs do not reliably invalidate a changed version/payload path.
& dotnet build $installerProject --configuration Release -t:Rebuild '-p:RestoreLockedMode=true' "-p:ProductVersion=$ProductVersion" "-p:PayloadRoot=$payloadRoot" "-p:OutputPath=$buildDirectory\"
& dotnet build $installerProject --configuration Release -t:Rebuild '-p:RestoreLockedMode=true' "-p:ProductVersion=$ProductVersion" "-p:PayloadRoot=$payloadRoot" "-p:OutputPath=$buildDirectory\" @brandInstallerArgs
if($LASTEXITCODE -ne 0) { throw 'MSI build failed.' }
$builtMsi = Join-Path $buildDirectory "QqqPrintAgent-$ProductVersion-x64.msi"
$builtMsi = Join-Path $buildDirectory "$msiBaseName-$ProductVersion-x64.msi"
if(-not (Test-Path -LiteralPath $builtMsi)) { throw 'MSI output was not produced.' }
# Keep earlier verified installers available for recovery when WiX cleans its build outputs.
$msi = Join-Path $outputDirectory "QqqPrintAgent-$ProductVersion-x64.msi"
$msi = Join-Path $outputDirectory "$msiBaseName-$ProductVersion-x64.msi"
Copy-Item -LiteralPath $builtMsi -Destination $msi -Force
if($CertificateThumbprint)
+48 -13
View File
@@ -4,8 +4,8 @@
[CmdletBinding()]
param(
[string] $ProductVersion = '1.1.0',
[string] $ReleaseVersion = '1.1.0',
[string] $ProductVersion = '1.2.1',
[string] $ReleaseVersion = '1.2.1',
# Assemble artifacts/release from an already built MSI (the signed stable flow builds and signs it in
# separate workflow steps). With ExpectedPublisherSubject the MSI must carry that publisher's signature.
[switch] $AssembleOnly,
@@ -17,6 +17,28 @@ param(
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$projectRoot = Split-Path -Parent $PSScriptRoot
function Get-Edition
{
<#
.SYNOPSIS
Lists the client editions under editions/ (folder id and release asset prefix), in name order.
#>
$root = Join-Path $projectRoot 'editions'
if(-not (Test-Path -LiteralPath $root -PathType Container)) { return @() }
$editions = @()
foreach($folder in (Get-ChildItem -LiteralPath $root -Directory | Sort-Object Name))
{
$descriptor = Join-Path $folder.FullName 'edition.json'
if(-not (Test-Path -LiteralPath $descriptor -PathType Leaf)) { throw "Edition folder $($folder.Name) has no edition.json." }
$edition = Get-Content -LiteralPath $descriptor -Raw | ConvertFrom-Json
if($edition.id -cne $folder.Name) { throw "edition.json id '$($edition.id)' must equal the folder name '$($folder.Name)'." }
if($edition.assetPrefix -cnotmatch '^[A-Za-z][A-Za-z0-9]{1,39}$') { throw "Edition $($folder.Name) has an invalid assetPrefix." }
$editions += [pscustomobject]@{ Id = $folder.Name; AssetPrefix = $edition.assetPrefix }
}
return $editions
}
if($ExpectedPublisherSubject -and -not $AssembleOnly)
{
throw 'ExpectedPublisherSubject applies only to -AssembleOnly; signing runs in the release workflow between publish and MSI build.'
@@ -41,22 +63,35 @@ try
$global:LASTEXITCODE = 0
& (Join-Path $PSScriptRoot 'Build-Installer.ps1') -ProductVersion $ProductVersion -ReleaseVersion $ReleaseVersion
if($LASTEXITCODE -ne 0) { throw 'Installer build failed.' }
foreach($edition in Get-Edition)
{
$global:LASTEXITCODE = 0
& (Join-Path $PSScriptRoot 'Build-Installer.ps1') -ProductVersion $ProductVersion -ReleaseVersion $ReleaseVersion -Edition $edition.Id
if($LASTEXITCODE -ne 0) { throw "Installer build failed for edition $($edition.Id)." }
}
}
$builtMsi = Join-Path $projectRoot "artifacts/installer/QqqPrintAgent-$ProductVersion-x64.msi"
if($ExpectedPublisherSubject)
{
& (Join-Path $PSScriptRoot 'Sign-Release.ps1') -Operation Verify -Kind Msi -Root $builtMsi -ExpectedSubject $ExpectedPublisherSubject
}
# The generic installer plus one per client edition (editions/<id>, named after its asset prefix). Every one
# must exist and, for a signed release, carry the publisher's signature; an edition is never silently dropped.
$installers = @([pscustomobject]@{ Prefix = 'QqqPrintAgent'; Label = 'generic' }) + @(Get-Edition | ForEach-Object { [pscustomobject]@{ Prefix = $_.AssetPrefix; Label = "edition $($_.Id)" } })
$releaseDirectory = Join-Path $projectRoot 'artifacts/release'
New-Item -ItemType Directory -Path $releaseDirectory -Force | Out-Null
$name = "QqqPrintAgent-v$ReleaseVersion-x64.msi"
Copy-Item -LiteralPath $builtMsi -Destination (Join-Path $releaseDirectory $name) -Force
$sums = Join-Path $releaseDirectory 'SHA256SUMS'
Remove-Item -LiteralPath $sums -Force -ErrorAction SilentlyContinue
foreach($installer in $installers)
{
$builtMsi = Join-Path $projectRoot "artifacts/installer/$($installer.Prefix)-$ProductVersion-x64.msi"
if(-not (Test-Path -LiteralPath $builtMsi -PathType Leaf)) { throw "Missing installer for the $($installer.Label) build: $builtMsi" }
if($ExpectedPublisherSubject)
{
& (Join-Path $PSScriptRoot 'Sign-Release.ps1') -Operation Verify -Kind Msi -Root $builtMsi -ExpectedSubject $ExpectedPublisherSubject
}
$name = "$($installer.Prefix)-v$ReleaseVersion-x64.msi"
Copy-Item -LiteralPath $builtMsi -Destination (Join-Path $releaseDirectory $name) -Force
$hash = (Get-FileHash -LiteralPath (Join-Path $releaseDirectory $name) -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $name" | Add-Content -LiteralPath $sums -Encoding ascii
}
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'Install-Branded.ps1') -Destination (Join-Path $releaseDirectory 'Install-Branded.ps1') -Force
Copy-Item -LiteralPath (Join-Path $projectRoot 'docs/branding.example.json') -Destination (Join-Path $releaseDirectory 'branding.example.json') -Force
$sums = Join-Path $releaseDirectory 'SHA256SUMS'
$hash = (Get-FileHash -LiteralPath (Join-Path $releaseDirectory $name) -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $name" | Set-Content -LiteralPath $sums -Encoding ascii
foreach($file in @('Install-Branded.ps1', 'branding.example.json'))
{
$hash = (Get-FileHash -LiteralPath (Join-Path $releaseDirectory $file) -Algorithm SHA256).Hash.ToLowerInvariant()
+4
View File
@@ -13,6 +13,10 @@
is run separately and must not be inferred from a successful unit-test run.
Report each suite separately; duplicate package measurements must not be averaged. -->
<RunSettings>
<!-- A test run that executes zero tests (for example, a test assembly blocked from loading) must fail, not pass. -->
<RunConfiguration>
<TreatNoTestsAsError>true</TreatNoTestsAsError>
</RunConfiguration>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
+16
View File
@@ -1,5 +1,21 @@
# Customer branding
There are two levels. A **client edition** is a separate, fully branded build and installer: its own mark, icon and logo, the client's product and company in every executable and in Windows, and its own installer and update asset. **Deployment branding** applies labels, a colour and artwork to the shared installer at install time. Editions are for clients who get their own installer; deployment branding is for customers of the shared one.
## Client editions
Add a folder under `editions/` (see [editions/README.md](../editions/README.md)) with `edition.json`, `app-icon.svg`, `app.ico` and an optional `logo.png`, then build with:
```powershell
./build/Build-Installer.ps1 -ProductVersion 1.2.0 -ReleaseVersion 1.2.0 -Edition gg-forge
```
The result is `artifacts/installer/<assetPrefix>-<version>-x64.msi`, for example `GgForgePrintAgent-1.2.0-x64.msi`. Installed, it shows the client's name and mark on the splash, About, window title, tray tooltip, Start menu, Programs list and service; Task Manager and file properties show the client's product, company and description; and the in-app updater follows `<assetPrefix>-v<version>-x64.msi` on the release feed. CI builds every edition's unsigned installer, and the stable release workflow signs and publishes one MSI per edition beside the generic one, all listed in the same `SHA256SUMS`. An edition installer forces its client's labels over any labels an earlier install stored.
An edition's descriptor can also name the server as its users know it (`serverName`, used in sign-in and status text instead of QQQ) and leave the Query mascot and License sections out of About (`aboutMascot`, `aboutLicense`); the generic build keeps all of them. Editions share application id, data paths, executable file names, the install directory, the protocol handler, the MSI upgrade identity and the signing identity, so an edition installer upgrades the generic build and other editions in place and keeps the pairing and history. Deployment branding below still applies on top of an edition.
## Deployment branding
Use the same [signed stable machine MSI](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) for every customer. No customer-specific build is required. The deployment wrapper stages an optional machine branding file and local assets, then supplies customer labels to Windows Installer. The companion, service display name, installed-product labels and all-users Start menu shortcut use that identity.
## Install with a customer name
+62
View File
@@ -0,0 +1,62 @@
# Printing cohort handoff for the Forge agent — 2026-11-05
Reusable changes were made in the printer client and the printing QBit repositories. Existing
authentication, discovery, enrollment, inventory and printing contracts are preserved; there are
**no breaking changes**. Nothing was installed over Forge's working client or dependencies; adoption,
pinning and the pairing tests (browser sign-in, session resume, enrollment, server printing,
TC22 → Echo → Z2) are yours. The previous cohort stays available for rollback.
## Exact versions and artifacts
| Component | Version | Tag / source | Artifact | SHA-256 |
| --- | --- | --- | --- | --- |
| Protocol (contract + fixtures) | 1.1.0 | `v1.1.0` at `5005f3c9e27053b6c327bb923695012832c0fdcc` | https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0 — `qqq-print-protocol-1.1.0.tar.gz` | `541f0f3054cbfe9ce16e7dea58fad01b55a7f0e9246ade8f30df1bb826940703` |
| Printing QBit (server) | **1.1.2**, native printing extension **2.4.0**, on QQQ **4.1.0-RC.22** from the Gitea registry (1.1.1 is the same code on RC.21 from Maven Central; 1.1.0 is superseded: do not adopt it) | `v1.1.2` at `f5639a98be9a2f15d4c61cf0955fe4ecef85824f`, push build 6435 + tag build 6475 | Maven `com.kingsrook.qbits:qbit-printing:1.1.2` from `https://git.kof22.com/api/packages/QRun-IO/maven` (repository id `qrun-io`, reader token); same bytes on https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2 | JAR `786e6e570c5df29b01529562efb246a4f4562bb1d9fff6bea77d264e0d7536ce`, POM `7691a4e1cb7b03c7a559b0e2b92eacd92f86545b98bddafac86fc70f8602b03d` |
| Windows print client | **1.2.1** (1.2.0 content + release-pipeline fix; tag v1.2.0 produced no installer) | `v1.2.1` at `3b9d75d18f9cc8b80f4cf6d5aae81cccf8d61243`, GitHub release run 42223525626 | Signed installers: `QqqPrintAgent-v1.2.1-x64.msi` (generic) and `GgForgePrintAgent-v1.2.1-x64.msi` (GG Forge edition), `SHA256SUMS`, on https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1 and byte-identical on https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1 | generic `09920872b3447f8679b404f2b5ebd7d9fe82204b8007db63d58e4287f436f2eb`, GG Forge `5ce489092c1403449174b8a35eda039cd452e8685cbfe436d99aea3e15a5d8b4`; Authenticode valid (CN=James Maes, Microsoft timestamp); GitHub provenance attestation verified |
Previous cohort for rollback: protocol 1.0.0, QBit 1.0.0 (extension 2.3.0), Windows 1.0.1 / 1.1.1.
**2026-11-06 update (QBit 1.1.1).** Forge's qualification of 1.1.0 found that the realtime upgrade fails before WELCOME behind an ingress that lowercases header names (the device id was read case-sensitively from the WebSocket context; the same defect exists in 1.0.0, which Forge ran with a local patch). 1.1.1 resolves the device once on the authorized upgrade and carries it on the connection, with a regression test that connects with lowercase header names. No contract change: the pairing matrix and everything else in this handoff is unchanged; replace the `1.0.0-forge…headers1` coordinate with the published 1.1.1 and drop the local patch.
**2026-11-06 update (QBit 1.1.2, Gitea-only runtime).** James's direction: every QRun artifact resolves from the QRun-IO Gitea registry, no more Maven Central. 1.1.2 is the 1.1.1 code certified on QQQ **4.1.0-RC.22** (the core version Forge already pins; it exists only in the Gitea registry) with `-Pui-demo verify`, 200 tests, 0 failures, and published by the shared `ci-workflows` pipeline. `qqq-frontend-next` 1.0.0-RC.37 is also mirrored into the registry byte-for-byte from Central. Pin `com.kingsrook.qbits:qbit-printing:1.1.2` together with core 4.1.0-RC.22 and read both from `https://git.kof22.com/api/packages/QRun-IO/maven`; third-party libraries still come through the Maven Central mirror until the registry proxies them. No contract change; pairing matrix unchanged.
## What changed, by contract
- **Printer approval over the realtime channel (extension 2.4.0, additive).** Client advertises
`printer-approval-v1` in HELLO only when discovery shows a stable printing 2.x with minor >= 4.
Server then sends a `PRINTERS` frame (`{ "printers": [ { "printerId", "enabled" } ] }`, the complete
approval state of the device's exposed printers) after each accepted INVENTORY and whenever an
administrator approves or disables one of that device's printers. Not acknowledged. A 2.3 peer on
either side keeps the exact 1.0 wire: 1.0.x/1.1.x clients never advertise it and never receive it;
the 1.2.1 client against QBit 1.0.0 reads the operator catalog instead (user-scoped; shows "Not
reported" without operator permission).
- **Remove printers / Remove device (QBit).** Archives with `removed_at`; hidden from lists, catalog,
claims and the PRINTERS frame; a re-shared printer returns as awaiting approval; retention purges
removed rows that no job references. QQQ 4.1 has no table-level default filter, so the raw Printers
/ Print devices tables still show removed rows until a "Removed at is empty" saved view is added.
- **Client sign-in nonce.** Sends `expectedNonce` under REQUIRED, OPTIONAL and the discovery capability
`nativeSessionNonceValidation`; legacy servers unchanged (PR 11 content, already merged earlier).
- **Client UX/ops.** Server column on the Printers page; verbose sign-in diagnostics with DEBUG level;
log size rollover; per-server untrusted-certificate option (development only, origin-bound);
connection dialog rework; Clear logs button in the log viewer; client editions (GG Forge build is a
separate MSI with its own update asset; one edition per PC, shared UpgradeCode).
## Pairing matrix (what must hold in your tests)
| Server | Client | Expected |
| --- | --- | --- |
| QBit 1.0.0 (2.3.0) | 1.2.1 | No `printer-approval-v1` in HELLO; catalog fallback; everything else as 1.1.1 |
| QBit 1.1.2 (2.4.0) | 1.0.1 / 1.1.1 | Four-capability HELLO at most; no PRINTERS frame; unchanged behaviour |
| QBit 1.1.2 (2.4.0) | 1.2.1 | Five-capability HELLO; PRINTERS after inventory and on approval change; Printers page shows Approved / Awaiting approval live |
## Evidence
- Protocol: `npm run check` (79 schema definitions, 82 fixtures), lint, typecheck, quality all green on 5005f3c.
- QBit: `mvnw -Pui-demo verify` 199 tests, 0 failures, 0 Checkstyle; main run 5473 verify + secrets + gate + post-publish green; registry files read back byte-for-byte.
- Client: hosted GitHub CI run 42116396114 on the PR head (197 Core + 362 Windows tests, 0 failures); Gitea required checks green on both merges; release run 42223525626 (verify, security, CodeQL, sign generic + GG Forge, assemble, publish).
## Notes for adoption
- Pin all three together. The client's CI fixture pin is `ac835f8b9396b641b525669249fdb4a1e2a50c99`; the QBit's is `5005f3c9e27053b6c327bb923695012832c0fdcc` (docs PRs 29 and 12 pending).
- Forge must approve the shared printers on the server after the first inventory from a 1.2.0 client, as before; the difference is that the client now shows that state.
- Coordinate installation on the Forge print station yourself; the GG Forge rc.3 build on James's PC is a local test build (file version 1.2.0.3) and will update to the signed 1.2.1 from its own GG Forge asset.
+43
View File
@@ -0,0 +1,43 @@
# Printer approval as seen by the agent
Sharing a printer is a local choice; approving it is the server's. The agent must show both for the
server it is connected to, so a printer shared before a server change is never mistaken for one the
new server approved.
## Printing extension 2.4: the server pushes approval (client 1.2.0, QBit 1.1.0, protocol 1.1.0)
When discovery advertises a stable printing `2.4.0` or newer extension, the client adds
`printer-approval-v1` to its HELLO capabilities. The server then sends a `PRINTERS` frame
(`{ "printers": [ { "printerId", "enabled" } ] }`, the complete approval state of every printer
in the device's latest accepted inventory) after each accepted INVENTORY and whenever an
administrator approves or disables one of this device's printers. The frame is authoritative:
enabled printers are approved, a shared printer listed as disabled or not listed yet is awaiting
approval, and the operator catalog is not read on such a server. The reading is reset with the
connection like every other negotiated state. The contract is in the protocol repository
(`docs/REALTIME.md`, `RealtimePrinters` in the schema, `fixtures/realtime-printers.message.json`).
## Printing extension 2.3: the client reads the operator catalog
- The inventory snapshot (`INVENTORY` over the realtime channel, `PUT /device/printers` otherwise) is
resent on every new connection, so a server that has never seen this computer's printers receives
them as soon as the agent connects. New printers are disabled on the server until approved.
- The device routes never report approval, so after each accepted inventory, and about once a
minute while connected, the service reads the operator printer catalog (`GET /operator/printers`,
printing extension 2.3) and keeps the entries whose `deviceId` is this device. That catalog lists
only approved, exposed destinations the signed-in user may use, so an entry is the server's
confirmation of approval. The reading is kept in memory and reset with the connection.
- Each printer in the status snapshot carries a `serverState`: `APPROVED`, `AWAITING_APPROVAL`,
`NOT_SHARED`, `NOT_REPORTED` (the catalog could not be read; the reason is in `printerSync.detail`
and the activity log) or `NOT_CHECKED` (no server asked yet). The Printers page shows it in the
Server column, the summary line and the expanded details.
## Why both paths exist
The catalog is user-scoped. On a 2.3 server, a signed-in user who is not permitted to read it leaves
every shared printer at `NOT_REPORTED` with the reason in the tooltip and the activity log. The 2.4
PRINTERS frame is device-scoped and pushed on change, so it needs no operator permission and
updates the Printers page the moment an approval changes. Pair the 1.2.0 client with QBit 1.1.0
(extension 2.4.0) for that behaviour; against QBit 1.0.0 (2.3.0) the client stays on the catalog
and the wire is unchanged. The compatibility matrix and the negotiation rule (`printer-approval-v1`
minimum 2.4.0, capabilities re-evaluated on every socket) are in the protocol repository's
`docs/VERSIONS.md`.
+2
View File
@@ -10,6 +10,8 @@ Older pairings without a bound QQQ user require sign-in and association. Routine
The installed browser callback is exactly `io.qrun.qqq-print-agent:/oauth/callback`. The host must advertise and approve this redirect for its configured QQQ OAuth2 session client; the separate public bearer client is not substituted for the server-managed session exchange. MSI registers the stable scheme independently of display branding and removes its owned registration on uninstall. The browser launches the quoted installed CLI callback helper, which forwards only to an active companion attempt in the same Windows user/session, after checking the companion executable. The companion validates the single-use state and PKCE exchange using the standard QQQ grant. No callback starts a new sign-in attempt. No provider registration, registry changes or installed browser sign-in are performed by source tests.
**Shared server with the Android scanner.** One QQQ host and one OAuth2 provider/`clientId` can serve both clients. The native grant's `redirectUris` must contain both exact callbacks, `io.qrun.qqq-print-agent:/oauth/callback` (print agent) and `io.qrun.mobile.scanner.oauth://callback/oauth2redirect` (scanner), and the provider must allow both exactly (no wildcards). The host must also advertise the `api` and `android` client channels and both the `printing` and `mobile-scanners` extensions. Nonce handling is negotiated per server: when the grant descriptor has `nonceValidation: REQUIRED` (which the scanner demands) or `OPTIONAL`, or when discovery advertises the `nativeSessionNonceValidation` capability, the agent sends that attempt's own authorization nonce as `expectedNonce` in the `manageSession` exchange. When neither is present (older servers) the exchange body is unchanged. Any other non-empty policy value is refused as a server configuration problem before a browser opens. The nonce is never taken from the callback, regenerated or logged. Human permissions (`printing_admin` versus the scanner's `mobile.*`) and device credentials stay separate. Validation against a real shared Authentik host is pending; this is not released.
Normal CLI `printers`, `enroll`, `expose`, `run` and `sign-out` commands use the installed authenticated service. `run` requests a connection refresh; the service owns unattended printing. Direct bearer-based foreground diagnostics require explicit `--legacy-compat` and a separate user data directory; they cannot use `--service`. They are a compatibility diagnostic, not the app's sign-in or fallback path.
The server and its identity provider govern session lifetime, renewal and current permissions. Unattended service operation does not promise an indefinite authorization lifetime: expiration, revocation or a policy change can require a new sign-in. Configure the advertised native session grant and approved callback for each deployment.
+5 -1
View File
@@ -2,7 +2,7 @@
Gitea is primary for source branches, reviews and normal pull requests. GitHub is the automatic source shadow. The existing authorized [GitHub release workflow](../.github/workflows/release.yml), its CI/security dependencies and the GitHub release assets remain the signing and installed-updater bridge. Do not retire those Windows workflows or remove the feed as part of ordinary source migration.
Current stable release: **[1.0.1 on Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1)** and the matching **[GitHub assets](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1)**. The protected [release run](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41532214447) completed signing and publication. The earlier immutable `v1.0.0` tag produced no signed installer; it must not be moved or reused.
Current stable release: **[1.2.1 on Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** and the matching **[GitHub assets](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** (generic and GG Forge installers). The protected [release run](https://github.com/QRun-IO/qqq-print-agent/actions/runs/42223525626) completed signing and publication; checksums, Authenticode signatures and provenance attestations were verified on the downloaded assets (generic `09920872…`, GG Forge `5ce48909…`). The earlier immutable `v1.0.0` and `v1.2.0` tags produced no signed installer (`v1.2.0`: the edition sign job's array splat, fixed in 1.2.1); they must not be moved or reused. Previous stable: 1.1.1.
## Stable releases
@@ -21,6 +21,10 @@ Source contents are read-only outside the publication job. That final job receiv
Release-candidate MSIs remain explicitly unsigned. GitHub provenance identifies the build workflow and artifact digest; it is not Authenticode publisher signing or production qualification. Verify attestations for the exact published files using `gh attestation verify <asset-path> --repo QRun-IO/qqq-print-agent`, and record the result. Older releases may lack attestations; the current workflow does not retroactively attest them. No signing secret or winget submission is implied. Observed in-app update evidence is in [ACCEPTANCE.md](ACCEPTANCE.md). The final job refuses to overwrite an existing GitHub release; use a new prerelease tag for changed binaries.
## Client editions
The stable release signs and publishes one installer per folder under `editions/` beside the generic one (`<assetPrefix>-vX.Y.Z-x64.msi`), each built from the same tagged source with that edition's branding, and lists them all in `SHA256SUMS`. The `editions` job enumerates the folders, the `sign` job runs once per edition, and the `assemble` job re-verifies every signature before writing the release files. An installed edition updates only from its own asset name. See [BRANDING.md](BRANDING.md#client-editions).
## Version identity constraint
Windows Installer replaces a versioned file only when its numeric file version is higher, and the .NET SDK would give every `X.Y.Z-rc.N` the same `X.Y.Z.0`. `build/Build-Installer.ps1` therefore stamps all three executables with `X.Y.Z.N` for `X.Y.Z-rc.N` (N from 1 to 65534) and `X.Y.Z.65535` for stable `X.Y.Z`, and fails if an executable reports a different file version. Only `rc.N` and stable are supported; any other prerelease label (for example `beta.1`) is refused rather than mapped to an unordered number, so a new label scheme needs a reviewed mapping first. The MSI ProductVersion stays `X.Y.Z`, and the updater still compares the semantic version string.
+43 -2
View File
@@ -1,6 +1,47 @@
# Session state — 2026-11-04
# Session state — 2026-11-06
## Current status: released and installed — 2026-11-04
## Current — 1.2.1 released; printing cohort protocol 1.1.0 / QBit 1.1.0 / client 1.2.1 complete
Client 1.2.1 (source `3b9d75d18f9cc8b80f4cf6d5aae81cccf8d61243`, GitHub release run 42223525626) is published with signed generic and GG Forge installers on GitHub and, byte-identical, on Gitea; see [RELEASING](RELEASING.md). Protocol 1.1.0 and QBit 1.1.0 (extension 2.4.0, including Remove printers / Remove device) are published. The handoff for the Forge agent, with every version, artifact and hash, is [FORGE-HANDOFF-2026-11-05.md](FORGE-HANDOFF-2026-11-05.md); Forge adoption, pairing tests and installation on its station are that lane's. The installed GG Forge rc.3 on this PC updates to 1.2.1 from its own asset through the in-app updater (not yet exercised). Open elsewhere: Gitea CI quota/runner-fleet work (Praetor lane); docs PRs qbit-printing 29 and qqq-print-protocol 12.
## 1.2.1 — release-pipeline fix after the v1.2.0 tag produced no signed installer
Protocol 1.1.0 and QBit 1.1.0 are published (see below). PR 10 merged as `c8ea97e` and tag `v1.2.0` was mirrored; GitHub release run 42128511730 failed in `sign (gg-forge)` at "Publish and check the payload": the workflow splatted `-Edition gg-forge` as an array, which PowerShell binds positionally into `CertificateThumbprint`/`TimestampServer`, so `Build-Installer.ps1` refused `-PublishOnly`. Fixed with a hashtable splat (local reproduction of the step publishes the GG Forge payload); version bumped to 1.2.1 because `v1.2.0` is immutable and must not be reused. Next: merge, tag `v1.2.1` on Gitea, mirror, signed release, Gitea mirror of the assets, Forge handoff.
## Printer approval over the realtime channel (extension 2.4.0) — implemented across three repositories, merges pending
- **Client (this repository), PR 10 head `f1ec404`** on `feat/client-editions-20261105`: operator-catalog fallback (2.3), `printer-approval-v1` negotiation and the authoritative `PRINTERS` frame (2.4), per-printer server state on the Printers page (Server column, summary, details), Clear logs button in the log viewer, pinned sign-in feedback banner, non-repeating sign-in reply. Hosted CI run 42116396114: 197 Core + 362 Windows tests, zero failures. Protocol fixture pin moved to `ac835f8b9396b641b525669249fdb4a1e2a50c99`.
- **Protocol 1.1.0**, [PR 11](https://git.kof22.com/QRun-IO/qqq-print-protocol/pulls/11) (`ac835f8`): capability, `RealtimePrinters` schema, fixtures, REALTIME/VERSIONS docs; all gates green.
- **QBit 1.1.0 (extension 2.4.0)**, [PR 25](https://git.kof22.com/QRun-IO/qbit-printing/pulls/25) (`3fa78de`): 195 tests green; [PR 26](https://git.kof22.com/QRun-IO/qbit-printing/pulls/26) (`c5a9c6d`, stacked on 25) adds Remove printers / Remove device as archives (`removed_at`) purged by retention when no job references them; 199 tests green.
- Merge order: protocol 11, then QBit 25 and 26, then client 10. Then tag protocol `v1.1.0`, QBit `v1.1.0` (registry publication through the main run), client `v1.2.0` on Gitea with GitHub mirror sync. Forge adoption and pairing tests belong to the Forge agent; current working versions (protocol 1.0.0, QBit 1.0.0, client 1.1.1 / GG Forge rc.3) stay for rollback.
- Local test builds today: 1.2.0-rc.1..rc.3 under `artifacts/share/`; GG Forge rc.3 (commit 887c58c, before the 2.4 negotiation) is installed on this PC.
# Session state — 2026-11-05
## Current status: 1.1.1 signed; client editions (1.2.0) complete in PR 10 — 2026-11-05
Signed **1.1.0** (sign-in diagnostics, debug logging, log rollover, per-server untrusted-certificate exception) and **1.1.1** (browser sign-in fix: the elevated companion's callback inbox pipe refused the unelevated protocol handler) are published on GitHub from Gitea tags through the mirror. Browser sign-in against the local Forge server (API 1.11.0, `nonceValidation: REQUIRED`) was confirmed working by the operator on the 1.1.1 build; PR 11 (required-nonce exchange) is on main.
[PR 10](https://git.kof22.com/QRun-IO/qqq-print-agent/pulls/10) (`feat/client-editions-20261105`, version 1.2.0) adds client editions (`editions/<id>`, first `gg-forge`): fully branded builds and installers named after the edition's asset prefix, an updater that follows only that asset, a release workflow that signs one installer per edition from one tag, edition options for server wording and About sections, nonce sending under OPTIONAL and the `nativeSessionNonceValidation` capability, and `build/Build-CiRelease.ps1` delegating to `Package-Release.ps1`. GitHub CI is green on the branch (182 Core, 352 Windows/CLI tests). Gitea refuses the merge only because its shared ".NET CI" jobs never start while the Windows runner is down; the operator is distributing local rc-numbered builds (`artifacts/share/print-agent-1.2.0-rc.1`) meanwhile. PRs 1 (stale dependabot) and 6 (superseded CI entrypoint) were closed. Next: when the runner returns, merge PR 10, tag `v1.2.0` on Gitea, synchronize the mirror, verify the first matrix release (generic + GG Forge), publish the same assets on Gitea.
# Previous session state — 2026-11-05
## Active scoped work: required-nonce native sign-in fix — 2026-11-05
Branch `fix/native-login-required-nonce`, integrated on main `f954f11`, makes native sign-in honor the server's `NativeGrant.NonceValidation` policy. One nonce is created per authorization attempt and kept in the attempt only when the policy is `REQUIRED`; the same value is sent as `expectedNonce` at `manageSession`. An absent (legacy) policy keeps the exact old four-field body, and an unknown non-empty policy is refused before any provider or browser step. The callback never supplies the nonce, and state, issuer, PKCE, single-use and expiry checks are unchanged.
The shared-server scanner Authentik requires this fix. The prior stable printing release below remains historical accepted evidence and is not reopened.
Observed so far:
- Previous-base solution build and 333 CLI tests passed locally. Core tests executed 0 tests because Windows security blocked the assembly; that is hosted-CI-only and is **not a pass**.
- The integrated NativeCI Release build passed with zero warnings/errors after extracting the code-exchange body into a private helper to meet the existing method-length limit. All 352 Windows/CLI tests passed with no failures/skips; the source-notice check passed for 300 tracked files. Core.Tests was excluded as required on this workstation.
- Root verified, in an isolated shared scanner/Printing 1.0.0 fixture with `REQUIRED` nonce, that both exact native callback registrations and real server HTTPS discovery work. This is discovery only, not native login proof.
Pending: hosted CI (including Core.Tests), real shared-provider native login acceptance, and the final published build.
## Historical: released and installed — 2026-11-04
Signed Windows **1.0.1** is published on primary Gitea and GitHub, from source `5bf00f3291923f7afb34c6cce61276e69649689b`. [Release run 41532214447](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41532214447) passed its protected gates, OIDC signing and publication. Checksums, signatures and provenance were verified. The November 3 local MSI installation exited 0; app/service/CLI matched 1.0.1 (`1.0.1.65535`) and the service was Running. This docs-only update does not imply a new runtime check.
+21
View File
@@ -1,5 +1,26 @@
# TODO
## Released: 1.2.1 — 2026-11-06
Client 1.2.1 (source `3b9d75d`, GitHub release run 42223525626) is the current stable: signed generic and GG Forge installers on GitHub and, byte-identical, on Gitea. See [RELEASING](RELEASING.md).
- [x] Nonce fix PR 11 (`abc3d0d`) and the OPTIONAL/discovery nonce change (`c59814d`) shipped in `v1.2.0`/`v1.2.1`.
- [x] Release run 42128511730 on `v1.2.0` failed in `sign (gg-forge)`: `release.yml` array-splatted `-Edition`, binding it positionally to `-CertificateThumbprint`. Fixed in PR 12 (`3b9d75d`); `v1.2.0` stays immutable and unpublished.
- [x] `v1.2.1` tagged on the merge commit, mirrored to GitHub, signed, assembled and published; checksums, signatures and provenance verified; same assets attached to the Gitea release.
- [x] Post-merge main checks (ci, codeql, security, scripts, scorecard) green on `b583130`.
- [ ] Actual native sign-in/sign-out acceptance against the shared provider (the shared-fixture discovery pass is not acceptance).
## Historical: required-nonce native sign-in fix — 2026-11-05 (published in 1.2.x)
The stable printing release below remains accepted historical evidence. The shared-server scanner Authentik requires this fix (`fix/native-login-required-nonce`, integrated on main `f954f11`).
- [x] Implement per-attempt nonce kept only under `REQUIRED`, sent as `expectedNonce`; legacy four-field body preserved; unknown policy refused before provider/browser.
- [x] Root verified exact native callback registrations and real server HTTPS discovery in an isolated shared scanner/Printing 1.0.0 fixture with `REQUIRED` nonce (discovery only, not login proof).
- [x] Integrated NativeCI Release build: zero warnings/errors; 352 Windows/CLI tests passed with no failures/skips; source notices passed for 300 tracked files. Existing method-length limit retained; Core.Tests excluded locally.
- [x] Hosted CI including Core.Tests: GitHub CI on the editions branch carrying this fix passed 182 Core and 352 Windows/CLI tests (run 42078731426, 2026-11-05).
- [ ] Real shared-provider native login acceptance.
- [x] Final published build: 1.2.1 (see above).
## Closed product release — 2026-11-04
- [x] Publish signed Windows 1.0.1 on Gitea and the GitHub updater feed; release run 41532214447 passed its gates and Azure OIDC signing.
+1 -1
View File
@@ -14,7 +14,7 @@ Open **QQQ Print Agent** from Start. In **Settings → Server connection → Sig
If enrollment times out, check QQQ before trying again: the code may already have been consumed. Generate a fresh code and revoke an unused/orphaned device through your administrator. An unreadable saved credential can be repaired by explicit enrollment; the previous encrypted file and journal are preserved.
In **Printers**, use the **Shared** checkbox for each intended queue, then approve it in QQQ. Local sharing and server approval are separate. Select a printer to expand its information directly below that row. **Settings** and **Print test** appear at the top of the expansion; there is no fixed printer-details area below the list. The description preview is limited to three lines, and **Windows diagnostics** expands the driver, port and identifier.
In **Printers**, use the **Shared** checkbox for each intended queue, then approve it on the server. Local sharing and server approval are separate, and the **Server** column shows the second one for the server this computer is connected to: **Approved** (the server's printer catalog lists the printer for this computer), **Awaiting approval** (the server received the inventory but an administrator has not approved the printer), **Not reported** (the server could not be asked: an older server, or the signed-in user may not read the operator printer catalog; the reason is in the tooltip and the activity log) or **Not checked** (not connected yet). The service asks the server after each inventory and about once a minute while connected, so after a sign-in to a different server every shared printer starts as awaiting approval there until that server approves it. Select a printer to expand its information directly below that row. **Settings** and **Print test** appear at the top of the expansion; there is no fixed printer-details area below the list. The description preview is limited to three lines, and **Windows diagnostics** expands the driver, port and identifier.
Double-click the compact printer row, or select it and choose **Settings**, to open a wider editor. Text selection, sharing checkboxes and controls inside the expansion retain their own behavior. Read or edit the full description in Settings. **Print test** asks for confirmation, format and stock/resolution before sending one real test through QQQ. It offers Letter/A4 PDF and 4 × 6, 4 × 2 or 4 × 1-inch PDF/ZPL labels, with 203/300/600 dpi choices for ZPL. Compatible label queues default to ZPL; the suggested DPI must be checked against the printer. Virtual PDF, XPS, fax and save-to-file queues that need interaction are unavailable to the service. **Windows printer settings** opens the native queue setup page.
+3 -1
View File
@@ -10,7 +10,9 @@ The remote-control adapter accepts only inventory refresh and cancellation of on
The native QQQ session adapter requires discovery before credentials and never treats a native control 404 as legacy permission. After support is negotiated, 404 does not remove restrictions. Authorization denial becomes authorization-required, without inferring that every 401/403 proves revocation. New claims wait for an authenticated, bound QQQ user and valid control state. Existing installations without a protected QQQ session wait for explicit sign-in; device proof alone cannot authorize normal service printing. Explicit isolated CLI diagnostics may use the compatibility bearer adapter with `--legacy-compat`; it is not a fallback from native authentication.
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. Passwords stay in the companion; provider refresh/access tokens remain server-side. Normal CLI operations use the existing authenticated service pipe. The private browser callback is fixed and MSI-owned, forwarding bounded data only into an active same-user/session companion inbox after executable verification. It carries a short-lived authorization code, never a session cookie or PKCE verifier. Core validates exact advertised redirect, random state, PKCE and single-use expiry; no callback creates an authentication attempt. Provider registration, installed callback delivery and long-duration renewal remain deployment qualifications.
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. Passwords stay in the companion; provider refresh/access tokens remain server-side. Normal CLI operations use the existing authenticated service pipe. The private browser callback is fixed and MSI-owned, forwarding bounded data only into an active same-user/session companion inbox after executable verification. It carries a short-lived authorization code, never a session cookie or PKCE verifier. Core validates exact advertised redirect, random state, PKCE and single-use expiry; when the server advertises `nonceValidation: REQUIRED` it also sends only that attempt's own authorization nonce as `expectedNonce` (never a callback-supplied or regenerated value) and refuses any other non-empty nonce policy; no callback creates an authentication attempt. Provider registration, installed callback delivery and long-duration renewal remain deployment qualifications.
Sign-out also ends the identity provider's browser session. The companion sends only the fixed registered callback with `sign-out`; the service refuses any other value, asks QQQ to log out with `nativeLogout.postLogoutRedirectUri`, and returns the provider end-session request to the companion over the administrator-only pipe. That request carries the provider's ID token hint (sensitive, bounded to 32 KiB, no whitespace or control characters), so it is never logged, the record prints redacted, and only the provider host is logged. Core refuses the data unless the issuer equals the one QQQ advertises, the end-session endpoint is HTTPS on the issuer's origin and identical to the provider's own discovery, and the callback is the one requested. The browser return is accepted only on the exact callback with the state this sign-out generated. Browser sign-in also sends `max_age=0` beside `prompt=login`.
Realtime frames and queues have administrator-configured byte/count bounds validated against declared client ceilings. Defaults are 1 MiB per complete frame, 64 queued frames and 4 MiB queued bytes. Fragmentation does not bypass the complete-message limit. The journal admits at most 4,096 command records and 128 retained connection states, failing closed when capacity is exhausted. A durable marker precedes any effect. Crash recovery produces OUTCOME_UNKNOWN; duplicate delivery reuses the exact saved receipt rather than repeating cancellation. No raw executable, printer name or arbitrary spool ID comes from the server: CANCEL_SPOOL resolves its job ID through the current connection's local receipt and verifies the exact QQQ document at the Windows spooler boundary.
+30
View File
@@ -0,0 +1,30 @@
# Client editions
One folder per client. `./build/Build-Installer.ps1 -Edition <folder>` builds a fully branded
installer from the same source: the companion ships the folder's mark and icon, every executable
carries the client's product and company metadata, the MSI's product name, manufacturer, service
display name and Start menu entry use the client's name, the MSI is named `<assetPrefix>-<version>-x64.msi`,
and the installed updater follows that asset name on the release feed.
Each folder holds:
| File | Required | Use |
| --- | --- | --- |
| `edition.json` | yes | Names, accent colour and asset prefix (fields below) |
| `app-icon.svg` | yes | Splash and About mark |
| `app.ico` | yes | Window, taskbar, executable and Start menu icon (multi-size, 16 to 256 px) |
| `logo.png` | no | Client logo in Settings → About and help (PNG, at most 2048 × 2048) |
`edition.json` fields: `id` (must equal the folder name; lowercase letters, digits, hyphens), `displayName`
and `companyName` (at most 180 characters), optional `tagline` and `description`, optional `supportUrl`
(HTTPS), optional `accentColor` (`#RRGGBB`, used only when white text stays readable on it) and
`assetPrefix` (2 to 40 letters or digits; the MSI and release asset name). Optional `serverName` is what
user-facing text calls the server ("Sign in to GG Forge", "GG Forge authorization required"); the generic
build says QQQ. Optional `aboutMascot` and `aboutLicense` (default true) set to `false` leave the Query
mascot section and the License section out of the About dialog; the header (name, tagline, version) and the
Open source components credits always remain, as does the copyright in the installed LICENSE and NOTICE.
An edition changes presentation and labels only. Application id, data paths, executable file names,
the install directory, the protocol handler, the MSI upgrade identity and the signing identity are
shared with every other edition, so editions upgrade each other and the generic build in place.
See docs/BRANDING.md.
+12
View File
@@ -0,0 +1,12 @@
<svg width="78" height="78" viewBox="0 0 78 78" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_8440_398)">
<path d="M39.0011 0H38.9989C17.4604 0 0 17.4604 0 38.9989V39.0011C0 60.5396 17.4604 78 38.9989 78H39.0011C60.5396 78 78 60.5396 78 39.0011V38.9989C78 17.4604 60.5396 0 39.0011 0Z" fill="#E2AB44"/>
<path d="M55.3877 26.4839L53.9782 26.0657C53.8211 26.0214 53.6574 26.1144 53.6131 26.2737C51.9957 31.1283 45.2624 32.9007 40.3059 34.2593L39.4718 34.4673C39.3678 34.5204 39.2638 34.4142 39.2638 34.2593C39.6289 32.6927 41.1423 26.9021 41.5606 25.4926C41.6048 25.3355 41.5119 25.1717 41.3526 25.1275L39.9431 24.6584C39.786 24.6141 39.6222 24.7071 39.578 24.8664C39.578 24.9195 39.5249 24.9704 39.5249 25.0235C39.5005 25.0899 39.4275 25.1253 39.3611 25.1009C39.3456 25.0943 39.3301 25.0854 39.3169 25.0744C39.0226 24.6761 38.6708 24.3243 38.2725 24.03C35.6084 22.1005 31.2803 23.5609 28.6693 27.3181C26.0606 31.0221 26.1646 35.5648 28.9304 37.4943C29.207 37.6824 29.5057 37.8395 29.8177 37.9634C29.904 38.0054 29.9416 38.1072 29.8996 38.1957C29.8819 38.2311 29.8531 38.2599 29.8177 38.2776C28.1405 39.3176 26.6292 40.6031 25.3282 42.0879C23.7129 43.9156 22.0932 47.2036 22.4583 50.1775C22.6087 51.6777 23.3633 53.0518 24.5449 53.9878C25.4454 54.6627 26.5496 55.0123 27.6758 54.9791C29.5035 54.9791 31.329 54.1449 32.7385 53.0474C36.3917 50.1222 36.9648 46.6283 37.487 43.2894C37.5401 42.9774 38.5823 37.1845 38.5823 37.1845C38.6331 36.95 38.8168 36.7663 39.0513 36.7154C39.6244 36.5583 40.1997 36.4034 40.8237 36.2463C46.2537 34.7859 53.664 32.8011 55.5957 26.8534C55.64 26.6963 55.5471 26.5326 55.3877 26.4883C55.3877 26.4883 55.3855 26.4883 55.3833 26.4883L55.3877 26.4839ZM35.5044 42.9752C34.9822 46.2101 34.5662 48.976 31.4861 51.4299C29.8686 52.6823 27.4147 53.5187 25.7973 52.3172C25.0648 51.7264 24.6112 50.8568 24.5449 49.9164C24.3369 47.9847 25.3282 45.2719 26.8948 43.4465C28.4348 41.7206 30.2736 40.2889 32.3225 39.218C33.5727 38.5675 34.8826 38.043 36.2368 37.6514C36.3408 37.5983 36.4448 37.7045 36.4448 37.8085C36.1328 39.2689 35.5575 42.7141 35.5066 42.9752H35.5044ZM37.0179 34.8346C36.9714 35.0537 36.8121 35.2329 36.5997 35.3037C36.0266 35.5117 34.8782 35.8768 34.8273 35.8768C31.9574 36.815 30.5479 36.1379 30.0788 35.8237C28.3065 34.5713 28.4082 31.2301 30.3399 28.5174C32.2716 25.8046 35.3495 24.55 37.1219 25.7515C38.2172 26.4817 38.6354 28.0483 38.2703 29.8737C37.8012 31.6483 37.3299 33.5269 37.0179 34.8324V34.8346Z" fill="#FFFFFF"/>
<path d="M49.4369 41.8775C50.78 41.8643 52.0811 42.3488 53.0901 43.2361C53.2096 43.3401 53.3888 43.3401 53.5083 43.2361L54.5527 42.1917C54.6567 42.0723 54.6567 41.893 54.5527 41.7735C51.5633 38.9966 46.8879 39.167 44.1088 42.1563C41.3318 45.1457 41.5022 49.8211 44.4915 52.6003C45.9829 53.9855 47.9788 54.6957 50.01 54.5608C51.6452 54.4324 53.1963 53.7929 54.4465 52.7309C54.6036 52.6003 54.6965 52.4122 54.7076 52.2087V46.834C54.6965 46.6658 54.5616 46.5331 54.3956 46.522H49.386C49.2179 46.5331 49.0851 46.668 49.0741 46.834V48.2944C49.0851 48.4625 49.2201 48.5953 49.386 48.6064H52.309C52.4772 48.6174 52.61 48.7524 52.621 48.9184V51.162C52.6409 51.4143 52.4927 51.6466 52.2559 51.7351C51.4306 52.2131 50.4946 52.4653 49.5431 52.4653C46.618 52.4941 44.2238 50.1464 44.1928 47.2212C44.1641 44.296 46.5117 41.9019 49.4369 41.8709V41.8775Z" fill="#FFFFFF"/>
</g>
<defs>
<clipPath id="clip0_8440_398">
<rect width="78" height="78" fill="#E2AB44"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"id": "gg-forge",
"displayName": "GG Forge Print Agent",
"companyName": "Greater Goods",
"tagline": "Cloud printing for GG Forge",
"description": "Connect local printers to GG Forge.",
"accentColor": "#424242",
"assetPrefix": "GgForgePrintAgent",
"serverName": "GG Forge",
"aboutMascot": false,
"aboutLicense": false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+9 -3
View File
@@ -4,15 +4,21 @@
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
<Package Name="QQQ Print Agent" Manufacturer="QRun-IO" Version="$(ProductVersion)"
<Package Name="$(BrandName)" Manufacturer="$(BrandCompany)" Version="$(ProductVersion)"
UpgradeCode="D761CD03-F616-4C98-9A94-9F590A36E449" Scope="perMachine" InstallerVersion="500">
<!-- Execute the protected-state preflight before removing the previous product.
Deferred actions are queued until InstallExecute, not run at their table slot.
Stable component GUIDs preserve shared files/service during old-product removal. -->
<MajorUpgrade AllowSameVersionUpgrades="yes" Schedule="afterInstallExecute" DowngradeErrorMessage="A newer print connector is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<?if $(BrandForced) = 1 ?>
<!-- An edition build carries its client's identity and applies it over any labels an earlier install stored. -->
<Property Id="BRANDNAME" Secure="yes" Value="$(BrandName)" />
<Property Id="BRANDCOMPANY" Secure="yes" Value="$(BrandCompany)" />
<?else?>
<Property Id="BRANDNAME" Secure="yes" />
<Property Id="BRANDCOMPANY" Secure="yes" />
<?endif?>
<Property Id="STORED_BRAND_NAME">
<RegistrySearch Id="FindBrandName" Root="HKLM" Key="SOFTWARE\QRunIO\PrintAgent" Name="DisplayName" Type="raw" Bitness="always64" />
</Property>
@@ -20,9 +26,9 @@
<RegistrySearch Id="FindBrandCompany" Root="HKLM" Key="SOFTWARE\QRunIO\PrintAgent" Name="CompanyName" Type="raw" Bitness="always64" />
</Property>
<SetProperty Id="BRANDNAME" Action="UseStoredBrandName" Value="[STORED_BRAND_NAME]" After="AppSearch" Condition="NOT BRANDNAME AND STORED_BRAND_NAME" />
<SetProperty Id="BRANDNAME" Action="UseDefaultBrandName" Value="QQQ Print Agent" After="UseStoredBrandName" Condition="NOT BRANDNAME" />
<SetProperty Id="BRANDNAME" Action="UseDefaultBrandName" Value="$(BrandName)" After="UseStoredBrandName" Condition="NOT BRANDNAME" />
<SetProperty Id="BRANDCOMPANY" Action="UseStoredBrandCompany" Value="[STORED_BRAND_COMPANY]" After="UseDefaultBrandName" Condition="NOT BRANDCOMPANY AND STORED_BRAND_COMPANY" />
<SetProperty Id="BRANDCOMPANY" Action="UseDefaultBrandCompany" Value="QRun-IO" After="UseStoredBrandCompany" Condition="NOT BRANDCOMPANY" />
<SetProperty Id="BRANDCOMPANY" Action="UseDefaultBrandCompany" Value="$(BrandCompany)" After="UseStoredBrandCompany" Condition="NOT BRANDCOMPANY" />
<SetProperty Id="ProductName" Action="SetBrandedProductName" Value="[BRANDNAME]" After="UseDefaultBrandCompany" />
<SetProperty Id="Manufacturer" Action="SetBrandedManufacturer" Value="[BRANDCOMPANY]" After="SetBrandedProductName" />
<CustomAction Id="ValidateMachineStateBeforeFolders" Directory="System64Folder" ExeCommand="&quot;[System64Folder]WindowsPowerShell\v1.0\powershell.exe&quot; -NoProfile -NonInteractive -Command &quot;$ErrorActionPreference=&apos;Stop&apos;; $trusted=@(&apos;S-1-5-18&apos;,&apos;S-1-5-32-544&apos;,&apos;S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464&apos;); function AssertNode([\[]string[\]]$p) [\{] $i=Get-Item -LiteralPath $p -Force; if($i.Attributes -band [\[]IO.FileAttributes[\]]::ReparsePoint) [\{] throw &apos;Machine print state contains a link. Installation refused.&apos; [\}]; $a=$i.GetAccessControl(); if($a.GetOwner([\[]Security.Principal.SecurityIdentifier[\]]).Value -notin $trusted) [\{] throw &apos;Machine print state has an untrusted owner. Installation refused.&apos; [\}]; $raw=[\[]Security.AccessControl.RawSecurityDescriptor[\]]::new($a.GetSecurityDescriptorBinaryForm(),0); if($null -eq $raw.DiscretionaryAcl) [\{] throw &apos;Machine print state has no ACL.&apos; [\}]; foreach($r in $a.GetAccessRules($true,$true,[\[]Security.Principal.SecurityIdentifier[\]])) [\{] if($r.AccessControlType -eq &apos;Allow&apos; -and [\[]int[\]]$r.FileSystemRights -ne 0 -and $r.IdentityReference.Value -notin $trusted) [\{] throw &apos;Machine print state permits untrusted access. Installation refused.&apos; [\}] [\}]; if($i.PSIsContainer) [\{] foreach($c in [\[]IO.Directory[\]]::EnumerateFileSystemEntries($p)) [\{] AssertNode $c [\}] [\}] [\}]; try [\{] $root=Join-Path ([\[]Environment[\]]::GetFolderPath(&apos;CommonApplicationData&apos;)) &apos;QqqPrintAgent&apos;; for($p=$root;$p;$p=[\[]IO.Path[\]]::GetDirectoryName($p)) [\{] if(Test-Path -LiteralPath $p) [\{] if((Get-Item -LiteralPath $p -Force).Attributes -band [\[]IO.FileAttributes[\]]::ReparsePoint) [\{] throw &apos;Machine print state path contains a link.&apos; [\}] [\}] [\}]; if(-not [\[]IO.Directory[\]]::Exists($root)) [\{] $acl=[\[]Security.AccessControl.DirectorySecurity[\]]::new(); $acl.SetSecurityDescriptorSddlForm(&apos;O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)&apos;); [\[]IO.DirectoryInfo[\]]::new($root).Create($acl) [\}]; AssertNode $root; exit 0 [\}] catch [\{] [\[]Console[\]]::Error.WriteLine($_.Exception.Message); exit 1 [\}]&quot;" Execute="deferred" Impersonate="no" Return="check" />
+8 -2
View File
@@ -9,8 +9,14 @@
<InstallerPlatform>x64</InstallerPlatform>
<ProductVersion Condition="'$(ProductVersion)' == ''">0.3.0</ProductVersion>
<PayloadRoot Condition="'$(PayloadRoot)' == ''">$(MSBuildThisFileDirectory)..\artifacts\package</PayloadRoot>
<OutputName>QqqPrintAgent-$(ProductVersion)-x64</OutputName>
<DefineConstants>ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot)</DefineConstants>
<!-- Client editions: the build script passes the edition's names, forces them over any stored labels and names the
MSI after the edition's asset prefix; the generic build keeps the defaults below. -->
<MsiBaseName Condition="'$(MsiBaseName)' == ''">QqqPrintAgent</MsiBaseName>
<BrandName Condition="'$(BrandName)' == ''">QQQ Print Agent</BrandName>
<BrandCompany Condition="'$(BrandCompany)' == ''">QRun-IO</BrandCompany>
<BrandForced Condition="'$(BrandForced)' == ''">0</BrandForced>
<OutputName>$(MsiBaseName)-$(ProductVersion)-x64</OutputName>
<DefineConstants>ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot);BrandName=$(BrandName);BrandCompany=$(BrandCompany);BrandForced=$(BrandForced)</DefineConstants>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Bundled Microsoft WinUI DLLs contain multi-language version resources exceeding
ICE03's Language-column limit, and newer MUI LCIDs absent from its static list.
+4
View File
@@ -126,6 +126,10 @@ public static class Program
};
string response = await send(request);
Console.WriteLine(response);
if(args[0] is "sign-out" && !response.StartsWith("Error:", StringComparison.Ordinal))
{
Console.WriteLine("The identity provider's browser session is not ended from the command line; use Sign out in the QQQ Print Agent app to end it too.");
}
return response.StartsWith("Error:", StringComparison.Ordinal) ? 1 : 0;
}
+31 -3
View File
@@ -14,11 +14,13 @@ namespace Qqq.PrintAgent.Core;
/// The application's identity, declared once and shared by the window, the command line,
/// and the tests. AppKit derives the data folder (%LOCALAPPDATA%\qqq-print-agent), the data-folder
/// override variable, the log file names, and the credential prefix from <see cref="Info"/>.
/// A compiled-in client <see cref="Edition"/> changes names, colours and the update asset; machine
/// branding applies on top of that; the id and paths never change.
/// </summary>
public static class AppIdentity
{
/// <summary>Who the app is: id, names, links, credits, and brand.</summary>
public static AppInfo DefaultInfo { get; } = new()
/// <summary>The generic identity before any edition or machine branding: id, names, links, credits, and brand.</summary>
public static AppInfo BaseInfo { get; } = new()
{
Id = "qqq-print-agent",
DisplayName = "QQQ Print Agent",
@@ -50,7 +52,33 @@ public static class AppIdentity
public static RuntimeBranding Branding { get; } = RuntimeBranding.LoadMachine();
/// <summary>The client edition compiled into this build, or null for the generic build.</summary>
public static Edition? Edition { get; } = Edition.LoadInstalled();
/// <summary>The identity this build ships with: the edition's presentation over <see cref="BaseInfo"/>, before machine branding.</summary>
public static AppInfo DefaultInfo { get; } = Edition?.Apply(BaseInfo) ?? BaseInfo;
/// <summary>What user-facing text calls the server: the edition's name for it, or QQQ in the generic build.</summary>
public static string ServerName => Edition?.ServerName ?? Core.Edition.DefaultServerName;
/// <summary>The release asset prefix the installed updater looks for, so an edition keeps updating on its own line.</summary>
public static string UpdateAssetPrefix => Edition?.AssetPrefix ?? Core.Edition.DefaultAssetPrefix;
/// <summary>The edition's logo shipped beside the executable, when the edition supplies one and no machine branding logo overrides it.</summary>
public static string? EditionLogoPath
{
get
{
if(Edition is null)
{
return null;
}
string path = Path.Combine(AppContext.BaseDirectory, "Assets", "Brand", "logo.png");
return File.Exists(path) ? path : null;
}
}
public static RuntimeBranding Branding { get; } = RuntimeBranding.LoadMachine(includeInstallerLabels: Edition is null);
public static AppInfo Info { get; } = Branding.Apply(DefaultInfo);
/// <summary>Where the app keeps its per-user data; honors the data-folder override variable.</summary>
@@ -56,6 +56,19 @@ public interface IQqqSessionClient : IDisposable
/// <summary>
/// Ends the session like <see cref="SignOutAsync"/> and also asks the server for the identity provider's sign-out, returned for the
/// host to open in the browser so the provider's own session ends too. Null means there is nothing to open (no session, a
/// password server, or a server without provider sign-out); the local session is forgotten either way. The default only signs out.
/// </summary>
async Task<QqqProviderLogout?> SignOutWithProviderAsync(Uri postLogoutRedirectUri, CancellationToken cancellationToken = default)
{
await SignOutAsync(cancellationToken).ConfigureAwait(false);
return null;
}
/// <summary>Returns the credential that lets a later start restore this session; it is the only secret the caller persists.</summary>
QqqSessionCredential ExportCredential();
@@ -21,10 +21,43 @@ public sealed class QqqApiDiscovery
/// Enables additive media negotiation only for one standard-path stable printing 2.3-or-newer extension within major two. Printing 2.2 is
/// the rendering extension and carries no media negotiation, so a 2.2 server must keep this client a three-capability peer.
/// </summary>
public bool SupportsPrinterMedia() => Extensions is not null
public bool SupportsPrinterMedia() => HasStandardPrinting(minimumMinor: 3);
/// <summary>
/// Enables the printer-approval-v1 capability (the server's PRINTERS frame) only for one standard-path stable printing 2.4-or-newer
/// extension within major two. Older servers keep the client on the operator catalog for approval, which is the 2.3 behaviour.
/// </summary>
public bool SupportsPrinterApproval() => HasStandardPrinting(minimumMinor: 4);
/// <summary>True when exactly one printing extension is advertised, at the standard path, as a plain 2.x release at or above the given minor.</summary>
private bool HasStandardPrinting(uint minimumMinor) => Extensions is not null
&& Extensions.Count(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)) == 1
&& Extensions.Any(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)
&& string.Equals(extension.PathPrefix, "/qbit/printing", StringComparison.Ordinal) && SupportedVersion(extension.Version, 3, "2"));
&& string.Equals(extension.PathPrefix, "/qbit/printing", StringComparison.Ordinal) && SupportedVersion(extension.Version, minimumMinor, "2"));
/// <summary>The UI API capability that says the server validates the native sign-in nonce at the code exchange.</summary>
public const string NonceValidationFeature = "nativeSessionNonceValidation";
/// <summary>The UI API capability that says logout can return the identity provider's end-session data.</summary>
public const string NativeSessionLogoutFeature = "nativeSessionLogout";
/// <summary>True when the server is a plain 1.10+ release that advertises provider sign-out. The capability is additive and never inferred from native sign-in support.</summary>
public bool SupportsNativeSessionLogout => Features?.Contains(NativeSessionLogoutFeature, StringComparer.Ordinal) == true && SupportedVersion(ApiVersion, 10);
/// <summary>True when the server advertises that it validates the native sign-in nonce at exchange.</summary>
public bool ValidatesNativeSessionNonce => Features?.Contains(NonceValidationFeature, StringComparer.Ordinal) == true;
@@ -22,6 +22,19 @@ public sealed class QqqAuthenticationMetadata
public string BaseUrl { get; init; } = "";
public string Scopes { get; init; } = "";
public NativeGrant? NativeSessionLogin { get; init; }
/// <summary>The server's opt-in to provider sign-out (OIDC RP-initiated logout) with the exact callbacks it will accept; absent on servers that do not offer it.</summary>
public NativeLogout? NativeSessionLogout { get; init; }
}
/// <summary>The server's declaration that it can return the provider's end-session data at logout: flow name and the exact post-logout callbacks.</summary>
public sealed class NativeLogout
{
public const string RpInitiatedFlow = "OIDC_RP_INITIATED_LOGOUT";
public string Flow { get; init; } = "";
public IReadOnlyList<string> PostLogoutRedirectUris { get; init; } = [];
}
@@ -34,6 +47,13 @@ public sealed class QqqAuthenticationMetadata
public string Prompt { get; init; } = "";
public string Issuer { get; init; } = "";
public string DiscoveryUrl { get; init; } = "";
/// <summary>The server's nonce policy for the code exchange. Absent on older servers; <see cref="NonceRequired"/> means the exchange must carry the attempt's own nonce as expectedNonce.</summary>
public string? NonceValidation { get; init; }
public const string NonceRequired = "REQUIRED";
/// <summary>The server validates the nonce when one is sent; sending the attempt's own nonce is always correct under this policy.</summary>
public const string NonceOptional = "OPTIONAL";
public IReadOnlyList<string> RedirectUris { get; init; } = [];
}
}
@@ -9,8 +9,9 @@ namespace Qqq.PrintAgent.Core.Authentication;
public sealed class QqqBrowserLogin
{
/// <summary>Records one sign-in attempt: the address to open in the browser, the registered redirect, the random state and PKCE verifier that only this attempt knows, the expected issuer, and when it started so it can expire.</summary>
internal QqqBrowserLogin(Uri authorizationUri, Uri redirectUri, string state, string verifier, string issuer, long startedAt)
internal QqqBrowserLogin(Uri authorizationUri, Uri redirectUri, string state, string verifier, string issuer, long startedAt, string? expectedNonce = null)
{
ExpectedNonce = expectedNonce;
AuthorizationUri = authorizationUri;
RedirectUri = redirectUri;
State = state;
@@ -26,10 +27,13 @@ public sealed class QqqBrowserLogin
internal string State { get; }
internal string Verifier { get; }
internal string Issuer { get; }
/// <summary>This attempt's own authorization nonce, kept only when the server advertised that it validates it at exchange; null otherwise. Never taken from a callback.</summary>
internal string? ExpectedNonce { get; }
internal long StartedAt { get; }
/// <summary>Returns a fixed redacted label, so logging or displaying an attempt can never reveal its state or verifier.</summary>
/// <summary>Returns a fixed redacted label, so logging or displaying an attempt can never reveal its state, nonce or verifier.</summary>
public override string ToString() => "QQQ browser sign-in attempt (redacted)";
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>
/// The identity provider's own sign-out, ready for the host to open in the browser. The address carries the provider's ID token
/// hint, so it is held only in memory, handed straight to the browser, never logged, and its label is fixed text.
/// </summary>
public sealed class QqqProviderLogout
{
/// <summary>Records the address to open, the registered address the provider returns the browser to, and the random state that binds the return to this sign-out.</summary>
public QqqProviderLogout(Uri endSessionUri, Uri redirectUri, string state)
{
EndSessionUri = endSessionUri ?? throw new ArgumentNullException(nameof(endSessionUri));
RedirectUri = redirectUri ?? throw new ArgumentNullException(nameof(redirectUri));
State = state ?? throw new ArgumentNullException(nameof(state));
}
/// <summary>The provider's end-session address including the sensitive ID token hint. Only open it in the browser.</summary>
public Uri EndSessionUri { get; }
/// <summary>The registered callback the provider sends the browser to when the sign-out is finished.</summary>
public Uri RedirectUri { get; }
/// <summary>The random state this sign-out sent and expects back.</summary>
public string State { get; }
/// <summary>True only for a callback on exactly the registered address, with no fragment or user information and this sign-out's own state, so a forged or stale return cannot be taken as the provider finishing.</summary>
public bool Matches(Uri callback)
{
ArgumentNullException.ThrowIfNull(callback);
if(!callback.IsAbsoluteUri || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0
|| !string.Equals(callback.GetLeftPart(UriPartial.Path), RedirectUri.AbsoluteUri, StringComparison.Ordinal) || callback.Query.Length < 2)
{ return false; }
string? state = null;
foreach(string entry in callback.Query[1..].Split('&'))
{
string[] pair = entry.Split('=', 2);
if(pair.Length != 2)
{ return false; }
if(string.Equals(Uri.UnescapeDataString(pair[0].Replace('+', ' ')), "state", StringComparison.Ordinal))
{
if(state is not null)
{ return false; }
state = Uri.UnescapeDataString(pair[1].Replace('+', ' '));
}
}
return string.Equals(state, State, StringComparison.Ordinal);
}
/// <summary>Returns a fixed redacted label, so logging or displaying a sign-out can never reveal the ID token hint or state.</summary>
public override string ToString() => "QQQ provider sign-out (redacted)";
}
@@ -35,6 +35,7 @@ public sealed partial class QqqSessionClient
Uri authorizationUri = await ProviderAuthorizationEndpointAsync(grant, cancellationToken).ConfigureAwait(false);
string state = Base64Url(RandomNumberGenerator.GetBytes(32));
string verifier = Base64Url(RandomNumberGenerator.GetBytes(32));
string nonce = Base64Url(RandomNumberGenerator.GetBytes(32));
var query = new Dictionary<string, string>(StringComparer.Ordinal)
{
["client_id"] = metadata.Values!.ClientId,
@@ -42,8 +43,10 @@ public sealed partial class QqqSessionClient
["redirect_uri"] = redirectUri.AbsoluteUri,
["scope"] = metadata.Values.Scopes,
["state"] = state,
["nonce"] = Base64Url(RandomNumberGenerator.GetBytes(32)),
["nonce"] = nonce,
["prompt"] = grant.Prompt,
// prompt=login alone was seen reusing a retained provider session on real Authentik; max_age=0 makes any session too old.
["max_age"] = "0",
["code_challenge_method"] = "S256",
["code_challenge"] = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))),
};
@@ -52,7 +55,12 @@ public sealed partial class QqqSessionClient
Query = string.Join("&", query.Select(pair => Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))),
};
ClearSession();
_browserLogin = new QqqBrowserLogin(builder.Uri, redirectUri, state, verifier, grant.Issuer, _timeProvider.GetTimestamp());
string? nonceSignal = NonceSignal(grant);
_diagnostics.Info(nonceSignal is null
? "The server does not validate the sign-in nonce at exchange (legacy server); the exchange body stays unchanged."
: $"The server validates the sign-in nonce at exchange ({nonceSignal}); this attempt's nonce will be sent as expectedNonce.");
// The per-attempt nonce is kept only when the server validates it at exchange; legacy servers keep the exact old exchange.
_browserLogin = new QqqBrowserLogin(builder.Uri, redirectUri, state, verifier, grant.Issuer, _timeProvider.GetTimestamp(), nonceSignal is null ? null : nonce);
_diagnostics.Info($"Browser sign-in attempt started: the authorization page is {QqqSessionDiagnostics.Describe(builder.Uri)} (query not logged); waiting for the callback, which expires after five minutes.");
return _browserLogin;
}
@@ -175,7 +183,7 @@ public sealed partial class QqqSessionClient
}
_diagnostics.Info("Browser callback validated; exchanging the authorization code with QQQ for a server-managed session.");
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post,
JsonBody(new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow }),
JsonBody(NativeCodeExchange(attempt, code)),
MetadataLimit, null, null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
AdoptManagedSession(response);
@@ -188,6 +196,33 @@ public sealed partial class QqqSessionClient
/// <summary>
/// Says why the exchange will carry the attempt's nonce, or null for a legacy server: the grant's policy REQUIRED, the grant's
/// policy OPTIONAL (the server validates a nonce when one is sent, so sending it is always right), or the discovery
/// capability nativeSessionNonceValidation on a server whose grant does not state a policy.
/// </summary>
private string? NonceSignal(QqqAuthenticationMetadata.NativeGrant grant)
{
if(string.Equals(grant.NonceValidation, QqqAuthenticationMetadata.NativeGrant.NonceRequired, StringComparison.Ordinal))
{ return "grant policy REQUIRED"; }
if(string.Equals(grant.NonceValidation, QqqAuthenticationMetadata.NativeGrant.NonceOptional, StringComparison.Ordinal))
{ return "grant policy OPTIONAL"; }
return _discovery?.ValidatesNativeSessionNonce == true ? "discovery capability " + QqqApiDiscovery.NonceValidationFeature : null;
}
/// <summary>
/// Builds the manageSession code-exchange body. A legacy attempt (no stored nonce) keeps the exact four-field body; an attempt
/// made under the server's REQUIRED policy adds that attempt's own nonce as expectedNonce. The nonce comes only from the attempt,
/// never from the callback, and is not logged.
/// </summary>
private static object NativeCodeExchange(QqqBrowserLogin attempt, string code) => attempt.ExpectedNonce is null
? new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow }
: new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow, expectedNonce = attempt.ExpectedNonce };
/// <summary>
/// Checks that the server's native-session grant is one this agent can use safely: an HTTPS server with OAuth2, the expected
/// flow, S256 and a forced login prompt, HTTPS issuer and discovery addresses, a client identifier, the openid scope, and a
@@ -212,6 +247,10 @@ public sealed partial class QqqSessionClient
{ problems.Add($"code challenge method is '{grant.CodeChallengeMethod}', expected S256"); }
if(!string.Equals(grant.Prompt, "login", StringComparison.Ordinal))
{ problems.Add($"prompt is '{grant.Prompt}', expected login"); }
// Absent means a legacy server; REQUIRED and OPTIONAL both mean the nonce is sent; any other value is a policy this client cannot honor, so refuse before any provider or browser step.
if(!string.IsNullOrEmpty(grant.NonceValidation) && !string.Equals(grant.NonceValidation, QqqAuthenticationMetadata.NativeGrant.NonceRequired, StringComparison.Ordinal)
&& !string.Equals(grant.NonceValidation, QqqAuthenticationMetadata.NativeGrant.NonceOptional, StringComparison.Ordinal))
{ problems.Add($"nonce validation policy '{grant.NonceValidation}' is not one this client supports (expected REQUIRED, OPTIONAL or absent)"); }
if(!TryHttps(grant.Issuer, out _))
{ problems.Add($"issuer '{grant.Issuer}' is not a plain HTTPS address"); }
if(!TryHttps(grant.DiscoveryUrl, out _))
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.Cryptography;
using System.Text.Json;
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>
/// Provider sign-out part of the session client. Signing out of the agent must also end the identity provider's browser session,
/// because the provider is meant to be invisible: leaving it signed in would let the next sign-in skip the credential prompt.
/// The server describes the provider's end-session request at logout, this client checks it against the provider's own discovery,
/// and the host opens it in the browser.
/// </summary>
public sealed partial class QqqSessionClient
{
private const int IdTokenHintLimit = 32_768;
/// <summary>
/// Ends the QQQ session and, when the server offers provider sign-out for this exact callback, returns the provider's end-session
/// request for the host to open in the browser. The request must name the issuer QQQ advertises, an end-session endpoint that is
/// HTTPS on the issuer's origin and identical to the provider's own discovery, and the callback that was asked for; anything else
/// is refused. A server without the capability gets the ordinary logout and null, and says so in the log. The local session is
/// forgotten in every case, including failure.
/// </summary>
public async Task<QqqProviderLogout?> SignOutWithProviderAsync(Uri postLogoutRedirectUri, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(postLogoutRedirectUri);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if(_sessionToken is null)
{
_diagnostics.Info("Sign-out requested with no held session; clearing local state only.");
return null;
}
_diagnostics.Info($"Signing out of {Origin.Authority}: ending the server session and asking for the identity provider sign-out.");
QqqAuthenticationMetadata metadata = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
string? unavailable = ProviderLogoutUnavailable(metadata, grant, postLogoutRedirectUri);
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/logout"), HttpMethod.Post,
unavailable is null ? JsonBody(new { nativeLogout = new { postLogoutRedirectUri = postLogoutRedirectUri.AbsoluteUri } }) : JsonBody(new { }),
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
_diagnostics.Info("Server session ended.");
if(unavailable is not null)
{
_diagnostics.Info("The identity provider session was NOT ended: " + unavailable + ".");
return null;
}
return await ProviderLogoutFromAsync(response, grant!, postLogoutRedirectUri, cancellationToken).ConfigureAwait(false);
}
catch(Exception exception) when(ReportFailure("Provider sign-out", exception))
{ throw; }
finally
{
ClearSession();
_gate.Release();
}
}
/// <summary>Says why provider sign-out cannot be requested (no capability, no listed callback, a password server), or null when it can.</summary>
private string? ProviderLogoutUnavailable(QqqAuthenticationMetadata metadata, QqqAuthenticationMetadata.NativeGrant? grant, Uri redirect)
{
if(!string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal) || grant is null)
{ return "this server signs in with a password, so there is no identity provider session"; }
if(_discovery?.SupportsNativeSessionLogout != true)
{ return "the server does not advertise the nativeSessionLogout capability (UI API 1.10 or later)"; }
QqqAuthenticationMetadata.NativeLogout? logout = metadata.Values?.NativeSessionLogout;
if(logout is null || !string.Equals(logout.Flow, QqqAuthenticationMetadata.NativeLogout.RpInitiatedFlow, StringComparison.Ordinal))
{ return "the server publishes no OIDC_RP_INITIATED_LOGOUT descriptor"; }
if(logout.PostLogoutRedirectUris is null || !logout.PostLogoutRedirectUris.Contains(redirect.AbsoluteUri, StringComparer.Ordinal))
{ return $"the server does not list {redirect.AbsoluteUri} as a post-logout callback"; }
return null;
}
/// <summary>Reads and checks the provider data the server returned at logout, then builds the end-session request with a fresh state. Anything that does not match is refused without logging the hint.</summary>
private async Task<QqqProviderLogout?> ProviderLogoutFromAsync(HttpResponseMessage response, QqqAuthenticationMetadata.NativeGrant grant, Uri redirect, CancellationToken cancellationToken)
{
using JsonDocument document = ParseDocument(response);
if(!document.RootElement.TryGetProperty("nativeLogoutData", out JsonElement data) || data.ValueKind != JsonValueKind.Object)
{
_diagnostics.Info("The identity provider session was NOT ended: the server returned no provider sign-out data (an older session, or none was stored).");
return null;
}
string? issuer = StringProperty(data, "issuer");
string? endpoint = StringProperty(data, "endSessionEndpoint");
string? callback = StringProperty(data, "postLogoutRedirectUri");
string? hint = StringProperty(data, "idTokenHint");
if(!string.Equals(issuer, grant.Issuer, StringComparison.Ordinal) || !string.Equals(callback, redirect.AbsoluteUri, StringComparison.Ordinal)
|| string.IsNullOrEmpty(hint) || hint.Length > IdTokenHintLimit || hint.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))
|| !TryHttps(endpoint, out Uri? endSession) || !SameOrigin(endSession!, new Uri(grant.Issuer)))
{
_diagnostics.Info("Provider sign-out refused: the data the server returned does not match the issuer, callback or HTTPS end-session endpoint it advertised.");
throw InvalidResponse();
}
Uri discovered = await ProviderEndSessionEndpointAsync(grant, cancellationToken).ConfigureAwait(false);
if(!string.Equals(discovered.AbsoluteUri, endSession!.AbsoluteUri, StringComparison.Ordinal))
{
_diagnostics.Info("Provider sign-out refused: the end-session endpoint from the server differs from the one in the identity provider's own discovery.");
throw InvalidResponse();
}
string state = Base64Url(RandomNumberGenerator.GetBytes(32));
var query = new Dictionary<string, string>(StringComparer.Ordinal)
{
["id_token_hint"] = hint,
["post_logout_redirect_uri"] = redirect.AbsoluteUri,
["state"] = state,
};
var builder = new UriBuilder(endSession)
{
Query = string.Join("&", query.Select(pair => Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))),
};
_diagnostics.Info($"Provider sign-out ready: the end-session page is {QqqSessionDiagnostics.Describe(builder.Uri)} (query not logged).");
return new QqqProviderLogout(builder.Uri, redirect, state);
}
/// <summary>Reads a string property of a JSON object, or null when it is missing or not a string.</summary>
private static string? StringProperty(JsonElement element, string name) => element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
/// <summary>
/// Fetches the identity provider's discovery document and returns its end-session endpoint, after the same issuer, HTTPS and
/// same-origin checks the authorization endpoint gets. A provider without one cannot be signed out of from here.
/// </summary>
private async Task<Uri> ProviderEndSessionEndpointAsync(QqqAuthenticationMetadata.NativeGrant grant, CancellationToken cancellationToken)
{
_diagnostics.Info($"Fetching the identity provider's discovery document from {QqqSessionDiagnostics.Describe(new Uri(grant.DiscoveryUrl))} for its end-session endpoint.");
using HttpResponseMessage response = await SendCoreAsync(new Uri(grant.DiscoveryUrl), HttpMethod.Get, null, MetadataLimit,
null, null, null, cancellationToken, providerDiscovery: true).ConfigureAwait(false);
RequireSuccess(response);
using JsonDocument document = ParseDocument(response);
string? providerIssuer = StringProperty(document.RootElement, "issuer");
string? endpoint = StringProperty(document.RootElement, "end_session_endpoint");
if(!string.Equals(providerIssuer, grant.Issuer, StringComparison.Ordinal) || !TryHttps(endpoint, out Uri? endSession) || !SameOrigin(endSession!, new Uri(grant.Issuer)))
{
_diagnostics.Info("Provider sign-out refused: the provider's discovery names a different issuer or no HTTPS end-session endpoint on the issuer's origin.");
throw QqqApiDiscovery.Configuration();
}
return endSession!;
}
}
@@ -337,8 +337,11 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
_diagnostics.Info($"Printing request {relativePath} refused locally: no approved device binding is held.");
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "An approved device binding is required.");
}
// Device and admin routes, enrollment, and reading the operator printer catalog (the agent's only view of server-side
// approval); operator submission stays off this client.
bool catalogRead = method == HttpMethod.Get && (string.Equals(relativePath, "operator/printers", StringComparison.Ordinal) || relativePath.StartsWith("operator/printers?", StringComparison.Ordinal));
if(!relativePath.StartsWith("device/", StringComparison.Ordinal) && !relativePath.StartsWith("admin/", StringComparison.Ordinal)
&& !string.Equals(relativePath, "devices/enroll", StringComparison.Ordinal))
&& !string.Equals(relativePath, "devices/enroll", StringComparison.Ordinal) && !catalogRead)
{
throw new ArgumentException("Only native printing operations are accepted.", nameof(relativePath));
}
+123
View File
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using System.Text.RegularExpressions;
using KofTwentyTwo.AppKit;
namespace Qqq.PrintAgent.Core;
/// <summary>
/// A client edition compiled into a build. The build script copies one folder under <c>editions/</c> into the companion
/// (its mark, icon and this descriptor as <c>edition.json</c> beside the executable), stamps the client's product and company
/// into every executable, and names the installer and release asset after <see cref="AssetPrefix"/>, so the installed
/// updater follows the client's own asset. An edition changes presentation and labels only: application id, data paths,
/// executable names, the install directory, the protocol handler and the MSI upgrade identity stay shared. Machine
/// branding (<see cref="RuntimeBranding"/>) still applies on top of an edition.
/// </summary>
public sealed partial record Edition(string Id, string DisplayName, string CompanyName, string? Tagline, string? Description, Uri? SupportUrl, string? AccentColor, string AssetPrefix,
string ServerName = Edition.DefaultServerName, bool ShowMascot = true, bool ShowLicense = true)
{
/// <summary>What the generic build calls the server in user-facing text.</summary>
public const string DefaultServerName = "QQQ";
/// <summary>The descriptor's file name, next to the executable in an edition build.</summary>
public const string FileName = "edition.json";
/// <summary>The release asset prefix of the generic build, used when no edition is compiled in.</summary>
public const string DefaultAssetPrefix = "QqqPrintAgent";
/// <summary>Reads the edition compiled into this build, or null for the generic build.</summary>
public static Edition? LoadInstalled() => Load(Path.Combine(AppContext.BaseDirectory, FileName));
/// <summary>
/// Reads an edition descriptor. A missing file means the generic build; a malformed one (bad id or asset prefix, missing
/// names, oversized, not an object) is treated the same way rather than half-applied, and an accent colour is kept only
/// when white text stays readable on it.
/// </summary>
public static Edition? Load(string path)
{
try
{
if(!File.Exists(path))
{
return null;
}
using FileStream stream = File.OpenRead(path);
if(stream.Length > 16 * 1024)
{
return null;
}
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions { MaxDepth = 4 });
JsonElement root = document.RootElement;
if(root.ValueKind != JsonValueKind.Object)
{
return null;
}
string? id = RuntimeBranding.ReadText(root, "id", 40);
string? name = RuntimeBranding.ReadText(root, "displayName", 180);
string? company = RuntimeBranding.ReadText(root, "companyName", 180);
string? prefix = RuntimeBranding.ReadText(root, "assetPrefix", 40);
if(id is null || !IdPattern().IsMatch(id) || name is null || company is null || prefix is null || !AssetPrefixPattern().IsMatch(prefix))
{
return null;
}
string? support = RuntimeBranding.ReadText(root, "supportUrl", 2048);
Uri? supportUri = Uri.TryCreate(support, UriKind.Absolute, out Uri? candidate)
&& string.Equals(candidate.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal) && string.IsNullOrEmpty(candidate.UserInfo) ? candidate : null;
string? accent = RuntimeBranding.ReadText(root, "accentColor", 7);
return new(id, name, company, RuntimeBranding.ReadText(root, "tagline", 180), RuntimeBranding.ReadText(root, "description", 180), supportUri,
RuntimeBranding.HasReadableWhiteText(accent) ? accent : null, prefix,
RuntimeBranding.ReadText(root, "serverName", 60) ?? DefaultServerName, ReadFlag(root, "aboutMascot"), ReadFlag(root, "aboutLicense"));
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or JsonException)
{
return null;
}
}
/// <summary>Applies the edition's presentation to the generic identity without touching id, author, copyright, repository or attributions.</summary>
public AppInfo Apply(AppInfo defaults)
{
ArgumentNullException.ThrowIfNull(defaults);
return defaults with
{
DisplayName = DisplayName,
Tagline = Tagline ?? CompanyName,
Description = Description ?? defaults.Description,
Website = SupportUrl ?? defaults.Website,
Brand = defaults.Brand with
{
GradientStart = AccentColor ?? defaults.Brand.GradientStart,
GradientEnd = AccentColor ?? defaults.Brand.GradientEnd,
},
};
}
/// <summary>Reads an optional boolean switch; anything but an explicit false keeps the generic behaviour.</summary>
private static bool ReadFlag(JsonElement root, string key)
=> !(root.TryGetProperty(key, out JsonElement field) && field.ValueKind == JsonValueKind.False);
/// <summary>An edition id is a folder name: lowercase letters, digits and hyphens, at most 40 characters.</summary>
[GeneratedRegex("^[a-z0-9][a-z0-9-]{0,39}$", RegexOptions.CultureInvariant, 1000)]
private static partial Regex IdPattern();
/// <summary>An asset prefix becomes a file name on the release feed: 2 to 40 letters or digits starting with a letter.</summary>
[GeneratedRegex("^[A-Za-z][A-Za-z0-9]{1,39}$", RegexOptions.CultureInvariant, 1000)]
private static partial Regex AssetPrefixPattern();
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Printing;
/// <summary>
/// One destination in the server's operator printer catalog. The catalog lists only printers that are approved on the server,
/// present in their device's latest exposure snapshot and visible to the signed-in user, so an entry for this device's printer
/// is the server's confirmation that the printer is approved. Extra members the server adds are ignored.
/// </summary>
public sealed record OperatorPrinter(string PrinterRowId, string DeviceId, string PrinterId, string Name, string DeviceName, string Status, string[] Formats);
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net;
using System.Net.Http.Json;
namespace Qqq.PrintAgent.Core.Printing;
/// <summary>
/// Catalog part of the API client: reads the operator printer catalog so the agent can tell which of its shared printers the
/// server has approved. The device-facing routes never report approval, and the catalog is the one authenticated view a
/// signed-in user has of it.
/// </summary>
public sealed partial class PrintApiClient
{
/// <summary>Catalog page size; the contract allows 1 to 200.</summary>
internal const int CatalogPageSize = 200;
/// <summary>Upper bound on catalog pages read in one call, so a server that keeps returning cursors cannot keep the agent busy.</summary>
internal const int CatalogPageLimit = 10;
/// <summary>
/// Reads the whole operator printer catalog (every page until the server returns no cursor, at most ten pages). It needs the
/// signed-in session: the catalog is user-scoped, so a device bearer token cannot read it. A 404 means the server does not
/// offer the catalog, a 401 or 403 that the signed-in user may not read it; both are reported as such rather than hidden.
/// </summary>
public async Task<IReadOnlyList<OperatorPrinter>> OperatorPrintersAsync(CancellationToken token)
{
if(!_nativeSession)
{
throw new InvalidOperationException("The operator printer catalog needs a signed-in session.");
}
List<OperatorPrinter> printers = [];
string? cursor = null;
for(int page = 0; page < CatalogPageLimit; page++)
{
string path = "operator/printers?limit=" + CatalogPageSize + (cursor is null ? "" : "&after=" + Uri.EscapeDataString(cursor));
using HttpResponseMessage response = await _http.GetAsync(path, token).ConfigureAwait(false);
if(response.StatusCode == HttpStatusCode.NotFound)
{
throw new NotSupportedException("The server does not offer the operator printer catalog (printing extension 2.3 or later).");
}
if(response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
throw new UnauthorizedAccessException($"The signed-in user may not read the operator printer catalog (HTTP {(int)response.StatusCode}).");
}
response.EnsureSuccessStatusCode();
OperatorPrinterList result = await response.Content.ReadFromJsonAsync<OperatorPrinterList>(JsonOptions, token).ConfigureAwait(false)
?? throw new InvalidDataException("Missing operator printer catalog.");
printers.AddRange(result.Printers ?? []);
cursor = result.NextCursor;
if(string.IsNullOrEmpty(cursor))
{
return printers;
}
}
throw new InvalidDataException($"The operator printer catalog did not end after {CatalogPageLimit} pages.");
}
/// <summary>One catalog page: its printers and the cursor for the next page, or null at the end.</summary>
private sealed record OperatorPrinterList(OperatorPrinter[]? Printers, string? NextCursor);
}
@@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Printing;
/// <summary>
/// One entry of the server's PRINTERS frame (printing extension 2.4, capability printer-approval-v1): a printer in this
/// device's latest accepted inventory and whether an administrator has approved it on the server.
/// </summary>
public sealed record PrinterApprovalState(string PrinterId, bool Enabled);
+9 -5
View File
@@ -17,11 +17,15 @@ public sealed record RuntimeBranding(string? DisplayName = null, string? Company
/// <summary>The MSI persists labels so unattended upgrades keep the customer identity.</summary>
public static RuntimeBranding LoadMachine()
/// <summary>
/// Reads the machine branding file and, for the generic build, the labels the installer persisted so unattended upgrades
/// keep a deployment's identity. An edition build skips the persisted labels: its installer writes the edition's own
/// labels anyway, and labels left by an earlier generic install must not relabel the edition. The branding file still wins.
/// </summary>
public static RuntimeBranding LoadMachine(bool includeInstallerLabels = true)
{
RuntimeBranding branding = Load(DefaultPath);
if(!OperatingSystem.IsWindows())
if(!OperatingSystem.IsWindows() || !includeInstallerLabels)
{
return branding;
}
@@ -160,7 +164,7 @@ public sealed record RuntimeBranding(string? DisplayName = null, string? Company
/// <summary>True for a #RRGGBB accent colour dark enough that white text on it has a contrast ratio of at least 4.5 to 1, so a customer colour can never make the header unreadable.</summary>
private static bool HasReadableWhiteText(string? color)
internal static bool HasReadableWhiteText(string? color)
{
if(color is not { Length: 7 } || color[0] != '#' || !color.Skip(1).All(Uri.IsHexDigit))
{
@@ -182,7 +186,7 @@ public sealed record RuntimeBranding(string? DisplayName = null, string? Company
/// <summary>Reads an optional text setting, trimmed, returning null when it is absent, not a string, empty, over the maximum length or contains control characters.</summary>
private static string? ReadText(JsonElement root, string key, int maximum)
internal static string? ReadText(JsonElement root, string key, int maximum)
{
if(!root.TryGetProperty(key, out JsonElement field) || field.ValueKind != JsonValueKind.String)
{
@@ -11,7 +11,7 @@ using KofTwentyTwo.AppKit.Logging;
namespace Qqq.PrintAgent.Windows;
/// <summary>Serializes service and companion writes to the same AppKit daily activity log.</summary>
public sealed partial class AgentActivityLog : IActivityLog
public sealed partial class AgentActivityLog : IAgentLog
{
private readonly FileActivityLog _log;
private LogSettings? _cachedSettings;
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
@@ -21,7 +22,7 @@ public sealed partial class AgentRuntime
private DateTimeOffset _nextSessionResume;
private bool _authorizationReady;
private bool _authorizationBlocked;
private string _authenticationMessage = "Sign in to QQQ to enable printing. Existing pairing and history are preserved.";
private string _authenticationMessage = $"Sign in to {AppIdentity.ServerName} to enable printing. Existing pairing and history are preserved.";
private string? _lastAuthorizationNote;
internal TimeSpan SessionResumeInterval { get; init; } = TimeSpan.FromMinutes(5);
@@ -98,7 +99,7 @@ public sealed partial class AgentRuntime
}
else if(!_sessionClient.IsAuthenticated)
{
WriteLog("WARN", "The held QQQ session is no longer authenticated (the server ended it or an identity check failed).");
WriteLog("WARN", $"The held {AppIdentity.ServerName} session is no longer authenticated (the server ended it or an identity check failed).");
BlockAuthorization();
return false;
}
@@ -121,7 +122,7 @@ public sealed partial class AgentRuntime
/// <summary>Explains, for the log, why the service cannot talk to QQQ yet: no session, signed out, no pairing, or a session for a server other than the paired one.</summary>
private static string DescribeMissingAuthorization(AgentAuthentication? saved, AgentConfiguration? configuration) => saved is null
? "No saved QQQ session: sign in from the companion to enable printing."
? $"No saved {AppIdentity.ServerName} session: sign in from the companion to enable printing."
: saved.Credential is null ? $"Signed out of {saved.Binding.Origin.Authority}: sign in again to enable printing; pairing and history are preserved."
: configuration is null ? $"The saved session for {saved.Binding.Origin.Authority} names device {saved.Binding.DeviceId}, but this computer has no pairing configuration; sign in and enroll again."
: $"The saved session is for {saved.Binding.Origin.Authority} but the pairing is for {configuration.ServerUrl}; sign in to the paired server, or start a new pairing.";
@@ -135,7 +136,7 @@ public sealed partial class AgentRuntime
private async Task RestoreSavedSessionAsync(AgentAuthentication saved, AgentConfiguration? configuration, AgentStore store, CancellationToken token)
{
QqqSessionCredential credential = saved.Credential ?? throw new InvalidOperationException("Sign in is required.");
WriteLog("INFO", $"Restoring the saved QQQ session for {credential.Origin.Authority} (saved identity: principal {saved.Binding.PrincipalId}, tenant {saved.Binding.TenantId}, device {saved.Binding.DeviceId ?? "(none)"}).");
WriteLog("INFO", $"Restoring the saved {AppIdentity.ServerName} session for {credential.Origin.Authority} (saved identity: principal {saved.Binding.PrincipalId}, tenant {saved.Binding.TenantId}, device {saved.Binding.DeviceId ?? "(none)"}).");
IQqqSessionClient restored = NewSession(credential.Origin);
try
{
@@ -144,11 +145,11 @@ public sealed partial class AgentRuntime
: await restored.BindDeviceAsync(new QqqDeviceCredential(configuration.DeviceId, configuration.DeviceToken), cancellationToken: token).ConfigureAwait(false);
RequireSameIdentity(saved.Binding, binding);
ActivateSession(restored, binding, store);
WriteLog("INFO", "Saved QQQ session restored. " + _authenticationMessage);
WriteLog("INFO", $"Saved {AppIdentity.ServerName} session restored. " + _authenticationMessage);
}
catch(Exception exception)
{
WriteFailure("Saved QQQ session could not be restored", exception);
WriteFailure($"Saved {AppIdentity.ServerName} session could not be restored", exception);
restored.Dispose();
throw;
}
@@ -204,11 +205,11 @@ public sealed partial class AgentRuntime
&& string.Equals(binding.DeviceId, configuration.DeviceId, StringComparison.Ordinal);
_authorizationBlocked = false;
_nextSessionResume = DateTimeOffset.UtcNow + SessionResumeInterval;
_authenticationMessage = _authorizationReady ? "Signed in to QQQ." : "Signed in. Enroll this computer to enable printing.";
_authenticationMessage = _authorizationReady ? $"Signed in to {AppIdentity.ServerName}." : "Signed in. Enroll this computer to enable printing.";
ready = _authorizationReady;
_lastAuthorizationNote = null;
}
WriteLog("INFO", $"QQQ session active for {binding.Origin.Authority}: principal {binding.PrincipalId}, tenant {binding.TenantId}, device {binding.DeviceId ?? "(none)"}; "
WriteLog("INFO", $"{AppIdentity.ServerName} session active for {binding.Origin.Authority}: principal {binding.PrincipalId}, tenant {binding.TenantId}, device {binding.DeviceId ?? "(none)"}; "
+ (ready ? "authorized to print." : configuration is null ? "this computer is not enrolled on any server; enroll to print."
: !SameOrigin(binding.Origin, configuration.ServerUrl) ? $"the pairing is for {configuration.ServerUrl}, so printing stays blocked until this computer is enrolled on the signed-in server."
: $"the session device is {binding.DeviceId ?? "(none)"} but the pairing is device {configuration.DeviceId}, so printing stays blocked until the pairing is re-associated.")
@@ -232,10 +233,10 @@ public sealed partial class AgentRuntime
_controlReady = false;
_realtimeReady = false;
_realtimeLifetime?.Cancel();
_authenticationMessage = "QQQ authorization is required. Sign in again; pairing and history are preserved.";
_authenticationMessage = $"{AppIdentity.ServerName} authorization is required. Sign in again; pairing and history are preserved.";
}
if(changed)
{ WriteLog("WARN", "Printing blocked: QQQ authorization is required. Sign in again from the companion; pairing and history are preserved."); }
{ WriteLog("WARN", $"Printing blocked: {AppIdentity.ServerName} authorization is required. Sign in again from the companion; pairing and history are preserved."); }
SignalPipeline();
}
@@ -260,7 +261,7 @@ public sealed partial class AgentRuntime
{
WriteLog("WARN", $"Identity check failed: the approved identity is principal {expected.PrincipalId}, tenant {expected.TenantId}, device {expected.DeviceId ?? "(none)"} on {expected.Origin.Authority}; "
+ $"the session now reports principal {actual.PrincipalId}, tenant {actual.TenantId}, device {actual.DeviceId ?? "(none)"} on {actual.Origin.Authority}. Sign in as the approved user, or start a new pairing.");
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The QQQ user, tenant or device changed. Explicit connection setup is required.");
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, $"The {AppIdentity.ServerName} user, tenant or device changed. Explicit connection setup is required.");
}
}
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
@@ -21,11 +22,11 @@ public sealed partial class AgentRuntime
try
{
WriteLog("INFO", $"Enrollment requested on {request.ServerUrl ?? "(no server)"} with a {(request.EnrollmentCode ?? "").Length}-character enrollment code (never logged), computer name {Environment.MachineName}, agent {PrintApiClient.AgentVersion}.");
IQqqSessionClient session = _sessionClient ?? throw Refused("no QQQ session is held; sign in first.", new InvalidOperationException("Sign in to QQQ before enrolling this computer."));
IQqqSessionClient session = _sessionClient ?? throw Refused($"no {AppIdentity.ServerName} session is held; sign in first.", new InvalidOperationException($"Sign in to {AppIdentity.ServerName} before enrolling this computer."));
if(!session.IsAuthenticated || request.ServerUrl is null || !SameOrigin(session.Origin, request.ServerUrl))
{
throw Refused(!session.IsAuthenticated ? "the held session is not authenticated." : request.ServerUrl is null ? "no server address was given."
: $"the session is for {session.Origin.Authority} but enrollment was requested on {request.ServerUrl}.", new InvalidOperationException("Sign in to the selected QQQ server before enrollment."));
: $"the session is for {session.Origin.Authority} but enrollment was requested on {request.ServerUrl}.", new InvalidOperationException($"Sign in to the selected {AppIdentity.ServerName} server before enrollment."));
}
WriteLog("INFO", "Step 1 of 4: reading the signed-in identity.");
QqqSessionBinding identity = await session.GetSessionIdentityAsync(token).ConfigureAwait(false);
@@ -47,7 +48,7 @@ public sealed partial class AgentRuntime
{
_queuedJobs = [];
}
WriteLog("INFO", "Device enrolled with the QQQ user session.");
WriteLog("INFO", $"Device enrolled with the {AppIdentity.ServerName} user session.");
WakePoll();
return $"Enrolled device {response.DeviceId}";
}
@@ -22,6 +22,7 @@ public sealed partial class AgentRuntime
/// <summary>Invalidate in-flight responses from the prior enrollment without clearing durable job evidence.</summary>
private void ResetConnectionState()
{
ResetPrinterCatalog();
using var store = new AgentStore(directory, recoverUnreadableConfiguration: true);
RealtimeSettings settings = store.LoadRealtimeSettings();
lock(_stateLock)
@@ -0,0 +1,161 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// Printer approval part of the runtime. On a printing 2.4 server that negotiated printer-approval-v1 the server pushes a
/// PRINTERS frame after each accepted inventory and whenever an administrator changes an approval, and that frame is the
/// authoritative reading. On an older server the service reads the operator printer catalog after the inventory is accepted and
/// about once a minute. Either way the result is kept in memory only, reset with the connection, and shown per printer in the
/// status snapshot so the companion can say "approved", "awaiting approval" or "not reported" for the connected server.
/// </summary>
public sealed partial class AgentRuntime
{
/// <summary>How long a catalog reading stays current before the synchronization loop reads it again.</summary>
internal static readonly TimeSpan CatalogRefreshInterval = TimeSpan.FromSeconds(60);
private HashSet<string>? _catalogApproved;
private PrinterServerSync _catalogSync = PrinterServerSync.Initial;
private DateTimeOffset? _catalogCheckedAt;
/// <summary>True while the current connection negotiated printer-approval-v1, so the server pushes approval and the catalog is not read.</summary>
private bool ApprovalNegotiated
{
get
{
lock(_stateLock)
{ return _approvalNegotiated; }
}
}
/// <summary>
/// Applies the server's PRINTERS frame: every printer in the latest accepted inventory with its approval. The enabled ones
/// become the approved set; a shared printer the frame lists as disabled, or does not list yet, is awaiting approval.
/// </summary>
private void ApplyPrinterApproval(RealtimeEnvelope envelope)
{
PrinterApprovalState[] printers = envelope.Payload.TryGetProperty("printers", out JsonElement list) && list.ValueKind == JsonValueKind.Array
? list.Deserialize<PrinterApprovalState[]>(PrintApiClient.JsonOptions) ?? [] : throw new InvalidDataException("The PRINTERS frame has no printers list.");
if(printers.Any(printer => string.IsNullOrEmpty(printer?.PrinterId)))
{ throw new InvalidDataException("The PRINTERS frame names a printer without an identifier."); }
var approved = printers.Where(printer => printer.Enabled).Select(printer => printer.PrinterId).ToHashSet(StringComparer.Ordinal);
string server = _transportOrigin?.Authority ?? "the server";
RecordCatalog(approved, new PrinterServerSync(PrinterServerSync.Reported, Now(), null, server, PrinterServerSync.RealtimeSource),
printers.Select(printer => printer.PrinterId).ToArray(), id => id, printers.Length);
}
/// <summary>Forgets the previous server's catalog reading, so a new connection or server starts from "not checked".</summary>
private void ResetPrinterCatalog()
{
lock(_stateLock)
{
_catalogApproved = null;
_catalogSync = PrinterServerSync.Initial;
_catalogCheckedAt = null;
}
}
/// <summary>The server-side state of one printer given the local sharing choice and the last catalog reading.</summary>
private string ServerStateFor(string printerId, bool exposed)
{
if(!exposed)
{ return PrinterServerState.NotShared; }
lock(_stateLock)
{
return _catalogSync.Outcome switch
{
PrinterServerSync.Reported => _catalogApproved?.Contains(printerId) == true ? PrinterServerState.Approved : PrinterServerState.AwaitingApproval,
PrinterServerSync.NotReported => PrinterServerState.NotReported,
_ => PrinterServerState.NotChecked,
};
}
}
/// <summary>
/// Reads the catalog unless a reading is still current (or <paramref name="force"/> is set, after a fresh inventory), keeps
/// the printers the server lists for this device, and logs the outcome whenever it changes: the approved set at INFO, a
/// catalog that cannot be read at WARN with the reason, once rather than every minute. A failure never breaks the connection.
/// </summary>
private async Task RefreshPrinterCatalogAsync(PrintApiClient api, AgentConfiguration configuration, IReadOnlyList<PrinterInfo> shared, bool force, CancellationToken token)
{
lock(_stateLock)
{
if(!force && _catalogCheckedAt is DateTimeOffset checkedAt && DateTimeOffset.UtcNow - checkedAt < CatalogRefreshInterval)
{ return; }
}
string server = new Uri(configuration.ServerUrl).Authority;
try
{
IReadOnlyList<OperatorPrinter> catalog = await api.OperatorPrintersAsync(token).ConfigureAwait(false);
var approved = catalog.Where(printer => string.Equals(printer.DeviceId, configuration.DeviceId, StringComparison.Ordinal))
.Select(printer => printer.PrinterId).ToHashSet(StringComparer.Ordinal);
RecordCatalog(approved, new PrinterServerSync(PrinterServerSync.Reported, Now(), null, server, PrinterServerSync.CatalogSource), shared.Select(printer => printer.PrinterId).ToArray(), NameOf(shared), catalog.Count);
}
catch(OperationCanceledException) when(token.IsCancellationRequested)
{ throw; }
catch(Exception exception) when(exception is NotSupportedException or UnauthorizedAccessException or HttpRequestException or IOException or InvalidDataException or System.Text.Json.JsonException or OperationCanceledException or ArgumentException or InvalidOperationException or QqqSessionException)
{
string reason = exception switch
{
NotSupportedException or UnauthorizedAccessException => exception.Message,
QqqSessionException { Failure: QqqSessionException.FailureKind.PermissionDenied } => "The signed-in user may not read the operator printer catalog (HTTP 403 Forbidden).",
_ => QqqSessionDiagnostics.Explain(exception),
};
RecordCatalog(null, new PrinterServerSync(PrinterServerSync.NotReported, Now(), reason, server, PrinterServerSync.CatalogSource), shared.Select(printer => printer.PrinterId).ToArray(), NameOf(shared), 0);
WriteLog("DEBUG", "Printer catalog detail: " + exception);
}
}
/// <summary>Maps a shared printer's identifier to its Windows queue name for the log, falling back to the identifier.</summary>
private static Func<string, string> NameOf(IReadOnlyList<PrinterInfo> shared)
=> id => shared.FirstOrDefault(printer => string.Equals(printer.PrinterId, id, StringComparison.Ordinal))?.Name ?? id;
/// <summary>Stores a reading and logs it when the approved set or the outcome differs from the previous reading.</summary>
private void RecordCatalog(HashSet<string>? approved, PrinterServerSync sync, string[] shared, Func<string, string> nameOf, int catalogCount)
{
bool changed;
lock(_stateLock)
{
changed = !string.Equals(_catalogSync.Outcome, sync.Outcome, StringComparison.Ordinal) || !string.Equals(_catalogSync.Detail, sync.Detail, StringComparison.Ordinal)
|| (approved is not null && (_catalogApproved is null || !_catalogApproved.SetEquals(approved)));
_catalogApproved = approved;
_catalogSync = sync;
_catalogCheckedAt = DateTimeOffset.UtcNow;
}
if(!changed)
{ return; }
if(approved is null)
{
WriteLog("WARN", $"Printer approval is not reported by {sync.Server}: {sync.Detail} Shared printers show as 'not reported' until the catalog can be read.");
return;
}
string[] approvedNames = shared.Where(approved.Contains).Select(nameOf).ToArray();
string[] waitingNames = shared.Where(id => !approved.Contains(id)).Select(nameOf).ToArray();
string source = string.Equals(sync.Source, PrinterServerSync.RealtimeSource, StringComparison.Ordinal) ? "Printer approval pushed by" : "Printer catalog from";
WriteLog("INFO", $"{source} {sync.Server}: {approvedNames.Length} of {shared.Length} shared printer(s) approved for this device"
+ (approvedNames.Length > 0 ? $" [{string.Join(", ", approvedNames)}]" : "")
+ (waitingNames.Length > 0 ? $"; awaiting approval on the server [{string.Join(", ", waitingNames)}]" : "")
+ (string.Equals(sync.Source, PrinterServerSync.RealtimeSource, StringComparison.Ordinal) ? "." : $"; the catalog listed {catalogCount} destination(s) in total."));
}
}
@@ -32,6 +32,7 @@ public sealed partial class AgentRuntime
private readonly SemaphoreSlim _statusRequested = new(0, 1);
private readonly SemaphoreSlim _queueRefreshRequested = new(0, 1);
private bool _inventoryReady;
private bool _approvalNegotiated;
private long _stateSequence;
@@ -122,7 +123,9 @@ public sealed partial class AgentRuntime
finally
{
lock(_stateLock)
{ if(generation == _connectionGeneration) { _realtime = null; _realtimeReady = false; _realtimeLifetime = null; } }
{ if(generation == _connectionGeneration) { _realtime = null; _realtimeReady = false; _realtimeLifetime = null; _approvalNegotiated = false; } }
// The next connection asks the server about printer approval again rather than showing the previous connection's answer.
ResetPrinterCatalog();
SignalPipeline();
}
RealtimeSettings settings = _realtimeSettings;
@@ -168,7 +171,8 @@ public sealed partial class AgentRuntime
RealtimeSettings settings = _realtimeSettings;
using var handshake = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
handshake.CancelAfter(settings.WebSocketHandshakeTimeoutMillis);
(WebSocket socket, bool supportsMedia) = await ConnectRealtimeSocketAsync(settings, lifetime, handshake.Token).ConfigureAwait(false);
(WebSocket socket, QqqApiDiscovery discovery) = await ConnectRealtimeSocketAsync(settings, lifetime, handshake.Token).ConfigureAwait(false);
string[] capabilities = NegotiateCapabilities(discovery);
using var channel = new PrintingRealtimeChannel(socket, settings, _traffic);
var updates = Channel.CreateBounded<RealtimeStateUpdate>(4096);
var commands = Channel.CreateBounded<RealtimeEnvelope>(4096);
@@ -193,7 +197,6 @@ public sealed partial class AgentRuntime
Task handshakeWatch = AwaitRealtimeHandshakeAsync(handshake.Token, lifetime.Token);
try
{
string[] capabilities = supportsMedia ? [.. RealtimeCapabilities, "printer-media-v1"] : RealtimeCapabilities;
WriteLog("INFO", $"Realtime socket open; sending HELLO (agent {PrintApiClient.AgentVersion}, capabilities [{string.Join(", ", capabilities)}], applied desired-state revision {_controlState.DesiredState.Revision}, settings revision {settings.Revision}); waiting up to {settings.WebSocketHandshakeTimeoutMillis} ms for WELCOME.");
await channel.SendAsync("HELLO", new
{
@@ -259,8 +262,28 @@ public sealed partial class AgentRuntime
/// <summary>
/// Chooses this socket's HELLO capabilities from the discovery just read: the three base capabilities always, printer-media-v1
/// on a 2.3-or-newer server and printer-approval-v1 on a 2.4-or-newer one. Whether approval was negotiated is remembered for
/// the connection, so the catalog fallback is skipped while the server pushes approval itself.
/// </summary>
private string[] NegotiateCapabilities(QqqApiDiscovery discovery)
{
bool supportsApproval = discovery.SupportsPrinterApproval();
lock(_stateLock)
{ _approvalNegotiated = supportsApproval; }
List<string> negotiated = [.. RealtimeCapabilities];
if(discovery.SupportsPrinterMedia())
{ negotiated.Add("printer-media-v1"); }
if(supportsApproval)
{ negotiated.Add("printer-approval-v1"); }
return [.. negotiated];
}
/// <summary>Reads the discovery refreshed by this socket establishment, so server upgrades and downgrades change capabilities on reconnect without polling.</summary>
private async Task<(WebSocket Socket, bool SupportsMedia)> ConnectRealtimeSocketAsync(RealtimeSettings settings, CancellationTokenSource lifetime, CancellationToken token)
private async Task<(WebSocket Socket, QqqApiDiscovery Discovery)> ConnectRealtimeSocketAsync(RealtimeSettings settings, CancellationTokenSource lifetime, CancellationToken token)
{
try
{
@@ -270,7 +293,7 @@ public sealed partial class AgentRuntime
try
{
QqqApiDiscovery discovery = await session.DiscoverAsync(token).ConfigureAwait(false);
return (socket, discovery.SupportsPrinterMedia());
return (socket, discovery);
}
catch { socket.Dispose(); throw; }
}
@@ -346,6 +369,10 @@ public sealed partial class AgentRuntime
WakePoll();
RequestQueueRefresh();
break;
case "PRINTERS":
ApplyPrinterApproval(envelope);
RequestStatusSynchronization();
break;
case "PONG":
break;
case "ERROR":
@@ -65,17 +65,24 @@ public sealed partial class AgentRuntime
ExposedPrinterBackend backend = new(new ProfiledPrinterBackend(_printers, store), store.GetExposedPrinterIds);
IReadOnlyList<PrinterInfo> inventory = backend.Discover();
string serialized = JsonSerializer.Serialize(inventory.OrderBy(item => item.PrinterId, StringComparer.Ordinal), PrintApiClient.JsonOptions);
bool freshInventory = false;
if(!string.Equals(serialized, previousInventory, StringComparison.Ordinal))
{
if(!NormalTrafficAllowed)
{ continue; }
await api.InventoryAsync(inventory, token).ConfigureAwait(false);
previousInventory = serialized;
freshInventory = true;
lock(_stateLock)
{ _inventoryReady = true; }
SignalPipeline();
WakePoll();
}
// A 2.4 server pushes approval in a PRINTERS frame after this inventory; an older server's operator catalog is read instead.
if(!ApprovalNegotiated)
{ await RefreshPrinterCatalogAsync(api, configuration, inventory, force: freshInventory, token).ConfigureAwait(false); }
if(freshInventory)
{ RequestStatusSynchronization(); }
}
await ReconcileRealtimeReceiptsAsync(store, token).ConfigureAwait(false);
_ = store.PruneHistory();
@@ -3,6 +3,8 @@
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
@@ -45,7 +47,7 @@ public sealed partial class AgentRuntime
if(request.StartNewPairing && binding.DeviceId is not null)
{
WriteLog("WARN", $"Sign-in refused: a new pairing needs a session without device proof, but the session already names device {binding.DeviceId}.");
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "New pairing requires a fresh QQQ session without device proof.");
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, $"New pairing requires a fresh {AppIdentity.ServerName} session without device proof.");
}
AgentAuthentication? saved = store.LoadAuthenticationForRecovery();
if(!request.StartNewPairing && configuration is not null && SameOrigin(credential.Origin, configuration.ServerUrl))
@@ -73,7 +75,7 @@ public sealed partial class AgentRuntime
previous?.Dispose();
WakePoll();
WriteLog("INFO", "Sign-in complete. " + _authenticationMessage);
return "Signed in to QQQ. " + _authenticationMessage;
return SignInReply();
}
catch(Exception exception)
{
@@ -86,10 +88,25 @@ public sealed partial class AgentRuntime
/// <summary>
/// The one-line result the companion shows after a successful sign-in. It always starts with "Signed in to {server}." (the
/// companion's success marker) and then says whether printing is enabled or what still has to happen.
/// </summary>
private string SignInReply()
{
string prefix = $"Signed in to {AppIdentity.ServerName}.";
const string signedIn = "Signed in.";
return _authorizationReady ? prefix + " Printing is enabled."
: _authenticationMessage.StartsWith(signedIn, StringComparison.Ordinal) ? prefix + _authenticationMessage[signedIn.Length..]
: prefix + " " + _authenticationMessage;
}
/// <summary>Checks the request shape before any network work (a credential is present and the pairing choice is not contradictory) and logs what was asked.</summary>
private QqqSessionCredential BeginSignIn(PipeRequest request)
{
QqqSessionCredential credential = request.SessionCredential ?? throw new InvalidOperationException("A QQQ sign-in session is required.");
QqqSessionCredential credential = request.SessionCredential ?? throw new InvalidOperationException($"A {AppIdentity.ServerName} sign-in session is required.");
if(request.StartNewPairing && request.ClaimLegacyDevice)
{
WriteLog("WARN", "Sign-in refused: the companion asked for both a new pairing and association with the existing pairing.");
@@ -103,8 +120,25 @@ public sealed partial class AgentRuntime
/// <summary>Runs sign-out as a connection transition, so it cannot overlap a claim or a printing job.</summary>
internal async Task<string> SignOutAsync(CancellationToken token)
=> await ConnectionTransitionAsync(() => SignOutCoreAsync(token), token).ConfigureAwait(false);
internal async Task<string> SignOutAsync(CancellationToken token) => await SignOutAsync(null, token).ConfigureAwait(false);
/// <summary>
/// Runs sign-out as a connection transition. When the companion passes the application's registered callback, the identity
/// provider's own sign-out is requested too and the reply is a <see cref="PipeSignOutResult"/> carrying it for the companion to open in
/// the browser; without a callback (the command line) only the QQQ session ends and the reply is plain text.
/// </summary>
internal async Task<string> SignOutAsync(string? postLogoutRedirectUri, CancellationToken token)
{
Uri? redirect = null;
if(postLogoutRedirectUri is not null && (!string.Equals(postLogoutRedirectUri, QqqBrowserCallback.RedirectUri, StringComparison.Ordinal)
|| !Uri.TryCreate(postLogoutRedirectUri, UriKind.Absolute, out redirect)))
{
throw new InvalidOperationException("The sign-out callback is not this application's registered callback.");
}
return await ConnectionTransitionAsync(() => SignOutCoreAsync(redirect, token), token).ConfigureAwait(false);
}
@@ -113,7 +147,7 @@ public sealed partial class AgentRuntime
/// sign-in is checked against the same user. An unreadable saved session is moved aside, not deleted. The server session is ended when
/// it can be, and the local client and connection state are always cleared, even if ending it on the server fails.
/// </summary>
private async Task<string> SignOutCoreAsync(CancellationToken token)
private async Task<string> SignOutCoreAsync(Uri? postLogoutRedirect, CancellationToken token)
{
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
try
@@ -124,11 +158,12 @@ public sealed partial class AgentRuntime
AgentAuthentication? saved = store.LoadAuthenticationForRecovery();
bool unreadableSession = store.QuarantineUnreadableAuthentication();
if(unreadableSession)
{ WriteLog("WARN", "The saved session data could not be read and was moved aside for recovery; revoke the previous session in QQQ."); }
{ WriteLog("WARN", $"The saved session data could not be read and was moved aside for recovery; revoke the previous session in {AppIdentity.ServerName}."); }
if(saved is not null)
{
store.SaveAuthentication(new AgentAuthentication(saved.Binding, null));
}
QqqProviderLogout? providerLogout = null;
try
{
if(_sessionClient is null && saved?.Credential is not null)
@@ -139,7 +174,10 @@ public sealed partial class AgentRuntime
}
if(_sessionClient is not null)
{
await _sessionClient.SignOutAsync(token).ConfigureAwait(false);
if(postLogoutRedirect is null)
{ await _sessionClient.SignOutAsync(token).ConfigureAwait(false); }
else
{ providerLogout = await _sessionClient.SignOutWithProviderAsync(postLogoutRedirect, token).ConfigureAwait(false); }
}
else
{ WriteLog("INFO", "No session to end on the server; local state cleared."); }
@@ -156,9 +194,11 @@ public sealed partial class AgentRuntime
ResetConnectionState();
WriteLog("INFO", "Signed out locally. Device pairing and print history were preserved.");
}
return unreadableSession
? "Signed out locally. Unreadable session data was preserved for recovery; revoke the previous session in QQQ. Device pairing and print history were preserved."
string message = unreadableSession
? $"Signed out locally. Unreadable session data was preserved for recovery; revoke the previous session in {AppIdentity.ServerName}. Device pairing and print history were preserved."
: "Signed out. Device pairing and print history were preserved.";
// The hint inside the provider request is sensitive: it goes only into the reply, never into the log.
return postLogoutRedirect is null ? message : JsonSerializer.Serialize(new PipeSignOutResult(message, providerLogout), PrintApiClient.JsonOptions);
}
finally { _authenticationGate.Release(); }
}
+9 -5
View File
@@ -7,6 +7,7 @@ using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
@@ -193,7 +194,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
PrintingRealtimeChannel realtime;
lock(_stateLock)
{ realtime = _realtime ?? throw new IOException("The realtime connection is unavailable."); }
return PrintApiClient.ForSession(_sessionClient ?? throw new InvalidOperationException("Sign in to QQQ before printing.")).WithRealtime(realtime, _realtimeSettings, CurrentRealtime);
return PrintApiClient.ForSession(_sessionClient ?? throw new InvalidOperationException($"Sign in to {AppIdentity.ServerName} before printing.")).WithRealtime(realtime, _realtimeSettings, CurrentRealtime);
}
@@ -233,12 +234,15 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
AgentConfiguration? configuration = store.LoadConfiguration();
IReadOnlySet<string> exposed = store.GetExposedPrinterIds();
var printers = new ProfiledPrinterBackend(_printers, store).Discover().Select(printer => new PrinterSnapshot(printer.PrinterId, printer.Name, printer.DriverName, printer.PortName, printer.Status, printer.Formats, exposed.Contains(printer.PrinterId), printer.DisplayName, printer.Description, printer.Location, printer.MediaProfiles)).ToList();
var printers = new ProfiledPrinterBackend(_printers, store).Discover().Select(printer => new PrinterSnapshot(printer.PrinterId, printer.Name, printer.DriverName, printer.PortName, printer.Status, printer.Formats, exposed.Contains(printer.PrinterId), printer.DisplayName, printer.Description, printer.Location, printer.MediaProfiles, ServerStateFor(printer.PrinterId, exposed.Contains(printer.PrinterId)))).ToList();
foreach(string missing in exposed.Where(id => !printers.Any(printer => string.Equals(printer.PrinterId, id, StringComparison.Ordinal))))
{
PrinterProfile profile = store.GetPrinterProfile(missing);
printers.Add(new PrinterSnapshot(missing, "Previously exposed printer is unavailable", "", "", "OFFLINE", [], true, profile.DisplayName, profile.Description, profile.Location));
printers.Add(new PrinterSnapshot(missing, "Previously exposed printer is unavailable", "", "", "OFFLINE", [], true, profile.DisplayName, profile.Description, profile.Location, ServerState: ServerStateFor(missing, exposed: true)));
}
PrinterServerSync printerSync;
lock(_stateLock)
{ printerSync = _catalogSync; }
IReadOnlyList<JobSnapshot> jobs = store.RecentJobs();
int attentionCount = store.NeedsAttentionCount();
lock(_stateLock)
@@ -266,7 +270,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
health = "RED";
status = _authenticationMessage;
}
return new AgentSnapshot(configuration is not null, connected, health, status, configuration?.ServerUrl, configuration?.DeviceId, _lastSuccessfulPoll?.ToString("O", System.Globalization.CultureInfo.InvariantCulture), printers, jobs, _logs.ToArray(), store.GetDeviceProfile(), _controlState, AuthenticationStatus(), GetTransportSnapshot());
return new AgentSnapshot(configuration is not null, connected, health, status, configuration?.ServerUrl, configuration?.DeviceId, _lastSuccessfulPoll?.ToString("O", System.Globalization.CultureInfo.InvariantCulture), printers, jobs, _logs.ToArray(), store.GetDeviceProfile(), _controlState, AuthenticationStatus(), GetTransportSnapshot(), PrinterSync: printerSync);
}
}
@@ -438,7 +442,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
case "sign-in":
return await SignInAsync(request, token).ConfigureAwait(false);
case "sign-out":
return await SignOutAsync(token).ConfigureAwait(false);
return await SignOutAsync(request.PostLogoutRedirectUri, token).ConfigureAwait(false);
case "reconnect":
return ReconnectLocal();
case "test-print":
+1 -1
View File
@@ -8,5 +8,5 @@ using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
/// <summary>Credential-free local service observations for the tray companion.</summary>
public sealed record AgentSnapshot(bool Enrolled, bool Connected, string Health, string Status, string? ServerUrl, string? DeviceId, string? LastSuccessfulPoll, IReadOnlyList<PrinterSnapshot> Printers, IReadOnlyList<JobSnapshot> Jobs, IReadOnlyList<AgentLogEntry> Logs, DeviceProfile? DeviceProfile = null, AgentControlState? Control = null, AgentAuthenticationStatus? Authentication = null, AgentTransportSnapshot? Transport = null);
public sealed record AgentSnapshot(bool Enrolled, bool Connected, string Health, string Status, string? ServerUrl, string? DeviceId, string? LastSuccessfulPoll, IReadOnlyList<PrinterSnapshot> Printers, IReadOnlyList<JobSnapshot> Jobs, IReadOnlyList<AgentLogEntry> Logs, DeviceProfile? DeviceProfile = null, AgentControlState? Control = null, AgentAuthenticationStatus? Authentication = null, AgentTransportSnapshot? Transport = null, PrinterServerSync? PrinterSync = null);
@@ -21,15 +21,22 @@ public sealed class GitHubMsiReleaseClient : IDisposable
private const string RepositoryPath = "/QRun-IO/qqq-print-agent/releases/download/";
private static readonly Uri Feed = new("https://api.github.com/repos/QRun-IO/qqq-print-agent/releases?per_page=100");
private readonly HttpClient _http;
private readonly string _assetPrefix;
/// <summary>
/// Creates the client with automatic redirects off, because every redirect is validated by hand against an allow-list of
/// GitHub hosts. A caller-supplied handler lets tests substitute the network without relaxing that rule.
/// GitHub hosts. A caller-supplied handler lets tests substitute the network without relaxing that rule. The asset prefix
/// selects which MSI on the shared feed this build follows: the generic one, or a client edition's.
/// </summary>
public GitHubMsiReleaseClient(HttpMessageHandler? handler = null)
public GitHubMsiReleaseClient(HttpMessageHandler? handler = null, string assetPrefix = Qqq.PrintAgent.Core.Edition.DefaultAssetPrefix)
{
if(string.IsNullOrEmpty(assetPrefix) || assetPrefix.Length > 40 || !char.IsAsciiLetter(assetPrefix[0]) || !assetPrefix.All(char.IsAsciiLetterOrDigit))
{
throw new ArgumentException("The update asset prefix must be 2 to 40 letters or digits.", nameof(assetPrefix));
}
_assetPrefix = assetPrefix;
_http = new HttpClient(handler ?? new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromMinutes(10),
@@ -198,7 +205,7 @@ public sealed class GitHubMsiReleaseClient : IDisposable
/// <summary>Rechecks a release description before it is used: version and channel eligibility, size, and that both URLs are exactly this repository's asset addresses.</summary>
private static void Validate(MsiRelease release, MsiReleaseVersion current)
private void Validate(MsiRelease release, MsiReleaseVersion current)
{
if(!MsiReleaseVersion.TryParse(release.Version, out MsiReleaseVersion candidate) || !Eligible(candidate, current) || release.Size is <= 0 or > MaximumMsiBytes)
{
@@ -211,7 +218,7 @@ public sealed class GitHubMsiReleaseClient : IDisposable
/// <summary>The single MSI file name a release must publish for a version; the updater depends on this exact name.</summary>
private static string AssetName(string version) => "QqqPrintAgent-v" + version + "-x64.msi";
private string AssetName(string version) => _assetPrefix + "-v" + version + "-x64.msi";
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
namespace Qqq.PrintAgent.Windows;
/// <summary>The activity log as the companion uses it: AppKit's levels plus the debug detail that the shared log settings switch on and off.</summary>
public interface IAgentLog : IActivityLog
{
/// <summary>True while debug logging is enabled in the shared log settings.</summary>
bool IsDebugEnabled { get; }
/// <summary>Writes request-level detail only while debug logging is enabled.</summary>
void Debug(string message);
}
@@ -10,9 +10,9 @@ using KofTwentyTwo.AppKit.Updates;
namespace Qqq.PrintAgent.Windows;
/// <summary>AppKit update flow backed by a coherent machine MSI rather than a per-user executable update.</summary>
public sealed class MsiUpdateService(Action exitApplication, IActivityLog log) : IUpdateService, IDisposable
public sealed class MsiUpdateService(Action exitApplication, IActivityLog log, string assetPrefix = Qqq.PrintAgent.Core.Edition.DefaultAssetPrefix) : IUpdateService, IDisposable
{
private readonly GitHubMsiReleaseClient _releases = new();
private readonly GitHubMsiReleaseClient _releases = new(assetPrefix: assetPrefix);
private MsiRelease? _pending;
public event EventHandler<UpdateStatusEventArgs>? StatusChanged;
+1 -1
View File
@@ -13,7 +13,7 @@ public sealed record PipeRequest(string Operation, string? ServerUrl = null, str
string? JobId = null, string? Format = null, bool ConfirmDuplicateRisk = false, JobQuery? JobQuery = null, LogSettings? LogSettings = null,
Qqq.PrintAgent.Core.Printing.DeviceProfile? DeviceProfile = null, PrinterProfile? PrinterProfile = null, string? TestProfile = null,
Qqq.PrintAgent.Core.Authentication.QqqSessionCredential? SessionCredential = null, bool ClaimLegacyDevice = false, bool StartNewPairing = false,
ServerTrust? ServerTrust = null)
ServerTrust? ServerTrust = null, string? PostLogoutRedirectUri = null)
{
/// <summary>Returns a fixed label instead of the generated record text, so logging a request can never reveal a session credential or enrollment code.</summary>
public override string ToString() => "Print agent management request (credentials redacted)";
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The service's reply to a companion sign-out: the plain message, and the identity provider's sign-out request when the server
/// offers one, for the companion to open in the browser so the provider's own session ends too. It carries the provider's ID token
/// hint, so it is never logged and prints as fixed text.
/// </summary>
public sealed record PipeSignOutResult(string Message, QqqProviderLogout? ProviderLogout)
{
/// <summary>Returns a fixed label instead of the generated record text, so logging a reply can never reveal the provider sign-out request.</summary>
public override string ToString() => "Print agent sign-out result (provider request redacted)";
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// What the connected server has said about one locally shared printer. Sharing is a local choice; approval is the server's,
/// and the two are shown side by side so a printer that is shared but not yet approved is never mistaken for a working one.
/// </summary>
public static class PrinterServerState
{
/// <summary>The server's catalog lists the printer for this device: it is approved and can receive jobs.</summary>
public const string Approved = "APPROVED";
/// <summary>The printer is shared and the server received the inventory, but its catalog does not list it: an administrator still has to approve it there.</summary>
public const string AwaitingApproval = "AWAITING_APPROVAL";
/// <summary>The printer is not shared, so the server is not offered it.</summary>
public const string NotShared = "NOT_SHARED";
/// <summary>The printer is shared but the server's catalog could not be read (older server, or the signed-in user may not read it), so approval is unknown.</summary>
public const string NotReported = "NOT_REPORTED";
/// <summary>The printer is shared and no server has been asked yet (not connected, or the first check is still pending).</summary>
public const string NotChecked = "NOT_CHECKED";
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The last attempt to read the server's printer catalog: when it was made, whether the server reported approvals
/// (REPORTED), could not (NOT_REPORTED, with the reason) or has not been asked (NOT_CHECKED), and which server answered.
/// </summary>
public sealed record PrinterServerSync(string Outcome, string? CheckedAt = null, string? Detail = null, string? Server = null, string? Source = null)
{
/// <summary>The reading came from the server's PRINTERS frame (printing extension 2.4, printer-approval-v1): authoritative and pushed on change.</summary>
public const string RealtimeSource = "REALTIME";
/// <summary>The reading came from the operator printer catalog (printing extension 2.3 fallback): read after inventory and about once a minute.</summary>
public const string CatalogSource = "CATALOG";
/// <summary>The catalog was read and the printer states are current as of <see cref="CheckedAt"/>.</summary>
public const string Reported = "REPORTED";
/// <summary>The catalog could not be read; <see cref="Detail"/> says why.</summary>
public const string NotReported = "NOT_REPORTED";
/// <summary>No server has been asked since the service started or the connection was reset.</summary>
public const string NotChecked = "NOT_CHECKED";
/// <summary>The state before any server contact.</summary>
public static PrinterServerSync Initial { get; } = new(NotChecked);
}
@@ -5,8 +5,11 @@
namespace Qqq.PrintAgent.Windows;
/// <summary>A locally discovered queue and whether its owner has exposed it to QQQ.</summary>
/// <summary>
/// A locally discovered queue, whether its owner has shared it with the server, and what the connected server has said about
/// it (<see cref="ServerState"/>, one of the <see cref="PrinterServerState"/> values).
/// </summary>
public sealed record PrinterSnapshot(string PrinterId, string Name, string DriverName, string PortName, string Status, string[] Formats, bool Exposed,
string? DisplayName = null, string? Description = null, string? Location = null,
IReadOnlyList<Qqq.PrintAgent.Core.Printing.PrinterMediaProfile>? MediaProfiles = null);
IReadOnlyList<Qqq.PrintAgent.Core.Printing.PrinterMediaProfile>? MediaProfiles = null, string ServerState = PrinterServerState.NotChecked);
@@ -6,6 +6,7 @@
using System.Diagnostics;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text;
@@ -28,11 +29,31 @@ public sealed partial class QqqBrowserCallback : IDisposable
/// <summary>
/// Creates the one-instance pipe. It is current-user-only and the first instance wins, so a second sign-in attempt or a
/// Creates the one-instance pipe. Only this user may open it and the first instance wins, so a second sign-in attempt or a
/// look-alike server created earlier by another process is refused rather than silently sharing the inbox.
/// </summary>
internal QqqBrowserCallback(string name) => _pipe = new NamedPipeServerStream(name, PipeDirection.InOut, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly | PipeOptions.FirstPipeInstance);
internal QqqBrowserCallback(string name) => _pipe = NamedPipeServerStreamAcl.Create(name, PipeDirection.InOut, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.FirstPipeInstance, 4096, 4096, InboxSecurity());
/// <summary>
/// The inbox's security descriptor. The companion runs elevated, and PipeOptions.CurrentUserOnly would key the pipe to
/// the elevated token's default owner, which is the Administrators group rather than the user; the protocol handler the
/// browser launches runs as the ordinary user with Administrators present only as a deny-only group, so it was refused.
/// The descriptor therefore grants access to the user's own SID (the same single-user restriction, keyed correctly) and
/// names the user as owner, so the unelevated handler can reach the inbox and nobody else can.
/// </summary>
internal static PipeSecurity InboxSecurity()
{
using var identity = WindowsIdentity.GetCurrent();
SecurityIdentifier user = identity.User ?? throw new InvalidOperationException("The current user has no security identifier.");
var security = new PipeSecurity();
security.SetOwner(user);
security.SetGroup(user);
security.AddAccessRule(new PipeAccessRule(user, PipeAccessRights.FullControl, AccessControlType.Allow));
return security;
}
@@ -69,7 +90,9 @@ public sealed partial class QqqBrowserCallback : IDisposable
trace?.Invoke("Callback address accepted: " + validated.GetLeftPart(UriPartial.Path) + " with " + (validated.Query.Length - 1).ToString(System.Globalization.CultureInfo.InvariantCulture) + " characters of query (not logged).");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
await using NamedPipeClientStream pipe = new(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, TokenImpersonationLevel.Anonymous);
// Not CurrentUserOnly: that client option compares the pipe owner with this token's default owner, which differs
// between an elevated and an unelevated process of the same user. The peer is verified by process image instead.
await using NamedPipeClientStream pipe = new(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous, TokenImpersonationLevel.Anonymous);
trace?.Invoke("Connecting to the companion's callback inbox (five second limit). If this is the last line, no companion in this user session was waiting for a callback: start the browser sign-in from the app first, and keep the app open.");
await pipe.ConnectAsync(timeout.Token).ConfigureAwait(false);
trace?.Invoke("Inbox connected; verifying that it belongs to the installed companion " + expectedExecutable + ".");
@@ -158,6 +181,11 @@ public sealed partial class QqqBrowserCallback : IDisposable
/// <summary>The security descriptor the operating system holds for the live inbox pipe, for tests that must check what was created rather than what was intended.</summary>
internal PipeSecurity CurrentSecurity() => _pipe.GetAccessControl();
/// <summary>Closes the inbox, which ends the sign-in attempt's ability to receive a callback.</summary>
public void Dispose() => _pipe.Dispose();
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
namespace Qqq.PrintAgent;
/// <summary>
/// Composes the shared AppKit About body for this build: the generic build keeps every section; an edition may leave out the
/// Query mascot section and the License section through its descriptor while the header (name, tagline, version) and the
/// Open source components credits always remain. Deployment branding adds the support link and logo.
/// </summary>
internal static class AboutContent
{
/// <summary>Applies the build's About choices to a freshly created dialog body.</summary>
internal static void Compose(StackPanel body, Uri? supportUrl, BitmapImage? logo)
{
if(supportUrl is not null)
{
body.Children.Add(new HyperlinkButton { Content = "Contact support", NavigateUri = supportUrl });
}
if(logo is not null)
{
body.Children.Insert(0, new Image { Source = logo, Width = 128, Height = 64, Stretch = Microsoft.UI.Xaml.Media.Stretch.Uniform, HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Left });
}
if(AppIdentity.Edition?.ShowLicense == false)
{
StackPanel? license = body.Children.OfType<StackPanel>().FirstOrDefault(section => section.Children.FirstOrDefault() is TextBlock { Text: "License" });
if(license is not null)
{
body.Children.Remove(license);
}
}
if(AppIdentity.Edition?.ShowMascot != false)
{
QueryAboutSection.InsertAfterHeader(body);
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ public partial class App : Application
/// <summary>The app-wide activity log.</summary>
internal static AgentActivityLog Log { get; } = AgentActivityLog.Create(AgentStore.DefaultDirectory);
internal static IAgentLog Log { get; } = AgentActivityLog.Create(AgentStore.DefaultDirectory);
+2 -2
View File
@@ -28,13 +28,13 @@ public sealed partial class MainWindow
/// Asks the operator to confirm an action in a dialog whose default button is Back, so pressing Enter cancels rather than
/// proceeds. It declines without showing anything while another request is running or the window is closing.
/// </summary>
private async Task<bool> ConfirmAsync(string title, string message, string action)
private async Task<bool> ConfirmAsync(string title, string message, string action, XamlRoot? owner = null)
{
if(_updatesClosed || _busy)
{
return false;
}
var dialog = new ContentDialog { Title = title, Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, PrimaryButtonText = action, CloseButtonText = "Back", DefaultButton = ContentDialogButton.Close, XamlRoot = Content.XamlRoot };
var dialog = new ContentDialog { Title = title, Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, PrimaryButtonText = action, CloseButtonText = "Back", DefaultButton = ContentDialogButton.Close, XamlRoot = owner ?? Content.XamlRoot };
return await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary;
}
@@ -69,7 +69,7 @@ public sealed partial class MainWindow
private void ShowServerTrust()
{
AllowUntrustedCertificateBox.IsChecked = _serverTrust.AllowUntrustedCertificate;
AllowUntrustedCertificateBox.Content = _serverTrust.AllowUntrustedCertificate
AllowUntrustedCertificateLabel.Text = _serverTrust.AllowUntrustedCertificate
? "Allow untrusted server certificate for " + _serverTrust.Origin + " (development only)"
: "Allow untrusted server certificate for this server (development only)";
}
@@ -195,6 +195,7 @@ public sealed partial class MainWindow
try
{
App.Log.Info("[Companion] Password sign-in submitted for " + _signInClient.Origin.Authority + ".");
Feedback("Signing in", "Checking the username and password with the server.", InfoBarSeverity.Informational);
await _signInClient.SignInWithPasswordAsync(UsernameBox.Text.Trim(), QqqPasswordBox.Password);
await TransferSignInAsync();
}
@@ -223,7 +224,7 @@ public sealed partial class MainWindow
App.Log.Info("[Companion] Sign-in accepted by the server; handing the session to the service (" + choice + ").");
string result = await _sendRequest(new PipeRequest("sign-in", SessionCredential: _signInClient!.ExportCredential(),
ClaimLegacyDevice: PairingChoice.SelectedIndex == 1, StartNewPairing: PairingChoice.SelectedIndex == 2));
bool success = result.StartsWith("Signed in to QQQ.", StringComparison.Ordinal);
bool success = result.StartsWith("Signed in to " + AppIdentity.ServerName + ".", StringComparison.Ordinal);
if(success)
{ App.Log.Info("[Companion] Service sign-in result: " + result); }
else
@@ -233,7 +234,11 @@ public sealed partial class MainWindow
/// <summary>Asks the service to sign out and forget its session, then drops the window's own sign-in client. Pairing and job history are not removed.</summary>
/// <summary>
/// Asks the service to sign out and forget its session, then drops the window's own sign-in client. The identity provider is meant
/// to be invisible, so the service also returns the provider's own sign-out when the server offers one and this window opens it in
/// the browser: staying signed in to the provider would let the next sign-in skip the credential prompt. Pairing and job history are not removed.
/// </summary>
private async void SignOut_Click(object sender, RoutedEventArgs e)
{
if(_busy)
@@ -242,11 +247,23 @@ public sealed partial class MainWindow
try
{
App.Log.Info("[Companion] Sign-out requested; asking the service to end the session.");
string result = await _sendRequest(new PipeRequest("sign-out"));
string result = await _sendRequest(new PipeRequest("sign-out", PostLogoutRedirectUri: QqqBrowserCallback.RedirectUri));
_signInClient?.Dispose();
_signInClient = null;
App.Log.Info("[Companion] Service sign-out result: " + result);
Feedback("Account sign-out", result, result.StartsWith("Error:", StringComparison.Ordinal) ? InfoBarSeverity.Warning : InfoBarSeverity.Informational);
PipeSignOutResult? outcome = ReadSignOutResult(result);
string message = outcome?.Message ?? result;
App.Log.Info("[Companion] Service sign-out result: " + message + (outcome?.ProviderLogout is null ? "" : " The identity provider sign-out is ready; opening it in the browser."));
InfoBarSeverity severity = message.StartsWith("Error:", StringComparison.Ordinal) ? InfoBarSeverity.Warning : InfoBarSeverity.Informational;
if(outcome?.ProviderLogout is { } providerLogout)
{
Feedback("Account sign-out", message + " Finish signing out of the identity provider on the page that opened in your browser.", severity);
bool providerEnded = await CompleteProviderSignOutAsync(providerLogout);
Feedback("Account sign-out", providerEnded ? message + " The identity provider session ended too, so the next sign-in asks for credentials."
: message + " The identity provider sign-out was opened in your browser but did not confirm; close that page and sign out there if it is still open.",
providerEnded ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
}
else
{ Feedback("Account sign-out", message, severity); }
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException)
{
@@ -258,6 +275,46 @@ public sealed partial class MainWindow
/// <summary>Reads the service's sign-out reply: structured text when a provider sign-out was requested, or null for a plain message or an error text.</summary>
private static PipeSignOutResult? ReadSignOutResult(string response)
{
if(response.StartsWith("Error:", StringComparison.Ordinal))
{ return null; }
try
{ return JsonSerializer.Deserialize<PipeSignOutResult>(response, Qqq.PrintAgent.Core.Printing.PrintApiClient.JsonOptions); }
catch(JsonException)
{ return null; }
}
/// <summary>
/// Opens the identity provider's end-session page in the default browser and waits up to two minutes for the browser to return
/// to this application's registered callback with this sign-out's own state. The inbox is opened first so a fast redirect is not lost.
/// The request carries a sensitive token hint, so only its host is logged. Returns whether the provider confirmed.
/// </summary>
private static async Task<bool> CompleteProviderSignOutAsync(QqqProviderLogout logout)
{
try
{
using QqqBrowserCallback inbox = new();
using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(2));
App.Log.Info("[Companion] Opening the identity provider's sign-out page in the default browser (" + logout.EndSessionUri.Authority + ").");
_ = Process.Start(new ProcessStartInfo(logout.EndSessionUri.AbsoluteUri) { UseShellExecute = true });
Uri callback = await inbox.ReceiveAsync(timeout.Token);
bool matched = logout.Matches(callback);
App.Log.Info("[Companion] Identity provider sign-out callback " + (matched ? "matched." : "did not match this sign-out."));
return matched;
}
catch(Exception exception) when(exception is OperationCanceledException or IOException or InvalidDataException or UnauthorizedAccessException or System.ComponentModel.Win32Exception)
{
App.Log.Warning("[Companion] Identity provider sign-out was not confirmed: " + QqqSessionDiagnostics.Describe(exception));
return false;
}
}
/// <summary>
/// Runs the browser sign-in with a three minute limit and a Cancel button. The redirect address must be the one this
/// application's installer registered, and only an address the server also lists is used, so a server cannot steer the
@@ -277,6 +334,7 @@ public sealed partial class MainWindow
?? throw Unlisted();
using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(3));
_browserSignInCancellation = timeout;
Feedback("Waiting for the browser", "Finish signing in on the page that opened in your browser. This dialog updates when the sign-in completes, is canceled or times out after three minutes.", InfoBarSeverity.Informational);
await CompleteBrowserSignInAsync(new Uri(redirect), timeout.Token);
await TransferSignInAsync();
}
+30 -2
View File
@@ -6,7 +6,9 @@
using System.Text.Json;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
@@ -85,11 +87,37 @@ public sealed partial class MainWindow
/// <summary>Clears the diagnostic logs only after the operator confirms; the prompt states that job history, printer settings and print jobs are kept.</summary>
private async void ClearLogs_Click(object sender, RoutedEventArgs e)
private async void ClearLogs_Click(object sender, RoutedEventArgs e) => await ClearLogsAsync(Content.XamlRoot);
/// <summary>Confirms in the window the operator is looking at, then asks the service (which owns the files) to clear the logs.</summary>
private async Task ClearLogsAsync(XamlRoot owner)
{
if(await ConfirmAsync("Clear local diagnostic logs?", "This permanently deletes retained service and companion diagnostic logs. Print jobs, printer settings and job history are preserved. New events will start a fresh log.", "Clear logs"))
if(await ConfirmAsync("Clear local diagnostic logs?", "This permanently deletes retained service and companion diagnostic logs. Print jobs, printer settings and job history are preserved. New events will start a fresh log.", "Clear logs", owner))
{
await RunActionAsync(new PipeRequest("clear-logs"));
}
}
/// <summary>
/// Opens (or activates) the shared AppKit log viewer and adds this application's "Clear logs" button to its toolbar. The viewer
/// is sealed and has no command hook, so the button is placed by walking its toolbar once per window; if the viewer's layout
/// ever changes the viewer still opens and the Settings page keeps its own Clear local logs button.
/// </summary>
private void ShowActivityLog()
{
var viewer = LogWindow.ShowSingle(App.Log, AppIdentity.Info);
if(viewer.Content is not Grid { Children: [StackPanel toolbar, ..] } || toolbar.Children.OfType<Button>().Any(button => string.Equals(AutomationProperties.GetAutomationId(button), "ClearLogsFromViewer", StringComparison.Ordinal)))
{
return;
}
var clear = new Button { Content = "Clear logs" };
AutomationProperties.SetAutomationId(clear, "ClearLogsFromViewer");
clear.Click += async (_, _) => await ClearLogsAsync(viewer.Content.XamlRoot);
int position = toolbar.Children.TakeWhile(child => child is ButtonBase).Count();
toolbar.Children.Insert(position, clear);
}
}
+2
View File
@@ -64,6 +64,7 @@ public sealed partial class MainWindow
{
UpdateSortHeader(PrinterNameHeader, "Name", _printerSortBy, _printerSortDescending);
UpdateSortHeader(PrinterStatusHeader, "Status", _printerSortBy, _printerSortDescending);
UpdateSortHeader(PrinterServerHeader, "Server", _printerSortBy, _printerSortDescending);
UpdateSortHeader(PrinterLocationHeader, "Location", _printerSortBy, _printerSortDescending);
UpdateSortHeader(PrinterFormatsHeader, "Formats", _printerSortBy, _printerSortDescending);
UpdateSortHeader(PrinterSharedHeader, "Shared", _printerSortBy, _printerSortDescending);
@@ -152,6 +153,7 @@ public sealed partial class MainWindow
int comparison = _printerSortBy switch
{
"status" => text.Compare(PrinterRowView.StatusLabel(first), PrinterRowView.StatusLabel(second)),
"server" => text.Compare(PrinterRowView.ServerLabel(first), PrinterRowView.ServerLabel(second)),
"location" => text.Compare(first.Location, second.Location),
"formats" => text.Compare(string.Join(", ", first.Formats), string.Join(", ", second.Formats)),
"shared" => first.Exposed.CompareTo(second.Exposed),
+2 -2
View File
@@ -46,7 +46,7 @@ public sealed partial class MainWindow
ThemeChoice.SelectedIndex = _settings.ThemeKind switch { AppTheme.Light => 1, AppTheme.Dark => 2, _ => 0 };
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(ThemeChoice, "AppTheme");
UpdateStartupToggle.IsOn = _settings.CheckForUpdatesOnStartup;
_updateService = new MsiUpdateService(() => DispatcherQueue.TryEnqueue(ExitCompanion), App.Log);
_updateService = new MsiUpdateService(() => DispatcherQueue.TryEnqueue(ExitCompanion), App.Log, AppIdentity.UpdateAssetPrefix);
_updateService.StatusChanged += UpdateService_StatusChanged;
var prompter = new LifetimeUserPrompter(new ContentDialogPrompter(UpdateDialogRoot), _updateLifetime.Token);
_updates = new UpdateCoordinator(_updateService, prompter, AppIdentity.Info.DisplayName, App.Log);
@@ -236,7 +236,7 @@ public sealed partial class MainWindow
private void LogsAccelerator_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args)
{
Navigation.SelectedItem = LogsNavigation;
LogWindow.ShowSingle(App.Log, AppIdentity.Info);
ShowActivityLog();
args.Handled = true;
}
}
+61 -3
View File
@@ -56,10 +56,10 @@
<Grid x:Name="PrintersPage" RowSpacing="10">
<Grid.RowDefinitions><RowDefinition Height="Auto" /><RowDefinition Height="Auto" /><RowDefinition Height="*" /><RowDefinition Height="Auto" /></Grid.RowDefinitions>
<Grid><Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions><TextBlock x:Name="PrintersSummary" Text="Waiting for the local print service" TextWrapping="Wrap" VerticalAlignment="Center" /><Button Grid.Column="1" Content="Windows printer settings" Click="PrinterSettings_Click" AutomationProperties.AutomationId="WindowsPrinterSettings" /></Grid>
<Grid Grid.Row="1" ColumnSpacing="12" Margin="8,0,24,0" AutomationProperties.AutomationId="PrinterTableHeader"><Grid.ColumnDefinitions><ColumnDefinition Width="2*" /><ColumnDefinition Width="1.2*" /><ColumnDefinition Width="*" /><ColumnDefinition Width="64" /><ColumnDefinition Width="76" /></Grid.ColumnDefinitions><Button x:Name="PrinterNameHeader" Grid.Column="0" Padding="36,6,4,6" Style="{StaticResource TableHeaderButton}" Content="Name ↑" Tag="name" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterName" AutomationProperties.Name="Sort by name, ascending" /><Button x:Name="PrinterStatusHeader" Grid.Column="1" Style="{StaticResource TableHeaderButton}" Content="Status" Tag="status" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterStatus" AutomationProperties.Name="Sort by status" /><Button x:Name="PrinterLocationHeader" Grid.Column="2" Style="{StaticResource TableHeaderButton}" Content="Location" Tag="location" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterLocation" AutomationProperties.Name="Sort by location" /><Button x:Name="PrinterFormatsHeader" Grid.Column="3" Style="{StaticResource TableHeaderButton}" Content="Formats" Tag="formats" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterFormats" AutomationProperties.Name="Sort by formats" /><Button x:Name="PrinterSharedHeader" Grid.Column="4" Style="{StaticResource TableHeaderButton}" Content="Shared" Tag="shared" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterShared" AutomationProperties.Name="Sort by shared" /></Grid>
<Grid Grid.Row="1" ColumnSpacing="12" Margin="8,0,24,0" AutomationProperties.AutomationId="PrinterTableHeader"><Grid.ColumnDefinitions><ColumnDefinition Width="2*" /><ColumnDefinition Width="*" /><ColumnDefinition Width="1.2*" /><ColumnDefinition Width="*" /><ColumnDefinition Width="64" /><ColumnDefinition Width="76" /></Grid.ColumnDefinitions><Button x:Name="PrinterNameHeader" Grid.Column="0" Padding="36,6,4,6" Style="{StaticResource TableHeaderButton}" Content="Name ↑" Tag="name" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterName" AutomationProperties.Name="Sort by name, ascending" /><Button x:Name="PrinterStatusHeader" Grid.Column="1" Style="{StaticResource TableHeaderButton}" Content="Status" Tag="status" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterStatus" AutomationProperties.Name="Sort by status" /><Button x:Name="PrinterServerHeader" Grid.Column="2" Style="{StaticResource TableHeaderButton}" Content="Server" Tag="server" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterServer" AutomationProperties.Name="Sort by server approval" /><Button x:Name="PrinterLocationHeader" Grid.Column="3" Style="{StaticResource TableHeaderButton}" Content="Location" Tag="location" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterLocation" AutomationProperties.Name="Sort by location" /><Button x:Name="PrinterFormatsHeader" Grid.Column="4" Style="{StaticResource TableHeaderButton}" Content="Formats" Tag="formats" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterFormats" AutomationProperties.Name="Sort by formats" /><Button x:Name="PrinterSharedHeader" Grid.Column="5" Style="{StaticResource TableHeaderButton}" Content="Shared" Tag="shared" Click="PrinterSort_Click" AutomationProperties.AutomationId="SortPrinterShared" AutomationProperties.Name="Sort by shared" /></Grid>
<ListView x:Name="PrintersList" Grid.Row="2" ItemContainerStyle="{StaticResource FullWidthListItem}" SelectionMode="Single" SelectionChanged="PrinterSelection_Changed" DoubleTapped="Printers_DoubleTapped" HorizontalContentAlignment="Stretch" AutomationProperties.AutomationId="PrinterInventory" />
<StackPanel x:Name="PrintersEmpty" Grid.Row="2" Spacing="12" HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="350"><TextBlock Text="Your printers will appear here" Style="{ThemeResource SubtitleTextBlockStyle}" TextAlignment="Center" TextWrapping="Wrap" /><TextBlock x:Name="PrintersEmptyText" Text="Install and start the local print service to discover printers." TextAlignment="Center" TextWrapping="Wrap" /><Button Content="Connection settings" Click="Connection_Click" HorizontalAlignment="Center" /></StackPanel>
<TextBlock Grid.Row="3" Text="Select a printer to expand its information. Double-click to open Settings. Shared printers also need server approval." TextWrapping="Wrap" FontSize="12" />
<TextBlock x:Name="PrintersHint" Grid.Row="3" Text="Select a printer to expand its information. Double-click to open Settings. A shared printer prints only after the server approves it; the Server column shows that approval." TextWrapping="Wrap" FontSize="12" />
</Grid>
<Grid x:Name="JobsPage" Visibility="Collapsed" RowSpacing="10">
<Grid.RowDefinitions><RowDefinition Height="Auto" /><RowDefinition Height="Auto" /><RowDefinition Height="*" /><RowDefinition Height="Auto" /><RowDefinition Height="Auto" /></Grid.RowDefinitions>
@@ -126,7 +126,65 @@
</Grid>
</Border>
<ContentDialog x:Name="ConnectionDialog" Title="Connection settings" CloseButtonText="Close" Closing="ConnectionDialog_Closing">
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="650"><StackPanel Spacing="12"><TextBlock Text="Sign in using this server's configured authentication. Printing uses your current permissions. Existing pairing and history are preserved." TextWrapping="Wrap" /><TextBox x:Name="ServerBox" Header="Server address" PlaceholderText="https://print.example.com" AutomationProperties.AutomationId="ServerAddress" /><CheckBox x:Name="AllowUntrustedCertificateBox" Content="Allow untrusted server certificate for this server (development only)" Click="AllowUntrustedCertificate_Click" AutomationProperties.AutomationId="AllowUntrustedCertificate" /><TextBlock Text="Enter the server address first; the exception is saved for that one server only and never applies to another. Use it for a development server with a self-signed or private-authority certificate. The service runs as LocalSystem and trusts only the machine certificate store, so a certificate your user account trusts can still be refused by the service. With this on, any certificate from that server is accepted and every session is logged with a warning." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /><ComboBox x:Name="PairingChoice" Header="Device pairing for this sign-in" SelectedIndex="0" HorizontalAlignment="Stretch" AutomationProperties.AutomationId="PairingChoice"><ComboBoxItem Content="Reuse the saved pairing" /><ComboBoxItem Content="Associate the existing pairing with my account" /><ComboBoxItem Content="Set up a new pairing" /></ComboBox><TextBlock Text="For revoked or replaced devices, choose Set up a new pairing before signing in. Printing stays paused until a new enrollment succeeds; existing history and printer choices are preserved." TextWrapping="Wrap" /><Button Content="Discover sign-in options" Click="DiscoverSignIn_Click" AutomationProperties.AutomationId="DiscoverQqqSignIn" /><StackPanel x:Name="PasswordSignInPanel" Spacing="8" Visibility="Collapsed"><TextBox x:Name="UsernameBox" Header="Username" AutomationProperties.AutomationId="QqqUsername" /><PasswordBox x:Name="QqqPasswordBox" Header="Password" AutomationProperties.AutomationId="QqqPassword" /><Button Content="Sign in" Click="PasswordSignIn_Click" AutomationProperties.AutomationId="QqqPasswordSignIn" /></StackPanel><Button x:Name="BrowserSignInButton" Content="Sign in with your browser" Visibility="Collapsed" Click="BrowserSignIn_Click" AutomationProperties.AutomationId="QqqBrowserSignIn" /><Button x:Name="CancelBrowserSignInButton" Content="Cancel browser sign-in" Visibility="Collapsed" Click="CancelBrowserSignIn_Click" AutomationProperties.AutomationId="CancelQqqSignIn" /><TextBlock Text="For a new connection, after signing in, create a single-use enrollment code on the server: Printing → Print devices → Actions → Enroll a print device." TextWrapping="Wrap" /><PasswordBox x:Name="EnrollmentBox" Header="Enrollment code (new pairing only)" PlaceholderText="Paste the code from the server" AutomationProperties.AutomationId="EnrollmentCode" /><Button x:Name="EnrollButton" Content="Connect this device" Click="Enroll_Click" Style="{ThemeResource AccentButtonStyle}" AutomationProperties.AutomationId="EnrollDevice" /><InfoBar x:Name="ConnectionFeedback" IsOpen="False" IsClosable="False" /></StackPanel></ScrollViewer>
<ContentDialog.Resources>
<!-- The default content-dialog width (about 548 px) clips this form; give it room and let the page scroll instead of the dialog. -->
<x:Double x:Key="ContentDialogMaxWidth">760</x:Double>
<x:Double x:Key="ContentDialogMaxHeight">900</x:Double>
</ContentDialog.Resources>
<Grid RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- The result of the last step stays in view above the form instead of at the end of the scrolling content. -->
<InfoBar x:Name="ConnectionFeedback" Grid.Row="0" IsOpen="False" IsClosable="False" AutomationProperties.AutomationId="ConnectionFeedback" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollMode="Disabled" HorizontalScrollBarVisibility="Disabled" MaxHeight="640" Padding="0,0,16,0">
<StackPanel Spacing="16">
<TextBlock Text="Sign in with the server's own authentication. Printing uses your permissions; pairing and history are kept." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<StackPanel Spacing="8">
<TextBlock Text="1. Server" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBox x:Name="ServerBox" Header="Server address" PlaceholderText="https://print.example.com" AutomationProperties.AutomationId="ServerAddress" />
<CheckBox x:Name="AllowUntrustedCertificateBox" Click="AllowUntrustedCertificate_Click" AutomationProperties.AutomationId="AllowUntrustedCertificate" MinWidth="0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
<TextBlock x:Name="AllowUntrustedCertificateLabel" Text="Allow untrusted server certificate for this server (development only)" TextWrapping="Wrap" />
</CheckBox>
<TextBlock TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Style="{ThemeResource CaptionTextBlockStyle}"
Text="Development servers only: accepts any certificate from this one server, logged with a warning. Hover for details."
ToolTipService.ToolTip="The exception is saved for the server address above and never applies to another server. Use it for a development server with a self-signed or private-authority certificate. The Windows service runs as LocalSystem and trusts only the machine certificate store, so a certificate your user account trusts can still be refused by the service; installing the issuer in the Local Machine Trusted Root store is the alternative. With this on, any certificate from that server is accepted and every session is logged with a warning." />
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="2. Sign in" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<ComboBox x:Name="PairingChoice" Header="Device pairing for this sign-in" SelectedIndex="0" HorizontalAlignment="Stretch" AutomationProperties.AutomationId="PairingChoice">
<ComboBoxItem Content="Reuse the saved pairing" />
<ComboBoxItem Content="Associate the existing pairing with my account" />
<ComboBoxItem Content="Set up a new pairing" />
</ComboBox>
<TextBlock TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Style="{ThemeResource CaptionTextBlockStyle}"
Text="Choose Set up a new pairing for a revoked or replaced device; printing pauses until the new enrollment succeeds and history and printer choices are kept." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Discover sign-in options" Click="DiscoverSignIn_Click" AutomationProperties.AutomationId="DiscoverQqqSignIn" />
<Button x:Name="BrowserSignInButton" Content="Sign in with your browser" Visibility="Collapsed" Click="BrowserSignIn_Click" Style="{ThemeResource AccentButtonStyle}" AutomationProperties.AutomationId="QqqBrowserSignIn" />
<Button x:Name="CancelBrowserSignInButton" Content="Cancel browser sign-in" Visibility="Collapsed" Click="CancelBrowserSignIn_Click" AutomationProperties.AutomationId="CancelQqqSignIn" />
</StackPanel>
<StackPanel x:Name="PasswordSignInPanel" Spacing="8" Visibility="Collapsed">
<TextBox x:Name="UsernameBox" Header="Username" AutomationProperties.AutomationId="QqqUsername" />
<PasswordBox x:Name="QqqPasswordBox" Header="Password" AutomationProperties.AutomationId="QqqPassword" />
<Button Content="Sign in" Click="PasswordSignIn_Click" Style="{ThemeResource AccentButtonStyle}" AutomationProperties.AutomationId="QqqPasswordSignIn" />
</StackPanel>
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="3. New pairing only" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Style="{ThemeResource CaptionTextBlockStyle}"
Text="After signing in, create a single-use enrollment code on the server (Printing → Print devices → Actions → Enroll a print device) and paste it here." />
<PasswordBox x:Name="EnrollmentBox" Header="Enrollment code" PlaceholderText="Paste the code from the server" AutomationProperties.AutomationId="EnrollmentCode" />
<Button x:Name="EnrollButton" Content="Connect this device" Click="Enroll_Click" AutomationProperties.AutomationId="EnrollDevice" HorizontalAlignment="Left" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</ContentDialog>
</Grid>
</Window>
+33 -17
View File
@@ -104,7 +104,7 @@ public sealed partial class MainWindow : Window, IDisposable
CompanyHeading.Visibility = AppIdentity.Branding.CompanyName is null ? Visibility.Collapsed : Visibility.Visible;
SupportLink.NavigateUri = AppIdentity.Branding.SupportUrl ?? new Uri("https://github.com/QRun-IO/qqq-print-agent/issues");
SupportLink.Visibility = Visibility.Visible;
if(AppIdentity.Branding.LogoPath is string logo)
if((AppIdentity.Branding.LogoPath ?? AppIdentity.EditionLogoPath) is string logo)
{
CustomerLogo.ImageFailed += (_, _) => CustomerLogo.Visibility = Visibility.Collapsed;
CustomerLogo.Source = new BitmapImage(new Uri(logo));
@@ -206,7 +206,7 @@ public sealed partial class MainWindow : Window, IDisposable
};
if(string.Equals(page, "Logs", StringComparison.Ordinal))
{
LogWindow.ShowSingle(App.Log, AppIdentity.Info);
ShowActivityLog();
}
if(args.IsSettingsSelected && !_initializing)
{
@@ -494,10 +494,10 @@ public sealed partial class MainWindow : Window, IDisposable
: "This device has not been enrolled. Open Connection settings to connect it to the server.";
AuthenticationText.Text = snapshot.Authentication is { } authentication
? authentication.SignedIn ? $"Signed in as {authentication.PrincipalId} · Tenant {authentication.TenantId}\n{authentication.Message}" : authentication.Message
: "Sign in to QQQ to enable printing.";
: $"Sign in to {AppIdentity.ServerName} to enable printing.";
if(snapshot.Authentication is { Authorized: false })
{
FooterStatus.Text = "QQQ authorization required";
FooterStatus.Text = AppIdentity.ServerName + " authorization required";
}
ToolTipService.SetToolTip(FooterStatus, FooterStatus.Text);
ToolTipService.SetToolTip(FooterContact, FooterContact.Text);
@@ -538,17 +538,41 @@ public sealed partial class MainWindow : Window, IDisposable
_printerRows.Add(printer.PrinterId, row);
PrintersList.Items.Add(row);
}
row.Update(printer, FriendlyState(printer.Status), _busy);
row.Update(printer, FriendlyState(printer.Status), PrinterRowView.ServerDetail(printer, snapshot.PrinterSync, AppIdentity.ServerName), _busy);
}
SortPrinterRows();
PrintersEmpty.Visibility = snapshot.Printers.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
PrintersEmptyText.Text = snapshot.Enrolled ? "Add a printer in Windows Settings, then refresh this list." : "Connect this device to the server, then choose the printers to share.";
PrintersSummary.Text = $"{snapshot.Printers.Count} printers · {snapshot.Printers.Count(printer => printer.Exposed)} shared with the server";
PrintersSummary.Text = PrintersSummaryText(snapshot);
ToolTipService.SetToolTip(PrintersSummary, snapshot.PrinterSync?.Detail);
UpdateSelectedPrinter();
}
/// <summary>
/// One line over the table: how many printers are shared and, for the connected server, how many of those it approved or still
/// has to approve; when the server could not report approval, says so instead of counting.
/// </summary>
internal static string PrintersSummaryText(AgentSnapshot snapshot)
{
int shared = snapshot.Printers.Count(printer => printer.Exposed);
string text = $"{snapshot.Printers.Count} printers · {shared} shared";
if(shared == 0)
{ return text; }
string server = AppIdentity.ServerName;
return snapshot.PrinterSync?.Outcome switch
{
PrinterServerSync.Reported => text + $" · {snapshot.Printers.Count(printer => string.Equals(printer.ServerState, PrinterServerState.Approved, StringComparison.Ordinal))} approved by {server}"
+ (snapshot.Printers.Any(printer => string.Equals(printer.ServerState, PrinterServerState.AwaitingApproval, StringComparison.Ordinal))
? $" · {snapshot.Printers.Count(printer => string.Equals(printer.ServerState, PrinterServerState.AwaitingApproval, StringComparison.Ordinal))} awaiting approval" : ""),
PrinterServerSync.NotReported => text + $" · approval not reported by {server}",
_ => text + $" · approval not checked with {server} yet",
};
}
/// <summary>Persist local printer exposure through the protected service pipe.</summary>
private async Task SetExposureAsync(PrinterSnapshot printer, bool exposed)
{
@@ -690,7 +714,7 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Show the shared AppKit activity log.</summary>
private void ActivityLog_Click(object sender, RoutedEventArgs e) => LogWindow.ShowSingle(App.Log, AppIdentity.Info);
private void ActivityLog_Click(object sender, RoutedEventArgs e) => ShowActivityLog();
@@ -700,15 +724,7 @@ public sealed partial class MainWindow : Window, IDisposable
var dialog = new AboutDialog(AppIdentity.Info) { XamlRoot = Content.XamlRoot };
if(dialog.Content is ScrollViewer { Content: StackPanel body })
{
if(AppIdentity.Branding.SupportUrl is Uri support)
{
body.Children.Add(new HyperlinkButton { Content = "Contact support", NavigateUri = support });
}
if(CustomerLogo.Visibility == Visibility.Visible)
{
body.Children.Insert(0, new Image { Source = CustomerLogo.Source, Width = 128, Height = 64, Stretch = Stretch.Uniform, HorizontalAlignment = HorizontalAlignment.Left });
}
QueryAboutSection.InsertAfterHeader(body);
AboutContent.Compose(body, AppIdentity.Branding.SupportUrl, CustomerLogo.Visibility == Visibility.Visible ? CustomerLogo.Source as BitmapImage : null);
}
await DialogGuard.ShowAsync(dialog);
}
@@ -719,7 +735,7 @@ public sealed partial class MainWindow : Window, IDisposable
private void ServiceLogs_Click(object sender, RoutedEventArgs e)
{
Navigation.SelectedItem = LogsNavigation;
LogWindow.ShowSingle(App.Log, AppIdentity.Info);
ShowActivityLog();
}
+4 -1
View File
@@ -15,6 +15,7 @@ internal sealed class PrinterDetailsView : StackPanel
private readonly TextBlock _name = Value();
private readonly TextBlock _queue = Value();
private readonly TextBlock _status = Value();
private readonly TextBlock _server = Value();
private readonly TextBlock _location = Value();
private readonly TextBlock _description = Value();
private readonly TextBlock _technical = Value();
@@ -28,6 +29,7 @@ internal sealed class PrinterDetailsView : StackPanel
AddField("Name", _name);
AddField("Windows queue", _queue);
AddField("Status", _status);
AddField("Server", _server);
AddField("Location", _location);
_description.MaxLines = 3;
_description.TextTrimming = TextTrimming.WordEllipsis;
@@ -39,11 +41,12 @@ internal sealed class PrinterDetailsView : StackPanel
/// <summary>Fills the fields from a printer snapshot, showing "Not set" for an empty location or description so a blank is never mistaken for a missing control.</summary>
internal void Update(PrinterSnapshot printer, string name, string status)
internal void Update(PrinterSnapshot printer, string name, string status, string server)
{
_name.Text = name;
_queue.Text = printer.Name;
_status.Text = status;
_server.Text = server;
_location.Text = string.IsNullOrWhiteSpace(printer.Location) ? "Not set" : printer.Location;
_description.Text = string.IsNullOrWhiteSpace(printer.Description) ? "Not set" : printer.Description;
_technical.Text = $"Driver: {printer.DriverName}\nPort: {printer.PortName}\nPrinter ID: {printer.PrinterId}";
+45 -8
View File
@@ -16,6 +16,7 @@ internal sealed class PrinterRowView : Grid
{
private readonly TextBlock _name = Cell();
private readonly TextBlock _status = Cell();
private readonly TextBlock _server = Cell();
private readonly TextBlock _location = Cell();
private readonly TextBlock _formats = Cell();
private readonly StackPanel _details = new() { Spacing = 10, Margin = new Thickness(0, 12, 0, 16), Visibility = Visibility.Collapsed, Tag = "PrinterInlineDetails" };
@@ -31,7 +32,7 @@ internal sealed class PrinterRowView : Grid
/// <summary>
/// Builds a printer row once and reuses it across refreshes, so focus and selection survive. It lays out the name,
/// status, location, formats and the share checkbox in five columns; the callbacks come from the window, and each reads
/// status, server approval, location, formats and the share checkbox in six columns; the callbacks come from the window, and each reads
/// the row's current snapshot when invoked, so the row never acts on a printer it no longer shows.
/// </summary>
internal PrinterRowView(Func<PrinterSnapshot, bool, Task> exposure, Func<PrinterSnapshot, Task> settings, Func<PrinterSnapshot, Task> test, Action<PrinterRowView> toggle)
@@ -41,6 +42,7 @@ internal sealed class PrinterRowView : Grid
RowDefinitions.Add(new RowDefinition { Height = new GridLength(44) });
RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(2, GridUnitType.Star) });
ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.2, GridUnitType.Star) });
ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(64) });
@@ -48,7 +50,8 @@ internal sealed class PrinterRowView : Grid
_disclosure.Content = _chevron;
_disclosure.Command = new RelayCommand(() => toggle(this));
AutomationProperties.SetAutomationId(_disclosure, "TogglePrinterDetails");
FrameworkElement[] cells = [NameCell(), _status, _location, _formats, Exposure];
FrameworkElement[] cells = [NameCell(), _status, _server, _location, _formats, Exposure];
AutomationProperties.SetAutomationId(_server, "PrinterServerState");
for(int index = 0; index < cells.Length; index++)
{
SetColumn(cells[index], index);
@@ -87,29 +90,31 @@ internal sealed class PrinterRowView : Grid
_details.Children.Add(actions);
_details.Children.Add(_detailsView);
SetRow(_details, 1);
SetColumnSpan(_details, 5);
SetColumnSpan(_details, 6);
Children.Add(_details);
}
/// <summary>
/// Applies a refreshed snapshot in place: the friendly name falls back to the Windows queue name, the status and
/// formats are shown as text (never color alone), the share checkbox follows the service, and tooltips carry the full
/// values and the unattended-printing explanation. The expanded or collapsed state is preserved.
/// Applies a refreshed snapshot in place: the friendly name falls back to the Windows queue name, the status, server
/// approval and formats are shown as text (never color alone), the share checkbox follows the service, and tooltips carry
/// the full values and the unattended-printing explanation. The expanded or collapsed state is preserved.
/// </summary>
internal void Update(PrinterSnapshot printer, string status, bool busy)
internal void Update(PrinterSnapshot printer, string status, string serverDetail, bool busy)
{
Snapshot = printer;
SetCell(_name, string.IsNullOrWhiteSpace(printer.DisplayName) ? printer.Name : printer.DisplayName);
SetCell(_status, StatusLabel(printer));
SetCell(_server, ServerLabel(printer));
SetCell(_location, string.IsNullOrWhiteSpace(printer.Location) ? "—" : printer.Location);
SetCell(_formats, printer.Formats.Length == 0 ? "—" : string.Join(", ", printer.Formats));
Exposure.IsChecked = printer.Exposed;
_detailsView.Update(printer, _name.Text, _status.Text);
_detailsView.Update(printer, _name.Text, _status.Text, serverDetail);
SetBusy(busy);
ToolTipService.SetToolTip(_name, $"{_name.Text}\nWindows queue: {printer.Name}");
ToolTipService.SetToolTip(_status, printer.Formats.Length == 0 ? "Unavailable for unattended service printing. Select this printer for details." : status);
ToolTipService.SetToolTip(_server, serverDetail);
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(Exposure, $"Share {_name.Text} with the server");
SetSelected(_details.Visibility == Visibility.Visible);
}
@@ -151,6 +156,38 @@ internal sealed class PrinterRowView : Grid
/// <summary>The server-approval word shown for a printer, from the service's last catalog reading for the connected server.</summary>
internal static string ServerLabel(PrinterSnapshot printer) => printer.ServerState switch
{
PrinterServerState.Approved => "Approved",
PrinterServerState.AwaitingApproval => "Awaiting approval",
PrinterServerState.NotReported => "Not reported",
PrinterServerState.NotShared => "Not shared",
_ => "Not checked",
};
/// <summary>
/// The sentence behind the server-approval word: what it means and, for a reading that failed, the service's reason.
/// The server is named so a printer shared before a server change is never read as approved by the new one.
/// </summary>
internal static string ServerDetail(PrinterSnapshot printer, PrinterServerSync? sync, string serverName)
{
string server = string.IsNullOrEmpty(sync?.Server) ? serverName : $"{serverName} ({sync!.Server})";
string freshness = string.Equals(sync?.Source, PrinterServerSync.RealtimeSource, StringComparison.Ordinal) ? " The server pushes a change the moment an administrator makes it." : " Checked about once a minute while connected.";
return printer.ServerState switch
{
PrinterServerState.Approved => $"{server} approved this printer for this computer; it can receive jobs.{freshness}",
PrinterServerState.AwaitingApproval => $"{server} received this printer in the inventory but has not approved it. An administrator approves it on the server (Printing → Print devices).{freshness}",
PrinterServerState.NotReported => $"{server} did not report approval: {sync?.Detail ?? "the catalog could not be read."} Approve or check the printer on the server.",
PrinterServerState.NotShared => "Not shared with the server. Turn on Shared to offer it; the server then has to approve it.",
_ => $"Not checked yet with {server}. The service asks the server after it connects and sends the inventory.",
};
}
/// <summary>Creates one single-line, centered, ellipsized text cell.</summary>
private static TextBlock Cell() => new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 };
+15
View File
@@ -33,6 +33,21 @@
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<!-- Client edition (editions/<id>, selected by the build script with -p:BrandEdition): its mark and icon replace the
generic ones at the same paths, its optional logo and the descriptor ship beside the executable, and the executable
icon is the client's. See editions/README.md. -->
<PropertyGroup Condition="'$(BrandEdition)' != ''">
<EditionRoot>$(MSBuildThisFileDirectory)..\..\editions\$(BrandEdition)\</EditionRoot>
<ApplicationIcon>$(EditionRoot)app.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup Condition="'$(BrandEdition)' != ''">
<Content Remove="Assets\Brand\app-icon.svg;Assets\Brand\app.ico" />
<Content Include="$(EditionRoot)app-icon.svg" Link="Assets\Brand\app-icon.svg" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="$(EditionRoot)app.ico" Link="Assets\Brand\app.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="$(EditionRoot)logo.png" Link="Assets\Brand\logo.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('$(EditionRoot)logo.png')" />
<Content Include="$(EditionRoot)edition.json" Link="edition.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="KofTwentyTwo.AppKit.WinUI" />
<PackageReference Include="H.NotifyIcon.WinUI" />
+1 -1
View File
@@ -35,7 +35,7 @@ internal sealed class TestPrintEditor : StackPanel
{
_driverName = printer.DriverName;
Spacing = 16;
Children.Add(new TextBlock { Text = $"Print one high-contrast QQQ diagnostic page or label on {printerLabel}. This uses real stock and requires local sharing and server approval.", TextWrapping = TextWrapping.Wrap });
Children.Add(new TextBlock { Text = $"Print one high-contrast {AppIdentity.ServerName} diagnostic page or label on {printerLabel}. This uses real stock and requires local sharing and server approval.", TextWrapping = TextWrapping.Wrap });
foreach(string format in printer.Formats)
{
_format.Items.Add(new ComboBoxItem { Content = string.Equals(format, "ZPL", StringComparison.Ordinal) ? "Printer-ready label (ZPL)" : "Document (PDF)", Tag = format });
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The server, not the local sharing choice, decides whether a shared printer may print. These tests prove the runtime asks the
/// connected server's catalog after the inventory is accepted, shows each shared printer as approved or awaiting approval for
/// that server, says "not reported" (with the reason, logged once) when the catalog cannot be read, and starts over on reconnect.
/// </summary>
public sealed class AgentPrinterCatalogTests
{
/// <summary>After the inventory is accepted the catalog is read; the printer the server lists is approved, the other shared one is awaiting approval, and the summary names the server.</summary>
[Fact]
public async Task SharedPrinters_ShowApprovalFromTheConnectedServersCatalog()
{
await using RealtimeRuntimeFixture fixture = new();
fixture.ApprovedPrinters = ["printer"];
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
AgentSnapshot snapshot = fixture.Runtime.GetSnapshot();
Assert.Equal(PrinterServerState.Approved, snapshot.Printers.Single(printer => string.Equals(printer.PrinterId, "printer", StringComparison.Ordinal)).ServerState);
Assert.Equal(PrinterServerState.AwaitingApproval, snapshot.Printers.Single(printer => string.Equals(printer.PrinterId, "other", StringComparison.Ordinal)).ServerState);
Assert.Equal(fixture.Peer.Origin.Authority, snapshot.PrinterSync!.Server);
Assert.Contains(fixture.Logs, line => line.Contains("1 of 2 shared printer(s) approved", StringComparison.Ordinal) && line.Contains("awaiting approval on the server [Other synthetic]", StringComparison.Ordinal));
Assert.Equal(0, fixture.ForbiddenPolls);
}
/// <summary>A printer that is not shared is reported as such regardless of the catalog, and never counted as awaiting approval.</summary>
[Fact]
public async Task UnsharedPrinter_IsNotShared_NotAwaitingApproval()
{
await using RealtimeRuntimeFixture fixture = new();
using(AgentStore store = new(fixture.DirectoryPath))
{ store.SetExposure("other", false, fixture.Printer.Discover()); }
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
AgentSnapshot snapshot = fixture.Runtime.GetSnapshot();
Assert.Equal(PrinterServerState.NotShared, snapshot.Printers.Single(printer => string.Equals(printer.PrinterId, "other", StringComparison.Ordinal)).ServerState);
Assert.Equal(PrinterServerState.Approved, snapshot.Printers.Single(printer => string.Equals(printer.PrinterId, "printer", StringComparison.Ordinal)).ServerState);
}
/// <summary>When the catalog cannot be read the shared printers are "not reported" with the server's reason, logged at WARN once, and the connection keeps working.</summary>
[Theory]
[InlineData(HttpStatusCode.NotFound, "does not offer the operator printer catalog")]
[InlineData(HttpStatusCode.Forbidden, "may not read the operator printer catalog (HTTP 403 Forbidden)")]
public async Task UnreadableCatalog_ReportsNotReportedWithTheReason(HttpStatusCode status, string reason)
{
await using RealtimeRuntimeFixture fixture = new();
fixture.CatalogStatus = status;
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.NotReported, StringComparison.Ordinal));
AgentSnapshot snapshot = fixture.Runtime.GetSnapshot();
Assert.All(snapshot.Printers, printer => Assert.Equal(PrinterServerState.NotReported, printer.ServerState));
Assert.Contains(reason, snapshot.PrinterSync!.Detail, StringComparison.Ordinal);
Assert.Single(fixture.Logs, line => line.Contains("Printer approval is not reported by", StringComparison.Ordinal));
Assert.True(snapshot.Connected);
}
/// <summary>A reading is kept for a minute: the heartbeat-driven synchronization passes do not read the catalog again straight away.</summary>
[Fact]
public async Task Catalog_IsNotReadOnEverySynchronizationPass()
{
await using RealtimeRuntimeFixture fixture = new();
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
int reads = fixture.CatalogRequests;
await Task.Delay(1500);
Assert.Equal(reads, fixture.CatalogRequests);
}
/// <summary>
/// On a printing 2.4 server the client advertises printer-approval-v1 and takes approval from the server's PRINTERS frame: the
/// catalog is never read, a disabled or unlisted shared printer is awaiting approval, and a later frame (an administrator approving
/// the printer) updates the state at once.
/// </summary>
[Fact]
public async Task PrintersFrame_IsAuthoritativeOnA24Server()
{
await using RealtimeRuntimeFixture fixture = new(printingVersion: "2.4.0");
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
Assert.Contains("printer-approval-v1", fixture.LastHelloCapabilities, StringComparer.Ordinal);
await fixture.WaitAsync(() => fixture.Messages.Any(message => message.Type is "INVENTORY"));
await connection.SendAsync("PRINTERS", new { printers = new object[] { new { printerId = "printer", enabled = true } } });
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
AgentSnapshot snapshot = fixture.Runtime.GetSnapshot();
Assert.Equal(PrinterServerSync.RealtimeSource, snapshot.PrinterSync!.Source);
Assert.Equal(PrinterServerState.Approved, snapshot.Printers.Single(printer => string.Equals(printer.PrinterId, "printer", StringComparison.Ordinal)).ServerState);
Assert.Equal(PrinterServerState.AwaitingApproval, snapshot.Printers.Single(printer => string.Equals(printer.PrinterId, "other", StringComparison.Ordinal)).ServerState);
await connection.SendAsync("PRINTERS", new { printers = new object[] { new { printerId = "printer", enabled = true }, new { printerId = "other", enabled = true } } });
await fixture.WaitAsync(() => fixture.Runtime.GetSnapshot().Printers.All(printer => string.Equals(printer.ServerState, PrinterServerState.Approved, StringComparison.Ordinal)));
await connection.SendAsync("PRINTERS", new { printers = new object[] { new { printerId = "printer", enabled = false }, new { printerId = "other", enabled = true } } });
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().Printers.Single(printer => string.Equals(printer.PrinterId, "printer", StringComparison.Ordinal)).ServerState, PrinterServerState.AwaitingApproval, StringComparison.Ordinal));
Assert.Equal(0, fixture.CatalogRequests);
Assert.Contains(fixture.Logs, line => line.Contains("Printer approval pushed by", StringComparison.Ordinal) && line.Contains("1 of 1 shared printer(s) approved", StringComparison.Ordinal));
Assert.Equal(0, fixture.ForbiddenPolls);
}
/// <summary>A PRINTERS frame without a printers list is a protocol violation that ends the connection, like any other malformed frame; the catalog is still not consulted on a 2.4 server.</summary>
[Fact]
public async Task MalformedPrintersFrame_EndsTheConnection()
{
await using RealtimeRuntimeFixture fixture = new(printingVersion: "2.4.0");
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
await first.SendAsync("PRINTERS", new { nothing = true });
await fixture.WaitAsync(() => !fixture.Runtime.GetSnapshot().Connected);
await using RealtimeRuntimeFixture.Connection second = await fixture.ConnectAsync();
Assert.Equal(0, fixture.CatalogRequests);
Assert.Contains(fixture.Logs, line => line.Contains("PRINTERS frame has no printers list", StringComparison.Ordinal));
}
/// <summary>Losing the connection resets the reading to "not checked", and the next connection asks the server again.</summary>
[Fact]
public async Task Reconnect_StartsFromNotCheckedAndAsksAgain()
{
await using RealtimeRuntimeFixture fixture = new();
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
int reads = fixture.CatalogRequests;
first.Abort();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.NotChecked, StringComparison.Ordinal));
Assert.All(fixture.Runtime.GetSnapshot().Printers.Where(printer => printer.Exposed), printer => Assert.Equal(PrinterServerState.NotChecked, printer.ServerState));
await using RealtimeRuntimeFixture.Connection second = await fixture.ConnectAsync();
await fixture.WaitAsync(() => fixture.CatalogRequests > reads);
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
}
}
@@ -13,17 +13,20 @@ public sealed class AgentRealtimeMediaCapabilitiesTests
{
/// <summary>Reads actual HELLO frames before and after reconnect, proving each socket refreshes discovery once without HTTP polling.</summary>
[Theory]
[InlineData("2.0.0", false)]
[InlineData("2.1.9", false)]
[InlineData("2.2.0", false)]
[InlineData("2.2.9", false)]
[InlineData("2.3.0", true)]
[InlineData("2.4.0", true)]
public async Task HelloAdvertisesMediaOnlyAfterStandardDiscoveryConfirmsSupport(string version, bool media)
[InlineData("2.0.0", false, false)]
[InlineData("2.1.9", false, false)]
[InlineData("2.2.0", false, false)]
[InlineData("2.2.9", false, false)]
[InlineData("2.3.0", true, false)]
[InlineData("2.3.9", true, false)]
[InlineData("2.4.0", true, true)]
[InlineData("2.5.2", true, true)]
public async Task HelloAdvertisesMediaOnlyAfterStandardDiscoveryConfirmsSupport(string version, bool media, bool approval)
{
await using var fixture = new RealtimeRuntimeFixture(printingVersion: version);
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
string[] expected = media ? ["realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1"]
string[] expected = approval ? ["realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1"]
: media ? ["realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1"]
: ["realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL"];
Assert.Equal(expected, fixture.LastHelloCapabilities);
// One initial authentication discovery, then one fresh discovery per socket.
@@ -0,0 +1,268 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net;
using System.Text;
using System.Text.Json;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Signing out must also end the identity provider's browser session, because the provider is invisible to users and the next
/// sign-in must always ask for credentials. These tests script the QQQ server and the provider's discovery document to check that
/// every browser sign-in forces re-authentication, that sign-out returns a provider end-session request only when everything the server
/// said matches the provider's own discovery, and that the service hands that request to the companion without logging it.
/// </summary>
public sealed class ProviderSignOutTests
{
private static readonly Uri Origin = new("https://printing.example.invalid");
private static readonly Uri Redirect = new(QqqBrowserCallback.RedirectUri);
private const string Issuer = "https://id.example.invalid/application/";
private const string EndSession = "https://id.example.invalid/application/end-session/";
private const string Features = "\"version\",\"metaData\",\"processes\",\"processCancel\",\"extensions\",\"widgets\",\"nativeSessionLogin\"";
private const string Extension = "\"extensions\":[{\"name\":\"printing\",\"version\":\"1.0.0\",\"pathPrefix\":\"/qbit/printing\"}]";
private const string Hint = "header.payload.signature";
/// <summary>Builds the server's discovery document, with or without the provider sign-out capability.</summary>
private static string Discovery(bool logout) => "{\"apiVersion\":\"" + (logout ? "1.10.0" : "1.9.0") + "\",\"features\":[" + Features + (logout ? ",\"nativeSessionLogout\"" : "") + "],\"clientChannels\":[\"api\"]," + Extension + "}";
/// <summary>Builds the OAuth2 authentication description with the native sign-in grant and, optionally, the provider sign-out descriptor for the given callbacks.</summary>
private static string OAuth(string? logoutCallbacks) => "{\"name\":\"p\",\"type\":\"OAUTH2\",\"values\":{\"clientId\":\"c\",\"scopes\":\"openid profile\",\"nativeSessionLogin\":{\"flow\":\"AUTHORIZATION_CODE_PKCE_SESSION\",\"codeChallengeMethod\":\"S256\",\"prompt\":\"login\",\"issuer\":\""
+ Issuer + "\",\"discoveryUrl\":\"" + Issuer + ".well-known/openid-configuration\",\"redirectUris\":[\"" + QqqBrowserCallback.RedirectUri + "\"]}"
+ (logoutCallbacks is null ? "" : ",\"nativeSessionLogout\":{\"flow\":\"OIDC_RP_INITIATED_LOGOUT\",\"postLogoutRedirectUris\":[" + logoutCallbacks + "]}") + "}}";
/// <summary>Builds the provider's own discovery document.</summary>
private static string Provider() => "{\"issuer\":\"" + Issuer + "\",\"authorization_endpoint\":\"https://id.example.invalid/authorize\",\"end_session_endpoint\":\"" + EndSession + "\",\"code_challenge_methods_supported\":[\"S256\"]}";
/// <summary>Builds the logout reply carrying the provider's end-session data, with any field replaced to test a mismatch.</summary>
private static string LogoutData(string? issuer = null, string? endpoint = null, string? callback = null, string? hint = null)
=> "{\"nativeLogoutData\":{\"issuer\":\"" + (issuer ?? Issuer) + "\",\"endSessionEndpoint\":\"" + (endpoint ?? EndSession) + "\",\"postLogoutRedirectUri\":\""
+ (callback ?? QqqBrowserCallback.RedirectUri) + "\",\"idTokenHint\":\"" + (hint ?? Hint) + "\"}}";
/// <summary>Signs in through the scripted browser flow so the client holds a session, and returns it with the server that recorded the conversation.</summary>
private static async Task<(QqqSessionClient Client, Server Server, QqqBrowserLogin Attempt)> SignedInAsync(bool logout, string? callbacks, params string[] afterLogin)
{
Server server = new();
server.Add(Discovery(logout));
server.Add(OAuth(callbacks));
server.Add(Provider());
server.Add("{}", "sessionUUID=opaque-session; Path=/; HttpOnly; Secure");
foreach(string reply in afterLogin)
{ server.Add(reply); }
QqqSessionClient client = new(Origin, server);
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(Redirect);
string state = Query(attempt.AuthorizationUri)["state"];
await client.CompleteBrowserLoginAsync(attempt, new Uri(Redirect.AbsoluteUri + "?code=one-time-code&state=" + Uri.EscapeDataString(state)));
Assert.True(client.IsAuthenticated);
return (client, server, attempt);
}
/// <summary>Every browser sign-in asks the provider to re-authenticate: prompt=login and max_age=0, because prompt=login alone was seen reusing a retained provider session.</summary>
[Fact]
public async Task BrowserSignInForcesFreshAuthentication()
{
(QqqSessionClient client, Server _, QqqBrowserLogin attempt) = await SignedInAsync(logout: true, callbacks: null);
using(client)
{
Dictionary<string, string> query = Query(attempt.AuthorizationUri);
Assert.Equal("login", query["prompt"]);
Assert.Equal("0", query["max_age"]);
}
}
/// <summary>
/// With a server that offers provider sign-out, the logout request names only the registered callback and carries only the session
/// cookie; the returned end-session request is built for the provider's own endpoint with the hint, callback and a fresh state; the
/// local session is gone; and only a return with that state counts as the provider finishing.
/// </summary>
[Fact]
public async Task SignOutReturnsTheProviderEndSessionRequestAndForgetsTheSession()
{
(QqqSessionClient client, Server server, QqqBrowserLogin _) = await SignedInAsync(true, "\"" + QqqBrowserCallback.RedirectUri + "\"", LogoutData(), Provider());
using(client)
{
QqqProviderLogout? logout = await client.SignOutWithProviderAsync(Redirect);
Assert.False(client.IsAuthenticated);
Assert.NotNull(logout);
Request request = server.Requests[4];
Assert.Equal("/qqq/v1/logout", request.Uri.AbsolutePath);
Assert.Null(request.Authorization);
Assert.Contains("sessionUUID=opaque-session", request.Cookie, StringComparison.Ordinal);
using var body = JsonDocument.Parse(request.Body!);
Assert.Equal(QqqBrowserCallback.RedirectUri, body.RootElement.GetProperty("nativeLogout").GetProperty("postLogoutRedirectUri").GetString());
Assert.Single(body.RootElement.EnumerateObject());
Assert.Equal(EndSession, logout.EndSessionUri.GetLeftPart(UriPartial.Path));
Dictionary<string, string> query = Query(logout.EndSessionUri);
Assert.Equal(Hint, query["id_token_hint"]);
Assert.Equal(QqqBrowserCallback.RedirectUri, query["post_logout_redirect_uri"]);
Assert.Equal(43, query["state"].Length);
Assert.Equal("QQQ provider sign-out (redacted)", logout.ToString());
Assert.True(logout.Matches(new Uri(Redirect.AbsoluteUri + "?state=" + Uri.EscapeDataString(logout.State))));
Assert.False(logout.Matches(new Uri(Redirect.AbsoluteUri + "?state=other")));
Assert.False(logout.Matches(new Uri(Redirect.AbsoluteUri + "?code=x")));
Assert.False(logout.Matches(new Uri("io.qrun.other:/oauth/callback?state=" + Uri.EscapeDataString(logout.State))));
}
}
/// <summary>A server without the capability (or without this callback listed) still gets an ordinary logout that ends the QQQ session, and nothing is returned to open.</summary>
[Theory]
[InlineData(false, "\"io.qrun.qqq-print-agent:/oauth/callback\"")]
[InlineData(true, null)]
[InlineData(true, "\"io.qrun.other:/oauth/callback\"")]
public async Task ServerWithoutProviderSignOutGetsOrdinaryLogout(bool capability, string? callbacks)
{
(QqqSessionClient client, Server server, QqqBrowserLogin _) = await SignedInAsync(capability, callbacks, "{}");
using(client)
{
Assert.Null(await client.SignOutWithProviderAsync(Redirect));
Assert.False(client.IsAuthenticated);
Assert.Equal("{}", server.Requests[4].Body);
Assert.Equal(5, server.Requests.Count);
}
}
/// <summary>
/// Provider data that does not match what the server advertised, or what the provider's own discovery publishes, is refused with no
/// request returned, and the local session is still forgotten.
/// </summary>
[Theory]
[InlineData("issuer")]
[InlineData("callback")]
[InlineData("hint-space")]
[InlineData("hint-empty")]
[InlineData("endpoint-http")]
[InlineData("endpoint-other-origin")]
[InlineData("endpoint-differs-from-discovery")]
public async Task MismatchedProviderDataIsRefusedAndSessionStillCleared(string flaw)
{
string data = flaw switch
{
"issuer" => LogoutData(issuer: "https://evil.example.invalid/application/"),
"callback" => LogoutData(callback: "io.qrun.other:/oauth/callback"),
"hint-space" => LogoutData(hint: "a b"),
"hint-empty" => LogoutData(hint: ""),
"endpoint-http" => LogoutData(endpoint: "http://id.example.invalid/application/end-session/"),
"endpoint-other-origin" => LogoutData(endpoint: "https://evil.example.invalid/end-session/"),
_ => LogoutData(endpoint: "https://id.example.invalid/application/other-end-session/"),
};
(QqqSessionClient client, Server _, QqqBrowserLogin _) = await SignedInAsync(true, "\"" + QqqBrowserCallback.RedirectUri + "\"", data, Provider());
using(client)
{
_ = await Assert.ThrowsAsync<QqqSessionException>(() => client.SignOutWithProviderAsync(Redirect));
Assert.False(client.IsAuthenticated);
}
}
/// <summary>A server that returns no provider data (an older session) ends the QQQ session and returns nothing to open.</summary>
[Fact]
public async Task MissingProviderDataReturnsNothingToOpen()
{
(QqqSessionClient client, Server _, QqqBrowserLogin _) = await SignedInAsync(true, "\"" + QqqBrowserCallback.RedirectUri + "\"", "{}");
using(client)
{
Assert.Null(await client.SignOutWithProviderAsync(Redirect));
Assert.False(client.IsAuthenticated);
}
}
/// <summary>The companion's sign-out reaches the service with the registered callback, and the reply carries the provider request for the browser; the command line sends no callback and gets plain text.</summary>
[Fact]
public async Task ServiceReturnsTheProviderRequestToTheCompanionOnly()
{
using SessionFixture fixture = new();
QqqProviderLogout logout = new(new Uri(EndSession + "?id_token_hint=" + Hint + "&state=s"), Redirect, "s");
fixture.Session.ProviderLogout = logout;
using AgentRuntime runtime = fixture.Runtime();
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential(), ClaimLegacyDevice: true), CancellationToken.None);
string reply = await runtime.SignOutAsync(QqqBrowserCallback.RedirectUri, CancellationToken.None);
PipeSignOutResult result = JsonSerializer.Deserialize<PipeSignOutResult>(reply, PrintApiClient.JsonOptions)!;
Assert.Equal(Redirect, fixture.Session.RequestedLogoutRedirect);
Assert.Equal(logout.EndSessionUri, result.ProviderLogout!.EndSessionUri);
Assert.Equal("s", result.ProviderLogout.State);
Assert.StartsWith("Signed out.", result.Message, StringComparison.Ordinal);
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential(), ClaimLegacyDevice: true), CancellationToken.None);
string plain = await runtime.SignOutAsync(CancellationToken.None);
Assert.StartsWith("Signed out.", plain, StringComparison.Ordinal);
Assert.Equal(Redirect, fixture.Session.RequestedLogoutRedirect);
}
/// <summary>The service accepts only this application's registered callback, so a local client cannot steer the provider's return elsewhere.</summary>
[Fact]
public async Task ServiceRefusesAnyOtherCallback()
{
using SessionFixture fixture = new();
using AgentRuntime runtime = fixture.Runtime();
_ = await Assert.ThrowsAsync<InvalidOperationException>(() => runtime.SignOutAsync("https://evil.example.invalid/cb", CancellationToken.None));
}
/// <summary>Splits a URL's query string into decoded name and value pairs.</summary>
private static Dictionary<string, string> Query(Uri uri) => uri.Query[1..].Split('&').Select(part => part.Split('=', 2))
.ToDictionary(part => Uri.UnescapeDataString(part[0]), part => Uri.UnescapeDataString(part[1]), StringComparer.Ordinal);
/// <summary>What the scripted server saw for one request.</summary>
private sealed record Request(Uri Uri, string? Body, string? Authorization, string? Cookie);
/// <summary>Scripted server: records each request and answers in the order replies were queued, so a test fixes the whole conversation.</summary>
private sealed class Server : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _replies = new();
internal List<Request> Requests { get; } = [];
/// <summary>Queues a JSON reply, optionally with a Set-Cookie header.</summary>
internal void Add(string body, string? cookie = null)
{
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
if(cookie is not null)
{ response.Headers.Add("Set-Cookie", cookie); }
_replies.Enqueue(response);
}
/// <summary>Records the request and returns the next queued reply.</summary>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string? body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
Requests.Add(new Request(request.RequestUri!, body, request.Headers.Authorization?.ToString(), request.Headers.TryGetValues("Cookie", out IEnumerable<string>? cookie) ? string.Join("; ", cookie) : null));
return _replies.Dequeue();
}
}
}
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The browser callback inbox must be reachable by the protocol handler the browser launches at medium integrity even when the
/// companion runs elevated: the pipe is owned by the user, restricted to the user, labelled medium integrity, and a forwarded
/// callback from a same-user process is received and acknowledged.
/// </summary>
public sealed class QqqBrowserCallbackInboxTests
{
/// <summary>The inbox descriptor names the current user (not the token's default owner) as owner and grants access to that user's SID only, so an elevated companion cannot lock out the unelevated handler and no other account can reach the inbox.</summary>
[Fact]
public void InboxSecurity_IsUserOwnedUserOnlyAndMediumIntegrity()
{
using var identity = WindowsIdentity.GetCurrent();
PipeSecurity security = QqqBrowserCallback.InboxSecurity();
Assert.Equal(identity.User, security.GetOwner(typeof(SecurityIdentifier)));
AuthorizationRuleCollection rules = security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier));
AuthorizationRule rule = Assert.Single(rules.Cast<AuthorizationRule>());
Assert.Equal(identity.User, rule.IdentityReference);
Assert.Equal(AccessControlType.Allow, ((PipeAccessRule)rule).AccessControlType);
Assert.Equal(PipeAccessRights.FullControl, ((PipeAccessRule)rule).PipeAccessRights);
}
/// <summary>
/// The pipe the companion actually creates is owned by the user's SID, not by the token's default owner. On an elevated
/// process (hosted CI runs elevated) the default owner is the Administrators group, so this fails there if the inbox ever
/// goes back to PipeOptions.CurrentUserOnly, which is exactly the regression that locked out the browser-launched handler.
/// </summary>
[Fact]
public void CreatedInbox_IsOwnedByTheUserNotTheTokenDefaultOwner()
{
using var identity = WindowsIdentity.GetCurrent();
using QqqBrowserCallback inbox = new("QqqPrintAgent.Auth.Test." + Guid.NewGuid().ToString("N"));
PipeSecurity live = inbox.CurrentSecurity();
Assert.Equal(identity.User, live.GetOwner(typeof(SecurityIdentifier)));
if(identity.Owner is not null && identity.Owner != identity.User)
{
Assert.NotEqual(identity.Owner, live.GetOwner(typeof(SecurityIdentifier)));
}
foreach(AuthorizationRule rule in live.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)))
{
Assert.Equal(identity.User, rule.IdentityReference);
}
}
/// <summary>A callback forwarded by a same-user process through the handler's own client path reaches the inbox, is validated, acknowledged, and returned to the waiting sign-in.</summary>
[Fact]
public async Task ForwardedCallback_ReachesTheInboxAndIsAcknowledged()
{
string name = "QqqPrintAgent.Auth.Test." + Guid.NewGuid().ToString("N");
using QqqBrowserCallback inbox = new(name);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
Task<Uri> receiving = inbox.ReceiveAsync(timeout.Token);
var steps = new List<string>();
await QqqBrowserCallback.ForwardAsync(QqqBrowserCallback.RedirectUri + "?code=test-code&state=test-state", name, Environment.ProcessPath!, timeout.Token, steps.Add);
Uri received = await receiving;
Assert.Equal("?code=test-code&state=test-state", received.Query);
Assert.Contains(steps, step => step.StartsWith("Companion verified", StringComparison.Ordinal));
Assert.DoesNotContain(steps, step => step.Contains("test-code", StringComparison.Ordinal));
}
/// <summary>An address that is not this application's redirect, or that carries no query, is refused before anything connects.</summary>
[Theory]
[InlineData("io.qrun.qqq-print-agent:/oauth/callback")]
[InlineData("io.qrun.qqq-print-agent:/other?code=x")]
[InlineData("https://evil.invalid/oauth/callback?code=x")]
public void InvalidCallback_IsRefused(string callback)
=> Assert.Throws<InvalidDataException>(() => QqqBrowserCallback.Validate(callback));
}
@@ -57,6 +57,21 @@ internal sealed class RealtimeRuntimeFixture : IAsyncDisposable
/// <summary>Controls advertised discovery across a server upgrade or downgrade while retaining the same authenticated client session.</summary>
internal string PrintingVersion { get; set; }
/// <summary>Printer identifiers the fake server's operator catalog lists for this device; both shared queues by default.</summary>
internal string[] ApprovedPrinters { get; set; } = ["printer", "other"];
/// <summary>When set, the operator catalog answers with this status instead of a page, to model an older server (404) or a user without catalog access (403).</summary>
internal HttpStatusCode? CatalogStatus { get; set; }
/// <summary>Counts operator catalog reads.</summary>
internal int CatalogRequests;
private readonly Lock _claimLock = new();
private PrintJob? _claimed;
private readonly CancellationTokenSource _stopping = new(TimeSpan.FromSeconds(30));
@@ -371,6 +386,17 @@ internal sealed class RealtimeRuntimeFixture : IAsyncDisposable
{
return Response(JsonSerializer.Serialize(new { jobs = fixture.Queue.ToArray() }, PrintApiClient.JsonOptions));
}
if(path.EndsWith("/operator/printers", StringComparison.Ordinal))
{
Interlocked.Increment(ref fixture.CatalogRequests);
if(fixture.CatalogStatus is HttpStatusCode status)
{ return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent("{}") }); }
return Response(JsonSerializer.Serialize(new
{
printers = fixture.ApprovedPrinters.Select(id => new { printerRowId = "row-" + id, deviceId = "device", printerId = id, name = id, deviceName = "Synthetic workstation", status = "READY", formats = new[] { "PDF" }, mediaProfiles = Array.Empty<object>() }),
nextCursor = (string?)null,
}, PrintApiClient.JsonOptions));
}
if(path.EndsWith("/devices/enroll", StringComparison.Ordinal))
{ return Response("""{"protocolVersion":1,"deviceId":"replacement-device","deviceToken":"replacement-proof","pollIntervalSeconds":5}"""); }
if(path.EndsWith("/printing/session", StringComparison.Ordinal))
@@ -122,6 +122,24 @@ internal sealed class SessionFixture : IDisposable
/// <summary>The scripted provider sign-out the fake server returns at logout; null means the server offers none.</summary>
internal QqqProviderLogout? ProviderLogout { get; set; }
/// <summary>The callback the service asked the provider to return to, or null when only the QQQ session was ended.</summary>
internal Uri? RequestedLogoutRedirect { get; private set; }
/// <summary>Marks the session signed out, records which callback was asked for and returns the scripted provider sign-out.</summary>
public Task<QqqProviderLogout?> SignOutWithProviderAsync(Uri postLogoutRedirectUri, CancellationToken cancellationToken = default)
{
IsAuthenticated = false;
RequestedLogoutRedirect = postLogoutRedirectUri;
return Task.FromResult(ProviderLogout);
}
/// <summary>Returns the scripted principal, tenant and device binding.</summary>
public Task<QqqSessionBinding> GetSessionIdentityAsync(CancellationToken cancellationToken = default) => Task.FromResult(Binding);
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Tests;
/// <summary>A compiled-in client edition is read strictly, applies presentation only, and is absent for the generic build.</summary>
public sealed class EditionTests
{
/// <summary>A complete descriptor yields the client's names, support link, accent and asset prefix, and applying it changes presentation but not identity.</summary>
[Fact]
public void ValidEdition_IsLoadedAndAppliedToPresentationOnly()
{
string path = Path.Combine(Path.GetTempPath(), "qqq-edition-" + Guid.NewGuid().ToString("N") + ".json");
try
{
File.WriteAllText(path, """{"id":"acme-print","displayName":"Acme Print Connector","companyName":"Acme Logistics","tagline":"Printing for Acme","supportUrl":"https://support.example.invalid/print","accentColor":"#0F766E","assetPrefix":"AcmePrintConnector"}""");
var edition = Edition.Load(path);
Assert.NotNull(edition);
Assert.Equal("acme-print", edition.Id);
Assert.Equal("Acme Print Connector", edition.DisplayName);
Assert.Equal("Acme Logistics", edition.CompanyName);
Assert.Equal("AcmePrintConnector", edition.AssetPrefix);
Assert.Equal("#0F766E", edition.AccentColor);
Assert.Equal(new Uri("https://support.example.invalid/print"), edition.SupportUrl);
Assert.Equal(Edition.DefaultServerName, edition.ServerName);
Assert.True(edition.ShowMascot);
Assert.True(edition.ShowLicense);
KofTwentyTwo.AppKit.AppInfo applied = edition.Apply(AppIdentity.BaseInfo);
Assert.Equal("Acme Print Connector", applied.DisplayName);
Assert.Equal("Printing for Acme", applied.Tagline);
Assert.Equal("#0F766E", applied.Brand.GradientStart);
Assert.Equal(AppIdentity.BaseInfo.Id, applied.Id);
Assert.Equal(AppIdentity.BaseInfo.Author, applied.Author);
Assert.Equal(AppIdentity.BaseInfo.RepositoryUrl, applied.RepositoryUrl);
Assert.Equal(AppIdentity.BaseInfo.Attributions, applied.Attributions);
}
finally
{
File.Delete(path);
}
}
/// <summary>A missing file is the generic build; a descriptor with a bad id, a bad asset prefix, missing names or the wrong shape is ignored as a whole, and an unreadable accent is dropped.</summary>
[Theory]
[InlineData("""{"id":"Bad Id","displayName":"X","companyName":"Y","assetPrefix":"XY"}""", false)]
[InlineData("""{"id":"ok","displayName":"X","companyName":"Y","assetPrefix":"has-hyphen"}""", false)]
[InlineData("""{"id":"ok","displayName":"X","assetPrefix":"XY"}""", false)]
[InlineData("""["not","an","object"]""", false)]
[InlineData("""{"id":"ok","displayName":"X","companyName":"Y","assetPrefix":"XY","accentColor":"#FFFF00"}""", true)]
public void InvalidEdition_IsIgnoredAndUnreadableAccentDropped(string json, bool loads)
{
string path = Path.Combine(Path.GetTempPath(), "qqq-edition-" + Guid.NewGuid().ToString("N") + ".json");
try
{
File.WriteAllText(path, json);
var edition = Edition.Load(path);
Assert.Equal(loads, edition is not null);
if(edition is not null)
{
Assert.Null(edition.AccentColor);
}
}
finally
{
File.Delete(path);
}
Assert.Null(Edition.Load(Path.Combine(Path.GetTempPath(), "qqq-edition-missing-" + Guid.NewGuid().ToString("N") + ".json")));
}
/// <summary>An edition may name the server and switch the About mascot and license sections off; only an explicit false switches them off.</summary>
[Fact]
public void EditionOptions_NameTheServerAndTrimAbout()
{
string path = Path.Combine(Path.GetTempPath(), "qqq-edition-" + Guid.NewGuid().ToString("N") + ".json");
try
{
File.WriteAllText(path, """{"id":"acme","displayName":"Acme Print","companyName":"Acme","assetPrefix":"AcmePrint","serverName":"Acme Cloud","aboutMascot":false,"aboutLicense":"no"}""");
var edition = Edition.Load(path);
Assert.NotNull(edition);
Assert.Equal("Acme Cloud", edition.ServerName);
Assert.False(edition.ShowMascot);
Assert.True(edition.ShowLicense);
}
finally
{
File.Delete(path);
}
}
/// <summary>The test host carries no edition, so the identity is the generic one and the updater follows the generic asset name.</summary>
[Fact]
public void GenericBuild_HasNoEdition()
{
Assert.Null(AppIdentity.Edition);
Assert.Equal(Edition.DefaultAssetPrefix, AppIdentity.UpdateAssetPrefix);
Assert.Equal("QQQ", AppIdentity.ServerName);
Assert.Equal("QQQ Print Agent", AppIdentity.DefaultInfo.DisplayName);
}
}
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Core.Tests;
/// <summary>
/// The operator printer catalog is the agent's only view of server-side approval. These tests pin the wire shape (the route
/// under the printing extension, the page size, the cursor continuation), the refusal reasons for a server without the catalog
/// or a user without access, the requirement for a signed-in session, and the page bound.
/// </summary>
public sealed class OperatorPrinterCatalogTests
{
/// <summary>Pages are followed through the opaque cursor until the server returns none, every entry is kept, and unknown members are ignored.</summary>
[Fact]
public async Task Catalog_FollowsCursorsAndCollectsEveryPage()
{
using CatalogSession session = new(
"""{"printers":[{"printerRowId":"r1","deviceId":"d1","printerId":"p1","name":"Laser","deviceName":"Desk","status":"READY","formats":["PDF"],"mediaProfiles":[]}],"nextCursor":"c1"}""",
"""{"printers":[{"printerRowId":"r2","deviceId":"d2","printerId":"p2","name":"Zebra","deviceName":"Dock","status":"OFFLINE","formats":["ZPL","PDF"],"extra":true}],"nextCursor":null}""");
using var api = PrintApiClient.ForSession(session);
IReadOnlyList<OperatorPrinter> printers = await api.OperatorPrintersAsync(CancellationToken.None);
Assert.Equal(new[] { "p1", "p2" }, printers.Select(printer => printer.PrinterId), StringComparer.Ordinal);
Assert.Equal(new[] { "d1", "d2" }, printers.Select(printer => printer.DeviceId), StringComparer.Ordinal);
Assert.Equal(new[] { "ZPL", "PDF" }, printers[1].Formats, StringComparer.Ordinal);
Assert.Equal(new[] { "operator/printers?limit=200", "operator/printers?limit=200&after=c1" }, session.Paths, StringComparer.Ordinal);
}
/// <summary>A 404 is an older server and a 401 or 403 a user without access; each becomes a distinct, explained exception rather than a generic HTTP failure.</summary>
[Theory]
[InlineData(HttpStatusCode.NotFound, typeof(NotSupportedException))]
[InlineData(HttpStatusCode.Forbidden, typeof(UnauthorizedAccessException))]
[InlineData(HttpStatusCode.Unauthorized, typeof(UnauthorizedAccessException))]
public async Task Catalog_ExplainsMissingRouteAndRefusedAccess(HttpStatusCode status, Type exception)
{
using CatalogSession session = new(status);
using var api = PrintApiClient.ForSession(session);
await Assert.ThrowsAsync(exception, () => api.OperatorPrintersAsync(CancellationToken.None));
}
/// <summary>The catalog is user-scoped: a client without a signed-in session refuses before any request.</summary>
[Fact]
public async Task Catalog_RequiresTheSignedInSession()
{
using PrintApiClient api = new("https://synthetic.invalid", "device-token");
await Assert.ThrowsAsync<InvalidOperationException>(() => api.OperatorPrintersAsync(CancellationToken.None));
}
/// <summary>A server that keeps returning cursors is cut off after the page bound instead of being followed forever.</summary>
[Fact]
public async Task Catalog_StopsAfterThePageBound()
{
using CatalogSession session = new(Enumerable.Repeat("""{"printers":[],"nextCursor":"again"}""", PrintApiClient.CatalogPageLimit + 1).ToArray());
using var api = PrintApiClient.ForSession(session);
await Assert.ThrowsAsync<InvalidDataException>(() => api.OperatorPrintersAsync(CancellationToken.None));
Assert.Equal(PrintApiClient.CatalogPageLimit, session.Paths.Count);
}
/// <summary>A session stub that answers the printing routes with scripted pages (or one status) and records the relative paths it was asked for.</summary>
private sealed class CatalogSession : IQqqSessionClient
{
private readonly Queue<string> _pages;
private readonly HttpStatusCode _status;
internal CatalogSession(params string[] pages)
{
_pages = new Queue<string>(pages);
_status = HttpStatusCode.OK;
}
internal CatalogSession(HttpStatusCode status)
{
_pages = new Queue<string>();
_status = status;
}
internal List<string> Paths { get; } = [];
public Uri Origin { get; } = new("https://synthetic.invalid");
public bool IsAuthenticated => true;
public Task<QqqApiDiscovery> DiscoverAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<QqqAuthenticationMetadata> GetAuthenticationMetadataAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task SignInWithPasswordAsync(string username, string password, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<QqqBrowserLogin> BeginBrowserLoginAsync(Uri redirectUri, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task CompleteBrowserLoginAsync(QqqBrowserLogin attempt, Uri callback, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task RestoreSessionAsync(QqqSessionCredential credential, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task ResumeSessionAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task SignOutAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
public QqqSessionCredential ExportCredential() => throw new NotSupportedException();
public Task<QqqSessionBinding> GetSessionIdentityAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<QqqSessionBinding> BindDeviceAsync(QqqDeviceCredential credential, bool claimLegacyDevice = false, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<HttpResponseMessage> SendPrintingAsync(HttpMethod method, string relativePath, HttpContent? content = null, CancellationToken cancellationToken = default)
{
Paths.Add(relativePath);
Assert.Equal(HttpMethod.Get, method);
if(_status != HttpStatusCode.OK)
{ return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent("{}") }); }
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(_pages.Dequeue()) });
}
public void Dispose() { }
}
}
@@ -37,6 +37,21 @@ public sealed class ProtocolFixtureTests
/// <summary>The shared PRINTERS frame (printing extension 2.4, printer-approval-v1) parses into the per-printer approval states the runtime applies: one approved, one known but not approved.</summary>
[Fact]
public void SharedPrintersFrameFixture_Deserializes()
{
string json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "fixtures", "realtime-printers.message.json"));
RealtimeEnvelope envelope = JsonSerializer.Deserialize<RealtimeEnvelope>(json, PrintApiClient.JsonOptions)!;
Assert.Equal("PRINTERS", envelope.Type);
PrinterApprovalState[] printers = envelope.Payload.GetProperty("printers").Deserialize<PrinterApprovalState[]>(PrintApiClient.JsonOptions)!;
Assert.Equal(2, printers.Length);
Assert.Contains(printers, printer => string.Equals(printer.PrinterId, "laser-example", StringComparison.Ordinal) && printer.Enabled);
Assert.Contains(printers, printer => string.Equals(printer.PrinterId, "label-example", StringComparison.Ordinal) && !printer.Enabled);
}
/// <summary>An event with no message or spooler id serializes without any null members, since the server treats an explicit null differently from an absent field.</summary>
[Fact]
public void OptionalEventFields_AreOmitted()
@@ -450,6 +450,7 @@ public sealed class QqqSessionAuthenticationTests
Assert.Null(exchange.DeviceToken);
using var body = JsonDocument.Parse(exchange.Body!);
Assert.Equal(4, body.RootElement.EnumerateObject().Count());
Assert.False(body.RootElement.TryGetProperty("expectedNonce", out _));
Assert.Equal("one-time-code", body.RootElement.GetProperty("code").GetString());
Assert.Equal(QqqAuthenticationMetadata.NativeSessionFlow, body.RootElement.GetProperty("authenticationFlow").GetString());
string verifier = body.RootElement.GetProperty("codeVerifier").GetString()!;
@@ -499,6 +500,108 @@ public sealed class QqqSessionAuthenticationTests
/// <summary>
/// When the server descriptor advertises nonceValidation REQUIRED, the exchange carries expectedNonce equal to the nonce in this
/// attempt's authorization URL, even if the callback tries to supply a different nonce, and the redacted attempt text does not reveal it.
/// </summary>
[Fact]
public async Task RequiredNonceValidationSendsTheAttemptsOwnAuthorizationNonce()
{
using Handler handler = NonceHandler("REQUIRED");
handler.Add("{}", cookie: "sessionUUID=opaque-session; Path=/; HttpOnly; Secure");
using var client = new QqqSessionClient(Origin, handler);
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(Redirect);
Dictionary<string, string> authorization = Query(attempt.AuthorizationUri);
Assert.DoesNotContain(authorization["nonce"], attempt.ToString(), StringComparison.Ordinal);
await client.CompleteBrowserLoginAsync(attempt, new Uri(Callback(attempt, authorization["state"]).AbsoluteUri + "&nonce=callback-supplied-nonce"));
using var body = JsonDocument.Parse(handler.Requests[3].Body!);
Assert.Equal(5, body.RootElement.EnumerateObject().Count());
Assert.Equal(authorization["nonce"], body.RootElement.GetProperty("expectedNonce").GetString());
Assert.NotEqual("callback-supplied-nonce", body.RootElement.GetProperty("expectedNonce").GetString(), StringComparer.Ordinal);
Assert.Equal("one-time-code", body.RootElement.GetProperty("code").GetString());
Assert.Equal("opaque-session", client.ExportCredential().SessionToken);
}
/// <summary>Under the OPTIONAL policy the server validates a nonce when one is sent, so the exchange carries this attempt's nonce exactly as under REQUIRED.</summary>
[Fact]
public async Task OptionalNonceValidationAlsoSendsTheAttemptsOwnNonce()
{
using Handler handler = NonceHandler("OPTIONAL");
handler.Add("{}", cookie: "sessionUUID=opaque-session; Path=/; HttpOnly; Secure");
using var client = new QqqSessionClient(Origin, handler);
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(Redirect);
Dictionary<string, string> authorization = Query(attempt.AuthorizationUri);
await client.CompleteBrowserLoginAsync(attempt, Callback(attempt, authorization["state"]));
using var body = JsonDocument.Parse(handler.Requests[3].Body!);
Assert.Equal(5, body.RootElement.EnumerateObject().Count());
Assert.Equal(authorization["nonce"], body.RootElement.GetProperty("expectedNonce").GetString());
}
/// <summary>A server that advertises the nativeSessionNonceValidation capability but whose grant states no policy still receives the attempt's nonce, while a server advertising neither keeps the legacy four-field body.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task DiscoveryCapabilityAloneDecidesTheNonceWhenTheGrantIsSilent(bool capability)
{
using var handler = new Handler();
handler.Add(capability ? Discovery.Replace("\"nativeSessionLogin\"", "\"nativeSessionLogin\",\"nativeSessionNonceValidation\"", StringComparison.Ordinal) : Discovery);
handler.Add(OAuth);
handler.Add(Provider);
handler.Add("{}", cookie: "sessionUUID=opaque-session; Path=/; HttpOnly; Secure");
using var client = new QqqSessionClient(Origin, handler);
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(Redirect);
Dictionary<string, string> authorization = Query(attempt.AuthorizationUri);
await client.CompleteBrowserLoginAsync(attempt, Callback(attempt, authorization["state"]));
using var body = JsonDocument.Parse(handler.Requests[3].Body!);
Assert.Equal(capability ? 5 : 4, body.RootElement.EnumerateObject().Count());
Assert.Equal(capability, body.RootElement.TryGetProperty("expectedNonce", out JsonElement expected));
if(capability)
{
Assert.Equal(authorization["nonce"], expected.GetString());
}
}
/// <summary>A nonce policy the client does not understand is refused as a configuration problem after the two public requests, before the provider is contacted, so no browser opens and nothing is exchanged.</summary>
[Theory]
[InlineData("required")]
[InlineData("NONE")]
[InlineData(" ")]
public async Task UnknownNonceValidationPolicyIsRefusedBeforeBrowserOrExchange(string policy)
{
using var handler = new Handler();
handler.Add(Discovery);
handler.Add(OAuthWithNonce(policy));
using var client = new QqqSessionClient(Origin, handler);
QqqSessionException exception = await Assert.ThrowsAsync<QqqSessionException>(() => client.BeginBrowserLoginAsync(Redirect));
Assert.Equal(QqqSessionException.FailureKind.ConfigurationRequired, exception.Failure);
Assert.Equal(2, handler.Requests.Count);
Assert.All(handler.Requests, AssertPublic);
}
/// <summary>With the nonce required, a callback carrying the wrong state is still refused without an exchange and consumes the attempt, so the correct callback cannot be replayed afterwards.</summary>
[Fact]
public async Task RequiredNonceDoesNotWeakenStateAndReplayProtection()
{
using Handler handler = NonceHandler("REQUIRED");
using var client = new QqqSessionClient(Origin, handler);
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(Redirect);
string state = Query(attempt.AuthorizationUri)["state"];
await Assert.ThrowsAsync<QqqSessionException>(() => client.CompleteBrowserLoginAsync(attempt, Callback(attempt, "wrong")));
await Assert.ThrowsAsync<QqqSessionException>(() => client.CompleteBrowserLoginAsync(attempt, Callback(attempt, state)));
Assert.Equal(3, handler.Requests.Count);
Assert.False(client.IsAuthenticated);
}
/// <summary>If the provider's own discovery document names a different issuer than the server advertised, no browser URL is produced, which stops a swapped or spoofed identity provider.</summary>
[Fact]
public async Task DiscoveryIssuerMismatchCannotLaunchBrowser()
@@ -603,6 +706,23 @@ public sealed class QqqSessionAuthenticationTests
/// <summary>The native browser-login description with an explicit nonceValidation value added to the grant.</summary>
private static string OAuthWithNonce(string policy) => OAuth.Replace("\"prompt\":\"login\"", "\"prompt\":\"login\",\"nonceValidation\":\"" + policy + "\"", StringComparison.Ordinal);
/// <summary>A fake server whose native grant advertises the given nonce policy, followed by the provider's discovery document.</summary>
private static Handler NonceHandler(string policy)
{
var handler = new Handler();
handler.Add(Discovery);
handler.Add(OAuthWithNonce(policy));
handler.Add(Provider);
return handler;
}
/// <summary>A fake server pre-loaded with discovery, the native browser-login description and the provider's discovery document, the three public replies before a browser sign-in starts.</summary>
private static Handler OAuthHandler()
{
+2 -2
View File
@@ -3,8 +3,8 @@
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
using Microsoft.UI.Xaml;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent;
@@ -12,7 +12,7 @@ namespace Qqq.PrintAgent;
public partial class App : Application
{
private Window? _window;
internal static IActivityLog Log { get; } = AppIdentity.CreateLog();
internal static IAgentLog Log { get; } = new HarnessLog(AppIdentity.CreateLog());
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Qqq.PrintAgent;
/// <summary>
/// Optional visual evidence of the build's identity (generic or a client edition): the splash as it opens, the main window,
/// the Settings page with the logo and the shared About dialog in both themes, captured from the real views. It runs only
/// when QQQ_LAYOUT_CAPTURE_EDITION=1 is set, so the normal layout gate is unchanged.
/// </summary>
internal static class EditionCaptureScenario
{
/// <summary>Shows the connection dialog with a server address and the browser button visible, captures it, and hides it again.</summary>
private static async Task CaptureConnectionDialogAsync(MainWindow window, FrameworkElement root)
{
// A dialog that was shown once lives in a popup afterwards, so take the window's own fields rather than a name lookup.
ContentDialog connection = Field<ContentDialog>(window, "ConnectionDialog");
connection.XamlRoot = root.XamlRoot;
Field<TextBox>(window, "ServerBox").Text = "https://127.0.0.1:18581";
Field<Button>(window, "BrowserSignInButton").Visibility = Visibility.Visible;
Task<ContentDialogResult?> connecting = DialogGuard.ShowAsync(connection);
try
{
// The dialog is measured inside its popup after it opens; render only once it has a real size, and capture
// its scroll content (the dialog chrome itself is a popup layer that RenderTargetBitmap does not accept).
for(int waited = 0; waited < 40 && (connection.ActualWidth < 1 || connection.ActualHeight < 1); waited++)
{ await Task.Delay(100); }
await Task.Delay(500);
connection.UpdateLayout();
// Captured on its own the content has no backdrop, so give it the light theme and a solid page colour for the picture.
connection.RequestedTheme = ElementTheme.Light;
// The dialog's content is the feedback bar over the scrolling form; capture that whole grid.
var content = (Grid)connection.Content;
content.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White);
content.Padding = new Thickness(24, 24, 40, 24);
await Task.Delay(300);
content.UpdateLayout();
await QueryAboutScenario.CaptureAsync(content, "edition-connection-dialog.png", requireContrast: false);
}
finally
{
connection.Hide();
await connecting;
Field<Button>(window, "BrowserSignInButton").Visibility = Visibility.Collapsed;
}
}
/// <summary>Reads one of the window's generated XAML fields, which stay valid after a dialog has been reparented into a popup.</summary>
private static T Field<T>(MainWindow window, string name) where T : class
=> typeof(MainWindow).GetField(name, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(window) as T
?? throw new InvalidOperationException("Window field missing: " + name);
/// <summary>True when the caller asked for identity captures.</summary>
internal static bool IsEnabled => string.Equals(Environment.GetEnvironmentVariable("QQQ_LAYOUT_CAPTURE_EDITION"), "1", StringComparison.Ordinal);
/// <summary>Captures the window while the splash overlay is still up, which the harness enables only in capture mode.</summary>
internal static async Task CaptureSplashAsync(FrameworkElement root)
{
if(!IsEnabled)
{
return;
}
root.UpdateLayout();
await QueryAboutScenario.CaptureAsync(root, "edition-splash.png", requireContrast: false);
}
/// <summary>Captures the Printers page, the Settings page and the About dialog (light and dark) with the build's real identity, then returns to Printers.</summary>
internal static async Task RunAsync(MainWindow window, FrameworkElement root)
{
if(!IsEnabled)
{
return;
}
await Task.Delay(500);
root.UpdateLayout();
await QueryAboutScenario.CaptureAsync(root, "edition-window.png", requireContrast: false);
var navigation = (NavigationView)root.FindName("Navigation");
navigation.SelectedItem = navigation.SettingsItem;
await Task.Delay(600);
root.UpdateLayout();
await QueryAboutScenario.CaptureAsync(root, "edition-settings.png", requireContrast: false);
foreach(ElementTheme theme in new[] { ElementTheme.Light, ElementTheme.Dark })
{
AppInfo identity = AppIdentity.Info;
var dialog = new AboutDialog(identity) { XamlRoot = root.XamlRoot, RequestedTheme = theme };
AboutContent.Compose((StackPanel)((ScrollViewer)dialog.Content).Content, AppIdentity.Branding.SupportUrl, null);
Task<ContentDialogResult?> showing = DialogGuard.ShowAsync(dialog);
try
{
await Task.Delay(700);
dialog.UpdateLayout();
await QueryAboutScenario.CaptureAsync(dialog, $"edition-about-{theme}.png", requireContrast: false);
}
finally
{
dialog.Hide();
await showing;
}
}
navigation.SelectedItem = navigation.MenuItems.OfType<NavigationViewItem>().First();
await Task.Delay(300);
await CaptureConnectionDialogAsync(window, root);
File.WriteAllText(Path.Combine(Program.EvidenceDirectory, "edition-identity.json"), System.Text.Json.JsonSerializer.Serialize(new
{
edition = AppIdentity.Edition?.Id,
displayName = AppIdentity.Info.DisplayName,
tagline = AppIdentity.Info.Tagline,
company = AppIdentity.Edition?.CompanyName,
accent = AppIdentity.Info.Brand.GradientStart,
updateAssetPrefix = AppIdentity.UpdateAssetPrefix,
logo = AppIdentity.EditionLogoPath,
}));
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent;
/// <summary>The harness's per-user log seen through the companion's log interface: debug detail is written as ordinary entries so the real views compile and run against the isolated profile instead of the protected machine log.</summary>
internal sealed class HarnessLog(IActivityLog inner) : IAgentLog
{
public bool IsDebugEnabled => true;
public string LogDirectory => inner.LogDirectory;
public string CurrentLogFilePath => inner.CurrentLogFilePath;
public void Info(string message) => inner.Info(message);
public void Warning(string message) => inner.Warning(message);
public void Error(string message, Exception? exception = null) => inner.Error(message, exception);
public void Debug(string message) => inner.Info("[DEBUG] " + message);
}
@@ -33,6 +33,7 @@ internal static class LayoutScenario
{
await WaitAsync(() => window.Content.XamlRoot is not null && service.Snapshots > 0);
var root = (FrameworkElement)window.Content;
await EditionCaptureScenario.CaptureSplashAsync(root);
await Task.Delay(250);
var printers = (ListView)root.FindName("PrintersList");
printers.SelectedItem = printers.Items.Cast<PrinterRowView>().Single(row => string.Equals(row.Snapshot?.PrinterId, SyntheticService.Printer.PrinterId, StringComparison.Ordinal));
@@ -60,6 +61,7 @@ internal static class LayoutScenario
root.UpdateLayout();
await VerifyFooterAsync(root, service);
await QueryAboutScenario.RunAsync(root);
await EditionCaptureScenario.RunAsync(window, root);
var feedback = (InfoBar)root.FindName("FeedbackBar");
if(!feedback.IsOpen || !string.Equals(feedback.Title, "Request completed", StringComparison.Ordinal))
{
+3 -1
View File
@@ -62,7 +62,9 @@ internal static class Program
string profile = Path.Combine(EvidenceDirectory, "profile");
Directory.CreateDirectory(profile);
Environment.SetEnvironmentVariable("QQQ_PRINT_AGENT_DATA_DIR", profile);
File.WriteAllText(Path.Combine(profile, "settings.json"), "{\"ShowSplashScreen\":false,\"CheckForUpdatesOnStartup\":false}");
File.WriteAllText(Path.Combine(profile, "settings.json"), EditionCaptureScenario.IsEnabled
? "{\"ShowSplashScreen\":true,\"SplashMilliseconds\":4000,\"CheckForUpdatesOnStartup\":false}"
: "{\"ShowSplashScreen\":false,\"CheckForUpdatesOnStartup\":false}");
WinRT.ComWrappersSupport.InitializeComWrappers();
Microsoft.UI.Xaml.Application.Start(callbackParameters =>
{
@@ -139,7 +139,7 @@ internal static class QueryAboutScenario
/// <summary>Checks optional card contrast and retains only synthetic native-dialog pixels for visual review.</summary>
private static async Task CaptureAsync(FrameworkElement target, string fileName, bool requireContrast)
internal static async Task CaptureAsync(FrameworkElement target, string fileName, bool requireContrast)
{
var rendering = new RenderTargetBitmap();
await rendering.RenderAsync(target);
@@ -17,7 +17,7 @@ namespace Qqq.PrintAgent;
/// </summary>
internal sealed class SyntheticService
{
internal static PrinterSnapshot Printer { get; } = new("synthetic-zebra", "Synthetic Zebra-2", "ZDesigner ZT411-300dpi ZPL", "synthetic", "READY", ["PDF", "ZPL"], true, DisplayName: "Z2");
internal static PrinterSnapshot Printer { get; } = new("synthetic-zebra", "Synthetic Zebra-2", "ZDesigner ZT411-300dpi ZPL", "synthetic", "READY", ["PDF", "ZPL"], true, DisplayName: "Z2", ServerState: PrinterServerState.Approved);
internal int Submissions { get; private set; }
internal int Snapshots { get; private set; }
internal int JobQueries { get; private set; }
@@ -40,6 +40,7 @@ internal sealed class SyntheticService
"query-jobs" => Jobs(),
"transport" => ReadTransport(),
"test-print" => Submit(request),
"get-server-trust" => JsonSerializer.Serialize(new ServerTrust(), PrintApiClient.JsonOptions),
_ => throw new InvalidOperationException("The nonprinting fixture rejected an unexpected operation: " + request.Operation),
};
}
@@ -62,15 +63,16 @@ internal sealed class SyntheticService
string now = DateTime.UtcNow.ToString("O");
PrinterSnapshot[] printers =
[
new("synthetic-laser", "Synthetic Laser", "Generic laser", "synthetic", "READY", ["PDF"], true),
new("synthetic-pdf", "Microsoft Print to PDF", "Microsoft Print To PDF", "synthetic", "READY", [], false),
new("synthetic-note", "Synthetic Notes Printer", "Generic interactive", "synthetic", "READY", [], false),
new("synthetic-laser", "Synthetic Laser", "Generic laser", "synthetic", "READY", ["PDF"], true, ServerState: PrinterServerState.AwaitingApproval),
new("synthetic-pdf", "Microsoft Print to PDF", "Microsoft Print To PDF", "synthetic", "READY", [], false, ServerState: PrinterServerState.NotShared),
new("synthetic-note", "Synthetic Notes Printer", "Generic interactive", "synthetic", "READY", [], false, ServerState: PrinterServerState.NotShared),
_changedSnapshot ? Printer with { Description = ChangedDescription, Location = "Synthetic packing station, updated during dialog" } : Printer,
];
var snapshot = new AgentSnapshot(true, true, "GREEN", "Connected to synthetic server", "https://synthetic.invalid", "synthetic-device", now, printers, SyntheticJobs(now), [],
Control: new AgentControlState("SUPPORTED", new DeviceDesiredState(4, false, "CONNECTED"), LastContact: now,
LastCommand: _changedSnapshot ? "REFRESH_INVENTORY" : null, LastOutcome: _changedSnapshot ? "INVENTORY_REFRESHED" : null, LastCommandAt: _changedSnapshot ? now : null),
Authentication: new AgentAuthenticationStatus(true, "synthetic-user", "synthetic-tenant", "Signed in", true), Transport: Transport());
Authentication: new AgentAuthenticationStatus(true, "synthetic-user", "synthetic-tenant", "Signed in", true), Transport: Transport(),
PrinterSync: new PrinterServerSync(PrinterServerSync.Reported, now, null, "synthetic.invalid"));
return JsonSerializer.Serialize(snapshot, PrintApiClient.JsonOptions);
}
+1 -1
View File
@@ -404,7 +404,7 @@ public class AppTests
AutomationElement logWindow = session.WaitFor(
() => session.TopLevelWindows().FirstOrDefault(window => string.Equals(window.Name, title, StringComparison.Ordinal)),
"the activity log window");
string[] controls = ["Follow", "Errors only", "Copy", "Open logs folder"];
string[] controls = ["Follow", "Errors only", "Copy", "Open logs folder", "Clear logs"];
foreach(string control in controls)
{
session.WaitFor(() => logWindow.FindFirstDescendant(cf => cf.ByName(control)), "log toolbar control " + control);