feat: add AppKit cloud printing service and tray companion
Co-authored-by: Codex (GPT-6) <noreply@openai.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
120 files changed
+6835
No files matched your search
+211
@@ -0,0 +1,211 @@
|
||||
# KofTwentyTwo .NET code style (standards: K22-CODE-CS): the Kingsrook code style
|
||||
# (https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md) applied to C#.
|
||||
# Copy to the repository root. Severities of "warning" fail the build because the
|
||||
# companion Directory.Build.props sets TreatWarningsAsErrors and EnforceCodeStyleInBuild,
|
||||
# so local `dotnet build` and CI enforce exactly the same rules. The resharper_* keys
|
||||
# apply the Kingsrook blank-line rules in Rider and ReSharper, which .NET's own
|
||||
# formatter cannot express.
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
# Kingsrook: always 3 spaces, never tabs.
|
||||
indent_size = 3
|
||||
tab_width = 3
|
||||
|
||||
# YAML stays at 2: the YAML ecosystem (GitHub Actions, Dependabot) assumes it.
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Windows-only script formats require CRLF.
|
||||
[*.{cmd,bat}]
|
||||
end_of_line = crlf
|
||||
|
||||
[*.cs]
|
||||
#### Formatting (IDE0055 makes every rule below a build error) ####
|
||||
dotnet_diagnostic.IDE0055.severity = warning
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
csharp_new_line_before_members_in_object_initializers = true
|
||||
csharp_new_line_before_members_in_anonymous_types = true
|
||||
csharp_new_line_between_query_expression_clauses = true
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_switch_labels = true
|
||||
csharp_indent_labels = one_less_than_current
|
||||
csharp_indent_block_contents = true
|
||||
csharp_indent_braces = false
|
||||
csharp_space_after_cast = false
|
||||
# Kingsrook: no space between a control keyword and its parenthesis: if(x), for(...), catch(...).
|
||||
csharp_space_after_keywords_in_control_flow_statements = false
|
||||
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||
csharp_space_between_parentheses = false
|
||||
csharp_space_before_colon_in_inheritance_clause = true
|
||||
csharp_space_after_colon_in_inheritance_clause = true
|
||||
csharp_space_around_binary_operators = before_and_after
|
||||
csharp_space_after_comma = true
|
||||
csharp_space_before_comma = false
|
||||
csharp_space_after_semicolon_in_for_statement = true
|
||||
csharp_preserve_single_line_statements = false
|
||||
csharp_preserve_single_line_blocks = true
|
||||
|
||||
#### Kingsrook blank lines and layout (Rider / ReSharper; .NET formatter cannot express) ####
|
||||
resharper_blank_lines_around_method = 3
|
||||
resharper_blank_lines_around_property = 3
|
||||
resharper_blank_lines_around_auto_property = 1
|
||||
resharper_blank_lines_around_type = 3
|
||||
resharper_blank_lines_after_using_list = 2
|
||||
resharper_keep_blank_lines_in_code = 1
|
||||
resharper_keep_blank_lines_in_declarations = 3
|
||||
resharper_brace_style = next_line
|
||||
resharper_indent_size = 3
|
||||
resharper_space_after_keywords_in_control_flow_statements = false
|
||||
resharper_max_line_length = 999
|
||||
resharper_wrap_before_first_method_call = true
|
||||
resharper_wrap_chained_method_calls = chop_if_long
|
||||
|
||||
#### Usings and namespaces ####
|
||||
# IDE0005 needs GenerateDocumentationFile=true to run during build (set in Directory.Build.props).
|
||||
dotnet_diagnostic.IDE0005.severity = warning
|
||||
csharp_using_directive_placement = outside_namespace:warning
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
dotnet_diagnostic.IDE0161.severity = warning
|
||||
|
||||
#### var: only when the type is apparent from the right-hand side ####
|
||||
csharp_style_var_for_built_in_types = false:warning
|
||||
csharp_style_var_when_type_is_apparent = true:warning
|
||||
csharp_style_var_elsewhere = false:warning
|
||||
dotnet_diagnostic.IDE0007.severity = warning
|
||||
dotnet_diagnostic.IDE0008.severity = warning
|
||||
|
||||
#### Braces, modifiers, readonly ####
|
||||
csharp_prefer_braces = true:warning
|
||||
dotnet_diagnostic.IDE0011.severity = warning
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
||||
dotnet_diagnostic.IDE0040.severity = warning
|
||||
dotnet_style_readonly_field = true:warning
|
||||
dotnet_diagnostic.IDE0044.severity = warning
|
||||
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:warning
|
||||
dotnet_diagnostic.IDE0036.severity = warning
|
||||
|
||||
#### this. qualification: never ####
|
||||
dotnet_style_qualification_for_field = false:warning
|
||||
dotnet_style_qualification_for_property = false:warning
|
||||
dotnet_style_qualification_for_method = false:warning
|
||||
dotnet_style_qualification_for_event = false:warning
|
||||
dotnet_diagnostic.IDE0003.severity = warning
|
||||
|
||||
#### Language keywords over framework type names ####
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
|
||||
dotnet_style_predefined_type_for_member_access = true:warning
|
||||
dotnet_diagnostic.IDE0049.severity = warning
|
||||
|
||||
#### Dead code and redundancy ####
|
||||
dotnet_diagnostic.IDE0051.severity = warning # unused private member
|
||||
dotnet_diagnostic.IDE0052.severity = warning # unread private member
|
||||
dotnet_diagnostic.IDE0059.severity = warning # unnecessary assignment
|
||||
dotnet_diagnostic.IDE0060.severity = warning # unused parameter
|
||||
dotnet_diagnostic.IDE0035.severity = warning # unreachable code
|
||||
dotnet_diagnostic.IDE0004.severity = warning # unnecessary cast
|
||||
dotnet_diagnostic.IDE0100.severity = warning # unnecessary equality operator
|
||||
dotnet_diagnostic.IDE0080.severity = warning # unnecessary suppression operator
|
||||
dotnet_diagnostic.IDE0079.severity = warning # unnecessary suppression
|
||||
dotnet_code_quality_unused_parameters = all
|
||||
|
||||
#### Modern language features (suggestions: shown in IDE, not enforced) ####
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||
csharp_style_prefer_switch_expression = true:suggestion
|
||||
csharp_style_prefer_primary_constructors = true:suggestion
|
||||
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
|
||||
dotnet_style_coalesce_expression = true:suggestion
|
||||
dotnet_style_null_propagation = true:suggestion
|
||||
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_properties = true:suggestion
|
||||
|
||||
#### Naming ####
|
||||
dotnet_naming_rule.interfaces_begin_with_i.symbols = interfaces
|
||||
dotnet_naming_rule.interfaces_begin_with_i.style = i_prefix_pascal_case
|
||||
dotnet_naming_rule.interfaces_begin_with_i.severity = warning
|
||||
dotnet_naming_symbols.interfaces.applicable_kinds = interface
|
||||
dotnet_naming_style.i_prefix_pascal_case.required_prefix = I
|
||||
dotnet_naming_style.i_prefix_pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_rule.type_parameters_begin_with_t.symbols = type_parameters
|
||||
dotnet_naming_rule.type_parameters_begin_with_t.style = t_prefix_pascal_case
|
||||
dotnet_naming_rule.type_parameters_begin_with_t.severity = warning
|
||||
dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
|
||||
dotnet_naming_style.t_prefix_pascal_case.required_prefix = T
|
||||
dotnet_naming_style.t_prefix_pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_rule.types_and_members_are_pascal_case.symbols = types_and_members
|
||||
dotnet_naming_rule.types_and_members_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning
|
||||
dotnet_naming_symbols.types_and_members.applicable_kinds = namespace, class, struct, enum, delegate, property, method, event
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel.style = underscore_camel_case
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel.severity = warning
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, private_protected
|
||||
dotnet_naming_symbols.private_fields.required_modifiers =
|
||||
dotnet_naming_style.underscore_camel_case.required_prefix = _
|
||||
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
|
||||
|
||||
dotnet_naming_rule.private_static_fields_are_s_camel.symbols = private_static_fields
|
||||
dotnet_naming_rule.private_static_fields_are_s_camel.style = s_camel_case
|
||||
dotnet_naming_rule.private_static_fields_are_s_camel.severity = warning
|
||||
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, private_protected
|
||||
dotnet_naming_symbols.private_static_fields.required_modifiers = static
|
||||
dotnet_naming_style.s_camel_case.required_prefix = s_
|
||||
dotnet_naming_style.s_camel_case.capitalization = camel_case
|
||||
|
||||
dotnet_naming_rule.constants_are_pascal_case.symbols = constants
|
||||
dotnet_naming_rule.constants_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.constants_are_pascal_case.severity = warning
|
||||
dotnet_naming_symbols.constants.applicable_kinds = field, local
|
||||
dotnet_naming_symbols.constants.required_modifiers = const
|
||||
|
||||
#### Analyzer adjustments (each one justified) ####
|
||||
# CA1716: flags members named like VB keywords (e.g. ILog.Error). KofTwentyTwo is C#-only.
|
||||
dotnet_diagnostic.CA1716.severity = none
|
||||
# MA0004 (ConfigureAwait): app code runs on UI threads where ConfigureAwait(false) is
|
||||
# wrong by default; libraries opt back in with a [*.cs] override in their own folder.
|
||||
dotnet_diagnostic.MA0004.severity = none
|
||||
# MA0049 (type name matches namespace): conflicts with the AppName/AppName.Core layout.
|
||||
dotnet_diagnostic.MA0049.severity = none
|
||||
|
||||
[**/{Generated,obj}/**.cs]
|
||||
generated_code = true
|
||||
|
||||
[{tests,test}/**.cs]
|
||||
# xUnit's Method_Scenario_Expectation names use underscores by convention.
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
# Inline arrays keep test data at the assertion; each test runs once.
|
||||
dotnet_diagnostic.CA1861.severity = none
|
||||
# Test projects are not documented API.
|
||||
dotnet_diagnostic.CS1591.severity = none
|
||||
# MA0048 (one type per file): a test file groups the test classes and fakes of one feature.
|
||||
dotnet_diagnostic.MA0048.severity = none
|
||||
|
||||
[src/Qqq.PrintAgent/**.cs]
|
||||
# CA1822: XAML event handlers and x:Bind targets must be instance members; the XAML
|
||||
# compiler generates instance calls (this.Handler) for them.
|
||||
dotnet_diagnostic.CA1822.severity = none
|
||||
# IDE0060: XAML event handlers must keep the (sender, e) signature the XAML compiler wires
|
||||
# up, even when a handler uses neither; `dotnet format` cannot see that wiring.
|
||||
dotnet_diagnostic.IDE0060.severity = none
|
||||
@@ -0,0 +1,6 @@
|
||||
# Text is stored and checked out as LF on every OS (K22-CODE-07, K22-REPO-03).
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.ico binary
|
||||
*.cmd text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
@@ -0,0 +1 @@
|
||||
* @KofTwentyTwo
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Bug report
|
||||
description: Something is broken
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >-
|
||||
Security problem? Do not file it here; use the private
|
||||
[vulnerability report](https://github.com/QRun-IO/qqq-print-agent/security/advisories/new).
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The exact version (Help, About, or `--version`).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened, and what did you expect?
|
||||
description: Include exact error messages.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: OS and version, and anything else relevant.
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Relevant log lines. Remove any tokens, passwords, or personal data first.
|
||||
render: text
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/QRun-IO/qqq-print-agent/security/advisories/new
|
||||
about: Please report security issues privately, never as a public issue.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Feature request
|
||||
description: Suggest an improvement
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem would this solve?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
@@ -0,0 +1,23 @@
|
||||
<!-- Title: a Conventional Commit header, e.g. "fix(sync): retry on transient network errors". -->
|
||||
|
||||
## What
|
||||
|
||||
<!-- What changes, in a sentence or two. -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- The problem or issue. "Fixes #123" closes the issue on merge. -->
|
||||
|
||||
## How it was tested
|
||||
|
||||
<!-- Tests added or changed, manual checks, screenshots for UI changes. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Commits are signed and signed off (`git commit -S -s`)
|
||||
- [ ] Zero build warnings; format and lint are clean
|
||||
- [ ] Tests added or updated; the coverage gate still passes
|
||||
- [ ] Docs updated for user-visible changes
|
||||
- [ ] No secrets, credentials, or personal data
|
||||
- [ ] New dependencies are justified below, or none were added
|
||||
- [ ] Substantial AI assistance, if any, is noted here
|
||||
@@ -0,0 +1,37 @@
|
||||
# Dependabot for a KofTwentyTwo repository: copy to .github/dependabot.yml and keep
|
||||
# the ecosystems the repository uses. Updates are grouped weekly. A 7-day cooldown
|
||||
# lets the community catch a compromised release before it reaches us; security
|
||||
# updates are not delayed by the cooldown.
|
||||
version: 2
|
||||
updates:
|
||||
# Always: keeps SHA-pinned actions and reusable workflows current (K22-CI-11).
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
commit-message:
|
||||
prefix: ci
|
||||
|
||||
# .NET: NuGet, including Directory.Packages.props and packages.lock.json.
|
||||
- package-ecosystem: nuget
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 14
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
commit-message:
|
||||
prefix: build
|
||||
|
||||
# Other ecosystems (npm, pip/uv, cargo, gomod, swift, maven, gradle, terraform,
|
||||
# docker): copy the block above and change package-ecosystem and directory.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Categories for GitHub's auto-generated release notes (uses issue/PR labels).
|
||||
changelog:
|
||||
exclude:
|
||||
labels: [deferred]
|
||||
categories:
|
||||
- title: Fixed
|
||||
labels: [bug]
|
||||
- title: Added
|
||||
labels: [enhancement]
|
||||
- title: Quality & internals
|
||||
labels: [quality, refactor]
|
||||
- title: Release & packaging
|
||||
labels: [release]
|
||||
- title: Documentation
|
||||
labels: [documentation]
|
||||
- title: Dependencies
|
||||
labels: [dependencies]
|
||||
- title: Other changes
|
||||
labels: ["*"]
|
||||
@@ -0,0 +1,79 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
product-version:
|
||||
type: string
|
||||
default: '0.1.0'
|
||||
release-version:
|
||||
type: string
|
||||
default: '0.1.0-rc.1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build-test-package:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
working-directory: qqq-print-agent
|
||||
steps:
|
||||
- name: Checkout agent
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
path: qqq-print-agent
|
||||
persist-credentials: false
|
||||
- name: Checkout pinned protocol fixtures
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: QRun-IO/qqq-print-protocol
|
||||
ref: f977bd01b62ceb7539190fd2861809537fcf504c
|
||||
path: qqq-print-protocol
|
||||
persist-credentials: false
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
global-json-file: qqq-print-agent/global.json
|
||||
- name: Locked restore, build and test
|
||||
run: ./build/Verify.ps1
|
||||
- name: Build unsigned prerelease MSI
|
||||
env:
|
||||
PRODUCT_VERSION: ${{ inputs.product-version || '0.1.0' }}
|
||||
RELEASE_VERSION: ${{ inputs.release-version || '0.1.0-rc.1' }}
|
||||
run: |
|
||||
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
New-Item -ItemType Directory -Path artifacts/release -Force | Out-Null
|
||||
$source = "artifacts/installer/QqqPrintAgent-$env:PRODUCT_VERSION-x64.msi"
|
||||
$name = "QqqPrintAgent-v$env:RELEASE_VERSION-x64.msi"
|
||||
Copy-Item -LiteralPath $source -Destination "artifacts/release/$name"
|
||||
$hash = (Get-FileHash -LiteralPath "artifacts/release/$name" -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash $name" | Set-Content artifacts/release/SHA256SUMS -Encoding ascii
|
||||
- name: Upload unsigned installer
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: windows-x64-installer
|
||||
path: qqq-print-agent/artifacts/release/*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
- name: Upload tests and coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: windows-test-results
|
||||
path: qqq-print-agent/TestResults/**
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# CodeQL static analysis (K22-CI-22): security-extended queries for C# (a manual x64
|
||||
# build of the solution) and for the workflows themselves, blocking high severity.
|
||||
name: codeql
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "41 6 * * 1"
|
||||
|
||||
workflow_call:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
|
||||
permissions:
|
||||
contents: read # check out the code
|
||||
security-events: write # upload CodeQL results to code scanning
|
||||
with:
|
||||
languages: '["csharp", "actions"]'
|
||||
build-mode: manual
|
||||
build-command: dotnet build src/Qqq.PrintAgent/Qqq.PrintAgent.csproj -p:Platform=x64 && dotnet build src/Qqq.PrintAgent.Service/Qqq.PrintAgent.Service.csproj -p:Platform=x64 && dotnet build src/Qqq.PrintAgent.Cli/Qqq.PrintAgent.Cli.csproj -p:Platform=x64
|
||||
runs-on: windows-latest
|
||||
setup-dotnet: true
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off, and
|
||||
# dependency review, from the shared KofTwentyTwo/standards workflow pinned by SHA
|
||||
# (K22-CI-11). Dependabot's github-actions ecosystem keeps the pin current.
|
||||
name: pr-gates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: pr-gates-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pr:
|
||||
uses: KofTwentyTwo/standards/.github/workflows/pr.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
|
||||
permissions:
|
||||
contents: read # read the commits being merged
|
||||
pull-requests: read # read the pull request title and dependency diff
|
||||
@@ -0,0 +1,70 @@
|
||||
name: prerelease
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*-rc.*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: prerelease-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
product: ${{ steps.validate.outputs.product }}
|
||||
release: ${{ steps.validate.outputs.release }}
|
||||
steps:
|
||||
- name: Validate prerelease tag
|
||||
id: validate
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
if [[ "$REF_TYPE" != tag || ! "$RELEASE_TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-rc\.([0-9]+)$ ]]; then
|
||||
echo '::error::Run this workflow on an existing vX.Y.Z-rc.N tag.'
|
||||
exit 1
|
||||
fi
|
||||
echo "product=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
verify:
|
||||
needs: version
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
product-version: ${{ needs.version.outputs.product }}
|
||||
release-version: ${{ needs.version.outputs.release }}
|
||||
permissions:
|
||||
contents: read
|
||||
publish:
|
||||
needs: [version, verify]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download verified installer
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: windows-x64-installer
|
||||
path: release
|
||||
- name: Publish unsigned prerelease
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
cat > release-notes.md <<'NOTES'
|
||||
This is an unsigned Windows x64 prerelease of QQQ Print Agent for QQQ 4.1+.
|
||||
|
||||
Assets were built and tested by this workflow. Verify the MSI against SHA256SUMS before installing. No Authenticode signature or production qualification is claimed.
|
||||
|
||||
Features: AppKit setup and tray status, explicit printer exposure, outbound cloud polling, PDF and ZPL spool submission, durable local receipts and protected service credentials. Physical laser acceptance passed locally; Zebra hardware qualification remains pending.
|
||||
|
||||
See CHANGELOG.md, docs/INSTALLATION.md and docs/ACCEPTANCE.md in the tagged source for scope and limitations.
|
||||
NOTES
|
||||
gh release create "$RELEASE_TAG" release/* --verify-tag --prerelease --title "QQQ Print Agent $RELEASE_TAG" --notes-file release-notes.md
|
||||
@@ -0,0 +1,54 @@
|
||||
# OpenSSF Scorecard (K22-CI-24). Scorecard's publishing API only accepts results
|
||||
# from a workflow shaped exactly like this one: a single job, read-only top-level
|
||||
# permissions, no top-level env or defaults, and only the allowed steps.
|
||||
name: scorecard
|
||||
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: "17 7 * * 1"
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: scorecard-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: analysis
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read # Scorecard reads workflow run metadata
|
||||
security-events: write # upload SARIF to code scanning
|
||||
id-token: write # publish results to the OpenSSF API and badge
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Scorecard
|
||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
with:
|
||||
results_file: scorecard.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
|
||||
- name: Keep the results
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: scorecard-sarif
|
||||
path: scorecard.sarif
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload SARIF to code scanning
|
||||
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
sarif_file: scorecard.sarif
|
||||
category: scorecard
|
||||
@@ -0,0 +1,42 @@
|
||||
# K22-CODE-SH: check repository tooling before merges and releases.
|
||||
name: scripts
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
powershell:
|
||||
name: powershell
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pinned analyzer and check scripts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force
|
||||
Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0
|
||||
Invoke-ScriptAnalyzer -Path build -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit
|
||||
- name: Verify coverage gate rejection paths
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force
|
||||
Import-Module Pester -RequiredVersion 5.7.1
|
||||
$testConfiguration = New-PesterConfiguration
|
||||
$testConfiguration.Run.Path = 'build/Assert-Coverage.Tests.ps1'
|
||||
$testConfiguration.Run.Exit = $true
|
||||
$testConfiguration.TestResult.Enabled = $true
|
||||
$testConfiguration.TestResult.OutputPath = 'TestResults/coverage-gate.xml'
|
||||
Invoke-Pester -Configuration $testConfiguration
|
||||
@@ -0,0 +1,27 @@
|
||||
# Security scans (K22-CI-20, K22-CI-21, K22-CI-23): gitleaks over full history,
|
||||
# Trivy + OSV-Scanner over the lock files, zizmor + actionlint over the workflows.
|
||||
# Runs on every pull request, every push to main, and weekly.
|
||||
name: security
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "23 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
workflow_call:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
uses: KofTwentyTwo/standards/.github/workflows/security.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
|
||||
permissions:
|
||||
contents: read # check out the code and its history
|
||||
security-events: write # upload Trivy and zizmor SARIF to code scanning
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from `dotnet new gitignore`
|
||||
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# Tye
|
||||
.tye/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||
!Directory.Build.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
.idea/
|
||||
|
||||
##
|
||||
## Visual studio for Mac
|
||||
##
|
||||
|
||||
|
||||
# globs
|
||||
Makefile.in
|
||||
*.userprefs
|
||||
*.usertasks
|
||||
config.make
|
||||
config.status
|
||||
aclocal.m4
|
||||
install-sh
|
||||
autom4te.cache/
|
||||
*.tar.gz
|
||||
tarballs/
|
||||
test-results/
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
# WinUI packaged apps need their publish profiles in source control
|
||||
!**/Properties/PublishProfiles/*.pubxml
|
||||
|
||||
# Default Pester CI report from local runs.
|
||||
testResults.xml
|
||||
|
||||
# Local print enrollment, journal and diagnostics (never publish)
|
||||
*.dpapi
|
||||
journal.db*
|
||||
worker.lock
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# KofTwentyTwo pre-commit configuration. Works with pre-commit and prek.
|
||||
# Copy to the repository root as .pre-commit-config.yaml, then install both hook
|
||||
# types once per clone:
|
||||
#
|
||||
# pre-commit install --hook-type pre-commit --hook-type commit-msg
|
||||
#
|
||||
# Every external hook is pinned to a full commit SHA with the release in a comment
|
||||
# (`pre-commit autoupdate --freeze` keeps that format). The hooks are the local half
|
||||
# of the CI gates in standards/ci-cd.md; CI runs the same tools again.
|
||||
default_install_hook_types: [pre-commit, commit-msg]
|
||||
default_stages: [pre-commit]
|
||||
|
||||
repos:
|
||||
# File hygiene.
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=1024]
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: detect-private-key
|
||||
- id: end-of-file-fixer
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
- id: no-commit-to-branch
|
||||
args: [--branch, main]
|
||||
|
||||
# Secrets (K22-CI-20): staged changes only, so it stays fast.
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # frozen: v8.30.1
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
|
||||
# Workflow security (K22-CI-23).
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: fa412071e4f5d44d44f9e365f4676f9df92456a2 # frozen: v1.30.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args: [--min-severity, medium]
|
||||
- repo: https://github.com/rhysd/actionlint
|
||||
rev: 914e7df21a07ef503a81201c76d2b11c789d3fca # frozen: v1.7.12
|
||||
hooks:
|
||||
- id: actionlint
|
||||
|
||||
# Commit message: Conventional Commits header (K22-SDLC commit rules).
|
||||
- repo: https://github.com/compilerla/conventional-pre-commit
|
||||
rev: 91ab4bf57e58b32adf1a122681f6ebe164d081c8 # frozen: v4.4.0
|
||||
hooks:
|
||||
- id: conventional-pre-commit
|
||||
stages: [commit-msg]
|
||||
args: [--strict, build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test]
|
||||
|
||||
# Commit message: DCO sign-off (`git commit -s`). pygrep with --negate fails when
|
||||
# no line of the message matches.
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: dco-sign-off
|
||||
name: DCO sign-off present (git commit -s)
|
||||
language: pygrep
|
||||
entry: '^Signed-off-by: .+ <.+@.+>$'
|
||||
args: [--negate]
|
||||
stages: [commit-msg]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Repository guidance
|
||||
|
||||
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.0 -ReleaseVersion 0.1.0-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. No per-user updater in the machine service package.
|
||||
- Never log or publish credentials, enrollment codes, print documents or local journals. Update docs/security/threat-model.md for boundary changes.
|
||||
- Do not physically print without the user's authorization. Describe spool acceptance separately from confirmed paper output.
|
||||
- Preserve unrelated changes and attribution. Do not commit/push/release without authorization.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0-rc.1 — 2026-10-29
|
||||
|
||||
Initial public prerelease for QQQ 4.1+.
|
||||
|
||||
### Added
|
||||
|
||||
- Windows AppKit/WinUI companion and background Windows service installed together by an x64 MSI.
|
||||
- Single-use enrollment, protected service credentials and outbound HTTPS polling using printing protocol v1.
|
||||
- Windows queue discovery with persistent local printer exposure choices.
|
||||
- System-tray health and a details surface for connection, printer status, jobs and recent service logs.
|
||||
- PDF rendering through the Windows PDF/GDI path; RAW ZPL submission for Zebra-compatible queues.
|
||||
- Local SQLite receipt/event journal, payload integrity checks and duplicate/uncertain-submission handling.
|
||||
- CLI enrollment, printer selection, status/snapshot and foreground diagnostic worker.
|
||||
- Shared Java/.NET contract fixtures, unit/Windows persistence tests, Windows CI and unsigned prerelease MSI automation.
|
||||
|
||||
### Validation and limits
|
||||
|
||||
Physical laser PDF output was confirmed locally, including the installed service path. Zebra hardware qualification remains pending. This release is unsigned and uses polling; it does not claim physical completion from a spool acknowledgment. Custom PDF media, automatic cancellation/reprint flows, retention, renderer isolation and production signing remain follow-up work. See docs/ACCEPTANCE.md.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Agent guidance
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for repository instructions and [CONTRIBUTING.md](CONTRIBUTING.md) for development conventions.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Contributing
|
||||
|
||||
QQQ Print Agent is developed in the [QRun-IO organization](https://github.com/QRun-IO). It uses the KofTwentyTwo Windows AppKit service architecture. Contributions are accepted under the [MIT license](LICENSE); preserve existing attribution.
|
||||
|
||||
## Set up
|
||||
|
||||
Use Windows x64, Git and the .NET 10 SDK selected by `global.json`. Check out `qqq-print-agent` and `qqq-print-protocol` into sibling directories. Check out the protocol commit pinned in `.github/workflows/ci.yml`; its JSON fixtures are linked into the contract tests.
|
||||
|
||||
```powershell
|
||||
./build/Verify.ps1
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.1.0 -ReleaseVersion 0.1.0-rc.1
|
||||
```
|
||||
|
||||
Verification uses locked NuGet restore, a warnings-as-errors solution build, core tests, Windows/CLI tests and a coverage report. Packaging creates a self-contained x64 MSI. Neither command installs the service or prints a document.
|
||||
|
||||
## Changes and review
|
||||
|
||||
- Branch from `main`, use Conventional Commit titles, and include a DCO sign-off (`git commit -s`). Sign commits where your account supports signing.
|
||||
- Keep business/protocol logic in Core, Windows integration in Windows, hosting in Service, and view/tray plumbing in the AppKit companion.
|
||||
- Use central versions in `Directory.Packages.props`; regenerate affected `packages.lock.json` files when dependencies change.
|
||||
- Follow `.editorconfig`: three-space indentation, Allman braces and the repository's C# analyzers. Builds must have zero warnings.
|
||||
- Add tests for behavior and failure boundaries. Run the protocol fixtures when changing the wire contract.
|
||||
- Update user/admin docs for visible changes, and the threat model when changing privileges, network interfaces or credential handling.
|
||||
- Do not include secrets, document contents, customer labels or local journals in issues, fixtures or commits.
|
||||
- Disclose substantial AI assistance in the PR and include a model-naming Co-Authored-By trailer when appropriate.
|
||||
|
||||
For the initial prerelease, coverage is reported rather than forced to the template's unattained 100% target. This is an explicit temporary exception documented in [ADR-0002](docs/adr/0002-prerelease-quality-and-packaging.md), not a claim of full coverage. Elevated desktop tests are separate from hosted CI because they need an interactive administrator session.
|
||||
|
||||
## UI and hardware tests
|
||||
|
||||
Build the app first. In an elevated interactive Windows session, set `K22_UITEST_EXE` to the companion executable and run the UI test project. Hardware testing must use a deliberately selected printer and recognizable test document. Coordinate physical printing with the machine owner; CI never prints.
|
||||
|
||||
A passing unit suite does not establish printer compatibility. Record Windows version, driver/model, format, paper/label size, and whether the output was physically inspected. Report spool acceptance separately from physical completion.
|
||||
|
||||
## Issues and releases
|
||||
|
||||
Open ordinary bugs or proposals in [Issues](https://github.com/QRun-IO/qqq-print-agent/issues). Use [private security reporting](SECURITY.md) for vulnerabilities. Release authorization and tagging belong to maintainers; see [RELEASING.md](docs/RELEASING.md).
|
||||
@@ -0,0 +1,72 @@
|
||||
<Project>
|
||||
|
||||
<!-- KofTwentyTwo .NET quality gate (standards: K22-CODE-CS, from
|
||||
KofTwentyTwo/standards configs/dotnet). Every rule fails `dotnet build` locally
|
||||
exactly as it fails in CI. -->
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<RuntimeIdentifiers Condition="'$(MSBuildProjectExtension)' != '.wixproj'">win-x64</RuntimeIdentifiers>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
<!-- Warnings are errors; analyzers and .editorconfig style run in every build. -->
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<!-- Required for IDE0005 (unused usings) to run during build. CS1591 (missing XML
|
||||
doc) stays on for packable projects and is silenced for everything else below. -->
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
<!-- Reproducible builds with source-linked symbols. -->
|
||||
<Deterministic>true</Deterministic>
|
||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
|
||||
<!-- Supply chain: audit direct AND transitive packages; pin the whole graph. -->
|
||||
<NuGetAudit>true</NuGetAudit>
|
||||
<NuGetAuditMode>all</NuGetAuditMode>
|
||||
<NuGetAuditLevel>low</NuGetAuditLevel>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- CI: normalized paths, and restore must match the committed lock files exactly. -->
|
||||
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(CI)' == 'true'">
|
||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||
<RestoreLockedMode>true</RestoreLockedMode>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Only shipped libraries must document their public API. -->
|
||||
<PropertyGroup Condition="'$(IsPackable)' != 'true'">
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Repository-wide analyzers; versions live in Directory.Packages.props. -->
|
||||
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Single version source. Local builds are <prefix>-dev; the release workflow passes
|
||||
-p:Version=<tag-version>, which overrides both parts. SourceLink appends the git
|
||||
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
|
||||
CLI's version output show as a short hash. -->
|
||||
<PropertyGroup>
|
||||
<VersionPrefix>0.1.0</VersionPrefix>
|
||||
<VersionSuffix>rc.1</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Product metadata stamped into every assembly. -->
|
||||
<PropertyGroup>
|
||||
<Authors>James Maes</Authors>
|
||||
<Product>QQQ Print Agent</Product>
|
||||
<Copyright>Copyright (c) 2026 James Maes</Copyright>
|
||||
<RepositoryUrl>https://github.com/QRun-IO/qqq-print-agent</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project>
|
||||
|
||||
<!-- Central package management (K22-CODE-CS-08): every version lives here, so
|
||||
Dependabot bumps one line and all projects move together. Transitive pinning
|
||||
lets a version here override a vulnerable transitive dependency. -->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Label="Analyzers">
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.296" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="KofTwentyTwo AppKit">
|
||||
<PackageVersion Include="KofTwentyTwo.AppKit" Version="0.1.0" />
|
||||
<PackageVersion Include="KofTwentyTwo.AppKit.Updates" Version="0.1.0" />
|
||||
<PackageVersion Include="KofTwentyTwo.AppKit.WinUI" Version="0.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
|
||||
<PackageVersion Include="H.NotifyIcon.WinUI" Version="2.4.1" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Tests">
|
||||
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
|
||||
<PackageVersion Include="FlaUI.UIA3" Version="5.0.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup><PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="10.0.0" /><PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" /><PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" /><PackageVersion Include="SQLitePCLRaw.provider.winsqlite3" Version="2.1.11" /></ItemGroup></Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 James Maes
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
QQQ Print Agent
|
||||
Copyright (c) 2026 James Maes
|
||||
|
||||
This project was generated from the KofTwentyTwo Windows application template:
|
||||
https://github.com/KofTwentyTwo/windows-app-template
|
||||
and uses KofTwentyTwo.AppKit / KofTwentyTwo.AppKit.WinUI:
|
||||
https://github.com/KofTwentyTwo/AppKit
|
||||
Their MIT attribution is retained in LICENSE and source files.
|
||||
|
||||
Other dependencies include .NET, Windows App SDK/WinUI, Microsoft.Data.Sqlite,
|
||||
SQLitePCLRaw, CommunityToolkit.Mvvm and WiX. These projects retain their own
|
||||
licenses and copyright notices. Windows OS printing/PDF components and bundled
|
||||
Microsoft runtime binaries remain subject to their corresponding terms.
|
||||
|
||||
The QQQ framework, qbit-printing and qqq-print-protocol are separate repositories;
|
||||
this notice does not change their licensing.
|
||||
@@ -0,0 +1,56 @@
|
||||
# KofTwentyTwo PowerShell analysis (K22-CODE-SH-05). CI runs:
|
||||
# Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit
|
||||
# and `Invoke-Formatter` with the same settings for layout. PowerShell supports the
|
||||
# Kingsrook layout fully: next-line (Allman) braces and 3-space indentation.
|
||||
@{
|
||||
Severity = @('Error', 'Warning', 'Information')
|
||||
IncludeDefaultRules = $true
|
||||
ExcludeRules = @()
|
||||
Rules = @{
|
||||
PSPlaceOpenBrace = @{
|
||||
Enable = $true
|
||||
OnSameLine = $false
|
||||
NewLineAfter = $true
|
||||
IgnoreOneLineBlock = $true
|
||||
}
|
||||
PSPlaceCloseBrace = @{
|
||||
Enable = $true
|
||||
NewLineAfter = $true
|
||||
IgnoreOneLineBlock = $true
|
||||
NoEmptyLineBefore = $true
|
||||
}
|
||||
PSUseConsistentIndentation = @{
|
||||
Enable = $true
|
||||
IndentationSize = 3
|
||||
Kind = 'space'
|
||||
PipelineIndentation = 'IncreaseIndentationForFirstPipeline'
|
||||
}
|
||||
PSUseConsistentWhitespace = @{
|
||||
Enable = $true
|
||||
CheckInnerBrace = $true
|
||||
# Kingsrook writes if(...) with no space; the rule cannot require that, so it
|
||||
# is not checked either way.
|
||||
CheckOpenParen = $false
|
||||
CheckOpenBrace = $false
|
||||
# Kingsrook aligns the `=` of consecutive assignments (PSAlignAssignmentStatement
|
||||
# below); CheckOperator would then flag every aligned line, so it is off.
|
||||
CheckOperator = $false
|
||||
CheckPipe = $true
|
||||
CheckSeparator = $true
|
||||
CheckParameter = $true
|
||||
}
|
||||
PSAlignAssignmentStatement = @{
|
||||
Enable = $true
|
||||
CheckHashtable = $true
|
||||
}
|
||||
PSUseCorrectCasing = @{ Enable = $true }
|
||||
PSAvoidUsingCmdletAliases = @{ Enable = $true }
|
||||
PSAvoidUsingWriteHost = @{ Enable = $true }
|
||||
PSProvideCommentHelp = @{
|
||||
Enable = $true
|
||||
ExportedOnly = $false
|
||||
BlockComment = $true
|
||||
Placement = 'before'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Solution>
|
||||
<Configurations>
|
||||
<Platform Name="ARM64" />
|
||||
<Platform Name="x64" />
|
||||
<Platform Name="x86" />
|
||||
</Configurations>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Qqq.PrintAgent/Qqq.PrintAgent.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
<Platform Solution="*|x86" Project="x86" />
|
||||
</Project>
|
||||
<Project Path="src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj" />
|
||||
<Project Path="src/Qqq.PrintAgent.Cli/Qqq.PrintAgent.Cli.csproj" />
|
||||
<Project Path="src/Qqq.PrintAgent.Windows/Qqq.PrintAgent.Windows.csproj" />
|
||||
<Project Path="src/Qqq.PrintAgent.Service/Qqq.PrintAgent.Service.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Qqq.PrintAgent.Core.Tests/Qqq.PrintAgent.Core.Tests.csproj" />
|
||||
<Project Path="tests/Qqq.PrintAgent.Cli.Tests/Qqq.PrintAgent.Cli.Tests.csproj" />
|
||||
<Project Path="tests/Qqq.PrintAgent.UiTests/Qqq.PrintAgent.UiTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# QQQ Print Agent
|
||||
|
||||
[](https://github.com/QRun-IO/qqq-print-agent/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
Print from **QQQ 4.1+** to printers at a local facility without opening inbound network ports. QQQ Print Agent runs as a Windows service, connects outward to QQQ, and submits documents to the printers installed for its Windows service account.
|
||||
|
||||
The native setup and status application uses the same [KofTwentyTwo AppKit](https://github.com/KofTwentyTwo/AppKit) foundation as GCLO and MarkdownViewr.Windows.
|
||||
|
||||
> **v0.1.0-rc.1 is a prerelease.** The first release focuses on enrollment, printer discovery, PDF/ZPL jobs and submission status. The MSI and first-party executables are unsigned. Windows may display an unrecognized-publisher prompt. 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.
|
||||
- See connection health in the system tray and inspect printer status, jobs and recent logs.
|
||||
- Print packing slips and other PDFs through the installed Windows driver.
|
||||
- Send printer-ready ZPL to Zebra/ZDesigner queues.
|
||||
- Enroll once using a short-lived code from QQQ; the service stores its own protected device credential.
|
||||
- Keep printing when the desktop app is closed or the user logs out.
|
||||
- Preserve job receipts and outgoing status events across service restarts.
|
||||
- Avoid automatically printing a job again when a previous submission is uncertain.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q[QQQ 4.1+ printing QBit] <-->|Outbound HTTPS polling| S[Windows service]
|
||||
U[AppKit setup and status app] -->|Local administrator pipe| S
|
||||
S --> W[Windows spooler]
|
||||
W --> L[Laser printer: PDF]
|
||||
W --> Z[Zebra printer: ZPL]
|
||||
```
|
||||
|
||||
The companion projects are [qbit-printing](https://github.com/QRun-IO/qbit-printing), the QQQ server integration, and [qqq-print-protocol](https://github.com/QRun-IO/qqq-print-protocol), the shared versioned API contract. The agent does not require a separate cloud printing vendor.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
The QQQ URL must use HTTPS. HTTP is accepted only for loopback development such as `http://localhost:18081`. No inbound firewall rule is installed. Existing outbound proxy/network policies and local printer connectivity still apply.
|
||||
|
||||
See the [installation guide](docs/INSTALLATION.md) for verification, service identity, upgrade and removal instructions, and the [user guide](docs/USER-GUIDE.md) for the setup screen and job statuses.
|
||||
|
||||
## Requirements and supported behavior
|
||||
|
||||
| Area | Initial support |
|
||||
| --- | --- |
|
||||
| Server | QQQ 4.1+ with the printing QBit and protocol v1 |
|
||||
| Windows | x64; Windows 11 used for hardware acceptance; API target Windows 10 build 19041+ |
|
||||
| Runtime | Bundled with the MSI; users do not install .NET separately |
|
||||
| Printers | Installed Windows queues accessible to LocalSystem, the default service account |
|
||||
| Documents | PDF and ZPL, at most 20 MiB per job |
|
||||
| PDF options | Copies, actual size or fit; queue-default paper/tray settings |
|
||||
| ZPL options | Copies; label dimensions/DPI encoded in printer-ready ZPL |
|
||||
| Transport | Outbound HTTPS polling, normally every five seconds |
|
||||
|
||||
A printer visible only in a signed-in user's profile may not be available to the service. Configure the printer for the machine and test through the installed service. Virtual PDF/fax queues are not suitable for unattended physical printing.
|
||||
|
||||
`SPOOLED` means Windows accepted the job. It does **not** prove that paper came out. If the agent cannot determine whether a previous submission succeeded, it reports `OUTCOME_UNKNOWN`; an operator should inspect the printer before requesting another copy.
|
||||
|
||||
## Validation so far
|
||||
|
||||
The 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).
|
||||
|
||||
The Zebra adapter is implemented, but physical Zebra qualification is pending connection of the printer. Automated tests cover the protocol fixtures, content integrity, duplicate suppression, uncertain submission, durable SQLite events and protected credential round trips. Test counts and remaining limits are recorded in the acceptance notes.
|
||||
|
||||
## Development
|
||||
|
||||
Requires Windows x64 and the .NET 10 SDK selected by `global.json`. Check out this repository and the protocol repository as siblings; use the pinned protocol commit in `.github/workflows/ci.yml`.
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/QRun-IO/qqq-print-agent.git
|
||||
git clone https://github.com/QRun-IO/qqq-print-protocol.git
|
||||
Set-Location qqq-print-agent
|
||||
./build/Verify.ps1
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.1.0 -ReleaseVersion 0.1.0-rc.1
|
||||
```
|
||||
|
||||
[Developer guide](docs/DEVELOPMENT.md) · [Administrator guide](docs/ADMINISTRATION.md) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [Release process](docs/RELEASING.md) · [Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
|
||||
|
||||
## Current limits
|
||||
|
||||
This prerelease uses polling rather than WebSocket notifications. Custom PDF paper dimensions are rejected; use the Windows queue's default media. Cancellation/reprint administration, richer hardware telemetry, document/journal retention, renderer subprocess isolation, automatic upgrades and production signing are not complete. Review the [roadmap](docs/TODO.md) before a production rollout.
|
||||
|
||||
## License and acknowledgments
|
||||
|
||||
[MIT](LICENSE). The original Windows template and AppKit copyright notices are preserved. See [NOTICE](NOTICE) for the foundation and bundled dependency acknowledgments. QQQ and the printing QBit are separate projects with their own licenses.
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Security policy
|
||||
|
||||
## Report privately
|
||||
|
||||
Do not publish a security vulnerability as a normal issue. Use [GitHub private vulnerability reporting](https://github.com/QRun-IO/qqq-print-agent/security/advisories/new). Include the affected version, reproduction steps, impact and sanitized evidence. Do not send live enrollment codes, device credentials, customer documents or local print databases.
|
||||
|
||||
The initial release is a prerelease for controlled evaluation. Fixes target the latest prerelease; there is no long-term support commitment or independently audited production security certification.
|
||||
|
||||
## Trust and privileges
|
||||
|
||||
The machine service runs as LocalSystem to access machine-installed queues. The companion requires local administrator rights and communicates over an administrator-only named pipe. Treat a trusted QQQ server as authorized to supply printer documents to the explicitly exposed queues. RAW ZPL is printer control data and must originate from trusted generation workflows.
|
||||
|
||||
Device credentials are stored with DPAPI under the service identity in an MSI-restricted ProgramData directory. They are never included in tray snapshots. The local event journal may contain operational job IDs and error text; protect it as private operational data.
|
||||
|
||||
Remote QQQ endpoints require HTTPS. Plain HTTP is allowed only on loopback for development. Hash/length checks detect changed payloads; they do not make an untrusted PDF or ZPL document safe. Keep Windows, drivers and the OS renderer patched.
|
||||
|
||||
## Distribution
|
||||
|
||||
The v0.1.0-rc.1 MSI and first-party executables are **unsigned**. Download from the official repository release and compare SHA256SUMS. No Authenticode identity or provenance attestation is claimed. Production signing, renderer process isolation, broader driver testing and operational hardening remain open work.
|
||||
|
||||
See [the threat model](docs/security/threat-model.md) for implemented controls and residual risks.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Support
|
||||
|
||||
Use [GitHub Issues](https://github.com/QRun-IO/qqq-print-agent/issues/new/choose) for bugs, setup questions and feature requests. Check the installation and troubleshooting guides first. Include version, Windows build, printer/driver and sanitized error details.
|
||||
|
||||
Do not attach enrollment codes, credentials, customer documents or local journals. Report vulnerabilities privately as described in SECURITY.md. This prerelease does not provide a commercial support SLA.
|
||||
@@ -0,0 +1,74 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Continue Pester commands so their Allman scriptblocks stay part of the call.
|
||||
BeforeAll `
|
||||
{
|
||||
$script:CoverageGate = Join-Path $PSScriptRoot 'Assert-Coverage.ps1'
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes a synthetic report so tests can exercise release-gate exit codes.
|
||||
#>
|
||||
function Write-CoverageReport
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Directory,
|
||||
[Parameter(Mandatory)] [ValidateRange(0, 1)] [double] $Rate
|
||||
)
|
||||
|
||||
$rateText = $Rate.ToString([Globalization.CultureInfo]::InvariantCulture)
|
||||
$secondLineHits = if ($Rate -eq 1) { 1 } else { 0 }
|
||||
$report = @"
|
||||
<coverage><packages><package name="Probe" line-rate="$rateText"><classes>
|
||||
<class filename="Probe.cs"><lines><line number="1" hits="1" /><line number="2" hits="$secondLineHits" /></lines></class>
|
||||
</classes></package></packages></coverage>
|
||||
"@
|
||||
Set-Content -LiteralPath (Join-Path $Directory 'coverage.cobertura.xml') -Value $report
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Required coverage gate' `
|
||||
{
|
||||
BeforeEach `
|
||||
{
|
||||
$script:ReportsDirectory = Join-Path $TestDrive ([guid]::NewGuid().ToString())
|
||||
New-Item -ItemType Directory -Path $script:ReportsDirectory | Out-Null
|
||||
}
|
||||
|
||||
It 'rejects a run that produced no report' `
|
||||
{
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match 'No coverage.cobertura.xml'
|
||||
}
|
||||
|
||||
It 'rejects an absent required package' `
|
||||
{
|
||||
Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Missing 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match 'Missing.*not found'
|
||||
}
|
||||
|
||||
It 'rejects coverage below the required percentage' `
|
||||
{
|
||||
Write-CoverageReport -Directory $script:ReportsDirectory -Rate 0.5
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match 'Coverage gate FAILED'
|
||||
}
|
||||
|
||||
It 'accepts full coverage of the required package' `
|
||||
{
|
||||
Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
$output | Should -Match 'Coverage gate passed'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#Requires -Version 7.4
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Fails when any named assembly is below the required line coverage.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads every coverage.cobertura.xml under -ResultsDirectory (one per test project
|
||||
run) and, for each package, takes the best line rate any report measured. A package
|
||||
can be split across test projects, and the report that exercises it most is
|
||||
authoritative. Without -Packages, every package the reports measured is gated:
|
||||
coverage.runsettings already limits measurement to the assemblies held to the bar.
|
||||
Exits 1 on any shortfall, or when a package named in -Packages is missing.
|
||||
|
||||
.EXAMPLE
|
||||
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults
|
||||
|
||||
.EXAMPLE
|
||||
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages Qqq.PrintAgent.Core
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $ResultsDirectory,
|
||||
[string[]] $Packages = @(),
|
||||
[ValidateRange(0, 100)] [double] $MinimumPercent = 100
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# `pwsh -File` passes "A,B" as one string; accept that as well as a real array.
|
||||
$Packages = @($Packages | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
|
||||
$files = @(Get-ChildItem -Path $ResultsDirectory -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue)
|
||||
if ($files.Count -eq 0)
|
||||
{
|
||||
Write-Error "No coverage.cobertura.xml found under '$ResultsDirectory'. Did the coverage collector run?" -ErrorAction Continue
|
||||
exit 1
|
||||
}
|
||||
|
||||
$reports = foreach ($file in $files) { [xml](Get-Content -LiteralPath $file.FullName -Raw) }
|
||||
if ($Packages.Count -eq 0)
|
||||
{
|
||||
$Packages = @($reports | ForEach-Object { @($_.coverage.packages.package) } | ForEach-Object { $_.name } | Where-Object { $_ } | Sort-Object -Unique)
|
||||
if ($Packages.Count -eq 0)
|
||||
{
|
||||
Write-Error 'The coverage reports measured no packages; check the Include filter in coverage.runsettings.' -ErrorAction Continue
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
$required = $MinimumPercent / 100
|
||||
$failed = $false
|
||||
|
||||
foreach ($name in $Packages)
|
||||
{
|
||||
$rates = foreach ($doc in $reports)
|
||||
{
|
||||
$package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
|
||||
if ($package) { [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture) }
|
||||
}
|
||||
if (-not $rates)
|
||||
{
|
||||
Write-Error "Package '$name' not found in any coverage report." -ErrorAction Continue
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
|
||||
$best = ($rates | Measure-Object -Maximum).Maximum
|
||||
Write-Output ("{0} line coverage: {1}% (required {2}%)" -f $name, [math]::Round($best * 100, 2), $MinimumPercent)
|
||||
if ($best -lt $required)
|
||||
{
|
||||
foreach ($doc in $reports)
|
||||
{
|
||||
$package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
|
||||
foreach ($class in @($package.classes.class))
|
||||
{
|
||||
$missed = @($class.lines.line) | Where-Object { $_.hits -eq '0' } | ForEach-Object { $_.number }
|
||||
if ($missed) { Write-Output " uncovered: $($class.filename) lines $($missed -join ', ')" }
|
||||
}
|
||||
}
|
||||
Write-Error "Coverage gate FAILED: $name is below $MinimumPercent%." -ErrorAction Continue
|
||||
$failed = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed) { exit 1 }
|
||||
Write-Output "Coverage gate passed."
|
||||
@@ -0,0 +1,72 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
||||
[string] $ProductVersion = '0.1.0',
|
||||
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
|
||||
[string] $ReleaseVersion = '0.1.0-rc.1',
|
||||
[switch] $SkipPublish,
|
||||
[string] $CertificateThumbprint,
|
||||
[string] $TimestampServer
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$payloadRoot = Join-Path $projectRoot 'artifacts\package'
|
||||
$installerProject = Join-Path $projectRoot 'installer\Qqq.PrintAgent.Installer.wixproj'
|
||||
$outputDirectory = Join-Path $projectRoot 'artifacts\installer'
|
||||
|
||||
# Each process has its own complete runtime directory; upgrades replace one coherent MSI.
|
||||
if(-not $SkipPublish)
|
||||
{
|
||||
$projects = @{
|
||||
service = 'src\Qqq.PrintAgent.Service\Qqq.PrintAgent.Service.csproj'
|
||||
app = 'src\Qqq.PrintAgent\Qqq.PrintAgent.csproj'
|
||||
cli = 'src\Qqq.PrintAgent.Cli\Qqq.PrintAgent.Cli.csproj'
|
||||
}
|
||||
foreach($entry in $projects.GetEnumerator())
|
||||
{
|
||||
& dotnet publish (Join-Path $projectRoot $entry.Value) --configuration Release --runtime win-x64 --self-contained true --output (Join-Path $payloadRoot $entry.Key) '-p:Platform=x64' "-p:Version=$ReleaseVersion"
|
||||
if($LASTEXITCODE -ne 0) { throw "Publishing $($entry.Key) failed." }
|
||||
}
|
||||
}
|
||||
|
||||
$executables = @(
|
||||
(Join-Path $payloadRoot 'service\Qqq.PrintAgent.Service.exe'),
|
||||
(Join-Path $payloadRoot 'app\Qqq.PrintAgent.exe'),
|
||||
(Join-Path $payloadRoot 'cli\qqq-print-agent.exe')
|
||||
)
|
||||
foreach($executable in $executables)
|
||||
{
|
||||
if(-not (Test-Path -LiteralPath $executable)) { throw "Missing published executable: $executable" }
|
||||
}
|
||||
|
||||
# A supplied certificate opts into signing; unsigned local builds are clearly labelled.
|
||||
if($CertificateThumbprint)
|
||||
{
|
||||
if(-not $TimestampServer) { throw 'TimestampServer is required when signing.' }
|
||||
$signTool = (Get-Command signtool.exe -ErrorAction Stop).Source
|
||||
foreach($executable in $executables)
|
||||
{
|
||||
& $signTool sign /sha1 $CertificateThumbprint /fd SHA256 /tr $TimestampServer /td SHA256 $executable
|
||||
if($LASTEXITCODE -ne 0) { throw "Signing failed: $executable" }
|
||||
}
|
||||
}
|
||||
|
||||
& dotnet build $installerProject --configuration Release '-p:RestoreLockedMode=true' "-p:ProductVersion=$ProductVersion" "-p:PayloadRoot=$payloadRoot" "-p:OutputPath=$outputDirectory\"
|
||||
if($LASTEXITCODE -ne 0) { throw 'MSI build failed.' }
|
||||
$msi = Join-Path $outputDirectory "QqqPrintAgent-$ProductVersion-x64.msi"
|
||||
if(-not (Test-Path -LiteralPath $msi)) { throw 'MSI output was not produced.' }
|
||||
|
||||
if($CertificateThumbprint)
|
||||
{
|
||||
& $signTool sign /sha1 $CertificateThumbprint /fd SHA256 /tr $TimestampServer /td SHA256 $msi
|
||||
if($LASTEXITCODE -ne 0) { throw 'MSI signing failed.' }
|
||||
& $signTool verify /pa $msi
|
||||
if($LASTEXITCODE -ne 0) { throw 'MSI signature verification failed.' }
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning 'Unsigned development MSI. Sign through the release workflow before distribution.'
|
||||
}
|
||||
Get-FileHash -LiteralPath $msi -Algorithm SHA256
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$protocolFixtures = Join-Path (Split-Path -Parent $projectRoot) 'qqq-print-protocol\fixtures'
|
||||
if(-not (Test-Path -LiteralPath $protocolFixtures))
|
||||
{
|
||||
throw 'Check out the pinned qqq-print-protocol repository beside qqq-print-agent before running verification.'
|
||||
}
|
||||
Push-Location $projectRoot
|
||||
try
|
||||
{
|
||||
& dotnet restore Qqq.PrintAgent.slnx '-p:Platform=x64' --locked-mode
|
||||
if($LASTEXITCODE -ne 0) { throw 'Locked dependency restore failed.' }
|
||||
& dotnet build Qqq.PrintAgent.slnx --configuration Release '-p:Platform=x64' --no-restore -warnaserror
|
||||
if($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||
foreach($project in @('tests/Qqq.PrintAgent.Core.Tests', 'tests/Qqq.PrintAgent.Cli.Tests'))
|
||||
{
|
||||
& dotnet test $project --configuration Release '-p:Platform=x64' --no-restore --logger trx --collect 'XPlat Code Coverage' --results-directory TestResults
|
||||
if($LASTEXITCODE -ne 0) { throw "Tests failed: $project" }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Coverage contract for the 100% gate (K22-TEST-10): every hand-written line of the
|
||||
UI-free core and the command line counts. There is deliberately no Include filter:
|
||||
coverlet measures the projects the unit tests reference whose sources are in this
|
||||
repository (Core and the CLI), never test assemblies or NuGet packages, and an Include
|
||||
filter would silently drop the CLI, whose assembly name (the app id) contains dashes.
|
||||
Excluded, with intent:
|
||||
- GeneratedCodeAttribute: source-generated JSON contexts and MVVM Toolkit members.
|
||||
- ExcludeFromCodeCoverageAttribute: thin adapters a test cannot reach, each with a
|
||||
justification at its declaration.
|
||||
The WinUI project is not measured: its views need a live compositor and are covered
|
||||
by the FlaUI tests. Logic belongs in Qqq.PrintAgent.Core instead. -->
|
||||
<RunSettings>
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<DataCollector friendlyName="XPlat Code Coverage">
|
||||
<Configuration>
|
||||
<Format>cobertura</Format>
|
||||
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
</DataCollectors>
|
||||
</DataCollectionRunSettings>
|
||||
</RunSettings>
|
||||
@@ -0,0 +1,20 @@
|
||||
# Acceptance evidence
|
||||
|
||||
## v0.1.0-rc.1 — local qualification, 2026-10-29
|
||||
|
||||
The initial implementation was exercised on Windows x64 with the actual Java printing server and a locally installed Canon laser queue.
|
||||
|
||||
- CLI enrollment, printer inventory, server-side queue enablement, PDF submission, download integrity verification, native Windows rendering/spooling and status acknowledgment completed end to end.
|
||||
- The user confirmed the physical one-page PDF output was correct.
|
||||
- The machine MSI was installed, the LocalSystem service path was exercised, and the MSI was uninstalled during the local acceptance session. Refer to the release notes for the final installed-service result; this document does not claim a broad driver matrix.
|
||||
- Core unit/contract tests: 12 passing. CLI/Windows tests: 7 passing, including SQLite restart/pending-event persistence, DPAPI protection/round trip, persisted exposure, submission rejection after deselection and credential-free snapshots.
|
||||
- AppKit companion, service, CLI and installer compile/package validation completed locally with warnings treated as errors.
|
||||
|
||||
## Not established by this acceptance
|
||||
|
||||
Physical Zebra/ZPL output is pending connection of the Zebra printer. Automated elevated desktop/FlaUI execution, printer-driver stress/large documents, unattended reboot/logoff scenarios, MSI upgrade compatibility, proxy authentication, long-running retention and signed production distribution require additional qualification.
|
||||
|
||||
A successful spool acknowledgment is distinct from the user's physical-output confirmation. The automated test reports the former; the hardware acceptance included the latter for the tested laser setup.
|
||||
|
||||
The added tray/exposure feature subsequently passed the full Release build and 19 automated tests. Final installer/UI acceptance for this feature is recorded by the release owner; the earlier physical laser confirmation should not be mistaken for a complete tray/color hardware matrix.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Administrator operations
|
||||
|
||||
## Service and data
|
||||
|
||||
| Item | Location / identity |
|
||||
| --- | --- |
|
||||
| Service | `QqqPrintAgent`, automatic, LocalSystem |
|
||||
| Local management | Administrator-only named pipe `QqqPrintAgent` |
|
||||
| Service data | `%ProgramData%\QqqPrintAgent` |
|
||||
| Credential | `connection.dpapi`, Windows DPAPI CurrentUser under LocalSystem |
|
||||
| Submission journal | `journal.db` and SQLite WAL/SHM files |
|
||||
| Worker coordination | Exclusive `worker.lock` in the data directory |
|
||||
| Desktop preferences/logs | AppKit's per-user `qqq-print-agent` directory |
|
||||
|
||||
The MSI applies SYSTEM/Administrators permissions to the service directory. Do not loosen those ACLs or copy its credential to another machine/account. Backups should treat the entire directory as sensitive. Do not upload the database or protected configuration to public issues.
|
||||
|
||||
## Operational checks
|
||||
|
||||
```powershell
|
||||
Get-Service QqqPrintAgent, Spooler
|
||||
& 'C:\Program Files\QQQ Print Agent\Cli\qqq-print-agent.exe' service-status
|
||||
```
|
||||
|
||||
Use the actual install path if it differs. The companion's printer list reflects the service identity. Running CLI `printers` directly reflects the current CLI account instead, which may see different queues.
|
||||
|
||||
Windows service messages are emitted through .NET hosting logging. Review Windows Event Viewer and the desktop activity log for the relevant timestamp; record job IDs and error text without customer document contents.
|
||||
|
||||
## Credentials and network
|
||||
|
||||
QQQ issues an enrollment code and a dedicated device credential. Each device is scoped by the server; the connector may claim and download only its assigned work. Revoke the device in QQQ when decommissioning a machine. Revocation cannot recall a job already accepted by the Windows spooler.
|
||||
|
||||
Only outbound HTTPS is required. The initial agent polls every five seconds. HTTPS validation uses Windows/.NET trust; do not disable certificate checks. Local `http://localhost` is a development exception, not a production endpoint. Proxy-specific authentication has not been qualified.
|
||||
|
||||
## Restart and uncertainty
|
||||
|
||||
The local journal records submission state and pending cloud events. Restarting preserves those records. If an attempt was interrupted during submission, the worker avoids silently submitting it again and reports an uncertain outcome when reclaimed. Do not erase or manually edit rows to force progress. Inspect the physical queue and coordinate an explicit reprint if needed.
|
||||
|
||||
There is no global transaction spanning the cloud database and a printer. `SPOOLED` is the observable success boundary in this release.
|
||||
|
||||
## Capacity and retention
|
||||
|
||||
Documents are limited to 20 MiB and copies to 1–100. The worker serializes its jobs. The native renderer is bounded but still runs in the service process. Journal/event retention and renderer subprocess isolation are planned; monitor disk use and use this prerelease in a controlled pilot rather than assuming unbounded production capacity.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Developer guide
|
||||
|
||||
## Architecture
|
||||
|
||||
| Project | Responsibility |
|
||||
| --- | --- |
|
||||
| Qqq.PrintAgent.Core | Wire DTOs/client, submission state machine and exposure filter |
|
||||
| Qqq.PrintAgent.Windows | Native spooler/PDF adapter, SQLite/DPAPI store and local management runtime |
|
||||
| Qqq.PrintAgent.Service | .NET Windows Service host |
|
||||
| Qqq.PrintAgent | AppKit/WinUI setup, details and tray companion |
|
||||
| Qqq.PrintAgent.Cli | Scriptable setup and foreground diagnostics |
|
||||
|
||||
The service owns the credential, journal and print operations. The UI is optional at runtime and sees only credential-free snapshots. Service IPC uses the `QqqPrintAgent` named pipe and accepts administrator clients. `snapshot` returns connection health, all locally discovered printers with exposure state, the latest 50 jobs and the latest 100 in-memory log entries. `set-exposure` persists a selected queue and returns a fresh snapshot. Legacy `status`, `printers` and `enroll` operations remain available.
|
||||
|
||||
The cloud API lives in [qqq-print-protocol](https://github.com/QRun-IO/qqq-print-protocol). Its inventory is the complete currently exposed printer set. The server separately owns authorization/approval. Removing local exposure filters inventory and prevents new local submission to that queue. It cannot retract a job already handed to Windows.
|
||||
|
||||
## Reproducible checks
|
||||
|
||||
Check out this repository and the protocol repository as siblings, then pin the protocol checkout to the exact SHA in `.github/workflows/ci.yml`. All package versions are centralized and lock files are committed.
|
||||
|
||||
```powershell
|
||||
./build/Verify.ps1
|
||||
```
|
||||
|
||||
This performs locked restore, Release solution build, core tests and Windows/CLI tests, with coverage/TRX output under TestResults. UI tests compile in the solution but are not automatically run by hosted CI. Use an elevated interactive desktop and `K22_UITEST_EXE` for those tests.
|
||||
|
||||
## Local server and CLI
|
||||
|
||||
Start the sibling qbit-printing demo as described in its README. The default local API origin is `http://localhost:18081`. The demo's administrative credential is a local harness mechanism, not the production QQQ authentication design.
|
||||
|
||||
```powershell
|
||||
$agent = '.\src\Qqq.PrintAgent.Cli\bin\x64\Debug\net10.0-windows10.0.19041.0\qqq-print-agent.exe'
|
||||
$data = Join-Path $env:LOCALAPPDATA 'QqqPrintAgent-Development'
|
||||
& $agent printers
|
||||
# Enrollment code is read from QQQ_PRINT_ENROLLMENT_CODE or the prompt.
|
||||
& $agent enroll --server http://localhost:18081 --data-dir $data
|
||||
& $agent expose --printer '<discovered-printer-id>' --exposed true --data-dir $data
|
||||
& $agent run --data-dir $data --once
|
||||
```
|
||||
|
||||
Enable the selected queue on the server, submit a recognizable test document, then run the worker again. Without `--once`, it polls until Ctrl+C. Use a dedicated private directory; do not share it with the installed service. The exclusive worker.lock prevents simultaneous workers on one journal.
|
||||
|
||||
For installed service management use elevated `enroll --service`, `expose --service`, `service-status` or `snapshot`. The CLI `printers` command runs under the invoking account; the service snapshot is authoritative for service-visible queues.
|
||||
|
||||
## Reliability boundary
|
||||
|
||||
The worker persists receipt and SUBMITTING before invoking Windows. It records SPOOLED and the spooler job ID afterward and replays pending cloud events. A crash between spool acceptance and recording cannot be made atomic. The worker uses OUTCOME_UNKNOWN rather than automatic reprint. Preserve that distinction when extending retry/cancellation logic.
|
||||
|
||||
## Packaging
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.1.0 -ReleaseVersion 0.1.0-rc.1
|
||||
```
|
||||
|
||||
MSI ProductVersion is numeric; assembly/package informational version may contain the prerelease suffix. Service, companion and CLI are published into separate self-contained runtime directories and shipped in one machine MSI. Do not reintroduce the template's per-user Velopack updater.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Install QQQ Print Agent
|
||||
|
||||
## Download and verify
|
||||
|
||||
Download the x64 MSI and `SHA256SUMS` from the same [GitHub release](https://github.com/QRun-IO/qqq-print-agent/releases). Check the file locally:
|
||||
|
||||
```powershell
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath .\QqqPrintAgent-v0.1.0-rc.1-x64.msi
|
||||
Get-Content .\SHA256SUMS
|
||||
```
|
||||
|
||||
Compare the complete hash with the entry for the downloaded MSI. Checksums detect changes after publication; they do not replace publisher signing. This prerelease is **unsigned** and no Authenticode or provenance attestation is claimed. Download only from the project's release page and follow your organization's software-install policy.
|
||||
|
||||
## Install
|
||||
|
||||
Run the MSI as an administrator. Windows Installer deploys one coherent application:
|
||||
|
||||
- The `QqqPrintAgent` automatic Windows service, running as LocalSystem and depending on the Windows Spooler.
|
||||
- The native **QQQ Print Agent** setup/status app and Start-menu entry.
|
||||
- The `qqq-print-agent.exe` diagnostic CLI.
|
||||
- A restricted `%ProgramData%\QqqPrintAgent` directory for enrollment and the job journal.
|
||||
|
||||
There is no inbound firewall rule, local HTTP server, or desktop self-updater. Keep outbound HTTPS available to the QQQ server. The UI requests administrator elevation because its management pipe permits only SYSTEM and local administrators.
|
||||
|
||||
## Prepare printers
|
||||
|
||||
Install each printer and its appropriate vendor driver in Windows. Print a normal Windows test page, then discover the queues through QQQ Print Agent. The installed service must have access; a queue mapped only under a user's profile is insufficient. Use machine-installed USB/TCP/IP queues where possible. Network share authentication may require a deliberately configured service identity; that variant has not been qualified for this prerelease.
|
||||
|
||||
Set the queue's default paper/tray/orientation for PDF printing. The initial API supports actual size or fit and copies, but rejects custom media dimensions. For ZPL, configure compatible Zebra/ZDesigner drivers and generate ZPL matching the printer's DPI and label stock.
|
||||
|
||||
## Enroll
|
||||
|
||||
Create a single-use enrollment code in QQQ. Open the companion, enter the QQQ server origin and code, then connect. Enrollment runs in the service so its credential is protected under the service identity. The desktop stores no device token.
|
||||
|
||||
Do not enroll the development CLI under your user and copy its protected file into ProgramData: DPAPI credentials are bound to the account that created them. Use the companion or an elevated CLI `enroll --service` command.
|
||||
|
||||
## Upgrade and remove
|
||||
|
||||
Install a newer MSI as administrator. The installer stops and replaces the service and companion together. Current authoring retains ProgramData through upgrades and uninstall. Stop new job submissions and resolve uncertain jobs before upgrading.
|
||||
|
||||
Use **Settings → Apps → Installed apps → QQQ Print Agent → Uninstall** to remove application files and the service. Enrollment and journal data are retained to support recovery. After revoking/decommissioning the device in QQQ and resolving all outstanding jobs, an administrator may deliberately remove the retained data. Never delete a live journal as a troubleshooting shortcut: it is evidence used to avoid duplicate printing.
|
||||
|
||||
The first prerelease has local install/service/uninstall acceptance. Upgrade, reboot and all supported-driver combinations still require wider qualification.
|
||||
|
||||
## Select exposed printers
|
||||
|
||||
New installations share no queues. Open the companion's Printers tab and enable **Expose to QQQ** for each intended printer, then approve/enable it in QQQ. Only the selected set is sent to the cloud; all discovered queues remain visible locally for setup. A missing formerly exposed queue is shown as offline.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Release process
|
||||
|
||||
## Prereleases
|
||||
|
||||
The `prerelease` workflow runs on tags matching `vX.Y.Z-rc.N`, or can be dispatched against an existing matching tag. It validates the tag, invokes the same Windows CI build/test/package workflow, downloads that run's installer artifact and creates a GitHub prerelease containing:
|
||||
|
||||
- `QqqPrintAgent-vX.Y.Z-rc.N-x64.msi`
|
||||
- `SHA256SUMS`
|
||||
|
||||
The workflow has read-only permissions except its final publication job, which receives `contents: write`. Action/reusable-workflow dependencies are pinned by commit. The protocol fixture checkout is pinned to a reviewed commit. A failed verification prevents publication.
|
||||
|
||||
The prerelease is explicitly unsigned. No signing secret, production release, winget submission, attestation or automatic desktop update is implied. The final job refuses to overwrite an existing GitHub release; use a new prerelease tag for changed binaries.
|
||||
|
||||
## Maintainer checklist
|
||||
|
||||
1. Update CHANGELOG.md, acceptance evidence and version inputs. Ensure no local enrollment/journal/payload files are tracked.
|
||||
2. Review the protocol pin, dependency lock files, unsigned-release notice and open limitations.
|
||||
3. Run `./build/Verify.ps1` and build the MSI locally or inspect the corresponding green CI run.
|
||||
4. Obtain explicit release authorization, create/push the immutable prerelease tag, and wait for the workflow.
|
||||
5. Verify the published filenames/checksum and record the workflow URL and hardware acceptance.
|
||||
|
||||
If publishing the initial release manually, attach only reviewed artifacts and disclose whether they were built locally. Do not claim GitHub build provenance for a local MSI.
|
||||
|
||||
## Production signing follow-up
|
||||
|
||||
`Build-Installer.ps1` accepts a certificate thumbprint and timestamp server for local signing. Production automation must integrate the organization's approved signing service and sign/verify first-party executables and the final MSI. That setup is not part of this prerelease. Upgrade/reboot/service-account qualification and broader hardware acceptance are also required before calling it a production release.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Session state — 2026-10-29
|
||||
|
||||
Initial public prerelease prepared for QQQ 4.1+: AppKit companion with system tray, printer exposure selection, credential-free connection/printer/job/log snapshots, Windows service and diagnostic CLI. Printing uses outbound HTTPS polling, native Windows PDF/GDI and RAW ZPL adapters, DPAPI credentials and SQLite receipts/events.
|
||||
|
||||
Verification: full Release solution builds with zero warnings/errors; 12 core tests and 7 CLI/Windows tests pass (19 total). Shared protocol fixtures are pinned to qqq-print-protocol commit f977bd01b62ceb7539190fd2861809537fcf504c. Locked restore/build/test and coverage run through build/Verify.ps1. The prerelease MSI uses numeric product version 0.1.0 and assembly release version 0.1.0-rc.1.
|
||||
|
||||
Earlier hardware acceptance confirmed two physical PDF pages on a local Canon laser printer: one through the development CLI and one through the installed LocalSystem service. MSI installation/start and uninstall passed. Exact local queue/job identifiers and private diagnostic artifacts are retained outside this public repository.
|
||||
|
||||
The new tray/details UI was visually checked in disconnected/red state. Printer selection, history and snapshot behavior have automated coverage; final installed tray/exposure smoke is coordinated by the release owner. UI test project compiles; full automated desktop acceptance requires an elevated interactive session.
|
||||
|
||||
Publication and final artifact checks are in progress. Production signing, Zebra hardware, upgrades/reboot qualification, broad driver compatibility and full coverage remain open. See TODO.md, ACCEPTANCE.md and RELEASING.md.
|
||||
@@ -0,0 +1,15 @@
|
||||
# TODO
|
||||
|
||||
- [x] Generate from existing Windows AppKit template.
|
||||
- [x] Implement protocol enrollment, discovery, claim/content/events and polling worker.
|
||||
- [x] Native PDF/GDI and RAW ZPL paths; protected credentials and durable event journal.
|
||||
- [x] Real AppKit setup/status UI and Windows service host.
|
||||
- [x] Meaningful worker and shared-contract unit tests.
|
||||
- [x] Physical laser output confirmed by user through the integration harness (root owns evidence).
|
||||
- [ ] Attach Zebra and validate real ZPL output with actual label stock/DPI.
|
||||
- [x] Qualify MSI installation/start/uninstall, LocalSystem printer discovery and real PDF output on the local laser printer; user confirmed physical service output.
|
||||
- [ ] Qualify service restart recovery, MSI upgrades and signed distribution.
|
||||
- [ ] Extend automated coverage to inherited 100% CI target; execute UI acceptance on an interactive elevated desktop.
|
||||
- [ ] Add renderer worker-process isolation, cancellation, retention and richer printer status.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| Companion cannot connect to service | Confirm the MSI is installed, `QqqPrintAgent` is running, and the companion is elevated. A development CLI worker does not host the installed service pipe. |
|
||||
| Enrollment fails | Check server URL, fresh/unconsumed code, device/facility configuration and server logs. Remote endpoints require HTTPS. |
|
||||
| No printers in the service list | Install the queue for the machine/service identity. User-specific network mappings may not be visible to LocalSystem. Confirm Spooler is running. |
|
||||
| Windows test page fails | Repair driver, connection or printer hardware locally before testing cloud printing. |
|
||||
| Discovered printer never receives work | Enable the queue in QQQ, verify device/printer IDs and submit to that assigned queue. |
|
||||
| Job remains queued | Check service status, outbound network access, device authorization and polling. |
|
||||
| SPOOLED but no output | Check Windows queue and printer paper/labels/jam conditions. Spool acceptance is not physical completion. |
|
||||
| OUTCOME_UNKNOWN | Inspect output and the Windows queue before reprinting. Do not delete the journal or assume failure. |
|
||||
| PDF clipped or wrong size | Select actual size versus fit deliberately and configure correct default media in the Windows queue. Custom API media dimensions are not supported yet. |
|
||||
| Zebra prints text or blank labels | Confirm ZPL-capable hardware/driver, correct DPI/label dimensions and ZPL document. Physical Zebra acceptance is pending. |
|
||||
| Protected configuration cannot be opened | It belongs to another Windows identity, or the service account changed. Re-enroll through the service after resolving old jobs; do not copy a user-enrolled DPAPI file. |
|
||||
| Another worker owns the directory | Stop the duplicate CLI worker. Only one worker may own a given journal/data directory. |
|
||||
| MSI publisher warning | v0.1.0-rc.1 is unsigned. Verify the release checksum and follow local installation policy. |
|
||||
|
||||
For a non-security bug, include Windows version, application version, printer model/driver, the stage that failed, job ID and sanitized error text in a [bug report](https://github.com/QRun-IO/qqq-print-agent/issues/new/choose). Never attach credentials, enrollment codes, customer PDFs, address labels, journal databases or raw diagnostic archives without reviewing them.
|
||||
|
||||
Security issues belong in [private reporting](../SECURITY.md).
|
||||
@@ -0,0 +1,34 @@
|
||||
# Using QQQ Print Agent
|
||||
|
||||
## Setup and status
|
||||
|
||||
Start **QQQ Print Agent** from the Start menu and accept the administrator prompt. Enter the full QQQ server origin, for example `https://warehouse.example.com`, and a fresh enrollment code from your QQQ administrator. Select **Connect**. The code is consumed once; later operation uses a device credential held by the background service.
|
||||
|
||||
The system tray shows green when connected and all exposed queues report ready, orange when connected but exposed printers need attention (including no printers selected), and red when disconnected or a recent job/runtime failure needs attention. Open the tray menu to show details. Closing the window hides the companion to the tray; exiting the companion still does not stop the service. If the service is not installed/running, the companion reports that it cannot reach the management pipe.
|
||||
|
||||
In **Printers**, check **Expose to QQQ** for the queues this computer should share. New installations expose none. The selection is saved by the service and takes effect in its next cloud inventory. Unselecting a queue prevents new local submissions but cannot retract paper already printing. In QQQ, review the exposed queues and explicitly enable the correct one. Local exposure and server approval are separate controls. Submit a small PDF test before sending packing slips. For Zebra labels, check printer language, DPI and label dimensions before the first real job.
|
||||
|
||||
## Job states
|
||||
|
||||
| State | Meaning and operator action |
|
||||
| --- | --- |
|
||||
| QUEUED | The cloud has accepted the document. |
|
||||
| CLAIMED | This connector has been assigned the job. |
|
||||
| RECEIVED | The connector has recorded receipt locally. |
|
||||
| SUBMITTING | It is entering the Windows print path. |
|
||||
| SPOOLED | Windows accepted the job; inspect the printer if no paper appears. |
|
||||
| FAILED | The connector reported a failure before it could safely complete the attempt. Read the message. |
|
||||
| OUTCOME_UNKNOWN | The attempt might have reached the printer. Inspect the printer/queue before submitting another copy. |
|
||||
|
||||
A ready queue or connected service does not guarantee that the printer has paper, ribbon or labels. This version reports Windows observations, not guaranteed hardware telemetry.
|
||||
|
||||
## Safe everyday operation
|
||||
|
||||
Keep the Windows computer awake and connected to the facility network. Leave the Spooler and QQQ Print Agent services running. Fix paper-out/jam conditions locally. Do not repeatedly submit the same shipping label to work around a delay: it may already be in the Windows queue.
|
||||
|
||||
A physical reprint is a new printing action; follow your QQQ application's workflow and preserve its audit trail. Automatic reprint/cancel controls are not provided by this first companion release.
|
||||
|
||||
## Jobs and logs
|
||||
|
||||
The details window shows the latest 50 local job states and the latest 100 service log entries. Logs are kept in memory for the running service session. No device credential or document bytes are included. The latest failed/uncertain job keeps health red until a later job succeeds; inspect the job before retrying. A red tray icon may also mean the service cannot be reached, not only that the cloud is down.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# ADR-0001: Windows AppKit service variant
|
||||
|
||||
Status: implemented for the initial prerelease. Date: 2026-10-29.
|
||||
|
||||
The owner requires the existing Windows AppKit foundation used by GCLO and MarkdownViewr.Windows. Cloud printing must continue without a logged-in desktop user.
|
||||
|
||||
Use the KofTwentyTwo Windows template's AppKit/WinUI shell with its documented Windows service variant: a .NET Worker Service, native Windows adapters, a CLI, and a signed-capable machine WiX MSI. The service owns cloud credentials and printing. The companion communicates over a local administrator pipe. Per-user Velopack hooks and self-updates are removed from the shipped companion.
|
||||
|
||||
The initial MSI is unsigned and labeled as a prerelease. Printer queues must be visible to LocalSystem; per-user connections are not guaranteed. AppKit preferences remain per-user, while service state lives in an ACL-protected ProgramData directory.
|
||||
@@ -0,0 +1,9 @@
|
||||
# ADR-0002: Initial prerelease quality and packaging gates
|
||||
|
||||
Status: accepted for the owner-authorized happy-path prerelease. Date: 2026-10-29.
|
||||
|
||||
The original template mandates 100% core/CLI coverage and automated desktop acceptance. The new connector includes native printing and a privileged service; those requirements are not yet established. The owner requested a working happy path, public setup and CI that actually verifies this release rather than claiming an unattained gate.
|
||||
|
||||
For v0.1.0-rc.1, require locked restore, zero-warning C# build, meaningful core/Windows tests, shared protocol fixtures and MSI packaging. Collect and publish coverage, but do not claim or enforce 100% until the missing tests exist. Compile UI tests and execute them separately on an elevated interactive desktop; hosted CI does not silently mark them as passed.
|
||||
|
||||
Keep the coverage tooling and track the full-coverage goal. This exception does not suppress NuGet vulnerability warnings, compiler/analyzer errors, failed tests or installer build failures. Physical printer qualification remains separately recorded evidence.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Threat model: QQQ Print Agent
|
||||
|
||||
Version: 0.1.0-rc.1. Reviewed: 2026-10-29. This is an implementation-level inventory, not an independent security audit.
|
||||
|
||||
## Boundaries and assets
|
||||
|
||||
The QQQ server supplies assigned document bytes over HTTPS. The Windows LocalSystem service validates document length/hash, records submission state, and calls OS renderer/spooler APIs. A locally elevated AppKit companion controls enrollment and printer exposure over an administrator-only named pipe. Local credentials, job IDs/events, Windows queue access and physical output are the protected assets.
|
||||
|
||||
| Boundary | Implemented control | Residual risk / qualification |
|
||||
| --- | --- | --- |
|
||||
| Internet to device | Outbound HTTPS, normal certificate validation; loopback-only HTTP development exception | Proxy/custom trust deployments not qualified |
|
||||
| Device to server | Dedicated bearer credential; server enforces device/job scope | A compromised trusted server can supply hostile print content |
|
||||
| Local UI to service | Pipe ACL permits SYSTEM/admins; narrow command set | Local administrators already control the machine; input allocation limits need further hardening |
|
||||
| Exposure selection | Service-owned persisted allowlist; discovered-ID validation; filter inventory and recheck submission | Removal cannot retract data already accepted by Windows |
|
||||
| Credentials at rest | DPAPI CurrentUser under service identity; restricted ProgramData ACL | Machine/service compromise defeats local protection |
|
||||
| Payload download | 20 MiB cap, declared length and SHA-256 validation | Integrity does not sanitize PDFs or printer languages |
|
||||
| Rendering/driver execution | Native Windows PDF/GDI/spooler APIs; bounded render dimensions | Runs in service process; malformed files/driver faults remain a privileged attack surface |
|
||||
| RAW ZPL | Explicit compatible queue, trusted server workflow | ZPL can contain printer-control commands; not a sandboxed format |
|
||||
| Duplicate output | Persistent receipts and events; uncertain submissions require review | Database and physical printer cannot share a transaction |
|
||||
| Tray snapshot/logs | No device credential or document bytes; bounded recent jobs/logs | IDs/errors remain operational data and must be reviewed before sharing |
|
||||
| Software distribution | Public source, pinned actions/dependencies, audited locked restore, checksums | Initial installer is unsigned; no attestation/production signing claim |
|
||||
|
||||
## Data handling
|
||||
|
||||
The service owns `connection.dpapi`, `journal.db` and its WAL. The MSI retains state through uninstall/reinstall. Revoke/decommission the device and resolve pending jobs before deliberately deleting retained state. Journal retention and automated redaction/export policy are open work; never publish raw state or customer documents.
|
||||
|
||||
Recent logs are an in-memory ring and recent jobs are bounded snapshot queries over the durable journal. Tray colors describe observed connectivity/queue state, not a guarantee of physical printer readiness or completed output.
|
||||
|
||||
## Follow-up
|
||||
|
||||
Qualify signed upgrades, renderer worker isolation, stricter bounded IPC reads, broad hardware/driver behavior, credential rotation/re-enrollment with pending work, cancellation and retention. Revisit this document when any network, privilege or persistence boundary changes.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.403",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<!-- MSI authoring is not a C# compilation; do not inherit runtime analyzers. -->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,59 @@
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="QQQ Print Agent" Manufacturer="QRun-IO" Version="$(ProductVersion)"
|
||||
UpgradeCode="D761CD03-F616-4C98-9A94-9F590A36E449" Scope="perMachine" InstallerVersion="500">
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer QQQ Print Agent is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
|
||||
|
||||
<StandardDirectory Id="ProgramFiles64Folder">
|
||||
<Directory Id="INSTALLFOLDER" Name="QQQ Print Agent">
|
||||
<Directory Id="SERVICEDIR" Name="Service">
|
||||
<Component Id="PrintService" Guid="8B5BA126-43F2-4F8D-91E5-8B9A5989D8BD" Bitness="always64">
|
||||
<File Id="ServiceExe" Source="$(PayloadRoot)\service\Qqq.PrintAgent.Service.exe" KeyPath="yes" />
|
||||
<ServiceInstall Id="InstallPrintService" Name="QqqPrintAgent" DisplayName="QQQ Print Agent"
|
||||
Description="Connects outbound to QQQ and prints assigned documents using local Windows printer queues."
|
||||
Type="ownProcess" Start="auto" ErrorControl="normal" Account="LocalSystem" Vital="yes">
|
||||
<ServiceDependency Id="Spooler" />
|
||||
</ServiceInstall>
|
||||
<ServiceControl Id="ControlPrintService" Name="QqqPrintAgent" Start="install" Stop="both" Remove="uninstall" Wait="yes" />
|
||||
</Component>
|
||||
<Files Include="$(PayloadRoot)\service\**">
|
||||
<Exclude Files="$(PayloadRoot)\service\Qqq.PrintAgent.Service.exe" />
|
||||
<Exclude Files="$(PayloadRoot)\service\**.pdb" />
|
||||
</Files>
|
||||
</Directory>
|
||||
<Directory Id="APPDIR" Name="App">
|
||||
<Component Id="AgentCompanion" Guid="2FD52718-B33B-42AA-9E28-21B3B70FA4BB" Bitness="always64">
|
||||
<File Id="CompanionExe" Source="$(PayloadRoot)\app\Qqq.PrintAgent.exe" KeyPath="yes">
|
||||
<Shortcut Id="AgentStartMenu" Directory="AgentMenuDirectory" Name="QQQ Print Agent" Advertise="yes" WorkingDirectory="APPDIR" />
|
||||
</File>
|
||||
<RemoveFolder Id="RemoveAgentMenu" Directory="AgentMenuDirectory" On="uninstall" />
|
||||
</Component>
|
||||
<Files Include="$(PayloadRoot)\app\**">
|
||||
<Exclude Files="$(PayloadRoot)\app\Qqq.PrintAgent.exe" />
|
||||
<Exclude Files="$(PayloadRoot)\app\**.pdb" />
|
||||
</Files>
|
||||
</Directory>
|
||||
<Directory Id="CLIDIR" Name="Cli">
|
||||
<Files Include="$(PayloadRoot)\cli\**">
|
||||
<Exclude Files="$(PayloadRoot)\cli\**.pdb" />
|
||||
</Files>
|
||||
</Directory>
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<StandardDirectory Id="CommonAppDataFolder">
|
||||
<Directory Id="DATADIR" Name="QqqPrintAgent">
|
||||
<!-- The journal must survive upgrades and uninstall/reinstall to avoid duplicate prints. -->
|
||||
<Component Id="ProtectedAgentData" Guid="55147A95-1D16-452D-BF75-1F3834867D07" Permanent="yes" Bitness="always64">
|
||||
<CreateFolder>
|
||||
<PermissionEx Sddl="D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" />
|
||||
</CreateFolder>
|
||||
</Component>
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<StandardDirectory Id="ProgramMenuFolder">
|
||||
<Directory Id="AgentMenuDirectory" Name="QQQ Print Agent" />
|
||||
</StandardDirectory>
|
||||
</Package>
|
||||
</Wix>
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="WixToolset.Sdk/6.0.2">
|
||||
<PropertyGroup>
|
||||
<OutputType>Package</OutputType>
|
||||
<InstallerPlatform>x64</InstallerPlatform>
|
||||
<ProductVersion Condition="'$(ProductVersion)' == ''">0.1.0</ProductVersion>
|
||||
<PayloadRoot Condition="'$(PayloadRoot)' == ''">$(MSBuildThisFileDirectory)..\artifacts\package</PayloadRoot>
|
||||
<OutputName>QqqPrintAgent-$(ProductVersion)-x64</OutputName>
|
||||
<DefineConstants>ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot)</DefineConstants>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- Bundled Microsoft WinUI DLLs contain multi-language version resources exceeding
|
||||
ICE03's Language-column limit, and newer MUI LCIDs absent from its static list.
|
||||
Keep those signed vendor files unchanged; retain every other MSI ICE check. -->
|
||||
<SuppressIces>ICE03</SuppressIces>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
# Machine installer
|
||||
|
||||
Build the service, AppKit companion and CLI into one x64 machine MSI:
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.1.0
|
||||
```
|
||||
|
||||
The build publishes self-contained .NET applications into `artifacts/package` and builds WiX 6.0.2 authoring. Existing publish outputs can be packaged using `-SkipPublish`. The resulting MSI is local development output and is **unsigned unless a signing certificate is explicitly provided**.
|
||||
|
||||
For a signing certificate already installed in the Windows certificate store:
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.1.0 -CertificateThumbprint '<thumbprint>' -TimestampServer 'https://your-approved-timestamp-service'
|
||||
```
|
||||
|
||||
Signing needs `signtool.exe` on PATH. The script signs the first-party executables before packaging and signs/verifies the resulting MSI. Use the existing organization signing workflow for distribution; never store signing secrets here.
|
||||
|
||||
Install the resulting MSI through an elevated Windows Installer session. It installs `QqqPrintAgent` as an automatic LocalSystem service, depends on the Windows Spooler, starts the service, and adds the companion to the Start menu. Configure and test printers accessible to LocalSystem; user-only network printer mappings are not sufficient. The current companion's management pipe requires local administrator access.
|
||||
|
||||
The installer protects `%ProgramData%\QqqPrintAgent` for SYSTEM and Administrators and preserves the journal/enrollment through upgrades and uninstall. It replaces app/service/CLI together and stops the old service before removing files. Preserved credentials/data can be deliberately removed by an administrator after decommissioning; do not delete the journal while a device still has unresolved print jobs.
|
||||
|
||||
No inbound firewall rule or automatic desktop updater is installed. Production release qualification must include signed installation, service-account printing while logged out, upgrade/reboot, and uninstall/reinstall recovery. Building an MSI alone does not establish those checks.
|
||||
|
||||
The installer suppresses only MSI validator ICE03 because the bundled Microsoft WinUI `Microsoft.ui.xaml.dll`/`Microsoft.UI.Xaml.Phone.dll` have multi-language version resources longer than that validator's File.Language constraint, and their MUI resources include newer locale IDs absent from its static list. The vendor binaries are retained unchanged. Other MSI validation remains enabled; installation and launch qualification are still required.
|
||||
|
||||
References: [WiX service installation](https://docs.firegiant.com/wix/schema/wxs/serviceinstall/), [file harvesting](https://docs.firegiant.com/wix/schema/wxs/files/), [MSI directory security](https://docs.firegiant.com/wix/schema/wxs/permissionex/).
|
||||
@@ -0,0 +1,14 @@
|
||||
# Local development package validation
|
||||
|
||||
2026-10-29:
|
||||
|
||||
- Published service, AppKit WinUI companion and CLI self-contained for win-x64, Release, version 0.1.0. Final shared runtime/CLI `worker.lock` code included.
|
||||
- WiX 6.0.2 MSI build passed with zero warnings/errors. Only documented ICE03 vendor WinUI-language metadata exception; remaining MSI ICE validation enabled and passed.
|
||||
- Build script passed repository PSScriptAnalyzer settings without diagnostics.
|
||||
- Output: `artifacts/installer/QqqPrintAgent-0.1.0-x64.msi`.
|
||||
- SHA-256: `93C671C6D6045888DBCDA9FFB450A1F8857DE00D710AB18B31DF731E17DCE1B5`.
|
||||
- Development MSI is unsigned. No production signing was performed.
|
||||
|
||||
Installed-service acceptance was subsequently completed by the primary agent on 2026-10-29: MSI installation/start, enrollment over the named pipe, printer discovery under LocalSystem, PDF rendering and spool submission succeeded. The user confirmed the physical laser-printed page. The temporary test installation was successfully uninstalled; its protected journal was retained by installer policy. The development CLI also printed a separately user-confirmed page.
|
||||
|
||||
This validates the tested local laser queue and installed service happy path. Zebra hardware remains pending, and signing, upgrades and broader driver qualification are not covered by this result. Detailed local evidence is retained separately in `cloud-printing-integration`.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"native,Version=v0.0": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Packages come only from nuget.org (K22-DEP-10). Package source mapping makes the
|
||||
source explicit for every package id, so a same-named package on another feed can
|
||||
never be picked up (dependency confusion). -->
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
using Qqq.PrintAgent.Windows;
|
||||
|
||||
namespace Qqq.PrintAgent.Cli;
|
||||
|
||||
/// <summary>Scriptable setup, printer selection and foreground diagnostics.</summary>
|
||||
public static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
string command = args.FirstOrDefault() ?? "help";
|
||||
string directory = Option(args, "--data-dir") ?? AgentStore.DefaultDirectory;
|
||||
switch(command)
|
||||
{
|
||||
case "printers":
|
||||
Console.WriteLine(JsonSerializer.Serialize(new WindowsPrinters().Discover(), PrintApiClient.JsonOptions));
|
||||
return 0;
|
||||
case "service-status":
|
||||
case "snapshot":
|
||||
Console.WriteLine(await AgentPipeClient.SendAsync(new PipeRequest(command is "snapshot" ? "snapshot" : "status")));
|
||||
return 0;
|
||||
case "enroll":
|
||||
return await EnrollAsync(args, directory);
|
||||
case "expose":
|
||||
return await SetExposureAsync(args, directory);
|
||||
case "run":
|
||||
return await RunAsync(args, directory);
|
||||
default:
|
||||
Console.WriteLine("printers | enroll --server URL [--data-dir PATH] [--service] | expose --printer ID --exposed true|false [--data-dir PATH] [--service] | run [--data-dir PATH] [--once] | service-status | snapshot\nEnrollment code is read from QQQ_PRINT_ENROLLMENT_CODE or an interactive prompt.");
|
||||
return command is "help" ? 0 : 1;
|
||||
}
|
||||
}
|
||||
catch(OperationCanceledException) { return 0; }
|
||||
catch(Exception exception) { Console.Error.WriteLine(exception.Message); return 1; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static string? Option(string[] args, string key)
|
||||
{
|
||||
int index = Array.IndexOf(args, key);
|
||||
return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static async Task<int> RunAsync(string[] args, string directory)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
using FileStream instance = new(Path.Combine(directory, "worker.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
|
||||
using AgentStore journal = new(directory);
|
||||
AgentConfiguration config = journal.LoadConfiguration() ?? throw new InvalidOperationException("Enroll this account first.");
|
||||
using PrintApiClient client = new(config.ServerUrl, config.DeviceToken);
|
||||
ExposedPrinterBackend printers = new(new WindowsPrinters(), journal.GetExposedPrinterIds);
|
||||
PrintWorker worker = new(client, printers, journal, Console.WriteLine);
|
||||
using CancellationTokenSource stopping = new();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stopping.Cancel(); };
|
||||
do
|
||||
{
|
||||
await worker.RunOnceAsync(stopping.Token);
|
||||
if(args.Contains("--once", StringComparer.Ordinal))
|
||||
{
|
||||
break;
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Max(1, config.PollIntervalSeconds)), stopping.Token);
|
||||
} while(!stopping.IsCancellationRequested);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static async Task<int> SetExposureAsync(string[] args, string directory)
|
||||
{
|
||||
string id = Option(args, "--printer") ?? throw new ArgumentException("--printer is required.", nameof(args));
|
||||
if(!bool.TryParse(Option(args, "--exposed"), out bool exposed))
|
||||
{
|
||||
throw new ArgumentException("--exposed must be true or false.", nameof(args));
|
||||
}
|
||||
if(args.Contains("--service", StringComparer.Ordinal))
|
||||
{
|
||||
string response = await AgentPipeClient.SendAsync(new PipeRequest("set-exposure", PrinterId: id, Exposed: exposed));
|
||||
Console.WriteLine(response);
|
||||
return response.StartsWith("Error:", StringComparison.Ordinal) ? 1 : 0;
|
||||
}
|
||||
using AgentStore store = new(directory);
|
||||
store.SetExposure(id, exposed, new WindowsPrinters().Discover());
|
||||
Console.WriteLine(exposed ? "Printer exposed to QQQ." : "Printer hidden from QQQ.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static async Task<int> EnrollAsync(string[] args, string directory)
|
||||
{
|
||||
string server = Option(args, "--server") ?? throw new ArgumentException("--server is required.", nameof(args));
|
||||
string? code = Environment.GetEnvironmentVariable("QQQ_PRINT_ENROLLMENT_CODE");
|
||||
if(string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
Console.Write("Enrollment code: ");
|
||||
code = Console.ReadLine();
|
||||
}
|
||||
if(string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new InvalidOperationException("Enrollment code is required.");
|
||||
}
|
||||
if(args.Contains("--service", StringComparer.Ordinal))
|
||||
{
|
||||
string response = await AgentPipeClient.SendAsync(new PipeRequest("enroll", server, code));
|
||||
Console.WriteLine(response);
|
||||
return response.StartsWith("Error:", StringComparison.Ordinal) ? 1 : 0;
|
||||
}
|
||||
using AgentStore store = new(directory);
|
||||
using PrintApiClient api = new(server);
|
||||
EnrollmentResponse result = await api.EnrollAsync(new EnrollmentRequest(code, Environment.MachineName, "0.1.0"));
|
||||
store.SaveConfiguration(new AgentConfiguration(server, result.DeviceId, result.DeviceToken, result.PollIntervalSeconds));
|
||||
Console.WriteLine($"Enrolled device {result.DeviceId}.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- The scriptable head over the same Core as the window. Ships as a self-contained
|
||||
single-file exe named after the app id (qqq-print-agent.exe), so the coverage package
|
||||
name is the assembly name, not the project name. -->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<AssemblyName>qqq-print-agent</AssemblyName>
|
||||
<RootNamespace>Qqq.PrintAgent.Cli</RootNamespace>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<ApplicationIcon>..\Qqq.PrintAgent\Assets\Brand\app.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Qqq.PrintAgent.Core\Qqq.PrintAgent.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Qqq.PrintAgent.Cli.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The app's own namespaces are global usings: their sort position against other
|
||||
usings depends on the app's name, which the template replaces. -->
|
||||
<Using Include="Qqq.PrintAgent.Core" />
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup><ProjectReference Include="../Qqq.PrintAgent.Windows/Qqq.PrintAgent.Windows.csproj" /></ItemGroup></Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0-windows10.0.19041": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.296, )",
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
},
|
||||
"SQLitePCLRaw.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.11",
|
||||
"contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA=="
|
||||
},
|
||||
"qqq.printagent.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"KofTwentyTwo.AppKit": "[0.1.0, )"
|
||||
}
|
||||
},
|
||||
"qqq.printagent.windows": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Microsoft.Data.Sqlite.Core": "[10.0.0, )",
|
||||
"Qqq.PrintAgent.Core": "[0.1.0-rc.1, )",
|
||||
"SQLitePCLRaw.provider.winsqlite3": "[2.1.11, )",
|
||||
"System.Security.Cryptography.ProtectedData": "[10.0.0, )"
|
||||
}
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[8.4.2, )",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
|
||||
},
|
||||
"KofTwentyTwo.AppKit": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.1.0, )",
|
||||
"resolved": "0.1.0",
|
||||
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
|
||||
},
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.0, )",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "wPKG/Ym6tSMCo06UOZXzVfeFGzawnOZrTba/R3PfK+RhNuNELZ9I7nFns4WGTtv2kKlrlmmErgJ+kgTXHaNdHg==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.provider.winsqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.11, )",
|
||||
"resolved": "2.1.11",
|
||||
"contentHash": "UpH9v68yuLAN3nvXB96s6GE3/tQhG1WnXenNJL/HulgfJAQ5S6ZT6BUy/sDFo5WpqaS0npBbW1z6szWlu6a4Xg==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"System.Security.Cryptography.ProtectedData": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.0, )",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "qy6C3gQRTrfhFfuiIYU1jNwWfFdJpBVo1BARW7jgMm1zTEm3LRzLLyOLJGaAuKE+nZCGkEP71y4rj9NWjqLGdQ=="
|
||||
}
|
||||
},
|
||||
"net10.0-windows10.0.19041/win-x64": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using KofTwentyTwo.AppKit;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
|
||||
|
||||
namespace Qqq.PrintAgent.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The application's identity, declared once and shared by the window, the command line,
|
||||
/// and the tests. AppKit derives the data folder (%LOCALAPPDATA%\qqq-print-agent), the data-folder
|
||||
/// override variable, the log file names, and the credential prefix from <see cref="Info"/>.
|
||||
/// </summary>
|
||||
public static class AppIdentity
|
||||
{
|
||||
/// <summary>Who the app is: id, names, links, credits, and brand.</summary>
|
||||
public static AppInfo Info { get; } = new()
|
||||
{
|
||||
Id = "qqq-print-agent",
|
||||
DisplayName = "QQQ Print Agent",
|
||||
Tagline = "Cloud printing for QQQ",
|
||||
Description = "Connect local printers to QQQ.",
|
||||
RepositoryUrl = new Uri("https://github.com/QRun-IO/qqq-print-agent"),
|
||||
Author = "James Maes",
|
||||
Copyright = "Copyright (c) 2026 James Maes",
|
||||
// Keep the gradient in step with the mark (regenerate both with AppKit's BrandTool).
|
||||
Brand = new AppBrand
|
||||
{
|
||||
GradientStart = "#0F766E",
|
||||
GradientEnd = "#134E4A",
|
||||
IconSvgPath = "Assets/Brand/app-icon.svg",
|
||||
IconIcoPath = "Assets/Brand/app.ico",
|
||||
},
|
||||
Attributions =
|
||||
[
|
||||
new("KofTwentyTwo.AppKit", "MIT License"),
|
||||
new("CommunityToolkit.Mvvm", "MIT License"),
|
||||
new("Velopack", "MIT License"),
|
||||
new("Windows App SDK / WinUI 3", "MIT License, with Microsoft binary components"),
|
||||
new(".NET Runtime", "MIT License"),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>Where the app keeps its per-user data; honors the data-folder override variable.</summary>
|
||||
public static AppPaths Paths { get; } = new(Info);
|
||||
|
||||
|
||||
|
||||
/// <summary>Loads and saves <see cref="AppSettings"/> at <see cref="AppPaths.SettingsFile"/>.</summary>
|
||||
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the app's daily activity log under <see cref="AppPaths.LogsDirectory"/>.</summary>
|
||||
public static IActivityLog CreateLog() => FileActivityLog.ForApp(Info, Paths);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
|
||||
|
||||
namespace Qqq.PrintAgent.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The app's preferences: AppKit's shell settings (theme, splash, update checks) plus the
|
||||
/// app's own. Add properties here, repair them in <see cref="Sanitize"/>, and they are
|
||||
/// saved by <see cref="AppIdentity.SettingsStore"/>.
|
||||
/// </summary>
|
||||
public sealed class AppSettings : ShellSettings
|
||||
{
|
||||
/// <summary>The last QQQ server address used by setup.</summary>
|
||||
public string ServerUrl { get; set; } = "";
|
||||
|
||||
|
||||
|
||||
/// <summary>Repairs the shell settings, then restores a missing server address (JSON can supply null).</summary>
|
||||
public override void Sanitize()
|
||||
{
|
||||
base.Sanitize();
|
||||
ServerUrl ??= "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace Qqq.PrintAgent.Core;
|
||||
|
||||
/// <summary>Source-generated JSON serializer for <see cref="AppSettings"/>, so trimmed builds need no reflection.</summary>
|
||||
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||
[JsonSerializable(typeof(AppSettings))]
|
||||
internal sealed partial class AppSettingsContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public sealed record AgentConfiguration(string ServerUrl, string DeviceId, string DeviceToken, int PollIntervalSeconds = 5);
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public sealed record EnrollmentRequest(string EnrollmentCode, string DeviceName, string AgentVersion);
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public sealed record EnrollmentResponse(int ProtocolVersion, string DeviceId, string DeviceToken, int PollIntervalSeconds);
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>Filters cloud inventory and rejects jobs for queues no longer exposed locally.</summary>
|
||||
public sealed class ExposedPrinterBackend(IPrinterBackend inner, Func<IReadOnlySet<string>> exposedIds) : IPrinterBackend
|
||||
{
|
||||
public IReadOnlyList<PrinterInfo> Discover()
|
||||
{
|
||||
IReadOnlySet<string> exposed = exposedIds();
|
||||
return inner.Discover().Where(printer => exposed.Contains(printer.PrinterId)).ToArray();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Task<string> PrintAsync(PrintJob job, byte[] content, CancellationToken cancellationToken)
|
||||
{
|
||||
if(!exposedIds().Contains(job.PrinterId))
|
||||
{
|
||||
throw new InvalidOperationException("The printer is not exposed to QQQ on this computer.");
|
||||
}
|
||||
return inner.PrintAsync(job, content, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public interface IJobJournal
|
||||
{
|
||||
string? GetState(string jobId);
|
||||
void Record(string jobId, PrintEvent printEvent);
|
||||
IReadOnlyList<(string JobId, PrintEvent Event)> Pending();
|
||||
void Acknowledge(string eventId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public interface IPrinterBackend
|
||||
{
|
||||
IReadOnlyList<PrinterInfo> Discover();
|
||||
Task<string> PrintAsync(PrintJob job, byte[] content, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public sealed record JobClaim(PrintJob? Job);
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public sealed class PrintApiClient : IDisposable
|
||||
{
|
||||
public static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull };
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public PrintApiClient(string serverUrl, string? token = null, HttpMessageHandler? handler = null)
|
||||
{
|
||||
Uri uri = new(serverUrl.TrimEnd('/') + "/printing/v1/");
|
||||
if(!string.Equals(uri.Scheme, "https", StringComparison.Ordinal) && !(string.Equals(uri.Scheme, "http", StringComparison.Ordinal) && uri.IsLoopback))
|
||||
{
|
||||
throw new ArgumentException("Use HTTPS, or HTTP on loopback for local development.", nameof(serverUrl));
|
||||
}
|
||||
_http = handler is null ? new HttpClient() : new HttpClient(handler);
|
||||
_http.BaseAddress = uri;
|
||||
_http.Timeout = TimeSpan.FromSeconds(60);
|
||||
if(token is not null)
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<EnrollmentResponse> EnrollAsync(EnrollmentRequest request, CancellationToken token = default)
|
||||
{
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync("enroll", request, JsonOptions, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
EnrollmentResponse result = await response.Content.ReadFromJsonAsync<EnrollmentResponse>(token).ConfigureAwait(false)
|
||||
?? throw new InvalidDataException("Missing enrollment response.");
|
||||
if(result.ProtocolVersion != 1)
|
||||
{
|
||||
throw new InvalidDataException("Unsupported printing protocol.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task InventoryAsync(IReadOnlyList<PrinterInfo> printers, CancellationToken token)
|
||||
{
|
||||
using HttpResponseMessage response = await _http.PutAsJsonAsync("device/printers", new { printers }, JsonOptions, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task HeartbeatAsync(CancellationToken token)
|
||||
{
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync("device/heartbeat", new { agentVersion = "0.1.0" }, JsonOptions, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<PrintJob?> ClaimAsync(CancellationToken token)
|
||||
{
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync("device/jobs/claim", new { }, JsonOptions, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<JobClaim>(token).ConfigureAwait(false))?.Job;
|
||||
}
|
||||
|
||||
public async Task<byte[]> DownloadAsync(PrintJob job, CancellationToken token)
|
||||
{
|
||||
if(job.ContentLength is < 1 or > 20_971_520)
|
||||
{
|
||||
throw new InvalidDataException("Document exceeds the 20 MiB limit or is empty.");
|
||||
}
|
||||
|
||||
using HttpResponseMessage response = await _http.GetAsync($"device/jobs/{Uri.EscapeDataString(job.JobId)}/content", HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using Stream source = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
|
||||
using MemoryStream output = new();
|
||||
byte[] buffer = new byte[81920];
|
||||
int read;
|
||||
while((read = await source.ReadAsync(buffer, token).ConfigureAwait(false)) != 0)
|
||||
{
|
||||
if(output.Length + read > job.ContentLength)
|
||||
{
|
||||
throw new InvalidDataException("Document length mismatch.");
|
||||
}
|
||||
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), token);
|
||||
}
|
||||
byte[] content = output.ToArray();
|
||||
if(content.LongLength != job.ContentLength || !string.Equals(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(content)), job.ContentSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException("Document integrity check failed.");
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
public async Task EventAsync(string jobId, PrintEvent printEvent, CancellationToken token)
|
||||
{
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync($"device/jobs/{Uri.EscapeDataString(jobId)}/events", printEvent, JsonOptions, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public sealed record PrintEvent(string EventId, string Status, string? Message = null, string? SpoolerJobId = null);
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public sealed record PrintJob(string JobId, string PrinterId, string Title, string Format, string ContentSha256, long ContentLength, PrintOptions Options, string CreatedAt, string Status);
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public sealed record PrintOptions(int Copies = 1, string Scale = "ACTUAL_SIZE", double? PaperWidthMm = null, double? PaperHeightMm = null);
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>Serial happy-path worker. A locally ambiguous submission is never automatically printed again.</summary>
|
||||
public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJobJournal journal, Action<string>? log = null)
|
||||
{
|
||||
public async Task RunOnceAsync(CancellationToken token = default)
|
||||
{
|
||||
await FlushAsync(token).ConfigureAwait(false);
|
||||
await api.HeartbeatAsync(token).ConfigureAwait(false);
|
||||
await api.InventoryAsync(printers.Discover(), token).ConfigureAwait(false);
|
||||
PrintJob? job = await api.ClaimAsync(token).ConfigureAwait(false);
|
||||
if(job is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? state = journal.GetState(job.JobId);
|
||||
if(state is "SUBMITTING")
|
||||
{
|
||||
Record(job, "OUTCOME_UNKNOWN", "Agent restarted during submission; inspect the printer before reprinting.");
|
||||
await FlushAsync(token).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
if(state is "SPOOLED" or "FAILED" or "OUTCOME_UNKNOWN")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool submitting = false;
|
||||
try
|
||||
{
|
||||
Record(job, "RECEIVED");
|
||||
await FlushAsync(token).ConfigureAwait(false);
|
||||
byte[] content = await api.DownloadAsync(job, token).ConfigureAwait(false);
|
||||
if(!printers.Discover().Any(p => string.Equals(p.PrinterId, job.PrinterId, StringComparison.Ordinal) && p.Formats.Contains(job.Format, StringComparer.Ordinal)))
|
||||
{
|
||||
throw new InvalidOperationException("Assigned printer or document format is unavailable.");
|
||||
}
|
||||
|
||||
Record(job, "SUBMITTING");
|
||||
await FlushAsync(token).ConfigureAwait(false);
|
||||
token.ThrowIfCancellationRequested();
|
||||
submitting = true;
|
||||
string spoolId = await printers.PrintAsync(job, content, token).ConfigureAwait(false);
|
||||
Record(job, "SPOOLED", spoolerJobId: spoolId);
|
||||
}
|
||||
catch(Exception exception) when(exception is not OperationCanceledException || submitting)
|
||||
{
|
||||
Record(job, submitting ? "OUTCOME_UNKNOWN" : "FAILED", exception.Message);
|
||||
}
|
||||
await FlushAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void Record(PrintJob job, string state, string? message = null, string? spoolerJobId = null)
|
||||
{
|
||||
journal.Record(job.JobId, new PrintEvent(Guid.NewGuid().ToString("N"), state, message, spoolerJobId));
|
||||
log?.Invoke($"{job.JobId}: {state}");
|
||||
}
|
||||
|
||||
private async Task FlushAsync(CancellationToken token)
|
||||
{
|
||||
foreach((string jobId, PrintEvent printEvent) in journal.Pending())
|
||||
{
|
||||
await api.EventAsync(jobId, printEvent, token).ConfigureAwait(false);
|
||||
journal.Acknowledge(printEvent.EventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
public sealed record PrinterInfo(string PrinterId, string Name, string DriverName, string PortName, string Status, string[] Formats);
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
// Protocol types are defined in individual files.
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- Everything that can be tested without a window lives here (architecture: "the
|
||||
UI project contains views and framework plumbing only"). Held to 100% line
|
||||
coverage by the CI gate. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="KofTwentyTwo.AppKit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Qqq.PrintAgent.Core.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.4.2, )",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
|
||||
},
|
||||
"KofTwentyTwo.AppKit": {
|
||||
"type": "Direct",
|
||||
"requested": "[0.1.0, )",
|
||||
"resolved": "0.1.0",
|
||||
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
|
||||
},
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.296, )",
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Qqq.PrintAgent.Windows;
|
||||
|
||||
namespace Qqq.PrintAgent.Service;
|
||||
|
||||
/// <summary>Hosts the same connector runtime used for local diagnostics under Windows Service Control Manager.</summary>
|
||||
internal sealed partial class PrintService(ILogger<PrintService> logger) : BackgroundService
|
||||
{
|
||||
/// <summary>Keep the connector alive until Windows requests service shutdown.</summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using AgentRuntime runtime = new(AgentStore.DefaultDirectory, message => LogAgentStatus(logger, message));
|
||||
await runtime.RunAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Write connector observations without allocating a logging argument array.</summary>
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{Message}")]
|
||||
private static partial void LogAgentStatus(ILogger logger, string message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Qqq.PrintAgent.Service;
|
||||
|
||||
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services.AddWindowsService(options => options.ServiceName = "QqqPrintAgent");
|
||||
builder.Services.AddHostedService<PrintService>();
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<PropertyGroup><TargetFramework>net10.0-windows10.0.19041.0</TargetFramework><OutputType>Exe</OutputType></PropertyGroup>
|
||||
<ItemGroup><PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" /></ItemGroup>
|
||||
<ItemGroup><ProjectReference Include="../Qqq.PrintAgent.Windows/Qqq.PrintAgent.Windows.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0-windows10.0.19041": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.296, )",
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
},
|
||||
"Microsoft.Extensions.Hosting.WindowsServices": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.0, )",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "4jFNSKTrGd2bHjIHY+TMqcB56gtIm91fNwfqWp2SMvVAvuweAnUMDWiai1CN4boM2s0zi4xVU9rWDLjIA58tDQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Hosting": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.EventLog": "10.0.0",
|
||||
"System.ServiceProcess.ServiceController": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Primitives": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Binder": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.CommandLine": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "CRj5clwZciVs46GMhAthkFq3+JiNM15Bz9CRlCZLBmRdggD6RwoBphRJ+EUDK2f+cZZ1L2zqVaQrn1KueoU5Kg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "TmFegsI/uCdwMBD4yKpmO+OkjVNHQL49Dh/ep83NI5rPUEoBK9OdsJo1zURc1A2FuS/R/Pos3wsTjlyLnguBLA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.FileExtensions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "LqCTyF0twrG4tyEN6PpSC5ewRBDwCBazRUfCOdRddwaQ3n2S57GDDeYOlTLcbV/V2dxSSZWg5Ofr48h6BsBmxw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.FileProviders.Physical": "10.0.0",
|
||||
"Microsoft.Extensions.Primitives": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Json": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "BIOPTEAZoeWbHlDT9Zudu+rpecZizFwhdIFRiyZKDml7JbayXmfTXKUt+ezifsSXfBkWDdJM10oDOxo8pufEng==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.0",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.UserSecrets": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "B4qHB6gQ2B3I52YRohSV7wetp01BQzi8jDmrtiVm6e4l8vH5vjqwxWcR5wumGWjdBkj1asJLLsDIocdyTQSP0A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Json": "10.0.0",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.FileProviders.Physical": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA=="
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "xjkxIPgrT0mKTfBwb+CVqZnRchyZgzKIfDQOp8z+WUC6vPe3WokIf71z+hJPkH0YBUYJwa7Z/al1R087ib9oiw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Options": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.FileProviders.Physical": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "UZUQ74lQMmvcprlG8w+XpxBbyRDQqfb7GAnccITw32hdkUBlmm9yNC4xl4aR9YjgV3ounZcub194sdmLSfBmPA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.FileSystemGlobbing": "10.0.0",
|
||||
"Microsoft.Extensions.Primitives": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.FileSystemGlobbing": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "5hfVl/e+bx1px2UkN+1xXhd3hu7Ui6ENItBzckFaRDQXfr+SHT/7qrCDrlQekCF/PBtEu2vtk87U2+gDEF8EhQ=="
|
||||
},
|
||||
"Microsoft.Extensions.Hosting": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "yKJiVdXkSfe9foojGpBRbuDPQI8YD71IO/aE8ehGjRHE0VkEF/YWkW6StthwuFF146pc2lypZrpk/Tks6Plwhw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.CommandLine": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Json": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection": "10.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Diagnostics": "10.0.0",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.FileProviders.Physical": "10.0.0",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Logging": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Console": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Debug": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.EventLog": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.EventSource": "10.0.0",
|
||||
"Microsoft.Extensions.Options": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Hosting.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Options": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Configuration": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "j8zcwhS6bYB6FEfaY3nYSgHdpiL2T+/V3xjpHtslVAegyI1JUbB9yAt/BFdvZdsNbY0Udm4xFtvfT/hUwcOOOg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Logging": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Options": "10.0.0",
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Console": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "treWetuksp8LVb09fCJ5zNhNJjyDkqzVm83XxcrlWQnAdXznR140UUXo8PyEPBvFlHhjKhFQZEOP3Sk/ByCvEw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Logging": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Options": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Debug": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "A/4vBtVaySLBGj4qluye+KSbeVCCMa6GcTbxf2YgnSDHs9b9105+VojBJ1eJPel8F1ny0JOh+Ci3vgCKn69tNQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Logging": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "EWda5nSXhzQZr3yJ3+XgIApOek+Hm+txhWCEzWNVPp/OfimL4qmvctgXu87m+S2RXw/AoUP8aLMNicJ2KWblVA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Logging": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Options": "10.0.0",
|
||||
"System.Diagnostics.EventLog": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.EventSource": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "+Qc+kgoJi1w2A/Jm+7h04LcK2JoJkwAxKg7kBakkNRcemTmRGocqPa7rVNVGorTYruFrUS25GwkFNtOECnjhXg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Logging": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Options": "10.0.0",
|
||||
"Microsoft.Extensions.Primitives": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Primitives": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "tL9cSl3maS5FPzp/3MtlZI21ExWhni0nnUCF8HY4npTsINw45n9SNDbkKXBMtFyUFGSsQep25fHIDN4f/Vp3AQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Options": "10.0.0",
|
||||
"Microsoft.Extensions.Primitives": "10.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w=="
|
||||
},
|
||||
"SQLitePCLRaw.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.11",
|
||||
"contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA=="
|
||||
},
|
||||
"System.Diagnostics.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "uaFRda9NjtbJRkdx311eXlAA3n2em7223c1A8d1VWyl+4FL9vkG7y2lpPfBU9HYdj/9KgdRNdn1vFK8ZYCYT/A=="
|
||||
},
|
||||
"System.ServiceProcess.ServiceController": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "PaFmFQXX/qL1vl0CfdJUMPMYT8TOEHlcdEiOZtd4LV2rcaS/WDQM4bpECcbRM4BrD+DWkWd/jtb6iPOqAX9TWA==",
|
||||
"dependencies": {
|
||||
"System.Diagnostics.EventLog": "10.0.0"
|
||||
}
|
||||
},
|
||||
"qqq.printagent.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"KofTwentyTwo.AppKit": "[0.1.0, )"
|
||||
}
|
||||
},
|
||||
"qqq.printagent.windows": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Microsoft.Data.Sqlite.Core": "[10.0.0, )",
|
||||
"Qqq.PrintAgent.Core": "[0.1.0-rc.1, )",
|
||||
"SQLitePCLRaw.provider.winsqlite3": "[2.1.11, )",
|
||||
"System.Security.Cryptography.ProtectedData": "[10.0.0, )"
|
||||
}
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[8.4.2, )",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
|
||||
},
|
||||
"KofTwentyTwo.AppKit": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.1.0, )",
|
||||
"resolved": "0.1.0",
|
||||
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
|
||||
},
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.0, )",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "wPKG/Ym6tSMCo06UOZXzVfeFGzawnOZrTba/R3PfK+RhNuNELZ9I7nFns4WGTtv2kKlrlmmErgJ+kgTXHaNdHg==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.provider.winsqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.11, )",
|
||||
"resolved": "2.1.11",
|
||||
"contentHash": "UpH9v68yuLAN3nvXB96s6GE3/tQhG1WnXenNJL/HulgfJAQ5S6ZT6BUy/sDFo5WpqaS0npBbW1z6szWlu6a4Xg==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"System.Security.Cryptography.ProtectedData": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.0, )",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "qy6C3gQRTrfhFfuiIYU1jNwWfFdJpBVo1BARW7jgMm1zTEm3LRzLLyOLJGaAuKE+nZCGkEP71y4rj9NWjqLGdQ=="
|
||||
}
|
||||
},
|
||||
"net10.0-windows10.0.19041/win-x64": {
|
||||
"System.Diagnostics.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "uaFRda9NjtbJRkdx311eXlAA3n2em7223c1A8d1VWyl+4FL9vkG7y2lpPfBU9HYdj/9KgdRNdn1vFK8ZYCYT/A=="
|
||||
},
|
||||
"System.ServiceProcess.ServiceController": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "PaFmFQXX/qL1vl0CfdJUMPMYT8TOEHlcdEiOZtd4LV2rcaS/WDQM4bpECcbRM4BrD+DWkWd/jtb6iPOqAX9TWA==",
|
||||
"dependencies": {
|
||||
"System.Diagnostics.EventLog": "10.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>One bounded, credential-free local runtime observation.</summary>
|
||||
public sealed record AgentLogEntry(string Timestamp, string Level, string Message);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
// Named pipe protocol types have individual files.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
public static class AgentPipeClient
|
||||
{
|
||||
public static async Task<string> SendAsync(PipeRequest request)
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30));
|
||||
await using NamedPipeClientStream pipe = new(".", "QqqPrintAgent", PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
await pipe.ConnectAsync(timeout.Token).ConfigureAwait(false);
|
||||
await using StreamWriter writer = new(pipe, leaveOpen: true) { AutoFlush = true };
|
||||
using StreamReader reader = new(pipe, leaveOpen: true);
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(request, PrintApiClient.JsonOptions)).ConfigureAwait(false);
|
||||
return await reader.ReadLineAsync(timeout.Token).ConfigureAwait(false) ?? "Service closed the connection.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.IO.Pipes;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>Hosts outbound printing and the local administrator management pipe.</summary>
|
||||
public sealed class AgentRuntime(string directory, Action<string> log, IPrinterBackend? printerBackend = null) : IDisposable
|
||||
{
|
||||
private readonly Lock _stateLock = new();
|
||||
private readonly IPrinterBackend _printers = printerBackend ?? new WindowsPrinters();
|
||||
private readonly Queue<AgentLogEntry> _logs = new();
|
||||
private DateTimeOffset? _lastSuccessfulPoll;
|
||||
private string? _failure;
|
||||
|
||||
/// <summary>No persistent database handles are held by the runtime.</summary>
|
||||
public void Dispose() { }
|
||||
|
||||
|
||||
|
||||
/// <summary>Owns the data directory until Windows requests shutdown.</summary>
|
||||
public async Task RunAsync(CancellationToken token)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
using FileStream instance = new(Path.Combine(directory, "worker.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
|
||||
WriteLog("INFO", "QQQ Print Agent service started.");
|
||||
await Task.WhenAll(PollAsync(token), ServePipeAsync(token)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task PollAsync(CancellationToken token)
|
||||
{
|
||||
while(!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using AgentStore store = new(directory);
|
||||
AgentConfiguration? configuration = store.LoadConfiguration();
|
||||
if(configuration is not null)
|
||||
{
|
||||
using PrintApiClient api = new(configuration.ServerUrl, configuration.DeviceToken);
|
||||
ExposedPrinterBackend exposed = new(_printers, store.GetExposedPrinterIds);
|
||||
await new PrintWorker(api, exposed, store, message => WriteLog("INFO", message)).RunOnceAsync(token).ConfigureAwait(false);
|
||||
lock(_stateLock)
|
||||
{
|
||||
_lastSuccessfulPoll = DateTimeOffset.UtcNow;
|
||||
_failure = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception exception) when(exception is not OperationCanceledException)
|
||||
{
|
||||
lock(_stateLock)
|
||||
{
|
||||
_failure = exception.Message;
|
||||
}
|
||||
WriteLog("ERROR", exception.Message);
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Current local observations, with no token or document content.</summary>
|
||||
public AgentSnapshot GetSnapshot()
|
||||
{
|
||||
using AgentStore store = new(directory);
|
||||
AgentConfiguration? configuration = store.LoadConfiguration();
|
||||
IReadOnlySet<string> exposed = store.GetExposedPrinterIds();
|
||||
var printers = _printers.Discover().Select(printer => new PrinterSnapshot(printer.PrinterId, printer.Name, printer.DriverName, printer.PortName, printer.Status, printer.Formats, exposed.Contains(printer.PrinterId))).ToList();
|
||||
foreach(string missing in exposed.Where(id => !printers.Any(printer => string.Equals(printer.PrinterId, id, StringComparison.Ordinal))))
|
||||
{
|
||||
printers.Add(new PrinterSnapshot(missing, "Previously exposed printer is unavailable", "", "", "OFFLINE", [], true));
|
||||
}
|
||||
IReadOnlyList<JobSnapshot> jobs = store.RecentJobs();
|
||||
lock(_stateLock)
|
||||
{
|
||||
bool connected = configuration is not null && _failure is null && _lastSuccessfulPoll is not null && DateTimeOffset.UtcNow - _lastSuccessfulPoll.Value < TimeSpan.FromSeconds(30);
|
||||
bool printerIssue = !printers.Any(printer => printer.Exposed) || printers.Any(printer => printer.Exposed && !string.Equals(printer.Status, "READY", StringComparison.Ordinal));
|
||||
bool jobFailure = jobs.Count > 0 && jobs[0].Status is "FAILED" or "OUTCOME_UNKNOWN";
|
||||
string health = !connected || jobFailure ? "RED" : printerIssue ? "ORANGE" : "GREEN";
|
||||
string status = configuration is null ? "Not enrolled" : _failure ?? (!connected ? "Waiting for QQQ connection" : jobFailure ? "Connected; latest job needs attention" : printerIssue ? "Connected; exposed printers need attention" : "Connected; exposed printers ready");
|
||||
return new AgentSnapshot(configuration is not null, connected, health, status, configuration?.ServerUrl, configuration?.DeviceId, _lastSuccessfulPoll?.ToString("O", System.Globalization.CultureInfo.InvariantCulture), printers, jobs, _logs.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void WriteLog(string level, string message)
|
||||
{
|
||||
string bounded = message.Length <= 1000 ? message : message[..1000];
|
||||
lock(_stateLock)
|
||||
{
|
||||
_logs.Enqueue(new AgentLogEntry(DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture), level, bounded));
|
||||
while(_logs.Count > 100)
|
||||
{
|
||||
_logs.Dequeue();
|
||||
}
|
||||
}
|
||||
log(bounded);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task ServePipeAsync(CancellationToken token)
|
||||
{
|
||||
PipeSecurity security = new();
|
||||
security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.LocalSystemSid, domainSid: null), PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, domainSid: null), PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance, AccessControlType.Allow));
|
||||
while(!token.IsCancellationRequested)
|
||||
{
|
||||
await using NamedPipeServerStream pipe = NamedPipeServerStreamAcl.Create("QqqPrintAgent", PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 4096, 4096, security);
|
||||
await pipe.WaitForConnectionAsync(token).ConfigureAwait(false);
|
||||
using StreamReader reader = new(pipe, leaveOpen: true);
|
||||
await using StreamWriter writer = new(pipe, leaveOpen: true) { AutoFlush = true };
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
string line = await reader.ReadLineAsync(timeout.Token).ConfigureAwait(false) ?? "";
|
||||
if(line.Length > 8192)
|
||||
{
|
||||
throw new InvalidDataException("Command too large.");
|
||||
}
|
||||
PipeRequest request = JsonSerializer.Deserialize<PipeRequest>(line, PrintApiClient.JsonOptions) ?? throw new InvalidDataException("Invalid command.");
|
||||
await writer.WriteLineAsync(await HandleCommandAsync(request, timeout.Token).ConfigureAwait(false)).ConfigureAwait(false);
|
||||
}
|
||||
catch(Exception exception) when(exception is not OperationCanceledException || !token.IsCancellationRequested)
|
||||
{
|
||||
await writer.WriteLineAsync("Error: " + exception.Message).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task<string> HandleCommandAsync(PipeRequest request, CancellationToken token)
|
||||
{
|
||||
switch(request.Operation)
|
||||
{
|
||||
case "status":
|
||||
return GetSnapshot().Status;
|
||||
case "snapshot":
|
||||
return JsonSerializer.Serialize(GetSnapshot(), PrintApiClient.JsonOptions);
|
||||
case "printers":
|
||||
return JsonSerializer.Serialize(_printers.Discover(), PrintApiClient.JsonOptions);
|
||||
case "set-exposure":
|
||||
if(string.IsNullOrWhiteSpace(request.PrinterId) || request.Exposed is null)
|
||||
{
|
||||
throw new InvalidOperationException("PrinterId and Exposed are required.");
|
||||
}
|
||||
using(AgentStore store = new(directory))
|
||||
{
|
||||
store.SetExposure(request.PrinterId, request.Exposed.Value, _printers.Discover());
|
||||
}
|
||||
WriteLog("INFO", request.Exposed.Value ? "A printer was exposed to QQQ." : "A printer was hidden from QQQ.");
|
||||
return JsonSerializer.Serialize(GetSnapshot(), PrintApiClient.JsonOptions);
|
||||
case "enroll":
|
||||
return await EnrollAsync(request, token).ConfigureAwait(false);
|
||||
default:
|
||||
throw new InvalidOperationException("Unknown command.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task<string> EnrollAsync(PipeRequest request, CancellationToken token)
|
||||
{
|
||||
using PrintApiClient api = new(request.ServerUrl ?? throw new InvalidOperationException("Server URL required."));
|
||||
EnrollmentResponse response = await api.EnrollAsync(new EnrollmentRequest(request.EnrollmentCode ?? "", Environment.MachineName, "0.1.0"), token).ConfigureAwait(false);
|
||||
using AgentStore store = new(directory);
|
||||
store.SaveConfiguration(new AgentConfiguration(request.ServerUrl!, response.DeviceId, response.DeviceToken, response.PollIntervalSeconds));
|
||||
lock(_stateLock)
|
||||
{
|
||||
_lastSuccessfulPoll = null;
|
||||
_failure = null;
|
||||
}
|
||||
WriteLog("INFO", "Device enrolled with QQQ.");
|
||||
return $"Enrolled device {response.DeviceId}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>Credential-free local service observations for the tray companion.</summary>
|
||||
public sealed record AgentSnapshot(bool Enrolled, bool Connected, string Health, string Status, string? ServerUrl, string? DeviceId, string? LastSuccessfulPoll, IReadOnlyList<PrinterSnapshot> Printers, IReadOnlyList<JobSnapshot> Jobs, IReadOnlyList<AgentLogEntry> Logs);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
public sealed class AgentStore : IJobJournal, IDisposable
|
||||
{
|
||||
static AgentStore() => SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_winsqlite3());
|
||||
private readonly SqliteConnection _database;
|
||||
private readonly string _directory;
|
||||
|
||||
public AgentStore(string directory)
|
||||
{
|
||||
_directory = Path.GetFullPath(directory);
|
||||
Directory.CreateDirectory(_directory);
|
||||
_database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(_directory, "journal.db") }.ToString());
|
||||
_database.Open();
|
||||
using SqliteCommand command = _database.CreateCommand();
|
||||
command.CommandText = "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS events(sequence INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT NOT NULL, event_id TEXT NOT NULL UNIQUE, state TEXT NOT NULL, json TEXT NOT NULL, acknowledged INTEGER NOT NULL DEFAULT 0);";
|
||||
command.ExecuteNonQuery();
|
||||
command.CommandText = "CREATE TABLE IF NOT EXISTS exposed_printers(printer_id TEXT PRIMARY KEY); SELECT COUNT(*) FROM pragma_table_info('events') WHERE name='recorded_at';";
|
||||
long hasTimestamp = Convert.ToInt64(command.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture);
|
||||
if(hasTimestamp == 0)
|
||||
{
|
||||
command.CommandText = "ALTER TABLE events ADD COLUMN recorded_at TEXT";
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public static string DefaultDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "QqqPrintAgent");
|
||||
|
||||
public void SaveConfiguration(AgentConfiguration config)
|
||||
{
|
||||
byte[] plaintext = JsonSerializer.SerializeToUtf8Bytes(config, PrintApiClient.JsonOptions);
|
||||
byte[] protectedData = ProtectedData.Protect(plaintext, optionalEntropy: null, DataProtectionScope.CurrentUser);
|
||||
string path = Path.Combine(_directory, "connection.dpapi");
|
||||
File.WriteAllBytes(path + ".tmp", protectedData);
|
||||
File.Move(path + ".tmp", path, overwrite: true);
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
|
||||
public AgentConfiguration? LoadConfiguration()
|
||||
{
|
||||
string path = Path.Combine(_directory, "connection.dpapi");
|
||||
if(!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] plaintext = ProtectedData.Unprotect(File.ReadAllBytes(path), optionalEntropy: null, DataProtectionScope.CurrentUser);
|
||||
try
|
||||
{ return JsonSerializer.Deserialize<AgentConfiguration>(plaintext, PrintApiClient.JsonOptions); }
|
||||
finally { CryptographicOperations.ZeroMemory(plaintext); }
|
||||
}
|
||||
|
||||
public string? GetState(string jobId)
|
||||
{
|
||||
using SqliteCommand command = _database.CreateCommand();
|
||||
command.CommandText = "SELECT state FROM events WHERE job_id=$job ORDER BY sequence DESC LIMIT 1";
|
||||
command.Parameters.AddWithValue("$job", jobId);
|
||||
return command.ExecuteScalar() as string;
|
||||
}
|
||||
|
||||
public void Record(string jobId, PrintEvent printEvent)
|
||||
{
|
||||
using SqliteCommand command = _database.CreateCommand();
|
||||
command.CommandText = "INSERT INTO events(job_id,event_id,state,json,recorded_at) VALUES($job,$event,$state,$json,$time)";
|
||||
command.Parameters.AddWithValue("$job", jobId);
|
||||
command.Parameters.AddWithValue("$event", printEvent.EventId);
|
||||
command.Parameters.AddWithValue("$state", printEvent.Status);
|
||||
command.Parameters.AddWithValue("$json", JsonSerializer.Serialize(printEvent, PrintApiClient.JsonOptions));
|
||||
command.Parameters.AddWithValue("$time", DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture));
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public IReadOnlyList<(string JobId, PrintEvent Event)> Pending()
|
||||
{
|
||||
using SqliteCommand command = _database.CreateCommand();
|
||||
command.CommandText = "SELECT job_id,json FROM events WHERE acknowledged=0 ORDER BY sequence";
|
||||
using SqliteDataReader reader = command.ExecuteReader();
|
||||
List<(string, PrintEvent)> events = [];
|
||||
while(reader.Read())
|
||||
{
|
||||
events.Add((reader.GetString(0), JsonSerializer.Deserialize<PrintEvent>(reader.GetString(1), PrintApiClient.JsonOptions)!));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
public void Acknowledge(string eventId)
|
||||
{
|
||||
using SqliteCommand command = _database.CreateCommand();
|
||||
command.CommandText = "UPDATE events SET acknowledged=1 WHERE event_id=$event";
|
||||
command.Parameters.AddWithValue("$event", eventId);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Dispose() => _database.Dispose();
|
||||
|
||||
|
||||
|
||||
/// <summary>Queues explicitly selected on this computer. New installations expose none.</summary>
|
||||
public IReadOnlySet<string> GetExposedPrinterIds()
|
||||
{
|
||||
using SqliteCommand command = _database.CreateCommand();
|
||||
command.CommandText = "SELECT printer_id FROM exposed_printers";
|
||||
using SqliteDataReader reader = command.ExecuteReader();
|
||||
HashSet<string> result = new(StringComparer.Ordinal);
|
||||
while(reader.Read())
|
||||
{
|
||||
result.Add(reader.GetString(0));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Persists a local exposure choice after validating it against current discovery.</summary>
|
||||
public void SetExposure(string printerId, bool exposed, IReadOnlyList<PrinterInfo> discovered)
|
||||
{
|
||||
if(exposed && !discovered.Any(printer => string.Equals(printer.PrinterId, printerId, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new InvalidOperationException("Select a currently discovered printer.");
|
||||
}
|
||||
using SqliteCommand command = _database.CreateCommand();
|
||||
command.CommandText = exposed ? "INSERT OR IGNORE INTO exposed_printers(printer_id) VALUES($id)" : "DELETE FROM exposed_printers WHERE printer_id=$id";
|
||||
command.Parameters.AddWithValue("$id", printerId);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns bounded latest job states without exposing document content.</summary>
|
||||
public IReadOnlyList<JobSnapshot> RecentJobs()
|
||||
{
|
||||
using SqliteCommand command = _database.CreateCommand();
|
||||
command.CommandText = "SELECT job_id,json,recorded_at FROM events WHERE sequence IN (SELECT MAX(sequence) FROM events GROUP BY job_id) ORDER BY sequence DESC LIMIT 50";
|
||||
using SqliteDataReader reader = command.ExecuteReader();
|
||||
List<JobSnapshot> jobs = [];
|
||||
while(reader.Read())
|
||||
{
|
||||
PrintEvent printEvent = JsonSerializer.Deserialize<PrintEvent>(reader.GetString(1), PrintApiClient.JsonOptions)!;
|
||||
jobs.Add(new JobSnapshot(reader.GetString(0), printEvent.Status, reader.IsDBNull(2) ? "Earlier session" : reader.GetString(2), printEvent.Message, printEvent.SpoolerJobId));
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>Most recent local observation for one job; no document bytes or customer title.</summary>
|
||||
public sealed record JobSnapshot(string JobId, string Status, string UpdatedAt, string? Message, string? SpoolerJobId);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
public sealed record PipeRequest(string Operation, string? ServerUrl = null, string? EnrollmentCode = null, string? PrinterId = null, bool? Exposed = null);
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>A locally discovered queue and whether its owner has exposed it to QQQ.</summary>
|
||||
public sealed record PrinterSnapshot(string PrinterId, string Name, string DriverName, string PortName, string Status, string[] Formats, bool Exposed);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net10.0-windows10.0.19041.0</TargetFramework><AllowUnsafeBlocks>true</AllowUnsafeBlocks></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="../Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj" /></ItemGroup>
|
||||
<ItemGroup><PackageReference Include="Microsoft.Data.Sqlite.Core" /><PackageReference Include="System.Security.Cryptography.ProtectedData" /></ItemGroup>
|
||||
<ItemGroup><PackageReference Include="SQLitePCLRaw.provider.winsqlite3" /></ItemGroup></Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
using Windows.Data.Pdf;
|
||||
using Windows.Graphics.Imaging;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>Windows spooler discovery, RAW ZPL, and Windows PDF renderer to native GDI.</summary>
|
||||
public sealed class WindowsPrinters : IPrinterBackend
|
||||
{
|
||||
/// <summary>Enumerate installed queues visible to this Windows identity.</summary>
|
||||
public IReadOnlyList<PrinterInfo> Discover()
|
||||
{
|
||||
Native.EnumPrinters(6, null, 2, IntPtr.Zero, 0, out uint needed, out _);
|
||||
if(needed == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
IntPtr memory = Marshal.AllocHGlobal(checked((int)needed));
|
||||
try
|
||||
{
|
||||
Check(Native.EnumPrinters(6, null, 2, memory, needed, out _, out uint count));
|
||||
List<PrinterInfo> printers = [];
|
||||
for(int i = 0; i < count; i++)
|
||||
{
|
||||
Native.PrinterInfo2 info = Marshal.PtrToStructure<Native.PrinterInfo2>(memory + i * Marshal.SizeOf<Native.PrinterInfo2>());
|
||||
string name = Marshal.PtrToStringUni(info.PrinterName) ?? "";
|
||||
string driver = Marshal.PtrToStringUni(info.DriverName) ?? "";
|
||||
bool zebra = (name + driver).Contains("Zebra", StringComparison.OrdinalIgnoreCase) || driver.Contains("ZDesigner", StringComparison.OrdinalIgnoreCase);
|
||||
string status = (info.Status & 0x80) != 0 ? "OFFLINE" : (info.Status & 0x1) != 0 ? "PAUSED" : info.Status == 0 ? "READY" : "ERROR";
|
||||
string id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(name))).ToLowerInvariant()[..24];
|
||||
printers.Add(new PrinterInfo(id, name, driver, Marshal.PtrToStringUni(info.PortName) ?? "", status, zebra ? ["PDF", "ZPL"] : ["PDF"]));
|
||||
}
|
||||
return printers;
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(memory); }
|
||||
}
|
||||
|
||||
/// <summary>Submit exactly one server job using its explicit queue and format.</summary>
|
||||
public async Task<string> PrintAsync(PrintJob job, byte[] content, CancellationToken cancellationToken)
|
||||
{
|
||||
PrinterInfo printer = Discover().Single(p => string.Equals(p.PrinterId, job.PrinterId, StringComparison.Ordinal));
|
||||
if(job.Options.Copies is < 1 or > 100)
|
||||
{
|
||||
throw new InvalidDataException("Copies must be between 1 and 100.");
|
||||
}
|
||||
if(job.Options.PaperWidthMm is not null || job.Options.PaperHeightMm is not null)
|
||||
{
|
||||
throw new InvalidDataException("Configure paper dimensions in the Windows queue for this version.");
|
||||
}
|
||||
if(!printer.Formats.Contains(job.Format, StringComparer.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("The queue does not support this document format.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return job.Format switch
|
||||
{
|
||||
"ZPL" => PrintRaw(printer.Name, job, content),
|
||||
"PDF" => await PrintPdfAsync(printer.Name, job, content, cancellationToken).ConfigureAwait(false),
|
||||
_ => throw new InvalidDataException("Unsupported document format."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Send printer-ready ZPL bytes, handling partial spooler writes.</summary>
|
||||
private static string PrintRaw(string printerName, PrintJob job, byte[] content)
|
||||
{
|
||||
Check(Native.OpenPrinter(printerName, out IntPtr printer, IntPtr.Zero));
|
||||
try
|
||||
{
|
||||
Native.DocInfo1 info = new() { DocumentName = $"QQQ {job.JobId}", DataType = "RAW" };
|
||||
uint id = Native.StartDocPrinter(printer, 1, ref info);
|
||||
if(id == 0)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
for(int copy = 0; copy < job.Options.Copies; copy++)
|
||||
{
|
||||
int offset = 0;
|
||||
while(offset < content.Length)
|
||||
{
|
||||
byte[] remaining = content[offset..];
|
||||
Check(Native.WritePrinter(printer, remaining, remaining.Length, out int written));
|
||||
if(written == 0)
|
||||
{
|
||||
throw new IOException("Printer accepted zero bytes.");
|
||||
}
|
||||
|
||||
offset += written;
|
||||
}
|
||||
}
|
||||
Check(Native.EndDocPrinter(printer));
|
||||
return id.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch { Native.AbortPrinter(printer); throw; }
|
||||
}
|
||||
finally { Native.ClosePrinter(printer); }
|
||||
}
|
||||
|
||||
/// <summary>Own the native document lifecycle while rendering its PDF pages.</summary>
|
||||
private static async Task<string> PrintPdfAsync(string printerName, PrintJob job, byte[] content, CancellationToken cancellationToken)
|
||||
{
|
||||
using InMemoryRandomAccessStream pdfStream = new();
|
||||
using(DataWriter writer = new(pdfStream))
|
||||
{
|
||||
writer.WriteBytes(content);
|
||||
await writer.StoreAsync();
|
||||
writer.DetachStream();
|
||||
}
|
||||
pdfStream.Seek(0);
|
||||
PdfDocument document = await PdfDocument.LoadFromStreamAsync(pdfStream);
|
||||
IntPtr dc = Native.CreateDC("WINSPOOL", printerName, null, IntPtr.Zero);
|
||||
if(dc == IntPtr.Zero)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Native.DocInfo info = new() { Size = Marshal.SizeOf<Native.DocInfo>(), DocumentName = $"QQQ {job.JobId}" };
|
||||
int jobId = Native.StartDoc(dc, ref info);
|
||||
if(jobId <= 0)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
for(int copy = 0; copy < job.Options.Copies; copy++)
|
||||
{
|
||||
for(uint pageIndex = 0; pageIndex < document.PageCount; pageIndex++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using PdfPage page = document.GetPage(pageIndex);
|
||||
await PrintPageAsync(dc, page, job.Options.Scale).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if(Native.EndDoc(dc) <= 0)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
return jobId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cleanup is best effort; preserve the original submission error.
|
||||
_ = Native.AbortDoc(dc);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally { Native.DeleteDC(dc); }
|
||||
}
|
||||
|
||||
/// <summary>Render a bounded bitmap and map its physical size onto the driver page.</summary>
|
||||
private static async Task PrintPageAsync(IntPtr dc, PdfPage page, string scaleMode)
|
||||
{
|
||||
if(!double.IsFinite(page.Size.Width) || !double.IsFinite(page.Size.Height) || page.Size.Width <= 0 || page.Size.Height <= 0)
|
||||
{
|
||||
throw new InvalidDataException("PDF page dimensions are invalid.");
|
||||
}
|
||||
double renderScale = Math.Min(150d / 96, 5000 / Math.Max(page.Size.Width, page.Size.Height));
|
||||
using InMemoryRandomAccessStream rendered = new();
|
||||
await page.RenderToStreamAsync(rendered, new PdfPageRenderOptions
|
||||
{
|
||||
DestinationWidth = (uint)Math.Max(1, page.Size.Width * renderScale),
|
||||
DestinationHeight = (uint)Math.Max(1, page.Size.Height * renderScale),
|
||||
});
|
||||
rendered.Seek(0);
|
||||
BitmapDecoder decoder = await BitmapDecoder.CreateAsync(rendered);
|
||||
PixelDataProvider pixels = await decoder.GetPixelDataAsync(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Ignore, new BitmapTransform(), ExifOrientationMode.IgnoreExifOrientation, ColorManagementMode.DoNotColorManage);
|
||||
byte[] data = pixels.DetachPixelData();
|
||||
int width = checked((int)(page.Size.Width / 96 * Native.GetDeviceCaps(dc, 88)));
|
||||
int height = checked((int)(page.Size.Height / 96 * Native.GetDeviceCaps(dc, 90)));
|
||||
int x = -Native.GetDeviceCaps(dc, 112);
|
||||
int y = -Native.GetDeviceCaps(dc, 113);
|
||||
if(string.Equals(scaleMode, "FIT", StringComparison.Ordinal))
|
||||
{
|
||||
int availableWidth = Native.GetDeviceCaps(dc, 8);
|
||||
int availableHeight = Native.GetDeviceCaps(dc, 10);
|
||||
double factor = Math.Min((double)availableWidth / width, (double)availableHeight / height);
|
||||
width = (int)(width * factor);
|
||||
height = (int)(height * factor);
|
||||
x = (availableWidth - width) / 2;
|
||||
y = (availableHeight - height) / 2;
|
||||
}
|
||||
Native.BitmapInfo bitmap = new() { HeaderSize = 40, Width = (int)decoder.PixelWidth, Height = -(int)decoder.PixelHeight, Planes = 1, BitCount = 32 };
|
||||
Check(Native.StartPage(dc) > 0);
|
||||
Check(Native.SetStretchBltMode(dc, 4) != 0);
|
||||
int result = Native.StretchDIBits(dc, x, y, width, height, 0, 0, (int)decoder.PixelWidth, (int)decoder.PixelHeight, data, ref bitmap, 0, 0x00CC0020);
|
||||
Check(result is not (0 or -1));
|
||||
Check(Native.EndPage(dc) > 0);
|
||||
}
|
||||
|
||||
/// <summary>Convert a Win32 failure to an error the worker can report.</summary>
|
||||
private static void Check(bool success)
|
||||
{
|
||||
if(!success)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
}
|
||||
|
||||
private static class Native
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct PrinterInfo2
|
||||
{
|
||||
internal IntPtr ServerName, PrinterName, ShareName, PortName, DriverName, Comment, Location, DevMode, SepFile, PrintProcessor, DataType, Parameters, SecurityDescriptor;
|
||||
internal uint Attributes, Priority, DefaultPriority, StartTime, UntilTime, Status, Jobs, AveragePpm;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct DocInfo1
|
||||
{
|
||||
[MarshalAs(UnmanagedType.LPWStr)] internal string DocumentName;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] internal string? OutputFile;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] internal string DataType;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct DocInfo
|
||||
{
|
||||
internal int Size;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] internal string DocumentName;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] internal string? OutputFile;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] internal string? DataType;
|
||||
internal uint Type;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct BitmapInfo
|
||||
{
|
||||
internal uint HeaderSize;
|
||||
internal int Width, Height;
|
||||
internal ushort Planes, BitCount;
|
||||
internal uint Compression, ImageSize;
|
||||
internal int XPelsPerMeter, YPelsPerMeter;
|
||||
internal uint ColorsUsed, ColorsImportant;
|
||||
}
|
||||
[DllImport("winspool.drv", EntryPoint = "EnumPrintersW", SetLastError = true, CharSet = CharSet.Unicode)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool EnumPrinters(uint flags, string? name, uint level, IntPtr buffer, uint size, out uint needed, out uint returned);
|
||||
[DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool OpenPrinter(string name, out IntPtr handle, IntPtr defaults);
|
||||
[DllImport("winspool.drv", EntryPoint = "StartDocPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern uint StartDocPrinter(IntPtr printer, int level, ref DocInfo1 info);
|
||||
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool WritePrinter(IntPtr printer, byte[] bytes, int count, out int written);
|
||||
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool EndDocPrinter(IntPtr printer);
|
||||
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool ClosePrinter(IntPtr printer);
|
||||
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool AbortPrinter(IntPtr printer);
|
||||
[DllImport("gdi32.dll", EntryPoint = "CreateDCW", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern IntPtr CreateDC(string driver, string device, string? output, IntPtr devMode);
|
||||
[DllImport("gdi32.dll", EntryPoint = "StartDocW", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern int StartDoc(IntPtr dc, ref DocInfo info);
|
||||
[DllImport("gdi32.dll")] internal static extern int GetDeviceCaps(IntPtr dc, int index);
|
||||
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int StartPage(IntPtr dc);
|
||||
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int EndPage(IntPtr dc);
|
||||
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int EndDoc(IntPtr dc);
|
||||
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int AbortDoc(IntPtr dc);
|
||||
[DllImport("gdi32.dll")][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool DeleteDC(IntPtr dc);
|
||||
[DllImport("gdi32.dll")] internal static extern int SetStretchBltMode(IntPtr dc, int mode);
|
||||
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int StretchDIBits(IntPtr dc, int x, int y, int width, int height, int sx, int sy, int sw, int sh, byte[] bits, ref BitmapInfo info, uint usage, uint operation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0-windows10.0.19041": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.296, )",
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
},
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.0, )",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "wPKG/Ym6tSMCo06UOZXzVfeFGzawnOZrTba/R3PfK+RhNuNELZ9I7nFns4WGTtv2kKlrlmmErgJ+kgTXHaNdHg==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.provider.winsqlite3": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.1.11, )",
|
||||
"resolved": "2.1.11",
|
||||
"contentHash": "UpH9v68yuLAN3nvXB96s6GE3/tQhG1WnXenNJL/HulgfJAQ5S6ZT6BUy/sDFo5WpqaS0npBbW1z6szWlu6a4Xg==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"System.Security.Cryptography.ProtectedData": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.0, )",
|
||||
"resolved": "10.0.0",
|
||||
"contentHash": "qy6C3gQRTrfhFfuiIYU1jNwWfFdJpBVo1BARW7jgMm1zTEm3LRzLLyOLJGaAuKE+nZCGkEP71y4rj9NWjqLGdQ=="
|
||||
},
|
||||
"SQLitePCLRaw.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.11",
|
||||
"contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA=="
|
||||
},
|
||||
"qqq.printagent.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"KofTwentyTwo.AppKit": "[0.1.0, )"
|
||||
}
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[8.4.2, )",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
|
||||
},
|
||||
"KofTwentyTwo.AppKit": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.1.0, )",
|
||||
"resolved": "0.1.0",
|
||||
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
|
||||
}
|
||||
},
|
||||
"net10.0-windows10.0.19041/win-x64": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Application
|
||||
x:Class="Qqq.PrintAgent.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using KofTwentyTwo.AppKit.WinUI;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
|
||||
namespace Qqq.PrintAgent;
|
||||
|
||||
/// <summary>The application: installs AppKit's crash net and the saved theme, then opens the main window.</summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
private Window? _window;
|
||||
|
||||
|
||||
|
||||
/// <summary>Installs the crash net and applies the saved theme; WinUI allows setting the application theme only here, before any window exists.</summary>
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
WinUIShell.InstallCrashNet(this, () => Log);
|
||||
WinUIShell.ApplyTheme(this, AppIdentity.SettingsStore.Load().ThemeKind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The app-wide activity log.</summary>
|
||||
internal static IActivityLog Log { get; } = AppIdentity.CreateLog();
|
||||
|
||||
|
||||
|
||||
/// <summary>Logs the start and opens the main window.</summary>
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
Log.Info($"{AppIdentity.Info.DisplayName} started.");
|
||||
_window = new MainWindow();
|
||||
_window.Activate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<!-- Generated by AppKit BrandTool. Transparent outside the rounded tile. -->
|
||||
<defs>
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#0F766E" />
|
||||
<stop offset="1" stop-color="#134E4A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="48" y="48" width="416" height="416" rx="104" fill="url(#tile)" />
|
||||
<path d="M 200 152 L 312 256 L 200 360" fill="none" stroke="#FFFFFF" stroke-width="56" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Window x:Class="Qqq.PrintAgent.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Window.SystemBackdrop><MicaBackdrop /></Window.SystemBackdrop>
|
||||
<Grid x:Name="RootGrid" Loaded="Root_Loaded">
|
||||
<Grid.RowDefinitions><RowDefinition Height="Auto" /><RowDefinition Height="*" /></Grid.RowDefinitions>
|
||||
<MenuBar>
|
||||
<MenuBarItem Title="File"><MenuFlyoutItem Text="Hide to tray" Click="Hide_Click" /><MenuFlyoutItem Text="Exit companion" Click="Exit_Click" /></MenuBarItem>
|
||||
<MenuBarItem Title="View">
|
||||
<RadioMenuFlyoutItem x:Name="ThemeSystem" Text="System theme" GroupName="Theme" Tag="System" Click="Theme_Click" />
|
||||
<RadioMenuFlyoutItem x:Name="ThemeLight" Text="Light theme" GroupName="Theme" Tag="Light" Click="Theme_Click" />
|
||||
<RadioMenuFlyoutItem x:Name="ThemeDark" Text="Dark theme" GroupName="Theme" Tag="Dark" Click="Theme_Click" AutomationProperties.AutomationId="ThemeDark" />
|
||||
<MenuFlyoutSeparator />
|
||||
<ToggleMenuFlyoutItem x:Name="SplashToggle" Text="Show splash at startup" Click="Splash_Click" />
|
||||
</MenuBarItem>
|
||||
<MenuBarItem Title="Help">
|
||||
<MenuFlyoutItem Text="Activity log" Click="ActivityLog_Click" AutomationProperties.AutomationId="ActivityLogMenuItem" />
|
||||
<MenuFlyoutItem Text="About QQQ Print Agent" Click="About_Click" AutomationProperties.AutomationId="AboutMenuItem" />
|
||||
</MenuBarItem>
|
||||
</MenuBar>
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="20" Padding="32" MaxWidth="900" HorizontalAlignment="Stretch">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="QQQ Print Agent" Style="{ThemeResource TitleTextBlockStyle}" />
|
||||
<TextBlock Text="Connect this facility’s printers to QQQ." TextWrapping="Wrap" />
|
||||
<TextBlock Text="Close this window to keep status in your system tray. Printing continues in the Windows service." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Ellipse x:Name="HealthDot" Width="16" Height="16" Fill="Red" />
|
||||
<TextBlock x:Name="HealthText" Text="Red — service not connected" FontWeight="SemiBold" AutomationProperties.AutomationId="AgentHealth" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<InfoBar x:Name="FeedbackBar" IsOpen="True" IsClosable="False" Severity="Informational" Title="Checking service" Message="Contacting the local print service…" />
|
||||
<Expander Header="Connection settings" IsExpanded="True" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Connect to QQQ" Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Text="In QQQ, open Printing → Print devices → Actions → Enroll a print device. Enter the server address and the single-use enrollment code below." TextWrapping="Wrap" />
|
||||
<TextBox x:Name="ServerBox" Header="QQQ server address" PlaceholderText="https://qqq.example.com" AutomationProperties.AutomationId="ServerAddress" />
|
||||
<PasswordBox x:Name="EnrollmentBox" Header="Enrollment code" PlaceholderText="Paste the code from QQQ" AutomationProperties.AutomationId="EnrollmentCode" />
|
||||
<Button x:Name="EnrollButton" Content="Connect this device" Click="Enroll_Click" Style="{ThemeResource AccentButtonStyle}" AutomationProperties.AutomationId="EnrollDevice" />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
<StackPanel Spacing="10">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Service and printers" Style="{ThemeResource SubtitleTextBlockStyle}" VerticalAlignment="Center" />
|
||||
<Button x:Name="RefreshButton" Grid.Column="1" Content="Refresh" Click="Refresh_Click" AutomationProperties.AutomationId="RefreshStatus" />
|
||||
</Grid>
|
||||
<TextBlock x:Name="StatusText" Text="Waiting for service status" TextWrapping="Wrap" AutomationProperties.AutomationId="ServiceStatus" />
|
||||
<TextBlock x:Name="ConnectionText" TextWrapping="Wrap" IsTextSelectionEnabled="True" />
|
||||
<TextBlock x:Name="PrintersSummary" Text="Printers visible to the service appear here." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
<ListView x:Name="PrintersList" SelectionMode="None" MaxHeight="300" AutomationProperties.AutomationId="PrinterInventory" />
|
||||
<TextBlock Text="Only checked printers are exposed to QQQ. Enable them in QQQ after exposing them here. A submitted job is not confirmation of physical output." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<Expander Header="Recent print jobs" IsExpanded="True" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||
<StackPanel Spacing="8"><TextBlock x:Name="JobsSummary" Text="No job information yet." /><ListView x:Name="JobsList" SelectionMode="None" MaxHeight="220" AutomationProperties.AutomationId="RecentJobs"><ListView.ItemTemplate><DataTemplate><TextBlock Text="{Binding}" TextWrapping="Wrap" IsTextSelectionEnabled="True" Margin="0,5" /></DataTemplate></ListView.ItemTemplate></ListView></StackPanel>
|
||||
</Expander>
|
||||
<Expander Header="Service logs" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||
<StackPanel Spacing="8"><TextBlock Text="Recent service observations from this run. Credentials and document contents are excluded." TextWrapping="Wrap" /><ListView x:Name="ServiceLogsList" SelectionMode="None" MaxHeight="260" AutomationProperties.AutomationId="ServiceLogs"><ListView.ItemTemplate><DataTemplate><TextBlock Text="{Binding}" TextWrapping="Wrap" IsTextSelectionEnabled="True" Margin="0,5" /></DataTemplate></ListView.ItemTemplate></ListView></StackPanel>
|
||||
</Expander>
|
||||
<TextBlock Text="Install updates using your organization’s approved installer or software management. The companion and service update together." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,347 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using H.NotifyIcon;
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
using KofTwentyTwo.AppKit.WinUI;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Qqq.PrintAgent.Windows;
|
||||
|
||||
|
||||
namespace Qqq.PrintAgent;
|
||||
|
||||
/// <summary>AppKit companion for enrollment and diagnostics of the machine print service.</summary>
|
||||
public sealed partial class MainWindow : Window, IDisposable
|
||||
{
|
||||
private readonly AppSettings _settings = AppIdentity.SettingsStore.Load();
|
||||
private bool _busy;
|
||||
private bool _exitRequested;
|
||||
private readonly TaskbarIcon _tray;
|
||||
private readonly MenuFlyoutItem _trayStatus;
|
||||
private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(5) };
|
||||
private string? _currentHealth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Initialize the shared shell and the printing setup screen.</summary>
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Title = AppIdentity.Info.DisplayName;
|
||||
this.ResizeForDpi(960, 820);
|
||||
this.SetMinimumSize(680, 600);
|
||||
this.SetAppIcon(AppIdentity.Info);
|
||||
SplashToggle.IsChecked = _settings.ShowSplashScreen;
|
||||
(_settings.ThemeKind switch { AppTheme.Light => ThemeLight, AppTheme.Dark => ThemeDark, _ => ThemeSystem }).IsChecked = true;
|
||||
SplashOverlay.ShowOver(RootGrid, AppIdentity.Info, _settings);
|
||||
_trayStatus = new MenuFlyoutItem { Text = "Red — service not connected", Command = new RelayCommand(ShowDetails) };
|
||||
MenuFlyout trayMenu = new();
|
||||
trayMenu.Items.Add(_trayStatus);
|
||||
trayMenu.Items.Add(new MenuFlyoutItem { Text = "Open details", Command = new RelayCommand(ShowDetails) });
|
||||
trayMenu.Items.Add(new MenuFlyoutItem { Text = "Refresh status", Command = new AsyncRelayCommand(RefreshAsync) });
|
||||
trayMenu.Items.Add(new MenuFlyoutSeparator());
|
||||
trayMenu.Items.Add(new MenuFlyoutItem { Text = "Exit companion (printing continues)", Command = new RelayCommand(ExitCompanion) });
|
||||
_tray = new TaskbarIcon { ToolTipText = "QQQ Print Agent — service not connected", ContextFlyout = trayMenu, LeftClickCommand = new RelayCommand(ShowDetails) };
|
||||
SetHealth("RED");
|
||||
_tray.ForceCreate(enablesEfficiencyMode: false);
|
||||
_refreshTimer.Tick += async (_, _) => await RefreshAsync();
|
||||
_refreshTimer.Start();
|
||||
AppWindow.Closing += (_, args) =>
|
||||
{
|
||||
if(!_exitRequested)
|
||||
{
|
||||
args.Cancel = true;
|
||||
AppWindow.Hide();
|
||||
}
|
||||
};
|
||||
Closed += (_, _) => Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Release the tray icon and polling timer when the companion exits.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
_tray.Dispose();
|
||||
LogWindow.CloseCurrent();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Read service status once the window has loaded.</summary>
|
||||
private async void Root_Loaded(object sender, RoutedEventArgs e) => await RefreshAsync();
|
||||
|
||||
|
||||
|
||||
/// <summary>Enroll through the service; the UI never receives or stores the device token.</summary>
|
||||
private async void Enroll_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if(_busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string address = ServerBox.Text.Trim();
|
||||
if(!Uri.TryCreate(address, UriKind.Absolute, out Uri? server)
|
||||
|| (!string.Equals(server.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal) && !(server.IsLoopback && string.Equals(server.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal)))
|
||||
|| !string.IsNullOrEmpty(server.UserInfo) || !string.IsNullOrEmpty(server.Query) || !string.IsNullOrEmpty(server.Fragment)
|
||||
|| string.IsNullOrWhiteSpace(EnrollmentBox.Password))
|
||||
{
|
||||
Feedback("Check the connection details", "Enter an HTTPS server address and the enrollment code from QQQ. Localhost HTTP is supported for development.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
SetBusy(true);
|
||||
try
|
||||
{
|
||||
Feedback("Connecting", "The service is enrolling this device with QQQ…", InfoBarSeverity.Informational);
|
||||
string result = await AgentPipeClient.SendAsync(new PipeRequest("enroll", address.TrimEnd('/'), EnrollmentBox.Password.Trim()));
|
||||
if(!result.StartsWith("Enrolled device ", StringComparison.Ordinal))
|
||||
{
|
||||
Feedback("Enrollment failed", result, InfoBarSeverity.Error);
|
||||
return;
|
||||
}
|
||||
Feedback("Device connected", result + ". Select which printers to expose here, then approve the discovered queues in QQQ.", InfoBarSeverity.Success);
|
||||
StatusText.Text = result;
|
||||
App.Log.Info("Print device enrollment completed.");
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException)
|
||||
{
|
||||
ServiceUnavailable();
|
||||
}
|
||||
finally
|
||||
{
|
||||
EnrollmentBox.Password = string.Empty;
|
||||
SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Refresh the local service and the queues visible under its identity.</summary>
|
||||
private async void Refresh_Click(object sender, RoutedEventArgs e) => await RefreshAsync();
|
||||
|
||||
|
||||
|
||||
/// <summary>Query diagnostic information without exposing credentials.</summary>
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if(_busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SetBusy(true);
|
||||
try
|
||||
{
|
||||
string response = await AgentPipeClient.SendAsync(new PipeRequest("snapshot"));
|
||||
AgentSnapshot snapshot = JsonSerializer.Deserialize<AgentSnapshot>(response, Qqq.PrintAgent.Core.Printing.PrintApiClient.JsonOptions)
|
||||
?? throw new JsonException("Missing snapshot.");
|
||||
RenderSnapshot(snapshot);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException)
|
||||
{
|
||||
ServiceUnavailable();
|
||||
}
|
||||
catch(Exception exception) when(exception is JsonException or InvalidOperationException or KeyNotFoundException)
|
||||
{
|
||||
SetHealth("RED");
|
||||
Feedback("Cannot read service response", "Install matching versions of the QQQ Print Agent companion and service, then refresh.", InfoBarSeverity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Give actionable guidance without logging pipe payloads or secrets.</summary>
|
||||
private void ServiceUnavailable()
|
||||
{
|
||||
SetHealth("RED");
|
||||
StatusText.Text = "Local print service unavailable";
|
||||
Feedback("Service unavailable", "Install and start the QQQ Print Agent Windows service, then refresh. This administration window needs administrator access.", InfoBarSeverity.Error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Prevent overlapping local administration requests.</summary>
|
||||
private void SetBusy(bool busy)
|
||||
{
|
||||
_busy = busy;
|
||||
EnrollButton.IsEnabled = !busy;
|
||||
RefreshButton.IsEnabled = !busy;
|
||||
foreach(StackPanel row in PrintersList.Items.OfType<StackPanel>())
|
||||
{
|
||||
foreach(CheckBox checkbox in row.Children.OfType<CheckBox>())
|
||||
{
|
||||
checkbox.IsEnabled = !busy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Present the latest operation's result.</summary>
|
||||
private void Feedback(string title, string message, InfoBarSeverity severity)
|
||||
{
|
||||
FeedbackBar.Title = title;
|
||||
FeedbackBar.Message = message;
|
||||
FeedbackBar.Severity = severity;
|
||||
FeedbackBar.IsOpen = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Hide details while the tray continues monitoring.</summary>
|
||||
private void Hide_Click(object sender, RoutedEventArgs e) => AppWindow.Hide();
|
||||
|
||||
|
||||
|
||||
/// <summary>Close only the companion; the service remains running.</summary>
|
||||
private void Exit_Click(object sender, RoutedEventArgs e) => ExitCompanion();
|
||||
|
||||
|
||||
|
||||
/// <summary>Remove the tray icon and close the companion deliberately.</summary>
|
||||
private void ExitCompanion()
|
||||
{
|
||||
_exitRequested = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Restore details from the tray without restarting the service.</summary>
|
||||
private void ShowDetails()
|
||||
{
|
||||
AppWindow.Show();
|
||||
Activate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Render the service's authoritative health, queues, jobs and bounded logs.</summary>
|
||||
private void RenderSnapshot(AgentSnapshot snapshot)
|
||||
{
|
||||
SetHealth(snapshot.Health);
|
||||
StatusText.Text = snapshot.Status;
|
||||
ConnectionText.Text = snapshot.Enrolled
|
||||
? $"Server: {snapshot.ServerUrl}\nDevice: {snapshot.DeviceId}\nLast successful poll: {snapshot.LastSuccessfulPoll ?? "Not yet"}"
|
||||
: "This device has not been enrolled. Open Connection settings to connect it to QQQ.";
|
||||
if(string.IsNullOrWhiteSpace(ServerBox.Text) && snapshot.ServerUrl is not null)
|
||||
{
|
||||
ServerBox.Text = snapshot.ServerUrl;
|
||||
}
|
||||
PrintersList.Items.Clear();
|
||||
foreach(PrinterSnapshot printer in snapshot.Printers)
|
||||
{
|
||||
CheckBox exposure = new() { Content = $"Expose {printer.Name} to QQQ", IsChecked = printer.Exposed, Tag = printer, IsEnabled = !_busy };
|
||||
exposure.Click += Exposure_Click;
|
||||
StackPanel row = new() { Spacing = 4, Margin = new Thickness(0, 6, 0, 6) };
|
||||
row.Children.Add(exposure);
|
||||
row.Children.Add(new TextBlock { Text = $"{printer.Status} · {string.Join(", ", printer.Formats)} · {(printer.Exposed ? "Exposed" : "Local only")}\nDriver: {printer.DriverName} · Port: {printer.PortName}\nPrinter ID: {printer.PrinterId}", TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true });
|
||||
PrintersList.Items.Add(row);
|
||||
}
|
||||
PrintersSummary.Text = $"{snapshot.Printers.Count} printer(s) visible · {snapshot.Printers.Count(printer => printer.Exposed)} exposed to QQQ";
|
||||
JobsList.ItemsSource = snapshot.Jobs.Select(job => $"{job.Status} · {job.UpdatedAt}\nJob: {job.JobId} · Spooler: {job.SpoolerJobId ?? "Not assigned"}\n{job.Message}").ToArray();
|
||||
JobsSummary.Text = snapshot.Jobs.Count == 0 ? "No local print jobs yet." : $"{snapshot.Jobs.Count} recent job(s)";
|
||||
ServiceLogsList.ItemsSource = snapshot.Logs.Select(entry => $"{entry.Timestamp} [{entry.Level}] {entry.Message}").ToArray();
|
||||
Feedback(HealthText.Text, snapshot.Status, snapshot.Health switch { "GREEN" => InfoBarSeverity.Success, "ORANGE" => InfoBarSeverity.Warning, _ => InfoBarSeverity.Error });
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Persist local printer exposure through the protected service pipe.</summary>
|
||||
private async void Exposure_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if(sender is not CheckBox { Tag: PrinterSnapshot printer } checkbox || _busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SetBusy(true);
|
||||
try
|
||||
{
|
||||
string response = await AgentPipeClient.SendAsync(new PipeRequest("set-exposure", PrinterId: printer.PrinterId, Exposed: checkbox.IsChecked == true));
|
||||
AgentSnapshot snapshot = JsonSerializer.Deserialize<AgentSnapshot>(response, Qqq.PrintAgent.Core.Printing.PrintApiClient.JsonOptions)
|
||||
?? throw new JsonException("Missing snapshot.");
|
||||
RenderSnapshot(snapshot);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException or JsonException)
|
||||
{
|
||||
checkbox.IsChecked = printer.Exposed;
|
||||
Feedback("Printer setting not saved", "The service did not confirm this change. Refresh and try again.", InfoBarSeverity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Pair tray color with a text status so color is never the only signal.</summary>
|
||||
private void SetHealth(string health)
|
||||
{
|
||||
string label = health switch { "GREEN" => "Green — connected and ready", "ORANGE" => "Orange — connected, printers need attention", _ => "Red — disconnected or failed" };
|
||||
HealthText.Text = label;
|
||||
_trayStatus.Text = label;
|
||||
_tray.ToolTipText = "QQQ Print Agent: " + label;
|
||||
if(string.Equals(_currentHealth, health, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
_currentHealth = health;
|
||||
global::Windows.UI.Color color = health switch { "GREEN" => Microsoft.UI.Colors.LimeGreen, "ORANGE" => Microsoft.UI.Colors.Orange, _ => Microsoft.UI.Colors.Red };
|
||||
SolidColorBrush brush = new(color);
|
||||
HealthDot.Fill = brush;
|
||||
_tray.IconSource = new GeneratedIconSource { Text = "●", FontSize = 64, Foreground = brush };
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Apply and save the AppKit theme.</summary>
|
||||
private void Theme_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_settings.Theme = (string)((FrameworkElement)sender).Tag;
|
||||
WinUIShell.ApplyTheme(RootGrid, _settings.ThemeKind);
|
||||
Save();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Save the splash preference.</summary>
|
||||
private void Splash_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_settings.ShowSplashScreen = SplashToggle.IsChecked;
|
||||
Save();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Show the shared AppKit activity log.</summary>
|
||||
private void ActivityLog_Click(object sender, RoutedEventArgs e) => LogWindow.ShowSingle(App.Log, AppIdentity.Info);
|
||||
|
||||
|
||||
|
||||
/// <summary>Show the shared AppKit About dialog.</summary>
|
||||
private async void About_Click(object sender, RoutedEventArgs e) => await AboutDialog.ShowAsync(AppIdentity.Info, Content.XamlRoot);
|
||||
|
||||
|
||||
|
||||
/// <summary>Persist presentation preferences only.</summary>
|
||||
private void Save()
|
||||
{
|
||||
if(!AppIdentity.SettingsStore.Save(_settings))
|
||||
{
|
||||
Feedback("Settings not saved", "The window preference could not be saved.", InfoBarSeverity.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user