Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93cd228188
|
||
|
|
a36f9105dd
|
||
|
|
458733bca8
|
||
|
|
46999ea21c
|
||
|
|
c3b7c37f47
|
||
|
|
af9e2f99e0
|
||
|
|
84a1002143
|
||
|
|
8689dd593c | ||
|
|
a2f90fb1fb | ||
|
|
bed2959293 | ||
|
|
e0bc919c3c | ||
|
|
d1517048f3 | ||
|
|
adf885beaf
|
||
|
|
c4b14963d3
|
||
|
|
a586ea7bf8
|
||
|
|
9906002d65
|
||
|
|
f7a9f79ab5
|
||
|
|
da43fa2918
|
||
|
|
b5831302d7
|
||
|
|
22b942b5e8
|
||
|
|
40872ab002
|
||
|
|
3b9d75d18f
|
||
|
|
6404922e1e
|
No files matched your search
@@ -0,0 +1,68 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
windows:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
runner: qqq-windows-amd64
|
||||
commands: |
|
||||
$temp = $env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()
|
||||
# Protocol fixtures pinned to the revision the verification expects.
|
||||
$protocol = Join-Path $temp "qqq-print-protocol-$env:GITHUB_RUN_ID"
|
||||
git clone --quiet https://git.kof22.com/QRun-IO/qqq-print-protocol.git $protocol
|
||||
git -C $protocol checkout --quiet ac835f8b9396b641b525669249fdb4a1e2a50c99
|
||||
pwsh -NoProfile -File ./build/Verify.ps1 -ProtocolFixtures (Join-Path $protocol fixtures)
|
||||
pwsh -NoProfile -File ./build/Test-WindowsSdkInventory.ps1 -VerifyPackages
|
||||
pwsh -NoProfile -File ./build/Test-BrandedInstaller.ps1
|
||||
pwsh -NoProfile -File ./build/Test-PackagingInputs.ps1
|
||||
# Unsigned MSIs: the base product plus one branded installer per edition. A vX.Y.Z[-rc.N] tag sets the version.
|
||||
$release = if ($env:GITHUB_REF_TYPE -eq 'tag') { $env:GITHUB_REF_NAME.TrimStart('v') } else { '1.2.1' }
|
||||
$product = $release -replace '-.*$', ''
|
||||
pwsh -NoProfile -File ./build/Build-Installer.ps1 -ProductVersion $product -ReleaseVersion $release
|
||||
$installers = @('QqqPrintAgent')
|
||||
foreach ($folder in Get-ChildItem -LiteralPath editions -Directory | Sort-Object Name) {
|
||||
pwsh -NoProfile -File ./build/Build-Installer.ps1 -ProductVersion $product -ReleaseVersion $release -Edition $folder.Name
|
||||
$installers += (Get-Content -LiteralPath (Join-Path $folder.FullName 'edition.json') -Raw | ConvertFrom-Json).assetPrefix
|
||||
}
|
||||
New-Item -ItemType Directory -Path artifacts/release -Force | Out-Null
|
||||
foreach ($prefix in $installers) {
|
||||
Copy-Item -LiteralPath "artifacts/installer/$prefix-$product-x64.msi" -Destination "artifacts/release/$prefix-v$release-x64.msi"
|
||||
}
|
||||
Copy-Item -LiteralPath build/Install-Branded.ps1, docs/branding.example.json -Destination artifacts/release/
|
||||
# PowerShell scripts: analyzer and Pester rejection-path tests with pinned modules saved to the job's temp directory.
|
||||
$modules = Join-Path $temp "psmodules-$env:GITHUB_RUN_ID"
|
||||
New-Item -ItemType Directory -Force -Path $modules | Out-Null
|
||||
Save-PSResource -Name PSScriptAnalyzer -Version 1.25.0 -Repository PSGallery -TrustRepository -Path $modules
|
||||
Save-PSResource -Name Pester -Version 5.7.1 -Repository PSGallery -TrustRepository -Path $modules
|
||||
$env:PSModulePath = "$modules$([IO.Path]::PathSeparator)$env:PSModulePath"
|
||||
pwsh -NoProfile -File ./build/Analyze-Scripts.ps1
|
||||
Import-Module Pester -RequiredVersion 5.7.1
|
||||
# The Pester suites assert on child processes' exit codes themselves.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$pester = New-PesterConfiguration
|
||||
$pester.Run.Path = @('build/Assert-Coverage.Tests.ps1', 'build/Analyze-Scripts.Tests.ps1', 'build/Install-Branded.Tests.ps1', 'build/Test-WindowsSdkInventory.Tests.ps1')
|
||||
$pester.Run.PassThru = $true
|
||||
if ((Invoke-Pester -Configuration $pester).Result -ne 'Passed') { throw 'Pester tests failed' }
|
||||
artifacts: |
|
||||
artifacts/release/*.msi
|
||||
artifacts/release/Install-Branded.ps1
|
||||
artifacts/release/branding.example.json
|
||||
scripts:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
|
||||
with:
|
||||
node-version: '24'
|
||||
uv: true
|
||||
install: npm ci --ignore-scripts --no-audit --no-fund
|
||||
commands: |
|
||||
uv sync --locked
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
python3 .github/scripts/test-source-headers.py
|
||||
python3 .github/scripts/check-source-headers.py
|
||||
node --test tools/virtual-agent/test/virtual-agent.test.mjs
|
||||
@@ -12,10 +12,10 @@ on:
|
||||
inputs:
|
||||
product-version:
|
||||
type: string
|
||||
default: '1.2.0'
|
||||
default: '1.2.1'
|
||||
release-version:
|
||||
type: string
|
||||
default: '1.2.0'
|
||||
default: '1.2.1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -59,8 +59,8 @@ jobs:
|
||||
./build/Test-PackagingInputs.ps1
|
||||
- name: Build unsigned prerelease MSI
|
||||
env:
|
||||
PRODUCT_VERSION: ${{ inputs.product-version || '1.2.0' }}
|
||||
RELEASE_VERSION: ${{ inputs.release-version || '1.2.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 }
|
||||
|
||||
@@ -166,7 +166,9 @@ jobs:
|
||||
Write-Output "Edition: '$env:EDITION' -> $prefix"
|
||||
- name: Publish and check the payload
|
||||
run: |
|
||||
$editionArgs = if ($env:EDITION_ARGS) { $env:EDITION_ARGS.Split(' ') } else { @() }
|
||||
# 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"
|
||||
@@ -212,7 +214,9 @@ jobs:
|
||||
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: |
|
||||
$editionArgs = if ($env:EDITION_ARGS) { $env:EDITION_ARGS.Split(' ') } else { @() }
|
||||
# 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 $env:MSI_PATH -Stage "$env:RUNNER_TEMP/sign-msi"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
@@ -28,6 +36,8 @@ reset with the connection. The activity log viewer has a Clear logs button, the
|
||||
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
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
|
||||
CLI's version output show as a short hash. -->
|
||||
<PropertyGroup>
|
||||
<VersionPrefix>1.2.0</VersionPrefix>
|
||||
<VersionPrefix>1.2.1</VersionPrefix>
|
||||
<VersionSuffix></VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
Print from **QQQ 4.1+** to local Windows printers without opening inbound network ports. The Windows service opens an authenticated two-way WebSocket to QQQ for commands, status and job notifications. Documents download separately over HTTPS, so a large transfer does not block the control connection. There is no job/control polling fallback.
|
||||
|
||||
**Current stable release: [1.0.1](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1).** The service, companion, CLI and MSI are signed with Azure Artifact Signing. The matching server release is [qbit-printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), using QQQ **4.1.0-RC.21** for native session login, with [protocol 1.0.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.0.0). Product versions are separate from native printing API **2.3**, wire payload **v1** and WebSocket subprotocol `qqq-printing.v1`.
|
||||
**Current stable release: [1.2.1](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** (generic and GG Forge edition installers). The service, companion, CLI and MSI are signed with Azure Artifact Signing. The matching server release is [qbit-printing 1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0), using QQQ **4.1.0-RC.21** for native session login, with [protocol 1.1.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0); 1.2.1 also works unchanged against qbit-printing 1.0.0. Product versions are separate from native printing API **2.4** (2.3 compatible), wire payload **v1** and WebSocket subprotocol `qqq-printing.v1`.
|
||||
|
||||
The setup/status application uses the same [KofTwentyTwo AppKit](https://git.kof22.com/KofTwentyTwo/AppKit) foundation as GCLO and MarkdownViewr.Windows. QQQ's mascot Query appears in About and branded warehouse test documents.
|
||||
|
||||
## Install and connect
|
||||
|
||||
1. Download `QqqPrintAgent-v1.0.1-x64.msi` and `SHA256SUMS` from the [stable release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1). Check the checksum and Authenticode signature as described in [Installation](docs/INSTALLATION.md).
|
||||
1. Download `QqqPrintAgent-v1.2.1-x64.msi` (or a client edition such as `GgForgePrintAgent-v1.2.1-x64.msi`; one edition per PC) and `SHA256SUMS` from the [stable release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1). Check the checksum and Authenticode signature as described in [Installation](docs/INSTALLATION.md).
|
||||
2. Install with administrator rights. The MSI installs the automatic Windows service and adds **QQQ Print Agent** under **Print Connectors** in Start.
|
||||
3. Open the app and accept its administrator prompt. Choose **Settings → Server connection → Sign in / Connection settings**, enter the QQQ HTTPS server address and select **Discover sign-in options**. Sign in using the advertised username/password or browser flow.
|
||||
4. For a new device, create an enrollment code in QQQ under **Printing → Print devices → Actions → Enroll a print device**, then enter it in the client. Existing pairings are reused or explicitly associated with the signed-in account; routine upgrades do not need a replacement pairing.
|
||||
@@ -55,7 +55,7 @@ The client reports the Windows queue's machine-default media as a revision-bound
|
||||
|
||||
## Requirements and limits
|
||||
|
||||
| Area | Stable 1.0.1 behavior |
|
||||
| Area | Stable 1.2.1 behavior |
|
||||
| --- | --- |
|
||||
| Server | QQQ 4.1+; recommended cohort QQQ 4.1.0-RC.21 and qbit-printing 1.0.0, native printing API 2.3 |
|
||||
| Windows | x64; Windows 11 used for hardware acceptance; API target Windows 10 build 19041+ |
|
||||
@@ -87,7 +87,7 @@ git clone https://git.kof22.com/QRun-IO/qqq-print-protocol.git
|
||||
git -C qqq-print-protocol checkout 42c0d249f81395f00408ec0f92638dcbbf5deb03
|
||||
Set-Location qqq-print-agent
|
||||
./build/Verify.ps1
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.2.1 -ReleaseVersion 1.2.1
|
||||
```
|
||||
|
||||
Run verification only on a permitted Windows test host; host-specific restrictions remain in force. Local packaging is unsigned unless explicitly signed; stable release signing runs through the protected workflow. Neither command installs the service or sends a physical print.
|
||||
@@ -96,7 +96,7 @@ Run verification only on a permitted Windows test host; host-specific restrictio
|
||||
|
||||
## Release evidence
|
||||
|
||||
The signed 1.0.1 release passed its protected build, test, security, CodeQL, script and signing workflow. Published checksums, signatures and provenance were verified. The local 1.0.1 MSI upgrade completed successfully on 2026-11-03 with matching app/service/CLI versions and a running service. Dated hardware and earlier upgrade evidence remains in [Acceptance](docs/ACCEPTANCE.md); current release status is in [Release readiness](docs/RELEASE-READINESS.md). Earlier test results retain their original version and scope.
|
||||
The signed 1.2.1 release passed its protected build, test, security, CodeQL, script and per-edition signing workflow (run 42223525626); checksums, Authenticode signatures and provenance attestations were verified on the published generic and GG Forge installers, and the GG Forge installer was upgraded in place on the development PC with pairing, session and settings preserved. The earlier signed 1.0.1 release passed its protected build, test, security, CodeQL, script and signing workflow. Published checksums, signatures and provenance were verified. The local 1.0.1 MSI upgrade completed successfully on 2026-11-03 with matching app/service/CLI versions and a running service. Dated hardware and earlier upgrade evidence remains in [Acceptance](docs/ACCEPTANCE.md); current release status is in [Release readiness](docs/RELEASE-READINESS.md). Earlier test results retain their original version and scope.
|
||||
|
||||
## License and acknowledgments
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ bundle with SHA256SUMS. It takes no certificate, timestamp server or payload inp
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
||||
[string] $ProductVersion = '1.2.0',
|
||||
[string] $ProductVersion = '1.2.1',
|
||||
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
|
||||
[string] $ReleaseVersion = '1.2.0'
|
||||
[string] $ReleaseVersion = '1.2.1'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
@@ -188,7 +188,30 @@ 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\" @brandInstallerArgs
|
||||
# WiX validates the MSI (ICE checks) through the Windows Installer service. A runner that logs on as a batch account cannot
|
||||
# open that service, so WiX fails every ICE action with WIX0217 ("The Windows Installer Service could not be accessed").
|
||||
# Validate whenever the service is reachable. Skip validation only when it is not, with a loud warning, and never for a
|
||||
# signed release build: that must be built on a host where validation works.
|
||||
$installerValidationArguments = @()
|
||||
try
|
||||
{
|
||||
$installerService = Get-Service -Name msiserver -ErrorAction Stop
|
||||
if($installerService.Status -ne 'Running')
|
||||
{
|
||||
$installerService.Start()
|
||||
$installerService.WaitForStatus('Running', [TimeSpan]::FromSeconds(30))
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if($CertificateThumbprint)
|
||||
{
|
||||
throw "The Windows Installer service is not reachable, so the MSI cannot be validated; refusing to build a signed release without validation. $($_.Exception.Message)"
|
||||
}
|
||||
Write-Warning "The Windows Installer service is not reachable from this session; building the MSI WITHOUT ICE validation (not a signed release build). $($_.Exception.Message)"
|
||||
$installerValidationArguments = @('-p:SuppressValidation=true')
|
||||
}
|
||||
& dotnet build $installerProject --configuration Release -t:Rebuild '-p:RestoreLockedMode=true' "-p:ProductVersion=$ProductVersion" "-p:PayloadRoot=$payloadRoot" "-p:OutputPath=$buildDirectory\" @brandInstallerArgs @installerValidationArguments
|
||||
if($LASTEXITCODE -ne 0) { throw 'MSI build failed.' }
|
||||
$builtMsi = Join-Path $buildDirectory "$msiBaseName-$ProductVersion-x64.msi"
|
||||
if(-not (Test-Path -LiteralPath $builtMsi)) { throw 'MSI output was not produced.' }
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $ProductVersion = '1.2.0',
|
||||
[string] $ReleaseVersion = '1.2.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,
|
||||
|
||||
@@ -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.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Gitea is primary for source branches, reviews and normal pull requests. GitHub is the automatic source shadow. The existing authorized [GitHub release workflow](../.github/workflows/release.yml), its CI/security dependencies and the GitHub release assets remain the signing and installed-updater bridge. Do not retire those Windows workflows or remove the feed as part of ordinary source migration.
|
||||
|
||||
Current stable release: **[1.0.1 on Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1)** and the matching **[GitHub assets](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1)**. The protected [release run](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41532214447) completed signing and publication. The earlier immutable `v1.0.0` tag produced no signed installer; it must not be moved or reused.
|
||||
Current stable release: **[1.2.1 on Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** and the matching **[GitHub assets](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** (generic and GG Forge installers). The protected [release run](https://github.com/QRun-IO/qqq-print-agent/actions/runs/42223525626) completed signing and publication; checksums, Authenticode signatures and provenance attestations were verified on the downloaded assets (generic `09920872…`, GG Forge `5ce48909…`). The earlier immutable `v1.0.0` and `v1.2.0` tags produced no signed installer (`v1.2.0`: the edition sign job's array splat, fixed in 1.2.1); they must not be moved or reused. Previous stable: 1.1.1.
|
||||
|
||||
## Stable releases
|
||||
|
||||
|
||||
+10
-1
@@ -1,4 +1,13 @@
|
||||
# Session state — 2026-11-05 (evening)
|
||||
# Session state — 2026-11-06
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+12
-2
@@ -1,6 +1,16 @@
|
||||
# TODO
|
||||
|
||||
## Active: required-nonce native sign-in fix — 2026-11-05
|
||||
## 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`).
|
||||
|
||||
@@ -9,7 +19,7 @@ The stable printing release below remains accepted historical evidence. The shar
|
||||
- [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.
|
||||
- [ ] Final published build.
|
||||
- [x] Final published build: 1.2.1 (see above).
|
||||
|
||||
## Closed product release — 2026-11-04
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class AgentControlConcurrencyTests
|
||||
using ControlFixture fixture = new();
|
||||
fixture.OfferJob = true;
|
||||
fixture.Printer.BlockPrint = true;
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(60)); // hang guard only: the test normally finishes in well under a second, but a loaded CI host can take seconds
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
Task running = runtime.RunAsync(stop.Token);
|
||||
try
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class AgentNewPairingTests
|
||||
ChangeOldConfiguration(fixture, unreadable);
|
||||
byte[] retained = File.ReadAllBytes(path);
|
||||
fixture.NextSession = UnboundSession();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using(AgentRuntime runtime = fixture.Runtime())
|
||||
{
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.NextSession.ExportCredential(), StartNewPairing: true), timeout.Token);
|
||||
@@ -77,7 +77,7 @@ public sealed class AgentNewPairingTests
|
||||
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
|
||||
byte[] retained = File.ReadAllBytes(path);
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
_ = await runtime.PollControlsOnceAsync(timeout.Token);
|
||||
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
Assert.Equal(0, fixture.Created);
|
||||
@@ -119,7 +119,7 @@ public sealed class AgentNewPairingTests
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
byte[] originalAuth = File.ReadAllBytes(authPath);
|
||||
fixture.NextSession = UnboundSession();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using(AgentRuntime runtime = fixture.Runtime())
|
||||
{
|
||||
await Assert.ThrowsAsync<QqqSessionException>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.NextSession.ExportCredential()), timeout.Token));
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class AgentPipeFramingTests
|
||||
public async Task Write_CancelsWhenConnectedClientDoesNotRead()
|
||||
{
|
||||
string name = "qqq-framing-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(60));
|
||||
await using NamedPipeServerStream server = new(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, 4096, 4096);
|
||||
await using NamedPipeClientStream client = new(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
Task connect = client.ConnectAsync(deadline.Token);
|
||||
@@ -60,7 +60,7 @@ public sealed class AgentPipeFramingTests
|
||||
public async Task Read_CancelsWhenConnectedClientNeverTerminatesCommand()
|
||||
{
|
||||
string name = "qqq-framing-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(60));
|
||||
await using NamedPipeServerStream server = new(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
await using NamedPipeClientStream client = new(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
Task connect = client.ConnectAsync(deadline.Token);
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class AgentPipeSecurityTests
|
||||
security.AddAccessRule(new PipeAccessRule(identity.User!, PipeAccessRights.ReadWrite, AccessControlType.Allow));
|
||||
await using NamedPipeServerStream server = NamedPipeServerStreamAcl.Create(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous | PipeOptions.FirstPipeInstance, 4096, 4096, security);
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
for(int connection = 0; connection < 2; connection++)
|
||||
{
|
||||
Task accepted = server.WaitForConnectionAsync(timeout.Token);
|
||||
@@ -58,7 +58,7 @@ public sealed class AgentPipeSecurityTests
|
||||
{
|
||||
string name = "QqqPrintAgent-untrusted-test-" + Guid.NewGuid().ToString("N");
|
||||
await using NamedPipeServerStream fake = new(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.FirstPipeInstance);
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
Task connected = fake.WaitForConnectionAsync(timeout.Token);
|
||||
Task<string> request = AgentPipeClient.SendAsync(new PipeRequest(operation, "https://print.example.com", "synthetic-enrollment-never-send"), name);
|
||||
await connected;
|
||||
|
||||
@@ -39,16 +39,16 @@ public sealed class AgentRealtimeBandwidthTests
|
||||
{
|
||||
// Both real runtime lanes must reach their independent body streams before
|
||||
// timing starts, so serialized admission cannot imitate an aggregate cap.
|
||||
await Task.WhenAll(first.Entered.Task, second.Entered.Task).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await Task.WhenAll(first.Entered.Task, second.Entered.Task).WaitAsync(TimeSpan.FromSeconds(60));
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
release.TrySetResult();
|
||||
await fixture.WaitAsync(() => fixture.Printer.Prints == 2).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await fixture.WaitAsync(() => fixture.Printer.Prints == 2).WaitAsync(TimeSpan.FromSeconds(60));
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
|
||||
|
||||
// Together these bodies need four seconds at the shared rate; independent
|
||||
// per-file throttles would permit both to finish after about two seconds.
|
||||
Assert.True(elapsed >= TimeSpan.FromSeconds(3.5), "Concurrent documents exceeded the configured aggregate download rate.");
|
||||
await fixture.WaitAsync(() => fixture.State("first-budget") is "SPOOLED" && fixture.State("second-budget") is "SPOOLED").WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await fixture.WaitAsync(() => fixture.State("first-budget") is "SPOOLED" && fixture.State("second-budget") is "SPOOLED").WaitAsync(TimeSpan.FromSeconds(60));
|
||||
Assert.Equal(0, fixture.ForbiddenPolls);
|
||||
}
|
||||
finally { release.TrySetResult(); }
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class AgentRealtimeGenerationTests
|
||||
{
|
||||
using var fixture = new SessionFixture();
|
||||
fixture.SaveSession();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var replacementEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class AgentRealtimeOrderingTests
|
||||
using var slow = new GatedDocument(bytes);
|
||||
fixture.AddJob("slow", () => slow, length: bytes.Length);
|
||||
await connection.SendAsync("JOB_AVAILABLE", new { });
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
var observed = new TaskCompletionSource<(bool Connected, string? State, int Prints)>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
fixture.BeforeAcknowledgement = async message =>
|
||||
{
|
||||
@@ -48,7 +48,7 @@ public sealed class AgentRealtimeOrderingTests
|
||||
try
|
||||
{
|
||||
await connection.SendAsync("DESIRED_STATE", new Qqq.PrintAgent.Core.Printing.DeviceDesiredState(2, false, "CONNECTED"));
|
||||
(bool connected, string? state, int prints) = await observed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
(bool connected, string? state, int prints) = await observed.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
Assert.False(connected);
|
||||
Assert.Equal("RECEIVED", state);
|
||||
Assert.Equal(0, prints);
|
||||
@@ -98,7 +98,7 @@ public sealed class AgentRealtimeOrderingTests
|
||||
using var slow = new GatedDocument(bytes);
|
||||
fixture.AddJob("slow", () => slow, length: bytes.Length);
|
||||
await connection.SendAsync("JOB_AVAILABLE", new { });
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await connection.SendAsync("SETTINGS", fixture.Settings with { Revision = 2, NativeSubmissionTimeoutMillis = 2345, MaxPdfBytes = rejectSize ? 1 : fixture.Settings.MaxPdfBytes });
|
||||
await connection.SendAsync("DESIRED_STATE", new Qqq.PrintAgent.Core.Printing.DeviceDesiredState(2, true, "CONNECTED"));
|
||||
await fixture.WaitAsync(() => fixture.Runtime.GetSnapshot().Control!.DesiredState.DispatchPaused);
|
||||
@@ -123,7 +123,7 @@ public sealed class AgentRealtimeOrderingTests
|
||||
fixture.AddJob("slow", () => slow, length: bytes.Length);
|
||||
fixture.AddJob("fast", printerId: "other");
|
||||
await connection.SendAsync("JOB_AVAILABLE", new { });
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
try
|
||||
{
|
||||
Assert.Empty(fixture.Printer.Order);
|
||||
@@ -156,8 +156,8 @@ public sealed class AgentRealtimeOrderingTests
|
||||
fixture.AddJob("slow", () => slow, length: slowBytes.Length);
|
||||
fixture.AddJob("fast", () => { fastStarted.TrySetResult(); return new MemoryStream(fastBytes); }, samePrinter ? "printer" : "other", fastBytes.Length);
|
||||
await connection.SendAsync("JOB_AVAILABLE", new { });
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await fastStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await fastStarted.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
try
|
||||
{
|
||||
if(samePrinter)
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class AgentRealtimeRecoveryTests
|
||||
byte[] unreadable = [1, 3, 5];
|
||||
File.WriteAllBytes(path, unreadable);
|
||||
string name = "qqq-recovery-" + Guid.NewGuid().ToString("N");
|
||||
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var runtime = new AgentRuntime(fixture.DirectoryPath, _ => { }, fixture.Printer, pipeName: name,
|
||||
pipeFactory: () => new NamedPipeServerStream(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly));
|
||||
Task running = runtime.RunAsync(stop.Token);
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class AgentRealtimeTests
|
||||
using var stalled = new StalledDocument();
|
||||
fixture.AddJob("large", () => stalled);
|
||||
await connection.SendAsync("JOB_AVAILABLE", new { });
|
||||
await stalled.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await stalled.Entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await connection.SendAsync("DESIRED_STATE", new DeviceDesiredState(2, true, "CONNECTED"));
|
||||
await fixture.WaitAsync(() => fixture.Runtime.GetSnapshot().Control!.DesiredState.DispatchPaused);
|
||||
Assert.Equal(0, fixture.Printer.Prints);
|
||||
@@ -83,7 +83,7 @@ public sealed class AgentRealtimeTests
|
||||
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync(loseSpoolAck: true);
|
||||
fixture.AddJob("first");
|
||||
await first.SendAsync("JOB_AVAILABLE", new { });
|
||||
await first.LostSpoolAck.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await first.LostSpoolAck.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
fixture.AddJob("buffered");
|
||||
_ = await fixture.ConnectAsync();
|
||||
await fixture.WaitAsync(() => string.Equals(fixture.State("buffered"), "SPOOLED", StringComparison.Ordinal));
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class AgentRealtimeTransitionTests
|
||||
await using var fixture = new RealtimeRuntimeFixture(settings: new RealtimeSettings(Revision: 5));
|
||||
_ = await fixture.ConnectAsync();
|
||||
fixture.Settings = fixture.Settings with { Revision = 0 };
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
_ = await fixture.Runtime.EnrollWithGatesAsync(new PipeRequest("enroll", fixture.Peer.Origin.AbsoluteUri, "synthetic-code"), timeout.Token);
|
||||
_ = await fixture.ConnectAsync();
|
||||
Assert.Equal(0, fixture.LastHelloSettingsRevision);
|
||||
|
||||
@@ -44,11 +44,11 @@ public sealed class AgentRuntimeBacklogTests
|
||||
{
|
||||
// Synthetic responses otherwise let RunAsync drain synchronously before this
|
||||
// assertion starts. Exclude setup/control readiness from the drain budget.
|
||||
await state.FirstClaim.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
stop.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
await state.FirstClaim.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
stop.CancelAfter(TimeSpan.FromSeconds(90));
|
||||
state.ReleaseClaims.SetResult();
|
||||
// Four claims must finish before the configured 20-second idle interval.
|
||||
await state.Empty.Task.WaitAsync(TimeSpan.FromSeconds(10), stop.Token);
|
||||
await state.Empty.Task.WaitAsync(TimeSpan.FromSeconds(60), stop.Token);
|
||||
Assert.Equal(3, printer.Printed);
|
||||
await Task.Delay(250, stop.Token);
|
||||
Assert.Equal(4, Volatile.Read(ref state.Claims));
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class AgentRuntimeHeartbeatTests
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-heartbeat-" + Guid.NewGuid().ToString("N"));
|
||||
string pipeName = "QqqHeartbeat-test-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(60));
|
||||
BlockingPrinter printer = new();
|
||||
int heartbeats = 0;
|
||||
Task? running = null;
|
||||
|
||||
@@ -74,7 +74,7 @@ public sealed class AgentRuntimePipeResponseTests
|
||||
public async Task ResponseWait_RespectsDeadline_WhenClientNeverReads()
|
||||
{
|
||||
string name = "QqqPrintAgent-test-response-deadline-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(5));
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(60));
|
||||
await using NamedPipeServerStream server = new(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, 4096, 4096);
|
||||
await using NamedPipeClientStream client = new(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
@@ -152,7 +152,7 @@ public sealed class AgentRuntimePipeResponseTests
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "qqq-pipe-response-" + Guid.NewGuid().ToString("N"));
|
||||
private readonly AgentRuntime _runtime;
|
||||
public string PipeName { get; } = "QqqPrintAgent-test-response-" + Guid.NewGuid().ToString("N");
|
||||
public CancellationTokenSource Stop { get; } = new(TimeSpan.FromSeconds(15));
|
||||
public CancellationTokenSource Stop { get; } = new(TimeSpan.FromSeconds(60));
|
||||
public Task Running { get; }
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class AgentRuntimeQueuedJobsTests
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-queued-" + Guid.NewGuid().ToString("N"));
|
||||
string pipeName = "QqqQueued-test-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(60));
|
||||
ServerState server = new();
|
||||
NoPrintBackend printers = new();
|
||||
AgentRuntime? runtime = null;
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class AgentRuntimeTests
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-runtime-" + Guid.NewGuid().ToString("N"));
|
||||
string pipeName = "QqqPrintAgent-test-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(20));
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(60));
|
||||
TaskCompletionSource failed = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
TaskCompletionSource recovered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int requests = 0;
|
||||
|
||||
@@ -64,7 +64,7 @@ public sealed class AgentSessionIntegrationTests
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
QqqSessionCredential invalid = new(new Uri("https://synthetic.invalid/not-an-origin"), "synthetic-cookie");
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: invalid), CancellationToken.None));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
|
||||
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ public sealed class AgentSessionIntegrationTests
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
fixture.Session.DelayUnauthorizedPoll = true;
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
Task<int> oldPoll = runtime.PollControlsOnceAsync(timeout.Token);
|
||||
await fixture.Session.PollEntered.Task.WaitAsync(timeout.Token);
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class AgentSessionRecoveryTests
|
||||
byte[] corrupt = ProtectedData.Protect(Encoding.UTF8.GetBytes(json), null, DataProtectionScope.CurrentUser);
|
||||
File.WriteAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), corrupt);
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using(AgentStore unreadable = new(fixture.Local.DirectoryPath))
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => unreadable.LoadAuthentication());
|
||||
@@ -60,7 +60,7 @@ public sealed class AgentSessionRecoveryTests
|
||||
byte[] corrupt = [1, 2, 3, 4];
|
||||
File.WriteAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), corrupt);
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
fixture.Session.DenyBinding = true;
|
||||
await Assert.ThrowsAsync<Qqq.PrintAgent.Core.Authentication.QqqSessionException>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token));
|
||||
Assert.Equal(corrupt, File.ReadAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi")));
|
||||
@@ -96,7 +96,7 @@ public sealed class AgentSessionRecoveryTests
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
Assert.False(runtime.GetSnapshot().Enrolled);
|
||||
Assert.Equal(corrupt, File.ReadAllBytes(path));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
|
||||
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
Assert.Equal(corrupt, File.ReadAllBytes(path));
|
||||
@@ -128,7 +128,7 @@ public sealed class AgentSessionRecoveryTests
|
||||
string path = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
File.WriteAllBytes(path, corrupt);
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
Assert.Contains("revoke the previous session in QQQ", await runtime.SignOutAsync(timeout.Token), StringComparison.Ordinal);
|
||||
Assert.False(File.Exists(path));
|
||||
Assert.Equal(corrupt, File.ReadAllBytes(Assert.Single(Directory.GetFiles(fixture.Local.DirectoryPath, "qqq-session.recovery-*.dpapi"))));
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class AgentTransportTests
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-transport-pipe-" + Guid.NewGuid().ToString("N"));
|
||||
string name = "qqq-transport-" + Guid.NewGuid().ToString("N");
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
var runtime = new AgentRuntime(directory, _ => { }, new ForbiddenPrinters(), pipeName: name,
|
||||
pipeFactory: () => new NamedPipeServerStream(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly));
|
||||
Task run = runtime.RunAsync(timeout.Token);
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class BrowserCallbackTests
|
||||
{
|
||||
string name = "qqq-callback-test-" + Guid.NewGuid().ToString("N");
|
||||
using QqqBrowserCallback inbox = new(name);
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
Task<Uri> received = inbox.ReceiveAsync(timeout.Token);
|
||||
string callback = QqqBrowserCallback.RedirectUri + "?code=synthetic&state=synthetic";
|
||||
await QqqBrowserCallback.ForwardAsync(callback, name, Environment.ProcessPath!, timeout.Token);
|
||||
@@ -75,7 +75,7 @@ public sealed class BrowserCallbackTests
|
||||
{
|
||||
string name = "qqq-callback-test-" + Guid.NewGuid().ToString("N");
|
||||
using QqqBrowserCallback inbox = new(name);
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
Task<Uri> received = inbox.ReceiveAsync(timeout.Token);
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => QqqBrowserCallback.ForwardAsync(
|
||||
QqqBrowserCallback.RedirectUri + "?code=synthetic&state=synthetic", name, "C:\\not-the-installed-companion.exe", timeout.Token));
|
||||
|
||||
@@ -47,7 +47,7 @@ public sealed class InstallerPreflightTests
|
||||
await process.StandardInput.WriteAsync(script);
|
||||
process.StandardInput.Close();
|
||||
string error = await process.StandardError.ReadToEndAsync();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, error);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ public sealed class PrinterWorkerIsolationTests
|
||||
},
|
||||
};
|
||||
PrintJob job = new("synthetic", "fixture", "Never printed", "PDF", new string('0', 64), 1, new PrintOptions(), "", "RECEIVED");
|
||||
using var watchdog = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var watchdog = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
await Assert.ThrowsAsync<TimeoutException>(() => backend.PrintFileAsync(job, Path.Combine(Path.GetTempPath(), "unused-synthetic.document"), TimeSpan.FromSeconds(1), watchdog.Token));
|
||||
Assert.True(ProcessExited(childId));
|
||||
}
|
||||
@@ -101,7 +101,7 @@ public sealed class PrinterWorkerIsolationTests
|
||||
ProcessStartInfo start = new(FixtureExecutable) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true };
|
||||
start.ArgumentList.Add("--launch-parent");
|
||||
using Process parent = Process.Start(start) ?? throw new InvalidOperationException("Parent fixture launch failed.");
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(15));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
try
|
||||
{
|
||||
string? line = await parent.StandardOutput.ReadLineAsync(timeout.Token);
|
||||
|
||||
@@ -63,7 +63,7 @@ public sealed class QqqBrowserCallbackInboxTests
|
||||
{
|
||||
string name = "QqqPrintAgent.Auth.Test." + Guid.NewGuid().ToString("N");
|
||||
using QqqBrowserCallback inbox = new(name);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
Task<Uri> receiving = inbox.ReceiveAsync(timeout.Token);
|
||||
var steps = new List<string>();
|
||||
await QqqBrowserCallback.ForwardAsync(QqqBrowserCallback.RedirectUri + "?code=test-code&state=test-state", name, Environment.ProcessPath!, timeout.Token, steps.Add);
|
||||
|
||||
@@ -116,7 +116,7 @@ internal sealed class RealtimeRuntimeFixture : IAsyncDisposable
|
||||
{
|
||||
WebSocket socket;
|
||||
try
|
||||
{ socket = await Peer.AcceptAsync(_stopping.Token).WaitAsync(TimeSpan.FromSeconds(5)); }
|
||||
{ socket = await Peer.AcceptAsync(_stopping.Token).WaitAsync(TimeSpan.FromSeconds(60)); }
|
||||
catch(TimeoutException exception) { throw new InvalidOperationException(Diagnostic("WebSocket handshake"), exception); }
|
||||
var connection = new Connection(socket, this, loseSpoolAck, _stopping.Token);
|
||||
_connections.Add(connection);
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class ApplicationTrafficTransportTests
|
||||
Assert.Equal(handler.Downloaded, before.DownloadedBytes);
|
||||
Assert.True(before.UploadedBytes > 0);
|
||||
using var api = PrintApiClient.ForSession(session);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
string staging = Path.Combine(Path.GetTempPath(), "qqq-traffic-" + Guid.NewGuid().ToString("N"));
|
||||
var job = new PrintJob("job", "printer", "Synthetic", "PDF", Convert.ToHexString(SHA256.HashData(document)), document.Length, new PrintOptions(), "", "CLAIMED");
|
||||
Task<DownloadedDocument> download = api.DownloadFileAsync(job, staging, new RealtimeSettings(), timeout.Token);
|
||||
@@ -88,7 +88,7 @@ public sealed class ApplicationTrafficTransportTests
|
||||
public async Task WebSocketCountsSentAndFragmentedUtf8PayloadsWithoutCountingQueuedFramesOrHandshake()
|
||||
{
|
||||
using var peer = new LoopbackWebSocketPeer();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var client = new ClientWebSocket();
|
||||
client.Options.AddSubProtocol("qqq-printing.v1");
|
||||
Task<WebSocket> accepting = peer.AcceptAsync(timeout.Token);
|
||||
|
||||
@@ -661,9 +661,9 @@ public sealed class QqqSessionAuthenticationTests
|
||||
await client.SignInWithPasswordAsync("user", "password");
|
||||
await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "proof"));
|
||||
Task<HttpResponseMessage> printing = client.SendPrintingAsync(HttpMethod.Get, "device/jobs/job-1/content");
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
using HttpResponseMessage control = await client.SendPrintingAsync(HttpMethod.Post, "device/control/poll").WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await client.SignOutAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
using HttpResponseMessage control = await client.SendPrintingAsync(HttpMethod.Post, "device/control/poll").WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await client.SignOutAsync().WaitAsync(TimeSpan.FromSeconds(60));
|
||||
pending.SetResult(Response("{}"));
|
||||
await Assert.ThrowsAsync<QqqSessionException>(async () => { using HttpResponseMessage response = await printing; });
|
||||
Assert.False(client.IsAuthenticated);
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class RealtimeTransportTests
|
||||
public async Task NativeCookieAndDeviceProofUpgradeKeepsReceivingDuringStalledDocument()
|
||||
{
|
||||
using var peer = new LoopbackWebSocketPeer();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var body = new BlockingDocument();
|
||||
using var handler = new NativeHandler(body);
|
||||
using var session = new QqqSessionClient(peer.Origin, handler, allowLoopbackHttp: true);
|
||||
|
||||
Reference in new issue
Block a user