feat: add sortable history and inline printer details

Co-authored-by: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandCodex committed 2026-10-30 16:30:19 -05:00
1 parent 1d1727a424
commit d9033aef95
43 files changed
+1077 -157

No files matched your search

+4 -4
View File
@@ -9,10 +9,10 @@ on:
inputs:
product-version:
type: string
default: '0.1.5'
default: '0.1.6'
release-version:
type: string
default: '0.1.5-rc.1'
default: '0.1.6-rc.1'
permissions:
contents: read
@@ -52,8 +52,8 @@ jobs:
run: ./build/Test-BrandedInstaller.ps1
- name: Build unsigned prerelease MSI
env:
PRODUCT_VERSION: ${{ inputs.product-version || '0.1.5' }}
RELEASE_VERSION: ${{ inputs.release-version || '0.1.5-rc.1' }}
PRODUCT_VERSION: ${{ inputs.product-version || '0.1.6' }}
RELEASE_VERSION: ${{ inputs.release-version || '0.1.6-rc.1' }}
run: |
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
Assets were built and tested by this workflow. Verify the MSI against SHA256SUMS before installing. No Authenticode signature or production qualification is claimed.
Features: native Windows navigation, AppKit logs/settings/update prompts, verified machine-wide MSI updates, tray status, explicit printer exposure, reconnect and service controls, searchable job history with test/retry/cancel/review actions, bounded printer workers, outbound cloud polling, PDF and ZPL spool submission, durable receipts and protected service credentials. Laser output was confirmed on earlier development builds; current installed acceptance is recorded in docs/FUNCTIONAL-AUDIT.md and docs/APPKIT-PARITY.md. Zebra qualification remains pending.
Features: sortable printer and job tables, inline printer details, double-click settings and job dialogs, bounded history pages with creation-time ordering, synchronized computer/printer profiles, daily log limits and clearing, same-installer customer branding, native Windows navigation, AppKit logs/settings/update prompts, verified machine-wide MSI updates, tray status, explicit printer exposure, reconnect and service controls, searchable job history with test/retry/cancel/review actions, bounded printer workers, outbound cloud polling, PDF and ZPL spool submission, durable receipts and protected service credentials. Laser output was confirmed on earlier development builds; current installed acceptance is recorded in docs/FUNCTIONAL-AUDIT.md and docs/APPKIT-PARITY.md. Zebra qualification remains pending.
See CHANGELOG.md, docs/INSTALLATION.md and docs/ACCEPTANCE.md in the tagged source for scope and limitations.
NOTES
+1 -1
View File
@@ -2,7 +2,7 @@
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 0.1.5 -ReleaseVersion 0.1.5-rc.1`.
- Verify with `./build/Verify.ps1`; package with `./build/Build-Installer.ps1 -ProductVersion 0.1.6 -ReleaseVersion 0.1.6-rc.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.
+11
View File
@@ -1,7 +1,18 @@
# Changelog
## 0.1.6-rc.1 — source changes, validation pending
- Expand selected printer information within its row, with actions first, labeled fields, a bounded description preview and expandable Windows diagnostics.
- Open printer settings and job details by double-click, while preserving checkbox/button actions and text selection. Equivalent Settings and Details buttons remain available.
- Use wider, window-responsive native dialogs for profile editing and structured job summary/event history.
- Add sortable headers with printer Name ascending and job Created descending defaults. Creation time is preserved independently of status updates; missing historical creation times remain explicit.
- Apply job ordering across the matching local history and bounded cloud queue preview before paging, using displayed labels and stable identifier tie-breaks. Sort indicators update only after a successful query.
- Source verification and installed 0.1.6 acceptance are pending; no physical print is claimed for this UI work.
## 0.1.5-rc.1 — 2026-10-30
Installed acceptance subsequently passed 12 interactive checks across a ten-test run and a focused two-test run. This evidence applies to 0.1.5, not the 0.1.6 changes above.
- Compact printer/job tables, stable row selection, full-text details and bounded 50-row job pages with prefix search.
- Client-managed computer/printer names, descriptions and locations synchronized to QQQ without changing physical queue identities.
- Indexed history queries, 200-event recovery batches and bounded detail maintenance; synthetic 10,000/30,000-job regression coverage.
+1 -1
View File
@@ -55,7 +55,7 @@
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
CLI's version output show as a short hash. -->
<PropertyGroup>
<VersionPrefix>0.1.5</VersionPrefix>
<VersionPrefix>0.1.6</VersionPrefix>
<VersionSuffix>rc.1</VersionSuffix>
</PropertyGroup>
+7 -4
View File
@@ -9,13 +9,14 @@ Print from **QQQ 4.1+** to printers at a local facility without opening inbound
The native setup and status application uses the same [KofTwentyTwo AppKit](https://github.com/KofTwentyTwo/AppKit) foundation as GCLO and MarkdownViewr.Windows.
> **Prerelease software.** Version 0.1.4 was installed successfully and the user confirmed the tray icon works. Current source adds table views, paged history, metadata editors and diagnostic limits; its new verification and installed acceptance are pending. The MSI and first-party executables are unsigned. Production signing and broad printer qualification remain follow-up work.
> **Prerelease software.** Installed 0.1.5 passed 12 interactive checks across a ten-test run and a focused two-test run. Current 0.1.6 source adds inline printer information, wider settings/details dialogs and sortable tables; its verification and installed acceptance are pending. The MSI and first-party executables are unsigned. Production signing and broad printer qualification remain follow-up work.
## What works
- 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.
- Browse compact printer/job tables with full details and confirmed test/reprint/cancel actions. Current 0.1.6 source expands printer information inside the selected row and opens printer settings or job details on double-click.
- 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.
@@ -43,7 +44,7 @@ The companion projects are [qbit-printing](https://github.com/QRun-IO/qbit-print
## Install and connect
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** to the Start menu.
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. In your QQQ printing administration, create an enrollment code for this facility/device.
4. Open **QQQ Print Agent**, accept the administrator prompt, enter the QQQ server URL and enrollment code, and select **Connect**.
5. In **Printers**, select which queues to expose to QQQ. Then enable the intended discovered queue in QQQ and submit a test document.
@@ -71,7 +72,9 @@ A printer visible only in a signed-in user's profile may not be available to the
## Validation so far
The installed 0.1.4 source passed locked restore, a zero-warning build and **163 unit/regression tests**. Its interactive suite passed **10 of 11 checks**; log-window discovery timed out and the focused rerun did not execute. The user separately confirmed the tray icon works. These results apply to that installed version, not the subsequent table, metadata and scalability changes. See [session state](docs/SESSION-STATE.md) and [UX review](docs/UX-REVIEW.md).
Installed 0.1.5 passed **12 interactive checks across runs of ten and two tests**. The subsequent 0.1.6 source review covers inline printer expansion, double-click actions, wider responsive dialogs and whole-result sorting, but its source gate and installed acceptance are still pending. See [acceptance notes](docs/ACCEPTANCE.md), [session state](docs/SESSION-STATE.md) and [UX review](docs/UX-REVIEW.md) for evidence tied to each version.
Historically, 0.1.4 passed a zero-warning build and 163 unit/regression tests. Its initial interactive run passed ten of eleven checks, with a log-window discovery timeout; the user separately confirmed that its tray icon worked. Those earlier results do not qualify later changes.
Earlier end-to-end local acceptance used the actual Java QQQ printing server, enrolled Windows connector, and a Canon laser queue. The user confirmed correct physical PDF output. The installed LocalSystem service path and MSI install/uninstall were also exercised; see [acceptance notes](docs/ACCEPTANCE.md).
+2 -2
View File
@@ -1,9 +1,9 @@
[CmdletBinding()]
param(
[ValidatePattern('^\d+\.\d+\.\d+$')]
[string] $ProductVersion = '0.1.5',
[string] $ProductVersion = '0.1.6',
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
[string] $ReleaseVersion = '0.1.5-rc.1',
[string] $ReleaseVersion = '0.1.6-rc.1',
[string] $PayloadDirectory,
[switch] $SkipPublish,
[string] $CertificateThumbprint,
+16 -4
View File
@@ -1,6 +1,18 @@
# Acceptance evidence
## Installed upgrade checkpoint — 2026-10-30
## Current 0.1.6 source — validation pending
Current source adds inline expansion of selected printer rows, double-click printer settings/job details, wider responsive native dialogs and sortable table headers. Printers default to Name ascending. Jobs default to immutable Created descending, with whole-result ordering before paging and explicit missing historical creation times. Status updates do not redefine a job's creation time.
Independent source review confirmed the correction of unbounded expanded descriptions, text-selection conflicts, fixed dialog width after resize, mismatched visible sort keys and premature sort indicators. This is source-review evidence only. The 0.1.6 source gate and installed acceptance have not yet been recorded. Pending checks include keyboard selection and action buttons, checkbox and selectable-text double-click behavior, resizing open dialogs, long text, both sort directions across pages and service-query failure feedback. No physical print was sent for this UI review.
## Installed 0.1.5 checkpoint — 2026-10-30
Installed 0.1.5 passed 12 interactive checks across a ten-test run and a focused two-test run. This evidence belongs to that installed version. It does not qualify the subsequently changed 0.1.6 rows, sorting or dialogs, and it does not establish a new physical print, Zebra qualification, a general printer-driver matrix or production signing.
The older checkpoints below are retained as history; their pending approval statements and artifact hashes describe their own point in time.
## Historical 0.1.1 upgrade and 0.1.2 correction — 2026-10-30
The authorized 0.1.1 MSI upgrade completed with installer exit 0. Saved encrypted credential bytes were unchanged, and the Windows service restarted successfully. SCM inspection confirmed the two 30-second recovery restarts. Nine installed desktop tests then exposed a native startup failure: the constructor passed an uncreated NavigationView SettingsItem to AutomationProperties. This failed result supersedes prior compile-only UI evidence.
@@ -8,11 +20,11 @@ The 0.1.2 correction waits for Navigation.Loaded and checks the item before assi
The sections below are historical checkpoints.
## Current source corrections — installed acceptance pending
## Historical 0.1.1/0.1.2 source corrections
The current source includes native Printers/Jobs/Logs/Settings navigation, unified AppKit logs, stable printer rows, service controls, opt-in sign-in startup, job identity/search/details/actions, accessible status text and branding contrast validation. The last integration corrections reject premature pipe EOF as an unknown outcome, retain an offline explanation for missing printers, and discard pre-action snapshots that could overwrite newer exposure state. Ordinary pipe requests are bounded to 35 seconds (3-second connection timeout); enrollment is bounded to 150 seconds.
Source at that checkpoint included native Printers/Jobs/Logs/Settings navigation, unified AppKit logs, stable printer rows, service controls, opt-in sign-in startup, job identity/search/details/actions, accessible status text and branding contrast validation. Its integration corrections rejected premature pipe EOF as an unknown outcome, retained an offline explanation for missing printers, and discarded pre-action snapshots that could overwrite newer exposure state. Ordinary pipe requests were bounded to 35 seconds (3-second connection timeout); enrollment to 150 seconds.
The latest source build passed with zero warnings/errors. The final gate passed 102 unit/regression tests (36 Core and 66 Windows/CLI). Current MSI upgrade/UAC approval and elevated interactive UI acceptance are pending. This section does not claim the installed companion, tray, startup task or new operator actions have passed acceptance. See [the current UX review](UX-REVIEW.md) for the remaining checks.
That source build passed with zero warnings/errors and 102 unit/regression tests (36 Core and 66 Windows/CLI). Its MSI upgrade/UAC approval and elevated interactive UI acceptance were still pending when recorded. That unit gate did not qualify the installed companion, tray, startup task or operator actions. See [the current UX review](UX-REVIEW.md) for current checks.
The historical evidence below includes **two user-confirmed laser pages**: the CLI path and the installed LocalSystem service path. Those confirmations remain valid for the tested earlier builds and do not qualify every subsequent UI/action change.
+10 -4
View File
@@ -2,7 +2,9 @@
Reviewed 2026-10-30 against the local AppKit repository, GCLO and MarkdownViewr.Windows. Scope is shared desktop behavior, not Markdown editing, account sync or other app-specific features. AppKit packages pinned by this project are **0.1.0**; unreleased AppKit 0.2 structured-logging work is not assumed available.
**Current checkpoint:** 0.1.4-rc.1 is installed from source 80e253f. Local verification passed 163 tests with zero warnings/errors, and exact-source GitHub CI passed. The installed desktop suite passed 10 of 11 tests, including standard File/Help menus, About, settings, splash, close-to-tray and relaunch. The log-window test timed out; Windows canceled elevation for the focused rerun; the installed update remains in place. A native WinUI probe reproduced and fixed the cached-icon ownership failure; user confirmation of the visible tray image is pending. Credentials were retained, the service is running, and four queues were detected. Cloud connectivity and actual in-app update apply are not newly qualified.
**Current checkpoint:** installed 0.1.5 passed 12 interactive checks across a ten-test run and a focused two-test run. Current 0.1.6 source adds inline printer information, sortable tables, immutable Created time and wider responsive native dialogs. Its source verification and installed acceptance are pending. These are connector workflow changes using the existing AppKit shell, not replacements for AppKit logs, settings, About or dialog coordination.
**Historical 0.1.4 checkpoint:** source 80e253f passed 163 tests, zero warnings/errors and exact-source GitHub CI. Its first installed suite passed ten of eleven tests, with a log-window discovery timeout; the focused rerun did not execute. The user subsequently confirmed tray visibility. Those results remain version-specific and do not establish 0.1.6 or actual in-app updater acceptance.
## Reference evidence
@@ -15,7 +17,7 @@ Reviewed 2026-10-30 against the local AppKit repository, GCLO and MarkdownViewr.
| Shared behavior | Connector source at review | Source status / remaining qualification | Tests and acceptance |
| --- | --- | --- | --- |
| Standard File/Help menu bar | Restored in0.1.4 with Settings, Close/Exit, activity log, help/support/releases, Check for Updates and About. | Uses existing AppKit actions and the same update busy state as Settings. | Installed menu/settings/About command test passed; actual update apply remains separate. |
| Standard File/Help menu bar | Restored in 0.1.4 with Settings, Close/Exit, activity log, help/support/releases, Check for Updates and About. | Uses existing AppKit actions and the same update busy state as Settings. | Installed menu/settings/About command test passed; actual update apply remains separate. |
| Stable app identity, version, paths and environment override | `AppIdentity`, AppKit `AppPaths`, settings context and branded assets are wired. | No identity-framework gap. Machine ProgramData is deliberately separate from per-user preferences. | Identity/path tests exist; installed version/About must match MSI payload. |
| Atomic persisted settings and sanitization | `AppSettings : ShellSettings`, `SettingsStore`, `Sanitize` and save-failure feedback are used. | No replacement settings system needed. New parity preferences must use this store. | Save/load and invalid-value tests exist; restart acceptance remains. |
| System/light/dark theme | Startup and live `WinUIShell.ApplyTheme` calls; native theme resources. | No source gap. | Live high-contrast, theme, multi-monitor and scaling acceptance outstanding. |
@@ -24,7 +26,7 @@ Reviewed 2026-10-30 against the local AppKit repository, GCLO and MarkdownViewr.
| Useful project, issue and support entry points | About links to the project; optional customer support link exists. | Source includes direct help/release/project/support entry points; customer support must not replace fixed update authority. | Validate target URLs and opening failures; do not imply a GitHub Discussions destination exists without verification. |
| Live logs, Follow, Errors only, Copy, Open folder | Every log action calls AppKit `LogWindow.ShowSingle`; shared service/companion log with redaction/retention. | No separate custom viewer required. | Log tests pass; actual shared viewer reuse and error visibility remain installed acceptance. |
| UI crash net | `WinUIShell.InstallCrashNet` installed in `App`. | No wiring gap. A crash net does not cure startup resource/native faults. | Installed startup and About checks passed on 0.1.4; full visual acceptance remains separate. |
| Serialized dialogs and native prompts | `DialogGuard` used for connection, About, job details and confirmations. | Update UI uses AppKit `ContentDialogPrompter` and `UpdateCoordinator`; manual/startup checks are serialized. | Test concurrent clicks/reentry and modal cancellation in the real shell. |
| Serialized dialogs and native prompts | `DialogGuard` used for connection, About, job details and confirmations. Current 0.1.6 profile/job dialogs scope their width resources and resize content with the window. | Update UI retains AppKit `ContentDialogPrompter` and `UpdateCoordinator`; manual/startup checks are serialized. | Current dialog changes are source-reviewed only; test resize, text selection, reentry and modal cancellation on the installed version. |
| DPI-aware size, minimum size and app icon | AppKit window helpers plus native brand/tray assets. | No baseline sizing gap. AppKit 0.1 does not promise persisted window placement; absence is not invented parity debt. | Verify actual taskbar/tray shell behavior, small work areas and DPI transitions. |
| Secret storage | Service uses CurrentUser DPAPI under LocalSystem plus restricted ProgramData; UI receives no token. | Deliberate service-specific equivalent to AppKit CredentialManagerVault, not a missing secret-store feature. | Credential round-trip/recovery tests; inspect installed ACL and service identity. |
| Single instance, tray and explicit Exit | User/session/profile activation, close-to-tray, explicit Exit and service-independent printing implemented. | Extra requirements of this app; not missing generic AppKit APIs. | Actual duplicate activation, hide/reopen and shell restart remain acceptance items. |
@@ -35,7 +37,7 @@ Reviewed 2026-10-30 against the local AppKit repository, GCLO and MarkdownViewr.
| Download, consent, error handling and coherent apply | Fixed-feed `GitHubMsiReleaseClient`, `MsiUpdateService` and copied `MsiUpdateInstaller` helper are present in source. | Source implements release checksum, protected staging, copied CLI helper and one service/UI/CLI MSI; tests/build passed; real apply still requires acceptance. Per-user Velopack replacement would violate machine package coherence. | Synthetic download/hash/request/ACL/version tests passed; real helper/MSI apply remains pending. Same-feed checksum is not publisher signing. |
| Supply-chain/privilege boundaries | Existing MSI installs protected binaries/state; first prerelease unsigned. | Documented update design in [threat model](security/threat-model.md); do not claim signing or attestation before available. | Real UAC cancel, file locks, Windows Installer reboot-required result and retained journal must be qualified. |
## Release gate for parity
## Historical 0.1.3 parity gate
1. Source and locked build/tests are complete: 163 tests, zero warnings/errors; UI tests compile only.
2. Package and validate a newly staged coherent 0.1.3-rc.1 MSI; installed acceptance is not established by the source gate.
@@ -44,3 +46,7 @@ Reviewed 2026-10-30 against the local AppKit repository, GCLO and MarkdownViewr.
5. Test one actual coherent upgrade with safe job-state handling, retained enrollment/journal, explicit failure/cancel feedback and correct restart behavior. Reboot-required outcomes must be reported honestly.
Document reading and source comparison performed no builds, installs, update downloads, physical printing or publication. Zebra, reboot qualification and publisher signing remain separate acceptance items.
## Current 0.1.6 gate
Run the current source gate, then qualify the installed tables, both sort directions across pages, keyboard alternatives to double-click, selectable inline text, checkbox behavior and responsive dialog sizing. Preserve the existing single AppKit viewer and coordinator behavior. Installed 0.1.5's 12 interactive checks are prior evidence, not a substitute for these changed interactions.
+12 -4
View File
@@ -2,9 +2,17 @@
Audit date: 2026-10-29. Scope: Windows service, AppKit companion, administrator management pipe, enrollment, SQLite journal, printer adapter, runtime branding, installer and regression coverage.
## Verdict
## Current 0.1.6 follow-up — validation pending
**Source corrections are implemented; installed acceptance is still required before rollout.** The final Windows gate passed **36 Core + 66 Windows/CLI tests (102 total)** with zero build warnings/errors. Installed UI, upgrade and hardware acceptance remain separate.
Installed 0.1.5 passed 12 interactive checks across a ten-test run and a focused two-test run. Current 0.1.6 source adds printer selection that expands information inline, double-click printer settings/job details, wider responsive native dialogs and sortable headers. Printers default to Name ascending; jobs default to immutable Created descending. Job ordering applies across the matching result set before paging, including the bounded cloud queue preview. Status updates retain creation time, and missing historical values remain explicit.
Read-only review confirmed bounded printer-description preview, actions before details, selectable-text/checkbox exclusions from double-click, scoped dialog sizing with open-window resize handling, displayed sort keys and applied indicators that update only after successful queries. The 0.1.6 source gate and installed checks are pending. See [UX review](UX-REVIEW.md) for the precise remaining interactions. No new physical print was performed for this follow-up.
The remainder of this document preserves the original 0.1.1/0.1.2-era audit and its evidence. Test counts, pending approvals and package hashes below are historical, not the current release state. The original 1,000-job detail limit was later raised; use [History and log limits](SCALABILITY.md) for the current policy.
## Historical audit verdict
**At this historical checkpoint, source corrections were implemented and installed acceptance remained required.** That Windows gate passed **36 Core + 66 Windows/CLI tests (102 total)** with zero build warnings/errors. Installed UI, upgrade and hardware acceptance were recorded separately.
The audit originally found backend liveness defects and missing day-to-day controls. Their corrections are mapped below so source completion is not confused with hardware or installed UX acceptance.
@@ -45,7 +53,7 @@ The audit originally found backend liveness defects and missing day-to-day contr
- User-confirmed laser evidence from the previous development/temporary installed-service tests remains valid for those tested builds. It does not qualify every new source change. Zebra remains deferred until connected and explicitly tested.
- Publisher signing, custom media dimensions, broad driver/multi-node qualification and physical-output confirmation remain separately tracked production work. Renderer isolation and safe detail retention are now implemented, not deferred features.
## Remaining acceptance before rollout
## Historical remaining acceptance checklist
1. The coherent service/companion/CLI MSI is built; its checksum and unsigned status are recorded below. Installed acceptance is still required.
2. Complete the pending user UAC approval before actual elevated upgrade/UI acceptance. On the elevated interactive desktop, exercise unconfigured, disconnected and stopped/missing-service states; enrollment/invalid code/recovery, reconnect, exposure persistence, meaningful history/details/review, safe retry/cancel and unified logs.
@@ -53,7 +61,7 @@ The audit originally found backend liveness defects and missing day-to-day contr
4. Verify opt-in task enable/disable and sign-in behavior, service recovery, upgrade while companion is open, reboot with retained journal/credentials and uninstall retaining evidence. Do not silently print during lifecycle checks.
5. Perform a separately authorized single laser acceptance job through the final installed path. Test Zebra with the intended stock/DPI when available. Record synthetic versus physical evidence distinctly.
## Verification record
## Historical verification record
- Final Windows `Verify.ps1`: **36 Core + 66 Windows/CLI tests passed (102 total)**, zero build warnings/errors. This supersedes the earlier checkpoints.
- Final regressions include real heartbeats during blocked submission, cloud-queued visibility/details/cancellation before local receipt, and stale-response protection.
+4 -2
View File
@@ -6,14 +6,16 @@ The 2026-10-30 Windows gate passed204 tests (39 Core and165 Windows/CLI) with ze
## Job history
The Jobs page queries SQLite rather than filtering the latest snapshot. Page size is bounded to 100, with SQL counts, status/printer/review filters and stable timestamp/ID ordering. Search matches the start of a job ID, title or printer name; literal percent and underscore characters are escaped. Search fields have SQLite prefix indexes. Only returned rows deserialize event JSON. Offset pages are live views: concurrent new jobs can move page boundaries, so refresh before assuming an unchanged list.
The Jobs page queries SQLite rather than filtering the latest snapshot. Page size is bounded to 100, with SQL counts and status/printer/review filters. Jobs default to original creation time descending; clickable headers sort the full local history by creation time, title, printer, displayed status or format, with a deterministic job-ID tie. Search matches the start of a job ID, title or printer name; literal percent and underscore characters are escaped. Search fields have SQLite prefix indexes. Only returned rows deserialize event JSON. Offset pages are live views: concurrent new jobs can move page boundaries, so refresh before assuming an unchanged list.
A bounded preview of up to 50 cloud-queued jobs appears ahead of local history and keeps queued cancellation available before receipt. Matching local jobs suppress duplicate preview rows. The UI marks when the preview reaches its bound; its combined count is not an authoritative total for an arbitrarily large cloud queue.
A bounded preview of up to 50 cloud-queued jobs interleaves with local history under the same selected sort order and keeps queued cancellation available before receipt. Matching local jobs suppress duplicate preview rows. The UI marks when the preview reaches its bound; its combined count is not an authoritative total for an arbitrarily large cloud queue. The merge reads at most 150 local candidates in bounded SQL pages, regardless of the requested offset, plus at most 50 cached queued jobs. Text ordering uses SQLite NOCASE (ASCII case folding, otherwise UTF-8 order) consistently for both sources; visible fallback labels and reviewed-status suffixes participate in ordering.
Outbox delivery reads at most 200 ordered events per poll. The worker heartbeats and defers new claims until the previous backlog has drained. Interrupted-state recovery is also batched. Job details expose the latest 200 retained events. The legacy event-to-summary migration runs once, rather than grouping all events each time the service opens its store.
Default detail retention is 30 days or 300,000 jobs, with at most 250 eligible jobs pruned per maintenance pass. Runtime schedules this maintenance once per minute. Pending events, active submissions, unreviewed failures and every uncertain outcome retain their evidence. Operator review never changes delivery status. Terminal summaries remain for duplicate suppression even after event detail is pruned; the database therefore has no fixed lifetime size ceiling. SQLite reuses freed pages; retention does not promise that its file immediately shrinks. Monitor disk use and resolve operational exceptions instead of deleting a live journal.
Creation time is separate from the latest status-update time. Receipt captures the normalized server creation timestamp once; later events and repeated claims preserve it. The one-time migration uses the earliest retained event timestamp, falling back to the stored update timestamp when old detail is gone. An absent historical timestamp remains unknown rather than inventing a date. The 0.1.6-rc.1 gate passed 211 tests (42 Core and 169 Windows/CLI), including the creation-time migration and every header order across 10,000 rows, both directions and distant pages. Locked restore and the build completed with zero warnings/errors. The 204-test timing observations above describe the prior source.
## 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.
+9 -1
View File
@@ -1,5 +1,13 @@
# Session state — 2026-10-30
## Live recovery checkpoint — 2026-10-30
## Current checkpoint: 0.1.6 source verification
Corrected 0.1.5-rc.1 from 1d1727a is installed and opened. Installation returned 0 without a reboot; credential bytes were retained, the service restarted, and four printer queues were discovered. Management, profile, job-page and log-settings queries and the branded shortcut passed. All 12 existing native UI checks have passing results across the initial 10 passes and a focused two-test rerun. Exact-source CI 41127963112 passed 204 tests and MSI packaging. The existing cloud connection is offline.
The 0.1.6 changes implement inline printer details, double-click settings/details, wider responsive dialogs and sortable headers. Printers default to friendly name ascending; jobs default to immutable creation time descending. SQL sorts the complete history, with bounded pages and a bounded merge of queued jobs. Creation-time migration preserves existing evidence. The full gate passed 211 tests (42 Core and 169 Windows/CLI), locked restore, and zero build warnings/errors. Packaging and installed UI acceptance are next. No physical print was sent for this scope.
Root owns packaging, the normal UAC upgrade and publication after verification. Agent-owned source is frozen for the gate. Older checkpoints below are historical and do not describe the current installation.
## Historical failed upgrade and recovery checkpoint — 2026-10-30
Attempted0.1.5 installation (b45a906,MSI82A6C8B283612CD45238820F38BAEEBBE2AA76003833FA3E5FAAD3BC2991EE79) failed1603 in ApplyBranding. The earlier MSI MajorUpgrade schedule removed0.1.4 outside the rollback transaction, leaving its service/app absent. Protected device state and pre-upgrade backup remain. Recovery uses the verified installer artifact from passing CI41076156859 under cloud-printing-integration/runs/recovery-014/verified-ci; Restore-014.ps1 was launched through normal UAC, but Windows canceled elevation before it started. The user has been asked to reply retry for a fresh prompt; no elevation is now pending. Do not claim installation/recovery complete until result.json confirms it. Root owns recovery/install. The corrected source passes204tests (39Core+165Windows/CLI): Start menu checks allow deletion-only inherited user grants while rejecting content writes, and MajorUpgrade now uses afterInstallInitialize. Build outputs are isolated per numeric version to preserve prior recovery installers. Remote CI for b45a906 failed one timing-based heartbeat assertion; its corrected test waits for observed traffic while retaining the no-claims pause invariant. The strict protected updater-data ACL policy remains unchanged.
## 0.1.5 source gate — 2026-10-30
+3 -2
View File
@@ -1,7 +1,8 @@
# TODO
- [x] 0.1.5 source gate:204tests, zero warnings/errors; branding script analysis/dry-run passed.
- [ ] Restore0.1.4 after failed branding upgrade (normal UAC was canceled), then install the corrected0.1.5 and run native table/profile UI acceptance.
- [x] 0.1.5 source gate: 204 tests, zero warnings/errors; branding script analysis/dry-run passed.
- [x] Restore 0.1.4, then install corrected 0.1.5; all 12 native UI checks have passing results; 204 tests and exact-source CI passed.
- [ ] Verify and install 0.1.6 row expansion, double-click dialogs and full-history sorting (implementation complete).
## Implemented
+14 -4
View File
@@ -1,5 +1,7 @@
# Using QQQ Print Agent
This guide describes the current 0.1.6 source. Its new sorting and inline-detail behavior is awaiting source and installed validation. Installed 0.1.5 has separate acceptance evidence; see [Acceptance](ACCEPTANCE.md).
## Menus
**File** provides **Settings**, **Close window** (hide to the tray), and **Exit companion**. Exiting the companion leaves the print service running. **Help** provides the shared **Activity log**, user guide, support, releases, project links, **Check for updates**, and **About**.
@@ -12,11 +14,19 @@ Open **QQQ Print Agent** from Start. In **Settings → Server connection**, choo
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 **Share with server** for each intended queue, then approve it in QQQ. Local sharing and server approval are separate. **Print test** asks for confirmation and PDF/ZPL format before sending one real test through QQQ. 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.
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.
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 and PDF/ZPL format before sending one real test through QQQ. 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.
## Jobs
The Jobs table aligns document title, printer, format, state and time. Select a row to enable its actions. 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 text is abbreviated with full tooltips; Details exposes selectable full values. Older jobs retain their IDs when their title was never recorded.
The Jobs table aligns document title, printer, state, format and **Created** time. It initially sorts by **Created descending**, newest first. Creation time is retained from the job and does not change when a later status event arrives; last update time is shown separately in Details. If an older record has no creation time, the UI says **Not recorded** rather than inventing one.
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.
- **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.
@@ -34,11 +44,11 @@ A timeout on a print action is not proof of failure. Inspect QQQ Jobs and Logs b
| FAILED | A definite failure or operator cancellation before dispatch; read its message. |
| OUTCOME_UNKNOWN | Submission might have reached the printer; inspect before reprinting. |
Detailed delivered history defaults to 30 days or the latest 300,000 detailed jobs. Pending events, active submissions, unreviewed failures and uncertain outcomes retain their evidence. Small final-state records remain for duplicate suppression. All retained local history is paged and searchable. Up to 50 server-queued jobs appear ahead of local history; a notice identifies a bounded queue preview. Its count is not the total of a larger cloud queue. Queued cancellation can fail if the server has already claimed a job. See [history and log limits](SCALABILITY.md).
Detailed delivered history defaults to 30 days or the latest 300,000 detailed jobs. Pending events, active submissions, unreviewed failures and uncertain outcomes retain their evidence. Small final-state records remain for duplicate suppression. All retained local history is paged and searchable. Up to 50 server-queued jobs merge into the selected result order; a notice identifies this bounded queue preview. Its count is not the total of a larger cloud queue. Queued cancellation can fail if the server has already claimed a job. See [history and log limits](SCALABILITY.md).
## Computer and printer details
Use **Settings → Computer details** to edit this installation’s display name, description and location. Select a printer and choose **Edit details** for its equivalent fields. Names/locations allow 512 characters; descriptions allow 4,096. Empty names fall back to Windows names. Metadata is stored by the service and synchronized to the server; it does not rename Windows queues, change stable IDs or replace server approval. Printer Details exposes the actual queue name, driver, port and ID.
Use **Settings → Computer details** to edit this installation’s display name, description and location. Select a printer and choose its inline **Settings** button, or double-click its compact row, for the equivalent editor. Names/locations allow 512 characters; descriptions allow 4,096. Empty names fall back to Windows names. Metadata changes display values here and on the server; it does not rename Windows queues, change stable IDs or replace server approval. The expanded printer information exposes the actual queue name, with driver, port and ID under Windows diagnostics.
## Tray, logs and service
+19 -5
View File
@@ -2,18 +2,18 @@
## Scope and evidence
This review separates current source behavior from installed acceptance. Installed 0.1.4 passed 163 unit/regression tests and a zero-warning source gate. Its interactive suite passed 10 of 11 checks, including menus/About, splash and close/relaunch; log-window discovery timed out, and its focused rerun did not execute. The user explicitly confirmed that the tray icon now works. No new physical print was performed for the current changes.
This review separates current source behavior from installed acceptance. Installed 0.1.5 passed 12 interactive checks across a ten-test run and a focused two-test run. The subsequent 0.1.6 source changes below have not yet completed their source gate or installed acceptance. No new physical print was performed for this review.
The table, paging, metadata and diagnostic-limit changes below are newer than that installed version. Their source gate and installed visual/interactive acceptance are pending. Reference patterns remain native WinUI NavigationView and menus, GCLO/MarkdownViewr settings, and the single built-in AppKit LogWindow.
The independent 0.1.6 source review inspected inline printer selection, double-click actions, dialog layout and lifetime, sortable headers, displayed sort keys and full-result paging. Reference patterns remain native WinUI NavigationView and menus, GCLO/MarkdownViewr settings, and the single built-in AppKit LogWindow. Source inspection does not establish Narrator, keyboard or resize behavior on an installed application.
## Current workflow matrix
| Workflow | Current implementation | Remaining acceptance or limitation |
| --- | --- | --- |
| Shell and navigation | File/Help menus; left Printers, Jobs, Logs and Settings; persistent connection footer. Shared AppKit About, update prompts, theme and splash preferences. | Inspect new content at default/minimum sizes, keyboard navigation and display scaling. Prior installed shell tests do not qualify new tables. |
| Printer inventory | Compact aligned name/status/location/formats/sharing columns. Stable rows retain identity across polls. Select a row for Details, Edit details or confirmed Print test; Windows printer settings is available. Unsupported interactive queues cannot be newly shared/tested, but old selections can be withdrawn. | Verify selection/focus across status/removal changes, long labels, real queue metadata and independent server approval. |
| Printer inventory | Compact sortable name/status/location/formats/sharing columns, initially Name ascending. Selection expands information inside the row, with Settings/Print test first, labeled fields, a three-line description preview and a diagnostics expander. Double-clicking the compact row opens Settings. | Verify keyboard selection/focus, large names, checkbox interactions and status/removal changes. The inline subtree and selectable text are excluded from row double-click handling in source. |
| Device/printer metadata | Settings edits computer name, description and location; selected printers have equivalent editors. Local service persistence and outbound synchronization preserve technical names and stable IDs. Names/locations have 512-character bounds, descriptions 4,096. | Verify save/reopen/restart/synchronization and explicit clearing. Friendly labels do not rename Windows queues. |
| Jobs | Aligned document/printer/state/format/time columns, single selection and selected-row actions. Service queries page 50 results with indexed prefix search and state/attention filters across durable history. Full selectable details retain identifiers/events. | Inspect populated multi-page history, filters and long values. Live offset boundaries can move as new jobs arrive. Cloud-queued preview is limited to 50 and visibly disclosed; its count is not an unlimited cloud total. |
| Jobs | Sortable document/printer/state/format/Created columns, initially immutable Created descending. Ordering covers matching local history and the bounded cloud preview before 50-row paging. Status sort uses displayed labels; missing metadata uses displayed fallbacks. Double-click or Details opens labeled summary fields and event columns. | Verify ordering across pages, changing filters and status updates, and missing historical creation times. Live offset boundaries can move as new jobs arrive. Cloud preview is limited to 50 and visibly disclosed. |
| Actions | Confirmed server-mediated tests/reprints, queued or identity-checked spooler cancellation, and failure-review acknowledgement. Unknown outcomes retain duplicate-risk warnings. | Physical output and operational action acceptance remain separate. Spool acceptance cannot prove paper output. Zebra qualification is pending. |
| Logs | Every logs entry point reuses the built-in AppKit viewer with both service/companion sources. Settings and Logs offer the same retention/daily-size editor and confirmed clearing. Usage/cap status is shown in the editor; daily rotation follows local calendar date. | Verify viewer discovery/reuse, both sources, cap/resume and clear confirmation on installed build. Limits are 1–365 days and 1–100 MiB; clearing preserves job evidence and credentials. |
| Service and startup | Settings shows actual service state, bounded Start/Restart, Windows Services and installer guidance. Restart requires confirmation. Explicit per-user elevated sign-in task is opt-in and fixed to the installed executable. | Verify missing/stopped/access-denied states, sign-in/reboot, task removal and recovery. Standard users require administrator authorization for management. |
@@ -23,9 +23,21 @@ The table, paging, metadata and diagnostic-limit changes below are newer than th
| Branding and long values | Runtime identity/logo/support, theme foregrounds and validated accent contrast. Table cells use ellipsis plus full tooltips; details/editor surfaces wrap and expose full values. | Inspect branded builds, long names, light/dark/high contrast and missing logos. Branding reload requires restart; fixed upgrade/service identity is preserved. |
| Accessibility | Native controls, accessible row/action labels, text plus color, live health region, selectable details and DPI-aware windows. | Narrator, focus order, high contrast, large text and multi-monitor checks remain unverified. Source annotations are not certification. |
## 0.1.6 review corrections
- Replaced a single unbounded printer text block with labeled fields and a three-line description preview. Actions appear first; the full description remains in Settings.
- Excluded buttons, checkboxes, editable/selectable text and the inline-details subtree from row double-click handling. Double-clicking text to select a word should not launch an editor.
- Kept wide-dialog size resources scoped to each dialog. Content dimensions follow `XamlRoot.Changed` while open, and the handler is removed on close.
- Reused the displayed printer status for sorting. Job ordering uses human-readable status labels and the same title/printer fallbacks shown in rows.
- Committed job sort arrows and accessible direction labels only after a successful query, avoiding old rows under a new order indicator when the service fails.
These corrections were confirmed by source review. Their build, automated regressions and installed interaction checks are pending for 0.1.6.
## Next installed checks
- Inspect table headers/columns at small and large window sizes; select rows with keyboard, change filters/pages, and confirm full long values remain available.
- Resize an open printer-settings/job-details dialog; double-click selectable expanded text; operate the Shared checkbox; verify those actions do not unexpectedly open a second dialog.
- Confirm Name ascending and Created descending defaults, both sort directions across multiple pages, and a failed query retaining the prior applied indicator. Ensure a status update does not change a job's Created value.
- Save and clear device/printer metadata, restart the service, and verify synchronization without changed IDs or queue names.
- Confirm log viewer reuse, daily-limit feedback, retention settings and diagnostic-only clearing.
- Exercise unavailable-service and action-error feedback, then verify stale polls cannot overwrite newer operations.
@@ -33,4 +45,6 @@ The table, paging, metadata and diagnostic-limit changes below are newer than th
## Historical observations
The early build had a long scrolling setup page, duplicated log viewers, an expanded enrollment form and refresh that rebuilt controls. Later reviews identified missing service controls, actions, startup preference and accent constraints. These observations explain completed changes and are not descriptions of the current source. Installed evidence is scoped above rather than inferred from source review.
The early build had a long scrolling setup page, duplicated log viewers, an expanded enrollment form and refresh that rebuilt controls. Later reviews identified missing service controls, actions, startup preference and accent constraints. These observations explain completed changes and are not descriptions of the current source.
Historically, installed 0.1.4 had a zero-warning source gate and 163 unit/regression tests. Its interactive suite passed ten of eleven checks; log-window discovery timed out and the focused rerun did not execute. The user confirmed its tray icon worked. The later 0.1.5 installed results above supersede that interactive checkpoint for the checks actually rerun, but neither version qualifies the new 0.1.6 interactions.
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Package</OutputType>
<InstallerPlatform>x64</InstallerPlatform>
<ProductVersion Condition="'$(ProductVersion)' == ''">0.1.5</ProductVersion>
<ProductVersion Condition="'$(ProductVersion)' == ''">0.1.6</ProductVersion>
<PayloadRoot Condition="'$(PayloadRoot)' == ''">$(MSBuildThisFileDirectory)..\artifacts\package</PayloadRoot>
<OutputName>QqqPrintAgent-$(ProductVersion)-x64</OutputName>
<DefineConstants>ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot)</DefineConstants>
@@ -31,7 +31,8 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
return;
}
journal.TrackJob(job, inventory.FirstOrDefault(printer => string.Equals(printer.PrinterId, job.PrinterId, StringComparison.Ordinal))?.Name);
PrinterInfo? assignedPrinter = inventory.FirstOrDefault(printer => string.Equals(printer.PrinterId, job.PrinterId, StringComparison.Ordinal));
journal.TrackJob(job, string.IsNullOrWhiteSpace(assignedPrinter?.DisplayName) ? assignedPrinter?.Name : assignedPrinter.DisplayName);
string? state = journal.GetState(job.JobId);
if(state is "SPOOLED" or "SPOOLER_COMPLETED" or "FAILED" or "OUTCOME_UNKNOWN")
+16 -12
View File
@@ -83,17 +83,21 @@ public sealed partial class AgentRuntime
internal static JobPage QueryJobsWithQueue(AgentStore store, JobQuery query, IReadOnlyList<JobSnapshot> queued)
{
// Validate paging/filter bounds before using them against the bounded cached prefix.
JobPage local = store.QueryJobs(query);
JobSnapshot[] prefix = queued.Take(50).DistinctBy(job => job.JobId, StringComparer.Ordinal).Where(job => MatchesQuery(job, query) && !store.HasJob(job.JobId)).ToArray();
int localOffset = Math.Max(0, query.Offset - prefix.Length);
if(localOffset != query.Offset)
_ = AgentStore.ValidateJobQuery(query);
JobSnapshot[] cloud = queued.Take(50).DistinctBy(job => job.JobId, StringComparer.Ordinal)
.Where(job => MatchesQuery(job, query) && !store.HasJob(job.JobId)).ToArray();
int localStart = Math.Max(0, query.Offset - cloud.Length);
int localCount = query.Offset - localStart + query.PageSize;
JobPage first = store.QueryJobs(query with { Offset = localStart, PageSize = Math.Min(localCount, 100) });
List<JobSnapshot> candidates = [.. first.Jobs, .. cloud];
if(localCount > 100)
{
local = store.QueryJobs(query with { Offset = localOffset });
JobPage second = store.QueryJobs(query with { Offset = localStart + 100, PageSize = localCount - 100 });
candidates.AddRange(second.Jobs);
}
var page = prefix.Skip(query.Offset).Take(query.PageSize).ToList();
page.AddRange(local.Jobs.Take(query.PageSize - page.Count));
return new JobPage(page, local.TotalCount + prefix.Length, query.Offset, query.PageSize, queued.Count >= 50);
candidates.Sort((left, right) => JobSorting.Compare(left, right, query));
JobSnapshot[] page = candidates.Skip(query.Offset - localStart).Take(query.PageSize).ToArray();
return new JobPage(page, first.TotalCount + cloud.Length, query.Offset, query.PageSize, queued.Count >= 50);
}
private static bool MatchesQuery(JobSnapshot job, JobQuery query)
@@ -111,8 +115,8 @@ public sealed partial class AgentRuntime
return false;
}
string? search = query.Search?.Trim();
return string.IsNullOrEmpty(search) || job.JobId.StartsWith(search, StringComparison.OrdinalIgnoreCase)
|| job.Title?.StartsWith(search, StringComparison.OrdinalIgnoreCase) == true
|| job.PrinterName?.StartsWith(search, StringComparison.OrdinalIgnoreCase) == true;
return string.IsNullOrEmpty(search) || JobSorting.MatchesPrefix(job.JobId, search)
|| JobSorting.MatchesPrefix(job.Title, search)
|| JobSorting.MatchesPrefix(job.PrinterName, search);
}
}
+6 -1
View File
@@ -105,7 +105,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
{
if(queueGeneration == _queueGeneration)
{
_queuedJobs = queued.Select(job => new JobSnapshot(job.JobId, "QUEUED", job.CreatedAt, "Waiting on the server; not yet received by this device.", null, job.PrinterId, null, job.Title, job.Format)).ToArray();
_queuedJobs = queued.Select(job => new JobSnapshot(job.JobId, "QUEUED", job.CreatedAt, "Waiting on the server; not yet received by this device.", null, job.PrinterId, _queuedJobs.FirstOrDefault(previous => string.Equals(previous.PrinterId, job.PrinterId, StringComparison.Ordinal))?.PrinterName, job.Title, job.Format, CreatedAt: JobSorting.NormalizeCreatedAt(job.CreatedAt))).ToArray();
}
}
ExposedPrinterBackend exposed = new(new ProfiledPrinterBackend(_printers, store), store.GetExposedPrinterIds);
@@ -154,6 +154,11 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
int attentionCount = store.NeedsAttentionCount();
lock(_stateLock)
{
_queuedJobs = _queuedJobs.Select(job =>
{
PrinterSnapshot? printer = printers.FirstOrDefault(item => string.Equals(item.PrinterId, job.PrinterId, StringComparison.Ordinal));
return printer is null ? job : job with { PrinterName = string.IsNullOrWhiteSpace(printer.DisplayName) ? printer.Name : printer.DisplayName };
}).ToArray();
jobs = jobs.Concat(_queuedJobs.Where(queued => !jobs.Any(local => string.Equals(local.JobId, queued.JobId, StringComparison.Ordinal))))
.Select(job => job.PrinterName is null ? job with { PrinterName = printers.FirstOrDefault(printer => string.Equals(printer.PrinterId, job.PrinterId, StringComparison.Ordinal))?.Name } : job).ToArray();
bool connected = configuration is not null && _failure is null && _heartbeatFailure is null && _lastSuccessfulPoll is not null && DateTimeOffset.UtcNow - _lastSuccessfulPoll.Value < TimeSpan.FromSeconds(30);
@@ -0,0 +1,31 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentStore
{
private void MigrateJobCreationTime()
{
using SqliteTransaction transaction = _database.BeginTransaction();
using SqliteCommand command = _database.CreateCommand();
command.Transaction = transaction;
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('local_jobs') WHERE name='created_at'";
if(Convert.ToInt64(command.ExecuteScalar(), CultureInfo.InvariantCulture) == 0)
{
command.CommandText = """
ALTER TABLE local_jobs ADD COLUMN created_at TEXT;
UPDATE local_jobs SET created_at=COALESCE(
(SELECT recorded_at FROM events WHERE events.connection_id=local_jobs.connection_id
AND events.job_id=local_jobs.job_id AND recorded_at IS NOT NULL ORDER BY sequence LIMIT 1),updated_at);
""";
command.ExecuteNonQuery();
}
foreach(string field in JobSorting.Fields)
{
command.CommandText = "CREATE INDEX IF NOT EXISTS jobs_display_" + field + " ON local_jobs(connection_id," + JobSorting.SqlColumn(field) + " COLLATE NOCASE DESC,job_id DESC)";
command.ExecuteNonQuery();
}
transaction.Commit();
}
}
+16 -10
View File
@@ -5,7 +5,7 @@ namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentStore
{
private const string JobColumns = "job_id,state,updated_at,last_event_json,printer_id,printer_name,title,format,failure_acknowledged,history_pruned";
private const string JobColumns = "job_id,state,updated_at,last_event_json,printer_id,printer_name,title,format,failure_acknowledged,history_pruned,created_at";
public bool HasJob(string jobId)
{
@@ -36,14 +36,7 @@ public sealed partial class AgentStore
public JobPage QueryJobs(JobQuery query)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentOutOfRangeException.ThrowIfNegative(query.Offset, nameof(query));
ArgumentOutOfRangeException.ThrowIfLessThan(query.PageSize, 1, nameof(query));
ArgumentOutOfRangeException.ThrowIfGreaterThan(query.PageSize, 100, nameof(query));
if(query.Search?.Length > 256 || query.Status?.Length > 32 || query.PrinterId?.Length > 256)
{
throw new ArgumentException("Job filter is too long.", nameof(query));
}
string order = ValidateJobQuery(query);
using SqliteTransaction transaction = _database.BeginTransaction(deferred: true);
using SqliteCommand command = _database.CreateCommand();
command.Transaction = transaction;
@@ -76,7 +69,7 @@ public sealed partial class AgentStore
}
command.CommandText = "SELECT COUNT(*) FROM local_jobs WHERE " + where;
int count = Convert.ToInt32(command.ExecuteScalar(), CultureInfo.InvariantCulture);
command.CommandText = "SELECT " + JobColumns + " FROM local_jobs WHERE " + where + " ORDER BY updated_at DESC,job_id DESC LIMIT $limit OFFSET $offset";
command.CommandText = "SELECT " + JobColumns + " FROM local_jobs WHERE " + where + " ORDER BY " + order + " LIMIT $limit OFFSET $offset";
command.Parameters.AddWithValue("$limit", query.PageSize);
command.Parameters.AddWithValue("$offset", query.Offset);
List<JobSnapshot> jobs = [];
@@ -91,6 +84,19 @@ public sealed partial class AgentStore
return new JobPage(jobs, count, query.Offset, query.PageSize);
}
internal static string ValidateJobQuery(JobQuery query)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentOutOfRangeException.ThrowIfNegative(query.Offset, nameof(query));
ArgumentOutOfRangeException.ThrowIfLessThan(query.PageSize, 1, nameof(query));
ArgumentOutOfRangeException.ThrowIfGreaterThan(query.PageSize, 100, nameof(query));
if(query.Search?.Length > 256 || query.Status?.Length > 32 || query.PrinterId?.Length > 256)
{
throw new ArgumentException("Job filter is too long.", nameof(query));
}
return JobSorting.SqlOrder(query);
}
/// <summary>One bounded maintenance batch; only delivered, reviewed terminal detail is removed.</summary>
public int PruneHistory(int retainDays = 30, int maxDetailedJobs = 300000)
{
+7 -5
View File
@@ -77,6 +77,7 @@ public sealed partial class AgentStore : IJobJournal, IDisposable
migration.Commit();
command.Transaction = null;
CreateJobIndexes();
MigrateJobCreationTime();
}
}
@@ -134,7 +135,7 @@ public sealed partial class AgentStore : IJobJournal, IDisposable
using SqliteTransaction transaction = _database.BeginTransaction();
using SqliteCommand command = _database.CreateCommand();
command.Transaction = transaction;
command.CommandText = "INSERT INTO events(job_id,event_id,state,json,recorded_at,connection_id) VALUES($job,$event,$state,$json,$time,$scope); INSERT INTO local_jobs(connection_id,job_id,state,last_event_json,updated_at) VALUES($scope,$job,$state,$json,$time) ON CONFLICT(connection_id,job_id) DO UPDATE SET state=$state,last_event_json=$json,updated_at=$time,failure_acknowledged=0,history_pruned=0;";
command.CommandText = "INSERT INTO events(job_id,event_id,state,json,recorded_at,connection_id) VALUES($job,$event,$state,$json,$time,$scope); INSERT INTO local_jobs(connection_id,job_id,state,last_event_json,updated_at,created_at) VALUES($scope,$job,$state,$json,$time,$time) ON CONFLICT(connection_id,job_id) DO UPDATE SET state=$state,last_event_json=$json,updated_at=$time,created_at=COALESCE(local_jobs.created_at,$time),failure_acknowledged=0,history_pruned=0;";
command.Parameters.AddWithValue("$job", jobId);
command.Parameters.AddWithValue("$event", printEvent.EventId);
command.Parameters.AddWithValue("$state", printEvent.Status);
@@ -148,13 +149,14 @@ public sealed partial class AgentStore : IJobJournal, IDisposable
public void TrackJob(PrintJob job, string? printerName)
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "INSERT INTO local_jobs(connection_id,job_id,printer_id,printer_name,title,format) VALUES($scope,$job,$printer,$name,$title,$format) ON CONFLICT(connection_id,job_id) DO UPDATE SET printer_id=$printer,printer_name=$name,title=$title,format=$format;";
command.CommandText = "INSERT INTO local_jobs(connection_id,job_id,printer_id,printer_name,title,format,created_at) VALUES($scope,$job,$printer,$name,$title,$format,$created) ON CONFLICT(connection_id,job_id) DO UPDATE SET printer_id=$printer,printer_name=$name,title=$title,format=$format,created_at=COALESCE(local_jobs.created_at,$created);";
command.Parameters.AddWithValue("$scope", _connectionScope);
command.Parameters.AddWithValue("$job", job.JobId);
command.Parameters.AddWithValue("$printer", job.PrinterId);
command.Parameters.AddWithValue("$name", (object?)Bounded(printerName) ?? DBNull.Value);
command.Parameters.AddWithValue("$title", (object?)Bounded(job.Title) ?? DBNull.Value);
command.Parameters.AddWithValue("$format", job.Format);
command.Parameters.AddWithValue("$created", JobSorting.NormalizeCreatedAt(job.CreatedAt) ?? DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
@@ -221,7 +223,7 @@ public sealed partial class AgentStore : IJobJournal, IDisposable
public IReadOnlyList<JobSnapshot> RecentJobs()
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "SELECT job_id,state,updated_at,last_event_json,printer_id,printer_name,title,format,failure_acknowledged,history_pruned FROM local_jobs WHERE connection_id=$scope ORDER BY CASE WHEN state IN ('FAILED','OUTCOME_UNKNOWN') AND failure_acknowledged=0 THEN 0 ELSE 1 END,updated_at DESC,job_id DESC LIMIT 50";
command.CommandText = "SELECT " + JobColumns + " FROM local_jobs WHERE connection_id=$scope ORDER BY CASE WHEN state IN ('FAILED','OUTCOME_UNKNOWN') AND failure_acknowledged=0 THEN 0 ELSE 1 END,updated_at DESC,job_id DESC LIMIT 50";
command.Parameters.AddWithValue("$scope", _connectionScope);
using SqliteDataReader reader = command.ExecuteReader();
List<JobSnapshot> jobs = [];
@@ -235,7 +237,7 @@ public sealed partial class AgentStore : IJobJournal, IDisposable
public JobDetailsSnapshot? GetJobDetails(string jobId)
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "SELECT job_id,state,updated_at,last_event_json,printer_id,printer_name,title,format,failure_acknowledged,history_pruned FROM local_jobs WHERE connection_id=$scope AND job_id=$job";
command.CommandText = "SELECT " + JobColumns + " FROM local_jobs WHERE connection_id=$scope AND job_id=$job";
command.Parameters.AddWithValue("$scope", _connectionScope);
command.Parameters.AddWithValue("$job", jobId);
JobSnapshot job;
@@ -286,7 +288,7 @@ public sealed partial class AgentStore : IJobJournal, IDisposable
{
PrintEvent? item = reader.IsDBNull(3) ? null : JsonSerializer.Deserialize<PrintEvent>(reader.GetString(3), PrintApiClient.JsonOptions);
return new JobSnapshot(reader.GetString(0), reader.IsDBNull(1) ? "CLAIMED" : reader.GetString(1), reader.IsDBNull(2) ? "Earlier session" : reader.GetString(2), item?.Message, item?.SpoolerJobId,
NullableString(reader, 4), NullableString(reader, 5), NullableString(reader, 6), NullableString(reader, 7), reader.GetInt32(8) != 0);
NullableString(reader, 4), NullableString(reader, 5), NullableString(reader, 6), NullableString(reader, 7), reader.GetInt32(8) != 0, NullableString(reader, 10));
}
private static string? NullableString(SqliteDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
+26
View File
@@ -0,0 +1,26 @@
namespace Qqq.PrintAgent.Windows;
/// <summary>Shared visible job labels used by the companion and server-side local sorting.</summary>
public static class JobLabels
{
public static string Status(string state, bool reviewed = false)
{
string label = state switch
{
"READY" => "Ready",
"OFFLINE" => "Offline",
"PAUSED" => "Paused",
"ERROR" => "Needs attention",
"QUEUED" => "Queued",
"CLAIMED" => "Assigned to this computer",
"RECEIVED" => "Received",
"SUBMITTING" => "Sending to printer",
"SPOOLED" => "Sent to printer",
"SPOOLER_COMPLETED" => "Windows completed the job",
"FAILED" => "Failed",
"OUTCOME_UNKNOWN" => "Check printer before retrying",
_ => "Status unavailable",
};
return label + (reviewed ? " · Reviewed" : "");
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ namespace Qqq.PrintAgent.Windows;
/// <summary>Queries durable local history; search matches the start of a job ID, title or printer name.</summary>
public sealed record JobQuery(string? Search = null, string? Status = null, string? PrinterId = null,
bool NeedsAttentionOnly = false, int Offset = 0, int PageSize = 50);
bool NeedsAttentionOnly = false, int Offset = 0, int PageSize = 50, string SortBy = "createdAt", bool Descending = true);
+1 -1
View File
@@ -7,5 +7,5 @@ namespace Qqq.PrintAgent.Windows;
/// <summary>Most recent local observation and bounded operator metadata; no document bytes.</summary>
public sealed record JobSnapshot(string JobId, string Status, string UpdatedAt, string? Message, string? SpoolerJobId,
string? PrinterId = null, string? PrinterName = null, string? Title = null, string? Format = null, bool FailureAcknowledged = false);
string? PrinterId = null, string? PrinterName = null, string? Title = null, string? Format = null, bool FailureAcknowledged = false, string? CreatedAt = null);
+102
View File
@@ -0,0 +1,102 @@
using System.Globalization;
using System.Text;
namespace Qqq.PrintAgent.Windows;
/// <summary>One whitelisted order for SQLite pages and the bounded in-memory cloud queue merge.</summary>
internal static class JobSorting
{
internal static readonly string[] Fields = ["createdAt", "title", "printerName", "status", "format"];
public static string SqlOrder(JobQuery query)
{
string direction = query.Descending ? " DESC" : " ASC";
return SqlColumn(query.SortBy) + " COLLATE NOCASE" + direction + ",job_id COLLATE BINARY" + direction;
}
internal static string SqlColumn(string field) => field switch
{
"createdAt" => "created_at",
"title" => "COALESCE(title,job_id)",
"printerName" => "COALESCE(printer_name,printer_id,'Not recorded')",
"status" => "(CASE COALESCE(state,'CLAIMED') WHEN 'READY' THEN 'Ready' WHEN 'OFFLINE' THEN 'Offline' WHEN 'PAUSED' THEN 'Paused' WHEN 'ERROR' THEN 'Needs attention' WHEN 'QUEUED' THEN 'Queued' WHEN 'CLAIMED' THEN 'Assigned to this computer' WHEN 'RECEIVED' THEN 'Received' WHEN 'SUBMITTING' THEN 'Sending to printer' WHEN 'SPOOLED' THEN 'Sent to printer' WHEN 'SPOOLER_COMPLETED' THEN 'Windows completed the job' WHEN 'FAILED' THEN 'Failed' WHEN 'OUTCOME_UNKNOWN' THEN 'Check printer before retrying' ELSE 'Status unavailable' END || CASE WHEN failure_acknowledged<>0 THEN ' · Reviewed' ELSE '' END)",
"format" => "COALESCE(format,'—')",
_ => throw new ArgumentException("Unknown job sort field.", nameof(field)),
};
public static int Compare(JobSnapshot left, JobSnapshot right, JobQuery query)
{
int comparison = CompareText(Value(left, query.SortBy), Value(right, query.SortBy), noCase: true);
if(comparison == 0)
{
comparison = CompareText(left.JobId, right.JobId, noCase: false);
}
return query.Descending ? -comparison : comparison;
}
public static string? NormalizeCreatedAt(string? value)
=> DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTimeOffset instant)
? instant.ToString("O", CultureInfo.InvariantCulture) : null;
public static bool MatchesPrefix(string? value, string prefix)
{
if(value is null)
{
return false;
}
byte[] text = Encoding.UTF8.GetBytes(value);
byte[] search = Encoding.UTF8.GetBytes(prefix);
if(text.Length < search.Length)
{
return false;
}
for(int index = 0; index < search.Length; index++)
{
if(FoldAscii(text[index]) != FoldAscii(search[index]))
{
return false;
}
}
return true;
}
private static string? Value(JobSnapshot job, string sortBy) => sortBy switch
{
"createdAt" => job.CreatedAt,
"title" => job.Title ?? job.JobId,
"printerName" => job.PrinterName ?? job.PrinterId ?? "Not recorded",
"status" => JobLabels.Status(job.Status, job.FailureAcknowledged),
"format" => job.Format ?? "—",
_ => throw new ArgumentException("Unknown job sort field.", nameof(sortBy)),
};
// SQLite's built-in NOCASE folds ASCII only and compares UTF-8 bytes. .NET's
// OrdinalIgnoreCase folds additional Unicode characters and orders UTF-16 instead.
private static int CompareText(string? left, string? right, bool noCase)
{
if(left is null || right is null)
{
return left is null ? right is null ? 0 : -1 : 1;
}
byte[] leftBytes = Encoding.UTF8.GetBytes(left);
byte[] rightBytes = Encoding.UTF8.GetBytes(right);
int count = Math.Min(leftBytes.Length, rightBytes.Length);
for(int index = 0; index < count; index++)
{
byte first = noCase ? FoldAscii(leftBytes[index]) : leftBytes[index];
byte second = noCase ? FoldAscii(rightBytes[index]) : rightBytes[index];
int comparison = first.CompareTo(second);
if(comparison != 0)
{
return comparison;
}
if(noCase && first == 0)
{
break;
}
}
return leftBytes.Length.CompareTo(rightBytes.Length);
}
private static byte FoldAscii(byte value) => value is >= (byte)'A' and <= (byte)'Z' ? (byte)(value + 32) : value;
}
+96
View File
@@ -0,0 +1,96 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent;
/// <summary>Readable, bounded job summary and event table; all diagnostic values remain selectable.</summary>
internal sealed class JobDetailsView : StackPanel
{
internal JobDetailsView(JobDetailsSnapshot details, Func<string, string> state, Func<string?, string> time)
{
Spacing = 20;
JobSnapshot job = details.Job;
Children.Add(Summary(job, state, time));
Children.Add(Text("Event history", heading: true));
Children.Add(Text("The service returns up to 200 retained events. Sent to printer means Windows accepted the job, not confirmed physical output."));
Children.Add(History(details.Events, state, time));
if(details.HistoryPruned)
{
Children.Add(Text("Older event detail has been removed by the retention policy. The job summary remains available."));
}
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(this, "JobDetailsContent");
}
private static StackPanel Summary(JobSnapshot job, Func<string, string> state, Func<string?, string> time)
{
StackPanel summary = new() { Spacing = 12 };
AddField(summary, "Document", job.Title ?? "Not recorded");
AddField(summary, "Printer", job.PrinterName ?? job.PrinterId ?? "Not recorded");
AddField(summary, "Status", state(job.Status) + (job.FailureAcknowledged ? " · Reviewed" : ""));
AddField(summary, "Created", job.CreatedAt is null ? "Not recorded" : time(job.CreatedAt));
AddField(summary, "Last update", time(job.UpdatedAt));
AddField(summary, "Outcome", string.IsNullOrWhiteSpace(job.Message) ? "No additional outcome message recorded." : job.Message);
AddField(summary, "Format", job.Format ?? "Not recorded");
AddField(summary, "Job ID", job.JobId);
AddField(summary, "Windows job", job.SpoolerJobId ?? "Not assigned");
return summary;
}
private static void AddField(Panel parent, string label, string value)
{
Grid field = new() { ColumnSpacing = 16 };
field.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(112) });
field.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
TextBlock title = Text(label, heading: true);
TextBlock content = Text(value);
Grid.SetColumn(content, 1);
field.Children.Add(title);
field.Children.Add(content);
parent.Children.Add(field);
}
private static StackPanel History(IReadOnlyList<JobEventSnapshot> events, Func<string, string> state, Func<string?, string> time)
{
StackPanel history = new() { Spacing = 12 };
history.Children.Add(EventRow("Time", "Status", "Message and delivery", heading: true));
foreach(JobEventSnapshot entry in events)
{
string message = $"{entry.Message ?? "No message"}\n{(entry.Delivered ? "Reported to server" : "Waiting to report")}\nEvent: {entry.EventId}";
if(entry.SpoolerJobId is not null)
{
message += $"\nWindows job: {entry.SpoolerJobId}";
}
history.Children.Add(EventRow(time(entry.RecordedAt), state(entry.Status), message));
}
if(events.Count == 0)
{
history.Children.Add(Text("No retained local events are available for this job."));
}
return history;
}
private static Grid EventRow(string timestamp, string status, string message, bool heading = false)
{
Grid row = new() { ColumnSpacing = 16 };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(140) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(132) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
string[] values = [timestamp, status, message];
for(int index = 0; index < values.Length; index++)
{
TextBlock cell = Text(values[index], heading);
Grid.SetColumn(cell, index);
row.Children.Add(cell);
}
return row;
}
private static TextBlock Text(string text, bool heading = false) => new()
{
Text = text,
TextWrapping = TextWrapping.Wrap,
IsTextSelectionEnabled = true,
FontWeight = heading ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal,
};
}
+86
View File
@@ -0,0 +1,86 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
namespace Qqq.PrintAgent;
public sealed partial class MainWindow
{
private ContentDialog CreateWideDialog(string title, UIElement body)
{
XamlRoot root = Content.XamlRoot;
ScrollViewer scroll = new() { Content = body, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
var dialog = new ContentDialog
{
Title = title,
CloseButtonText = "Close",
XamlRoot = Content.XamlRoot,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
Content = scroll,
};
dialog.Resources["ContentDialogMinWidth"] = 0d;
dialog.Resources["ContentDialogMaxWidth"] = 900d;
void Resize(XamlRoot sender, XamlRootChangedEventArgs args) => ResizeWideDialog(scroll, root);
dialog.Opened += (sender, args) =>
{
ResizeWideDialog(scroll, root);
root.Changed += Resize;
};
dialog.Closed += (sender, args) => root.Changed -= Resize;
ResizeWideDialog(scroll, root);
return dialog;
}
private static void ResizeWideDialog(ScrollViewer scroll, XamlRoot root)
{
scroll.Width = Math.Max(180, Math.Min(900, root.Size.Width - 48) - 48);
scroll.MaxHeight = Math.Min(640, Math.Max(180, root.Size.Height - 200));
}
private async void Printers_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
{
PrinterRowView? row = FindDoubleTappedRow<PrinterRowView>(e.OriginalSource);
if(_busy || row?.Snapshot is not { } printer)
{
return;
}
e.Handled = true;
PrintersList.SelectedItem = row;
await EditPrinterAsync(printer);
}
private async void Jobs_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
{
JobRowView? row = FindDoubleTappedRow<JobRowView>(e.OriginalSource);
if(_busy || row is null)
{
return;
}
e.Handled = true;
JobsList.SelectedItem = row;
await JobDetailsAsync(row.Snapshot);
}
private static T? FindDoubleTappedRow<T>(object source) where T : FrameworkElement
{
for(var current = source as DependencyObject; current is not null; current = VisualTreeHelper.GetParent(current))
{
if(current is ButtonBase or TextBox or PasswordBox or TextBlock { IsTextSelectionEnabled: true }
or FrameworkElement { Tag: "PrinterInlineDetails" })
{
return null;
}
if(current is T row)
{
return row;
}
if(current is ListViewItem item && item.Content is T content)
{
return content;
}
}
return null;
}
}
+4 -10
View File
@@ -44,7 +44,7 @@ public sealed partial class MainWindow
string filter = (JobStatusFilter.SelectedItem as ComboBoxItem)?.Tag as string ?? "ALL";
int mutationVersion = _mutationVersion;
var query = new JobQuery(Search: JobSearch.Text.Trim(), Status: filter is "ALL" or "ATTENTION" ? null : filter,
NeedsAttentionOnly: string.Equals(filter, "ATTENTION", StringComparison.Ordinal), Offset: _jobOffset, PageSize: JobPageSize);
NeedsAttentionOnly: string.Equals(filter, "ATTENTION", StringComparison.Ordinal), Offset: _jobOffset, PageSize: JobPageSize, SortBy: _jobSortBy, Descending: _jobSortDescending);
JobsPrevious.IsEnabled = false;
JobsNext.IsEnabled = false;
try
@@ -63,6 +63,7 @@ public sealed partial class MainWindow
_jobOffset = page.Offset;
_jobTotal = page.TotalCount;
RenderJobPage(page);
UpdateJobSortHeaders(query.SortBy, query.Descending);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException or JsonException)
{
@@ -99,7 +100,7 @@ public sealed partial class MainWindow
_jobRows.Add(job.JobId, row);
JobsList.Items.Insert(index, row);
}
row.Update(job, FriendlyState(job.Status), FormatTime(job.UpdatedAt));
row.Update(job, FriendlyState(job.Status), job.CreatedAt is null ? "Not recorded" : FormatTime(job.CreatedAt));
if(JobsList.Items.IndexOf(row) != index)
{
JobsList.Items.Remove(row);
@@ -209,14 +210,7 @@ public sealed partial class MainWindow
private async Task ShowJobDetailsAsync(JobDetailsSnapshot details)
{
JobSnapshot job = details.Job;
string history = string.Join("\n\n", details.Events.Select(entry => $"{FormatTime(entry.RecordedAt)} · {FriendlyState(entry.Status)}\n{entry.Message}\n{(entry.Delivered ? "Reported to the server" : "Waiting to report to the server")}"));
string text = $"Document: {job.Title ?? "Not recorded"}\nPrinter: {job.PrinterName ?? job.PrinterId ?? "Not recorded"}\nFormat: {job.Format ?? "Not recorded"}\nJob ID: {job.JobId}\nWindows job: {job.SpoolerJobId ?? "Not assigned"}\n\nHistory\n{history}";
if(details.HistoryPruned)
{
text += "\n\nOlder history has been removed by the retention policy.";
}
var dialog = new ContentDialog { Title = "Print job details", CloseButtonText = "Close", XamlRoot = Content.XamlRoot, Content = new ScrollViewer { MaxHeight = 480, Content = new TextBlock { Text = text, TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true } } };
ContentDialog dialog = CreateWideDialog("Print job details", new JobDetailsView(details, FriendlyState, FormatTime));
await KofTwentyTwo.AppKit.WinUI.DialogGuard.ShowAsync(dialog);
}
+10 -29
View File
@@ -33,7 +33,10 @@ public sealed partial class MainWindow
{
return false;
}
var dialog = new ContentDialog { Title = title, Content = new ScrollViewer { Content = editor, MaxHeight = 420 }, PrimaryButtonText = "Save", CloseButtonText = "Cancel", DefaultButton = ContentDialogButton.Primary, XamlRoot = Content.XamlRoot };
ContentDialog dialog = CreateWideDialog(title, editor);
dialog.PrimaryButtonText = "Save";
dialog.CloseButtonText = "Cancel";
dialog.DefaultButton = ContentDialogButton.Primary;
return await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary;
}
@@ -41,46 +44,24 @@ public sealed partial class MainWindow
private void UpdateSelectedPrinter()
{
PrinterSnapshot? printer = (PrintersList.SelectedItem as PrinterRowView)?.Snapshot;
PrinterDetailsButton.IsEnabled = printer is not null && !_busy;
PrinterEditButton.IsEnabled = printer is not null && !_busy;
PrinterTestButton.IsEnabled = !_busy && printer is { Exposed: true } && printer.Formats.Length > 0;
SelectedPrinterText.Text = printer is null ? "Select a printer to view or edit its details." : $"{PrinterLabel(printer)}\nWindows queue: {printer.Name}";
ToolTipService.SetToolTip(SelectedPrinterText, SelectedPrinterText.Text);
}
private async void PrinterTest_Click(object sender, RoutedEventArgs e)
{
if((PrintersList.SelectedItem as PrinterRowView)?.Snapshot is PrinterSnapshot printer)
var selected = PrintersList.SelectedItem as PrinterRowView;
foreach(PrinterRowView row in _printerRows.Values)
{
await TestPrintAsync(printer);
row.SetSelected(ReferenceEquals(row, selected));
row.SetBusy(_busy);
}
}
private async void PrinterEdit_Click(object sender, RoutedEventArgs e)
private async Task EditPrinterAsync(PrinterSnapshot printer)
{
if((PrintersList.SelectedItem as PrinterRowView)?.Snapshot is not PrinterSnapshot printer)
{
return;
}
var editor = new ProfileEditor(printer.DisplayName, printer.Description, printer.Location);
editor.NameBox.PlaceholderText = printer.Name;
editor.AddPrinterContext(printer.Name, FriendlyState(printer.Status));
if(await EditProfileAsync("Edit printer details", editor))
{
await RunActionAsync(new PipeRequest("set-printer-profile", PrinterId: printer.PrinterId, PrinterProfile: new PrinterProfile(editor.NameBox.Text, editor.DescriptionBox.Text, editor.LocationBox.Text)));
}
}
private async void PrinterDetails_Click(object sender, RoutedEventArgs e)
{
if(_busy || (PrintersList.SelectedItem as PrinterRowView)?.Snapshot is not PrinterSnapshot printer)
{
return;
}
string text = $"Name: {PrinterLabel(printer)}\nWindows queue: {printer.Name}\nLocation: {printer.Location}\nDescription: {printer.Description}\n\nStatus: {FriendlyState(printer.Status)}\nFormats: {string.Join(", ", printer.Formats)}\nDriver: {printer.DriverName}\nPort: {printer.PortName}\nPrinter ID: {printer.PrinterId}";
var dialog = new ContentDialog { Title = "Printer details", CloseButtonText = "Close", XamlRoot = Content.XamlRoot, Content = new ScrollViewer { MaxHeight = 480, Content = new TextBlock { Text = text, TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true } } };
await DialogGuard.ShowAsync(dialog);
}
private static string PrinterLabel(PrinterSnapshot printer) => string.IsNullOrWhiteSpace(printer.DisplayName) ? printer.Name : printer.DisplayName;
}
+104
View File
@@ -0,0 +1,104 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
namespace Qqq.PrintAgent;
public sealed partial class MainWindow
{
private string _printerSortBy = "name";
private bool _printerSortDescending;
private string _jobSortBy = "createdAt";
private bool _jobSortDescending = true;
private void PrinterSort_Click(object sender, RoutedEventArgs e)
{
if(sender is not Button { Tag: string key })
{
return;
}
_printerSortDescending = string.Equals(_printerSortBy, key, StringComparison.Ordinal) && !_printerSortDescending;
_printerSortBy = key;
UpdatePrinterSortHeaders();
SortPrinterRows();
}
private async void JobSort_Click(object sender, RoutedEventArgs e)
{
if(sender is not Button { Tag: string key })
{
return;
}
_jobSortDescending = string.Equals(_jobSortBy, key, StringComparison.Ordinal) ? !_jobSortDescending : string.Equals(key, "createdAt", StringComparison.Ordinal);
_jobSortBy = key;
_jobOffset = 0;
await RefreshJobPageAsync();
}
private void UpdatePrinterSortHeaders()
{
UpdateSortHeader(PrinterNameHeader, "Name", _printerSortBy, _printerSortDescending);
UpdateSortHeader(PrinterStatusHeader, "Status", _printerSortBy, _printerSortDescending);
UpdateSortHeader(PrinterLocationHeader, "Location", _printerSortBy, _printerSortDescending);
UpdateSortHeader(PrinterFormatsHeader, "Formats", _printerSortBy, _printerSortDescending);
UpdateSortHeader(PrinterSharedHeader, "Shared", _printerSortBy, _printerSortDescending);
}
private void UpdateJobSortHeaders(string sortBy, bool descending)
{
UpdateSortHeader(JobTitleHeader, "Document", sortBy, descending);
UpdateSortHeader(JobPrinterHeader, "Printer", sortBy, descending);
UpdateSortHeader(JobStatusHeader, "Status", sortBy, descending);
UpdateSortHeader(JobFormatHeader, "Format", sortBy, descending);
UpdateSortHeader(JobCreatedHeader, "Created", sortBy, descending);
}
private static void UpdateSortHeader(Button header, string label, string sortBy, bool descending)
{
bool active = string.Equals(header.Tag as string, sortBy, StringComparison.Ordinal);
header.Content = label + (active ? descending ? " ↓" : " ↑" : "");
AutomationProperties.SetName(header, $"Sort by {label.ToLowerInvariant()}{(active ? descending ? ", descending" : ", ascending" : "")}");
}
private void SortPrinterRows()
{
var selected = PrintersList.SelectedItem as PrinterRowView;
var focused = FocusManager.GetFocusedElement(Content.XamlRoot) as Control;
PrinterRowView[] sorted = _printerRows.Values.OrderBy(row => row, Comparer<PrinterRowView>.Create(ComparePrinters)).ToArray();
for(int index = 0; index < sorted.Length; index++)
{
if(PrintersList.Items.IndexOf(sorted[index]) != index)
{
PrintersList.Items.Remove(sorted[index]);
PrintersList.Items.Insert(index, sorted[index]);
}
}
if(selected is not null && !ReferenceEquals(selected, PrintersList.SelectedItem))
{
PrintersList.SelectedItem = selected;
}
if(focused?.XamlRoot is not null && !ReferenceEquals(focused, FocusManager.GetFocusedElement(Content.XamlRoot)))
{
_ = focused.Focus(FocusState.Programmatic);
}
}
private int ComparePrinters(PrinterRowView left, PrinterRowView right)
{
if(left.Snapshot is not { } first || right.Snapshot is not { } second)
{
return 0;
}
StringComparer text = StringComparer.CurrentCultureIgnoreCase;
int comparison = _printerSortBy switch
{
"status" => text.Compare(PrinterRowView.StatusLabel(first), PrinterRowView.StatusLabel(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),
_ => text.Compare(PrinterLabel(first), PrinterLabel(second)),
};
return comparison == 0 ? StringComparer.Ordinal.Compare(first.PrinterId, second.PrinterId) : _printerSortDescending ? -comparison : comparison;
}
}
+6 -5
View File
@@ -7,6 +7,7 @@
<KeyboardAccelerator Key="L" Modifiers="Control,Shift" Invoked="LogsAccelerator_Invoked" />
</Grid.KeyboardAccelerators>
<Grid.Resources>
<Style x:Key="TableHeaderButton" TargetType="Button"><Setter Property="Padding" Value="0,6" /><Setter Property="Background" Value="Transparent" /><Setter Property="BorderThickness" Value="0" /><Setter Property="HorizontalAlignment" Value="Stretch" /><Setter Property="HorizontalContentAlignment" Value="Left" /></Style>
<Style x:Key="FullWidthListItem" TargetType="ListViewItem"><Setter Property="HorizontalContentAlignment" Value="Stretch" /><Setter Property="Padding" Value="8,0" /></Style>
</Grid.Resources>
<Grid.RowDefinitions><RowDefinition Height="Auto" /><RowDefinition Height="*" /><RowDefinition Height="Auto" /></Grid.RowDefinitions>
@@ -50,16 +51,16 @@
<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><TextBlock Text="Name" FontWeight="SemiBold" /><TextBlock Grid.Column="1" Text="Status" FontWeight="SemiBold" /><TextBlock Grid.Column="2" Text="Location" FontWeight="SemiBold" /><TextBlock Grid.Column="3" Text="Formats" FontWeight="SemiBold" /><TextBlock Grid.Column="4" Text="Shared" FontWeight="SemiBold" /></Grid>
<ListView x:Name="PrintersList" Grid.Row="2" ItemContainerStyle="{StaticResource FullWidthListItem}" SelectionMode="Single" SelectionChanged="PrinterSelection_Changed" HorizontalContentAlignment="Stretch" AutomationProperties.AutomationId="PrinterInventory" />
<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" 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>
<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>
<StackPanel Grid.Row="3" Spacing="8"><TextBlock x:Name="SelectedPrinterText" Text="Select a printer to view or edit its details." TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" /><StackPanel Orientation="Horizontal" Spacing="8"><Button x:Name="PrinterDetailsButton" Content="Details" Click="PrinterDetails_Click" IsEnabled="False" AutomationProperties.AutomationId="PrinterDetails" /><Button x:Name="PrinterEditButton" Content="Edit details" Click="PrinterEdit_Click" IsEnabled="False" AutomationProperties.AutomationId="EditPrinterProfile" /><Button x:Name="PrinterTestButton" Content="Print test" Click="PrinterTest_Click" IsEnabled="False" AutomationProperties.AutomationId="PrintTest" /></StackPanel><TextBlock Text="Shared printers also need approval on the server before jobs can be sent." TextWrapping="Wrap" FontSize="12" /></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" />
</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>
<Grid ColumnSpacing="12"><Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions><TextBox x:Name="JobSearch" MaxLength="256" AutomationProperties.AutomationId="JobSearch" PlaceholderText="Search the start of a job ID, title or printer name" AutomationProperties.Name="Search print history by prefix" TextChanged="JobFilters_Changed" /><ComboBox x:Name="JobStatusFilter" AutomationProperties.AutomationId="JobStatusFilter" Grid.Column="1" MinWidth="160" SelectedIndex="0" SelectionChanged="JobFilters_Changed" AutomationProperties.Name="Filter jobs by status"><ComboBoxItem Content="All states" Tag="ALL" /><ComboBoxItem Content="Needs attention" Tag="ATTENTION" /><ComboBoxItem Content="Queued" Tag="QUEUED" /><ComboBoxItem Content="Received" Tag="RECEIVED" /><ComboBoxItem Content="Sending" Tag="SUBMITTING" /><ComboBoxItem Content="Sent to printer" Tag="SPOOLED" /><ComboBoxItem Content="Failed" Tag="FAILED" /><ComboBoxItem Content="Outcome unknown" Tag="OUTCOME_UNKNOWN" /><ComboBoxItem Content="Windows completed" Tag="SPOOLER_COMPLETED" /></ComboBox></Grid>
<Grid Grid.Row="1" ColumnSpacing="12" Margin="8,0,24,0" AutomationProperties.AutomationId="JobTableHeader"><Grid.ColumnDefinitions><ColumnDefinition Width="2*" /><ColumnDefinition Width="1.4*" /><ColumnDefinition Width="1.2*" /><ColumnDefinition Width="52" /><ColumnDefinition Width="120" /></Grid.ColumnDefinitions><TextBlock Text="Document" FontWeight="SemiBold" /><TextBlock Grid.Column="1" Text="Printer" FontWeight="SemiBold" /><TextBlock Grid.Column="2" Text="Status" FontWeight="SemiBold" /><TextBlock Grid.Column="3" Text="Format" FontWeight="SemiBold" /><TextBlock Grid.Column="4" Text="Updated" FontWeight="SemiBold" /></Grid>
<ListView x:Name="JobsList" Grid.Row="2" ItemContainerStyle="{StaticResource FullWidthListItem}" SelectionMode="Single" SelectionChanged="JobSelection_Changed" HorizontalContentAlignment="Stretch" AutomationProperties.AutomationId="RecentJobs" />
<Grid Grid.Row="1" ColumnSpacing="12" Margin="8,0,24,0" AutomationProperties.AutomationId="JobTableHeader"><Grid.ColumnDefinitions><ColumnDefinition Width="2*" /><ColumnDefinition Width="1.4*" /><ColumnDefinition Width="1.2*" /><ColumnDefinition Width="52" /><ColumnDefinition Width="120" /></Grid.ColumnDefinitions><Button x:Name="JobTitleHeader" Grid.Column="0" Style="{StaticResource TableHeaderButton}" Content="Document" Tag="title" Click="JobSort_Click" AutomationProperties.AutomationId="SortJobTitle" AutomationProperties.Name="Sort by document" /><Button x:Name="JobPrinterHeader" Grid.Column="1" Style="{StaticResource TableHeaderButton}" Content="Printer" Tag="printerName" Click="JobSort_Click" AutomationProperties.AutomationId="SortJobPrinter" AutomationProperties.Name="Sort by printer" /><Button x:Name="JobStatusHeader" Grid.Column="2" Style="{StaticResource TableHeaderButton}" Content="Status" Tag="status" Click="JobSort_Click" AutomationProperties.AutomationId="SortJobStatus" AutomationProperties.Name="Sort by status" /><Button x:Name="JobFormatHeader" Grid.Column="3" Style="{StaticResource TableHeaderButton}" Content="Format" Tag="format" Click="JobSort_Click" AutomationProperties.AutomationId="SortJobFormat" AutomationProperties.Name="Sort by format" /><Button x:Name="JobCreatedHeader" Grid.Column="4" Style="{StaticResource TableHeaderButton}" Content="Created ↓" Tag="createdAt" Click="JobSort_Click" AutomationProperties.AutomationId="SortJobCreated" AutomationProperties.Name="Sort by created, descending" /></Grid>
<ListView x:Name="JobsList" Grid.Row="2" ItemContainerStyle="{StaticResource FullWidthListItem}" SelectionMode="Single" SelectionChanged="JobSelection_Changed" DoubleTapped="Jobs_DoubleTapped" HorizontalContentAlignment="Stretch" AutomationProperties.AutomationId="RecentJobs" />
<StackPanel x:Name="JobsEmpty" Grid.Row="2" Spacing="12" HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="350"><TextBlock x:Name="JobsEmptyTitle" Text="No jobs yet" Style="{ThemeResource SubtitleTextBlockStyle}" HorizontalAlignment="Center" /><TextBlock x:Name="JobsEmptyMessage" Text="History will appear when the service receives jobs." TextWrapping="Wrap" TextAlignment="Center" /></StackPanel>
<Grid Grid.Row="3" ColumnSpacing="8"><Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions><TextBlock x:Name="JobsSummary" AutomationProperties.AutomationId="JobsSummary" Text="Waiting for history" TextWrapping="Wrap" VerticalAlignment="Center" /><Button x:Name="JobsPrevious" Grid.Column="1" Content="Previous" Click="PreviousJobs_Click" IsEnabled="False" AutomationProperties.AutomationId="PreviousJobPage" /><Button x:Name="JobsNext" Grid.Column="2" Content="Next" Click="NextJobs_Click" IsEnabled="False" AutomationProperties.AutomationId="NextJobPage" /></Grid>
<StackPanel Grid.Row="4" Spacing="8"><TextBlock x:Name="SelectedJobText" Text="Select a job for details and actions." TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" /><StackPanel Orientation="Horizontal" Spacing="8"><Button x:Name="JobDetailsButton" Content="Details" Click="JobDetails_Click" IsEnabled="False" AutomationProperties.AutomationId="JobDetails" /><Button x:Name="JobRetryButton" Content="Print again" Click="JobRetry_Click" IsEnabled="False" /><Button x:Name="JobCancelButton" Content="Cancel job" Click="JobCancel_Click" IsEnabled="False" /><Button x:Name="JobReviewButton" Content="Mark reviewed" Click="JobReview_Click" IsEnabled="False" /></StackPanel><TextBlock x:Name="JobsNotice" Text="Sent to printer means Windows accepted the job. Check the printer to confirm physical output." FontSize="12" TextWrapping="Wrap" /></StackPanel>
+3 -17
View File
@@ -427,12 +427,13 @@ public sealed partial class MainWindow : Window, IDisposable
{
if(!_printerRows.TryGetValue(printer.PrinterId, out PrinterRowView? row))
{
row = new PrinterRowView(SetExposureAsync);
row = new PrinterRowView(SetExposureAsync, EditPrinterAsync, TestPrintAsync);
_printerRows.Add(printer.PrinterId, row);
PrintersList.Items.Add(row);
}
row.Update(printer, FriendlyState(printer.Status), _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";
@@ -602,22 +603,7 @@ public sealed partial class MainWindow : Window, IDisposable
=> DateTimeOffset.TryParse(timestamp, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out DateTimeOffset time)
? time.ToLocalTime().ToString("g", System.Globalization.CultureInfo.CurrentCulture) : "Not yet";
private static string FriendlyState(string state) => state switch
{
"READY" => "Ready",
"OFFLINE" => "Offline",
"PAUSED" => "Paused",
"ERROR" => "Needs attention",
"QUEUED" => "Queued",
"CLAIMED" => "Assigned to this computer",
"RECEIVED" => "Received",
"SUBMITTING" => "Sending to printer",
"SPOOLED" => "Sent to printer",
"SPOOLER_COMPLETED" => "Windows completed the job",
"FAILED" => "Failed",
"OUTCOME_UNKNOWN" => "Check printer before retrying",
_ => "Status unavailable",
};
private static string FriendlyState(string state) => JobLabels.Status(state);
/// <summary>Persist presentation preferences only.</summary>
private void Save()
+53
View File
@@ -0,0 +1,53 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent;
/// <summary>Selected-printer information stays readable without expanding long descriptions into the table.</summary>
internal sealed class PrinterDetailsView : StackPanel
{
private readonly TextBlock _name = Value();
private readonly TextBlock _queue = Value();
private readonly TextBlock _status = Value();
private readonly TextBlock _location = Value();
private readonly TextBlock _description = Value();
private readonly TextBlock _technical = Value();
internal PrinterDetailsView()
{
Spacing = 8;
AddField("Name", _name);
AddField("Windows queue", _queue);
AddField("Status", _status);
AddField("Location", _location);
_description.MaxLines = 3;
_description.TextTrimming = TextTrimming.WordEllipsis;
AddField("Description", _description);
Children.Add(new TextBlock { Text = "Open Settings to read or edit the full description.", TextWrapping = TextWrapping.Wrap, FontSize = 12 });
Children.Add(new Expander { Header = "Windows diagnostics", Content = _technical, HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalContentAlignment = HorizontalAlignment.Stretch });
}
internal void Update(PrinterSnapshot printer, string name, string status)
{
_name.Text = name;
_queue.Text = printer.Name;
_status.Text = status;
_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}";
}
private void AddField(string label, TextBlock value)
{
Grid row = new() { ColumnSpacing = 12 };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(112) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
row.Children.Add(new TextBlock { Text = label, TextWrapping = TextWrapping.Wrap, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
Grid.SetColumn(value, 1);
row.Children.Add(value);
Children.Add(row);
}
private static TextBlock Value() => new() { TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true };
}
+37 -4
View File
@@ -17,13 +17,19 @@ internal sealed class PrinterRowView : Grid
private readonly TextBlock _status = 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" };
private readonly PrinterDetailsView _detailsView = new();
private readonly Button _settings = new() { Content = "Settings" };
private readonly Button _test = new() { Content = "Print test" };
internal PrinterSnapshot? Snapshot { get; private set; }
internal CheckBox Exposure { get; } = new() { VerticalAlignment = VerticalAlignment.Center };
internal PrinterRowView(Func<PrinterSnapshot, bool, Task> exposure)
internal PrinterRowView(Func<PrinterSnapshot, bool, Task> exposure, Func<PrinterSnapshot, Task> settings, Func<PrinterSnapshot, Task> test)
{
ColumnSpacing = 12;
MinHeight = 44;
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.2, GridUnitType.Star) });
ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
@@ -36,24 +42,51 @@ internal sealed class PrinterRowView : Grid
Children.Add(cells[index]);
}
Exposure.Command = new AsyncRelayCommand(() => Snapshot is PrinterSnapshot printer ? exposure(printer, Exposure.IsChecked == true) : Task.CompletedTask);
InitializeDetails(settings, test);
}
private void InitializeDetails(Func<PrinterSnapshot, Task> settings, Func<PrinterSnapshot, Task> test)
{
_settings.Command = new AsyncRelayCommand(() => Snapshot is PrinterSnapshot printer ? settings(printer) : Task.CompletedTask);
_test.Command = new AsyncRelayCommand(() => Snapshot is PrinterSnapshot printer ? test(printer) : Task.CompletedTask);
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(_settings, "EditPrinterProfile");
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(_test, "PrintTest");
StackPanel actions = new() { Orientation = Orientation.Horizontal, Spacing = 8 };
actions.Children.Add(_settings);
actions.Children.Add(_test);
_details.Children.Add(actions);
_details.Children.Add(_detailsView);
SetRow(_details, 1);
SetColumnSpan(_details, 5);
Children.Add(_details);
}
internal void Update(PrinterSnapshot printer, string status, bool busy)
{
Snapshot = printer;
SetCell(_name, string.IsNullOrWhiteSpace(printer.DisplayName) ? printer.Name : printer.DisplayName);
SetCell(_status, printer.Formats.Length > 0 ? status : string.Equals(printer.Status, "OFFLINE", StringComparison.Ordinal)
? "Offline" : "Interactive only");
SetCell(_status, StatusLabel(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);
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);
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(Exposure, $"Share {_name.Text} with the server");
}
internal void SetBusy(bool busy) => Exposure.IsEnabled = !busy && Snapshot is PrinterSnapshot printer && (printer.Formats.Length > 0 || printer.Exposed);
internal void SetBusy(bool busy)
{
Exposure.IsEnabled = !busy && Snapshot is PrinterSnapshot printer && (printer.Formats.Length > 0 || printer.Exposed);
_settings.IsEnabled = !busy;
_test.IsEnabled = !busy && Snapshot is { Exposed: true } selected && selected.Formats.Length > 0;
}
internal void SetSelected(bool selected) => _details.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
internal static string StatusLabel(PrinterSnapshot printer) => printer.Formats.Length > 0 ? JobLabels.Status(printer.Status)
: string.Equals(printer.Status, "OFFLINE", StringComparison.Ordinal) ? "Offline" : "Interactive only";
private static TextBlock Cell() => new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 };
+8 -1
View File
@@ -8,7 +8,7 @@ internal sealed class ProfileEditor : StackPanel
{
internal TextBox NameBox { get; } = new() { Header = "Name", MaxLength = 512 };
internal TextBox LocationBox { get; } = new() { Header = "Location", MaxLength = 512 };
internal TextBox DescriptionBox { get; } = new() { Header = "Description", MaxLength = 4096, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MaxHeight = 180 };
internal TextBox DescriptionBox { get; } = new() { Header = "Description", MaxLength = 4096, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MinHeight = 140, MaxHeight = 240, HorizontalAlignment = HorizontalAlignment.Stretch };
internal ProfileEditor(string? name, string? description, string? location)
{
@@ -23,4 +23,11 @@ internal sealed class ProfileEditor : StackPanel
Children.Add(LocationBox);
Children.Add(DescriptionBox);
}
internal void AddPrinterContext(string queue, string status)
{
Children.Add(new TextBlock { Text = "Windows printer", FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
Children.Add(new TextBlock { Text = queue, TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true });
Children.Add(new TextBlock { Text = $"Status: {status}\nThese details are displayed here and on the server. The Windows queue name and sharing approval remain unchanged.", TextWrapping = TextWrapping.Wrap });
}
}
@@ -7,7 +7,7 @@ namespace Qqq.PrintAgent.Cli.Tests;
public sealed class AgentJobPageTests
{
[Fact]
public void QueuePrefix_PreservesPaginationFilteringAndLocalDeduplication()
public void SortedQueue_PreservesPaginationFilteringAndLocalDeduplication()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-page-queue-" + Guid.NewGuid().ToString("N"));
try
@@ -15,7 +15,7 @@ public sealed class AgentJobPageTests
using AgentStore store = new(directory);
store.Record("local", new PrintEvent("local-event", "SPOOLED"));
store.Record("duplicate", new PrintEvent("duplicate-event", "SPOOLED"));
JobSnapshot[] queued = [new("cloud", "QUEUED", "now", null, null, "laser", "Warehouse", "Packing slip"), new("duplicate", "QUEUED", "now", null, null)];
JobSnapshot[] queued = [new("cloud", "QUEUED", "now", null, null, "laser", "Warehouse", "Packing slip", CreatedAt: "2099-01-01T00:00:00.0000000+00:00"), new("duplicate", "QUEUED", "now", null, null)];
JobPage first = AgentRuntime.QueryJobsWithQueue(store, new JobQuery(PageSize: 1), queued);
Assert.Equal("cloud", Assert.Single(first.Jobs).JobId);
Assert.Equal(3, first.TotalCount);
@@ -30,6 +30,7 @@ public sealed class AgentRuntimeQueuedJobsTests
using(AgentStore store = new(directory))
{
store.SaveConfiguration(new AgentConfiguration("https://example.invalid", "device", "synthetic-token"));
store.SavePrinterProfile("printer", new PrinterProfile("Dispatch labels"));
}
runtime = new AgentRuntime(directory, _ => { }, printers,
configuration => new PrintApiClient(configuration.ServerUrl, configuration.DeviceToken, new Handler(server)),
@@ -43,15 +44,13 @@ public sealed class AgentRuntimeQueuedJobsTests
Assert.Equal("queued-job", queued.JobId);
Assert.Equal("QUEUED", queued.Status);
Assert.Equal("Synthetic queued document", queued.Title);
Assert.Equal("Synthetic printer", queued.PrinterName);
Assert.Equal("Dispatch labels", queued.PrinterName);
await VerifyPagedPrinterNameAsync(pipeName, stop.Token);
using(AgentStore store = new(directory))
{
Assert.Null(store.GetJobDetails(queued.JobId));
}
string detailsText = await SendAsync(pipeName, new PipeRequest("job-details", JobId: queued.JobId), stop.Token);
JobDetailsSnapshot details = JsonSerializer.Deserialize<JobDetailsSnapshot>(detailsText, PrintApiClient.JsonOptions)!;
Assert.Equal("QUEUED", details.Job.Status);
Assert.Empty(details.Events);
await VerifyQueuedDetailsAsync(pipeName, queued.JobId, stop.Token);
string result = await SendAsync(pipeName, new PipeRequest("cancel-job", JobId: queued.JobId), stop.Token);
Assert.StartsWith("The server accepted the cancellation before dispatch.", result, StringComparison.Ordinal);
Assert.Equal(1, Volatile.Read(ref server.Cancellations));
@@ -77,6 +76,21 @@ public sealed class AgentRuntimeQueuedJobsTests
}
}
private static async Task VerifyQueuedDetailsAsync(string pipeName, string jobId, CancellationToken token)
{
string text = await SendAsync(pipeName, new PipeRequest("job-details", JobId: jobId), token);
JobDetailsSnapshot details = JsonSerializer.Deserialize<JobDetailsSnapshot>(text, PrintApiClient.JsonOptions)!;
Assert.Equal("QUEUED", details.Job.Status);
Assert.Empty(details.Events);
}
private static async Task VerifyPagedPrinterNameAsync(string pipeName, CancellationToken token)
{
string text = await SendAsync(pipeName, new PipeRequest("query-jobs", JobQuery: new JobQuery(SortBy: "printerName", Descending: false)), token);
JobPage page = JsonSerializer.Deserialize<JobPage>(text, PrintApiClient.JsonOptions)!;
Assert.Equal("Dispatch labels", Assert.Single(page.Jobs).PrinterName);
}
private static async Task<string> SendAsync(string name, PipeRequest request, CancellationToken token)
{
await using NamedPipeClientStream pipe = new(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
@@ -0,0 +1,83 @@
using Microsoft.Data.Sqlite;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
public sealed class JobCreationTimeTests
{
[Fact]
public void Receipt_NormalizesServerCreationAndPreservesItAcrossStatusChangesAndReplay()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-created-" + Guid.NewGuid().ToString("N"));
try
{
using AgentStore store = new(directory);
PrintJob older = new("older", "printer", "Older job", "PDF", "00", 1, new PrintOptions(), "2026-01-01T05:00:00-05:00", "CLAIMED");
store.TrackJob(older, "Laser");
store.Record(older.JobId, new PrintEvent("received", "RECEIVED"));
store.TrackJob(older with { CreatedAt = "2099-01-01T00:00:00Z" }, "Laser");
PrintJob newer = older with { JobId = "newer", CreatedAt = "2026-01-02T10:00:00Z" };
store.TrackJob(newer, "Laser");
store.Record(newer.JobId, new PrintEvent("newer-spooled", "SPOOLED"));
store.Record(older.JobId, new PrintEvent("older-spooled", "SPOOLED"));
JobSnapshot saved = store.GetJobDetails(older.JobId)!.Job;
Assert.Equal("2026-01-01T10:00:00.0000000+00:00", saved.CreatedAt);
Assert.NotEqual(saved.CreatedAt, saved.UpdatedAt, StringComparer.Ordinal);
Assert.Equal(new[] { "newer", "older" }, store.QueryJobs(new JobQuery()).Jobs.Select(job => job.JobId), StringComparer.Ordinal);
Assert.Equal(new[] { "older", "newer" }, store.QueryJobs(new JobQuery(Descending: false)).Jobs.Select(job => job.JobId), StringComparer.Ordinal);
}
finally
{
SqliteConnection.ClearAllPools();
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void LegacyBackfill_UsesFirstEventThenSummaryFallbackAndRunsOnlyOnce()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-created-migration-" + Guid.NewGuid().ToString("N"));
try
{
using(AgentStore store = new(directory))
{
store.Record("events", new PrintEvent("first", "RECEIVED"));
store.Record("events", new PrintEvent("last", "SPOOLED"));
store.Record("pruned", new PrintEvent("pruned", "SPOOLED"));
}
Execute(directory, """
UPDATE events SET recorded_at='2020-01-01T00:00:00.0000000+00:00' WHERE event_id='first';
UPDATE events SET recorded_at='2020-02-01T00:00:00.0000000+00:00' WHERE event_id='last';
DELETE FROM events WHERE job_id='pruned';
UPDATE local_jobs SET updated_at='2021-01-01T00:00:00.0000000+00:00' WHERE job_id='pruned';
DROP INDEX jobs_display_createdAt;
ALTER TABLE local_jobs DROP COLUMN created_at;
""");
using(AgentStore migrated = new(directory))
{
Assert.Equal("2020-01-01T00:00:00.0000000+00:00", migrated.GetJobDetails("events")!.Job.CreatedAt);
Assert.Equal("2021-01-01T00:00:00.0000000+00:00", migrated.GetJobDetails("pruned")!.Job.CreatedAt);
migrated.Record("events", new PrintEvent("late", "SPOOLER_COMPLETED"));
}
Execute(directory, "UPDATE events SET recorded_at='2099-01-01T00:00:00.0000000+00:00'; UPDATE local_jobs SET updated_at='2099-01-01T00:00:00.0000000+00:00'");
using AgentStore reopened = new(directory);
Assert.Equal("2020-01-01T00:00:00.0000000+00:00", reopened.GetJobDetails("events")!.Job.CreatedAt);
Assert.Equal("2021-01-01T00:00:00.0000000+00:00", reopened.GetJobDetails("pruned")!.Job.CreatedAt);
}
finally
{
SqliteConnection.ClearAllPools();
Directory.Delete(directory, recursive: true);
}
}
private static void Execute(string directory, string sql)
{
using SqliteConnection connection = new(new SqliteConnectionStringBuilder { DataSource = Path.Combine(directory, "journal.db") }.ToString());
connection.Open();
using SqliteCommand command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
}
@@ -0,0 +1,142 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
public sealed class JobSortingTests
{
[Fact]
public void TenThousandJobs_AllColumnsSortAcrossPagesAndInterleaveBoundedCloudQueue()
{
using Fixture fixture = new();
JobSnapshot[] local = fixture.Seed(10000);
JobSnapshot[] cloud = Enumerable.Range(0, 49).Select(index => MakeJob(index * 211, "cloud" + index.ToString("D4", CultureInfo.InvariantCulture))).Append(local[10] with { Status = "QUEUED" }).ToArray();
foreach(string field in new[] { "createdAt", "title", "printerName", "status", "format" })
{
foreach(bool descending in new[] { true, false })
{
VerifyPages(fixture.Store, local, cloud, new JobQuery(SortBy: field, Descending: descending));
}
}
Assert.Throws<ArgumentException>(() => fixture.Store.QueryJobs(new JobQuery(SortBy: "title; DELETE FROM local_jobs")));
// A distant corrupt payload would expose any accidental whole-history materialization.
fixture.Execute("UPDATE local_jobs SET last_event_json='invalid-json' WHERE job_id='job09999'");
Assert.Equal(100, AgentRuntime.QueryJobsWithQueue(fixture.Store, new JobQuery(Offset: 5000, PageSize: 100), cloud).Jobs.Count);
}
private static void VerifyPages(AgentStore store, JobSnapshot[] local, JobSnapshot[] cloud, JobQuery query)
{
var comparer = Comparer<JobSnapshot>.Create((left, right) => JobSorting.Compare(left, right, query));
JobSnapshot[] expectedLocal = local.Order(comparer).ToArray();
JobSnapshot[] expectedAll = local.Concat(cloud.Where(job => !local.Any(saved => string.Equals(saved.JobId, job.JobId, StringComparison.Ordinal)))).Order(comparer).ToArray();
foreach(int offset in new[] { 0, 4990, 10020 })
{
JobQuery pageQuery = query with { Offset = offset, PageSize = 100 };
JobPage page = store.QueryJobs(pageQuery);
Assert.Equal(expectedLocal.Skip(offset).Take(100).Select(job => job.JobId), page.Jobs.Select(job => job.JobId), StringComparer.Ordinal);
JobPage merged = AgentRuntime.QueryJobsWithQueue(store, pageQuery, cloud);
Assert.Equal(expectedAll.Skip(offset).Take(100).Select(job => job.JobId), merged.Jobs.Select(job => job.JobId), StringComparer.Ordinal);
Assert.Equal(expectedAll.Length, merged.TotalCount);
Assert.True(merged.ServerQueueLimited);
}
}
[Fact]
public void QueueTextComparator_MatchesSqliteUnicodeNullAndCaseOrdering()
{
using Fixture fixture = new();
string?[] values = [null, "", "alpha", "ALPHA", "Éclair", "éclair", "\uE000", "😀", "\0a", "\0bb"];
foreach(string? first in values)
{
foreach(string? second in values)
{
int expected = fixture.CompareSql(first, second);
JobSnapshot left = new("same", "QUEUED", "", null, null, Title: first, CreatedAt: first);
JobSnapshot right = left with { CreatedAt = second };
Assert.Equal(expected, Math.Sign(JobSorting.Compare(left, right, new JobQuery(Descending: false))));
}
}
JobSnapshot lowerId = new("\uE000", "QUEUED", "", null, null);
JobSnapshot higherId = lowerId with { JobId = "😀" };
Assert.True(JobSorting.Compare(lowerId, higherId, new JobQuery(Descending: false)) < 0);
}
private static JobSnapshot MakeJob(int index, string id)
{
string?[] titles = [null, "alpha", "ALPHA", "Éclair", "éclair", "\uE000", "😀", "Packing slip"];
string[] states = ["QUEUED", "CLAIMED", "RECEIVED", "SUBMITTING", "SPOOLED", "SPOOLER_COMPLETED", "FAILED", "OUTCOME_UNKNOWN"];
string? created = index % 17 == 0 ? null : new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(index).ToString("O", CultureInfo.InvariantCulture);
string updated = new DateTimeOffset(2026, 2, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(-index).ToString("O", CultureInfo.InvariantCulture);
return new JobSnapshot(id, states[index % states.Length], updated, null, null, index % 13 == 0 ? null : "printer-id",
titles[(index / 3) % titles.Length], titles[index % titles.Length], index % 5 == 0 ? null : index % 2 == 0 ? "PDF" : "ZPL", index % 7 == 0, created);
}
private sealed class Fixture : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "qqq-sort-" + Guid.NewGuid().ToString("N"));
private readonly SqliteConnection _database;
public AgentStore Store { get; }
public Fixture()
{
Store = new AgentStore(_directory);
_database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(_directory, "journal.db") }.ToString());
_database.Open();
}
public JobSnapshot[] Seed(int count)
{
using SqliteTransaction transaction = _database.BeginTransaction();
using SqliteCommand command = _database.CreateCommand();
command.Transaction = transaction;
command.CommandText = "INSERT INTO local_jobs(connection_id,job_id,title,printer_id,printer_name,format,state,created_at,updated_at,failure_acknowledged) VALUES('unenrolled',$id,$title,$printer,$name,$format,$state,$created,$updated,$reviewed)";
foreach(string key in new[] { "$id", "$title", "$printer", "$name", "$format", "$state", "$created", "$updated", "$reviewed" })
{
command.Parameters.Add(key, SqliteType.Text);
}
List<JobSnapshot> jobs = [];
for(int index = 0; index < count; index++)
{
JobSnapshot job = MakeJob(index, "job" + index.ToString("D5", CultureInfo.InvariantCulture));
jobs.Add(job);
command.Parameters["$id"].Value = job.JobId;
command.Parameters["$title"].Value = (object?)job.Title ?? DBNull.Value;
command.Parameters["$printer"].Value = (object?)job.PrinterId ?? DBNull.Value;
command.Parameters["$name"].Value = (object?)job.PrinterName ?? DBNull.Value;
command.Parameters["$format"].Value = (object?)job.Format ?? DBNull.Value;
command.Parameters["$state"].Value = job.Status;
command.Parameters["$created"].Value = (object?)job.CreatedAt ?? DBNull.Value;
command.Parameters["$updated"].Value = job.UpdatedAt;
command.Parameters["$reviewed"].Value = job.FailureAcknowledged ? 1 : 0;
command.ExecuteNonQuery();
}
transaction.Commit();
return jobs.ToArray();
}
public int CompareSql(string? first, string? second)
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "SELECT CASE WHEN $a IS NULL THEN CASE WHEN $b IS NULL THEN 0 ELSE -1 END WHEN $b IS NULL THEN 1 WHEN $a COLLATE NOCASE < $b THEN -1 WHEN $a COLLATE NOCASE > $b THEN 1 ELSE 0 END";
command.Parameters.AddWithValue("$a", (object?)first ?? DBNull.Value);
command.Parameters.AddWithValue("$b", (object?)second ?? DBNull.Value);
return Convert.ToInt32(command.ExecuteScalar(), CultureInfo.InvariantCulture);
}
public void Execute(string sql)
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
public void Dispose()
{
Store.Dispose();
_database.Dispose();
SqliteConnection.ClearAllPools();
Directory.Delete(_directory, recursive: true);
}
}
}
@@ -46,6 +46,24 @@ public sealed class PrintWorkerTests
Assert.Equal(new[] { "RECEIVED", "SUBMITTING", "SPOOLED" }, handler.Events, StringComparer.Ordinal);
}
[Theory]
[InlineData("Dispatch labels", "Dispatch labels")]
[InlineData("", "Test")]
[InlineData(null, "Test")]
public async Task ReceiptCapturesFriendlyNameWithoutChangingDispatchIdentity(string? displayName, string expected)
{
byte[] content = [1];
PrintJob job = new("named-job", "printer1", "Test", "ZPL", Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(content)), 1, new PrintOptions(), "2026-01-01T00:00:00Z", "CLAIMED");
using FixtureHandler handler = new(job, content);
using PrintApiClient api = new("http://localhost", "test-device", handler);
MemoryJournal journal = new();
FakePrinter printer = new(displayName);
await new PrintWorker(api, printer, journal).RunOnceAsync();
Assert.Equal(expected, journal.Jobs[job.JobId].PrinterName);
Assert.Equal("printer1", journal.Jobs[job.JobId].Job.PrinterId);
Assert.Equal(1, printer.PrintCount);
}
[Fact]
public async Task RunOnce_PriorSubmittingMarksUnknownWithoutPrinting()
{
@@ -135,10 +153,10 @@ public sealed class PrintWorkerTests
}
}
private sealed class FakePrinter : IPrinterBackend
private sealed class FakePrinter(string? displayName = null) : IPrinterBackend
{
public int PrintCount { get; private set; }
public IReadOnlyList<PrinterInfo> Discover() => [new("printer1", "Test", "Zebra", "USB", "READY", ["ZPL"])];
public IReadOnlyList<PrinterInfo> Discover() => [new("printer1", "Test", "Zebra", "USB", "READY", ["ZPL"], DisplayName: displayName)];
public Task<string> PrintAsync(PrintJob job, byte[] content, CancellationToken cancellationToken) { PrintCount++; return Task.FromResult("123"); }
}
+7 -9
View File
@@ -147,6 +147,11 @@ public sealed class AppSession : IDisposable
public AutomationElement WaitForElement(string automationId, TimeSpan? timeout = null)
=> WaitFor(() => FindInApp(cf => cf.ByAutomationId(automationId)), $"element '{automationId}'", timeout);
/// <summary>Finds visible text by its native UIA peer rather than a layout container without a peer.</summary>
public AutomationElement WaitForVisibleText(string text)
=> WaitFor(() => MainWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text)))
.FirstOrDefault(element => !element.IsOffscreen), $"visible text '{text}'");
/// <summary>Waits until no element with the given automation id remains.</summary>
@@ -333,21 +338,14 @@ public sealed class AppSession : IDisposable
/// <summary>Closes the app (killing it if needed), then disposes the automation and deletes the data folder.</summary>
/// <summary>Terminates only this fixture's launched companion, then releases automation and its isolated settings.</summary>
public void Dispose()
{
try
{
App?.Close();
}
catch
{
// Already gone; the kill below double-checks.
}
try
{
if(App is { HasExited: false })
{
// Window close intentionally hides to tray. Cleanup is not an exit-behavior test.
App.Kill();
}
}
+74 -2
View File
@@ -72,7 +72,13 @@ public class AppTests
session.WaitForElement("FooterStatus");
session.WaitForElement("ViewServiceLogs");
session.ClickElement("NavigateJobs");
session.WaitForElement("JobTableHeader");
string[] jobHeaders = ["SortJobTitle", "SortJobPrinter", "SortJobStatus", "SortJobFormat", "SortJobCreated"];
foreach(string header in jobHeaders)
{
AutomationElement button = session.WaitForElement(header);
Assert.Equal(ControlType.Button, button.ControlType);
Assert.False(button.IsOffscreen);
}
session.WaitForElement("PreviousJobPage");
session.WaitForElement("NextJobPage");
session.WaitForElement("RecentJobs");
@@ -96,7 +102,13 @@ public class AppTests
public void MetadataEditor_CanBeOpenedAndCanceled()
{
using var session = new AppSession();
session.WaitForElement("PrinterTableHeader");
string[] printerHeaders = ["SortPrinterName", "SortPrinterStatus", "SortPrinterLocation", "SortPrinterFormats", "SortPrinterShared"];
foreach(string header in printerHeaders)
{
AutomationElement button = session.WaitForElement(header);
Assert.Equal(ControlType.Button, button.ControlType);
Assert.False(button.IsOffscreen);
}
session.ClickElement("NavigateSettings");
session.ClickElement("EditDeviceProfile");
session.WaitForElement("MetadataName");
@@ -107,6 +119,66 @@ public class AppTests
Assert.False(session.App.HasExited);
}
/// <summary>A real installed queue expands in place and opens settings without saving or printing.</summary>
[Fact]
public void ExistingPrinter_DoubleClickOpensSettingsWithoutMutation()
{
using var session = new AppSession();
AutomationElement inventory = session.WaitForElement("PrinterInventory");
AutomationElement row = session.WaitFor(() => inventory.FindFirstDescendant(cf => cf.ByControlType(ControlType.ListItem)), "an installed printer row");
row.Patterns.SelectionItem.Pattern.Select();
AutomationElement settings = session.WaitForElement("EditPrinterProfile");
Assert.False(settings.IsOffscreen);
Assert.True(settings.BoundingRectangle.Top >= row.BoundingRectangle.Top);
Assert.True(settings.BoundingRectangle.Bottom <= row.BoundingRectangle.Bottom);
AutomationElement name = session.WaitFor(() => row.FindFirstDescendant(cf => cf.ByControlType(ControlType.Text)), "printer row text");
session.MainWindow.SetForeground();
name.DoubleClick();
AutomationElement nameBox = session.WaitForElement("MetadataName");
Assert.True(nameBox.BoundingRectangle.Width > 600, "The responsive printer settings editor should use the wide dialog layout.");
session.WaitForElement("MetadataLocation");
session.WaitForElement("MetadataDescription");
session.ClickButton("Cancel");
session.WaitForElementGone("MetadataName");
Assert.False(session.App.HasExited);
}
/// <summary>Uses existing job evidence only; the fixture must have at least one retained job.</summary>
[Fact]
public void ExistingJob_DoubleClickOpensStructuredDetailsWithoutMutation()
{
using var session = new AppSession();
session.ClickElement("NavigateJobs");
AutomationElement history = session.WaitForElement("RecentJobs");
AutomationElement row = session.WaitFor(() => history.FindFirstDescendant(cf => cf.ByControlType(ControlType.ListItem)), "an existing retained job row");
AutomationElement title = session.WaitFor(() => row.FindFirstDescendant(cf => cf.ByControlType(ControlType.Text)), "job row text");
session.MainWindow.SetForeground();
title.DoubleClick();
session.WaitForVisibleText("Outcome");
session.WaitFor(() => session.FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName("Event history"))), "structured event history");
session.ClickButton("Close");
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName("Event history"))), "job details to close");
Assert.False(session.App.HasExited);
}
/// <summary>Header direction changes are presentation-only and do not change service job records.</summary>
[Fact]
public void SortingHeaders_ExposeDefaultsAndToggleDirection()
{
using var session = new AppSession();
Assert.Equal("Sort by name, ascending", session.WaitForElement("SortPrinterName").Name);
session.ClickElement("SortPrinterName");
Assert.Equal("Sort by name, descending", session.WaitForElement("SortPrinterName").Name);
session.ClickElement("SortPrinterLocation");
Assert.Equal("Sort by location, ascending", session.WaitForElement("SortPrinterLocation").Name);
session.ClickElement("NavigateJobs");
Assert.Equal("Sort by created, descending", session.WaitForElement("SortJobCreated").Name);
session.ClickElement("SortJobCreated");
session.WaitFor(() => session.FindInApp(cf => cf.ByAutomationId("SortJobCreated")) is { Name: "Sort by created, ascending" } button ? button : null, "creation sort query to finish");
session.ClickElement("SortJobTitle");
session.WaitFor(() => session.FindInApp(cf => cf.ByAutomationId("SortJobTitle")) is { Name: "Sort by document, ascending" } button ? button : null, "document sort query to finish");
}
/// <summary>The close button hides details while the tray process stays alive.</summary>