Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a695996091
|
||
|
|
5d4b7f43ce
|
||
|
|
adf885beaf
|
||
|
|
c4b14963d3
|
||
|
|
a586ea7bf8
|
||
|
|
9906002d65
|
||
|
|
f7a9f79ab5
|
||
|
|
da43fa2918
|
||
|
|
b5831302d7
|
||
|
|
22b942b5e8
|
||
|
|
40872ab002
|
||
|
|
3b9d75d18f
|
||
|
|
6404922e1e
|
||
|
|
c8ea97e54d
|
||
|
|
73e36c61a3
|
||
|
|
f1ec4040ce
|
||
|
|
86c7a5e6d3
|
||
|
|
d401146985
|
||
|
|
887c58cd31
|
||
|
|
ac582e7bff
|
||
|
|
548d831d2c
|
||
|
|
c59814d713
|
||
|
|
0b9ded7bf7
|
||
|
|
397b60026a
|
||
|
|
abc3d0d66b
|
||
|
|
4ee980eb76
|
||
|
|
443f6429b3
|
||
|
|
9d52231d7e
|
||
|
|
f8b85ddd83
|
||
|
|
f954f11243
|
||
|
|
cd7c0f7e1f
|
||
|
|
e63cf02b06
|
||
|
|
eae4f40e1e
|
||
|
|
05f8fe0947
|
||
|
|
29ebef1e16
|
||
|
|
b51322cb58
|
||
|
|
aaa5051f8e
|
||
|
|
acb3238086
|
||
|
|
401bea6daf
|
||
|
|
5bf00f3291
|
||
|
|
a45fb97613
|
No files matched your search
+20
-10
@@ -12,10 +12,10 @@ on:
|
||||
inputs:
|
||||
product-version:
|
||||
type: string
|
||||
default: '1.0.0'
|
||||
default: '1.2.1'
|
||||
release-version:
|
||||
type: string
|
||||
default: '1.0.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.0.0' }}
|
||||
RELEASE_VERSION: ${{ inputs.release-version || '1.0.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
|
||||
|
||||
@@ -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,10 +151,47 @@ 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
|
||||
# package source mapping (nuget.config) rejects. Install only the SignCLI version it expects, through that same
|
||||
# mapped config, into the exact folder layout the action looks for; it then finds the tool and skips its install.
|
||||
- name: Install the pinned SignCLI through the repository NuGet configuration
|
||||
run: |
|
||||
$version = '0.9.1-beta.26227.3'
|
||||
$versions = Join-Path $env:LOCALAPPDATA 'ArtifactSigning\sign'
|
||||
$install = Join-Path $versions "sign.$version"
|
||||
$dll = Join-Path $install 'tools\net8.0\any\sign.dll'
|
||||
if (Test-Path -LiteralPath $dll -PathType Leaf) { Write-Output "SignCLI $version is already installed."; return }
|
||||
# Install into a unique temporary folder, then copy the package contents into the expected folder. Nothing
|
||||
# already under ArtifactSigning (other versions, shims) is deleted or moved.
|
||||
$temp = Join-Path $env:RUNNER_TEMP "signcli-$([guid]::NewGuid().ToString('N'))"
|
||||
dotnet tool install sign --version $version --tool-path $temp --configfile (Join-Path $env:GITHUB_WORKSPACE 'nuget.config')
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet tool install sign $version failed." }
|
||||
$store = Join-Path $temp ".store\sign\$version\sign\$version"
|
||||
if (-not (Test-Path -LiteralPath $store -PathType Container)) { throw "SignCLI $version package content was not found." }
|
||||
New-Item -ItemType Directory -Path $install -Force | Out-Null
|
||||
Copy-Item -Path (Join-Path $store '*') -Destination $install -Recurse -Force
|
||||
if (-not (Test-Path -LiteralPath $dll -PathType Leaf)) { throw "SignCLI $version is missing tools\net8.0\any\sign.dll." }
|
||||
- name: Azure login (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
@@ -149,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:
|
||||
@@ -165,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
|
||||
@@ -176,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:
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
This is QRun-IO/qqq-print-agent, built on KofTwentyTwo AppKit's Windows service variant. Follow the current user request and repository .editorconfig/central package versions.
|
||||
|
||||
- Verify with `./build/Verify.ps1`; package with `./build/Build-Installer.ps1 -ProductVersion 1.0.0 -ReleaseVersion 1.0.0`.
|
||||
- Gitea is primary for ordinary branches and pull requests. GitHub is the source shadow, with the authorized existing release/signing and CI/security workflows retained; the installed updater requires GitHub release assets. Do not remove that bridge as part of a source-host migration.
|
||||
- Verify with `./build/Verify.ps1`; package with `./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1`.
|
||||
- Core owns protocol/state/policy; Windows owns OS adapters/storage/runtime; Service owns hosting; UI owns native views/tray plumbing.
|
||||
- Zero compiler/analyzer warnings; locked dependencies. Coverage is reported for the initial prerelease under docs/adr/0002-prerelease-quality-and-packaging.md.
|
||||
- Keep the service alive independently of the companion. Use AppKit update UX with the machine MSI adapter; update service, companion and CLI together. Do not install a per-user-only replacement over the machine package.
|
||||
|
||||
@@ -1,5 +1,96 @@
|
||||
# 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,
|
||||
authentication metadata, native grant validation, provider discovery, browser callback handling,
|
||||
password sign-in, session restore and renewal, identity checks, device binding and the realtime
|
||||
socket upgrade, and says exactly which server-side requirement is missing when it refuses a server;
|
||||
the service logs each sign-in, sign-out, enrollment, authorization and reconnect step and every
|
||||
failure with its inner causes; the companion logs its side and shows the failure reason in the
|
||||
dialog instead of a generic message. TLS certificate rejections are logged with the policy error,
|
||||
certificate identity and remedy. A new **Allow untrusted server certificate for this server** option (Connection
|
||||
settings, bound to one server origin, persisted in the protected store, honored by the service and
|
||||
companion, logged with a warning on every session) supports development servers with self-signed or
|
||||
private-authority certificates without weakening any other server connection.
|
||||
Log settings gain **Debug logging** (request routes, status codes, timings, realtime frames and
|
||||
full failure detail; never credentials) and replace the daily cap that silenced the log with size
|
||||
rollover: a file that reaches the limit (default 100 MiB, up to 1024) becomes a numbered segment and
|
||||
logging continues, keeping at most a configurable number of segments per day (default 10). No wire
|
||||
change; native printing stays 2.3 with wire payload v1.
|
||||
|
||||
## 1.0.1
|
||||
|
||||
Packaging fix; no application, wire or dependency change. The `v1.0.0` release run passed
|
||||
every build, test, security, CodeQL and script gate and the Azure OIDC login, then stopped
|
||||
before signing: the Artifact Signing action installs its SignCLI tool with
|
||||
`dotnet tool install --add-source`, which the repository's NuGet package source mapping
|
||||
rejects. No signed 1.0.0 installer was published and that tag is unchanged. The release
|
||||
workflow now installs only the pinned SignCLI (`0.9.1-beta.26227.3`) through the mapped
|
||||
repository `nuget.config` into the exact folder the action expects, before the first signing
|
||||
step, so the action finds it and the mapping is not weakened. Native printing stays 2.3 with
|
||||
wire payload v1.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Product version 1.0.0 for the same cohort as 0.4.0-rc.4. A stable `v1.0.0` tag runs the
|
||||
|
||||
@@ -1,3 +1,95 @@
|
||||
# Agent guidance
|
||||
# CLAUDE.md
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for repository instructions and [CONTRIBUTING.md](CONTRIBUTING.md) for development conventions.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Policy lives in [AGENTS.md](AGENTS.md) (repository rules: hosting split, authorization, logging, printing) and [CONTRIBUTING.md](CONTRIBUTING.md) (conventions, script quality). Read both; this file adds the commands and architecture they leave implicit.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Windows x64, PowerShell 7.4+, .NET SDK pinned by `global.json` (10.0.x).
|
||||
- `qqq-print-protocol` checked out as a **sibling directory** at the SHA pinned in `.github/workflows/ci.yml`. Only `Core.Tests` consumes it (it links `../qqq-print-protocol/fixtures/*.json` at build time; override with `-p:PrintingProtocolFixtures=<dir>` or `Verify.ps1 -ProtocolFixtures`), so the NativeCI solution builds without it.
|
||||
- Scripts tooling: `uv sync --locked` and `npm ci --ignore-scripts` (Node 24+).
|
||||
|
||||
## Commands
|
||||
|
||||
**Host restriction first.** `Qqq.PrintAgent.Core.Tests` is hosted-CI-only. On this development PC, Windows Defender quarantines the Core test assembly, so that project must not be built or run locally (see `docs/SESSION-STATE.md`; do not add exclusions or restore quarantine to work around it). Local work uses `Qqq.PrintAgent.NativeCI.slnx`, which is the full solution minus `Core.Tests`. Core test evidence comes from the hosted Windows CI run on the pushed commit.
|
||||
|
||||
Local default (locked restore, Release x64 build with `-warnaserror`, Windows/CLI tests), always with `-p:Platform=x64` because the solution maps test projects to AnyCPU and a direct `dotnet test` without it picks stale output. Never pass `--no-build` to `dotnet test`: the solution build writes the test DLL to the AnyCPU folder, so an x64 `--no-build` run executes whatever old x64 DLL is lying around and reports a pass against stale code.
|
||||
|
||||
```powershell
|
||||
dotnet restore Qqq.PrintAgent.NativeCI.slnx -p:Platform=x64 --locked-mode
|
||||
dotnet build Qqq.PrintAgent.NativeCI.slnx -c Release -p:Platform=x64 --no-restore -warnaserror
|
||||
dotnet test tests/Qqq.PrintAgent.Cli.Tests -c Release -p:Platform=x64 --no-restore
|
||||
```
|
||||
|
||||
Single test (xunit):
|
||||
|
||||
```powershell
|
||||
dotnet test tests/Qqq.PrintAgent.Cli.Tests -p:Platform=x64 --filter "FullyQualifiedName~AgentPipeSecurityTests"
|
||||
```
|
||||
|
||||
Full verification, **only on a permitted host or hosted CI** (adds Core.Tests, coverage and TRX output under `TestResults/verify-<guid>`; requires the sibling protocol checkout):
|
||||
|
||||
```powershell
|
||||
./build/Verify.ps1
|
||||
```
|
||||
|
||||
Packaging (unsigned self-contained x64 MSI; never installs or prints):
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
./build/Package-Release.ps1 # runs the CI packaging checks and assembles artifacts/release
|
||||
```
|
||||
|
||||
Script quality gates (CI runs the same):
|
||||
|
||||
```powershell
|
||||
./build/Analyze-Scripts.ps1 # PSScriptAnalyzer 1.25 over build/*.ps1, one rule at a time
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
python .github/scripts/check-source-headers.py # owned-file copyright/SPDX notices
|
||||
npm run typecheck; npm run lint; npm test # tools/virtual-agent (Node, ESLint Allman)
|
||||
```
|
||||
|
||||
Interactive-only suites (not run by hosted CI):
|
||||
|
||||
- **UI tests**: elevated interactive desktop; build the companion, optionally set `K22_UITEST_EXE`, then `dotnet test tests/Qqq.PrintAgent.UiTests -p:Platform=x64`.
|
||||
- **Layout tests**: `tests/Qqq.PrintAgent.LayoutTests/Run.ps1 -EvidenceDirectory <new dir>`; synthetic service only, not in the solution file.
|
||||
|
||||
Pre-commit hooks (`pre-commit install --hook-type pre-commit --hook-type commit-msg`) enforce Conventional Commit types, DCO sign-off (`git commit -s`), gitleaks, and no direct commits to `main`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Five projects, strict layering (Core → Windows → Service/UI/CLI). Business logic never lives above Core; OS calls never live in Core.
|
||||
|
||||
| Project | Owns | Key entry points |
|
||||
| --- | --- | --- |
|
||||
| `Qqq.PrintAgent.Core` | QQQ session auth, HTTP/WSS clients, realtime settings/policy, document streaming, job state machine | `Authentication/QqqSessionClient*`, `Printing/PrintApiClient*`, `Printing/PrintWorker`, `Printing/PrintingRealtimeChannel` |
|
||||
| `Qqq.PrintAgent.Windows` | Spooler/PDF/ZPL backends, SQLite + DPAPI store, named-pipe IPC, the long-running runtime | `AgentRuntime.*.cs` (partial class split by concern), `AgentPipe*`, `AgentStore` |
|
||||
| `Qqq.PrintAgent.Service` | Windows Service host only | `PrintService.cs` |
|
||||
| `Qqq.PrintAgent` | WinUI/AppKit companion: tray, Printers/Jobs/Logs/Settings views, MSI update UX | `MainWindow.*.cs` (partial class split by concern) |
|
||||
| `Qqq.PrintAgent.Cli` | Authenticated service management over the pipe; isolated legacy diagnostics | `Program.cs` |
|
||||
|
||||
Large classes are deliberately split into partial files by concern (`AgentRuntime.Realtime.cs`, `AgentRuntime.Jobs.cs`, `MainWindow.Updates.cs`, ...). Add new behavior to the matching partial rather than growing `AgentRuntime.cs` / `MainWindow.xaml.cs`.
|
||||
|
||||
**Runtime model.** The service is the only process holding credentials, the journal and printer access. It opens an outbound authenticated WebSocket (`/qqq/v1/qbit/printing/device/realtime`, subprotocol `qqq-printing.v1`) for commands, status and job notifications; documents stream separately over HTTPS into a protected staging directory with length + SHA-256 verification. There is intentionally **no polling fallback**. The companion and CLI talk to the service only through the `QqqPrintAgent` named pipe (administrator clients) and receive credential-free snapshots. Printing runs in an isolated child process with a deadline. See `docs/REALTIME.md` for the worker split, bounds and reconnect rules.
|
||||
|
||||
**Reliability boundary.** `PrintWorker` persists receipt and `SUBMITTING` before calling Windows, records `SPOOLED` plus the spooler job ID afterward, and replays pending cloud events. A crash between spool acceptance and recording is not atomic; the worker reports `OUTCOME_UNKNOWN` rather than auto-reprinting. Preserve this distinction in any retry/cancel change, and keep "spool accepted" and "paper came out" separate in docs and messages.
|
||||
|
||||
**Versions.** Product version (`VersionPrefix` in `Directory.Build.props`, release workflow overrides with `-p:Version`), native printing API (2.3), wire payload (v1) and the WebSocket subprotocol are independent. Do not conflate them when bumping.
|
||||
|
||||
**Updates.** `MsiUpdateService` + `GitHubMsiReleaseClient` read the GitHub release feed and apply a machine MSI through Windows Installer, updating service, companion and CLI together. That GitHub feed is why the GitHub shadow must stay even though Gitea is primary.
|
||||
|
||||
## Test layout
|
||||
|
||||
- `tests/Qqq.PrintAgent.Core.Tests`: protocol fixtures (`ProtocolFixtureTests`), loopback WebSocket peer, worker state machine, media contracts. Hosted CI only on this PC (see Commands); new Core tests are written here and validated by the CI run.
|
||||
- `tests/Qqq.PrintAgent.Cli.Tests`: despite the name, this is the Windows runtime suite (`AgentRuntime*`, `AgentRealtime*`, `AgentPipe*`, `AgentStore*`). Put Windows-layer tests here.
|
||||
- `tests/Qqq.PrintAgent.WorkerFixture`: child-process fixture used by runtime tests.
|
||||
|
||||
## Conventions worth knowing
|
||||
|
||||
- `.editorconfig`: 3-space indentation, Allman braces, everywhere (C#, PowerShell, scripts). Analyzers run in build; zero warnings is enforced by `TreatWarningsAsErrors`.
|
||||
- Every owned source file starts with the `Copyright (c) 2026 James Maes` / `SPDX-License-Identifier: MIT` header; `check-source-headers.py` fails otherwise. `tools/virtual-agent` keeps its QRun.IO attribution.
|
||||
- Package versions are central (`Directory.Packages.props`); after changing them, regenerate each affected `packages.lock.json` (`dotnet restore --force-evaluate`).
|
||||
- Coverage is reported, not gated, per `docs/adr/0002-prerelease-quality-and-packaging.md`.
|
||||
- `docs/SESSION-STATE.md` records the current release/verification state; consult it before claiming anything is verified.
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
# Contributing
|
||||
|
||||
QQQ Print Agent is developed in the [QRun-IO organization](https://github.com/QRun-IO). It uses the KofTwentyTwo Windows AppKit service architecture. Contributions are accepted under the [MIT license](LICENSE); preserve existing attribution.
|
||||
QQQ Print Agent is developed in the [QRun-IO organization on Gitea](https://git.kof22.com/QRun-IO). It uses the KofTwentyTwo Windows AppKit service architecture. Contributions are accepted under the [MIT license](LICENSE); preserve existing attribution.
|
||||
|
||||
## Set up
|
||||
|
||||
@@ -8,13 +8,15 @@ Use Windows x64, Git and the .NET 10 SDK selected by `global.json`. Check out `q
|
||||
|
||||
```powershell
|
||||
./build/Verify.ps1
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.0 -ReleaseVersion 1.0.0
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
```
|
||||
|
||||
Verification uses locked NuGet restore, a warnings-as-errors solution build, core tests, Windows/CLI tests and a coverage report. Packaging creates a self-contained x64 MSI. Neither command installs the service or prints a document.
|
||||
|
||||
## Changes and review
|
||||
|
||||
Publish ordinary branches and pull requests to the [primary Gitea repository](https://git.kof22.com/QRun-IO/qqq-print-agent). GitHub is a source shadow; the existing authorized release/signing workflow and CI/security workflows remain there. GitHub release assets also serve the installed updater. See [Releasing](docs/RELEASING.md) before changing either distribution path.
|
||||
|
||||
- Branch from `main`, use Conventional Commit titles, and include a DCO sign-off (`git commit -s`). Sign commits where your account supports signing.
|
||||
- Keep business/protocol logic in Core, Windows integration in Windows, hosting in Service, and view/tray plumbing in the AppKit companion.
|
||||
- Use central versions in `Directory.Packages.props`; regenerate affected `packages.lock.json` files when dependencies change.
|
||||
@@ -34,7 +36,7 @@ A passing unit suite does not establish printer compatibility. Record Windows ve
|
||||
|
||||
## Issues and releases
|
||||
|
||||
Open ordinary bugs or proposals in [Issues](https://github.com/QRun-IO/qqq-print-agent/issues). Use [private security reporting](SECURITY.md) for vulnerabilities. Release authorization and tagging belong to maintainers; see [RELEASING.md](docs/RELEASING.md).
|
||||
Open ordinary bugs or proposals in [Issues](https://git.kof22.com/QRun-IO/qqq-print-agent/issues). Use [private security reporting](SECURITY.md) for vulnerabilities. Release authorization and tagging belong to maintainers; see [RELEASING.md](docs/RELEASING.md).
|
||||
|
||||
## Script quality
|
||||
|
||||
|
||||
@@ -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.0.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>
|
||||
|
||||
@@ -4,57 +4,40 @@
|
||||
<!-- markdownlint-disable-next-line MD033 -->
|
||||
<img src="src/Qqq.PrintAgent/Assets/Brand/app-preview.png" width="96" alt="QQQ Print Agent cloud and printer icon">
|
||||
|
||||
[](https://github.com/QRun-IO/qqq-print-agent/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
Print from **QQQ 4.1+** to printers at a local facility without opening inbound network ports. QQQ Print Agent runs as a Windows service, connects outward to QQQ, and submits documents to the printers installed for its Windows service account.
|
||||
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.
|
||||
|
||||
The native setup and status application uses the same [KofTwentyTwo AppKit](https://github.com/KofTwentyTwo/AppKit) foundation as GCLO and MarkdownViewr.Windows.
|
||||
**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`.
|
||||
|
||||
> **Earlier published prerelease (historical evidence): [0.3.2-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.2-rc.1).** All four assets, checksums and provenance attestations were verified. The real in-app update completed with matching app/service/CLI builds, retained identity and history, and an automatically reopened Connected app. The installer remains unsigned.
|
||||
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.
|
||||
|
||||
> **Version 1.0.0.** Stable releases are built by the release workflow, which signs the service, companion and CLI binaries and the MSI with Azure Artifact Signing and publishes checksums and provenance generated after signing ([signing](docs/RELEASE-SIGNING.md)). Earlier release candidates were unsigned. 1.0 is a product version only: native printing stays 2.3 with wire payload v1. The cohort adds optional printer-media selection against printing extension 2.3+ servers (2.2 is rendering only and stays a three-capability peer), the 4 × 2 and 4 × 1 test labels, one phase-timing log line per job, and the completed source documentation. Physical acceptance covers 4 × 6, Letter and 4 × 1; 4 × 2 is deferred. See [release readiness](docs/RELEASE-READINESS.md).
|
||||
## Install and connect
|
||||
|
||||
**0.3.2-rc.1** checks the app, service and CLI for the same complete
|
||||
build version after an update, reports an inconsistent installation explicitly,
|
||||
and corrects the SDK license credit. Its native test host now uses production's
|
||||
PerMonitorV2 DPI declaration. The original 0.3.0 layout-cycle cause remains
|
||||
unproven; current operator acceptance and repeated native replays pass. The
|
||||
existing fatal-error handling is a mitigation, not an underlying layout fix.
|
||||
See [changelog](CHANGELOG.md) and [session state](docs/SESSION-STATE.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.
|
||||
5. In **Printers**, choose which queues to share. Enable the intended discovered queue in QQQ, then submit an authorized test print matched to the loaded paper or labels.
|
||||
|
||||
The operator used **Help → Check for updates**, chose **Not now** and confirmed
|
||||
the app remained responsive, then selected **Update and restart**. Independent
|
||||
before/after reports verified the published 0.3.2 bytes and preserved earlier
|
||||
jobs, pairing and profiles; the operator confirmed the reopened Connected app
|
||||
and both printers. Successful paper output was previously confirmed on
|
||||
**0.3.1-rc.1** and remains separate evidence; see [acceptance notes](docs/ACCEPTANCE.md).
|
||||
The service uses machine-installed printers available to its LocalSystem account. A printer mapped only in a user's profile may be unavailable. The QQQ endpoint needs outbound HTTPS and WebSocket upgrades; HTTP/WS is accepted only for loopback development. No inbound firewall rule is installed.
|
||||
|
||||
The 0.3 client requires discovered printing extension `>=2.0.0 <3.0.0` and standard QQQ sign-in. Upgrade the server before adopting it; a 1.0 server cannot provide realtime printing. The service opens authenticated WSS for controls, receipts, status and job notifications, while documents stream separately over HTTPS. There is no job/control polling or fallback. Existing unbound pairings require explicit account association, not automatic replacement. See [realtime operation](docs/REALTIME.md) and [sign-in and migration](docs/QQQ-SIGN-IN.md). Real Authentik login and renewal passed in isolated development containers against an unreleased QQQ API 1.9 candidate. Released RC.20 lacks that native-session grant; installed browser/private-callback acceptance still requires a compatible published runtime and configured HTTPS provider.
|
||||
See [User guide](docs/USER-GUIDE.md), [QQQ sign-in](docs/QQQ-SIGN-IN.md) and [Troubleshooting](docs/TROUBLESHOOTING.md).
|
||||
|
||||
## Current source features
|
||||
## Daily use
|
||||
|
||||
- Discover Windows print queues and explicitly choose which ones to expose to QQQ.
|
||||
- Use native Printers, Jobs, Logs and Settings navigation, persistent status, reconnect and a visible health tray icon.
|
||||
- Browse compact printer/job tables with full details and confirmed test/reprint/cancel actions. Expand a selected row for printer information or recent job history; double-click for printer settings or full job details.
|
||||
- Sort table headers in either direction. Printers default to Name ascending; jobs default to immutable Created time descending. Job sorting applies across the matching result set before paging.
|
||||
- Page through local history in groups of 50, searching by job ID, document-title or printer-name prefix.
|
||||
- Edit computer/printer names, descriptions and locations locally and synchronize them to QQQ.
|
||||
- Configure daily log retention/size limits, inspect usage and explicitly clear diagnostic logs.
|
||||
- Start/restart the service and opt into tray startup at sign-in; service and companion share the AppKit log viewer.
|
||||
- Check for updates manually or at startup using AppKit; confirm a machine MSI upgrade of the service, companion and CLI together.
|
||||
- Set theme and splash duration, and open project/help/release links and dependency credits.
|
||||
- Print packing slips and other PDFs through the installed Windows driver.
|
||||
- Send printer-ready ZPL to Zebra/ZDesigner queues.
|
||||
- Generate one QQQ-branded warehouse diagnostic at a time: Letter, A4, or 4 × 6, 4 × 2 or 4 × 1-inch PDF; 4 × 6, 4 × 2 or 4 × 1-inch ZPL at 203, 300 or 600 dpi. Native choices prefer ZPL on compatible label queues, with a driver-name DPI suggestion that the operator verifies.
|
||||
- Sign in using the QQQ server's advertised authentication, then associate an existing pairing or enroll a new device with a short-lived code; the service protects its session and secondary device credential.
|
||||
- Keep printing when the desktop app is closed or the user logs out.
|
||||
- Preserve job receipts and outgoing status events across service restarts.
|
||||
- Avoid automatically printing a job again when a previous submission is uncertain.
|
||||
- **Printers:** share queues, sort columns, expand a selected row, or double-click to edit its name, description and location. The same metadata is reported to QQQ.
|
||||
- **Jobs:** sort and search paged history, expand a selected row for recent events, or double-click for full details. Printers default to Name ascending; jobs default to Created descending. Reprint and cancellation actions require confirmation.
|
||||
- **Logs:** service and companion events share the AppKit viewer, with live follow, error filtering, copying and folder access. Settings controls daily rotation, retention, size limits and clearing diagnostic logs.
|
||||
- **Settings:** select the server and sign in, edit computer details, control the Windows service, choose theme and startup preferences, or check for updates.
|
||||
- **Footer and tray:** show connection/printer health. The footer exposes the server address, reconnect/log shortcuts and live application upload/download counters. Printer status is an observation from Windows, not proof of paper output.
|
||||
- **Background printing:** the Windows service keeps working when the companion is closed or the user logs out. Optional tray startup is a separate setting.
|
||||
|
||||
QQQ can see devices, exposed printers, status, jobs and queue history. Its authorized device actions control dispatch, disconnect/reconnect operation, refresh inventory and revoke credentials. Current QQQ user, tenant, table, process and record permissions apply to client operations; a device token does not replace user authority.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q[QQQ 4.1+ printing QBit] <-->|Client-initiated WSS: controls and receipts| S[Windows service]
|
||||
Q[QQQ printing QBit] <-->|Client-initiated WSS: commands and receipts| S[Windows service]
|
||||
Q -->|Separate streamed HTTPS documents| S
|
||||
U[AppKit setup and status app] -->|Local administrator pipe| S
|
||||
S --> W[Windows spooler]
|
||||
@@ -62,88 +45,59 @@ flowchart LR
|
||||
W --> Z[Zebra printer: ZPL]
|
||||
```
|
||||
|
||||
The companion projects are [qbit-printing](https://github.com/QRun-IO/qbit-printing), the QQQ server integration, and [qqq-print-protocol](https://github.com/QRun-IO/qqq-print-protocol), the shared versioned API contract. The agent does not require a separate cloud printing vendor.
|
||||
## Documents and printer media
|
||||
|
||||
## Install and connect
|
||||
The service prints PDFs through the Windows driver and sends printer-ready ZPL to compatible Zebra/ZDesigner queues. Test prints offer Letter and A4 PDF, plus 4 × 6, 4 × 2 and 4 × 1-inch PDF/ZPL labels; ZPL offers 203, 300 and 600 dpi. Match the actual stock and printer resolution before printing. Query branding, warehouse-readable characters and alignment marks make the diagnostics easy to identify.
|
||||
|
||||
1. Download the x64 MSI and `SHA256SUMS` from [Releases](https://github.com/QRun-IO/qqq-print-agent/releases). Verify the checksum before installing.
|
||||
2. Install the MSI with administrator rights. It installs the service and adds **QQQ Print Agent**, or your configured customer label, under **Print Connectors** in the Start menu.
|
||||
3. Open **QQQ Print Agent**, accept the administrator prompt and use **Settings → Sign in / Connection settings** to discover the QQQ server's sign-in options and authenticate.
|
||||
4. For a new pairing, create an enrollment code in QQQ and enter it after sign-in. For an existing unbound pairing, explicitly associate it with your account; do not create a replacement merely to upgrade.
|
||||
5. In **Printers**, select which queues to expose to QQQ. Then enable the intended discovered queue in QQQ and submit an authorized test document.
|
||||
The client reports the Windows queue's machine-default media as a revision-bound profile. It does not detect which stock is physically loaded. Selected media is checked before submission; a stale selection is refused. PDF jobs can instead request an explicit width/height pair for that job without changing the queue defaults. The short-label PDF tests use this path. See [Printer media](docs/PRINTER-MEDIA.md).
|
||||
|
||||
The QQQ URL must use HTTPS, with outbound WebSocket upgrades allowed to the same origin. HTTP/WS is accepted only for explicit loopback development. No inbound firewall rule is installed. Existing outbound proxy/network policies and local printer connectivity still apply. The previous 0.2 installer uses its earlier HTTP transport; coordinate the 0.3 server and client upgrade because native API 2.0 requires an active synchronized channel for claims.
|
||||
`SPOOLED` means Windows accepted the job; it does not prove that paper came out. `OUTCOME_UNKNOWN` means a submission may have reached the printer. Inspect the output before requesting another copy. Retained receipts prevent uncertain jobs from being silently printed again.
|
||||
|
||||
See the [installation guide](docs/INSTALLATION.md) for verification, service identity, upgrade and removal instructions, and the [user guide](docs/USER-GUIDE.md) for the setup screen and job statuses.
|
||||
## Requirements and limits
|
||||
|
||||
## Requirements and supported behavior
|
||||
|
||||
| Area | 0.3 release candidate behavior |
|
||||
| Area | Stable 1.2.1 behavior |
|
||||
| --- | --- |
|
||||
| Server | QQQ 4.1+ with printing semantic extension 2.0 and wire-v1 payloads |
|
||||
| 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+ |
|
||||
| Runtime | Bundled with the MSI; users do not install .NET separately |
|
||||
| Printers | Installed Windows queues accessible to LocalSystem, the default service account |
|
||||
| Documents | PDF and ZPL; administrator defaults are 100 MiB PDF and 20 MiB ZPL, with validated configurable limits |
|
||||
| PDF options | Copies, actual size or fit; queue-default paper/tray settings |
|
||||
| ZPL options | Copies; label dimensions/DPI encoded in printer-ready ZPL |
|
||||
| Transport | Persistent outbound authenticated WSS plus separate same-origin HTTPS streams; no polling fallback |
|
||||
| Capacity | Configurable bounded download/print lanes, staging, bandwidth and timeouts; one download/print lane by default and ordered submission per printer |
|
||||
| Printers | Installed Windows queues accessible to the service account |
|
||||
| Documents | PDF and ZPL; default limits 100 MiB PDF and 20 MiB ZPL, configurable by authorized QQQ administrators |
|
||||
| Transport | Outbound authenticated WSS plus same-origin HTTPS document streams, with automatic reconnect |
|
||||
| Capacity | Bounded download/print concurrency, staging, bandwidth and timeouts; one download and one print lane by default, ordered per printer |
|
||||
| Local history | 50-row pages; default detailed retention 30 days or latest 300,000 detailed jobs, with active/uncertain evidence and duplicate-suppression records retained |
|
||||
| Diagnostic logs | Daily rotation plus size rollover to numbered segments; defaults 30 days, 100 MiB per file and 10 segments per day, with optional debug logging, configurable in the client |
|
||||
|
||||
A printer visible only in a signed-in user's profile may not be available to the service. Configure the printer for the machine and test through the installed service. Virtual PDF/fax queues are not suitable for unattended physical printing.
|
||||
The client supports native printing extension `>=2.0.0 <3.0.0`; optional media negotiation requires stable extension 2.3 or later in that major. Limits are resource bounds, not a printer-throughput guarantee. See [Realtime settings](docs/REALTIME.md) and [History and log limits](docs/SCALABILITY.md).
|
||||
|
||||
`SPOOLED` means Windows accepted the job. It does **not** prove that paper came out. If the agent cannot determine whether a previous submission succeeded, it reports `OUTCOME_UNKNOWN`; an operator should inspect the printer before requesting another copy.
|
||||
## Updates and customer branding
|
||||
|
||||
## Validation so far
|
||||
Use **Help → Check for updates** or the same button in Settings. Startup checks are optional; installation requires confirmation and updates the service, companion and CLI together. Stable installations receive newer stable releases. The installed updater reads the project's [GitHub release feed](https://github.com/QRun-IO/qqq-print-agent/releases); this feed remains available alongside the primary Gitea releases.
|
||||
|
||||
The 0.3.2 Release gate passed **426 tests** with zero warnings/errors/skips.
|
||||
[Release workflow 41324312106](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41324312106)
|
||||
completed all nine jobs and published the release for the signed tag at protected main
|
||||
`268f951b6728b0e69fedaf46769785c3f5ae55f4`. All four release assets and their
|
||||
attestations passed verification. Static MSI inspection confirmed product
|
||||
version `0.3.2`, matching app/service/CLI source versions, WinUI resources,
|
||||
service configuration and callback registration. The separately observed
|
||||
0.3.1-to-0.3.2 in-app update returned MSI exit 0, restored the service's WebSocket
|
||||
and automatically reopened the companion. Actual failed/canceled updates,
|
||||
restart-required/reboot behavior and installed provider-browser acceptance
|
||||
remain open. See [session state](docs/SESSION-STATE.md).
|
||||
Customers can supply a name, company, support link, colors and local artwork during installation using the shared MSI and `Install-Branded.ps1`. No custom build is needed. Branding preserves the software's signing identity, protocol and update feed. See [Customer branding](docs/BRANDING.md).
|
||||
|
||||
### Installed published 0.2.0 — 2026-10-31
|
||||
## Development and contributions
|
||||
|
||||
The exact published 0.2.0-rc.1 MSI installed successfully with pairing, printer exposure and history retained. Native QQQ table-based sign-in, existing-device association, server controls and inventory refresh succeeded. The user confirmed that the Letter PDF and 4 × 6-inch Zebra ZPL printed correctly, and manually confirmed the app's Connected footer and both printers. This is local 0.2 acceptance, not installed or physical-output evidence for 0.3, Authentik or a remote production deployment. See [acceptance notes](docs/ACCEPTANCE.md).
|
||||
Source, branches, issues and pull requests are maintained on [Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent). GitHub is the automatic source shadow and the existing authorized signing/update distribution bridge; do not push ordinary source changes there.
|
||||
|
||||
### Historical 0.1.9 build and installed checks
|
||||
|
||||
The 0.1.9 source gate passed **255 tests (66 Core + 189 Windows/CLI)** with zero warnings/errors, and the full solution/UI build passed. [CI build 41185716708](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41185716708) and the required security, CodeQL and script gates passed. The CI MSI upgraded the installed service, companion and CLI with exit 0 and no reboot, preserving protected credentials and journal history. Authenticated management, connection and history checks succeeded; all **16 installed UI checks passed in one run**. No print was submitted by the upgrade or UI tests.
|
||||
|
||||
[Release workflow 41190815995](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41190815995) published v0.1.9-rc.1 from protected main revision `cf0e362`. The MSI, `SHA256SUMS`, `Install-Branded.ps1` and `branding.example.json` were verified, including provenance attestations for all four assets. The published MSI differs from the installed CI MSI; runtime sources are unchanged, but the release package has not been separately reinstalled. Their full hashes and verification scopes are in [acceptance notes](docs/ACCEPTANCE.md).
|
||||
|
||||
These results cover the local installed service/app upgrade. They do not establish remote deployment, Windows reboot/recovery, updater-initiated elevation, hostile-tree rollback, broad driver qualification or every accessibility scenario. Build attestations establish provenance, not publisher signing; the MSI remains unsigned. The earlier clean service stop still lacks a confirmed cause.
|
||||
|
||||
### Historical 0.1.7 hardware acceptance — 2026-10-30
|
||||
|
||||
The user confirmed laser PDF, Zebra ZPL and Zebra PDF output on 0.1.7. The first Zebra PDF was accepted by the spooler but did not print until machine defaults were corrected to 4 × 6-inch direct thermal and the hardware pause was cleared. A fresh reviewed PDF then printed fully and correctly. Barcode scanning and measured alignment remain unverified. This dated evidence does not claim fresh physical output from 0.1.9; the [configuration diagnosis and earlier results](docs/ACCEPTANCE.md) retain their original scope.
|
||||
|
||||
## Development
|
||||
|
||||
Requires Windows x64 and the .NET 10 SDK selected by `global.json`. Check out this repository and the protocol repository as siblings; use the pinned protocol commit `42c0d249f81395f00408ec0f92638dcbbf5deb03` from `.github/workflows/ci.yml` ([Gitea PR 2](https://git.kof22.com/QRun-IO/qqq-print-protocol/pulls/2)).
|
||||
Use Windows x64 and the .NET 10 SDK selected by `global.json`. Clone the protocol repository beside the client and check out the fixture commit pinned by CI:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/QRun-IO/qqq-print-agent.git
|
||||
git clone https://github.com/QRun-IO/qqq-print-protocol.git
|
||||
git clone https://git.kof22.com/QRun-IO/qqq-print-agent.git
|
||||
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.0 -ReleaseVersion 1.0.0
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.2.1 -ReleaseVersion 1.2.1
|
||||
```
|
||||
|
||||
[Realtime architecture and settings](docs/REALTIME.md) · [History and log limits](docs/SCALABILITY.md) · [Customer branding](docs/BRANDING.md) · [Developer guide](docs/DEVELOPMENT.md) · [Administrator guide](docs/ADMINISTRATION.md) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [Release process](docs/RELEASING.md) · [Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
|
||||
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.
|
||||
|
||||
## Current limits
|
||||
[Contributing](CONTRIBUTING.md) · [Developer guide](docs/DEVELOPMENT.md) · [Administrator guide](docs/ADMINISTRATION.md) · [Release process](docs/RELEASING.md) · [Security policy](SECURITY.md)
|
||||
|
||||
Realtime transport requires a matching server and network path that permits WSS; unsupported servers and failed connections do not enable polling. Custom PDF paper dimensions are rejected; use the Windows queue's default media. Physical completion telemetry, custom media and production signing remain outside the deployment scope. Update checks are implemented; installation requires operator confirmation and administrator rights. Configurable limits are resource bounds, not printer-throughput guarantees. Review the [roadmap](docs/TODO.md) before a production rollout.
|
||||
## Release evidence
|
||||
|
||||
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
|
||||
|
||||
[MIT](LICENSE). The original Windows template and AppKit copyright notices are preserved. See [NOTICE](NOTICE) for the foundation and bundled dependency acknowledgments. QQQ and the printing QBit are separate projects with their own licenses.
|
||||
|
||||
Microsoft Windows SDK components retain their [separate terms and distribution scope](docs/WINDOWS-SDK-LICENSES.md). The installed App directory includes all five distinct SDK license texts and their notices; these components are not relicensed as MIT.
|
||||
[MIT](LICENSE). Original Windows template and AppKit notices are preserved in [NOTICE](NOTICE). QQQ and its printing QBit are separate projects with their own licenses. Microsoft Windows SDK components retain their [separate terms](docs/WINDOWS-SDK-LICENSES.md); the installed app includes their license texts and notices.
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
Do not publish a security vulnerability as a normal issue. Use [GitHub private vulnerability reporting](https://github.com/QRun-IO/qqq-print-agent/security/advisories/new). Include the affected version, reproduction steps, impact and sanitized evidence. Do not send live enrollment codes, device credentials, customer documents or local print databases.
|
||||
|
||||
The initial release is a prerelease for controlled evaluation. Fixes target the latest prerelease; there is no long-term support commitment or independently audited production security certification.
|
||||
Fixes target the latest stable 1.0 release. There is no long-term support commitment or independently audited production security certification. Gitea is the primary source repository; the established private reporting channel above remains available on GitHub.
|
||||
|
||||
## Trust and privileges
|
||||
|
||||
@@ -16,6 +16,6 @@ Remote QQQ endpoints require HTTPS. Plain HTTP is allowed only on loopback for d
|
||||
|
||||
## Distribution
|
||||
|
||||
The v0.1.0-rc.1 MSI and first-party executables are **unsigned**. Download from the official repository release and compare SHA256SUMS. No Authenticode identity or provenance attestation is claimed. Production signing, renderer process isolation, broader driver testing and operational hardening remain open work.
|
||||
Stable **1.0.1** publishes an Azure Artifact Signing-signed MSI and first-party executables, `SHA256SUMS` and build provenance. Download from the [official release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) and verify both checksum and Authenticode signature. The installed updater uses the matching GitHub release assets. Earlier unsigned prereleases retain their historical status; signing does not establish printer compatibility or make arbitrary printer documents safe. See [Installation](docs/INSTALLATION.md) and [release signing](docs/RELEASE-SIGNING.md).
|
||||
|
||||
See [the threat model](docs/security/threat-model.md) for implemented controls and residual risks.
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
# Support
|
||||
|
||||
Use [GitHub Issues](https://github.com/QRun-IO/qqq-print-agent/issues/new/choose) for bugs, setup questions and feature requests. Check the installation and troubleshooting guides first. Include version, Windows build, printer/driver and sanitized error details.
|
||||
Use [Gitea Issues](https://git.kof22.com/QRun-IO/qqq-print-agent/issues) for bugs, setup questions and feature requests. Check the [installation](docs/INSTALLATION.md) and [troubleshooting](docs/TROUBLESHOOTING.md) guides first. Include version, Windows build, printer/driver and sanitized error details.
|
||||
|
||||
Do not attach enrollment codes, credentials, customer documents or local journals. Report vulnerabilities privately as described in SECURITY.md. This prerelease does not provide a commercial support SLA.
|
||||
Do not attach enrollment codes, credentials, customer documents or local journals. Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md). No commercial support SLA is implied.
|
||||
@@ -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
|
||||
@@ -5,14 +5,18 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
||||
[string] $ProductVersion = '1.0.0',
|
||||
[string] $ProductVersion = '1.0.1',
|
||||
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
|
||||
[string] $ReleaseVersion = '1.0.0',
|
||||
[string] $ReleaseVersion = '1.0.1',
|
||||
[string] $PayloadDirectory,
|
||||
[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
@@ -4,8 +4,8 @@
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $ProductVersion = '1.0.0',
|
||||
[string] $ReleaseVersion = '1.0.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()
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# Administrator operations
|
||||
|
||||
These transport and capacity instructions describe release candidate `0.3.0-rc.1`.
|
||||
The published `0.2.0-rc.1` installer uses its earlier native HTTP transport.
|
||||
See [realtime architecture and settings](REALTIME.md) for the coordinated server requirement.
|
||||
These instructions describe client **1.0.1** with qbit-printing **1.0.0**, native printing API **2.3** and wire payload **v1**. See [realtime architecture and settings](REALTIME.md) for the coordinated server requirement.
|
||||
|
||||
## Service and data
|
||||
|
||||
@@ -14,7 +12,8 @@ See [realtime architecture and settings](REALTIME.md) for the coordinated server
|
||||
| Credentials | Device proof and QQQ session protected separately with Windows DPAPI CurrentUser under LocalSystem |
|
||||
| Submission journal | `journal.db` and SQLite WAL/SHM files |
|
||||
| Worker coordination | Exclusive `worker.lock` in the data directory |
|
||||
| Desktop preferences/logs | AppKit's per-user `qqq-print-agent` directory |
|
||||
| Desktop preferences | AppKit's per-user `qqq-print-agent` directory |
|
||||
| Shared service/companion logs | `%ProgramData%\QqqPrintAgent\logs` |
|
||||
|
||||
The MSI applies SYSTEM/Administrators permissions to the service directory. Do not loosen those ACLs or copy its credential to another machine/account. Backups should treat the entire directory as sensitive. Do not upload the database or protected configuration to public issues.
|
||||
|
||||
@@ -27,7 +26,7 @@ Get-Service QqqPrintAgent, Spooler
|
||||
|
||||
Use the actual install path if it differs. Normal CLI commands and the companion use the authenticated installed service. Its printer list reflects the service identity; explicit isolated legacy diagnostics may see different queues under their invoking account.
|
||||
|
||||
Windows service messages are emitted through .NET hosting logging. Review Windows Event Viewer and the desktop activity log for the relevant timestamp; record job IDs and error text without customer document contents.
|
||||
Use the built-in AppKit activity log for shared service and companion events, with source labels and severity filtering. Windows Event Viewer can also help diagnose service-host startup failures. Record relevant timestamps, job IDs and error text without customer document contents.
|
||||
|
||||
## Credentials and network
|
||||
|
||||
|
||||
+18
-2
@@ -1,6 +1,22 @@
|
||||
# Customer branding
|
||||
|
||||
Use the same released machine MSI 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.
|
||||
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
|
||||
|
||||
@@ -21,7 +37,7 @@ Use PowerShell 7.4 or newer. First validate a downloaded MSI and configuration w
|
||||
./build/Install-Branded.ps1 -MsiPath 'C:\Deployment\QqqPrintAgent.msi' -BrandingFile './docs/branding.example.json' -WhatIf
|
||||
```
|
||||
|
||||
Run the same command **without `-WhatIf` from an elevated administrator PowerShell** to install. The script does not silently elevate or bypass UAC. Supply `-ExpectedSHA256 '<64 hexadecimal digits>'` when you have the expected MSI checksum from your approved release source. The wrapper verifies it before installation and keeps the source MSI read-locked through execution. A checksum does not substitute for publisher signing.
|
||||
Run the same command **without `-WhatIf` from an elevated administrator PowerShell** to install. The script does not silently elevate or bypass UAC. Supply `-ExpectedSHA256 '<64 hexadecimal digits>'` when you have the expected MSI checksum from your approved release source. The wrapper verifies it before installation and keeps the source MSI read-locked through execution. A checksum does not substitute for publisher signing; verify the stable MSI signature as described in [Installation](INSTALLATION.md#download-and-verify). Customer branding does not change the Authenticode publisher or update feed.
|
||||
|
||||
The wrapper launches `msiexec.exe` with `/i`, `/passive` and `/norestart`, using separate arguments. It accepts success/reboot codes 0, 1641 and 3010 and reports the result. It creates `%ProgramData%\QqqPrintAgent` with SYSTEM/Administrators permissions when absent, or rejects unsafe existing permissions. It never broadens access to existing device state. It copies validated assets under unique names, replaces `branding.json`, and restores the previous configuration if Windows Installer fails. The client/service/enrollment remain one coherent MSI installation.
|
||||
|
||||
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
## Architecture
|
||||
|
||||
Current source prepares release `1.0.0` (not yet published), with native printing 2.3 (wire payload v1).
|
||||
Current source is version `1.0.1` (a packaging fix; see CHANGELOG.md), with native printing 2.3 (wire payload v1).
|
||||
See [realtime architecture](REALTIME.md) for worker separation, policy, streaming
|
||||
and reconnect rules. The service never falls back from WSS to job/control polling.
|
||||
|
||||
@@ -16,7 +16,7 @@ and reconnect rules. The service never falls back from WSS to job/control pollin
|
||||
|
||||
The service owns the credential, journal and print operations. The UI is optional at runtime and sees only credential-free snapshots. Service IPC uses the `QqqPrintAgent` named pipe and accepts administrator clients. `snapshot` returns connection health, all locally discovered printers with exposure state, the latest 50 jobs and the latest 100 in-memory log entries. `set-exposure` persists a selected queue and returns a fresh snapshot. Legacy `status`, `printers` and `enroll` operations remain available.
|
||||
|
||||
The cloud API lives in [qqq-print-protocol](https://github.com/QRun-IO/qqq-print-protocol). Its inventory is the complete currently exposed printer set. The server separately owns authorization/approval. Removing local exposure filters inventory and prevents new local submission to that queue. It cannot retract a job already handed to Windows.
|
||||
The cloud API lives in [qqq-print-protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol). Its inventory is the complete currently exposed printer set. The server separately owns authorization/approval. Removing local exposure filters inventory and prevents new local submission to that queue. It cannot retract a job already handed to Windows.
|
||||
|
||||
## Reproducible checks
|
||||
|
||||
@@ -30,7 +30,7 @@ This performs locked restore, Release solution build, core tests and Windows/CLI
|
||||
|
||||
## Local server and CLI
|
||||
|
||||
Use a disposable native QQQ host from the sibling qbit-printing project, advertising printing extension 2.0. Sign in through the companion's advertised QQQ authentication flow before using normal CLI commands. The service owns the session, device proof and unattended printing. A bearer-only demo is not a compatible realtime host.
|
||||
Use a disposable native QQQ host from the sibling qbit-printing project, using qbit-printing 1.0.0 on QQQ 4.1.0-RC.21 and advertising native printing API 2.3. Sign in through the companion's advertised QQQ authentication flow before using normal CLI commands. The service owns the session, device proof and unattended printing. A bearer-only demo is not a compatible realtime host.
|
||||
|
||||
```powershell
|
||||
$agent = '.\src\Qqq.PrintAgent.Cli\bin\x64\Debug\net10.0-windows10.0.19041.0\qqq-print-agent.exe'
|
||||
@@ -58,12 +58,12 @@ The worker persists receipt and SUBMITTING before invoking Windows. It records S
|
||||
## Packaging
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.0 -ReleaseVersion 1.0.0
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
```
|
||||
|
||||
MSI ProductVersion is numeric; assembly/package informational version may contain the prerelease suffix. Service, companion and CLI are published into separate self-contained runtime directories and shipped in one machine MSI. Do not reintroduce the template's per-user Velopack updater.
|
||||
|
||||
`./build/Package-Release.ps1` (defaults `1.0.0` / `1.0.0`) is the repository entry point for the unsigned local package; the signed stable package is built by the release workflow. It runs the same packaging checks as `.github/workflows/ci.yml` (`Test-WindowsSdkInventory.ps1 -VerifyPackages`, `Test-BrandedInstaller.ps1`, `Test-PackagingInputs.ps1`, `Build-Installer.ps1`) and assembles `artifacts/release` with the versioned MSI, `Install-Branded.ps1`, `branding.example.json` and `SHA256SUMS`. It does not build or run Core.Tests.
|
||||
`./build/Package-Release.ps1` (defaults `1.0.1` / `1.0.1`) is the repository entry point for the unsigned local package; the signed stable package is built by the release workflow. It runs the same packaging checks as `.github/workflows/ci.yml` (`Test-WindowsSdkInventory.ps1 -VerifyPackages`, `Test-BrandedInstaller.ps1`, `Test-PackagingInputs.ps1`, `Build-Installer.ps1`) and assembles `artifacts/release` with the versioned MSI, `Install-Branded.ps1`, `branding.example.json` and `SHA256SUMS`. It does not build or run Core.Tests.
|
||||
|
||||
`Qqq.PrintAgent.NativeCI.slnx` is `Qqq.PrintAgent.slnx` without the `Core.Tests` project, for hosts that must not build that assembly (for example `dotnet build Qqq.PrintAgent.NativeCI.slnx --configuration Release '-p:Platform=x64' -warnaserror`, then `dotnet test tests/Qqq.PrintAgent.Cli.Tests`). `Qqq.PrintAgent.slnx` and the test projects are unchanged, so `Verify.ps1` still covers Core.Tests; run those tests on a clean CI runner.
|
||||
|
||||
|
||||
@@ -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.
|
||||
+12
-11
@@ -2,14 +2,15 @@
|
||||
|
||||
## Download and verify
|
||||
|
||||
Download the x64 MSI and `SHA256SUMS` from the same [GitHub release](https://github.com/QRun-IO/qqq-print-agent/releases). Check the file locally:
|
||||
Download the x64 MSI and `SHA256SUMS` from the same [stable Gitea release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1). Matching assets remain on [GitHub](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) for the installed updater. Check the file locally:
|
||||
|
||||
```powershell
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath .\QqqPrintAgent-v0.3.0-rc.1-x64.msi
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath .\QqqPrintAgent-v1.0.1-x64.msi
|
||||
Get-Content .\SHA256SUMS
|
||||
Get-AuthenticodeSignature -LiteralPath .\QqqPrintAgent-v1.0.1-x64.msi | Format-List Status, SignerCertificate, TimeStamperCertificate
|
||||
```
|
||||
|
||||
Use the filename of the release you downloaded and compare the complete hash with its `SHA256SUMS` entry. Current release candidate: [0.3.0-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.0-rc.1). Checksums detect changes after publication; they do not replace publisher signing. The MSI is **unsigned**, with no Authenticode identity. The current release workflow also produces GitHub build-provenance attestations, which are verified separately from checksums and are not code signing. Older releases may lack those attestations; their presence must be checked for the exact downloaded asset. Download only from the project's release page and follow your organization's software-install policy.
|
||||
Use the filename of the release you downloaded and compare the complete hash with its `SHA256SUMS` entry. Stable **1.0.1** signs the MSI, companion, service and CLI through Azure Artifact Signing. Authenticode verification should report **Valid** with publisher **James Maes** and a timestamp. The release also provides build-provenance attestations. Checksums, publisher signatures and provenance establish different facts; see [Release signing](RELEASE-SIGNING.md). Earlier unsigned prereleases retain their historical status. Download only official assets and follow your organization's software-install policy.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -20,7 +21,7 @@ Run the MSI as an administrator. Windows Installer deploys one coherent applicat
|
||||
- The `qqq-print-agent.exe` diagnostic CLI.
|
||||
- A restricted `%ProgramData%\QqqPrintAgent` directory for enrollment and the job journal.
|
||||
|
||||
There is no inbound firewall rule or local HTTP server. The 0.3 realtime client requires outbound WSS upgrades and HTTPS to the QQQ server; update checks/downloads use HTTPS to GitHub release services. Upgrade the matching server first; it must advertise printing extension 2.0. The previous 0.2 transport is HTTP-based. The UI requests administrator elevation because its management pipe permits only SYSTEM and local administrators.
|
||||
There is no inbound firewall rule or local HTTP server. The client requires outbound WSS upgrades and HTTPS to the QQQ server; update checks/downloads use HTTPS to GitHub release services. Use the matching qbit-printing 1.0.0 server with native printing API 2.3. There is no job/control polling fallback. The UI requests administrator elevation because its management pipe permits only SYSTEM and local administrators.
|
||||
|
||||
## Service recovery
|
||||
|
||||
@@ -54,13 +55,13 @@ This bounds a blocked printer driver without stopping cloud connectivity permane
|
||||
|
||||
## Prepare printers
|
||||
|
||||
Install each printer and its appropriate vendor driver in Windows. Print a normal Windows test page, then discover the queues through QQQ Print Agent. The installed service must have access; a queue mapped only under a user's profile is insufficient. Use machine-installed USB/TCP/IP queues where possible. Network share authentication may require a deliberately configured service identity; that variant has not been qualified for this prerelease.
|
||||
Install each printer and its appropriate vendor driver in Windows. Print a normal Windows test page, then discover the queues through QQQ Print Agent. The installed service must have access; a queue mapped only under a user's profile is insufficient. Use machine-installed USB/TCP/IP queues where possible. Network share authentication may require a deliberately configured service identity; check access under that identity before using it for unattended printing.
|
||||
|
||||
Set the queue's default paper/tray/orientation for PDF printing. The initial API supports actual size or fit and copies, but rejects custom media dimensions. For ZPL, configure compatible Zebra/ZDesigner drivers and generate ZPL matching the printer's DPI and label stock.
|
||||
Set the queue's machine default paper/tray/orientation for PDF printing. Jobs can use an advertised media profile or a complete explicit PDF width/height pair; job-specific choices do not change the shared queue. See [Printer media](PRINTER-MEDIA.md). For ZPL, configure compatible Zebra/ZDesigner drivers and generate ZPL matching the printer's DPI and label stock. Ribbonless printers require the appropriate direct-thermal mode.
|
||||
|
||||
## Sign in and pair
|
||||
|
||||
Upgrade the QQQ printing server first; realtime source requires printing extension 2.0. In the companion's connection settings, enter its origin, discover the advertised sign-in options and sign in through the configured QQQ authentication. See [QQQ sign-in](QQQ-SIGN-IN.md) for browser-provider requirements and recovery.
|
||||
Use qbit-printing 1.0.0 on QQQ 4.1.0-RC.21 or a compatible newer server; the released combination advertises native printing API 2.3. In the companion's connection settings, enter its origin, discover the advertised sign-in options and sign in through the configured QQQ authentication. See [QQQ sign-in](QQQ-SIGN-IN.md) for browser-provider requirements and recovery.
|
||||
|
||||
For an existing unbound installation, choose **Associate the existing pairing with my account**; preserve its device record and history. For a new installation, complete sign-in and enter a single-use enrollment code created in QQQ. Enrollment runs in the service, protecting its device proof and QQQ session under the service identity. The desktop does not retain the password or device token. **Set up a new pairing** is an explicit replacement flow, not routine upgrade migration.
|
||||
|
||||
@@ -70,13 +71,13 @@ Normal CLI setup, printer exposure and connection commands use the authenticated
|
||||
|
||||
In the MSI-installed companion, open Settings and select **Check for updates**. The **Check for updates on startup** preference enables a quiet check after startup; it does not authorize installation. AppKit's `UpdateCoordinator` and native prompts handle offers and confirmation. Development/portable copies cannot apply machine updates. The elevated companion and apply helper require local administrator rights; there is no persistent privileged update daemon or UAC bypass.
|
||||
|
||||
The feed is fixed to [QRun-IO/qqq-print-agent releases](https://github.com/QRun-IO/qqq-print-agent/releases), independently of customer branding or the QQQ server. Stable builds accept newer stable versions; prerelease builds may accept newer RC or stable versions. Offers below core version 0.1.3 are rejected. Downloads require the expected versioned MSI and same-release `SHA256SUMS`. SHA-256 checks establish consistency with that release, not independent publisher identity or signing; the prerelease remains unsigned.
|
||||
The feed is fixed to [QRun-IO/qqq-print-agent releases](https://github.com/QRun-IO/qqq-print-agent/releases), independently of customer branding or the QQQ server. Stable builds accept newer stable versions; prerelease builds may accept newer RC or stable versions. Offers below core version 0.1.3 are rejected. Downloads require the expected versioned MSI and same-release `SHA256SUMS`. SHA-256 checks establish consistency with that release, not independent publisher identity. Stable 1.0.1 is signed; earlier unsigned prereleases remain distinguishable by their exact release.
|
||||
|
||||
After confirmation, the adapter downloads into a protected unique folder under `%ProgramData%\QqqPrintAgent\updates`, verifies size/hash and prepares a copied CLI helper. The service pauses new claims and waits for active native submissions; connection/control traffic remains separate. The helper waits for the companion to exit, rechecks the package, then runs one MSI for service, companion and CLI. It preserves enrollment and journal evidence. Windows Installer is not forcibly terminated. A failed or abandoned handoff does not leave claims paused permanently.
|
||||
|
||||
The helper records a dated result in `updates\last-result.json`, retains a private installation log and attempts to reopen the companion. Settings displays the last result. Exit 3010 means a Windows restart is required, not that the new app is fully ready. On failure, inspect Settings/Logs and the installed version before retrying. Completed update folders, helper copies and install logs are retained for administrator review; there is no automatic cleanup policy for completed stages. Remove an old stage only after confirming its installer/helper has exited and its evidence is no longer needed; never remove enrollment or journal files.
|
||||
|
||||
The published 0.2 MSI has completed a state-preserving local upgrade, native QQQ sign-in and device-control checks. The user confirmed Letter PDF and Zebra ZPL output plus the Connected footer and both printers. The earlier 0.1.9 automated UI run and 0.1.7 hardware checks retain their own scope. None of these establishes installed 0.3 realtime behavior, a complete in-app updater handoff or restart-required recovery. See [acceptance evidence](ACCEPTANCE.md) and [session state](SESSION-STATE.md) for exact versions and remaining checks. A nonworking older companion can be repaired using the reviewed MSI directly.
|
||||
The signed 1.0.1 MSI is installed locally; the November 3, 2026 check found matching companion, service and CLI versions with the service running. The earlier in-app update from 0.3.1 to 0.3.2-rc.1 completed and reopened Connected, as confirmed by the operator. Those are distinct observations; see [acceptance evidence](ACCEPTANCE.md) and [release readiness](RELEASE-READINESS.md) for their exact scope. A nonworking older companion can be repaired using the official MSI directly.
|
||||
|
||||
## Upgrade and remove
|
||||
|
||||
@@ -84,8 +85,8 @@ Install a newer MSI as administrator. The installer stops and replaces the servi
|
||||
|
||||
Use **Settings → Apps → Installed apps → QQQ Print Agent → Uninstall** to remove application files and the service. Enrollment and journal data are retained to support recovery. After revoking/decommissioning the device in QQQ and resolving all outstanding jobs, an administrator may deliberately remove the retained data. Never delete a live journal as a troubleshooting shortcut: it is evidence used to avoid duplicate printing.
|
||||
|
||||
Local MSI upgrades and installed UI checks are recorded by version in the acceptance evidence. Reboot/recovery, a complete in-app upgrade and broader driver combinations still require qualification; an MSI exit code alone does not establish those results.
|
||||
Local MSI upgrades, updater handoff and installed UI checks are recorded by version in the acceptance evidence. An MSI exit code alone does not establish reboot recovery or compatibility with every printer/driver combination.
|
||||
|
||||
## Select exposed printers
|
||||
|
||||
New installations share no queues. Open the companion's Printers tab and enable **Expose to QQQ** for each intended printer, then approve/enable it in QQQ. Only the selected set is sent to the cloud; all discovered queues remain visible locally for setup. A missing formerly exposed queue is shown as offline.
|
||||
New installations share no queues. Open the companion's Printers page and enable **Shared** for each intended printer, then approve/enable it in QQQ. Only the selected set is sent to the cloud; all discovered queues remain visible locally for setup. A missing formerly exposed queue is shown as offline.
|
||||
@@ -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`.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Windows queue-default media profiles
|
||||
|
||||
The candidate client advertises `printer-media-v1` only when standard discovery
|
||||
Client 1.0.1 advertises `printer-media-v1` only when standard discovery
|
||||
reports a stable printing extension version of at least 2.3.0 within major two.
|
||||
Printing 2.0, 2.1 and 2.2 (rendering, no media negotiation) servers retain the original three realtime capabilities and
|
||||
legacy unselected jobs. Each WebSocket establishment refreshes standard discovery
|
||||
|
||||
+5
-3
@@ -1,18 +1,20 @@
|
||||
# QQQ sign-in
|
||||
|
||||
Standard QQQ sign-in shipped in `0.2.0-rc.1`. Release candidate `0.3.0-rc.1` requires printing extension `>=2.0.0 <3.0.0` before opening its realtime channel or dispatching work. The shared authentication client may discover and sign in to compatible 1.x or 2.x servers; a 1.x login does not authorize realtime dispatch or polling fallback. Extension 2.0 makes the authenticated channel mandatory for claims. This guide is not installed/provider acceptance evidence.
|
||||
QQQ Print Agent **1.0.1** uses standard QQQ authentication. The matching released server is qbit-printing **1.0.0** on QQQ **4.1.0-RC.21**, advertising native printing API **2.3**. Realtime dispatch requires a compatible 2.x printing API and an authenticated WebSocket; an older API login never enables job polling. Product, native API and wire payload versions are separate. See [Realtime architecture](REALTIME.md).
|
||||
|
||||
Open **Settings → Sign in / Connection settings**, enter the QQQ server origin and choose **Discover sign-in options**. Discovery must advertise a compatible QQQ UI API and the printing extension before credentials are sent. Table-based authentication presents username/password fields; configured external authentication presents a browser button only for an explicitly advertised native session grant. Passwords remain in the companion and are sent only to the selected QQQ server, never stored or sent to the service.
|
||||
|
||||
The service receives the opaque QQQ session through its existing authenticated administrator pipe. It validates and resumes that session, verifies the authoritative user/tenant/device binding, and protects the session with service-identity DPAPI. Provider access and refresh tokens remain on the QQQ server. Normal printing uses `/qqq/v1/qbit/printing` with the current user session; the device ID/token are secondary possession proof, not independent user authority. Session resume runs periodically while printing continues. Expired or denied authorization stops new traffic until sign-in is restored, preserving receipts and pairing.
|
||||
|
||||
Existing installations require sign-in after upgrading. To associate an existing unbound pairing, explicitly select **Associate the existing pairing with my account**. A new pairing still requires an enrollment code after sign-in. Sign-out preserves the device credential, printer choices and print journal, while removing the locally stored QQQ session. It does not recall pages already sent to a printer.
|
||||
Older pairings without a bound QQQ user require sign-in and association. Routine upgrades preserve a valid saved session and pairing. To associate an existing unbound pairing, explicitly select **Associate the existing pairing with my account**. A new pairing still requires an enrollment code after sign-in. Sign-out preserves the device credential, printer choices and print journal, while removing the locally stored QQQ session. It does not recall pages already sent to a printer.
|
||||
|
||||
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.
|
||||
|
||||
Pending qualification includes the configured provider's grant, callback registration in the packaged installation, current-user permission changes and long-lived provider renewal. No successful synthetic test substitutes for those deployment checks.
|
||||
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.
|
||||
|
||||
## Recovering unreadable local credentials
|
||||
|
||||
|
||||
+3
-3
@@ -1,16 +1,16 @@
|
||||
# Realtime architecture and operation
|
||||
|
||||
The realtime transport was introduced in release candidate [0.3.0-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.0-rc.1) as native printing 2.0, and the published `0.2.0-rc.1`
|
||||
polling transport and its acceptance evidence remain historical. The current candidate speaks native printing 2.3 with
|
||||
polling transport and its acceptance evidence remain historical. The released 1.0.1 client speaks native printing 2.3 with
|
||||
wire payload v1 and the same realtime model; media negotiation is offered only to a stable 2.3+ server. The authoritative
|
||||
[contract at the consumer pin](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/commit/42c0d249f81395f00408ec0f92638dcbbf5deb03/docs/REALTIME.md)
|
||||
(commit `42c0d249f81395f00408ec0f92638dcbbf5deb03`, [Gitea PR 2](https://git.kof22.com/QRun-IO/qqq-print-protocol/pulls/2))
|
||||
defines exact fields, bounds and required cross-runtime tests. The earlier 0.3.0 pin was `f3cbc02e9b0fb4fdfefaa59d976c04df747cc589`, kept as history.
|
||||
|
||||
The 2.0 transport origin: native printing became version 2.0 because claiming then required a synchronized
|
||||
authenticated WebSocket; published 1.0 clients could claim using HTTP alone.
|
||||
authenticated WebSocket; clients of native printing API 1.0 could claim using HTTP alone.
|
||||
This is a breaking behavioral change even though the route and domain payloads
|
||||
are retained. Product version 0.3, framework `/qqq/v1`, wire payload v1 and the
|
||||
are retained. Product version 1.0.1, framework `/qqq/v1`, wire payload v1 and the
|
||||
`qqq-printing.v1` subprotocol have separate version meanings.
|
||||
|
||||
## Connection and work
|
||||
|
||||
+12
-19
@@ -1,33 +1,26 @@
|
||||
# Release readiness — stable 1.0 closeout
|
||||
# Release readiness — signed 1.0 closeout
|
||||
|
||||
Updated 2026-11-02. This replaces the stale candidate matrix, which is kept below as a clearly historical section. No stable tag exists yet and **no 1.0 or production-readiness statement is made** by this file.
|
||||
Updated 2026-11-04. The requested product release and local installation are complete. The dated records below distinguish the stable release from earlier candidate evidence; they are not a claim that every printer or deployment has been qualified.
|
||||
|
||||
## Current truth
|
||||
## Current release
|
||||
|
||||
- **Installed:** the official 0.4.0-rc.4 build is installed with all three components (companion, service, CLI). The normal MSI upgrade exited 0 and the service is Running.
|
||||
- **Hosted gate:** clean hosted Windows CI run 41442796961 on source `53fe1b8281806eda7adc314f92c8205aefcb51ce` passed 499 tests (166 Core + 333 CLI) with zero failures and skips, and the build and MSI had zero warnings and errors. This is the Core.Tests evidence; Core.Tests is never built on the local host. The earlier run 41384467500 on `b5eb5d73` is historical.
|
||||
- **Signing:** stable signing is implemented. All 19 first-party binary copies of an installed RC4 payload, including `Cli/qqq-print-agent.dll`, and a disposable official RC4 MSI were Azure-signed and strictly verified (exact publisher, SHA256, timestamp). This is a local administrator proof; the new CI OIDC identity has not been exercised ([RELEASE-SIGNING.md](RELEASE-SIGNING.md)). Installed and published remains RC4; no new stable tag exists.
|
||||
- **Published:** the primary Gitea feed and the public feed carry all three prereleases (Windows 0.4.0-rc.4; server and protocol 0.4.0-rc.1). Nine assets were checked and their provenance verified.
|
||||
- **Physical output:** the operator confirmed the 4 × 1 label on RC3 with the same relevant printing runtime; 4 × 6 and Letter were accepted earlier. Physical 4 × 2 was deferred by the operator and is **not** a stable-release blocker.
|
||||
- **Source standards:** the source-header inventory (289 tracked files, 231 C# files) reported zero issues; that work is done.
|
||||
- **Windows:** signed [1.0.1 on Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) and matching [GitHub updater assets](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1), from source `5bf00f3291923f7afb34c6cce61276e69649689b`.
|
||||
- **Release gate:** [run 41532214447](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41532214447) passed the build/test/package, security, CodeQL, script, Azure OIDC signing and publication gates. Published checksums, signatures and provenance were verified. See [release signing](RELEASE-SIGNING.md).
|
||||
- **Installed:** the November 3 check recorded MSI exit 0, app/service/CLI product version `1.0.1+5bf00f3291923f7afb34c6cce61276e69649689b`, file version `1.0.1.65535`, and service **Running**. No new runtime check is implied by this documentation update.
|
||||
- **Server and contract:** [qbit-printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), [protocol 1.0.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.0.0) and QQQ **4.1.0-RC.21** are released. Native printing API **2.3**, payload **v1** and WebSocket subprotocol `qqq-printing.v1` retain their own version meanings.
|
||||
- **Physical scope:** Letter and 4 × 6 output were accepted earlier; the operator confirmed 4 × 1 on RC3 with the same relevant print path. Physical 4 × 2 was deferred and is not a release blocker. Dated evidence remains in [Acceptance](ACCEPTANCE.md).
|
||||
- **Documentation:** current install, sign-in, settings, media, logs, branding and release instructions reflect the stable release. Ordinary source changes use Gitea; the authorized GitHub signing and updater bridge remains in place.
|
||||
|
||||
## Active stable-release work (only these)
|
||||
No product implementation, release publication or installation task remains in this closeout. Shared CI-platform migration is separate from the shipped product. Historical unchecked items below do not reopen cancelled work or authorize physical tests.
|
||||
|
||||
1. Windows release branch (Gitea PR 2): normal protected merge, awaiting the central native 1725 repair for Windows ACL and file sharing on Linux.
|
||||
2. A supported, published QQQ core with native session login, still awaited (the last official release, RC20, does not have it). Stable tag publication, including the first CI OIDC signing run, follows it.
|
||||
|
||||
## Accepted known limitation
|
||||
## Historical scope decision — 2026-11-02
|
||||
|
||||
The post-print native UI layout exception (WinUI `LayoutCycleException` after a print, recovered by the existing mitigation) was removed from the 1.0 blocker list by the user on 2026-11-02 and is accepted as a known limitation. It is **not fixed**, and the history below is preserved unchanged. It is not carried as another mandatory qualification.
|
||||
|
||||
## Cancelled, not active
|
||||
## Historical cancelled work, not active
|
||||
|
||||
Repeat Auth/S3 qualification, the print-latency investigation, and the MSI comparison/install-free upgrade analysis were cancelled by the user. Their recorded evidence is historical; do not resume them.
|
||||
|
||||
## Not claimed
|
||||
|
||||
Authenticode signing of the rc builds (signing belongs to the stable-tag work above), a published native-authentication cohort result, 4 × 2 physical output, a broad driver matrix, and any 1.0 readiness until the stable tag is published and verified.
|
||||
|
||||
---
|
||||
|
||||
# Historical matrix — candidate 0.4.0-rc.1 (superseded 2026-11-02)
|
||||
|
||||
+14
-30
@@ -72,34 +72,18 @@ Read the immutable owner and repository IDs from `gh api repos/QRun-IO/qqq-print
|
||||
(and confirm `use_immutable_subject`). Trust must not be broadened to pull requests or other environments. MarkdownViewr's
|
||||
and gclo's identities must not be reused for this repository without separate approval and their own trust.
|
||||
|
||||
## Configuration status and limits
|
||||
## Published stable result — 2026-11-03
|
||||
|
||||
- Azure setup is configured: existing account `kof22signing`, profile `releases` (Active, PublicTrust), a
|
||||
printing-only client `083a3874-ebbb-453f-b625-71bbfefd3227` with the exact immutable subject
|
||||
`repo:QRun-IO@93010898/qqq-print-agent@1438820325:environment:release`, a profile-scoped signer role and the
|
||||
seven environment variables, with no secret. The real CI OIDC exchange and signing are **not yet proven**; only a
|
||||
hosted stable-tag run will show that; a local administrator signing proof cannot.
|
||||
- The `azure/artifact-signing-action` inputs used (`endpoint`, `signing-account-name`, `certificate-profile-name`,
|
||||
`files-folder`, `files-folder-filter`, `files-folder-recurse`, `file-digest`, `timestamp-rfc3161`, `timestamp-digest`)
|
||||
were checked against the `action.yml` of the pinned commit (`c7ab2a86…`, v2.0.0) supplied by root. The action
|
||||
filters by extension, which is why only owned files are staged, and it authenticates through the Azure CLI
|
||||
credential left by `azure/login` (the Azure CLI credential type is not excluded by default).
|
||||
- Proof so far (local, existing Azure client): the signing code and configuration are implemented, and the source head
|
||||
`53fe1b8281806eda7adc314f92c8205aefcb51ce` passed clean hosted Windows CI run 41442796961 (499 tests, zero failures and
|
||||
skips; build and MSI zero warnings and errors). All 19 first-party binary copies of an installed, published RC4 payload,
|
||||
including `Cli/qqq-print-agent.dll`, were Azure-signed and each passed the strict `Sign-Release.ps1 -Operation Verify`
|
||||
(exact publisher subject, SHA256, timestamp, one signature, zero warnings and errors); the source payload was
|
||||
untouched. A disposable copy of the official RC4 MSI was also Azure-signed and passed `Apply`/`Verify`. This is a
|
||||
local administrator proof of the signer and the verification path. It does **not** prove the CI identity's OIDC
|
||||
exchange, nor signed payload files inside the old MSI; that is shown only by the first hosted stable-tag run.
|
||||
Installed and published remains RC4; no new stable tag exists.
|
||||
- `Install-Branded.ps1` and `branding.example.json` are not Authenticode-signed; their integrity rests on the
|
||||
checksums in `SHA256SUMS` and the release provenance attestation.
|
||||
- A published QQQ core with native session login is still awaited; the last official release, RC20 (API 1.8), does
|
||||
not have it, and a stable tag waits for it. The Windows release branch (Gitea PR 2) awaits a normal protected merge
|
||||
after the central native 1725 repair for Windows ACL and file sharing on Linux.
|
||||
- `Apply` requires the signed stage to contain exactly the staged files, and fails closed otherwise.
|
||||
- The subject comparison is an exact string match against the signer certificate; a formatting difference (for
|
||||
example `S=` versus `ST=`) fails the release rather than weakening the check.
|
||||
- No stable version is set here; the `VersionPrefix`/ProductVersion are finalized separately. Signing proves
|
||||
publisher identity, not product qualification; it is not production hardware or server acceptance.
|
||||
The authorized GitHub signing bridge completed [release run 41532214447](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41532214447) for `v1.0.1`, source `5bf00f3291923f7afb34c6cce61276e69649689b`. Its build, test, security, CodeQL, script, OIDC signing and publication gates passed. The signed MSI and matching assets are published on [Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) and [GitHub](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1); the latter remains the installed updater feed.
|
||||
|
||||
The downloaded MSI's checksum matched `SHA256SUMS`; Authenticode reported **Valid**, the exact configured publisher `CN=James Maes, O=James Maes, L=Chester, S=il, C=US`, and a timestamp. Release provenance was verified. The local installation on November 3 exited 0 with companion, service and CLI all reporting `1.0.1+5bf00f3291923f7afb34c6cce61276e69649689b`, file version `1.0.1.65535`, and service state **Running**. See [release readiness](RELEASE-READINESS.md).
|
||||
|
||||
The printing-only Azure client uses the exact immutable repository/environment subject, a profile-scoped signer role and protected environment variables, with no stored client secret. The same signing account/profile and verification rules described above remain in use.
|
||||
|
||||
## Historical bootstrap failure and limits
|
||||
|
||||
The first hosted stable attempt, `v1.0.0`, passed its prerequisite gates and Azure OIDC login but stopped before signing because the action's SignCLI installer conflicted with NuGet package source mapping. No signed 1.0.0 installer was published. Version 1.0.1 fixes the bootstrap by preinstalling only pinned SignCLI `0.9.1-beta.26227.3` through the mapped `nuget.config` into the action's expected `%LOCALAPPDATA%\ArtifactSigning\sign\sign.<version>` layout. The 1.0.0 tag is unchanged.
|
||||
|
||||
The earlier local signing proof used copies of the RC4 payload and MSI; it was not the hosted release. The successful 1.0.1 run now supplies the CI identity/signing evidence. QQQ 4.1.0-RC.21 and qbit-printing 1.0.0 provide the released native-authentication cohort; the older RC20 prerequisite is superseded.
|
||||
|
||||
`Apply` still requires exactly the staged files, and publisher matching remains an exact string comparison. `Install-Branded.ps1` and `branding.example.json` are not Authenticode-signed; their integrity rests on release checksums and provenance. Signing establishes publisher identity, not physical printer compatibility or universal deployment qualification.
|
||||
+16
-8
@@ -1,8 +1,12 @@
|
||||
# Release process
|
||||
|
||||
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.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
|
||||
|
||||
The same workflow also runs on a stable `vX.Y.Z` tag. After every gate below it signs the service, companion and CLI binaries, builds the MSI from them, signs the MSI with the project's Azure Artifact Signing identity, verifies the exact publisher, and publishes a non-prerelease with checksums computed after signing. Prerelease tags are unaffected and stay unsigned. Setup, ordering and required variables are in [RELEASE-SIGNING.md](RELEASE-SIGNING.md).
|
||||
The release workflow runs on a stable `vX.Y.Z` tag. After every gate below it signs the service, companion and CLI binaries, builds the MSI from them, signs the MSI with the project's Azure Artifact Signing identity, verifies the exact publisher, and publishes a non-prerelease with checksums computed after signing. Prerelease tags are unaffected and stay unsigned. Setup, ordering and required variables are in [RELEASE-SIGNING.md](RELEASE-SIGNING.md).
|
||||
|
||||
## Prereleases
|
||||
|
||||
@@ -17,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.
|
||||
@@ -24,23 +32,23 @@ Windows Installer replaces a versioned file only when its numeric file version i
|
||||
## Maintainer checklist
|
||||
|
||||
1. Update CHANGELOG.md, acceptance evidence and version inputs. Ensure no local enrollment/journal/payload files are tracked.
|
||||
2. Review the protocol pin, dependency lock files, unsigned-release notice and open limitations.
|
||||
3. Run `./build/Verify.ps1` and build the MSI locally or inspect the corresponding green CI run.
|
||||
4. Obtain explicit release authorization, create/push the immutable prerelease tag, and wait for the workflow.
|
||||
5. Verify every published filename/checksum and its provenance attestation. Record the workflow URL and version-specific installed/hardware acceptance, distinguishing observed results from pending qualification.
|
||||
2. Review the protocol pin, dependency lock files, channel-specific signing notice and release scope.
|
||||
3. Inspect the corresponding successful CI run, or run `./build/Verify.ps1` and build the MSI on a permitted Windows test host. Host restrictions remain in force.
|
||||
4. Obtain explicit release authorization, create the immutable stable or prerelease tag from reviewed primary source, and use the authorized GitHub bridge to run the workflow. Do not push ordinary branches to the GitHub shadow.
|
||||
5. Verify every published filename/checksum, applicable Authenticode signature and provenance attestation. Publish the same reviewed release assets on primary Gitea without rebuilding or changing them. Record the workflow URL and version-specific installed/hardware evidence.
|
||||
|
||||
If publishing the initial release manually, attach only reviewed artifacts and disclose whether they were built locally. Do not claim GitHub build provenance for a local MSI.
|
||||
|
||||
## Signing
|
||||
|
||||
Stable tags are signed by the release workflow with Azure Artifact Signing (see [Stable releases](#stable-releases) and [RELEASE-SIGNING.md](RELEASE-SIGNING.md)); release candidates stay unsigned. `Build-Installer.ps1` also accepts a certificate thumbprint and timestamp server for local signing only. The signing code and configuration are implemented, but the CI identity's first signing run has not happened, and no stable release has been published. Current evidence and open items are in [RELEASE-READINESS.md](RELEASE-READINESS.md).
|
||||
Stable tags are signed by the release workflow with Azure Artifact Signing (see [Stable releases](#stable-releases) and [RELEASE-SIGNING.md](RELEASE-SIGNING.md)); release candidates stay unsigned. `Build-Installer.ps1` also accepts a certificate thumbprint and timestamp server for local signing only. The CI identity completed OIDC authentication and signed stable 1.0.1. Current evidence is in [RELEASE-READINESS.md](RELEASE-READINESS.md).
|
||||
|
||||
## Update-feed contract
|
||||
|
||||
The companion uses AppKit update prompts with a machine MSI adapter. Release assets are also updater inputs: preserve the exact versioned MSI filename and matching `SHA256SUMS` entry. The updater checks release identity, size and SHA-256; provenance verification is a separate release/operator check. The fixed feed is this repository's GitHub releases; branding and QQQ configuration cannot redirect it. Stable installations ignore prereleases, while RC installations may receive a newer RC or stable version. The minimum offered core version is 0.1.3. Never replace a published release's binary under the same version.
|
||||
|
||||
MSI has a numeric three-part ProductVersion; informational version preserves `-rc.N`. Same-numeric-version MSI upgrades are enabled for RC-to-RC/stable transitions, while the updater enforces a strictly newer semantic release. This intentional authoring requires a narrow ICE61 exception alongside the existing Microsoft-resource ICE03 exception; no other MSI checks are disabled. Manual MSI deployments must follow semantic release order because MSI alone cannot order RC suffixes. See [ADR-0002](adr/0002-prerelease-quality-and-packaging.md). The helper verifies the installed companion's actual ProductVersion against the requested release after exit 0. Exit 3010 remains restart-required.
|
||||
MSI has a numeric three-part ProductVersion; informational version preserves `-rc.N`. Same-numeric-version MSI upgrades are enabled for RC-to-RC/stable transitions, while the updater enforces a strictly newer semantic release. This intentional authoring requires a narrow ICE61 exception alongside the existing Microsoft-resource ICE03 exception; no other MSI checks are disabled. Manual MSI deployments must follow semantic release order because MSI alone cannot order RC suffixes. See [ADR-0002](adr/0002-prerelease-quality-and-packaging.md). The helper verifies the installed component versions against the requested release after exit 0. Exit 3010 remains restart-required.
|
||||
|
||||
Same-feed SHA-256 establishes asset consistency, not independent publisher authentication; stable releases additionally carry Authenticode signatures on the first-party binaries and the MSI, while release candidates do not. The helper requires administrator rights, installs service/UI/CLI together and retains credentials/journal; no per-user updater or privileged persistent daemon is shipped. Completed staging folders and install logs are retained for administrator review.
|
||||
|
||||
Historical: the 0.1.3 source gate passed 163 tests with zero warnings/errors and locked restore. A real in-app update from 0.3.1 to 0.3.2 has since been accepted with retained identity, history and profiles, matching component versions and an automatically reopened companion; see [ACCEPTANCE.md](ACCEPTANCE.md) and [RELEASE-READINESS.md](RELEASE-READINESS.md). Packaging or UI test compilation alone does not establish update acceptance, and no stable 1.0 publication is claimed here.
|
||||
Historical: the 0.1.3 source gate passed 163 tests with zero warnings/errors and locked restore. A real in-app update from 0.3.1 to 0.3.2 has since been accepted with retained identity, history and profiles, matching component versions and an automatically reopened companion; see [ACCEPTANCE.md](ACCEPTANCE.md) and [RELEASE-READINESS.md](RELEASE-READINESS.md). Packaging or UI test compilation alone does not establish update acceptance. Stable 1.0.1 publication and its separate local MSI installation are recorded above.
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# History and diagnostic limits
|
||||
|
||||
Release candidate `0.3.0-rc.1` uses event-driven claim drains and direct realtime control
|
||||
Client `1.0.1` uses event-driven claim drains and direct realtime control
|
||||
traffic, with separately bounded document downloads and native submissions.
|
||||
Default document limits are 100 MiB PDF and 20 MiB ZPL; administrators configure
|
||||
these and concurrency, staging, bandwidth and deadlines. There is no periodic
|
||||
@@ -50,7 +50,7 @@ Creation time is separate from the latest status-update time. Receipt captures t
|
||||
|
||||
## Logs
|
||||
|
||||
AppKit retains its local-midnight daily filename rotation and shared log viewer. Machine-wide settings default to 30 days and 20 MiB per daily file; supported ranges are 1–365 days and 1–100 MiB. Service and companion use one cross-process lock for appends, settings, retention and clearing.
|
||||
AppKit retains its local-midnight daily filename rotation and shared log viewer. Machine-wide settings default to 30 days retention, 100 MiB per file and 10 rolled-over segments per day; supported ranges are 1–365 days, 1–1024 MiB and 1–100 segments. A file that reaches its limit is renamed to `qqq-print-agent-yyyy-MM-dd.N.log` and a fresh file continues, so the bound per day is the file size times the kept segments plus the live file, and no entry is ever suppressed. Debug logging (off by default) adds request-level detail. Service and companion use one cross-process lock for appends, rollover, settings, retention and clearing.
|
||||
|
||||
At the daily limit, the backend writes a warning when space permits and persists a cap marker. Settings exposes current bytes and whether that day's limit was reached. Further diagnostics are suppressed until the next local day, an increased limit or explicit clearing; printing continues. The cap is visible rather than silently discarding ongoing errors. Detailed high-frequency logs may need a higher limit than the normal three status entries per job used by the synthetic workload.
|
||||
|
||||
|
||||
+55
-2
@@ -1,6 +1,59 @@
|
||||
# Session state — 2026-11-02
|
||||
# Session state — 2026-11-06
|
||||
|
||||
## Current status: stable 1.0 closeout — 2026-11-02
|
||||
## 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.
|
||||
|
||||
The matching released cohort is **qbit-printing 1.0.0**, **protocol 1.0.0** and **QQQ 4.1.0-RC.21**, with native printing API 2.3 and wire payload v1. The supported native sign-in prerequisite and protected release merges are complete. See [Release readiness](RELEASE-READINESS.md).
|
||||
|
||||
Current README and operator/maintainer guides have been refreshed against the released source. Gitea owns ordinary branches and PRs. The authorized Windows GitHub signing/CI workflows and GitHub release feed remain necessary; the installed updater uses that feed. Server/protocol Actions retirement does not retire the Windows bridge or remove the pinned protocol fixtures.
|
||||
|
||||
The user explicitly authorized publishing this documentation update after being informed that a Markdown-only push triggers the retained Microsoft-hosted workflow's full `Core.Tests` build. The branch `docs/printing-release-closeout-20261104` uses the normal Gitea PR and required checks. This authorization covers that hosted documentation build only; it does not clear local/VM6218 builds, quarantine restoration, exclusions or changes to protections. The prior local publication hold is superseded for this documentation update.
|
||||
|
||||
The requested product implementation, release and local installation are closed. No new builds, print tests, auth/storage qualification, UI-waiver work or Defender investigation belong to this documentation closeout. Earlier checkpoints below retain their original evidence and pending language as history, not active tasks.
|
||||
|
||||
## Historical: stable 1.0 closeout checkpoint — 2026-11-02
|
||||
|
||||
The official 0.4.0-rc.4 build is installed with all three components; the normal MSI
|
||||
upgrade exited 0 and the service is Running. The latest source is
|
||||
|
||||
+35
-1
@@ -1,6 +1,40 @@
|
||||
# TODO
|
||||
|
||||
## Stable 1.0 closeout — 2026-11-02
|
||||
## 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.
|
||||
- [x] Verify release assets, signatures and provenance; install the official MSI locally. November 3 receipt: exit 0, matching app/service/CLI 1.0.1, service Running.
|
||||
- [x] Publish qbit-printing 1.0.0 and protocol 1.0.0 with QQQ 4.1.0-RC.21 native sign-in support.
|
||||
- [x] Refresh current README, operator and maintainer documentation against the stable source; preserve dated evidence.
|
||||
|
||||
The requested product work is complete. Shared CI migration is separate. Everything below is historical or unscheduled backlog; unchecked boxes do not reopen work or authorize more tests. See [Release readiness](RELEASE-READINESS.md).
|
||||
|
||||
## Documentation publication scope
|
||||
|
||||
The user authorized publishing `docs/printing-release-closeout-20261104` through a normal Gitea PR, including its automatic Microsoft-hosted checks. The previous publication hold is superseded for that scope. No code or workflow change, local/VM6218 build, quarantine restoration or protection exception is part of this update. The PR records publication and merge status.
|
||||
|
||||
## Historical stable 1.0 checkpoint — 2026-11-02
|
||||
|
||||
Active stable-release work is only the two open items below. Every other unchecked item in this file is historical or unscheduled backlog, not a stable-1.0 blocker.
|
||||
|
||||
|
||||
@@ -3,29 +3,31 @@
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| Companion cannot connect to service | Confirm the MSI is installed, `QqqPrintAgent` is running, and the companion is elevated. A development CLI worker does not host the installed service pipe. |
|
||||
| Enrollment fails | Check server URL, fresh/unconsumed code, device/facility configuration and server logs. Remote endpoints require HTTPS. |
|
||||
| Sign-in or discovery fails | Open **View logs**: every step (discovery, authentication metadata, native grant validation, provider discovery, browser callback, session verification by the service, device binding, identity check) is logged with the reason it stopped, and the dialog shows the same reason. Turn on **Debug logging** in Log settings for request routes, status codes, timings and realtime frames. |
|
||||
| Sign-in works in the companion but "Service authorization not completed" | The service verifies the session itself as LocalSystem. A self-signed or private-authority certificate (for example a local Caddy root) that your user account trusts is not in the machine store, so the service's TLS check fails; the log shows `TLS certificate for host rejected: RemoteCertificateChainErrors`. Install the issuing root in **Local Machine → Trusted Root Certification Authorities**, or enter the server address and enable **Allow untrusted server certificate for this server** in Connection settings for a development server (the exception is bound to that one server). |
|
||||
| Enrollment fails | Complete standard QQQ sign-in, then check the server URL, fresh/unconsumed code, device/facility configuration and server logs. Remote endpoints require HTTPS. The log records each enrollment step and the server's answer. |
|
||||
| No printers in the service list | Install the queue for the machine/service identity. User-specific network mappings may not be visible to LocalSystem. Confirm Spooler is running. |
|
||||
| Windows test page fails | Repair driver, connection or printer hardware locally before testing cloud printing. |
|
||||
| Discovered printer never receives work | Enable the queue in QQQ, verify device/printer IDs and submit to that assigned queue. |
|
||||
| Discovered printer never receives work | Share the queue locally, enable the matching discovered printer in QQQ and submit to that printer on the intended device. |
|
||||
| Job remains queued | Check service status, authenticated realtime connection, current user/device authority, server pause/disconnect state, queue approval/exposure and configured capacity. The cloud preview is bounded; inspect server records for the full queue. |
|
||||
| Realtime connection cannot start | Require printing extension 2.0 and allow outbound WSS upgrades to the configured origin. Inspect the safe connection/policy error; a 1.0 server or blocked socket does not enable HTTP polling fallback. |
|
||||
| Realtime connection cannot start | Use the released native printing API 2.3 server and allow outbound WSS upgrades to the configured origin. Inspect the connection/policy error; a legacy native API 1.x server or blocked socket does not enable HTTP polling fallback. |
|
||||
| CONFIGURATION_REQUIRED | Have the QQQ administrator correct the complete effective policy or upgrade the incompatible client. Limits must fit declared client ceilings; reconnect does not silently lower them. |
|
||||
| SPOOLED but no output | Check machine Printing Defaults, hardware media/method, pause and paper/label/ribbon conditions. A clear Windows queue or READY status is not proof of physical output. |
|
||||
| OUTCOME_UNKNOWN | Inspect output and the Windows queue before reprinting. Do not delete the journal or assume failure. |
|
||||
| PDF clipped or wrong size | Select actual size versus fit deliberately and configure correct default media in the Windows queue. Custom API media dimensions are not supported yet. |
|
||||
| PDF clipped or wrong size | Select actual size versus fit deliberately. Match the machine default, advertised media profile or explicit per-job PDF dimensions to the loaded stock; see [Printer media](PRINTER-MEDIA.md). |
|
||||
| Zebra prints text or blank labels | Confirm ZPL-capable hardware/driver, DPI, stock dimensions and actual printer mode. Inspect the specific document; previously confirmed output does not qualify every format/driver combination. |
|
||||
| Protected configuration cannot be opened | It belongs to another Windows identity, or the service account changed. Re-enroll through the service after resolving old jobs; do not copy a user-enrolled DPAPI file. |
|
||||
| Another worker owns the directory | Stop the duplicate CLI worker. Only one worker may own a given journal/data directory. |
|
||||
| MSI publisher warning | Current prerelease builds are unsigned. Verify the release checksum and follow local installation policy. |
|
||||
| MSI publisher warning | Stable 1.0.1 is signed. Verify the exact official release checksum and Authenticode signature as described in [Installation](INSTALLATION.md). Earlier prereleases may be unsigned. |
|
||||
|
||||
## ZPL prints but PDF does not
|
||||
|
||||
The paths use different settings: RAW ZPL carries its format instructions, while PDF is rendered through the Windows driver using its defaults. The diagnostic media choice controls the generated document, not the machine driver's configuration. A default that works in a signed-in user's **Printing Preferences** may differ from the defaults used by the Windows service.
|
||||
The paths use different settings: RAW ZPL carries its format instructions, while PDF is rendered through the Windows driver using its default or the requested per-job media. A diagnostic media choice does not rewrite the machine driver's shared configuration; short-label PDF tests send explicit job dimensions. A default that works in a signed-in user's **Printing Preferences** may differ from the defaults used by the Windows service.
|
||||
|
||||
For 4 × 6-inch direct-thermal stock with no ribbon, inspect **Printer properties → Advanced → Printing Defaults** for the queue. Use **4.00 × 6.00 inches** and **Thermal Direct**, with the printer's correct DPI; preserve other settings. Other supplies may require different dimensions or Thermal Transfer. Also verify the physical printer's print method and pause/error state. Do not assume Windows READY or an empty queue rules out a hardware problem.
|
||||
|
||||
During 0.1.7 acceptance the machine defaults were 4.09 × 2-inch Thermal Transfer. The ZPL label printed, but the PDF did not despite SPOOLED. Correcting the machine defaults and hardware state allowed one fresh reviewed PDF to print fully and correctly; the user confirmed it. No application-code fix was needed. Inspect the existing job/output before retrying to avoid duplicates. See [acceptance evidence](ACCEPTANCE.md).
|
||||
|
||||
For a non-security bug, include Windows version, application version, printer model/driver, the stage that failed, job ID and sanitized error text in a [bug report](https://github.com/QRun-IO/qqq-print-agent/issues/new/choose). Never attach credentials, enrollment codes, customer PDFs, address labels, journal databases or raw diagnostic archives without reviewing them.
|
||||
For a non-security bug, include Windows version, application version, printer model/driver, the stage that failed, job ID and sanitized error text in a [bug report](https://git.kof22.com/QRun-IO/qqq-print-agent/issues). Never attach credentials, enrollment codes, customer PDFs, address labels, journal databases or raw diagnostic archives without reviewing them.
|
||||
|
||||
Security issues belong in [private reporting](../SECURITY.md).
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
# Using QQQ Print Agent
|
||||
|
||||
This guide describes installed 0.1.7. Its 227-test source gate and all 16 installed UI checks passed. Laser PDF and Zebra ZPL/PDF output were confirmed; barcode scanning, measured alignment and broader accessibility remain separate. See [Acceptance](ACCEPTANCE.md).
|
||||
This guide describes **1.0.1**, the signed stable Windows release. Use it with qbit-printing **1.0.0** on QQQ **4.1.0-RC.21** or a compatible newer server. See [Acceptance](ACCEPTANCE.md) for dated installation and physical-print evidence.
|
||||
|
||||
## Menus
|
||||
|
||||
@@ -10,19 +10,19 @@ This guide describes installed 0.1.7. Its 227-test source gate and all 16 instal
|
||||
|
||||
## Connect and select printers
|
||||
|
||||
Open **QQQ Print Agent** from Start. In **Settings → Server connection**, choose **Connect this device**, enter your HTTPS QQQ server address and a fresh enrollment code, then connect. The Windows service holds the device credential; the companion never receives it. **Reconnect** immediately requests another connection attempt. **Sign in again** replaces the connection using a fresh code while preserving prior job evidence.
|
||||
Open **QQQ Print Agent** from Start. In **Settings → Server connection → Sign in / Connection settings**, enter your HTTPS QQQ server address and select **Discover sign-in options**. Every step of discovery, sign-in, the hand-off to the service and the service's own verification is written to the activity log, and a failure's reason is shown in the dialog as well as logged; open **View logs** when something stops. For a development server with a self-signed or private-authority certificate, enter its address and turn on **Allow untrusted server certificate for this server**; both the companion and the Windows service then accept that one server's certificate, and no other server is affected. The service runs as LocalSystem and trusts only the machine certificate store, so a certificate that your user account trusts can still be refused by the service; either install the issuing root in the Local Machine Trusted Root store or use this option. With it on, any certificate is accepted and every session is logged with a warning, so turn it off for production servers. Sign in with the advertised username/password or browser flow. **Reuse the saved pairing** is the default; an older unbound device can be explicitly associated with your account. For a new pairing, obtain an enrollment code from QQQ and choose **Connect this device**. The Windows service holds the device credential; the companion never receives it. **Reconnect** requests another connection attempt. **Sign in again** restores user authentication while preserving the saved pairing and history. Changing servers or replacing a revoked pairing requires the explicit **Set up a new pairing** choice; see [QQQ sign-in](QQQ-SIGN-IN.md).
|
||||
|
||||
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/4 × 6-inch PDF and 4 × 6-inch ZPL at 203/300/600 dpi. 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.
|
||||
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.
|
||||
|
||||
Printers initially sort by **Name ascending**. Click Name, Status, Location, Formats or Shared to sort that column; click it again to reverse direction. The active header shows an arrow and an accessible direction label. Long table values use ellipses and full tooltips; the expanded fields and editor expose the values separately from technical queue identity.
|
||||
|
||||
## Set machine media defaults before printing labels
|
||||
|
||||
PDF uses the Windows driver's machine defaults; choosing a diagnostic document size does not rewrite them. For a ribbonless direct-thermal printer loaded with 4 × 6-inch labels, open **Windows printer settings → your queue → Printer properties → Advanced → Printing Defaults**. Set width **4.00 inches**, height **6.00 inches**, and the printing method to **Thermal Direct** (driver wording may vary). Preserve unrelated settings. **Printing Preferences** for the signed-in user may not affect the LocalSystem service.
|
||||
PDF can use the machine default, an advertised media profile or explicit per-job dimensions. These job choices do not rewrite the shared Windows queue. Keep the machine default matched to the usual stock; see [Printer media](PRINTER-MEDIA.md). For a ribbonless direct-thermal printer loaded with 4 × 6-inch labels, open **Windows printer settings → your queue → Printer properties → Advanced → Printing Defaults**. Set width **4.00 inches**, height **6.00 inches**, and the printing method to **Thermal Direct** (driver wording may vary). Preserve unrelated settings. **Printing Preferences** for the signed-in user may not affect the LocalSystem service.
|
||||
|
||||
Use the method appropriate to your actual printer and supplies; Thermal Transfer expects ribbon. Check the printer's own mode and pause/error state as well as Windows. After correcting a mismatch, inspect existing output/queue state before requesting one new test. A job marked **SPOOLED** can still fail to produce paper or a label. See [label troubleshooting](TROUBLESHOOTING.md#zpl-prints-but-pdf-does-not).
|
||||
|
||||
@@ -32,7 +32,7 @@ The Jobs table aligns document title, printer, state, format and **Created** tim
|
||||
|
||||
Click Document, Printer, Status, Format or Created to change sorting; click the active header again to reverse it. Sorting applies to the entire matching result set before 50-row paging, including the bounded cloud queue preview. Changing sort returns to the first page. The direction indicator describes the last successfully loaded order; a failed refresh leaves the old rows with an unavailable/stale explanation.
|
||||
|
||||
Select a row to enable its actions. Double-click the row or choose **Details** to open a wider dialog with labeled document/printer/outcome fields and a structured event history. The dialog follows the available window size and scrolls vertically. Previous and Next browse 50 results at a time. Search matches the beginning of a document title, printer name or job ID; choose a state or Needs attention to narrow results. Long table text is abbreviated with full tooltips; Details exposes selectable full values. Older jobs retain their IDs when their title was never recorded.
|
||||
Select a row to expand its details, recent events and actions directly below that row. Double-click the row or choose **Details** to open a wider dialog with labeled document/printer/outcome fields and a structured event history. The dialog follows the available window size and scrolls vertically. Previous and Next browse 50 results at a time. Search matches the beginning of a document title, printer name or job ID; choose a state or Needs attention to narrow results. Long table text is abbreviated with full tooltips; Details exposes selectable full values. Older jobs retain their IDs when their title was never recorded.
|
||||
|
||||
- **Details** shows local events and whether each was reported to QQQ.
|
||||
- **Print again** creates a new server job after confirmation. Inspect output first, especially for uncertain or previously spooled work. The original job is never reset.
|
||||
@@ -58,11 +58,11 @@ Use **Settings → Computer details** to edit this installation’s display name
|
||||
|
||||
## Tray, logs and service
|
||||
|
||||
The footer and tray use text plus color: green for connected/ready, orange for exposed printer issues or no shared queues, and red for disconnection, failures or unreviewed uncertain jobs. Printer status is a Windows observation, not guaranteed hardware telemetry.
|
||||
The footer and tray use text plus color: green for connected/ready, orange for exposed printer issues or no shared queues, and red for disconnection, failures or unreviewed uncertain jobs. The footer also shows the WebSocket connection, configured server and live upload/download rates, with totals since service start in the traffic tooltip. Select the server to open connection settings, or use **Reconnect** and **View logs**. These counters describe application traffic, not all network traffic on the computer. Printer status is a Windows observation, not guaranteed hardware telemetry.
|
||||
|
||||
**Logs**, **View logs**, and **Open activity log** all open the same built-in AppKit viewer. Service and companion activity are together, persisted under the protected machine data directory with configurable daily rotation and retention. Use **Follow**, **Errors only**, **Copy**, and **Open logs folder**. Credentials and document payloads must never be logged; local identifiers remain operational data.
|
||||
|
||||
**Settings → Diagnostic logs → Log settings** controls retention (1–365 days, default 30) and daily size (1–100 MiB, default 20). It shows usage and whether the daily cap was reached. At the cap, printing continues but diagnostics pause until the next local day, an increased limit or explicit clearing. **Clear local logs** confirms removal of diagnostic logs only, preserving credentials, jobs, settings and journal history. The same controls are available on Logs.
|
||||
**Settings → Diagnostic logs → Log settings** controls retention (1–365 days, default 30), the file size at which a day's log rolls over (1–1024 MiB, default 100), how many rolled-over segments of a day are kept (1–100, default 10) and **Debug logging**. A file that reaches the size limit is renamed to a numbered segment (`qqq-print-agent-yyyy-MM-dd.1.log`, `.2.log`, …) and logging continues in a fresh file; logging never stops because of size, and the oldest segments beyond the kept count are removed. Debug logging adds every request route, status code and timing, every realtime frame and full failure detail; credentials, documents and enrollment codes are never logged at any level. **Clear local logs** confirms removal of diagnostic logs only, preserving credentials, jobs, settings and journal history. The same controls are available on Logs.
|
||||
|
||||
Settings shows actual Windows service state and provides **Start service**, **Restart service**, **Windows Services**, and an installer link. Restarting can interrupt a submission; wait for active work when possible. The service continues printing when the companion exits.
|
||||
|
||||
@@ -72,8 +72,8 @@ Administrators can apply [customer branding](BRANDING.md) without rebuilding. Ap
|
||||
|
||||
## Branded warehouse test prints
|
||||
|
||||
Select a printer and choose **Print test**. Choose a document format and stock from the native option boxes, then confirm **Print one test**. PDF offers Letter, A4 or a 4 × 6, 4 × 2 or 4 × 1-inch label. ZPL offers those label sizes at 203, 300 or 600 dpi. Match the loaded stock and actual device resolution; the driver-name suggestion is only a hint. The default stays 4 × 6 until you pick another size, so load 4 × 1 or 4 × 2 stock before choosing it. PDF stock choices do not change Windows queue defaults; a 4 × 2 or 4 × 1 PDF sends its own actual-size paper for that job only, and needs the matching 0.4 server.
|
||||
Select a printer and choose **Print test**. Choose a document format and stock from the native option boxes, then confirm **Print one test**. PDF offers Letter, A4 or a 4 × 6, 4 × 2 or 4 × 1-inch label. ZPL offers those label sizes at 203, 300 or 600 dpi. Match the loaded stock and actual device resolution; the driver-name suggestion is only a hint. The default stays 4 × 6 until you pick another size, so load 4 × 1 or 4 × 2 stock before choosing it. PDF stock choices do not change Windows queue defaults; a 4 × 2 or 4 × 1 PDF sends its own actual-size paper for that job only, with the released qbit-printing 1.0.0 server.
|
||||
|
||||
QQQ generates the same branded diagnostics for desktop requests and its own printer record/table actions. The pages use the QQQ logo, large warehouse identifiers, dotted zeros and distinct 1/I/l glyphs from embedded JetBrains Mono Bold. PDF embeds the font; ZPL carries rasterized glyphs in bounded document graphics, so neither depends on a resident printer font. Label diagnostics include a Code 128 barcode on the ZPL path, and all layouts include size/alignment checks. Paper output and scanner readability must be checked physically.
|
||||
QQQ generates the same branded diagnostics for desktop requests and its own printer record/table actions. The pages use Query, QQQ's mascot, large warehouse identifiers, dotted zeros and distinct 1/I/l glyphs from embedded JetBrains Mono Bold. PDF embeds the font; ZPL carries rasterized glyphs in bounded document graphics, so neither depends on a resident printer font. Label diagnostics include a Code 128 barcode on the ZPL path, and all layouts include size/alignment checks. Paper output and scanner readability must be checked physically.
|
||||
|
||||
Use qbit-printing 0.1.3-rc.1 or later for these branded profiles. Older clients can omit the profile, defaulting to Letter PDF or 203-dpi 4 × 6 ZPL. Native QQQ actions can process selected printers; group devices with the same stock and label resolution. Connecting or discovering a device never automatically sends a test.
|
||||
Use qbit-printing 1.0.0 for the complete set of branded profiles and per-job media options. Older clients can omit the profile, defaulting to Letter PDF or 203-dpi 4 × 6 ZPL. Native QQQ actions can process selected printers; group devices with the same stock and label resolution. Connecting or discovering a device never automatically sends a test.
|
||||
@@ -1,12 +1,18 @@
|
||||
# Threat model: QQQ Print Agent
|
||||
|
||||
## Realtime source boundary — 0.3.0-rc.1 release candidate
|
||||
## Current release boundary — 1.0.1
|
||||
|
||||
The signed 1.0.1 release uses the realtime and native-authentication boundaries described here. Stable distribution now includes Azure-signed first-party binaries and MSI, checksums and provenance; the earlier unsigned audit baseline below is retained as history. Gitea is the primary source; the authorized GitHub signing workflow and fixed GitHub updater feed remain trusted distribution infrastructure. See [release signing](../RELEASE-SIGNING.md) and [current release status](../RELEASE-READINESS.md).
|
||||
|
||||
## Realtime boundary
|
||||
|
||||
The remote-control adapter accepts only inventory refresh and cancellation of one locally journaled spooler job. Desired pause/disconnect state is revisioned and persists by canonical server URL and device identity; older or conflicting revisions cannot resume a device. An independently processed authenticated WSS channel carries controls and receipts while documents stream over separate same-origin HTTPS requests. There is no job/control polling fallback. A paused device claims no new work. A disconnected device retains control traffic and in-flight receipts; already-submitted printing is not recalled. Reconnect is a transport refresh, never an OS or service restart. See [the realtime contract and operational limits](../REALTIME.md).
|
||||
|
||||
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.
|
||||
|
||||
@@ -14,7 +20,7 @@ Successful cancellation means Windows accepted the request, not that printed pap
|
||||
|
||||
## Historical audit baseline
|
||||
|
||||
Installed baseline: 0.1.7-rc.1; audit source changes pending release. Reviewed: 2026-10-30. This is an implementation-level inventory. The separate independent deep scan has not yet returned its findings.
|
||||
Historical baseline: 0.1.7-rc.1, reviewed 2026-10-30. The inventory and source checkpoints below retain their original evidence and pending language; they do not describe the current release status or reopen qualification work. Current distribution and acceptance are summarized above and in [Release readiness](../RELEASE-READINESS.md).
|
||||
|
||||
## Boundaries and assets
|
||||
|
||||
@@ -31,14 +37,15 @@ The QQQ server supplies assigned document bytes over HTTPS. The Windows LocalSys
|
||||
| Rendering/driver execution | Native Windows PDF/GDI/spooler APIs; bounded render dimensions | Disposable child with bounded timeouts and parent-exit cleanup; child retains service privileges, so this is fault isolation, not a security sandbox |
|
||||
| RAW ZPL | Explicit compatible queue, trusted server workflow | ZPL can contain printer-control commands; not a sandboxed format |
|
||||
| Duplicate output | Persistent receipts and events; uncertain submissions require review | Database and physical printer cannot share a transaction |
|
||||
| Tray snapshot/logs | No device credential or document bytes; bounded recent jobs/logs | IDs/errors remain operational data and must be reviewed before sharing |
|
||||
| Tray snapshot/logs | No device credential or document bytes; bounded recent jobs/logs; debug logging adds routes, status codes, timings, frame types and certificate identities, never secrets | IDs/errors remain operational data and must be reviewed before sharing |
|
||||
| Server certificate trust | Windows machine-store validation for the service and user-store validation for the companion; rejections are logged with the policy error and certificate identity | An administrator may enable "Allow untrusted server certificate" for one named server origin (scheme, host and port) for development; it accepts any certificate (including a forged one) from that server only until turned off, is persisted in the protected store, never applies to another server, and every session created under it is logged with a warning |
|
||||
| Software distribution | Public source, pinned actions/dependencies, audited locked restore, checksums and exact-release attestations | Published prereleases remain unsigned; source checks do not qualify installed behavior |
|
||||
|
||||
## Data handling
|
||||
|
||||
The service owns `connection.dpapi`, `journal.db` and its WAL. The MSI retains state through uninstall/reinstall. Revoke/decommission the device and resolve pending jobs before deliberately deleting retained state. Only delivered terminal detail whose failures were reviewed is pruned; final-state evidence is retained. Never publish raw state or customer documents.
|
||||
|
||||
Service and companion persist serialized, credential-redacted AppKit logs with 30-day retention; recent job snapshots come from durable connection-scoped metadata. Tray colors describe observed connectivity/queue state, not a guarantee of physical printer readiness or completed output.
|
||||
Service and companion persist serialized, credential-redacted AppKit logs with 30-day retention, rolling a day's file over to numbered segments at a configurable size (default 100 MiB, at most 10 segments kept per day) rather than suppressing entries; recent job snapshots come from durable connection-scoped metadata. Tray colors describe observed connectivity/queue state, not a guarantee of physical printer readiness or completed output.
|
||||
|
||||
## Follow-up
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -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=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -Command "$ErrorActionPreference='Stop'; $trusted=@('S-1-5-18','S-1-5-32-544','S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'); function AssertNode([\[]string[\]]$p) [\{] $i=Get-Item -LiteralPath $p -Force; if($i.Attributes -band [\[]IO.FileAttributes[\]]::ReparsePoint) [\{] throw 'Machine print state contains a link. Installation refused.' [\}]; $a=$i.GetAccessControl(); if($a.GetOwner([\[]Security.Principal.SecurityIdentifier[\]]).Value -notin $trusted) [\{] throw 'Machine print state has an untrusted owner. Installation refused.' [\}]; $raw=[\[]Security.AccessControl.RawSecurityDescriptor[\]]::new($a.GetSecurityDescriptorBinaryForm(),0); if($null -eq $raw.DiscretionaryAcl) [\{] throw 'Machine print state has no ACL.' [\}]; foreach($r in $a.GetAccessRules($true,$true,[\[]Security.Principal.SecurityIdentifier[\]])) [\{] if($r.AccessControlType -eq 'Allow' -and [\[]int[\]]$r.FileSystemRights -ne 0 -and $r.IdentityReference.Value -notin $trusted) [\{] throw 'Machine print state permits untrusted access. Installation refused.' [\}] [\}]; if($i.PSIsContainer) [\{] foreach($c in [\[]IO.Directory[\]]::EnumerateFileSystemEntries($p)) [\{] AssertNode $c [\}] [\}] [\}]; try [\{] $root=Join-Path ([\[]Environment[\]]::GetFolderPath('CommonApplicationData')) 'QqqPrintAgent'; 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 'Machine print state path contains a link.' [\}] [\}] [\}]; if(-not [\[]IO.Directory[\]]::Exists($root)) [\{] $acl=[\[]Security.AccessControl.DirectorySecurity[\]]::new(); $acl.SetSecurityDescriptorSddlForm('O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)'); [\[]IO.DirectoryInfo[\]]::new($root).Create($acl) [\}]; AssertNode $root; exit 0 [\}] catch [\{] [\[]Console[\]]::Error.WriteLine($_.Exception.Message); exit 1 [\}]"" Execute="deferred" Impersonate="no" Return="check" />
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
Build the service, AppKit companion and CLI into one x64 machine MSI:
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.1.8 -ReleaseVersion 0.1.8-rc.1
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
```
|
||||
|
||||
The build publishes self-contained .NET applications into a fresh per-run directory under `artifacts/package` and builds WiX 6.0.2 authoring. An explicit publish directory must not already exist; this prevents obsolete files from entering the harvested MSI. Existing reviewed outputs can be packaged using `-SkipPublish -PayloadDirectory <path>`; all component versions must match the requested release and source version. The resulting MSI is local development output and is **unsigned unless a signing certificate is explicitly provided**.
|
||||
@@ -11,7 +11,7 @@ The build publishes self-contained .NET applications into a fresh per-run direct
|
||||
For a signing certificate already installed in the Windows certificate store:
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.1.3 -ReleaseVersion 0.1.3-rc.1 -CertificateThumbprint '<thumbprint>' -TimestampServer 'https://your-approved-timestamp-service'
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1 -CertificateThumbprint '<thumbprint>' -TimestampServer 'https://your-approved-timestamp-service'
|
||||
```
|
||||
|
||||
Signing needs `signtool.exe` on PATH. The script signs the first-party executables before packaging and signs/verifies the resulting MSI. Stable releases are signed by the release workflow with Azure Artifact Signing instead; see [RELEASE-SIGNING.md](../docs/RELEASE-SIGNING.md). Never store signing secrets here.
|
||||
@@ -36,4 +36,4 @@ The installer suppresses MSI validator ICE03 because the bundled Microsoft WinUI
|
||||
|
||||
References: [WiX service installation](https://docs.firegiant.com/wix/schema/wxs/serviceinstall/), [file harvesting](https://docs.firegiant.com/wix/schema/wxs/files/), [MSI directory security](https://docs.firegiant.com/wix/schema/wxs/permissionex/).
|
||||
|
||||
The 0.1.3-rc.1 source gate passed 163 tests with zero warnings/errors. The new MSI is being packaged; installed startup/UI and actual in-app upgrade acceptance remain pending. Same-numeric-version upgrades support RC transitions; the updater separately enforces a strictly newer semantic version.
|
||||
The signed stable [1.0.1 MSI](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) is published and installed. See [release readiness](../docs/RELEASE-READINESS.md) for the dated release and installation evidence. Same-numeric-version upgrades support RC transitions; the updater separately enforces a strictly newer semantic version.
|
||||
@@ -23,7 +23,7 @@ public static class Program
|
||||
["--apply-update", string request] => ExecuteAsync(() => MsiUpdateInstaller.ApplyAsync(request)),
|
||||
["--auth-callback", string callback] => ExecuteAsync(async () =>
|
||||
{
|
||||
await QqqBrowserCallback.ForwardAsync(callback);
|
||||
await QqqBrowserCallback.ForwardFromHandlerAsync(callback);
|
||||
return 0;
|
||||
}),
|
||||
_ => ExecuteAsync(() => DispatchAsync(args)),
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -33,28 +66,69 @@ public sealed class QqqApiDiscovery
|
||||
|
||||
|
||||
|
||||
/// <summary>A one-line, credential-free summary for the log: API version and every advertised extension with its version and path.</summary>
|
||||
public string Describe()
|
||||
{
|
||||
string extensions = Extensions is null || Extensions.Count == 0 ? "no extensions"
|
||||
: string.Join(", ", Extensions.Where(extension => extension is not null).Select(extension => $"{extension.Name} {extension.Version} at {extension.PathPrefix}"));
|
||||
return $"QQQ API {(string.IsNullOrEmpty(ApiVersion) ? "(missing)" : ApiVersion)}, {Features?.Count ?? 0} feature(s), client channels [{string.Join(", ", ClientChannels ?? [])}], {extensions}.";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the server offers what this agent needs: an API version of 1.8 or later (1.9 when native browser sign-in is
|
||||
/// required, together with that feature), the core features the agent calls, the api client channel, and exactly one printing
|
||||
/// extension at the standard path in major version 1 or 2. Anything else throws a configuration error, so an unsuitable
|
||||
/// server is refused with one clear message instead of failing later in some other way.
|
||||
/// </summary>
|
||||
internal void Validate(bool nativeSession = false)
|
||||
internal void Validate(bool nativeSession = false, Action<string>? report = null)
|
||||
{
|
||||
List<string> problems = Problems(nativeSession);
|
||||
if(problems.Count != 0)
|
||||
{
|
||||
report?.Invoke("Discovery rejected: " + string.Join("; ", problems) + ".");
|
||||
throw Configuration();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Lists every way the discovery document falls short of what this agent needs, in plain words, so the log can say which
|
||||
/// server-side requirement is missing. The thrown failure stays generic; only the local log sees this detail.
|
||||
/// </summary>
|
||||
private List<string> Problems(bool nativeSession)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
string[] required = ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
|
||||
if(!SupportedVersion(ApiVersion, nativeSession ? 9u : 8u) || Features is null
|
||||
|| required.Any(feature => !Features.Contains(feature, StringComparer.Ordinal))
|
||||
|| ClientChannels is null || !ClientChannels.Contains("api", StringComparer.Ordinal)
|
||||
|| Extensions is null || Extensions.Count(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)) != 1)
|
||||
uint minimumMinor = nativeSession ? 9u : 8u;
|
||||
if(!SupportedVersion(ApiVersion, minimumMinor))
|
||||
{ problems.Add($"API version '{ApiVersion}' is not a plain 1.{minimumMinor}+ release{(nativeSession ? " (1.9 is needed for native browser sign-in)" : "")}"); }
|
||||
if(Features is null)
|
||||
{ problems.Add("no features list"); }
|
||||
else
|
||||
{
|
||||
throw Configuration();
|
||||
string[] missing = [.. required.Where(feature => !Features.Contains(feature, StringComparer.Ordinal))];
|
||||
if(missing.Length != 0)
|
||||
{ problems.Add("missing feature(s) " + string.Join(", ", missing)); }
|
||||
if(nativeSession && !Features.Contains("nativeSessionLogin", StringComparer.Ordinal))
|
||||
{ problems.Add("the nativeSessionLogin feature is not advertised, so browser sign-in cannot be used"); }
|
||||
}
|
||||
Extension printing = Extensions.Single(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal));
|
||||
if(!(SupportedVersion(printing.Version, 0) || SupportedVersion(printing.Version, 0, "2")) || !string.Equals(printing.PathPrefix, "/qbit/printing", StringComparison.Ordinal)
|
||||
|| (nativeSession && !Features.Contains("nativeSessionLogin", StringComparer.Ordinal)))
|
||||
if(ClientChannels is null || !ClientChannels.Contains("api", StringComparer.Ordinal))
|
||||
{ problems.Add("the 'api' client channel is not offered"); }
|
||||
int printingCount = Extensions?.Count(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)) ?? 0;
|
||||
if(printingCount != 1)
|
||||
{ problems.Add(printingCount == 0 ? "no printing extension is advertised (is the printing QBit installed and enabled?)" : $"{printingCount} printing extensions are advertised, expected exactly one"); }
|
||||
else
|
||||
{
|
||||
throw Configuration();
|
||||
Extension printing = Extensions!.Single(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal));
|
||||
if(!(SupportedVersion(printing.Version, 0) || SupportedVersion(printing.Version, 0, "2")))
|
||||
{ problems.Add($"printing extension version '{printing.Version}' is not a plain 1.x or 2.x release"); }
|
||||
if(!string.Equals(printing.PathPrefix, "/qbit/printing", StringComparison.Ordinal))
|
||||
{ problems.Add($"printing extension path '{printing.PathPrefix}' is not /qbit/printing"); }
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)";
|
||||
}
|
||||
@@ -28,27 +28,14 @@ public sealed partial class QqqSessionClient
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in requested with callback {redirectUri.AbsoluteUri}.");
|
||||
QqqAuthenticationMetadata metadata = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
QqqAuthenticationMetadata.NativeGrant grant = ValidateNativeGrant(metadata);
|
||||
if(!ValidRedirect(redirectUri) || !grant.RedirectUris.Contains(redirectUri.AbsoluteUri, StringComparer.Ordinal))
|
||||
{ throw QqqApiDiscovery.Configuration(); }
|
||||
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);
|
||||
JsonElement provider = document.RootElement;
|
||||
if(!provider.TryGetProperty("issuer", out JsonElement issuer) || issuer.ValueKind != JsonValueKind.String
|
||||
|| !string.Equals(issuer.GetString(), grant.Issuer, StringComparison.Ordinal)
|
||||
|| !provider.TryGetProperty("authorization_endpoint", out JsonElement authorization) || authorization.ValueKind != JsonValueKind.String
|
||||
|| !TryHttps(authorization.GetString(), out Uri? authorizationUri)
|
||||
|| !SameOrigin(authorizationUri!, new Uri(grant.Issuer))
|
||||
|| !provider.TryGetProperty("code_challenge_methods_supported", out JsonElement methods) || methods.ValueKind != JsonValueKind.Array
|
||||
|| !methods.EnumerateArray().Any(value => value.ValueKind == JsonValueKind.String && string.Equals(value.GetString(), "S256", StringComparison.Ordinal)))
|
||||
{
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
RequireListedRedirect(grant, redirectUri);
|
||||
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,
|
||||
@@ -56,24 +43,89 @@ 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))),
|
||||
};
|
||||
var builder = new UriBuilder(authorizationUri!)
|
||||
var builder = new UriBuilder(authorizationUri)
|
||||
{
|
||||
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;
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Browser sign-in start", exception))
|
||||
{ throw; }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Refuses a callback address that is not an app link or private scheme, or that the server's native grant does not list exactly, saying which in the log.</summary>
|
||||
private void RequireListedRedirect(QqqAuthenticationMetadata.NativeGrant grant, Uri redirectUri)
|
||||
{
|
||||
if(!ValidRedirect(redirectUri))
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in refused locally: the callback {redirectUri.AbsoluteUri} is not an HTTPS app link or dotted private scheme.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(!grant.RedirectUris.Contains(redirectUri.AbsoluteUri, StringComparer.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in refused: the server's native grant does not list the callback {redirectUri.AbsoluteUri}; it lists [{string.Join(", ", grant.RedirectUris)}]. Add this callback to the QQQ native session grant.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the identity provider's discovery document and returns its authorization endpoint, after checking that the
|
||||
/// provider names the same issuer QQQ advertises, an HTTPS endpoint on that issuer's origin, and S256 support. Each
|
||||
/// shortfall is logged before the generic configuration failure is thrown.
|
||||
/// </summary>
|
||||
private async Task<Uri> ProviderAuthorizationEndpointAsync(QqqAuthenticationMetadata.NativeGrant grant, CancellationToken cancellationToken)
|
||||
{
|
||||
_diagnostics.Info($"Fetching the identity provider's discovery document from {QqqSessionDiagnostics.Describe(new Uri(grant.DiscoveryUrl))}.");
|
||||
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);
|
||||
JsonElement provider = document.RootElement;
|
||||
string? providerIssuer = provider.TryGetProperty("issuer", out JsonElement issuer) && issuer.ValueKind == JsonValueKind.String ? issuer.GetString() : null;
|
||||
string? providerAuthorization = provider.TryGetProperty("authorization_endpoint", out JsonElement authorization) && authorization.ValueKind == JsonValueKind.String ? authorization.GetString() : null;
|
||||
bool s256 = provider.TryGetProperty("code_challenge_methods_supported", out JsonElement methods) && methods.ValueKind == JsonValueKind.Array
|
||||
&& methods.EnumerateArray().Any(value => value.ValueKind == JsonValueKind.String && string.Equals(value.GetString(), "S256", StringComparison.Ordinal));
|
||||
_diagnostics.Info($"Provider discovery: issuer {providerIssuer ?? "(missing)"}, authorization endpoint {providerAuthorization ?? "(missing)"}, S256 {(s256 ? "supported" : "not listed")}.");
|
||||
if(!string.Equals(providerIssuer, grant.Issuer, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in refused: the provider's issuer '{providerIssuer}' does not exactly match the issuer QQQ advertises '{grant.Issuer}'.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(!TryHttps(providerAuthorization, out Uri? authorizationUri) || !SameOrigin(authorizationUri!, new Uri(grant.Issuer)))
|
||||
{
|
||||
_diagnostics.Info("Browser sign-in refused: the provider's authorization endpoint is not an HTTPS address on the issuer's origin.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(!s256)
|
||||
{
|
||||
_diagnostics.Info("Browser sign-in refused: the provider does not list S256 among its supported code challenge methods.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
return authorizationUri!;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Finishes a browser sign-in from the provider's redirect. The attempt is consumed whatever happens, so a callback can be
|
||||
/// used once; it must be the pending attempt, less than five minutes old, with no fragment or credentials and exactly the
|
||||
@@ -90,31 +142,87 @@ public sealed partial class QqqSessionClient
|
||||
QqqBrowserLogin? pending = _browserLogin;
|
||||
_browserLogin = null;
|
||||
TimeSpan age = _timeProvider.GetElapsedTime(attempt.StartedAt);
|
||||
if(!ReferenceEquals(pending, attempt)
|
||||
|| age < TimeSpan.Zero || age > TimeSpan.FromMinutes(5)
|
||||
|| !callback.IsAbsoluteUri || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0
|
||||
_diagnostics.Info($"Browser callback received at {QqqSessionDiagnostics.Describe(callback)} after {age.TotalSeconds:0} s.");
|
||||
if(!ReferenceEquals(pending, attempt))
|
||||
{
|
||||
_diagnostics.Info("Browser callback refused: it does not belong to the pending sign-in attempt (a new attempt was started, or the attempt was already used).");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(age < TimeSpan.Zero || age > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
_diagnostics.Info("Browser callback refused: the attempt is older than five minutes. Start the browser sign-in again.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(!callback.IsAbsoluteUri || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0
|
||||
|| !string.Equals(callback.GetLeftPart(UriPartial.Path), attempt.RedirectUri.AbsoluteUri, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Browser callback refused: its address does not match the registered callback {attempt.RedirectUri.AbsoluteUri} exactly, or it carries a fragment or user information.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
Dictionary<string, string> values = CallbackValues(callback);
|
||||
if(values.ContainsKey("error") || !values.TryGetValue("state", out string? state) || !string.Equals(state, attempt.State, StringComparison.Ordinal)
|
||||
|| !values.TryGetValue("code", out string? code) || string.IsNullOrWhiteSpace(code) || code.Length > 4096
|
||||
|| (values.TryGetValue("iss", out string? issuer) && !string.Equals(issuer, attempt.Issuer, StringComparison.Ordinal)))
|
||||
_diagnostics.Debug(() => $"Browser callback parameters: [{string.Join(", ", values.Keys)}] (values not logged).");
|
||||
if(values.TryGetValue("error", out string? providerError))
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in refused by the identity provider: error '{providerError}'{(values.TryGetValue("error_description", out string? description) ? " (" + description + ")" : "")}.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(!values.TryGetValue("state", out string? state) || !string.Equals(state, attempt.State, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info("Browser callback refused: the state parameter is missing or does not match this attempt.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(!values.TryGetValue("code", out string? code) || string.IsNullOrWhiteSpace(code) || code.Length > 4096)
|
||||
{
|
||||
_diagnostics.Info("Browser callback refused: no usable authorization code was returned.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(values.TryGetValue("iss", out string? issuer) && !string.Equals(issuer, attempt.Issuer, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Browser callback refused: the issuer '{issuer}' differs from the expected '{attempt.Issuer}'.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
_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);
|
||||
_diagnostics.Info("Browser sign-in accepted; a server-managed session is now held.");
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Browser sign-in completion", exception))
|
||||
{ throw; }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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
|
||||
@@ -122,19 +230,50 @@ public sealed partial class QqqSessionClient
|
||||
/// </summary>
|
||||
private QqqAuthenticationMetadata.NativeGrant ValidateNativeGrant(QqqAuthenticationMetadata metadata)
|
||||
{
|
||||
_discovery!.Validate(nativeSession: true);
|
||||
_discovery!.Validate(nativeSession: true, report: _diagnostics.Info);
|
||||
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
|
||||
if(!string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) || !string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal)
|
||||
|| grant is null || !string.Equals(grant.Flow, QqqAuthenticationMetadata.NativeSessionFlow, StringComparison.Ordinal)
|
||||
|| !string.Equals(grant.CodeChallengeMethod, "S256", StringComparison.Ordinal) || !string.Equals(grant.Prompt, "login", StringComparison.Ordinal)
|
||||
|| !TryHttps(grant.Issuer, out _) || !TryHttps(grant.DiscoveryUrl, out _)
|
||||
|| string.IsNullOrWhiteSpace(metadata.Values!.ClientId) || metadata.Values.ClientId.Length > 1024
|
||||
|| string.IsNullOrWhiteSpace(metadata.Values.Scopes) || !metadata.Values.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains("openid", StringComparer.Ordinal)
|
||||
|| grant.RedirectUris is null || grant.RedirectUris.Count is < 1 or > 32
|
||||
|| grant.RedirectUris.Any(value => !Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) || !ValidRedirect(uri)))
|
||||
var problems = new List<string>();
|
||||
if(!string.Equals(Origin.Scheme, "https", StringComparison.Ordinal))
|
||||
{ problems.Add("the server origin is not HTTPS"); }
|
||||
if(!string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
|
||||
{ problems.Add($"authentication type is '{metadata.Type}', not OAUTH2"); }
|
||||
if(grant is null)
|
||||
{ problems.Add("no nativeSessionLogin grant is published"); }
|
||||
else
|
||||
{
|
||||
if(!string.Equals(grant.Flow, QqqAuthenticationMetadata.NativeSessionFlow, StringComparison.Ordinal))
|
||||
{ problems.Add($"grant flow is '{grant.Flow}', expected {QqqAuthenticationMetadata.NativeSessionFlow}"); }
|
||||
if(!string.Equals(grant.CodeChallengeMethod, "S256", StringComparison.Ordinal))
|
||||
{ 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 _))
|
||||
{ problems.Add($"discovery address '{grant.DiscoveryUrl}' is not a plain HTTPS address"); }
|
||||
if(grant.RedirectUris is null || grant.RedirectUris.Count is < 1 or > 32)
|
||||
{ problems.Add($"{grant.RedirectUris?.Count ?? 0} redirect address(es) listed; expected 1 to 32"); }
|
||||
else
|
||||
{
|
||||
string[] invalid = [.. grant.RedirectUris.Where(value => !Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) || !ValidRedirect(uri))];
|
||||
if(invalid.Length != 0)
|
||||
{ problems.Add("redirect address(es) not an HTTPS app link or dotted private scheme: " + string.Join(", ", invalid)); }
|
||||
}
|
||||
}
|
||||
if(string.IsNullOrWhiteSpace(metadata.Values?.ClientId) || metadata.Values.ClientId.Length > 1024)
|
||||
{ problems.Add("client id is missing or too long"); }
|
||||
if(string.IsNullOrWhiteSpace(metadata.Values?.Scopes) || !metadata.Values.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains("openid", StringComparer.Ordinal))
|
||||
{ problems.Add($"scopes '{metadata.Values?.Scopes}' do not include openid"); }
|
||||
if(problems.Count != 0)
|
||||
{
|
||||
_diagnostics.Info("Native browser sign-in grant rejected: " + string.Join("; ", problems) + ".");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
_diagnostics.Info($"Native browser sign-in grant accepted: issuer {grant!.Issuer}, {grant.RedirectUris!.Count} redirect address(es).");
|
||||
return grant;
|
||||
}
|
||||
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public sealed partial class QqqSessionClient
|
||||
/// <summary>Refreshes standard discovery once per socket establishment, then connects only to that origin with the current session and approved device proof.</summary>
|
||||
public async Task<WebSocket> ConnectPrintingAsync(TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var invoker = new HttpMessageInvoker(new SocketsHttpHandler { AllowAutoRedirect = false, UseCookies = false });
|
||||
HttpMessageInvoker invoker = CreateSocketInvoker();
|
||||
var socket = new ClientWebSocket();
|
||||
long generation;
|
||||
try
|
||||
@@ -25,46 +25,49 @@ public sealed partial class QqqSessionClient
|
||||
try
|
||||
{
|
||||
RequireAuthenticated();
|
||||
_diagnostics.Info("Preparing the realtime printing socket: refreshing discovery first.");
|
||||
await RefreshPrintingDiscoveryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if(_device is null || _binding?.HasApprovedDevice != true)
|
||||
{
|
||||
_diagnostics.Info("Realtime socket refused locally: no approved device binding is held. Enroll or re-associate this computer first.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "An approved device binding is required.");
|
||||
}
|
||||
generation = _generation;
|
||||
socket.Options.AddSubProtocol("qqq-printing.v1");
|
||||
socket.Options.CollectHttpResponseDetails = true;
|
||||
socket.Options.SetRequestHeader("Cookie", CookieHeader());
|
||||
socket.Options.SetRequestHeader("Origin", Origin.GetLeftPart(UriPartial.Authority));
|
||||
socket.Options.SetRequestHeader("X-QQQ-Client", ClientChannel);
|
||||
socket.Options.SetRequestHeader("X-QQQ-Device-Id", _device.DeviceId);
|
||||
socket.Options.SetRequestHeader("X-QQQ-Device-Token", _device.DeviceToken);
|
||||
socket.Options.KeepAliveInterval = heartbeatInterval;
|
||||
socket.Options.KeepAliveTimeout = heartbeatTimeout;
|
||||
ConfigureSocket(socket, _device, heartbeatInterval, heartbeatTimeout);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
var uri = new UriBuilder(new Uri(Origin, PrintingPath + "device/realtime")) { Scheme = string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) ? "wss" : "ws" };
|
||||
_diagnostics.Info($"Opening the realtime socket to {QqqSessionDiagnostics.Describe(uri.Uri)} with subprotocol qqq-printing.v1 (session cookie and device proof attached; heartbeat every {heartbeatInterval.TotalSeconds:0} s, timeout {heartbeatTimeout.TotalSeconds:0} s).");
|
||||
long started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
await socket.ConnectAsync(uri.Uri, invoker, cancellationToken).ConfigureAwait(false);
|
||||
_diagnostics.Info($"Realtime socket upgraded in {Elapsed(started)}; server selected subprotocol {socket.SubProtocol ?? "(none)"}.");
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if(generation != _generation)
|
||||
{ throw AuthenticationRequired(); }
|
||||
{
|
||||
_diagnostics.Info("Realtime socket discarded: the session changed while it was connecting.");
|
||||
throw AuthenticationRequired();
|
||||
}
|
||||
if(!string.Equals(socket.SubProtocol, "qqq-printing.v1", StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info("Realtime socket refused: the server did not select the qqq-printing.v1 subprotocol.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return new OwnedPrintingSocket(socket, invoker);
|
||||
}
|
||||
catch(WebSocketException) when(socket.HttpStatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden)
|
||||
catch(WebSocketException exception) when(socket.HttpStatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
_diagnostics.Info($"Realtime socket upgrade refused with HTTP {(int)socket.HttpStatusCode} ({exception.WebSocketErrorCode}); authorization is required.");
|
||||
socket.Dispose();
|
||||
invoker.Dispose();
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.AuthenticationRequired, "QQQ authorization is required to restore the realtime connection.");
|
||||
}
|
||||
catch
|
||||
catch(Exception exception)
|
||||
{
|
||||
ReportSocketFailure(exception, socket);
|
||||
socket.Dispose();
|
||||
invoker.Dispose();
|
||||
throw;
|
||||
@@ -73,6 +76,57 @@ public sealed partial class QqqSessionClient
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP invoker for the WebSocket upgrade: no redirects, no cookie jar (the cookie is attached by hand) and the same
|
||||
/// certificate policy as the HTTP client, so a rejected or explicitly allowed certificate is logged the same way. See the
|
||||
/// MA0039 note on the constructor.
|
||||
/// </summary>
|
||||
private HttpMessageInvoker CreateSocketInvoker()
|
||||
{
|
||||
#pragma warning disable MA0039
|
||||
return new HttpMessageInvoker(new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => EvaluateCertificate(Origin.Authority,
|
||||
certificate is null ? null : certificate as System.Security.Cryptography.X509Certificates.X509Certificate2 ?? new System.Security.Cryptography.X509Certificates.X509Certificate2(certificate), errors),
|
||||
},
|
||||
});
|
||||
#pragma warning restore MA0039
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sets the subprotocol, session cookie, origin, client channel, device proof and keep-alive on a socket before it connects; the caller holds the gate.</summary>
|
||||
private void ConfigureSocket(ClientWebSocket socket, QqqDeviceCredential device, TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout)
|
||||
{
|
||||
socket.Options.AddSubProtocol("qqq-printing.v1");
|
||||
socket.Options.CollectHttpResponseDetails = true;
|
||||
socket.Options.SetRequestHeader("Cookie", CookieHeader());
|
||||
socket.Options.SetRequestHeader("Origin", Origin.GetLeftPart(UriPartial.Authority));
|
||||
socket.Options.SetRequestHeader("X-QQQ-Client", ClientChannel);
|
||||
socket.Options.SetRequestHeader("X-QQQ-Device-Id", device.DeviceId);
|
||||
socket.Options.SetRequestHeader("X-QQQ-Device-Token", device.DeviceToken);
|
||||
socket.Options.KeepAliveInterval = heartbeatInterval;
|
||||
socket.Options.KeepAliveTimeout = heartbeatTimeout;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Logs why the socket could not be established: the WebSocket error and any HTTP status from the upgrade, a cancellation, or the exception type and message.</summary>
|
||||
private void ReportSocketFailure(Exception exception, ClientWebSocket socket)
|
||||
{
|
||||
int status = (int)socket.HttpStatusCode;
|
||||
_diagnostics.Info(exception is WebSocketException webSocket
|
||||
? $"Realtime socket upgrade failed: {webSocket.WebSocketErrorCode}{(status == 0 ? "" : ", HTTP " + status.ToString(System.Globalization.CultureInfo.InvariantCulture))}: {webSocket.Message}"
|
||||
: exception is OperationCanceledException ? "Realtime socket connection was cancelled or timed out."
|
||||
: "Realtime socket connection failed: " + QqqSessionDiagnostics.Describe(exception));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Refreshes discovery under the caller-held session gate and rejects incompatible printing majors before sending any WebSocket credentials.</summary>
|
||||
private async Task RefreshPrintingDiscoveryAsync(CancellationToken token)
|
||||
{
|
||||
@@ -81,7 +135,9 @@ public sealed partial class QqqSessionClient
|
||||
QqqApiDiscovery.Extension extension = discovery.Extensions.Single(item => string.Equals(item.Name, "printing", StringComparison.Ordinal));
|
||||
if(!Version.TryParse(extension.Version, out Version? version) || version.Major != 2)
|
||||
{
|
||||
_diagnostics.Info($"Realtime printing needs printing extension 2.x; the server advertises {extension.Version}.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.ConfigurationRequired, "Upgrade the server to printing extension 2.x for realtime printing.");
|
||||
}
|
||||
_diagnostics.Info($"Printing extension {extension.Version} supports realtime; media negotiation {(discovery.SupportsPrinterMedia() ? "available" : "not available (needs 2.3+)")}.");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Transport;
|
||||
@@ -22,6 +24,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
private readonly HttpClient _http;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ApplicationTrafficMeter? _traffic;
|
||||
private readonly QqqSessionDiagnostics _diagnostics;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private CookieContainer _cookies = new();
|
||||
private QqqApiDiscovery? _discovery;
|
||||
@@ -40,9 +43,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
/// manages cookies, because redirects are refused and the session cookie is attached by hand to each request, and the
|
||||
/// HTTP client has no overall timeout because every request sets its own bound.
|
||||
/// </summary>
|
||||
public QqqSessionClient(Uri origin, HttpMessageHandler? handler = null, TimeProvider? timeProvider = null, bool allowLoopbackHttp = false, ApplicationTrafficMeter? traffic = null)
|
||||
public QqqSessionClient(Uri origin, HttpMessageHandler? handler = null, TimeProvider? timeProvider = null, bool allowLoopbackHttp = false, ApplicationTrafficMeter? traffic = null,
|
||||
QqqSessionDiagnostics? diagnostics = null, bool allowUntrustedCertificate = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(origin);
|
||||
_diagnostics = diagnostics ?? QqqSessionDiagnostics.None;
|
||||
AllowsUntrustedCertificate = allowUntrustedCertificate;
|
||||
if(!origin.IsAbsoluteUri || origin.UserInfo.Length != 0 || origin.Query.Length != 0 || origin.Fragment.Length != 0
|
||||
|| !string.Equals(origin.AbsolutePath, "/", StringComparison.Ordinal)
|
||||
|| (!string.Equals(origin.Scheme, "https", StringComparison.Ordinal)
|
||||
@@ -53,10 +59,23 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
Origin = new Uri(origin.GetLeftPart(UriPartial.Authority) + "/", UriKind.Absolute);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_traffic = traffic;
|
||||
_http = new HttpClient(handler ?? new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false })
|
||||
// MA0039: the callback keeps the platform's verdict (it accepts only SslPolicyErrors.None) unless the administrator
|
||||
// explicitly allowed untrusted certificates for a development server; it exists so a rejection is logged with its
|
||||
// reason instead of failing silently, and so the opt-in is visible in the log on every connection.
|
||||
#pragma warning disable MA0039
|
||||
_http = new HttpClient(handler ?? new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) => EvaluateCertificate(request.RequestUri?.Authority ?? Origin.Authority, certificate, errors),
|
||||
})
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan,
|
||||
};
|
||||
#pragma warning restore MA0039
|
||||
_diagnostics.Info($"Session client created for {Origin.Scheme}://{Origin.Authority} (plain HTTP {(string.Equals(Origin.Scheme, "http", StringComparison.Ordinal) ? "allowed for loopback" : "not allowed")}; untrusted server certificates {(allowUntrustedCertificate ? "ALLOWED" : "refused")}).");
|
||||
if(allowUntrustedCertificate)
|
||||
{ _diagnostics.Info($"WARNING: certificate validation is relaxed for {Origin.Authority}. Any certificate, including a forged one, will be accepted. Use this only for development servers."); }
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +83,40 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
public Uri Origin { get; }
|
||||
public bool IsAuthenticated => _sessionToken is not null;
|
||||
|
||||
/// <summary>True when this client was told to accept a server certificate that Windows does not trust (self-signed or private authority), for development servers only.</summary>
|
||||
public bool AllowsUntrustedCertificate { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a TLS server certificate is accepted and, either way, says why in the log. A certificate Windows trusts
|
||||
/// is accepted silently (details at debug level). One it does not trust is rejected with the exact policy errors, the
|
||||
/// subject, issuer and validity, and the remedy, unless untrusted certificates were explicitly allowed for this client,
|
||||
/// in which case it is accepted with a warning naming the certificate so the exception is never silent.
|
||||
/// </summary>
|
||||
internal bool EvaluateCertificate(string host, X509Certificate2? certificate, SslPolicyErrors errors)
|
||||
{
|
||||
string summary = certificate is null ? "no certificate presented"
|
||||
: $"subject '{certificate.Subject}', issuer '{certificate.Issuer}', valid {certificate.NotBefore.ToUniversalTime():yyyy-MM-dd} to {certificate.NotAfter.ToUniversalTime():yyyy-MM-dd}, thumbprint {certificate.Thumbprint}";
|
||||
if(errors == SslPolicyErrors.None)
|
||||
{
|
||||
_diagnostics.Debug(() => $"TLS certificate for {host} accepted by Windows: {summary}.");
|
||||
return true;
|
||||
}
|
||||
if(AllowsUntrustedCertificate)
|
||||
{
|
||||
_diagnostics.Info($"TLS certificate for {host} is NOT trusted by Windows ({errors}) but was accepted because untrusted certificates are allowed for this server: {summary}.");
|
||||
return true;
|
||||
}
|
||||
_diagnostics.Info($"TLS certificate for {host} rejected: {errors}. Certificate: {summary}. "
|
||||
+ (errors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors)
|
||||
? "Windows does not trust the issuing authority for the account this process runs as (the service runs as LocalSystem and reads the machine store, not your user store). Install the issuer's root certificate in the Local Machine Trusted Root store, or enable 'Allow untrusted server certificate' in Connection settings for a development server."
|
||||
: errors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch)
|
||||
? "The certificate is not issued for this host name or address; use the address the certificate names, or reissue it."
|
||||
: "The server presented no usable certificate."));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the validated discovery document, fetching it when none is cached; the client gate makes concurrent callers share one fetch.</summary>
|
||||
@@ -108,16 +161,25 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_diagnostics.Info("Password sign-in requested.");
|
||||
_diagnostics.Debug(() => $"Password sign-in user name: '{username}' ({username.Length} characters); password {password.Length} characters (never logged).");
|
||||
QqqAuthenticationMetadata authentication = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
if(!string.Equals(authentication.Type, "TABLE_BASED", StringComparison.Ordinal))
|
||||
{ throw QqqApiDiscovery.Configuration(); }
|
||||
{
|
||||
_diagnostics.Info($"Password sign-in refused locally: the server authenticates with {authentication.Type}, not TABLE_BASED.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
ClearSession();
|
||||
string basic = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + password));
|
||||
_diagnostics.Info("Sending password sign-in to /qqq/v1/manageSession (HTTP Basic, this request only).");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post, JsonBody(new { }),
|
||||
MetadataLimit, null, null, basic, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
AdoptManagedSession(response);
|
||||
_diagnostics.Info("Password sign-in accepted; a server-managed session is now held.");
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Password sign-in", exception))
|
||||
{ throw; }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
@@ -131,17 +193,24 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(credential);
|
||||
if(credential.Origin != Origin)
|
||||
{ throw new ArgumentException("The session belongs to another server.", nameof(credential)); }
|
||||
{
|
||||
_diagnostics.Info($"Saved session refused: it belongs to {QqqSessionDiagnostics.Describe(credential.Origin)}, not {QqqSessionDiagnostics.Describe(Origin)}.");
|
||||
throw new ArgumentException("The session belongs to another server.", nameof(credential));
|
||||
}
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_diagnostics.Info($"Restoring a saved session for {Origin.Authority}: checking discovery and authentication metadata first.");
|
||||
await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
ClearSession();
|
||||
SetSession(credential.SessionToken);
|
||||
_diagnostics.Info("Asking the server to confirm the saved session.");
|
||||
await ResumeCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
_diagnostics.Info("Saved session confirmed by the server.");
|
||||
}
|
||||
catch
|
||||
catch(Exception exception)
|
||||
{
|
||||
ReportFailure("Session restore", exception);
|
||||
ClearSession();
|
||||
throw;
|
||||
}
|
||||
@@ -155,7 +224,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{ await ResumeCoreAsync(cancellationToken).ConfigureAwait(false); }
|
||||
{
|
||||
_diagnostics.Info("Renewing the session with the server.");
|
||||
await ResumeCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Session renewal", exception))
|
||||
{ throw; }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
@@ -169,11 +243,17 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
{
|
||||
if(_sessionToken is not null)
|
||||
{
|
||||
_diagnostics.Info($"Signing out of {Origin.Authority}: ending the server session.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/logout"), HttpMethod.Post, JsonBody(new { }),
|
||||
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
_diagnostics.Info("Server session ended.");
|
||||
}
|
||||
else
|
||||
{ _diagnostics.Info("Sign-out requested with no held session; clearing local state only."); }
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Sign-out", exception))
|
||||
{ throw; }
|
||||
finally
|
||||
{
|
||||
ClearSession();
|
||||
@@ -211,20 +291,28 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
try
|
||||
{
|
||||
RequireAuthenticated();
|
||||
_diagnostics.Info($"Binding device {credential.DeviceId} to the session{(claimLegacyDevice ? ", claiming it as a legacy device first" : "")}.");
|
||||
if(claimLegacyDevice)
|
||||
{
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, PrintingPath + "device/bind"), HttpMethod.Post, JsonBody(new { }),
|
||||
MetadataLimit, CookieHeader(), credential, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
_diagnostics.Info($"Server accepted the legacy claim for device {credential.DeviceId}.");
|
||||
}
|
||||
QqqSessionBinding identity = await IdentityCoreAsync(credential, cancellationToken).ConfigureAwait(false);
|
||||
if(!string.Equals(identity.DeviceId, credential.DeviceId, StringComparison.Ordinal))
|
||||
{ throw InvalidResponse(); }
|
||||
{
|
||||
_diagnostics.Info($"Device binding refused: the server reports device {identity.DeviceId ?? "(none)"} for this session, not {credential.DeviceId}.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
_device = credential;
|
||||
_binding = identity;
|
||||
_generation++;
|
||||
_diagnostics.Info($"Device {credential.DeviceId} bound to principal {identity.PrincipalId} in tenant {identity.TenantId}.");
|
||||
return identity;
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Device binding", exception))
|
||||
{ throw; }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
@@ -246,10 +334,14 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
device = relativePath.StartsWith("device/", StringComparison.Ordinal) ? _device : null;
|
||||
if(relativePath.StartsWith("device/", StringComparison.Ordinal) && (device is null || _binding?.HasApprovedDevice != true))
|
||||
{
|
||||
_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));
|
||||
}
|
||||
@@ -287,11 +379,14 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
{
|
||||
if(_discovery is not null)
|
||||
{ return _discovery; }
|
||||
_diagnostics.Info($"Discovering QQQ at {Origin.Authority}: requesting /qqq/v1/version.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/version"), HttpMethod.Get, null, MetadataLimit,
|
||||
null, null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
QqqApiDiscovery discovery = Parse<QqqApiDiscovery>(response);
|
||||
discovery.Validate();
|
||||
_diagnostics.Info("Discovery received: " + discovery.Describe());
|
||||
_diagnostics.Debug(() => $"Discovery features: [{string.Join(", ", discovery.Features ?? [])}]; client channels: [{string.Join(", ", discovery.ClientChannels ?? [])}].");
|
||||
discovery.Validate(report: _diagnostics.Info);
|
||||
_discovery = discovery;
|
||||
return discovery;
|
||||
}
|
||||
@@ -307,12 +402,19 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
await DiscoverCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
if(_authentication is not null)
|
||||
{ return _authentication; }
|
||||
_diagnostics.Info("Requesting authentication metadata from /qqq/v1/metaData/authentication.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/metaData/authentication"), HttpMethod.Get, null, MetadataLimit,
|
||||
null, null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
QqqAuthenticationMetadata metadata = Parse<QqqAuthenticationMetadata>(response);
|
||||
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
|
||||
_diagnostics.Info($"Authentication metadata: type {(string.IsNullOrEmpty(metadata.Type) ? "(missing)" : metadata.Type)}, provider '{metadata.Name}', native browser sign-in {(grant is null ? "not offered" : "offered")}.");
|
||||
_diagnostics.Debug(() => grant is null
|
||||
? $"Provider values present: {metadata.Values is not null}; client id {(string.IsNullOrEmpty(metadata.Values?.ClientId) ? "(none)" : "present")}; scopes '{metadata.Values?.Scopes}'."
|
||||
: $"Native grant: flow {grant.Flow}, challenge {grant.CodeChallengeMethod}, prompt {grant.Prompt}, issuer {grant.Issuer}, discovery {grant.DiscoveryUrl}, {grant.RedirectUris?.Count ?? 0} redirect address(es) [{string.Join(", ", grant.RedirectUris ?? [])}], scopes '{metadata.Values?.Scopes}'.");
|
||||
if(!string.Equals(metadata.Type, "TABLE_BASED", StringComparison.Ordinal) && !string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Authentication type '{metadata.Type}' is not supported; only TABLE_BASED and OAUTH2 are.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
|
||||
@@ -327,12 +429,17 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
private async Task ResumeCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RequireAuthenticated();
|
||||
string? previous = _sessionToken;
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post, JsonBody(new { }),
|
||||
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
AdoptManagedSession(response);
|
||||
_diagnostics.Info($"Session renewed; the session token {(string.Equals(previous, _sessionToken, StringComparison.Ordinal) ? "is unchanged" : "was rotated by the server")}.");
|
||||
if(_binding is not null)
|
||||
{ await IdentityCoreAsync(_device, cancellationToken).ConfigureAwait(false); }
|
||||
{
|
||||
_diagnostics.Info("Re-checking the bound identity after renewal.");
|
||||
await IdentityCoreAsync(_device, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -345,6 +452,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
private async Task<QqqSessionBinding> IdentityCoreAsync(QqqDeviceCredential? credential, CancellationToken cancellationToken)
|
||||
{
|
||||
RequireAuthenticated();
|
||||
_diagnostics.Info($"Reading the session identity from the printing session route{(credential is null ? "" : " with device proof for " + credential.DeviceId)}.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, PrintingPath + "session"), HttpMethod.Get, null,
|
||||
MetadataLimit, CookieHeader(), credential, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
@@ -354,10 +462,14 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
string tenant = IdentityValue(root, "tenantId");
|
||||
string? device = root.TryGetProperty("deviceId", out JsonElement value) && value.ValueKind != JsonValueKind.Null ? IdentityValue(root, "deviceId") : null;
|
||||
var identity = new QqqSessionBinding(Origin, principal, tenant, device);
|
||||
_diagnostics.Info($"Session identity: principal {principal}, tenant {tenant}, device {device ?? "(none)"}.");
|
||||
if((_binding is not null && (!string.Equals(_binding.PrincipalId, principal, StringComparison.Ordinal)
|
||||
|| !string.Equals(_binding.TenantId, tenant, StringComparison.Ordinal)))
|
||||
|| (credential is not null && !string.Equals(credential.DeviceId, device, StringComparison.Ordinal)))
|
||||
{
|
||||
_diagnostics.Info(_binding is not null && (!string.Equals(_binding.PrincipalId, principal, StringComparison.Ordinal) || !string.Equals(_binding.TenantId, tenant, StringComparison.Ordinal))
|
||||
? $"Identity changed: the session was bound to principal {_binding.PrincipalId} in tenant {_binding.TenantId}; the server now reports principal {principal} in tenant {tenant}. Session cleared."
|
||||
: $"Device mismatch: proof was sent for device {credential!.DeviceId} but the server reports device {device ?? "(none)"}. Session cleared.");
|
||||
ClearSession();
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The server returned a different principal or device binding.");
|
||||
}
|
||||
@@ -378,11 +490,57 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
QqqDeviceCredential? device, string? basic, CancellationToken cancellationToken, bool providerDiscovery = false, bool streamContent = false, bool boundedByCaller = false)
|
||||
{
|
||||
if(!providerDiscovery && !SameOrigin(uri, Origin))
|
||||
{ throw new ArgumentException("Cross-origin QQQ requests are forbidden.", nameof(uri)); }
|
||||
{
|
||||
_diagnostics.Info($"Refused a request to {QqqSessionDiagnostics.Describe(uri)}: it is not on the session origin {Origin.Authority}.");
|
||||
throw new ArgumentException("Cross-origin QQQ requests are forbidden.", nameof(uri));
|
||||
}
|
||||
string route = QqqSessionDiagnostics.Describe(uri);
|
||||
long started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
_diagnostics.Debug(() => $"HTTP {method} {route}: session cookie {(cookie is not null ? "yes" : "no")}, device proof {(device is not null ? "yes" : "no")}, basic credential {(basic is not null ? "yes" : "no")}, body {(content is null ? "none" : content.Headers.ContentType?.MediaType ?? "present")}, limit {(boundedByCaller ? "caller" : "20 s")}, response bound {maximumBytes} bytes.");
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
if(!boundedByCaller)
|
||||
{ timeout.CancelAfter(TimeSpan.FromSeconds(20)); }
|
||||
using var request = new HttpRequestMessage(method, uri)
|
||||
using HttpRequestMessage request = BuildRequest(uri, method, content, cookie, device, basic, providerDiscovery);
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{ response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); }
|
||||
catch(Exception exception) when(exception is HttpRequestException or OperationCanceledException or IOException)
|
||||
{
|
||||
ReportSendFailure(method, route, started, exception, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
try
|
||||
{
|
||||
_diagnostics.Debug(() => $"HTTP {method} {route} -> {(int)response.StatusCode} {response.ReasonPhrase} in {Elapsed(started)}; content type {response.Content.Headers.ContentType?.MediaType ?? "(none)"}, length {response.Content.Headers.ContentLength?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unknown"}, Set-Cookie {(response.Headers.Contains("Set-Cookie") ? "present" : "absent")}.");
|
||||
if(!providerDiscovery && _traffic is not null)
|
||||
{ response.Content = new TrafficHttpContent(response.Content, _traffic.RecordDownload); }
|
||||
if((int)response.StatusCode is >= 300 and < 400)
|
||||
{
|
||||
_diagnostics.Info($"HTTP {method} {route} answered {(int)response.StatusCode} (redirect); redirects are refused, so the request failed. Check the server address: it must be the exact HTTPS origin QQQ serves from.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "Redirected authentication or API requests are refused.");
|
||||
}
|
||||
if(streamContent && response.IsSuccessStatusCode)
|
||||
{
|
||||
response.RequestMessage = null;
|
||||
return response;
|
||||
}
|
||||
await BufferResponseAsync(response, maximumBytes, timeout.Token).ConfigureAwait(false);
|
||||
response.RequestMessage = null;
|
||||
return response;
|
||||
}
|
||||
catch
|
||||
{
|
||||
response.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds the outgoing request: no-store caching, this agent's channel header, and only the cookie, origin, Basic credential and device proof the caller passed.</summary>
|
||||
private HttpRequestMessage BuildRequest(Uri uri, HttpMethod method, HttpContent? content, string? cookie, QqqDeviceCredential? device, string? basic, bool providerDiscovery)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, uri)
|
||||
{
|
||||
Content = !providerDiscovery && _traffic is not null && content is not null ? new TrafficHttpContent(content, _traffic.RecordUpload) : content,
|
||||
};
|
||||
@@ -404,29 +562,19 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
request.Headers.Add("X-QQQ-Device-Id", device.DeviceId);
|
||||
request.Headers.Add("X-QQQ-Device-Token", device.DeviceToken);
|
||||
}
|
||||
HttpResponseMessage response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if(!providerDiscovery && _traffic is not null)
|
||||
{ response.Content = new TrafficHttpContent(response.Content, _traffic.RecordDownload); }
|
||||
if((int)response.StatusCode is >= 300 and < 400)
|
||||
{
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "Redirected authentication or API requests are refused.");
|
||||
}
|
||||
if(streamContent && response.IsSuccessStatusCode)
|
||||
{
|
||||
response.RequestMessage = null;
|
||||
return response;
|
||||
}
|
||||
await BufferResponseAsync(response, maximumBytes, timeout.Token).ConfigureAwait(false);
|
||||
response.RequestMessage = null;
|
||||
return response;
|
||||
}
|
||||
catch
|
||||
{
|
||||
response.Dispose();
|
||||
throw;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Logs a request that produced no response headers: cancelled by the caller, timed out, or a connection failure with its inner cause (DNS, TLS, refused).</summary>
|
||||
private void ReportSendFailure(HttpMethod method, string route, long started, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
_diagnostics.Info(cancellationToken.IsCancellationRequested
|
||||
? $"HTTP {method} {route} was cancelled by the caller after {Elapsed(started)}."
|
||||
: exception is OperationCanceledException
|
||||
? $"HTTP {method} {route} timed out after {Elapsed(started)} with no response headers."
|
||||
: $"HTTP {method} {route} could not be sent after {Elapsed(started)}: {QqqSessionDiagnostics.Describe(exception)}{(exception.InnerException is null ? "" : " (" + QqqSessionDiagnostics.Describe(exception.InnerException) + ")")}.");
|
||||
}
|
||||
|
||||
|
||||
@@ -435,10 +583,13 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
/// Reads a response body into memory but never more than the stated limit, whether announced by Content-Length or
|
||||
/// discovered while reading, so a hostile or broken server cannot exhaust memory. The buffered content keeps the original headers.
|
||||
/// </summary>
|
||||
private static async Task BufferResponseAsync(HttpResponseMessage response, int maximumBytes, CancellationToken token)
|
||||
private async Task BufferResponseAsync(HttpResponseMessage response, int maximumBytes, CancellationToken token)
|
||||
{
|
||||
if(response.Content.Headers.ContentLength > maximumBytes)
|
||||
{ throw InvalidResponse(); }
|
||||
{
|
||||
_diagnostics.Info($"Response rejected: announced length {response.Content.Headers.ContentLength} bytes exceeds the {maximumBytes} byte bound.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
await using Stream input = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
|
||||
using var output = new MemoryStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
@@ -446,7 +597,10 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
while((count = await input.ReadAsync(buffer, token).ConfigureAwait(false)) != 0)
|
||||
{
|
||||
if(output.Length + count > maximumBytes)
|
||||
{ throw InvalidResponse(); }
|
||||
{
|
||||
_diagnostics.Info($"Response rejected: the body exceeded the {maximumBytes} byte bound while reading.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
output.Write(buffer, 0, count);
|
||||
}
|
||||
var buffered = new ByteArrayContent(output.ToArray());
|
||||
@@ -469,11 +623,23 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
? value.ValueKind == JsonValueKind.String ? value.GetString() : throw InvalidResponse() : null;
|
||||
string? cookie = ReadSessionCookie(response);
|
||||
if(body is not null && cookie is not null && !string.Equals(body, cookie, StringComparison.Ordinal))
|
||||
{ ClearSession(); throw InvalidResponse(); }
|
||||
{
|
||||
_diagnostics.Info("Session response rejected: the session cookie and the body's session id disagree.");
|
||||
ClearSession();
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(cookie is null && body is null && _sessionToken is null)
|
||||
{ _diagnostics.Info("Session response rejected: no session cookie, no session id in the body and no session already held."); }
|
||||
string token = cookie ?? body ?? _sessionToken ?? throw InvalidResponse();
|
||||
try
|
||||
{ SetSession(new QqqSessionCredential(Origin, token).SessionToken); }
|
||||
catch(ArgumentException) { ClearSession(); throw InvalidResponse(); }
|
||||
catch(ArgumentException)
|
||||
{
|
||||
_diagnostics.Info("Session response rejected: the session id is not well formed.");
|
||||
ClearSession();
|
||||
throw InvalidResponse();
|
||||
}
|
||||
_diagnostics.Debug(() => $"Session token adopted from {(cookie is not null && body is not null ? "cookie and body (agreeing)" : cookie is not null ? "the Set-Cookie header" : body is not null ? "the response body" : "the existing session")}; {token.Length} characters; session generation {_generation}.");
|
||||
}
|
||||
|
||||
|
||||
@@ -499,6 +665,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
string name = header.Split('=', 2)[0].Trim();
|
||||
if((name is "sessionUUID" or "sessionId") && parsed.GetAllCookies().Count == 0)
|
||||
{
|
||||
_diagnostics.Info($"The server deleted the {name} cookie; the session has ended and sign-in is required.");
|
||||
ClearSession();
|
||||
throw AuthenticationRequired();
|
||||
}
|
||||
@@ -512,7 +679,11 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(cookie.Expired || string.IsNullOrEmpty(cookie.Value))
|
||||
{ ClearSession(); throw AuthenticationRequired(); }
|
||||
{
|
||||
_diagnostics.Info($"The server returned an {(cookie.Expired ? "expired" : "empty")} {cookie.Name} cookie; the session has ended and sign-in is required.");
|
||||
ClearSession();
|
||||
throw AuthenticationRequired();
|
||||
}
|
||||
token = cookie.Value;
|
||||
}
|
||||
}
|
||||
@@ -577,9 +748,14 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
private void CheckAuthorization(HttpResponseMessage response)
|
||||
{
|
||||
if(response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{ ClearSession(); throw AuthenticationRequired(); }
|
||||
{
|
||||
_diagnostics.Info("The server answered 401 Unauthorized: the session is no longer accepted. Local session cleared; sign-in is required.");
|
||||
ClearSession();
|
||||
throw AuthenticationRequired();
|
||||
}
|
||||
if(response.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
_diagnostics.Info("The server answered 403 Forbidden: the signed-in user or device is not permitted to perform this operation. Signing in again will not help; check permissions and device approval in QQQ.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The current QQQ user is not permitted to perform this operation.");
|
||||
}
|
||||
}
|
||||
@@ -592,6 +768,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
CheckAuthorization(response);
|
||||
if(!response.IsSuccessStatusCode)
|
||||
{
|
||||
_diagnostics.Info($"The server answered {(int)response.StatusCode} {response.ReasonPhrase}; the request did not succeed.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "The QQQ request did not succeed.");
|
||||
}
|
||||
}
|
||||
@@ -631,12 +808,16 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
|
||||
|
||||
/// <summary>Deserializes a response into a type, reporting a missing or malformed body as the generic invalid-response failure.</summary>
|
||||
private static T Parse<T>(HttpResponseMessage response)
|
||||
private T Parse<T>(HttpResponseMessage response)
|
||||
{
|
||||
using JsonDocument document = ParseDocument(response);
|
||||
try
|
||||
{ return document.RootElement.Deserialize<T>(JsonOptions) ?? throw InvalidResponse(); }
|
||||
catch(JsonException) { throw InvalidResponse(); }
|
||||
catch(JsonException exception)
|
||||
{
|
||||
_diagnostics.Info($"Response rejected: the JSON did not match the expected {typeof(T).Name} shape ({exception.Message}).");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -645,7 +826,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
/// Parses a response as a JSON object with a depth limit and no duplicate property names (a common smuggling trick for
|
||||
/// disagreeing parsers). Anything else is an invalid response; the caller owns the returned document.
|
||||
/// </summary>
|
||||
private static JsonDocument ParseDocument(HttpResponseMessage response)
|
||||
private JsonDocument ParseDocument(HttpResponseMessage response)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -654,13 +835,20 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
try
|
||||
{
|
||||
if(document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{ throw InvalidResponse(); }
|
||||
{
|
||||
_diagnostics.Info($"Response rejected: expected a JSON object but received {document.RootElement.ValueKind} (content type {response.Content.Headers.ContentType?.MediaType ?? "none"}). A sign-in page or proxy error page in place of the API is a common cause.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
RejectDuplicateProperties(document.RootElement);
|
||||
return document;
|
||||
}
|
||||
catch { document.Dispose(); throw; }
|
||||
}
|
||||
catch(JsonException) { throw InvalidResponse(); }
|
||||
catch(JsonException exception)
|
||||
{
|
||||
_diagnostics.Info($"Response rejected: the body is not valid JSON ({exception.Message}; content type {response.Content.Headers.ContentType?.MediaType ?? "none"}). A sign-in page or proxy error page in place of the API is a common cause.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -688,11 +876,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
|
||||
|
||||
/// <summary>Reads a required identity string that must be non-blank, at most 1024 characters and free of control characters.</summary>
|
||||
private static string IdentityValue(JsonElement root, string name)
|
||||
private string IdentityValue(JsonElement root, string name)
|
||||
{
|
||||
if(!root.TryGetProperty(name, out JsonElement value) || value.ValueKind != JsonValueKind.String
|
||||
|| string.IsNullOrWhiteSpace(value.GetString()) || value.GetString()!.Length > 1024 || value.GetString()!.Any(char.IsControl))
|
||||
{
|
||||
_diagnostics.Info($"Identity response rejected: '{name}' is missing, blank, not a string, too long or contains control characters.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
return value.GetString()!;
|
||||
@@ -700,6 +889,27 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Records why an operation failed (type and message only) and returns false, so it can sit in an exception filter and
|
||||
/// leave the exception, its stack and the catch order untouched. Cancellation requested by the caller is noted, not reported as a failure.
|
||||
/// </summary>
|
||||
private bool ReportFailure(string operation, Exception exception)
|
||||
{
|
||||
_diagnostics.Info(exception is OperationCanceledException
|
||||
? operation + " was cancelled."
|
||||
: exception is QqqSessionException session
|
||||
? $"{operation} failed ({session.Failure}): {session.Message}"
|
||||
: $"{operation} failed: {QqqSessionDiagnostics.Describe(exception)}");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Milliseconds since a Stopwatch timestamp, for request timing in the log.</summary>
|
||||
private static string Elapsed(long started) => System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + " ms";
|
||||
|
||||
|
||||
|
||||
/// <summary>Clears the session, so a late response is refused, and releases the HTTP client and gate.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Credential-free progress reporting for the session client. Info entries describe each step of discovery, sign-in,
|
||||
/// session restore, identity checks and the realtime upgrade so an operator can see where a new server or account
|
||||
/// stops working; debug entries add request-level detail (route, status, timing, header names) and are produced only
|
||||
/// while the host says debug logging is on. Callers never receive tokens, cookies, passwords, codes or device proof.
|
||||
/// </summary>
|
||||
public sealed class QqqSessionDiagnostics(Action<string>? info = null, Action<string>? debug = null, Func<bool>? debugEnabled = null)
|
||||
{
|
||||
/// <summary>A sink that discards everything, for callers that have no log.</summary>
|
||||
public static QqqSessionDiagnostics None { get; } = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>True when a debug sink exists and the host currently wants debug detail.</summary>
|
||||
public bool DebugEnabled => debug is not null && (debugEnabled?.Invoke() ?? true);
|
||||
|
||||
|
||||
|
||||
/// <summary>Records one step of normal operation.</summary>
|
||||
public void Info(string message) => info?.Invoke(message);
|
||||
|
||||
|
||||
|
||||
/// <summary>Records request-level detail when debug logging is on; the message is built only in that case.</summary>
|
||||
public void Debug(Func<string> message)
|
||||
{
|
||||
if(DebugEnabled)
|
||||
{ debug!.Invoke(message()); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Describes an address without its query, fragment or user information, so a logged route can never carry a code, state or credential.</summary>
|
||||
public static string Describe(Uri uri) => uri.IsAbsoluteUri ? uri.Scheme + "://" + uri.Authority + uri.AbsolutePath : uri.ToString();
|
||||
|
||||
|
||||
|
||||
/// <summary>Describes a failure by type and message, followed by each inner cause the same way, so "see inner exception" never hides the real reason; stack traces belong to the host's error sink.</summary>
|
||||
public static string Describe(Exception exception)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
var parts = new List<string>();
|
||||
for(Exception? current = exception; current is not null && parts.Count < 4; current = current.InnerException)
|
||||
{ parts.Add(current.GetType().Name + ": " + current.Message); }
|
||||
return string.Join(" <- ", parts);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The human-readable reason for a failure: the message and each inner cause, without type names, for an on-screen explanation.</summary>
|
||||
public static string Explain(Exception exception)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
var parts = new List<string>();
|
||||
for(Exception? current = exception; current is not null && parts.Count < 4; current = current.InnerException)
|
||||
{
|
||||
string message = current.Message.Trim();
|
||||
if(message.Length != 0 && !parts.Contains(message, StringComparer.Ordinal))
|
||||
{ parts.Add(message); }
|
||||
}
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -12,9 +12,10 @@ using Qqq.PrintAgent.Core.Printing;
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Retention, daily size cap and clearing for the shared activity log. The service and the companion write the same files,
|
||||
/// Retention, size rollover and clearing for the shared activity log. The service and the companion write the same files,
|
||||
/// so every change here happens under one cross-process lock file, and every path is checked for links so a planted
|
||||
/// link cannot redirect a write or a delete outside the log directory.
|
||||
/// link cannot redirect a write or a delete outside the log directory. A file that reaches its size limit is renamed to a
|
||||
/// numbered segment of the same day and a fresh file starts; logging never stops because of size.
|
||||
/// </summary>
|
||||
public sealed partial class AgentActivityLog
|
||||
{
|
||||
@@ -23,13 +24,17 @@ public sealed partial class AgentActivityLog
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the saved retention and size limits together with today's file size and whether today's cap was reached, for the Settings page.</summary>
|
||||
/// <summary>Returns the saved retention, size and debug settings together with the current file's size and how many segments rolled over today, for the Settings page.</summary>
|
||||
public static LogSettings GetSettings(string directory)
|
||||
{
|
||||
AgentActivityLog log = new(directory);
|
||||
using FileStream ownership = log.AcquireOwnership();
|
||||
LogSettings settings = log.ReadSettings();
|
||||
return settings with { CurrentDayBytes = File.Exists(log.CurrentLogFilePath) ? new FileInfo(log.CurrentLogFilePath).Length : 0, DailyLimitReached = log.IsCapped(settings) };
|
||||
return settings with
|
||||
{
|
||||
CurrentFileBytes = File.Exists(log.CurrentLogFilePath) ? new FileInfo(log.CurrentLogFilePath).Length : 0,
|
||||
SegmentsToday = log.Segments(DateOnly.FromDateTime(DateTime.Now)).Count,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,11 +56,13 @@ public sealed partial class AgentActivityLog
|
||||
{
|
||||
using(FileStream stream = new(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
JsonSerializer.Serialize(stream, new LogSettings(settings.RetentionDays, settings.MaxDailyMegabytes), PrintApiClient.JsonOptions);
|
||||
JsonSerializer.Serialize(stream, new LogSettings(settings.RetentionDays, settings.MaxFileMegabytes, settings.SegmentsPerDay, settings.DebugLogging), PrintApiClient.JsonOptions);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
log._cachedSettings = null;
|
||||
log.PruneOwnedLogs(settings.RetentionDays);
|
||||
log.TrimSegments(DateOnly.FromDateTime(DateTime.Now), settings.SegmentsPerDay);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -68,7 +75,7 @@ public sealed partial class AgentActivityLog
|
||||
|
||||
|
||||
|
||||
/// <summary>Deletes only this application's dated log files, under the same cross-process append lock.</summary>
|
||||
/// <summary>Deletes only this application's dated log files and rolled segments (and any cap marker left by an earlier version), under the same cross-process append lock.</summary>
|
||||
public static int ClearLogs(string directory)
|
||||
{
|
||||
AgentActivityLog log = new(directory);
|
||||
@@ -79,9 +86,10 @@ public sealed partial class AgentActivityLog
|
||||
File.Delete(path);
|
||||
count++;
|
||||
}
|
||||
foreach(string path in log.OwnedCapMarkers())
|
||||
foreach(string path in Directory.EnumerateFiles(log.LogDirectory, "qqq-print-agent-*.log.capped", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
File.Delete(path);
|
||||
if(!File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint))
|
||||
{ File.Delete(path); }
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -114,13 +122,15 @@ public sealed partial class AgentActivityLog
|
||||
|
||||
|
||||
|
||||
/// <summary>Accepts only retention of 1 to 365 days and a daily size of 1 to 100 MiB, the ranges the Settings page offers.</summary>
|
||||
/// <summary>Accepts only retention of 1 to 365 days, a file size of 1 to 1024 MiB and 1 to 100 kept segments per day, the ranges the Settings page offers.</summary>
|
||||
private static void ValidateSettings(LogSettings settings)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(settings.RetentionDays, 1, nameof(settings));
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(settings.RetentionDays, 365, nameof(settings));
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(settings.MaxDailyMegabytes, 1, nameof(settings));
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(settings.MaxDailyMegabytes, 100, nameof(settings));
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(settings.MaxFileMegabytes, 1, nameof(settings));
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(settings.MaxFileMegabytes, 1024, nameof(settings));
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(settings.SegmentsPerDay, 1, nameof(settings));
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(settings.SegmentsPerDay, 100, nameof(settings));
|
||||
}
|
||||
|
||||
|
||||
@@ -151,13 +161,12 @@ public sealed partial class AgentActivityLog
|
||||
|
||||
|
||||
|
||||
/// <summary>Lists only files named like this application's dated logs and that are not links, so clearing or pruning can never touch an unrelated file.</summary>
|
||||
/// <summary>Lists only files named like this application's dated logs or their numbered segments, and that are not links, so clearing or pruning can never touch an unrelated file.</summary>
|
||||
private IEnumerable<string> OwnedLogs()
|
||||
{
|
||||
foreach(string path in Directory.EnumerateFiles(LogDirectory, "qqq-print-agent-*.log", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
string stamp = Path.GetFileNameWithoutExtension(path)[16..];
|
||||
if(DateOnly.TryParseExact(stamp, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _) && !File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint))
|
||||
if(TryParseStamp(path, out _, out _) && !File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
yield return path;
|
||||
}
|
||||
@@ -166,6 +175,44 @@ public sealed partial class AgentActivityLog
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the day and segment number from an owned log name: qqq-print-agent-yyyy-MM-dd.log is segment 0 (the live file) and
|
||||
/// qqq-print-agent-yyyy-MM-dd.N.log (N from 1) is a rolled segment of that day. Anything else is not ours.
|
||||
/// </summary>
|
||||
private static bool TryParseStamp(string path, out DateOnly day, out int segment)
|
||||
{
|
||||
day = default;
|
||||
segment = 0;
|
||||
string name = Path.GetFileNameWithoutExtension(path);
|
||||
if(name.Length < 26)
|
||||
{ return false; }
|
||||
string stamp = name[16..];
|
||||
string date = stamp.Length > 10 ? stamp[..10] : stamp;
|
||||
if(!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out day))
|
||||
{ return false; }
|
||||
if(stamp.Length == 10)
|
||||
{ return true; }
|
||||
return stamp[10] == '.' && int.TryParse(stamp[11..], NumberStyles.None, CultureInfo.InvariantCulture, out segment) && segment > 0
|
||||
&& stamp[11..].Length <= 9 && stamp[11] != '0';
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The rolled segments of one day, oldest first.</summary>
|
||||
private List<(int Segment, string Path)> Segments(DateOnly day)
|
||||
{
|
||||
var segments = new List<(int Segment, string Path)>();
|
||||
foreach(string path in OwnedLogs())
|
||||
{
|
||||
if(TryParseStamp(path, out DateOnly fileDay, out int segment) && fileDay == day && segment > 0)
|
||||
{ segments.Add((segment, path)); }
|
||||
}
|
||||
segments.Sort((left, right) => left.Segment.CompareTo(right.Segment));
|
||||
return segments;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Deletes dated logs and cap markers older than the retention window. It runs at most once per local day for an unchanged
|
||||
/// retention value, so frequent appends do not rescan the folder, and it counts today as one of the retained days.
|
||||
@@ -180,16 +227,7 @@ public sealed partial class AgentActivityLog
|
||||
DateOnly cutoff = today.AddDays(-(retentionDays - 1));
|
||||
foreach(string path in OwnedLogs())
|
||||
{
|
||||
var day = DateOnly.ParseExact(Path.GetFileNameWithoutExtension(path)[16..], "yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
if(day < cutoff)
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
foreach(string path in OwnedCapMarkers())
|
||||
{
|
||||
string stamp = Path.GetFileName(path)[16..^11];
|
||||
if(DateOnly.ParseExact(stamp, "yyyy-MM-dd", CultureInfo.InvariantCulture) < cutoff)
|
||||
if(TryParseStamp(path, out DateOnly day, out _) && day < cutoff)
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
@@ -212,56 +250,49 @@ public sealed partial class AgentActivityLog
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether today's file has room for one more entry. It allows a margin for the prefix AppKit adds, writes one
|
||||
/// visible warning when the limit is first reached (so the cap is never silent), and leaves a marker so later entries
|
||||
/// are suppressed cheaply until the next local day, a higher limit or clearing. Printing is never affected.
|
||||
/// Rolls today's file over when the next entry would push it past the size limit: the live file is renamed to the next
|
||||
/// numbered segment of the day, the oldest segments beyond the kept count are deleted, and the entry then lands in a
|
||||
/// fresh file. The caller holds the cross-process lock and the AppKit writer opens the file per append, so the rename is
|
||||
/// safe; if another reader (such as the log viewer) holds the file, the rename is skipped and retried on the next entry
|
||||
/// rather than losing the entry. Printing is never affected.
|
||||
/// </summary>
|
||||
private bool CanAppend(string message, LogSettings settings)
|
||||
private void RotateIfNeeded(string message, LogSettings settings)
|
||||
{
|
||||
RejectLink(CurrentLogFilePath);
|
||||
if(IsCapped(settings))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
long length = File.Exists(CurrentLogFilePath) ? new FileInfo(CurrentLogFilePath).Length : 0;
|
||||
if(!File.Exists(CurrentLogFilePath))
|
||||
{ return; }
|
||||
long length = new FileInfo(CurrentLogFilePath).Length;
|
||||
// AppKit adds a short ASCII timestamp/severity prefix and a newline.
|
||||
long limit = settings.MaxDailyMegabytes * 1024L * 1024L;
|
||||
long limit = settings.MaxFileMegabytes * 1024L * 1024L;
|
||||
if(length + Encoding.UTF8.GetByteCount(message) + 640 <= limit)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if(length + 512 <= limit)
|
||||
{
|
||||
_log.Warning("Daily diagnostic limit reached; further entries are suppressed. Increase the limit or clear logs in Settings, or wait for the next local day. Printing continues.");
|
||||
}
|
||||
string marker = CurrentLogFilePath + ".capped";
|
||||
RejectLink(marker);
|
||||
File.WriteAllText(marker, settings.MaxDailyMegabytes.ToString(CultureInfo.InvariantCulture));
|
||||
return false;
|
||||
{ return; }
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
List<(int Segment, string Path)> segments = Segments(today);
|
||||
int next = segments.Count == 0 ? 1 : segments[^1].Segment + 1;
|
||||
string archive = Path.Combine(LogDirectory, "qqq-print-agent-" + today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + "." + next.ToString(CultureInfo.InvariantCulture) + ".log");
|
||||
RejectLink(archive);
|
||||
try
|
||||
{ File.Move(CurrentLogFilePath, archive); }
|
||||
catch(IOException)
|
||||
{ return; }
|
||||
TrimSegments(today, settings.SegmentsPerDay);
|
||||
_log.Info("[Log] Rolled over: the previous " + (length / (1024d * 1024d)).ToString("0.0", CultureInfo.InvariantCulture) + " MiB segment is now "
|
||||
+ Path.GetFileName(archive) + "; up to " + settings.SegmentsPerDay.ToString(CultureInfo.InvariantCulture) + " segment(s) per day are kept.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when today's cap marker exists and records the limit now in force; a marker written under a different limit is stale, so raising the limit resumes logging at once.</summary>
|
||||
private bool IsCapped(LogSettings settings)
|
||||
/// <summary>Deletes the oldest rolled segments of a day beyond the kept count. A segment another process holds open is skipped and retried on the next rollover.</summary>
|
||||
private void TrimSegments(DateOnly day, int keep)
|
||||
{
|
||||
string marker = CurrentLogFilePath + ".capped";
|
||||
RejectLink(marker);
|
||||
return File.Exists(marker) && new FileInfo(marker).Length <= 64
|
||||
&& string.Equals(File.ReadAllText(marker), settings.MaxDailyMegabytes.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Lists only cap markers named like this application's dated logs and that are not links, mirroring the log listing.</summary>
|
||||
private IEnumerable<string> OwnedCapMarkers()
|
||||
{
|
||||
foreach(string path in Directory.EnumerateFiles(LogDirectory, "qqq-print-agent-*.log.capped", SearchOption.TopDirectoryOnly))
|
||||
List<(int Segment, string Path)> segments = Segments(day);
|
||||
for(int index = 0; index < segments.Count - keep; index++)
|
||||
{
|
||||
string stamp = Path.GetFileName(path)[16..^11];
|
||||
if(DateOnly.TryParseExact(stamp, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _) && !File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint))
|
||||
try
|
||||
{ File.Delete(segments[index].Path); }
|
||||
catch(IOException)
|
||||
{
|
||||
yield return path;
|
||||
// Held open by a reader; it is retried on the next rollover.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
|
||||
@@ -10,9 +11,11 @@ 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;
|
||||
private long _cachedSettingsAt;
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +29,38 @@ public sealed partial class AgentActivityLog : IActivityLog
|
||||
|
||||
|
||||
/// <summary>Uses the protected service directory; child files inherit its installed access controls.</summary>
|
||||
public static IActivityLog Create(string directory) => new AgentActivityLog(directory);
|
||||
public static AgentActivityLog Create(string directory) => new(directory);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True while the saved settings ask for debug detail. The settings file is re-read at most every two seconds, so a toggle
|
||||
/// in the companion reaches both processes quickly without a file read on every candidate debug line.
|
||||
/// </summary>
|
||||
public bool IsDebugEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
if(_cachedSettings is null || Stopwatch.GetElapsedTime(_cachedSettingsAt) > TimeSpan.FromSeconds(2))
|
||||
{
|
||||
try
|
||||
{ _cachedSettings = ReadSettings(); }
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{ _cachedSettings ??= new LogSettings(); }
|
||||
_cachedSettingsAt = Stopwatch.GetTimestamp();
|
||||
}
|
||||
return _cachedSettings.DebugLogging;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Writes a debug entry (request-level detail) only while debug logging is enabled; the entry goes through the same locked, redacting path.</summary>
|
||||
public void Debug(string message)
|
||||
{
|
||||
if(IsDebugEnabled)
|
||||
{ Write(_log.Info, "[DEBUG] " + message); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -103,10 +137,8 @@ public sealed partial class AgentActivityLog : IActivityLog
|
||||
LogSettings settings = ReadSettings();
|
||||
PruneOwnedLogs(settings.RetentionDays);
|
||||
string redacted = Redact(message);
|
||||
if(CanAppend(redacted, settings))
|
||||
{
|
||||
append(redacted);
|
||||
}
|
||||
RotateIfNeeded(redacted, settings);
|
||||
append(redacted);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
|
||||
@@ -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,8 @@ 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);
|
||||
|
||||
|
||||
@@ -30,8 +32,34 @@ public sealed partial class AgentRuntime
|
||||
/// Creates the session client for a server origin, using the injected factory when one is supplied (tests) and
|
||||
/// otherwise a real client that permits plain HTTP only for a loopback origin.
|
||||
/// </summary>
|
||||
private IQqqSessionClient NewSession(Uri origin) => sessionFactory?.Invoke(origin)
|
||||
?? new QqqSessionClient(origin, allowLoopbackHttp: origin.IsLoopback, traffic: _traffic);
|
||||
private IQqqSessionClient NewSession(Uri origin)
|
||||
{
|
||||
if(sessionFactory is not null)
|
||||
{ return sessionFactory(origin); }
|
||||
ServerTrust trust;
|
||||
using(var store = new AgentStore(directory, recoverUnreadableConfiguration: true))
|
||||
{ trust = store.LoadServerTrust(); }
|
||||
bool allowUntrusted = trust.AppliesTo(origin);
|
||||
if(allowUntrusted)
|
||||
{ WriteLog("WARN", $"Creating a session for {origin.Authority} with untrusted server certificates ALLOWED for this server (administrator setting; development only)."); }
|
||||
else if(trust.AllowUntrustedCertificate)
|
||||
{ WriteLog("INFO", $"The untrusted-certificate exception is saved for {trust.Origin}, not {ServerTrust.Normalize(origin)}; normal certificate validation applies to this session."); }
|
||||
return new QqqSessionClient(origin, allowLoopbackHttp: origin.IsLoopback, traffic: _traffic, diagnostics: SessionDiagnostics(), allowUntrustedCertificate: allowUntrusted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Logs an authorization observation only when it differs from the last one, because the reconnect loop re-evaluates authorization on every attempt.</summary>
|
||||
private void NoteAuthorization(string level, string message)
|
||||
{
|
||||
lock(_stateLock)
|
||||
{
|
||||
if(string.Equals(_lastAuthorizationNote, message, StringComparison.Ordinal))
|
||||
{ return; }
|
||||
_lastAuthorizationNote = message;
|
||||
}
|
||||
WriteLog(level, message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -60,25 +88,18 @@ public sealed partial class AgentRuntime
|
||||
if(saved?.Credential is null || (saved.Binding.HasApprovedDevice
|
||||
&& (configuration is null || !SameOrigin(saved.Binding.Origin, configuration.ServerUrl))))
|
||||
{
|
||||
NoteAuthorization("INFO", DescribeMissingAuthorization(saved, configuration));
|
||||
lock(_stateLock)
|
||||
{ _transportNeedsSignIn = true; }
|
||||
return false;
|
||||
}
|
||||
if(_sessionClient is null)
|
||||
{
|
||||
IQqqSessionClient restored = NewSession(saved.Credential.Origin);
|
||||
try
|
||||
{
|
||||
await restored.RestoreSessionAsync(saved.Credential, token).ConfigureAwait(false);
|
||||
QqqSessionBinding binding = configuration is null || !saved.Binding.HasApprovedDevice ? await restored.GetSessionIdentityAsync(token).ConfigureAwait(false)
|
||||
: await restored.BindDeviceAsync(new QqqDeviceCredential(configuration.DeviceId, configuration.DeviceToken), cancellationToken: token).ConfigureAwait(false);
|
||||
RequireSameIdentity(saved.Binding, binding);
|
||||
ActivateSession(restored, binding, store);
|
||||
}
|
||||
catch { restored.Dispose(); throw; }
|
||||
await RestoreSavedSessionAsync(saved, configuration, store, token).ConfigureAwait(false);
|
||||
}
|
||||
else if(!_sessionClient.IsAuthenticated)
|
||||
{
|
||||
WriteLog("WARN", $"The held {AppIdentity.ServerName} session is no longer authenticated (the server ended it or an identity check failed).");
|
||||
BlockAuthorization();
|
||||
return false;
|
||||
}
|
||||
@@ -90,6 +111,7 @@ public sealed partial class AgentRuntime
|
||||
}
|
||||
catch(QqqSessionException exception) when(exception.Failure is QqqSessionException.FailureKind.AuthenticationRequired or QqqSessionException.FailureKind.PermissionDenied)
|
||||
{
|
||||
WriteLog("WARN", $"Authorization withdrawn ({exception.Failure}): {exception.Message}");
|
||||
BlockAuthorization();
|
||||
return false;
|
||||
}
|
||||
@@ -98,6 +120,43 @@ 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 {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.";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Restores the protected saved session on a fresh client, binds the paired device when there is one, checks the identity
|
||||
/// against what was approved and makes the session current. The client is disposed and the failure logged on any error.
|
||||
/// </summary>
|
||||
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 {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
|
||||
{
|
||||
await restored.RestoreSessionAsync(credential, token).ConfigureAwait(false);
|
||||
QqqSessionBinding binding = configuration is null || !saved.Binding.HasApprovedDevice ? await restored.GetSessionIdentityAsync(token).ConfigureAwait(false)
|
||||
: await restored.BindDeviceAsync(new QqqDeviceCredential(configuration.DeviceId, configuration.DeviceToken), cancellationToken: token).ConfigureAwait(false);
|
||||
RequireSameIdentity(saved.Binding, binding);
|
||||
ActivateSession(restored, binding, store);
|
||||
WriteLog("INFO", $"Saved {AppIdentity.ServerName} session restored. " + _authenticationMessage);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
WriteFailure($"Saved {AppIdentity.ServerName} session could not be restored", exception);
|
||||
restored.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Renews the live session, re-verifies the identity and saves the new credential. When the session token rotated, the
|
||||
/// realtime socket is cancelled and a reconnect requested, because the socket was authenticated with the old token.
|
||||
@@ -106,12 +165,14 @@ public sealed partial class AgentRuntime
|
||||
{
|
||||
IQqqSessionClient session = _sessionClient ?? throw new InvalidOperationException("Sign in is required.");
|
||||
string previousToken = session.ExportCredential().SessionToken;
|
||||
WriteLog("INFO", "Scheduled session renewal: renewing with the server and re-checking the identity.");
|
||||
await session.ResumeSessionAsync(token).ConfigureAwait(false);
|
||||
QqqSessionBinding binding = await session.GetSessionIdentityAsync(token).ConfigureAwait(false);
|
||||
RequireSameIdentity(saved.Binding, binding);
|
||||
ActivateSession(session, binding, store);
|
||||
if(!string.Equals(previousToken, session.ExportCredential().SessionToken, StringComparison.Ordinal))
|
||||
{
|
||||
WriteLog("INFO", "The session token was rotated during renewal; the realtime socket will reconnect with the new token.");
|
||||
lock(_stateLock)
|
||||
{
|
||||
_realtimeReady = false;
|
||||
@@ -133,6 +194,7 @@ public sealed partial class AgentRuntime
|
||||
{
|
||||
store.SaveAuthentication(new AgentAuthentication(binding, client.ExportCredential()));
|
||||
AgentConfiguration? configuration = store.LoadConfiguration();
|
||||
bool ready;
|
||||
lock(_stateLock)
|
||||
{
|
||||
_sessionClient = client;
|
||||
@@ -143,8 +205,15 @@ 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", $"{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.")
|
||||
+ $" Next renewal in {SessionResumeInterval.TotalMinutes.ToString("0", System.Globalization.CultureInfo.InvariantCulture)} min.");
|
||||
}
|
||||
|
||||
|
||||
@@ -155,15 +224,19 @@ public sealed partial class AgentRuntime
|
||||
/// </summary>
|
||||
private void BlockAuthorization()
|
||||
{
|
||||
bool changed;
|
||||
lock(_stateLock)
|
||||
{
|
||||
changed = !_authorizationBlocked;
|
||||
_authorizationReady = false;
|
||||
_authorizationBlocked = true;
|
||||
_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: {AppIdentity.ServerName} authorization is required. Sign in again from the companion; pairing and history are preserved."); }
|
||||
SignalPipeline();
|
||||
}
|
||||
|
||||
@@ -182,11 +255,13 @@ public sealed partial class AgentRuntime
|
||||
/// Requires the identity key (user, tenant and device) to be unchanged since setup. A different identity is reported as
|
||||
/// permission denied, because silently adopting it would let another account print through this pairing.
|
||||
/// </summary>
|
||||
private static void RequireSameIdentity(QqqSessionBinding expected, QqqSessionBinding actual)
|
||||
private void RequireSameIdentity(QqqSessionBinding expected, QqqSessionBinding actual)
|
||||
{
|
||||
if(!string.Equals(expected.IdentityKey, actual.IdentityKey, StringComparison.Ordinal))
|
||||
{
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The QQQ user, tenant or device changed. Explicit connection setup is required.");
|
||||
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 {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;
|
||||
|
||||
@@ -20,30 +21,51 @@ public sealed partial class AgentRuntime
|
||||
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
IQqqSessionClient session = _sessionClient ?? throw new InvalidOperationException("Sign in to QQQ before enrolling this computer.");
|
||||
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 {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 new InvalidOperationException("Sign in to the selected QQQ server before enrollment.");
|
||||
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 {AppIdentity.ServerName} server before enrollment."));
|
||||
}
|
||||
WriteLog("INFO", "Step 1 of 4: reading the signed-in identity.");
|
||||
QqqSessionBinding identity = await session.GetSessionIdentityAsync(token).ConfigureAwait(false);
|
||||
using var api = PrintApiClient.ForSession(session);
|
||||
WriteLog("INFO", "Step 2 of 4: exchanging the enrollment code for a device credential.");
|
||||
EnrollmentResponse response = await api.EnrollAsync(new EnrollmentRequest(request.EnrollmentCode ?? "", Environment.MachineName, PrintApiClient.AgentVersion), token).ConfigureAwait(false);
|
||||
WriteLog("INFO", $"Enrollment accepted: device {response.DeviceId}, poll interval {response.PollIntervalSeconds} s. Saving the pairing to protected storage.");
|
||||
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
|
||||
store.SaveConfiguration(new AgentConfiguration(request.ServerUrl, response.DeviceId, response.DeviceToken, response.PollIntervalSeconds));
|
||||
QqqSessionBinding expected = identity with { DeviceId = response.DeviceId };
|
||||
store.SaveAuthentication(new AgentAuthentication(expected, session.ExportCredential()));
|
||||
ResetConnectionState();
|
||||
WriteLog("INFO", "Step 3 of 4: binding the new device to the session and confirming the identity.");
|
||||
QqqSessionBinding actual = await session.BindDeviceAsync(new QqqDeviceCredential(response.DeviceId, response.DeviceToken), cancellationToken: token).ConfigureAwait(false);
|
||||
RequireSameIdentity(expected, actual);
|
||||
WriteLog("INFO", "Step 4 of 4: activating the session for printing.");
|
||||
ActivateSession(session, actual, store);
|
||||
lock(_stateLock)
|
||||
{
|
||||
_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}";
|
||||
}
|
||||
catch(Exception exception) when(exception is not InvalidOperationException)
|
||||
{
|
||||
WriteFailure("Enrollment failed; any existing pairing is unchanged", exception);
|
||||
throw;
|
||||
}
|
||||
finally { _authenticationGate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Logs why an enrollment request was refused before it reached the server, and returns the exception to throw.</summary>
|
||||
private InvalidOperationException Refused(string reason, InvalidOperationException exception)
|
||||
{
|
||||
WriteLog("WARN", "Enrollment refused: " + reason);
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -117,16 +118,23 @@ public sealed partial class AgentRuntime
|
||||
if(exception is QqqSessionException session && session.Failure is QqqSessionException.FailureKind.AuthenticationRequired or QqqSessionException.FailureKind.PermissionDenied)
|
||||
{ BlockAuthorization(); }
|
||||
}
|
||||
WriteLog("ERROR", "Realtime connection stopped: " + exception.Message);
|
||||
WriteFailure("Realtime connection stopped", exception);
|
||||
}
|
||||
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;
|
||||
await _reconnectRequested.WaitAsync(ReconnectDelay(settings, _reconnectAttempts++, Random.Shared.NextDouble()), token).ConfigureAwait(false);
|
||||
TimeSpan delay = ReconnectDelay(settings, _reconnectAttempts++, Random.Shared.NextDouble());
|
||||
if(!token.IsCancellationRequested)
|
||||
{
|
||||
WriteLog("DEBUG", $"Next realtime connection attempt ({_reconnectAttempts}) in {delay.TotalSeconds.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture)} s, or sooner on an explicit reconnect request.");
|
||||
}
|
||||
await _reconnectRequested.WaitAsync(delay, token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,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);
|
||||
@@ -188,10 +197,11 @@ public sealed partial class AgentRuntime
|
||||
Task handshakeWatch = AwaitRealtimeHandshakeAsync(handshake.Token, lifetime.Token);
|
||||
try
|
||||
{
|
||||
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
|
||||
{
|
||||
agentVersion = PrintApiClient.AgentVersion,
|
||||
capabilities = supportsMedia ? [.. RealtimeCapabilities, "printer-media-v1"] : RealtimeCapabilities,
|
||||
capabilities,
|
||||
lastAppliedRevision = _controlState.DesiredState.Revision,
|
||||
lastSettingsRevision = settings.Revision,
|
||||
limits = RealtimeSettings.ClientLimits,
|
||||
@@ -214,8 +224,66 @@ public sealed partial class AgentRuntime
|
||||
|
||||
|
||||
|
||||
/// <summary>Parses, validates and persists the settings carried by WELCOME or SETTINGS, refusing a revision older than the one in force, and hands them to the channel.</summary>
|
||||
private void ApplyRealtimeSettings(RealtimeEnvelope envelope, AgentStore store, PrintingRealtimeChannel channel)
|
||||
{
|
||||
JsonElement value = string.Equals(envelope.Type, "WELCOME", StringComparison.Ordinal) ? envelope.Payload.GetProperty("settings") : envelope.Payload;
|
||||
var settings = RealtimeSettings.Parse(value);
|
||||
settings.Validate();
|
||||
if(settings.Revision < _realtimeSettings.Revision)
|
||||
{ throw new InvalidDataException("Settings revision regressed."); }
|
||||
store.SaveRealtimeSettings(settings);
|
||||
_realtimeSettings = settings;
|
||||
channel.UpdateSettings(settings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Marks the runtime ready when the acknowledged update is the newest one on the current connection and the desired
|
||||
/// state did not change meanwhile; returns true only on the transition from not ready to ready, so it can be logged once.
|
||||
/// </summary>
|
||||
private bool MarkRealtimeReady(long generation, RealtimeStateUpdate update, AgentControlState saved)
|
||||
{
|
||||
lock(_stateLock)
|
||||
{
|
||||
if(generation != _connectionGeneration || update.Sequence != _stateSequence || saved.DesiredState.Revision != _controlState.DesiredState.Revision)
|
||||
{ return false; }
|
||||
bool becameReady = !_realtimeReady;
|
||||
_realtimeReady = true;
|
||||
_transportConnectedBefore = true;
|
||||
_controlReady = true;
|
||||
_controlFailure = null;
|
||||
_failure = null;
|
||||
_reconnectAttempts = 0;
|
||||
return becameReady;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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
|
||||
{
|
||||
@@ -225,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; }
|
||||
}
|
||||
@@ -272,6 +340,7 @@ public sealed partial class AgentRuntime
|
||||
{
|
||||
lock(_stateLock)
|
||||
{ _lastSuccessfulPoll = ObservationTime; _heartbeatFailure = null; }
|
||||
WriteLog("DEBUG", $"Realtime frame received: {envelope.Type}.");
|
||||
if(envelope.Type is "WELCOME" or "PONG")
|
||||
{
|
||||
string? serverTime = envelope.Payload.GetProperty("serverTime").GetString();
|
||||
@@ -300,10 +369,15 @@ public sealed partial class AgentRuntime
|
||||
WakePoll();
|
||||
RequestQueueRefresh();
|
||||
break;
|
||||
case "PRINTERS":
|
||||
ApplyPrinterApproval(envelope);
|
||||
RequestStatusSynchronization();
|
||||
break;
|
||||
case "PONG":
|
||||
break;
|
||||
case "ERROR":
|
||||
string code = envelope.Payload.GetProperty("code").GetString() ?? "UNKNOWN";
|
||||
WriteLog("WARN", $"The server sent a realtime ERROR frame with code {code}{(envelope.Payload.TryGetProperty("message", out JsonElement detail) && detail.ValueKind == JsonValueKind.String ? ": " + detail.GetString() : "")}.");
|
||||
if(code is "AUTHENTICATION_REQUIRED" or "PERMISSION_DENIED" or "AUTHORIZATION_REQUIRED")
|
||||
{ BlockAuthorization(); }
|
||||
throw new IOException("Realtime server response: " + code);
|
||||
@@ -364,34 +438,18 @@ public sealed partial class AgentRuntime
|
||||
{ if(generation != _connectionGeneration) { throw new OperationCanceledException(token); } }
|
||||
using var store = new AgentStore(directory);
|
||||
if(envelope.Type is "WELCOME" or "SETTINGS")
|
||||
{
|
||||
JsonElement value = string.Equals(envelope.Type, "WELCOME", StringComparison.Ordinal) ? envelope.Payload.GetProperty("settings") : envelope.Payload;
|
||||
var settings = RealtimeSettings.Parse(value);
|
||||
settings.Validate();
|
||||
if(settings.Revision < _realtimeSettings.Revision)
|
||||
{ throw new InvalidDataException("Settings revision regressed."); }
|
||||
store.SaveRealtimeSettings(settings);
|
||||
_realtimeSettings = settings;
|
||||
channel.UpdateSettings(settings);
|
||||
}
|
||||
{ ApplyRealtimeSettings(envelope, store, channel); }
|
||||
lock(_stateLock)
|
||||
{ saved = _controlState; }
|
||||
store.SaveControlState(saved);
|
||||
}
|
||||
finally { _connectionGate.Release(); }
|
||||
await channel.SendAsync("STATE_APPLIED", new { lastAppliedRevision = saved.DesiredState.Revision, lastSettingsRevision = _realtimeSettings.Revision }, acknowledged: true, token: token).ConfigureAwait(false);
|
||||
lock(_stateLock)
|
||||
{
|
||||
if(generation == _connectionGeneration && update.Sequence == _stateSequence && saved.DesiredState.Revision == _controlState.DesiredState.Revision)
|
||||
{
|
||||
_realtimeReady = true;
|
||||
_transportConnectedBefore = true;
|
||||
_controlReady = true;
|
||||
_controlFailure = null;
|
||||
_failure = null;
|
||||
_reconnectAttempts = 0;
|
||||
}
|
||||
}
|
||||
bool becameReady = MarkRealtimeReady(generation, update, saved);
|
||||
if(becameReady)
|
||||
{ WriteLog("INFO", $"Realtime channel ready after {envelope.Type}: settings revision {_realtimeSettings.Revision}, desired-state revision {saved.DesiredState.Revision}. Connected to the print server."); }
|
||||
else
|
||||
{ WriteLog("DEBUG", $"Realtime {envelope.Type} applied and acknowledged (settings revision {_realtimeSettings.Revision}, desired-state revision {saved.DesiredState.Revision})."); }
|
||||
SignalPipeline();
|
||||
RequestStatusSynchronization();
|
||||
RequestQueueRefresh();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -24,6 +24,26 @@ public sealed partial class AgentRuntime
|
||||
case "set-log-settings":
|
||||
AgentActivityLog.SaveSettings(directory, request.LogSettings ?? throw new InvalidOperationException("Log settings are required."));
|
||||
return JsonSerializer.Serialize(AgentActivityLog.GetSettings(directory), PrintApiClient.JsonOptions);
|
||||
case "get-server-trust":
|
||||
using(var store = new AgentStore(directory, recoverUnreadableConfiguration: true))
|
||||
{
|
||||
return JsonSerializer.Serialize(store.LoadServerTrust(), PrintApiClient.JsonOptions);
|
||||
}
|
||||
case "set-server-trust":
|
||||
ServerTrust trust = request.ServerTrust ?? throw new InvalidOperationException("Server trust settings are required.");
|
||||
if(trust.AllowUntrustedCertificate && (trust.Origin is null || !Uri.TryCreate(trust.Origin, UriKind.Absolute, out Uri? trusted) || !string.Equals(ServerTrust.Normalize(trusted), trust.Origin, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new InvalidOperationException("Enter the server address before allowing its untrusted certificate; the exception is saved for that one server only.");
|
||||
}
|
||||
trust = trust.AllowUntrustedCertificate ? trust : new ServerTrust();
|
||||
using(var store = new AgentStore(directory, recoverUnreadableConfiguration: true))
|
||||
{
|
||||
store.SaveServerTrust(trust);
|
||||
}
|
||||
WriteLog(trust.AllowUntrustedCertificate ? "WARN" : "INFO", trust.AllowUntrustedCertificate
|
||||
? $"An administrator ALLOWED untrusted server certificates for {trust.Origin}. The service and companion will accept any certificate, including a forged one, from that server only; use this with a development server and turn it off afterwards."
|
||||
: "Untrusted server certificates are refused again for every server (default). Only certificates Windows trusts for the LocalSystem account are accepted.");
|
||||
return JsonSerializer.Serialize(trust, PrintApiClient.JsonOptions);
|
||||
case "clear-logs":
|
||||
int deleted = AgentActivityLog.ClearLogs(directory);
|
||||
lock(_stateLock)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,43 +31,55 @@ public sealed partial class AgentRuntime
|
||||
/// </summary>
|
||||
private async Task<string> SignInCoreAsync(PipeRequest request, CancellationToken token)
|
||||
{
|
||||
QqqSessionCredential credential = request.SessionCredential ?? throw new InvalidOperationException("A QQQ sign-in session is required.");
|
||||
if(request.StartNewPairing && request.ClaimLegacyDevice)
|
||||
{
|
||||
throw new InvalidOperationException("Choose either a new pairing or association with the existing pairing.");
|
||||
}
|
||||
QqqSessionCredential credential = BeginSignIn(request);
|
||||
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
|
||||
IQqqSessionClient? candidate = null;
|
||||
try
|
||||
{
|
||||
candidate = NewSession(credential.Origin);
|
||||
WriteLog("INFO", "Step 1 of 4: verifying the companion's session with the server.");
|
||||
await candidate.RestoreSessionAsync(credential, token).ConfigureAwait(false);
|
||||
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
|
||||
AgentConfiguration? configuration = store.LoadConfiguration();
|
||||
WriteLog("INFO", "Step 2 of 4: reading the session identity. Saved pairing: "
|
||||
+ (configuration is null ? "none (this computer has never been enrolled)." : $"server {configuration.ServerUrl}, device {configuration.DeviceId}."));
|
||||
QqqSessionBinding binding = await candidate.GetSessionIdentityAsync(token).ConfigureAwait(false);
|
||||
if(request.StartNewPairing && binding.DeviceId is not null)
|
||||
{
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "New pairing requires a fresh QQQ session without device proof.");
|
||||
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 {AppIdentity.ServerName} session without device proof.");
|
||||
}
|
||||
AgentAuthentication? saved = store.LoadAuthenticationForRecovery();
|
||||
if(!request.StartNewPairing && configuration is not null && SameOrigin(credential.Origin, configuration.ServerUrl))
|
||||
{
|
||||
WriteLog("INFO", $"Step 3 of 4: the session is for the paired server; binding device {configuration.DeviceId}{(request.ClaimLegacyDevice ? " and claiming it as a legacy device" : "")}.");
|
||||
binding = await candidate.BindDeviceAsync(new QqqDeviceCredential(configuration.DeviceId, configuration.DeviceToken), request.ClaimLegacyDevice, token).ConfigureAwait(false);
|
||||
if(saved?.Binding.DeviceId is not null && SameOrigin(saved.Binding.Origin, configuration.ServerUrl))
|
||||
{
|
||||
WriteLog("INFO", "Comparing the new identity with the previously approved identity.");
|
||||
RequireSameIdentity(saved.Binding, binding);
|
||||
}
|
||||
store.SaveControlState(store.LoadControlState() with { AuthorizationRequired = false });
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteLog("INFO", "Step 3 of 4: no device binding. "
|
||||
+ (request.StartNewPairing ? "A new pairing was requested; enroll this computer next."
|
||||
: configuration is null ? "This computer is not enrolled; enroll it next to enable printing."
|
||||
: $"The session is for {credential.Origin.Authority} but the pairing is for {configuration.ServerUrl}; enroll on this server, or sign in to the paired one."));
|
||||
}
|
||||
IQqqSessionClient? previous = _sessionClient;
|
||||
ResetConnectionState();
|
||||
WriteLog("INFO", "Step 4 of 4: making the verified session current and saving it to protected storage.");
|
||||
ActivateSession(candidate, binding, store);
|
||||
previous?.Dispose();
|
||||
WakePoll();
|
||||
return "Signed in to QQQ. " + _authenticationMessage;
|
||||
WriteLog("INFO", "Sign-in complete. " + _authenticationMessage);
|
||||
return SignInReply();
|
||||
}
|
||||
catch
|
||||
catch(Exception exception)
|
||||
{
|
||||
WriteFailure("Sign-in failed; the previous session, pairing and history are unchanged", exception);
|
||||
candidate?.Dispose();
|
||||
throw;
|
||||
}
|
||||
@@ -74,9 +88,57 @@ 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 {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.");
|
||||
throw new InvalidOperationException("Choose either a new pairing or association with the existing pairing.");
|
||||
}
|
||||
WriteLog("INFO", $"Sign-in requested for {credential.Origin.Authority}; pairing choice: "
|
||||
+ (request.StartNewPairing ? "start a new pairing." : request.ClaimLegacyDevice ? "associate with the existing pairing (claim legacy device)." : "keep the current pairing."));
|
||||
return credential;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -85,40 +147,58 @@ 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
|
||||
{
|
||||
WriteLog("INFO", "Sign-out requested: blocking printing, removing the saved session credential and ending the server session. Pairing and history are kept.");
|
||||
BlockAuthorization();
|
||||
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
|
||||
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 {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)
|
||||
{
|
||||
WriteLog("INFO", $"No live session is held; restoring the saved one for {saved.Credential.Origin.Authority} so the server can end it.");
|
||||
_sessionClient = NewSession(saved.Credential.Origin);
|
||||
await _sessionClient.RestoreSessionAsync(saved.Credential, token).ConfigureAwait(false);
|
||||
}
|
||||
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."); }
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
WriteFailure("The server session could not be ended; local sign-out still completes", exception);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sessionClient?.Dispose();
|
||||
_sessionClient = null;
|
||||
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(); }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ using System.IO.Pipes;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text.Json;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using Qqq.PrintAgent.Core;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
|
||||
private readonly Queue<AgentLogEntry> _logs = new();
|
||||
private readonly SemaphoreSlim _pollRequested = new(0, 1);
|
||||
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||
private readonly IActivityLog _activityLog = AgentActivityLog.Create(directory);
|
||||
private readonly AgentActivityLog _activityLog = AgentActivityLog.Create(directory);
|
||||
private DateTimeOffset? _lastSuccessfulPoll;
|
||||
private string? _failure;
|
||||
private int _pollIntervalSeconds = 5;
|
||||
@@ -194,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -234,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)
|
||||
@@ -267,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,12 +278,23 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
|
||||
|
||||
/// <summary>
|
||||
/// Records a service message in the in-memory ring shown to the companion (newest 100) and in the shared activity log.
|
||||
/// The text is redacted first and capped at 1000 characters, so credentials and document content cannot reach either sink.
|
||||
/// The text is redacted first and capped at 1000 characters (4000 for debug detail), so credentials and document content
|
||||
/// cannot reach either sink. DEBUG entries go only to the file, only while debug logging is on, and never displace the
|
||||
/// ring's operational entries.
|
||||
/// </summary>
|
||||
private void WriteLog(string level, string message)
|
||||
{
|
||||
bool debug = string.Equals(level, "DEBUG", StringComparison.Ordinal);
|
||||
if(debug && !_activityLog.IsDebugEnabled)
|
||||
{ return; }
|
||||
string sanitized = AgentActivityLog.Redact(message);
|
||||
string bounded = sanitized.Length <= 1000 ? sanitized : sanitized[..1000];
|
||||
int cap = debug ? 4000 : 1000;
|
||||
string bounded = sanitized.Length <= cap ? sanitized : sanitized[..cap];
|
||||
if(debug)
|
||||
{
|
||||
_activityLog.Debug("[Service] " + bounded);
|
||||
return;
|
||||
}
|
||||
lock(_stateLock)
|
||||
{
|
||||
_logs.Enqueue(new AgentLogEntry(DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture), level, bounded));
|
||||
@@ -289,15 +303,38 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
|
||||
_logs.Dequeue();
|
||||
}
|
||||
}
|
||||
if(string.Equals(level, "ERROR", StringComparison.Ordinal))
|
||||
{ _activityLog.Error("[Service] " + bounded); }
|
||||
else
|
||||
{ _activityLog.Info("[Service] " + bounded); }
|
||||
switch(level)
|
||||
{
|
||||
case "ERROR":
|
||||
_activityLog.Error("[Service] " + bounded);
|
||||
break;
|
||||
case "WARN":
|
||||
_activityLog.Warning("[Service] " + bounded);
|
||||
break;
|
||||
default:
|
||||
_activityLog.Info("[Service] " + bounded);
|
||||
break;
|
||||
}
|
||||
log(bounded);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Records a failure with its type and message at ERROR, and the full exception (stack and inner causes) as debug detail.</summary>
|
||||
private void WriteFailure(string context, Exception exception)
|
||||
{
|
||||
WriteLog("ERROR", context + ": " + QqqSessionDiagnostics.Describe(exception));
|
||||
WriteLog("DEBUG", context + " detail: " + exception);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The session client's progress sink: step entries at INFO, request detail at DEBUG, both through the redacting service log.</summary>
|
||||
private QqqSessionDiagnostics SessionDiagnostics() => new(message => WriteLog("INFO", "[Session] " + message),
|
||||
message => WriteLog("DEBUG", "[Session] " + message), () => _activityLog.IsDebugEnabled);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Serves the local administrator pipe one client at a time, each command under a timeout (longer for sign-in and
|
||||
/// enrollment). A failing or vanished client gets a short, redacted error and never stops the pipe; an unreadable saved
|
||||
@@ -322,10 +359,10 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
|
||||
}
|
||||
catch(Exception exception) when(exception is not OperationCanceledException || !token.IsCancellationRequested)
|
||||
{
|
||||
WriteLog("ERROR", "Local command failed: " + exception.Message);
|
||||
WriteLog("ERROR", "Local command failed: " + QqqSessionDiagnostics.Describe(exception));
|
||||
string message = exception is System.Security.Cryptography.CryptographicException
|
||||
? "The saved connection cannot be opened. Open Settings, choose Connect this device or Sign in again, and use a fresh server enrollment code. Existing job history will be preserved."
|
||||
: AgentActivityLog.Redact(exception.Message);
|
||||
: AgentActivityLog.Redact(QqqSessionDiagnostics.Explain(exception));
|
||||
try
|
||||
{
|
||||
using var errorTimeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
@@ -372,6 +409,8 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
|
||||
case "get-device-profile":
|
||||
case "get-log-settings":
|
||||
case "set-log-settings":
|
||||
case "get-server-trust":
|
||||
case "set-server-trust":
|
||||
case "clear-logs":
|
||||
return HandleSettingsCommand(request);
|
||||
case "prepare-update":
|
||||
@@ -403,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":
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Transport trust part of the store. The setting is machine-wide rather than connection-scoped because it must apply to
|
||||
/// the sign-in that happens before any pairing exists, and a damaged saved value falls back to the safe default (refuse).
|
||||
/// </summary>
|
||||
public sealed partial class AgentStore
|
||||
{
|
||||
/// <summary>Reads the administrator's transport trust choice; a missing or unreadable value means untrusted certificates are refused.</summary>
|
||||
public ServerTrust LoadServerTrust()
|
||||
{
|
||||
using SqliteCommand command = _database.CreateCommand();
|
||||
command.CommandText = "CREATE TABLE IF NOT EXISTS server_trust(scope TEXT PRIMARY KEY,json TEXT NOT NULL); SELECT json FROM server_trust WHERE scope='machine'";
|
||||
try
|
||||
{ return command.ExecuteScalar() is string json ? JsonSerializer.Deserialize<ServerTrust>(json, PrintApiClient.JsonOptions) ?? new() : new(); }
|
||||
catch(JsonException)
|
||||
{ return new ServerTrust(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Saves the administrator's transport trust choice for both the service and the companion.</summary>
|
||||
public void SaveServerTrust(ServerTrust trust)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(trust);
|
||||
using SqliteCommand command = _database.CreateCommand();
|
||||
command.CommandText = "CREATE TABLE IF NOT EXISTS server_trust(scope TEXT PRIMARY KEY,json TEXT NOT NULL); INSERT INTO server_trust(scope,json) VALUES('machine',$json) ON CONFLICT(scope) DO UPDATE SET json=$json";
|
||||
command.Parameters.AddWithValue("$json", JsonSerializer.Serialize(trust, PrintApiClient.JsonOptions));
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -5,5 +5,12 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>Machine-wide diagnostic limits. Daily rotation is always enabled.</summary>
|
||||
public sealed record LogSettings(int RetentionDays = 30, int MaxDailyMegabytes = 20, long CurrentDayBytes = 0, bool DailyLimitReached = false);
|
||||
/// <summary>
|
||||
/// Machine-wide diagnostic limits. Daily rotation is always on. A day's file that reaches <see cref="MaxFileMegabytes"/> rolls
|
||||
/// over to a numbered segment and logging continues in a fresh file; only the newest <see cref="SegmentsPerDay"/> rolled
|
||||
/// segments of a day are kept, and whole days older than <see cref="RetentionDays"/> are removed. <see cref="DebugLogging"/>
|
||||
/// adds request-level detail to the shared log. <see cref="CurrentFileBytes"/> and <see cref="SegmentsToday"/> are read-only
|
||||
/// observations for the Settings page.
|
||||
/// </summary>
|
||||
public sealed record LogSettings(int RetentionDays = 30, int MaxFileMegabytes = 100, int SegmentsPerDay = 10, bool DebugLogging = false,
|
||||
long CurrentFileBytes = 0, int SegmentsToday = 0);
|
||||
@@ -7,7 +7,6 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
@@ -217,7 +216,7 @@ public static partial class MsiUpdateInstaller
|
||||
try
|
||||
{
|
||||
WriteOutcome(new InstallOutcome(DateTimeOffset.UtcNow, version, code, message));
|
||||
IActivityLog log = AgentActivityLog.Create(AgentStore.DefaultDirectory);
|
||||
var log = AgentActivityLog.Create(AgentStore.DefaultDirectory);
|
||||
if(code is 0 or 3010)
|
||||
{
|
||||
log.Info("[Update] " + 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;
|
||||
|
||||
@@ -12,7 +12,8 @@ namespace Qqq.PrintAgent.Windows;
|
||||
public sealed record PipeRequest(string Operation, string? ServerUrl = null, string? EnrollmentCode = null, string? PrinterId = null, bool? Exposed = null,
|
||||
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)
|
||||
Qqq.PrintAgent.Core.Authentication.QqqSessionCredential? SessionCredential = null, bool ClaimLegacyDevice = false, bool StartNewPairing = false,
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostics for the browser hand-off. The protocol handler runs as the ordinary (non-elevated) user, launched by the
|
||||
/// browser, so it cannot write the protected machine log; it appends one-line, credential-free entries to a small per-user
|
||||
/// trace file instead, and the companion copies the recent entries into the activity log when a browser sign-in does not
|
||||
/// complete. The callback address itself (which carries the one-time code and state) is never written.
|
||||
/// </summary>
|
||||
public sealed partial class QqqBrowserCallback
|
||||
{
|
||||
private const long TraceLimit = 256 * 1024;
|
||||
|
||||
|
||||
|
||||
/// <summary>The per-user trace file the protocol handler writes.</summary>
|
||||
public static string TracePath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "QqqPrintAgent", "auth-callback.log");
|
||||
|
||||
|
||||
|
||||
/// <summary>Forwards a callback from the protocol handler and records each step and the outcome in the trace file, so a hand-off that never reaches the companion can be diagnosed afterwards.</summary>
|
||||
public static async Task ForwardFromHandlerAsync(string callback, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
Trace("Protocol handler started by the browser (process " + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + ", session "
|
||||
+ System.Diagnostics.Process.GetCurrentProcess().SessionId.ToString(CultureInfo.InvariantCulture) + "); callback length " + callback.Length.ToString(CultureInfo.InvariantCulture) + ".");
|
||||
try
|
||||
{
|
||||
await ForwardAsync(callback, PipeName(), InstalledCompanionPath(), token, Trace).ConfigureAwait(false);
|
||||
Trace("Callback delivered: the companion acknowledged it.");
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
Trace("Callback NOT delivered: " + exception.GetType().Name + ": " + exception.Message
|
||||
+ (exception.InnerException is null ? "" : " (" + exception.InnerException.GetType().Name + ": " + exception.InnerException.Message + ")"));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the trace entries written within the window (newest last, at most 40), or an empty list when there is no trace or it cannot be read.</summary>
|
||||
public static IReadOnlyList<string> RecentTrace(TimeSpan window)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!File.Exists(TracePath))
|
||||
{ return []; }
|
||||
DateTimeOffset cutoff = DateTimeOffset.Now - window;
|
||||
var recent = new List<string>();
|
||||
foreach(string line in File.ReadLines(TracePath))
|
||||
{
|
||||
if(line.Length > 24 && DateTimeOffset.TryParseExact(line[..23], "yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out DateTimeOffset at) && at >= cutoff)
|
||||
{ recent.Add(line); }
|
||||
}
|
||||
return recent.Count <= 40 ? recent : recent[^40..];
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Appends one timestamped line to the per-user trace, truncating the file when it grows past the limit; a failure to write never affects the hand-off.</summary>
|
||||
private static void Trace(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
string path = TracePath;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
if(File.Exists(path) && new FileInfo(path).Length > TraceLimit)
|
||||
{ File.Delete(path); }
|
||||
File.AppendAllText(path, DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + " " + message + Environment.NewLine);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// The trace is best-effort diagnostics only.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -54,7 +75,7 @@ public sealed partial class QqqBrowserCallback : IDisposable
|
||||
|
||||
/// <summary>Forwards a browser callback from the protocol handler to the running companion, using the installed companion's path as the expected peer.</summary>
|
||||
public static Task ForwardAsync(string callback, CancellationToken token = default) => ForwardAsync(callback, PipeName(),
|
||||
InstalledCompanionPath(), token);
|
||||
InstalledCompanionPath(), token, null);
|
||||
|
||||
|
||||
|
||||
@@ -63,14 +84,20 @@ public sealed partial class QqqBrowserCallback : IDisposable
|
||||
/// the whole exchange has a five second limit, and the process serving the pipe must be the expected companion executable
|
||||
/// in this session, so the address (which carries a one-time code) is never handed to an impostor. The companion must acknowledge it.
|
||||
/// </summary>
|
||||
internal static async Task ForwardAsync(string callback, string pipeName, string expectedExecutable, CancellationToken token)
|
||||
internal static async Task ForwardAsync(string callback, string pipeName, string expectedExecutable, CancellationToken token, Action<string>? trace = null)
|
||||
{
|
||||
_ = Validate(callback);
|
||||
Uri validated = Validate(callback);
|
||||
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 + ".");
|
||||
using Process peer = AuthenticatePeer(pipe.SafePipeHandle, expectedExecutable);
|
||||
trace?.Invoke("Companion verified (process " + peer.Id.ToString(System.Globalization.CultureInfo.InvariantCulture) + "); sending the callback.");
|
||||
await AgentPipeFraming.WriteAsync(pipe, callback, MaximumLength, timeout.Token).ConfigureAwait(false);
|
||||
string acknowledgement = await AgentPipeFraming.ReadAsync(pipe, 32, timeout.Token).ConfigureAwait(false);
|
||||
if(!string.Equals(acknowledgement, "received", StringComparison.Ordinal))
|
||||
@@ -154,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();
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Transport trust chosen by an administrator for one server. <see cref="AllowUntrustedCertificate"/> makes the service and
|
||||
/// companion accept a certificate Windows does not trust (self-signed or a private authority such as a local Caddy root),
|
||||
/// but only for the server whose scheme, host and port equal <see cref="Origin"/>; a session for any other server keeps
|
||||
/// normal validation, so a development exception cannot carry over to a production pairing. It is for development servers
|
||||
/// only: with it on, a forged certificate for that server is also accepted. Every session created under it is logged with
|
||||
/// a warning, and the setting stays until it is turned off or saved for a different server.
|
||||
/// </summary>
|
||||
public sealed record ServerTrust(bool AllowUntrustedCertificate = false, string? Origin = null)
|
||||
{
|
||||
/// <summary>True when untrusted certificates are allowed and the given server is exactly the one the exception was saved for.</summary>
|
||||
public bool AppliesTo(Uri origin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(origin);
|
||||
return AllowUntrustedCertificate && Origin is not null && origin.IsAbsoluteUri
|
||||
&& string.Equals(Normalize(origin), Origin, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The scheme, host and port of a server address in the form the exception is saved as.</summary>
|
||||
public static string Normalize(Uri origin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(origin);
|
||||
return origin.GetLeftPart(UriPartial.Authority).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using KofTwentyTwo.AppKit.WinUI;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Qqq.PrintAgent.Windows;
|
||||
@@ -30,7 +29,7 @@ public partial class App : Application
|
||||
|
||||
|
||||
/// <summary>The app-wide activity log.</summary>
|
||||
internal static IActivityLog Log { get; } = AgentActivityLog.Create(AgentStore.DefaultDirectory);
|
||||
internal static IAgentLog Log { get; } = AgentActivityLog.Create(AgentStore.DefaultDirectory);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
@@ -21,6 +22,106 @@ public sealed partial class MainWindow
|
||||
private QqqSessionClient? _signInClient;
|
||||
private QqqAuthenticationMetadata? _signInMetadata;
|
||||
private CancellationTokenSource? _browserSignInCancellation;
|
||||
private ServerTrust _serverTrust = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>The companion's progress sink for the sign-in client: steps at INFO, request detail at DEBUG, in the shared log the service also writes.</summary>
|
||||
private static QqqSessionDiagnostics CompanionDiagnostics() => new(message => App.Log.Info("[Companion] " + message),
|
||||
message => App.Log.Debug("[Companion] " + message), () => App.Log.IsDebugEnabled);
|
||||
|
||||
|
||||
|
||||
/// <summary>Records a companion-side failure: type and message at ERROR, the full exception as debug detail.</summary>
|
||||
private static void LogFailure(string context, Exception exception)
|
||||
{
|
||||
App.Log.Error("[Companion] " + context + ": " + QqqSessionDiagnostics.Describe(exception));
|
||||
App.Log.Debug("[Companion] " + context + " detail: " + exception);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the service's transport trust setting into the connection dialog's checkbox; when the service cannot be reached the box stays unchecked and the reason is logged.</summary>
|
||||
private async Task LoadServerTrustAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
string response = await _sendRequest(new PipeRequest("get-server-trust"));
|
||||
if(!response.StartsWith("Error:", StringComparison.Ordinal))
|
||||
{
|
||||
_serverTrust = JsonSerializer.Deserialize<ServerTrust>(response, Qqq.PrintAgent.Core.Printing.PrintApiClient.JsonOptions) ?? new();
|
||||
ShowServerTrust();
|
||||
return;
|
||||
}
|
||||
App.Log.Warning("[Companion] Could not read the server trust setting: " + response);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException or JsonException)
|
||||
{
|
||||
LogFailure("Could not read the server trust setting from the service", exception);
|
||||
}
|
||||
_serverTrust = new ServerTrust();
|
||||
ShowServerTrust();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reflects the saved exception in the checkbox and names the server it is bound to, so an exception saved for another server is visibly not in force for the one being entered.</summary>
|
||||
private void ShowServerTrust()
|
||||
{
|
||||
AllowUntrustedCertificateBox.IsChecked = _serverTrust.AllowUntrustedCertificate;
|
||||
AllowUntrustedCertificateLabel.Text = _serverTrust.AllowUntrustedCertificate
|
||||
? "Allow untrusted server certificate for " + _serverTrust.Origin + " (development only)"
|
||||
: "Allow untrusted server certificate for this server (development only)";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the saved exception is on and bound to exactly the server being signed in to.</summary>
|
||||
private bool AllowsUntrustedCertificate(Uri origin) => _serverTrust.AppliesTo(origin);
|
||||
|
||||
|
||||
|
||||
/// <summary>Saves the checkbox to the service so the service's own sessions honor it, and logs the change loudly; a failed save reverts the box and shows why.</summary>
|
||||
private async void AllowUntrustedCertificate_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
bool allow = AllowUntrustedCertificateBox.IsChecked == true;
|
||||
Uri? origin = null;
|
||||
if(allow && !Uri.TryCreate(ServerBox.Text.Trim(), UriKind.Absolute, out origin))
|
||||
{
|
||||
AllowUntrustedCertificateBox.IsChecked = false;
|
||||
Feedback("Enter the server address first", "The certificate exception is saved for one server only, so the server address must be entered before it can be allowed.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
ServerTrust requested = allow ? new ServerTrust(true, ServerTrust.Normalize(origin!)) : new ServerTrust();
|
||||
try
|
||||
{
|
||||
string response = await _sendRequest(new PipeRequest("set-server-trust", ServerTrust: requested));
|
||||
if(response.StartsWith("Error:", StringComparison.Ordinal))
|
||||
{
|
||||
ShowServerTrust();
|
||||
Feedback("Server trust not saved", response, InfoBarSeverity.Error);
|
||||
return;
|
||||
}
|
||||
_serverTrust = requested;
|
||||
ShowServerTrust();
|
||||
if(allow)
|
||||
{
|
||||
App.Log.Warning("[Companion] Untrusted server certificates ALLOWED by the operator for " + requested.Origin + " (development only).");
|
||||
Feedback("Untrusted certificate allowed for " + requested.Origin, "The service and companion will accept any certificate from this one server until this is turned off. Other servers keep normal validation. Use only with a development server.", InfoBarSeverity.Warning);
|
||||
}
|
||||
else
|
||||
{
|
||||
App.Log.Info("[Companion] Untrusted server certificates refused again for every server (default).");
|
||||
Feedback("Untrusted certificates refused", "Only certificates Windows trusts for the service account are accepted again.", InfoBarSeverity.Informational);
|
||||
}
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException)
|
||||
{
|
||||
ShowServerTrust();
|
||||
LogFailure("Server trust setting could not be saved", exception);
|
||||
ServiceUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -50,24 +151,31 @@ public sealed partial class MainWindow
|
||||
{
|
||||
_signInClient?.Dispose();
|
||||
_signInClient = null;
|
||||
if(!Uri.TryCreate(ServerBox.Text.Trim(), UriKind.Absolute, out Uri? origin))
|
||||
string entered = ServerBox.Text.Trim();
|
||||
App.Log.Info("[Companion] Discover sign-in options requested for '" + entered + "'.");
|
||||
if(!Uri.TryCreate(entered, UriKind.Absolute, out Uri? origin))
|
||||
{
|
||||
App.Log.Warning("[Companion] The server address is not an absolute URL (expected https://host[:port]).");
|
||||
throw new InvalidOperationException("Enter a valid server origin.");
|
||||
}
|
||||
QqqSessionClient client = new(origin, allowLoopbackHttp: origin.IsLoopback);
|
||||
QqqSessionClient client = new(origin, allowLoopbackHttp: origin.IsLoopback, diagnostics: CompanionDiagnostics(), allowUntrustedCertificate: AllowsUntrustedCertificate(origin));
|
||||
_signInClient = client;
|
||||
_ = await client.DiscoverAsync();
|
||||
_signInMetadata = await client.GetAuthenticationMetadataAsync();
|
||||
PasswordSignInPanel.Visibility = string.Equals(_signInMetadata.Type, "TABLE_BASED", StringComparison.Ordinal) ? Visibility.Visible : Visibility.Collapsed;
|
||||
BrowserSignInButton.Visibility = _signInMetadata.Values?.NativeSessionLogin is not null ? Visibility.Visible : Visibility.Collapsed;
|
||||
bool supported = PasswordSignInPanel.Visibility == Visibility.Visible || BrowserSignInButton.Visibility == Visibility.Visible;
|
||||
App.Log.Info("[Companion] Sign-in options for " + origin.Authority + ": password sign-in "
|
||||
+ (PasswordSignInPanel.Visibility == Visibility.Visible ? "offered" : "not offered") + ", browser sign-in "
|
||||
+ (BrowserSignInButton.Visibility == Visibility.Visible ? "offered" : "not offered") + ".");
|
||||
Feedback(supported ? "Sign-in options ready" : "Server configuration required",
|
||||
supported ? "Use the sign-in method configured by this server." : "This server has not enabled a supported native sign-in grant. Ask its administrator to configure it.",
|
||||
supported ? InfoBarSeverity.Informational : InfoBarSeverity.Warning);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or ArgumentException or InvalidOperationException or HttpRequestException or OperationCanceledException)
|
||||
{
|
||||
Feedback("Discovery failed", "The server did not provide compatible authentication and printing discovery. Check its address and configuration.", InfoBarSeverity.Error);
|
||||
LogFailure("Discovery failed", exception);
|
||||
Feedback("Discovery failed", "The server did not provide compatible authentication and printing discovery. Reason: " + QqqSessionDiagnostics.Explain(exception) + " The activity log has the full detail.", InfoBarSeverity.Error);
|
||||
}
|
||||
finally { SetBusy(false); }
|
||||
}
|
||||
@@ -86,12 +194,15 @@ public sealed partial class MainWindow
|
||||
SetBusy(true);
|
||||
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();
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or ArgumentException or HttpRequestException or OperationCanceledException or UnauthorizedAccessException)
|
||||
{
|
||||
Feedback("Sign-in not completed", "The server did not authorize this connection, or the service could not confirm it. Check your sign-in, device ownership and current permissions.", InfoBarSeverity.Error);
|
||||
LogFailure("Password sign-in not completed", exception);
|
||||
Feedback("Sign-in not completed", "The server did not authorize this connection, or the service could not confirm it. Reason: " + QqqSessionDiagnostics.Explain(exception) + " The activity log has the full detail.", InfoBarSeverity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -109,15 +220,25 @@ public sealed partial class MainWindow
|
||||
/// </summary>
|
||||
private async Task TransferSignInAsync()
|
||||
{
|
||||
string choice = PairingChoice.SelectedIndex == 2 ? "start a new pairing" : PairingChoice.SelectedIndex == 1 ? "associate with the existing pairing" : "keep the current pairing";
|
||||
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
|
||||
{ App.Log.Error("[Companion] Service sign-in result: " + result); }
|
||||
Feedback(success ? "Signed in" : "Service authorization not completed", result, success ? InfoBarSeverity.Success : InfoBarSeverity.Error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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)
|
||||
@@ -125,18 +246,75 @@ public sealed partial class MainWindow
|
||||
SetBusy(true);
|
||||
try
|
||||
{
|
||||
string result = await _sendRequest(new PipeRequest("sign-out"));
|
||||
App.Log.Info("[Companion] Sign-out requested; asking the service to end the session.");
|
||||
string result = await _sendRequest(new PipeRequest("sign-out", PostLogoutRedirectUri: QqqBrowserCallback.RedirectUri));
|
||||
_signInClient?.Dispose();
|
||||
_signInClient = null;
|
||||
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)
|
||||
{ ServiceUnavailable(); }
|
||||
{
|
||||
LogFailure("Sign-out could not reach the service", exception);
|
||||
ServiceUnavailable();
|
||||
}
|
||||
finally { SetBusy(false); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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
|
||||
@@ -150,16 +328,20 @@ public sealed partial class MainWindow
|
||||
CancelBrowserSignInButton.Visibility = Visibility.Visible;
|
||||
try
|
||||
{
|
||||
App.Log.Info("[Companion] Browser sign-in requested for " + _signInClient.Origin.Authority + "; this application's callback is " + QqqBrowserCallback.RedirectUri + ".");
|
||||
string redirect = _signInMetadata?.Values?.NativeSessionLogin?.RedirectUris.FirstOrDefault(value =>
|
||||
string.Equals(value, QqqBrowserCallback.RedirectUri, StringComparison.Ordinal))
|
||||
?? throw new InvalidOperationException("No approved native browser callback is configured.");
|
||||
?? 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();
|
||||
}
|
||||
catch(OperationCanceledException)
|
||||
{
|
||||
App.Log.Info("[Companion] Browser sign-in cancelled or timed out (three minute limit) before a callback arrived.");
|
||||
ReportCallbackTrace();
|
||||
_signInClient?.Dispose();
|
||||
_signInClient = null;
|
||||
BrowserSignInButton.Visibility = Visibility.Collapsed;
|
||||
@@ -167,7 +349,9 @@ public sealed partial class MainWindow
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or InvalidOperationException or HttpRequestException or UnauthorizedAccessException or System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Feedback("Browser sign-in not completed", "No validated account session was received. Check the server's advertised native sign-in grant and installed callback configuration, then try again explicitly.", InfoBarSeverity.Warning);
|
||||
LogFailure("Browser sign-in not completed", exception);
|
||||
ReportCallbackTrace();
|
||||
Feedback("Browser sign-in not completed", "No validated account session was received. Reason: " + QqqSessionDiagnostics.Explain(exception) + " Check the server's advertised native sign-in grant and installed callback configuration, then try again explicitly. The activity log has the full detail.", InfoBarSeverity.Warning);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -179,6 +363,24 @@ public sealed partial class MainWindow
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Copies the protocol handler's recent trace into the activity log, or states that the browser never launched the
|
||||
/// handler at all, so a hand-off that stops outside the companion is still visible in one place.
|
||||
/// </summary>
|
||||
private static void ReportCallbackTrace()
|
||||
{
|
||||
IReadOnlyList<string> lines = QqqBrowserCallback.RecentTrace(TimeSpan.FromMinutes(10));
|
||||
if(lines.Count == 0)
|
||||
{
|
||||
App.Log.Warning("[Companion] The browser never launched the sign-in protocol handler in the last 10 minutes (no entries in " + QqqBrowserCallback.TracePath
|
||||
+ "). Either the identity provider did not redirect to " + QqqBrowserCallback.RedirectUri + " (check the provider's allowed redirect URIs and any error page it showed), or the browser blocked opening the QQQ Print Agent sign-in link.");
|
||||
return;
|
||||
}
|
||||
App.Log.Warning("[Companion] Protocol handler trace from " + QqqBrowserCallback.TracePath + ":" + Environment.NewLine + string.Join(Environment.NewLine, lines));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The browser exchange itself: open the callback inbox first (so a fast redirect is not lost), begin the login, open the
|
||||
/// provider's page in the default browser, wait for the forwarded callback, and complete the login against the attempt
|
||||
@@ -187,9 +389,22 @@ public sealed partial class MainWindow
|
||||
private async Task CompleteBrowserSignInAsync(Uri redirect, CancellationToken token)
|
||||
{
|
||||
using QqqBrowserCallback inbox = new();
|
||||
App.Log.Info("[Companion] Browser callback inbox open; starting the sign-in attempt.");
|
||||
QqqBrowserLogin attempt = await _signInClient!.BeginBrowserLoginAsync(redirect, token);
|
||||
App.Log.Info("[Companion] Opening the identity provider's sign-in page in the default browser (" + attempt.AuthorizationUri.Authority + ").");
|
||||
_ = Process.Start(new ProcessStartInfo(attempt.AuthorizationUri.AbsoluteUri) { UseShellExecute = true });
|
||||
Uri callbackUri = await inbox.ReceiveAsync(token);
|
||||
App.Log.Info("[Companion] Browser callback received; completing the sign-in.");
|
||||
await _signInClient.CompleteBrowserLoginAsync(attempt, callbackUri, token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Logs which callbacks the server does list before reporting that this application's callback is not among them.</summary>
|
||||
private InvalidOperationException Unlisted()
|
||||
{
|
||||
App.Log.Warning("[Companion] The server's native grant does not list this application's callback " + QqqBrowserCallback.RedirectUri + "; it lists ["
|
||||
+ string.Join(", ", _signInMetadata?.Values?.NativeSessionLogin?.RedirectUris ?? []) + "]. Add the callback to the QQQ native session grant.");
|
||||
return new InvalidOperationException("No approved native browser callback is configured.");
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,24 @@
|
||||
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;
|
||||
|
||||
namespace Qqq.PrintAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Log settings part of the main window: retention and daily size limits for the shared diagnostic log, and clearing it. The values are
|
||||
/// applied by the service, which owns the log files, so the service and companion always share one policy.
|
||||
/// Log settings part of the main window: retention, file size rollover, kept segments and debug detail for the shared diagnostic log, and
|
||||
/// clearing it. The values are applied by the service, which owns the log files, so the service and companion always share one policy.
|
||||
/// </summary>
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows the current retention and daily size limits (with how much of today's limit is used and whether it was reached) in a dialog and,
|
||||
/// if the operator saves valid numbers, sends them to the service. Rotation happens at the start of the next local day.
|
||||
/// Shows the current retention, file size limit, kept segments and debug switch (with the current file's size and how many times it rolled
|
||||
/// over today) in a dialog and, if the operator saves valid values, sends them to the service. Files rotate at the start of the next local
|
||||
/// day and whenever one reaches the size limit.
|
||||
/// </summary>
|
||||
private async void LogSettings_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -30,22 +33,28 @@ public sealed partial class MainWindow
|
||||
return;
|
||||
}
|
||||
NumberBox retention = new() { Header = "Retention (days)", Minimum = 1, Maximum = 365, Value = settings.RetentionDays, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline };
|
||||
NumberBox limit = new() { Header = "Daily size limit (MiB)", Minimum = 1, Maximum = 100, Value = settings.MaxDailyMegabytes, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline };
|
||||
NumberBox limit = new() { Header = "File size limit before rollover (MiB)", Minimum = 1, Maximum = 1024, Value = settings.MaxFileMegabytes, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline };
|
||||
NumberBox segments = new() { Header = "Rolled-over segments kept per day", Minimum = 1, Maximum = 100, Value = settings.SegmentsPerDay, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline };
|
||||
ToggleSwitch debug = new() { Header = "Debug logging", OnContent = "On: every request, response and frame is logged", OffContent = "Off: steps, warnings and errors only", IsOn = settings.DebugLogging };
|
||||
StackPanel content = new() { Spacing = 12 };
|
||||
content.Children.Add(new TextBlock { Text = "Rotation: daily, at the start of the next local calendar day. These limits apply to both service and companion logs.", TextWrapping = TextWrapping.Wrap });
|
||||
content.Children.Add(new TextBlock { Text = "Logs start a new file each local day and roll over to a numbered segment when a file reaches the size limit; logging never stops because of size. These settings apply to both service and companion logs.", TextWrapping = TextWrapping.Wrap });
|
||||
content.Children.Add(retention);
|
||||
content.Children.Add(limit);
|
||||
string usage = $"Current daily log: {settings.CurrentDayBytes / (1024d * 1024d):0.0} MiB.";
|
||||
content.Children.Add(new TextBlock { Text = usage + (settings.DailyLimitReached ? " The daily limit has been reached. Increase the limit, clear local logs, or wait until the next local day to resume diagnostic logging." : ""), TextWrapping = TextWrapping.Wrap });
|
||||
content.Children.Add(segments);
|
||||
content.Children.Add(debug);
|
||||
content.Children.Add(new TextBlock { Text = "Debug logging adds request routes, status codes, timings and realtime frames. Credentials, documents and enrollment codes are never logged at any level.", TextWrapping = TextWrapping.Wrap, Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] });
|
||||
string usage = $"Current log file: {settings.CurrentFileBytes / (1024d * 1024d):0.0} MiB; rolled over {settings.SegmentsToday} time(s) today.";
|
||||
content.Children.Add(new TextBlock { Text = usage, TextWrapping = TextWrapping.Wrap });
|
||||
var dialog = new ContentDialog { Title = "Log settings", Content = content, PrimaryButtonText = "Save", CloseButtonText = "Cancel", DefaultButton = ContentDialogButton.Primary, XamlRoot = Content.XamlRoot };
|
||||
if(await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary)
|
||||
{
|
||||
if(double.IsNaN(retention.Value) || double.IsNaN(limit.Value))
|
||||
if(double.IsNaN(retention.Value) || double.IsNaN(limit.Value) || double.IsNaN(segments.Value))
|
||||
{
|
||||
Feedback("Log settings not saved", "Enter valid retention and daily size values.", InfoBarSeverity.Warning);
|
||||
Feedback("Log settings not saved", "Enter valid retention, file size and segment values.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
await RunActionAsync(new PipeRequest("set-log-settings", LogSettings: new LogSettings((int)retention.Value, (int)limit.Value)));
|
||||
App.Log.Info($"[Companion] Log settings saved: retention {(int)retention.Value} days, file size {(int)limit.Value} MiB, {(int)segments.Value} segments per day, debug logging {(debug.IsOn ? "on" : "off")}.");
|
||||
await RunActionAsync(new PipeRequest("set-log-settings", LogSettings: new LogSettings((int)retention.Value, (int)limit.Value, (int)segments.Value, debug.IsOn)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -75,7 +75,7 @@
|
||||
<Button Content="Open activity log" Click="ActivityLog_Click" AutomationProperties.AutomationId="ActivityLogMenuItem"><Button.Style><StaticResource ResourceKey="AccentButtonStyle" /></Button.Style></Button>
|
||||
<TextBlock Text="Follow live updates, show errors only, copy entries or open the logs folder using the viewer’s toolbar." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8"><Button Content="Log settings" Click="LogSettings_Click" AutomationProperties.AutomationId="LogSettings" /><Button Content="Clear local logs" Click="ClearLogs_Click" AutomationProperties.AutomationId="ClearLogs" /></StackPanel>
|
||||
<TextBlock Text="Logs rotate daily with bounded size and retention. Clearing diagnostic logs does not remove job history." TextWrapping="Wrap" />
|
||||
<TextBlock Text="Logs rotate daily and roll over to numbered segments at the size limit, with bounded retention. Turn on debug logging in Log settings to record every request and realtime frame. Clearing diagnostic logs does not remove job history." TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ScrollViewer x:Name="SettingsPage" Visibility="Collapsed" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="24" MaxWidth="680" HorizontalAlignment="Left">
|
||||
@@ -88,7 +88,7 @@
|
||||
<HyperlinkButton Content="Get the installer" NavigateUri="https://github.com/QRun-IO/qqq-print-agent/releases" Padding="0" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="10"><TextBlock Text="Server connection" Style="{ThemeResource SubtitleTextBlockStyle}" /><TextBlock x:Name="StatusText" Text="Waiting for service status" TextWrapping="Wrap" AutomationProperties.AutomationId="ServiceStatus" /><TextBlock x:Name="ConnectionText" Text="Connect this computer to your print server." TextWrapping="Wrap" IsTextSelectionEnabled="True" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /><TextBlock x:Name="AuthenticationText" Text="Sign in to your account to enable printing." TextWrapping="Wrap" AutomationProperties.AutomationId="QqqAuthenticationStatus" /><StackPanel Orientation="Horizontal" Spacing="8"><Button x:Name="ConnectionButton" Content="Sign in / Connection settings" Click="Connection_Click" AutomationProperties.AutomationId="ConnectionSettings" /><Button Content="Sign out" Click="SignOut_Click" AutomationProperties.AutomationId="QqqSignOut" /></StackPanel></StackPanel>
|
||||
<StackPanel Spacing="10"><TextBlock Text="Diagnostic logs" Style="{ThemeResource SubtitleTextBlockStyle}" /><TextBlock Text="Service and companion logs rotate daily. Set retention and the daily size limit, inspect usage, or clear retained diagnostic logs." TextWrapping="Wrap" /><StackPanel Orientation="Horizontal" Spacing="8"><Button Content="Log settings" Click="LogSettings_Click" AutomationProperties.AutomationId="SettingsLogSettings" /><Button Content="Clear local logs" Click="ClearLogs_Click" AutomationProperties.AutomationId="SettingsClearLogs" /></StackPanel></StackPanel>
|
||||
<StackPanel Spacing="10"><TextBlock Text="Diagnostic logs" Style="{ThemeResource SubtitleTextBlockStyle}" /><TextBlock Text="Service and companion logs rotate daily and roll over at a size limit. Set retention, the file size limit, kept segments and debug logging, inspect usage, or clear retained diagnostic logs." TextWrapping="Wrap" /><StackPanel Orientation="Horizontal" Spacing="8"><Button Content="Log settings" Click="LogSettings_Click" AutomationProperties.AutomationId="SettingsLogSettings" /><Button Content="Clear local logs" Click="ClearLogs_Click" AutomationProperties.AutomationId="SettingsClearLogs" /></StackPanel></StackPanel>
|
||||
<StackPanel Spacing="12"><TextBlock Text="Appearance" Style="{ThemeResource SubtitleTextBlockStyle}" /><ComboBox x:Name="ThemeChoice" Header="App theme" MinWidth="220" SelectionChanged="Theme_SelectionChanged"><ComboBoxItem Content="Use Windows setting" Tag="System" /><ComboBoxItem Content="Light" Tag="Light" /><ComboBoxItem Content="Dark" Tag="Dark" AutomationProperties.AutomationId="ThemeDark" /></ComboBox><ToggleSwitch x:Name="SplashToggle" Header="Show splash screen at startup" Toggled="Splash_Toggled" AutomationProperties.AutomationId="SplashAtStartup" /><NumberBox x:Name="SplashDuration" Header="Splash duration (milliseconds)" SpinButtonPlacementMode="Inline" SmallChange="500" LargeChange="1000" ValueChanged="SplashDuration_ValueChanged" AutomationProperties.AutomationId="SplashDuration" MinWidth="220" HorizontalAlignment="Left" /></StackPanel>
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Updates" Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
@@ -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" /><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>
|
||||
@@ -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)
|
||||
{
|
||||
@@ -229,6 +229,7 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
private async Task ShowConnectionAsync()
|
||||
{
|
||||
ConnectionDialog.XamlRoot = Content.XamlRoot;
|
||||
await LoadServerTrustAsync();
|
||||
await DialogGuard.ShowAsync(ConnectionDialog);
|
||||
}
|
||||
|
||||
@@ -493,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);
|
||||
@@ -537,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)
|
||||
{
|
||||
@@ -689,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();
|
||||
|
||||
|
||||
|
||||
@@ -699,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);
|
||||
}
|
||||
@@ -718,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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class AgentActivityLogTests
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-print-log-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
IActivityLog log = AgentActivityLog.Create(directory);
|
||||
var log = AgentActivityLog.Create(directory);
|
||||
log.Info("[Companion] Authorization: Bearer synthetic-bearer deviceToken=synthetic-device enrollmentCode=\"synthetic-code\" https://operator:synthetic-password@example.invalid/");
|
||||
log.Error("[Service] Enrollment failed", new InvalidOperationException("password='synthetic-secret' contentBase64=synthetic-document"));
|
||||
string text = File.ReadAllText(log.CurrentLogFilePath);
|
||||
@@ -50,8 +50,8 @@ public sealed class AgentActivityLogTests
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-print-log-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
IActivityLog service = AgentActivityLog.Create(directory);
|
||||
IActivityLog companion = AgentActivityLog.Create(directory);
|
||||
var service = AgentActivityLog.Create(directory);
|
||||
var companion = AgentActivityLog.Create(directory);
|
||||
Assert.Equal(service.CurrentLogFilePath, companion.CurrentLogFilePath);
|
||||
await Task.WhenAll(
|
||||
Task.Run(() =>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using Qqq.PrintAgent.Windows;
|
||||
|
||||
namespace Qqq.PrintAgent.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The activity log must never go quiet because of size: a file that reaches its limit rolls over to a numbered segment of the
|
||||
/// same day and logging continues, only the newest segments of a day are kept, debug entries appear only while the saved
|
||||
/// setting asks for them, and the debug switch round-trips through the settings file both processes share.
|
||||
/// </summary>
|
||||
public sealed class AgentLogRotationTests
|
||||
{
|
||||
/// <summary>At a 1 MiB limit, writing past the limit renames the live file to segment 1 and the next entry lands in a fresh live file with a rollover notice; nothing is dropped.</summary>
|
||||
[Fact]
|
||||
public void ReachingTheFileLimit_RollsOverToANumberedSegmentAndKeepsLogging()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-rotate-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
AgentActivityLog.SaveSettings(directory, new LogSettings(30, 1, 10));
|
||||
var log = AgentActivityLog.Create(directory);
|
||||
string payload = new('x', 8192);
|
||||
for(int index = 0; index < 140; index++)
|
||||
{
|
||||
log.Info("entry " + index.ToString(CultureInfo.InvariantCulture) + " " + payload);
|
||||
}
|
||||
log.Info("after rollover");
|
||||
string today = DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
string segment = Path.Combine(log.LogDirectory, "qqq-print-agent-" + today + ".1.log");
|
||||
Assert.True(File.Exists(segment), "The full file should have been renamed to segment 1.");
|
||||
Assert.InRange(new FileInfo(segment).Length, 900_000, 1024 * 1024);
|
||||
string live = File.ReadAllText(log.CurrentLogFilePath);
|
||||
Assert.Contains("Rolled over", live, StringComparison.Ordinal);
|
||||
Assert.Contains("after rollover", live, StringComparison.Ordinal);
|
||||
LogSettings settings = AgentActivityLog.GetSettings(directory);
|
||||
Assert.Equal(1, settings.SegmentsToday);
|
||||
Assert.InRange(settings.CurrentFileBytes, 1, 1024 * 1024);
|
||||
int lines = File.ReadLines(segment).Count() + File.ReadLines(log.CurrentLogFilePath).Count();
|
||||
Assert.Equal(142, lines);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>With two segments kept per day, a third rollover deletes segment 1; segments 2 and 3 and the live file remain, and Clear removes them all.</summary>
|
||||
[Fact]
|
||||
public void SegmentsPerDay_BoundsHowManyRolledSegmentsSurviveAndClearRemovesThem()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-segments-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
AgentActivityLog.SaveSettings(directory, new LogSettings(30, 1, 2));
|
||||
var log = AgentActivityLog.Create(directory);
|
||||
string payload = new('y', 8192);
|
||||
for(int index = 0; index < 3 * 135; index++)
|
||||
{
|
||||
log.Info(payload);
|
||||
}
|
||||
string today = DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
Assert.False(File.Exists(Path.Combine(log.LogDirectory, "qqq-print-agent-" + today + ".1.log")));
|
||||
Assert.True(File.Exists(Path.Combine(log.LogDirectory, "qqq-print-agent-" + today + ".2.log")));
|
||||
Assert.True(File.Exists(Path.Combine(log.LogDirectory, "qqq-print-agent-" + today + ".3.log")));
|
||||
Assert.Equal(2, AgentActivityLog.GetSettings(directory).SegmentsToday);
|
||||
Assert.Equal(3, AgentActivityLog.ClearLogs(directory));
|
||||
Assert.Empty(Directory.EnumerateFiles(log.LogDirectory, "qqq-print-agent-*.log"));
|
||||
log.Info("fresh");
|
||||
Assert.Contains("fresh", File.ReadAllText(log.CurrentLogFilePath), StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Retention removes a whole old day including its rolled segments, and leaves a file whose name merely resembles a segment alone.</summary>
|
||||
[Fact]
|
||||
public void Retention_RemovesOldDaysWithTheirSegmentsOnly()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-segment-retention-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var log = AgentActivityLog.Create(directory);
|
||||
log.Info("current");
|
||||
string oldDate = DateTime.Today.AddDays(-10).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
string oldLive = Path.Combine(log.LogDirectory, "qqq-print-agent-" + oldDate + ".log");
|
||||
string oldSegment = Path.Combine(log.LogDirectory, "qqq-print-agent-" + oldDate + ".4.log");
|
||||
string lookalike = Path.Combine(log.LogDirectory, "qqq-print-agent-" + oldDate + ".x.log");
|
||||
foreach(string path in new[] { oldLive, oldSegment, lookalike })
|
||||
{
|
||||
File.WriteAllText(path, "evidence");
|
||||
}
|
||||
AgentActivityLog.SaveSettings(directory, new LogSettings(7, 100, 10));
|
||||
Assert.False(File.Exists(oldLive));
|
||||
Assert.False(File.Exists(oldSegment));
|
||||
Assert.Equal("evidence", File.ReadAllText(lookalike));
|
||||
Assert.True(File.Exists(log.CurrentLogFilePath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Debug entries are dropped while the switch is off and written, tagged, once it is on; the switch is saved with the other settings and read back by a second log instance.</summary>
|
||||
[Fact]
|
||||
public void DebugEntries_AreWrittenOnlyWhileTheSavedSwitchIsOn()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-debug-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var log = AgentActivityLog.Create(directory);
|
||||
Assert.False(log.IsDebugEnabled);
|
||||
log.Debug("hidden detail");
|
||||
log.Info("visible step");
|
||||
Assert.DoesNotContain("hidden detail", File.ReadAllText(log.CurrentLogFilePath), StringComparison.Ordinal);
|
||||
AgentActivityLog.SaveSettings(directory, new LogSettings(30, 100, 10, DebugLogging: true));
|
||||
Assert.True(AgentActivityLog.GetSettings(directory).DebugLogging);
|
||||
var second = AgentActivityLog.Create(directory);
|
||||
Assert.True(second.IsDebugEnabled);
|
||||
second.Debug("shown detail token=secret-value");
|
||||
string text = File.ReadAllText(second.CurrentLogFilePath);
|
||||
Assert.Contains("[DEBUG] shown detail token=[REDACTED]", text, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("secret-value", text, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A file size above 1024 MiB or a segment count outside 1 to 100 is refused and the saved policy is unchanged.</summary>
|
||||
[Theory]
|
||||
[InlineData(30, 1025, 10)]
|
||||
[InlineData(30, 100, 0)]
|
||||
[InlineData(30, 100, 101)]
|
||||
public void InvalidRolloverSettings_AreRefused(int days, int megabytes, int segments)
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-rollover-validation-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
AgentActivityLog.SaveSettings(directory, new LogSettings());
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => AgentActivityLog.SaveSettings(directory, new LogSettings(days, megabytes, segments)));
|
||||
Assert.Equal(new LogSettings(), AgentActivityLog.GetSettings(directory));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using Qqq.PrintAgent.Windows;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
@@ -13,14 +12,14 @@ namespace Qqq.PrintAgent.Cli.Tests;
|
||||
/// <summary>The activity log must be bounded and safe to manage: a normal day of heavy printing fits the default budget, retention and clearing touch only the agent's own daily logs, concurrent writers share one size cap, and invalid settings are refused.</summary>
|
||||
public sealed class AgentLogSettingsTests(ITestOutputHelper output)
|
||||
{
|
||||
/// <summary>Ten thousand jobs with three status lines each (30,000 entries) stay under the default 20 MiB daily limit without tripping it, so a very busy day is not silently cut off from the log.</summary>
|
||||
/// <summary>Ten thousand jobs with three status lines each (30,000 entries) fit in one file under the default 100 MiB limit, so a very busy day does not even roll over.</summary>
|
||||
[Fact]
|
||||
public void TenThousandJobs_ThreeStatusEntriesEachFitDefaultDailyBudget()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-day-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
IActivityLog log = AgentActivityLog.Create(directory);
|
||||
var log = AgentActivityLog.Create(directory);
|
||||
long started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
for(int index = 0; index < 10000; index++)
|
||||
{
|
||||
@@ -30,10 +29,10 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
|
||||
log.Info("[Service] job-" + id + ": SPOOLED");
|
||||
}
|
||||
LogSettings settings = AgentActivityLog.GetSettings(directory);
|
||||
Assert.False(settings.DailyLimitReached);
|
||||
Assert.Equal(0, settings.SegmentsToday);
|
||||
Assert.Equal(30000, File.ReadLines(log.CurrentLogFilePath).Count());
|
||||
Assert.InRange(settings.CurrentDayBytes, 1, 20 * 1024 * 1024);
|
||||
output.WriteLine($"10k jobs / 30k diagnostic entries: {settings.CurrentDayBytes} bytes, {System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds:F0} ms.");
|
||||
Assert.InRange(settings.CurrentFileBytes, 1, 100 * 1024 * 1024);
|
||||
output.WriteLine($"10k jobs / 30k diagnostic entries: {settings.CurrentFileBytes} bytes, {System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds:F0} ms.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -54,7 +53,7 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-settings-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
IActivityLog log = AgentActivityLog.Create(directory);
|
||||
var log = AgentActivityLog.Create(directory);
|
||||
log.Info("Current diagnostic");
|
||||
string oldDate = DateTime.Today.AddDays(-10).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
string oldLog = Path.Combine(log.LogDirectory, "qqq-print-agent-" + oldDate + ".log");
|
||||
@@ -66,14 +65,14 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
|
||||
File.WriteAllText(path, "preserved evidence");
|
||||
}
|
||||
AgentActivityLog.SaveSettings(directory, new LogSettings(7, 1));
|
||||
Assert.Equal(new LogSettings(7, 1), AgentActivityLog.GetSettings(directory) with { CurrentDayBytes = 0 });
|
||||
Assert.Equal(new LogSettings(7, 1), AgentActivityLog.GetSettings(directory) with { CurrentFileBytes = 0 });
|
||||
Assert.False(File.Exists(oldLog));
|
||||
Assert.Equal(1, AgentActivityLog.ClearLogs(directory));
|
||||
foreach(string path in new[] { unrelated, misleading, journal })
|
||||
{
|
||||
Assert.Equal("preserved evidence", File.ReadAllText(path));
|
||||
}
|
||||
Assert.Equal(new LogSettings(7, 1), AgentActivityLog.GetSettings(directory) with { CurrentDayBytes = 0 });
|
||||
Assert.Equal(new LogSettings(7, 1), AgentActivityLog.GetSettings(directory) with { CurrentFileBytes = 0 });
|
||||
log.Info("Logging resumes after explicit clear.");
|
||||
Assert.Contains("Logging resumes", File.ReadAllText(log.CurrentLogFilePath), StringComparison.Ordinal);
|
||||
}
|
||||
@@ -85,26 +84,25 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
|
||||
|
||||
|
||||
|
||||
/// <summary>Two writers sharing one directory together stop at the 1 MiB daily cap (with a limit-reached notice in the file) and further writes are dropped; clearing the logs lifts the cap and both writers can log again.</summary>
|
||||
/// <summary>Two writers sharing one directory and a 1 MiB limit roll the file over instead of stopping: every entry from both writers is kept across the live file and its segments, and nothing is ever suppressed.</summary>
|
||||
[Fact]
|
||||
public async Task SharedWriters_RespectDailySizeLimitAndContinueAfterClear()
|
||||
public async Task SharedWriters_RollOverAtTheSizeLimitAndLoseNothing()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-cap-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
AgentActivityLog.SaveSettings(directory, new LogSettings(30, 1));
|
||||
IActivityLog first = AgentActivityLog.Create(directory);
|
||||
IActivityLog second = AgentActivityLog.Create(directory);
|
||||
AgentActivityLog.SaveSettings(directory, new LogSettings(30, 1, 10));
|
||||
var first = AgentActivityLog.Create(directory);
|
||||
var second = AgentActivityLog.Create(directory);
|
||||
string payload = new('x', 8192);
|
||||
await Task.WhenAll(Task.Run(() => WriteMany(first, payload)), Task.Run(() => WriteMany(second, payload)));
|
||||
long length = new FileInfo(first.CurrentLogFilePath).Length;
|
||||
Assert.InRange(length, 900000, 1024 * 1024);
|
||||
Assert.True(AgentActivityLog.GetSettings(directory).DailyLimitReached);
|
||||
Assert.Contains("Daily diagnostic limit reached", File.ReadAllText(first.CurrentLogFilePath), StringComparison.Ordinal);
|
||||
first.Info(payload);
|
||||
Assert.Equal(length, new FileInfo(first.CurrentLogFilePath).Length);
|
||||
Assert.Equal(1, AgentActivityLog.ClearLogs(directory));
|
||||
Assert.False(AgentActivityLog.GetSettings(directory).DailyLimitReached);
|
||||
LogSettings settings = AgentActivityLog.GetSettings(directory);
|
||||
Assert.InRange(settings.SegmentsToday, 1, 3);
|
||||
Assert.InRange(new FileInfo(first.CurrentLogFilePath).Length, 1, 1024 * 1024);
|
||||
int entries = Directory.EnumerateFiles(first.LogDirectory, "qqq-print-agent-*.log").Sum(path => File.ReadLines(path).Count(line => line.Contains("xxxx", StringComparison.Ordinal)));
|
||||
Assert.Equal(200, entries);
|
||||
Assert.InRange(AgentActivityLog.ClearLogs(directory), 2, 4);
|
||||
Assert.Equal(0, AgentActivityLog.GetSettings(directory).SegmentsToday);
|
||||
second.Error("After clear");
|
||||
Assert.Contains("[ERROR] After clear", File.ReadAllText(second.CurrentLogFilePath), StringComparison.Ordinal);
|
||||
}
|
||||
@@ -116,12 +114,12 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
|
||||
|
||||
|
||||
|
||||
/// <summary>Retention outside 1 to 365 days or a size cap outside 1 to 100 MiB is rejected, and the settings already saved remain in force.</summary>
|
||||
/// <summary>Retention outside 1 to 365 days or a file size outside 1 to 1024 MiB is rejected, and the settings already saved remain in force.</summary>
|
||||
[Theory]
|
||||
[InlineData(0, 20)]
|
||||
[InlineData(366, 20)]
|
||||
[InlineData(30, 0)]
|
||||
[InlineData(30, 101)]
|
||||
[InlineData(30, 1025)]
|
||||
public void InvalidSettings_DoNotChangePersistedPolicy(int days, int megabytes)
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-validation-" + Guid.NewGuid().ToString("N"));
|
||||
@@ -139,8 +137,8 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
|
||||
|
||||
|
||||
|
||||
/// <summary>Writes the same 8 KiB line 100 times, enough from two writers to pass the daily cap.</summary>
|
||||
private static void WriteMany(IActivityLog log, string payload)
|
||||
/// <summary>Writes the same 8 KiB line 100 times, enough from two writers to pass a 1 MiB limit.</summary>
|
||||
private static void WriteMany(AgentActivityLog log, string payload)
|
||||
{
|
||||
for(int index = 0; index < 100; index++)
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user