Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3396e6da8c
|
||
|
|
feea631d17
|
||
|
|
e1961e6bd0
|
No files matched your search
@@ -1,68 +0,0 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
windows:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
runner: qqq-windows-amd64
|
||||
commands: |
|
||||
$temp = $env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()
|
||||
# Protocol fixtures pinned to the revision the verification expects.
|
||||
$protocol = Join-Path $temp "qqq-print-protocol-$env:GITHUB_RUN_ID"
|
||||
git clone --quiet https://git.kof22.com/QRun-IO/qqq-print-protocol.git $protocol
|
||||
git -C $protocol checkout --quiet ac835f8b9396b641b525669249fdb4a1e2a50c99
|
||||
pwsh -NoProfile -File ./build/Verify.ps1 -ProtocolFixtures (Join-Path $protocol fixtures)
|
||||
pwsh -NoProfile -File ./build/Test-WindowsSdkInventory.ps1 -VerifyPackages
|
||||
pwsh -NoProfile -File ./build/Test-BrandedInstaller.ps1
|
||||
pwsh -NoProfile -File ./build/Test-PackagingInputs.ps1
|
||||
# Unsigned MSIs: the base product plus one branded installer per edition. A vX.Y.Z[-rc.N] tag sets the version.
|
||||
$release = if ($env:GITHUB_REF_TYPE -eq 'tag') { $env:GITHUB_REF_NAME.TrimStart('v') } else { '1.2.1' }
|
||||
$product = $release -replace '-.*$', ''
|
||||
pwsh -NoProfile -File ./build/Build-Installer.ps1 -ProductVersion $product -ReleaseVersion $release
|
||||
$installers = @('QqqPrintAgent')
|
||||
foreach ($folder in Get-ChildItem -LiteralPath editions -Directory | Sort-Object Name) {
|
||||
pwsh -NoProfile -File ./build/Build-Installer.ps1 -ProductVersion $product -ReleaseVersion $release -Edition $folder.Name
|
||||
$installers += (Get-Content -LiteralPath (Join-Path $folder.FullName 'edition.json') -Raw | ConvertFrom-Json).assetPrefix
|
||||
}
|
||||
New-Item -ItemType Directory -Path artifacts/release -Force | Out-Null
|
||||
foreach ($prefix in $installers) {
|
||||
Copy-Item -LiteralPath "artifacts/installer/$prefix-$product-x64.msi" -Destination "artifacts/release/$prefix-v$release-x64.msi"
|
||||
}
|
||||
Copy-Item -LiteralPath build/Install-Branded.ps1, docs/branding.example.json -Destination artifacts/release/
|
||||
# PowerShell scripts: analyzer and Pester rejection-path tests with pinned modules saved to the job's temp directory.
|
||||
$modules = Join-Path $temp "psmodules-$env:GITHUB_RUN_ID"
|
||||
New-Item -ItemType Directory -Force -Path $modules | Out-Null
|
||||
Save-PSResource -Name PSScriptAnalyzer -Version 1.25.0 -Repository PSGallery -TrustRepository -Path $modules
|
||||
Save-PSResource -Name Pester -Version 5.7.1 -Repository PSGallery -TrustRepository -Path $modules
|
||||
$env:PSModulePath = "$modules$([IO.Path]::PathSeparator)$env:PSModulePath"
|
||||
pwsh -NoProfile -File ./build/Analyze-Scripts.ps1
|
||||
Import-Module Pester -RequiredVersion 5.7.1
|
||||
# The Pester suites assert on child processes' exit codes themselves.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$pester = New-PesterConfiguration
|
||||
$pester.Run.Path = @('build/Assert-Coverage.Tests.ps1', 'build/Analyze-Scripts.Tests.ps1', 'build/Install-Branded.Tests.ps1', 'build/Test-WindowsSdkInventory.Tests.ps1')
|
||||
$pester.Run.PassThru = $true
|
||||
if ((Invoke-Pester -Configuration $pester).Result -ne 'Passed') { throw 'Pester tests failed' }
|
||||
artifacts: |
|
||||
artifacts/release/*.msi
|
||||
artifacts/release/Install-Branded.ps1
|
||||
artifacts/release/branding.example.json
|
||||
scripts:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
|
||||
with:
|
||||
node-version: '24'
|
||||
uv: true
|
||||
install: npm ci --ignore-scripts --no-audit --no-fund
|
||||
commands: |
|
||||
uv sync --locked
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
python3 .github/scripts/test-source-headers.py
|
||||
python3 .github/scripts/check-source-headers.py
|
||||
node --test tools/virtual-agent/test/virtual-agent.test.mjs
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"typeCheckingMode": "strict",
|
||||
"pythonVersion": "3.13",
|
||||
"include": [
|
||||
"scripts/*.py"
|
||||
],
|
||||
"reportMissingTypeStubs": "error",
|
||||
"reportUnnecessaryTypeIgnoreComment": "error",
|
||||
"reportImplicitOverride": "error",
|
||||
"exclude": [
|
||||
"**/__pycache__"
|
||||
],
|
||||
"venvPath": "..",
|
||||
"venv": ".venv"
|
||||
}
|
||||
@@ -2,107 +2,55 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Reject missing/malformed CodeQL evidence before enforcing the severity gate."""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
RuleProperties = TypedDict("RuleProperties", {"security-severity": str | float | None}, total=False)
|
||||
|
||||
|
||||
class Rule(TypedDict):
|
||||
"""Keep the rule identity and optional severity metadata used by the gate."""
|
||||
|
||||
id: str
|
||||
properties: RuleProperties
|
||||
|
||||
|
||||
class Component(TypedDict, total=False):
|
||||
"""Describe CodeQL driver or extension rule metadata."""
|
||||
|
||||
name: str
|
||||
rules: list[Rule]
|
||||
|
||||
|
||||
class Tool(TypedDict, total=False):
|
||||
"""Group the driver and optional extension rule catalogs."""
|
||||
|
||||
driver: Component
|
||||
extensions: list[Component]
|
||||
|
||||
|
||||
class Result(TypedDict, total=False):
|
||||
"""Describe only the result fields consumed by the severity gate."""
|
||||
|
||||
ruleId: str
|
||||
rule: dict[str, str]
|
||||
suppressions: list[object]
|
||||
|
||||
|
||||
class Run(TypedDict, total=False):
|
||||
"""Retain invocation success and findings for one analyzed language."""
|
||||
|
||||
tool: Tool
|
||||
results: list[Result]
|
||||
invocations: list[dict[str, bool]]
|
||||
|
||||
|
||||
class Report(TypedDict, total=False):
|
||||
"""Represent SARIF fields while retaining the explicit runtime validation."""
|
||||
|
||||
version: str
|
||||
runs: list[Run]
|
||||
|
||||
|
||||
MAX_SEVERITY = 10
|
||||
|
||||
|
||||
def check(directory: str | Path, language: str, threshold: float) -> bool: # noqa: PLR0912 -- Keep the ordered evidence checks together; none may be skipped.
|
||||
"""Validate complete CodeQL evidence and report findings at the threshold."""
|
||||
if not math.isfinite(threshold) or not 0 <= threshold <= MAX_SEVERITY:
|
||||
raise ValueError("Invalid security severity threshold")
|
||||
canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language)
|
||||
files = sorted(Path(directory).glob("*.sarif"))
|
||||
if not canonical or not any(path.name == canonical + ".sarif" for path in files):
|
||||
raise ValueError("Missing expected CodeQL SARIF report for " + language)
|
||||
blocking: list[tuple[float, str]] = []
|
||||
for path in files:
|
||||
report: Report = json.loads(path.read_text(encoding="utf-8"))
|
||||
runs = report.get("runs")
|
||||
if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs:
|
||||
raise ValueError("Invalid or empty SARIF runs: " + path.name)
|
||||
for run in runs:
|
||||
driver = run.get("tool", {}).get("driver", {})
|
||||
results = run.get("results")
|
||||
if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list):
|
||||
raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name)
|
||||
rules: dict[str, Rule] = {}
|
||||
for component in [driver, *run.get("tool", {}).get("extensions", [])]:
|
||||
for rule in component.get("rules", []):
|
||||
rules[rule["id"]] = rule
|
||||
if not rules:
|
||||
raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name)
|
||||
for invocation in run.get("invocations", []):
|
||||
if invocation.get("executionSuccessful") is False:
|
||||
raise ValueError("CodeQL invocation failed: " + path.name)
|
||||
for result in results:
|
||||
rule_id = result.get("ruleId") or result.get("rule", {}).get("id")
|
||||
if rule_id not in rules:
|
||||
raise ValueError("Result references missing rule metadata: " + path.name)
|
||||
if result.get("suppressions"):
|
||||
continue
|
||||
severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0)
|
||||
if not math.isfinite(severity) or not 0 <= severity <= MAX_SEVERITY:
|
||||
raise ValueError("Invalid rule severity: " + rule_id)
|
||||
if severity >= threshold:
|
||||
blocking.append((severity, rule_id))
|
||||
for severity, rule_id in sorted(blocking, reverse=True):
|
||||
print(f"::error title=CodeQL security finding::{rule_id} ({severity})")
|
||||
print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)")
|
||||
return bool(blocking)
|
||||
def check(directory, language, threshold):
|
||||
if not math.isfinite(threshold) or not 0 <= threshold <= 10:
|
||||
raise ValueError("Invalid security severity threshold")
|
||||
canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language)
|
||||
files = sorted(Path(directory).glob("*.sarif"))
|
||||
if not canonical or not any(path.name == canonical + ".sarif" for path in files):
|
||||
raise ValueError("Missing expected CodeQL SARIF report for " + language)
|
||||
blocking = []
|
||||
for path in files:
|
||||
report = json.loads(path.read_text(encoding="utf-8"))
|
||||
runs = report.get("runs")
|
||||
if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs:
|
||||
raise ValueError("Invalid or empty SARIF runs: " + path.name)
|
||||
for run in runs:
|
||||
driver = run.get("tool", {}).get("driver", {})
|
||||
results = run.get("results")
|
||||
if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list):
|
||||
raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name)
|
||||
rules = {}
|
||||
for component in [driver, *run.get("tool", {}).get("extensions", [])]:
|
||||
for rule in component.get("rules", []):
|
||||
rules[rule["id"]] = rule
|
||||
if not rules:
|
||||
raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name)
|
||||
for invocation in run.get("invocations", []):
|
||||
if invocation.get("executionSuccessful") is False:
|
||||
raise ValueError("CodeQL invocation failed: " + path.name)
|
||||
for result in results:
|
||||
rule_id = result.get("ruleId") or result.get("rule", {}).get("id")
|
||||
if rule_id not in rules:
|
||||
raise ValueError("Result references missing rule metadata: " + path.name)
|
||||
if result.get("suppressions"):
|
||||
continue
|
||||
severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0)
|
||||
if not math.isfinite(severity) or not 0 <= severity <= 10:
|
||||
raise ValueError("Invalid rule severity: " + rule_id)
|
||||
if severity >= threshold:
|
||||
blocking.append((severity, rule_id))
|
||||
for severity, rule_id in sorted(blocking, reverse=True):
|
||||
print(f"::error title=CodeQL security finding::{rule_id} ({severity})")
|
||||
print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)")
|
||||
return bool(blocking)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
|
||||
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
|
||||
@@ -2,103 +2,58 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Require scanner evidence for every resolved NuGet lock-file dependency."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
import sys
|
||||
|
||||
|
||||
class LockedPackage(TypedDict, total=False):
|
||||
"""Describe the resolved NuGet fields consumed by inventory comparison."""
|
||||
|
||||
type: str
|
||||
resolved: str
|
||||
def expected_packages(root):
|
||||
packages = set()
|
||||
for path in Path(root).rglob("packages.lock.json"):
|
||||
if any(part in {"bin", "obj", ".git", ".trivycache"} for part in path.parts):
|
||||
continue
|
||||
lock = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
for framework in lock.get("dependencies", {}).values():
|
||||
for name, package in framework.items():
|
||||
if package.get("type", "").lower() == "project":
|
||||
continue
|
||||
version = package.get("resolved")
|
||||
if not isinstance(version, str) or not version:
|
||||
raise ValueError("Missing resolved NuGet version in " + str(path))
|
||||
packages.add((name.lower(), version))
|
||||
required = {"microsoft.data.sqlite.core", "microsoft.windowsappsdk", "koftwentytwo.appkit.winui"}
|
||||
if not required.issubset({name for name, _ in packages}):
|
||||
raise ValueError("Expected application NuGet lock graph is missing")
|
||||
return packages
|
||||
|
||||
|
||||
class Lock(TypedDict, total=False):
|
||||
"""Map framework names to their resolved dependency entries."""
|
||||
|
||||
dependencies: dict[str, dict[str, LockedPackage]]
|
||||
def check(mode, report_path, root):
|
||||
expected = expected_packages(root)
|
||||
report = json.loads(Path(report_path).read_text(encoding="utf-8"))
|
||||
if mode == "trivy":
|
||||
packages = [package for result in report.get("Results", []) for package in result.get("Packages", [])]
|
||||
actual = {(package.get("Name", "").lower(), package.get("Version")) for package in packages}
|
||||
elif mode == "sbom":
|
||||
if report.get("bomFormat") != "CycloneDX":
|
||||
raise ValueError("Expected a CycloneDX SBOM")
|
||||
packages = report.get("components", [])
|
||||
actual = {(package.get("name", "").lower(), package.get("version")) for package in packages
|
||||
if package.get("purl", "").lower().startswith("pkg:nuget/")}
|
||||
else:
|
||||
raise ValueError("Unknown inventory mode")
|
||||
missing = expected - actual
|
||||
if missing:
|
||||
raise ValueError(f"Scanner omitted {len(missing)} resolved NuGet package/version(s): {sorted(missing)[:10]}")
|
||||
print(f"Validated {mode} inventory: all {len(expected)} resolved NuGet package/version(s) present")
|
||||
|
||||
|
||||
class Package(TypedDict, total=False):
|
||||
"""Describe the Trivy and CycloneDX coordinate spellings."""
|
||||
|
||||
Name: str
|
||||
Version: str
|
||||
name: str
|
||||
version: str
|
||||
purl: str
|
||||
|
||||
|
||||
class Result(TypedDict, total=False):
|
||||
"""Hold packages analyzed in one Trivy result."""
|
||||
|
||||
Packages: list[Package]
|
||||
|
||||
|
||||
class Report(TypedDict, total=False):
|
||||
"""Describe the supported scanner inventories without changing validation."""
|
||||
|
||||
Results: list[Result]
|
||||
bomFormat: str
|
||||
components: list[Package]
|
||||
|
||||
|
||||
MAX_REPORTED_MISSING = 10
|
||||
OSV_STATUS_ARGUMENT_COUNT = 3
|
||||
|
||||
|
||||
def expected_packages(root: str | Path) -> set[tuple[str, str]]:
|
||||
"""Collect every selected package/version from owned NuGet lock files."""
|
||||
packages: set[tuple[str, str]] = set()
|
||||
for path in Path(root).rglob("packages.lock.json"):
|
||||
if any(part in {"bin", "obj", ".git", ".trivycache"} for part in path.parts):
|
||||
continue
|
||||
lock: Lock = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
for framework in lock.get("dependencies", {}).values():
|
||||
for name, package in framework.items():
|
||||
if package.get("type", "").lower() == "project":
|
||||
continue
|
||||
version = package.get("resolved")
|
||||
if not isinstance(version, str) or not version:
|
||||
raise ValueError("Missing resolved NuGet version in " + str(path))
|
||||
packages.add((name.lower(), version))
|
||||
required = {"microsoft.data.sqlite.core", "microsoft.windowsappsdk", "koftwentytwo.appkit.winui"}
|
||||
if not required.issubset({name for name, _ in packages}):
|
||||
raise ValueError("Expected application NuGet lock graph is missing")
|
||||
return packages
|
||||
|
||||
|
||||
def check(mode: str, report_path: str | Path, root: str | Path) -> None:
|
||||
"""Require the report to cover the entire selected NuGet graph."""
|
||||
expected = expected_packages(root)
|
||||
report: Report = json.loads(Path(report_path).read_text(encoding="utf-8"))
|
||||
if mode == "trivy":
|
||||
packages = [package for result in report.get("Results", []) for package in result.get("Packages", [])]
|
||||
actual = {(package.get("Name", "").lower(), package.get("Version")) for package in packages}
|
||||
elif mode == "sbom":
|
||||
if report.get("bomFormat") != "CycloneDX":
|
||||
raise ValueError("Expected a CycloneDX SBOM")
|
||||
packages = report.get("components", [])
|
||||
actual = {(package.get("name", "").lower(), package.get("version")) for package in packages if package.get("purl", "").lower().startswith("pkg:nuget/")}
|
||||
else:
|
||||
raise ValueError("Unknown inventory mode")
|
||||
missing = expected - actual
|
||||
if missing:
|
||||
raise ValueError(f"Scanner omitted {len(missing)} resolved NuGet package/version(s): {sorted(missing)[:MAX_REPORTED_MISSING]}")
|
||||
print(f"Validated {mode} inventory: all {len(expected)} resolved NuGet package/version(s) present")
|
||||
|
||||
|
||||
def check_osv_status(status: int) -> None:
|
||||
"""Accept only successful OSV scans, including scans reporting findings."""
|
||||
if status not in (0, 1):
|
||||
raise ValueError(f"OSV failed or reported no packages (exit {status})")
|
||||
def check_osv_status(status):
|
||||
if status not in (0, 1):
|
||||
raise ValueError(f"OSV failed or reported no packages (exit {status})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == OSV_STATUS_ARGUMENT_COUNT and sys.argv[1] == "osv-status":
|
||||
check_osv_status(int(sys.argv[2]))
|
||||
else:
|
||||
check(*sys.argv[1:])
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "osv-status":
|
||||
check_osv_status(int(sys.argv[2]))
|
||||
else:
|
||||
check(*sys.argv[1:])
|
||||
@@ -1,144 +0,0 @@
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
PYTHON_SUFFIXES = (".py", ".pyi", ".pyw")
|
||||
SCRIPT_PATTERN = ".github/scripts/*.py"
|
||||
PYRIGHT_CONFIG = ".github"
|
||||
JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx")
|
||||
# EX-0015: the only npm package whose license classification the PR gate may exempt (the matcher ignores versions, so this guard pins it).
|
||||
URI_JS_PURL = "pkg:npm/uri-js"
|
||||
URI_JS_VERSION = "4.4.1"
|
||||
URI_JS_INTEGRITY = "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="
|
||||
URI_JS_LICENSE_SHA256 = "0af366eff4c01ec147c9c61ea9e8ffad64a4294754c9d79355f3fd1b97cb2fb9"
|
||||
PR_GATES = ".github/workflows/pr-gates.yml"
|
||||
# Microsoft's official npm Pyright, restored from package-lock.json by `npm ci --ignore-scripts`.
|
||||
PYRIGHT_ENTRY = Path(__file__).resolve().parents[2] / "node_modules" / "pyright" / "index.js"
|
||||
|
||||
|
||||
def tracked_files(root: Path) -> list[str]:
|
||||
"""List tracked and not-yet-ignored new paths so no source file can silently bypass the gates."""
|
||||
# Fixed Git inventory operation; the root is an argument, never shell input.
|
||||
output = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"], check=True, capture_output=True).stdout.decode("utf-8") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
|
||||
return [name for name in output.split("\u0000") if name]
|
||||
|
||||
|
||||
def python_scope(tracked: list[str]) -> list[str]:
|
||||
"""Return the tracked Python sources, rejecting any outside the one checked script root."""
|
||||
candidates = sorted(name for name in tracked if name.endswith(PYTHON_SUFFIXES))
|
||||
outside = [name for name in candidates if not PurePosixPath(name).full_match(SCRIPT_PATTERN)]
|
||||
if outside:
|
||||
raise ValueError("Tracked Python outside the checked script root: " + ", ".join(outside))
|
||||
if not candidates:
|
||||
raise ValueError("No owned Python scripts selected")
|
||||
return candidates
|
||||
|
||||
|
||||
def javascript_scope(root: Path, tracked: list[str]) -> list[str]:
|
||||
"""Return tracked JavaScript/TypeScript sources, rejecting any outside the tsconfig include list."""
|
||||
candidates = sorted(name for name in tracked if name.endswith(JAVASCRIPT_SUFFIXES))
|
||||
tsconfig = root / "tsconfig.json"
|
||||
if not tsconfig.is_file():
|
||||
if candidates:
|
||||
raise ValueError("Tracked JavaScript/TypeScript without a tsconfig.json: " + ", ".join(candidates))
|
||||
return []
|
||||
includes: list[str] = json.loads(tsconfig.read_text(encoding="utf-8"))["include"]
|
||||
outside = [name for name in candidates if not any(PurePosixPath(name).full_match(pattern) for pattern in includes)]
|
||||
if outside:
|
||||
raise ValueError("Tracked JavaScript/TypeScript outside the tsconfig include list: " + ", ".join(outside))
|
||||
if not candidates:
|
||||
raise ValueError("No owned JavaScript sources selected")
|
||||
return candidates
|
||||
|
||||
|
||||
def run_pyright(root: Path, config: str) -> dict[str, Any]:
|
||||
"""Run the locked npm Pyright through Node with JSON output and return its full report."""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
raise ValueError("Node.js is required to run the locked Pyright")
|
||||
if not PYRIGHT_ENTRY.is_file():
|
||||
raise ValueError(f"Locked Pyright is missing; run npm ci --ignore-scripts ({PYRIGHT_ENTRY})")
|
||||
# Resolved Node and locked entry script; the config is an argument, never shell input.
|
||||
completed = subprocess.run([node, str(PYRIGHT_ENTRY), "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Resolved Node and locked entry script; no shell.
|
||||
try:
|
||||
report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :])
|
||||
int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing.
|
||||
report["exitCode"] = completed.returncode
|
||||
except (ValueError, KeyError) as error:
|
||||
raise ValueError(f"Pyright produced no JSON summary (exit {completed.returncode}): {completed.stdout}{completed.stderr}") from error
|
||||
return report
|
||||
|
||||
|
||||
def check_pyright_report(report: dict[str, Any], expected: int) -> None:
|
||||
"""Require the analyzed-file count to match the selected sources, with no errors or warnings."""
|
||||
summary = report["summary"]
|
||||
analyzed = summary["filesAnalyzed"]
|
||||
if analyzed == 0 or analyzed != expected:
|
||||
raise ValueError(f"Pyright analyzed {analyzed} files, expected {expected}")
|
||||
if summary["errorCount"] or summary["warningCount"]:
|
||||
details = [f"{item['file']}: {item['message']}" for item in report["generalDiagnostics"]]
|
||||
raise ValueError("Pyright reported findings:\n" + "\n".join(details))
|
||||
if report["exitCode"] != 0:
|
||||
raise ValueError(f"Pyright exited {report['exitCode']} despite a clean summary")
|
||||
|
||||
|
||||
def run_ruff(root: Path, arguments: list[str], sources: list[str]) -> None:
|
||||
"""Run Ruff on the explicit source list so no selected file can be skipped."""
|
||||
# Fixed interpreter module invocation; paths are arguments, never shell input.
|
||||
if subprocess.run([sys.executable, "-m", "ruff", *arguments, "--no-cache", *sources], cwd=root, check=False).returncode != 0: # noqa: S603 -- Fixed interpreter and module; no shell.
|
||||
raise ValueError("Ruff " + " ".join(arguments) + " failed")
|
||||
|
||||
|
||||
def check_uri_js_exception(root: Path) -> None:
|
||||
"""Allow the EX-0015 license exception only for the reviewed, unmodified, development-only uri-js."""
|
||||
packages: dict[str, dict[str, Any]] = json.loads((root / "package-lock.json").read_text(encoding="utf-8"))["packages"]
|
||||
copies = [entry for path, entry in packages.items() if path.rsplit("node_modules/", 1)[-1] == "uri-js"]
|
||||
if len(copies) != 1:
|
||||
raise ValueError(f"EX-0015 covers exactly one locked uri-js copy, found {len(copies)}")
|
||||
if copies[0].get("version") != URI_JS_VERSION or copies[0].get("integrity") != URI_JS_INTEGRITY or copies[0].get("dev") is not True:
|
||||
raise ValueError(f"EX-0015 covers only development-only uri-js {URI_JS_VERSION} with the reviewed integrity")
|
||||
root_entry = packages.get("", {})
|
||||
if "uri-js" in {*root_entry.get("dependencies", {}), *root_entry.get("devDependencies", {})}:
|
||||
raise ValueError("EX-0015 covers uri-js only as a transitive tool dependency, not a direct one")
|
||||
consumers = [path for path, entry in packages.items() if "uri-js" in entry.get("dependencies", {}) and entry.get("dev") is not True]
|
||||
if consumers:
|
||||
raise ValueError("uri-js is required by a non-development package: " + ", ".join(consumers))
|
||||
digest = hashlib.sha256((root / "node_modules" / "uri-js" / "LICENSE").read_bytes()).hexdigest()
|
||||
if digest != URI_JS_LICENSE_SHA256:
|
||||
raise ValueError(f"Installed uri-js LICENSE bytes differ from the reviewed EX-0015 copy ({digest})")
|
||||
caller = [line for line in (root / PR_GATES).read_text(encoding="utf-8").splitlines() if not line.lstrip().startswith("#")]
|
||||
listed = re.findall(r"pkg:npm/[^\s,]+", "\n".join(caller))
|
||||
if listed != [URI_JS_PURL]:
|
||||
raise ValueError(f"EX-0015 permits only {URI_JS_PURL} in allow-dependencies-licenses, found {listed}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Verify scope, then Ruff format/lint and strict Pyright with a positive analyzed count."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
tracked = tracked_files(root)
|
||||
sources = python_scope(tracked)
|
||||
javascript = javascript_scope(root, tracked)
|
||||
run_ruff(root, ["format", "--check"], sources)
|
||||
run_ruff(root, ["check"], sources)
|
||||
report = run_pyright(root, PYRIGHT_CONFIG)
|
||||
check_pyright_report(report, len(sources))
|
||||
check_uri_js_exception(root)
|
||||
print(f"Script quality verified: {len(sources)} Python scripts analyzed by Ruff and Pyright, {len(javascript)} JavaScript files in tsconfig scope")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -19,14 +19,14 @@ def selected(name: str, policy: dict[str, Any]) -> bool:
|
||||
return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"])
|
||||
|
||||
|
||||
def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch.
|
||||
def leading_comments(text: str, style: str) -> str:
|
||||
"""Read comments only, preserving required interpreter/XML directive ordering."""
|
||||
text = text.removeprefix("\ufeff")
|
||||
if text.startswith("<?xml"):
|
||||
declaration = re.match(r"<\?xml[^?]*\?>", text)
|
||||
if style != "xml" or declaration is None:
|
||||
raise ValueError("Invalid XML declaration")
|
||||
text = text[declaration.end() :]
|
||||
text = text[declaration.end():]
|
||||
elif text.startswith("#!"):
|
||||
if style not in ("hash", "powershell", "slash"):
|
||||
raise ValueError("Unexpected interpreter directive")
|
||||
@@ -35,7 +35,7 @@ def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each rec
|
||||
if style != "powershell":
|
||||
raise ValueError("Unexpected PowerShell directive")
|
||||
text = text.partition("\n")[2]
|
||||
comments: list[str] = []
|
||||
comments = []
|
||||
while text.strip():
|
||||
text = text.lstrip()
|
||||
if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE):
|
||||
@@ -46,9 +46,11 @@ def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each rec
|
||||
elif style == "slash" and text.startswith("//"):
|
||||
line, _, text = text.partition("\n")
|
||||
comments.append(line[2:])
|
||||
elif (style == "slash" and text.startswith("/*")) or (style == "xml" and text.startswith("<!--")) or (style == "powershell" and text.startswith("<#")):
|
||||
elif ((style == "slash" and text.startswith("/*"))
|
||||
or (style == "xml" and text.startswith("<!--"))
|
||||
or (style == "powershell" and text.startswith("<#"))):
|
||||
opening, closing = {"slash": ("/*", "*/"), "xml": ("<!--", "-->"), "powershell": ("<#", "#>")}[style]
|
||||
body, separator, rest = text[len(opening) :].partition(closing)
|
||||
body, separator, rest = text[len(opening):].partition(closing)
|
||||
if not separator:
|
||||
raise ValueError("Unterminated leading comment")
|
||||
comments.append(body)
|
||||
@@ -70,22 +72,19 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
|
||||
approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])]
|
||||
for notice in approved:
|
||||
holder = re.escape(notice["holder"])
|
||||
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-\u2013]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
|
||||
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
|
||||
if re.search(copyright_pattern, comments) is None:
|
||||
continue
|
||||
spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments)
|
||||
if spdx and any(value != notice["license"] for value in spdx):
|
||||
continue
|
||||
apache = all(
|
||||
phrase in comments
|
||||
for phrase in (
|
||||
"Licensed under the Apache License, Version 2.0",
|
||||
"you may not use this file except in compliance with the License.",
|
||||
"https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
|
||||
"limitations under the License.",
|
||||
)
|
||||
)
|
||||
apache = all(phrase in comments for phrase in (
|
||||
"Licensed under the Apache License, Version 2.0",
|
||||
"you may not use this file except in compliance with the License.",
|
||||
"https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
|
||||
"limitations under the License.",
|
||||
))
|
||||
if suffix == ".java" and not apache:
|
||||
continue
|
||||
if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache):
|
||||
@@ -95,9 +94,8 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
|
||||
|
||||
def check_repository(root: Path, policy: dict[str, Any]) -> int:
|
||||
"""Check tracked sources without scanning build outputs or executing source."""
|
||||
# Fixed Git inventory operation; paths are arguments, never shell input.
|
||||
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
|
||||
failures: list[str] = []
|
||||
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000")
|
||||
failures = []
|
||||
count = 0
|
||||
for name in tracked:
|
||||
if not name or not selected(name, policy):
|
||||
|
||||
+102
-125
@@ -2,167 +2,144 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Exercise the exact CI guards using synthetic evidence only."""
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, cast, override
|
||||
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
|
||||
def load(name: str) -> ModuleType:
|
||||
"""Load the real sibling guard for synthetic evidence tests."""
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
def load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
codeql = load("check-codeql-sarif")
|
||||
|
||||
|
||||
class SarifTests(unittest.TestCase):
|
||||
"""Reject incomplete SARIF evidence and preserve severity enforcement."""
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.directory = Path(self.temporary.name)
|
||||
self.report = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]}
|
||||
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
"""Create isolated synthetic evidence with cleanup after each test."""
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.directory = Path(self.temporary.name)
|
||||
self.report: dict[str, Any] = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]}
|
||||
def save(self, report):
|
||||
(self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8")
|
||||
|
||||
def save(self, report: object) -> None:
|
||||
"""Write the test body as SARIF without validating it first."""
|
||||
(self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8")
|
||||
def test_clean_report_with_zero_findings_passes(self):
|
||||
self.save(self.report)
|
||||
self.assertFalse(codeql.check(self.directory, "java-kotlin", 7))
|
||||
|
||||
def test_clean_report_with_zero_findings_passes(self) -> None:
|
||||
"""Verify clean report with zero findings passes."""
|
||||
self.save(self.report)
|
||||
self.assertFalse(codeql.check(self.directory, "java-kotlin", 7))
|
||||
def test_missing_report_or_wrong_language_fails(self):
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "csharp", 7)
|
||||
|
||||
def test_missing_report_or_wrong_language_fails(self) -> None:
|
||||
"""Verify missing report or wrong language fails."""
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "csharp", 7)
|
||||
def test_empty_or_malformed_report_fails(self):
|
||||
for text in ["", "{", "null", "[]"]:
|
||||
with self.subTest(text=text):
|
||||
(self.directory / "java.sarif").write_text(text)
|
||||
with self.assertRaises((ValueError, AttributeError)):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
|
||||
def test_empty_or_malformed_report_fails(self) -> None:
|
||||
"""Verify empty or malformed report fails."""
|
||||
for text in ["", "{", "null", "[]"]:
|
||||
with self.subTest(text=text):
|
||||
(self.directory / "java.sarif").write_text(text)
|
||||
with self.assertRaises((ValueError, AttributeError)):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
def test_missing_runs_tool_rules_or_results_fails(self):
|
||||
variants = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}]
|
||||
for field in ["tool", "results"]:
|
||||
bad = copy.deepcopy(self.report)
|
||||
del bad["runs"][0][field]
|
||||
variants.append(bad)
|
||||
bad = copy.deepcopy(self.report)
|
||||
bad["runs"][0]["tool"]["driver"]["rules"] = []
|
||||
variants.append(bad)
|
||||
for bad in variants:
|
||||
with self.subTest(report=bad):
|
||||
self.save(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
|
||||
def test_missing_runs_tool_rules_or_results_fails(self) -> None:
|
||||
"""Verify missing runs tool rules or results fails."""
|
||||
variants: list[dict[str, Any]] = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}]
|
||||
for field in ["tool", "results"]:
|
||||
bad = copy.deepcopy(self.report)
|
||||
del bad["runs"][0][field]
|
||||
variants.append(bad)
|
||||
bad = copy.deepcopy(self.report)
|
||||
bad["runs"][0]["tool"]["driver"]["rules"] = []
|
||||
variants.append(bad)
|
||||
for bad in variants:
|
||||
with self.subTest(report=bad):
|
||||
self.save(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
def test_high_finding_blocks_and_unknown_rule_fails(self):
|
||||
self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}]
|
||||
self.save(self.report)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertTrue(codeql.check(self.directory, "java-kotlin", 7))
|
||||
self.report["runs"][0]["results"] = [{"ruleId": "missing"}]
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
|
||||
def test_high_finding_blocks_and_unknown_rule_fails(self) -> None:
|
||||
"""Verify high finding blocks and unknown rule fails."""
|
||||
self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}]
|
||||
self.save(self.report)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertTrue(codeql.check(self.directory, "java-kotlin", 7))
|
||||
self.report["runs"][0]["results"] = [{"ruleId": "missing"}]
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
|
||||
def test_failed_invocation_and_invalid_threshold_fail(self) -> None:
|
||||
"""Verify failed invocation and invalid threshold fail."""
|
||||
self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}]
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", float("nan"))
|
||||
def test_failed_invocation_and_invalid_threshold_fail(self):
|
||||
self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}]
|
||||
self.save(self.report)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", 7)
|
||||
with self.assertRaises(ValueError):
|
||||
codeql.check(self.directory, "java-kotlin", float("nan"))
|
||||
|
||||
|
||||
inventory = load("check-dependency-inventory")
|
||||
|
||||
|
||||
class InventoryTests(unittest.TestCase):
|
||||
"""Exercise selected dependency coverage using isolated synthetic reports."""
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.names = ["Microsoft.Data.Sqlite.Core", "Microsoft.WindowsAppSDK", "KofTwentyTwo.AppKit.WinUI", "Synthetic.Transitive"]
|
||||
lock = {"dependencies": {"net10": {name: {"type": "Transitive", "resolved": "1.0.0"} for name in self.names}}}
|
||||
(self.root / "packages.lock.json").write_text(json.dumps(lock))
|
||||
self.report = self.root / "report.json"
|
||||
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
"""Create isolated synthetic evidence with cleanup after each test."""
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.names = ["Microsoft.Data.Sqlite.Core", "Microsoft.WindowsAppSDK", "KofTwentyTwo.AppKit.WinUI", "Synthetic.Transitive"]
|
||||
lock = {"dependencies": {"net10": {name: {"type": "Transitive", "resolved": "1.0.0"} for name in self.names}}}
|
||||
(self.root / "packages.lock.json").write_text(json.dumps(lock))
|
||||
self.report = self.root / "report.json"
|
||||
def test_complete_trivy_inventory_passes_and_missing_package_fails(self):
|
||||
packages = [{"Name": name, "Version": "1.0.0"} for name in self.names]
|
||||
self.report.write_text(json.dumps({"Results": [{"Packages": packages}]}))
|
||||
inventory.check("trivy", self.report, self.root)
|
||||
packages.pop()
|
||||
self.report.write_text(json.dumps({"Results": [{"Packages": packages}]}))
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check("trivy", self.report, self.root)
|
||||
|
||||
def test_complete_trivy_inventory_passes_and_missing_package_fails(self) -> None:
|
||||
"""Verify complete trivy inventory passes and missing package fails."""
|
||||
packages = [{"Name": name, "Version": "1.0.0"} for name in self.names]
|
||||
self.report.write_text(json.dumps({"Results": [{"Packages": packages}]}))
|
||||
inventory.check("trivy", self.report, self.root)
|
||||
packages.pop()
|
||||
self.report.write_text(json.dumps({"Results": [{"Packages": packages}]}))
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check("trivy", self.report, self.root)
|
||||
def test_nonempty_sbom_must_contain_expected_nuget_graph(self):
|
||||
packages = [{"name": name, "version": "1.0.0", "purl": "pkg:nuget/" + name + "@1.0.0"} for name in self.names]
|
||||
self.report.write_text(json.dumps({"bomFormat": "CycloneDX", "components": packages}))
|
||||
inventory.check("sbom", self.report, self.root)
|
||||
for components in [[], packages[:1], [{"name": "unrelated", "version": "1", "purl": "pkg:npm/unrelated@1"}]]:
|
||||
with self.subTest(components=components):
|
||||
self.report.write_text(json.dumps({"bomFormat": "CycloneDX", "components": components}))
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check("sbom", self.report, self.root)
|
||||
|
||||
def test_nonempty_sbom_must_contain_expected_nuget_graph(self) -> None:
|
||||
"""Verify nonempty sbom must contain expected nuget graph."""
|
||||
packages = [{"name": name, "version": "1.0.0", "purl": "pkg:nuget/" + name + "@1.0.0"} for name in self.names]
|
||||
self.report.write_text(json.dumps({"bomFormat": "CycloneDX", "components": packages}))
|
||||
inventory.check("sbom", self.report, self.root)
|
||||
for components in [[], packages[:1], [{"name": "unrelated", "version": "1", "purl": "pkg:npm/unrelated@1"}]]:
|
||||
with self.subTest(components=components):
|
||||
self.report.write_text(json.dumps({"bomFormat": "CycloneDX", "components": components}))
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check("sbom", self.report, self.root)
|
||||
def test_empty_or_wrong_version_inventory_fails(self):
|
||||
for report in [{}, {"Results": []}, {"Results": [{"Packages": [{"Name": name, "Version": "0.0.0"} for name in self.names]}]}]:
|
||||
with self.subTest(report=report):
|
||||
self.report.write_text(json.dumps(report))
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check("trivy", self.report, self.root)
|
||||
|
||||
def test_empty_or_wrong_version_inventory_fails(self) -> None:
|
||||
"""Verify empty or wrong version inventory fails."""
|
||||
for report in cast(list[object], [{}, {"Results": []}, {"Results": [{"Packages": [{"Name": name, "Version": "0.0.0"} for name in self.names]}]}]):
|
||||
with self.subTest(report=report):
|
||||
self.report.write_text(json.dumps(report))
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check("trivy", self.report, self.root)
|
||||
|
||||
def test_osv_empty_inventory_or_engine_failure_is_rejected(self) -> None:
|
||||
"""Verify osv empty inventory or engine failure is rejected."""
|
||||
for status in [0, 1]:
|
||||
inventory.check_osv_status(status)
|
||||
for status in [2, 127, 128]:
|
||||
with self.subTest(status=status), self.assertRaises(ValueError):
|
||||
def test_osv_empty_inventory_or_engine_failure_is_rejected(self):
|
||||
for status in [0, 1]:
|
||||
inventory.check_osv_status(status)
|
||||
for status in [2, 127, 128]:
|
||||
with self.subTest(status=status):
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.check_osv_status(status)
|
||||
|
||||
def test_missing_lock_graph_fails(self) -> None:
|
||||
"""Verify missing lock graph fails."""
|
||||
(self.root / "packages.lock.json").unlink()
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.expected_packages(self.root)
|
||||
def test_missing_lock_graph_fails(self):
|
||||
(self.root / "packages.lock.json").unlink()
|
||||
with self.assertRaises(ValueError):
|
||||
inventory.expected_packages(self.root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
@@ -1,297 +0,0 @@
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Prove the quality gates reject real violations, using private temporary fixtures and the locked tools."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, override
|
||||
from unittest import mock
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
GOOD_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
|
||||
UNUSED_IMPORT_PYTHON = '"""Probe module."""\n\nimport os\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
|
||||
TWO_SPACE_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
|
||||
INCOMPATIBLE_PYTHON = '"""Probe module."""\n\nVALUE: int = "text"\n'
|
||||
UNANNOTATED_PYTHON = '"""Probe module."""\n\n\ndef echo(value):\n """Return the value."""\n return value\n'
|
||||
GOOD_JAVASCRIPT = "export function addNumbers(left, right)\n{\n return left + right;\n}\n"
|
||||
BAD_LAYOUT_JAVASCRIPT = "export function addNumbers(left, right) {\n return left + right\n}\n"
|
||||
GOOD_TYPED_JAVASCRIPT = "/** @type {number} */\nexport const value = 1;\n"
|
||||
INCOMPATIBLE_JAVASCRIPT = '/** @type {number} */\nexport const value = "text";\n'
|
||||
|
||||
|
||||
def load(name: str) -> ModuleType:
|
||||
"""Load the real sibling guard for tests."""
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
guard = load("check-script-quality")
|
||||
|
||||
|
||||
def run(command: list[str], cwd: Path, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
|
||||
"""Run one real tool invocation without a shell and capture its diagnostics."""
|
||||
return subprocess.run(command, cwd=cwd, input=stdin, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed tool paths and private temporary arguments; no shell.
|
||||
|
||||
|
||||
def ruff(arguments: list[str], source: Path) -> subprocess.CompletedProcess[str]:
|
||||
"""Run the locked Ruff with the repository configuration on one private fixture."""
|
||||
return run([sys.executable, "-m", "ruff", *arguments, "--no-cache", "--config", str(ROOT / "ruff.toml"), str(source)], source.parent)
|
||||
|
||||
|
||||
class ScopeTests(unittest.TestCase):
|
||||
"""Reject tracked sources that fall outside the explicit tool roots."""
|
||||
|
||||
def test_python_outside_script_root_is_rejected(self) -> None:
|
||||
"""Reject a tracked Python file outside the script root instead of letting it disappear."""
|
||||
for stray in ("tools/helper.py", ".github/scripts/nested/helper.py", "helper.pyi"):
|
||||
with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the checked script root"):
|
||||
guard.python_scope([".github/scripts/check-a.py", stray])
|
||||
|
||||
def test_python_scope_returns_sorted_script_root_and_rejects_empty(self) -> None:
|
||||
"""Select only the direct script root, sorted, and reject selecting nothing."""
|
||||
self.assertEqual([".github/scripts/a.py", ".github/scripts/b.py"], guard.python_scope([".github/scripts/b.py", "README.md", ".github/scripts/a.py"]))
|
||||
with self.assertRaisesRegex(ValueError, "No owned Python"):
|
||||
guard.python_scope(["README.md"])
|
||||
|
||||
def test_javascript_requires_tsconfig_include_scope(self) -> None:
|
||||
"""Reject tracked JavaScript outside tsconfig include patterns, including .js/.cjs/.ts."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
(root / "tsconfig.json").write_text(json.dumps({"include": ["tools/**/*.mjs", "eslint.config.mjs"]}), encoding="utf-8")
|
||||
self.assertEqual(["eslint.config.mjs", "tools/a/b.mjs", "tools/c.mjs"], guard.javascript_scope(root, ["tools/c.mjs", "tools/a/b.mjs", "eslint.config.mjs", "README.md"]))
|
||||
for stray in ("src/z.js", "tools/w.cjs", "tools/v.ts", "tools/u.js"):
|
||||
with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the tsconfig include list"):
|
||||
guard.javascript_scope(root, ["tools/c.mjs", stray])
|
||||
with self.assertRaisesRegex(ValueError, "No owned JavaScript"):
|
||||
guard.javascript_scope(root, ["README.md"])
|
||||
|
||||
def test_javascript_without_tsconfig_is_rejected(self) -> None:
|
||||
"""Reject JavaScript in a repository that has no tsconfig."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
self.assertEqual([], guard.javascript_scope(root, ["README.md"]))
|
||||
with self.assertRaisesRegex(ValueError, "without a tsconfig.json"):
|
||||
guard.javascript_scope(root, ["tools/a.mjs"])
|
||||
|
||||
|
||||
class PyrightCountTests(unittest.TestCase):
|
||||
"""Reject Pyright reports that analyzed too few files or found anything."""
|
||||
|
||||
@staticmethod
|
||||
def report(analyzed: int, errors: int = 0, warnings: int = 0) -> dict[str, Any]:
|
||||
"""Build the exact Pyright JSON shape consumed by the guard."""
|
||||
return {"summary": {"filesAnalyzed": analyzed, "errorCount": errors, "warningCount": warnings}, "generalDiagnostics": [{"file": "x.py", "message": "finding"}] * (errors + warnings), "exitCode": 1 if errors or warnings else 0}
|
||||
|
||||
def test_nonzero_exit_fails_even_with_clean_summary(self) -> None:
|
||||
"""Fail a nonzero Pyright exit even when every count is clean."""
|
||||
clean = self.report(4)
|
||||
clean["exitCode"] = 1
|
||||
with self.assertRaisesRegex(ValueError, "exited 1"):
|
||||
guard.check_pyright_report(clean, 4)
|
||||
|
||||
def test_count_must_match_selected_sources(self) -> None:
|
||||
"""Fail zero or mismatched analyzed counts even with no diagnostics."""
|
||||
guard.check_pyright_report(self.report(4), 4)
|
||||
for analyzed in (0, 3, 5):
|
||||
with self.subTest(analyzed=analyzed), self.assertRaisesRegex(ValueError, "analyzed"):
|
||||
guard.check_pyright_report(self.report(analyzed), 4)
|
||||
|
||||
def test_errors_and_warnings_fail(self) -> None:
|
||||
"""Fail on errors and on warnings at the exact count."""
|
||||
for report in (self.report(4, errors=1), self.report(4, warnings=1)):
|
||||
with self.subTest(report=report), self.assertRaisesRegex(ValueError, "reported findings"):
|
||||
guard.check_pyright_report(report, 4)
|
||||
|
||||
|
||||
class RuffProbeTests(unittest.TestCase):
|
||||
"""Run the real locked Ruff on bounded private fixtures."""
|
||||
|
||||
def test_compliant_fixture_passes_and_violations_fail(self) -> None:
|
||||
"""Pass a compliant control and reject unused imports and wrong indentation."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
probe = Path(name) / "probe.py"
|
||||
probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
|
||||
self.assertEqual(0, ruff(["check"], probe).returncode)
|
||||
self.assertEqual(0, ruff(["format", "--check"], probe).returncode)
|
||||
probe.write_text(UNUSED_IMPORT_PYTHON, encoding="utf-8", newline="\n")
|
||||
unused = ruff(["check", "--output-format", "concise"], probe)
|
||||
self.assertNotEqual(0, unused.returncode)
|
||||
self.assertIn("F401", unused.stdout)
|
||||
probe.write_text(TWO_SPACE_PYTHON, encoding="utf-8", newline="\n")
|
||||
indented = ruff(["format", "--check"], probe)
|
||||
self.assertNotEqual(0, indented.returncode)
|
||||
self.assertIn("would be reformatted", (indented.stdout + indented.stderr).lower())
|
||||
|
||||
|
||||
class PyrightProbeTests(unittest.TestCase):
|
||||
"""Run the real locked Pyright with the repository strict settings on private projects."""
|
||||
|
||||
@staticmethod
|
||||
def project(root: Path, include: list[str] | None = None) -> None:
|
||||
"""Write the repository Pyright config, without its venv location, into a private project."""
|
||||
config: dict[str, Any] = json.loads((ROOT / ".github/pyrightconfig.json").read_text(encoding="utf-8"))
|
||||
del config["venvPath"], config["venv"]
|
||||
if include is not None:
|
||||
config["include"] = include
|
||||
(root / "pyrightconfig.json").write_text(json.dumps(config), encoding="utf-8")
|
||||
|
||||
def test_compliant_control_passes_and_strict_violations_fail(self) -> None:
|
||||
"""Analyze the control once with no findings and reject incompatible and unannotated code."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
self.project(root)
|
||||
scripts = root / "scripts"
|
||||
scripts.mkdir()
|
||||
probe = scripts / "probe.py"
|
||||
probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
|
||||
clean = guard.run_pyright(root, ".")
|
||||
self.assertEqual(0, clean["exitCode"])
|
||||
guard.check_pyright_report(clean, 1)
|
||||
clean["exitCode"] = 1
|
||||
with self.assertRaisesRegex(ValueError, "exited 1"):
|
||||
guard.check_pyright_report(clean, 1)
|
||||
probe.write_text(INCOMPATIBLE_PYTHON, encoding="utf-8", newline="\n")
|
||||
report = guard.run_pyright(root, ".")
|
||||
self.assertIn("reportAssignmentType", {item.get("rule") for item in report["generalDiagnostics"]})
|
||||
with self.assertRaisesRegex(ValueError, "reported findings"):
|
||||
guard.check_pyright_report(report, 1)
|
||||
probe.write_text(UNANNOTATED_PYTHON, encoding="utf-8", newline="\n")
|
||||
strict = guard.run_pyright(root, ".")
|
||||
self.assertIn("reportMissingParameterType", {item.get("rule") for item in strict["generalDiagnostics"]})
|
||||
|
||||
def test_missing_locked_pyright_or_node_is_rejected(self) -> None:
|
||||
"""Reject a missing locked Pyright entry or Node instead of silently skipping the type gate."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
with mock.patch.object(guard, "PYRIGHT_ENTRY", root / "missing.js"), self.assertRaisesRegex(ValueError, "Locked Pyright is missing"):
|
||||
guard.run_pyright(root, ".")
|
||||
with mock.patch.object(guard.shutil, "which", return_value=None), self.assertRaisesRegex(ValueError, "Node.js is required"):
|
||||
guard.run_pyright(root, ".")
|
||||
|
||||
def test_zero_analyzed_files_cannot_pass(self) -> None:
|
||||
"""Reject an empty include and a hidden-directory root config in the wrapper."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
scripts = root / ".github" / "scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
(scripts / "probe.py").write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
|
||||
for include in (["nothing/*.py"], [".github/scripts/*.py"]):
|
||||
self.project(root, include)
|
||||
with self.subTest(include=include), self.assertRaises(ValueError):
|
||||
guard.check_pyright_report(guard.run_pyright(root, "."), 1)
|
||||
|
||||
|
||||
class JavaScriptProbeTests(unittest.TestCase):
|
||||
"""Run the real locked ESLint and TypeScript on bounded private input."""
|
||||
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
"""Skip only where tsconfig is absent, because the scope guard forbids JavaScript there."""
|
||||
if not (ROOT / "tsconfig.json").is_file():
|
||||
self.skipTest("No JavaScript tooling in this repository")
|
||||
node = shutil.which("node")
|
||||
self.assertIsNotNone(node)
|
||||
self.node = str(node)
|
||||
|
||||
def eslint(self, text: str) -> subprocess.CompletedProcess[str]:
|
||||
"""Lint text as an in-scope tool file using the repository configuration."""
|
||||
return run([self.node, str(ROOT / "node_modules/eslint/bin/eslint.js"), "--stdin", "--stdin-filename", "tools/probe.mjs", "--max-warnings", "0", "--format", "json"], ROOT, text)
|
||||
|
||||
def test_eslint_rejects_wrong_layout(self) -> None:
|
||||
"""Pass a compliant control and reject K&R braces, two-space indent and a missing semicolon."""
|
||||
good = self.eslint(GOOD_JAVASCRIPT)
|
||||
self.assertEqual(0, good.returncode, good.stdout + good.stderr)
|
||||
bad = self.eslint(BAD_LAYOUT_JAVASCRIPT)
|
||||
self.assertNotEqual(0, bad.returncode)
|
||||
rules = {message["ruleId"] for message in json.loads(bad.stdout)[0]["messages"]}
|
||||
self.assertLessEqual({"@stylistic/brace-style", "@stylistic/semi", "@stylistic/indent"}, rules)
|
||||
|
||||
def test_checked_javascript_rejects_incompatible_assignment(self) -> None:
|
||||
"""Reject a real incompatible assignment under the repository strict checkJs settings."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
(root / "tsconfig.json").write_text(json.dumps({"extends": (ROOT / "tsconfig.json").as_posix(), "compilerOptions": {"typeRoots": [(ROOT / "node_modules/@types").as_posix()]}, "include": ["probe.mjs"]}), encoding="utf-8")
|
||||
tsc = [self.node, str(ROOT / "node_modules/typescript/bin/tsc"), "-p", str(root / "tsconfig.json")]
|
||||
(root / "probe.mjs").write_text(GOOD_TYPED_JAVASCRIPT, encoding="utf-8", newline="\n")
|
||||
good = run(tsc, root)
|
||||
self.assertEqual(0, good.returncode, good.stdout + good.stderr)
|
||||
(root / "probe.mjs").write_text(INCOMPATIBLE_JAVASCRIPT, encoding="utf-8", newline="\n")
|
||||
bad = run(tsc, root)
|
||||
self.assertNotEqual(0, bad.returncode)
|
||||
self.assertIn("TS2322", bad.stdout)
|
||||
|
||||
|
||||
URI_GATES = "jobs:\n pr:\n with:\n allow-dependencies-licenses: pkg:npm/uri-js\n"
|
||||
URI_MIXED_GATES = "jobs:\n pr:\n with:\n allow-dependencies-licenses: >-\n pkg:nuget/Microsoft.WindowsAppSDK,\n pkg:npm/uri-js\n"
|
||||
|
||||
|
||||
def uri_lock(version: str = "4.4.1", integrity: str | None = None, dev: bool = True, direct: bool = False, consumer_dev: bool = True) -> dict[str, Any]:
|
||||
"""Build a minimal package-lock shape with uri-js under an ESLint-like consumer."""
|
||||
root_entry: dict[str, Any] = {"devDependencies": {"uri-js": version} if direct else {"eslint": "10.14.0"}}
|
||||
return {"packages": {"": root_entry, "node_modules/uri-js": {"version": version, "integrity": integrity or guard.URI_JS_INTEGRITY, "dev": dev}, "node_modules/eslint": {"dev": consumer_dev, "dependencies": {"uri-js": "^4.2.2"}}}}
|
||||
|
||||
|
||||
def uri_project(root: Path, lock: dict[str, Any], gates: str = URI_GATES, license_bytes: bytes | None = None) -> None:
|
||||
"""Write a private project with the lock, installed LICENSE and PR caller that the guard inspects."""
|
||||
(root / "package-lock.json").write_text(json.dumps(lock), encoding="utf-8")
|
||||
installed = root / "node_modules" / "uri-js"
|
||||
installed.mkdir(parents=True)
|
||||
installed.joinpath("LICENSE").write_bytes((ROOT / "node_modules" / "uri-js" / "LICENSE").read_bytes() if license_bytes is None else license_bytes)
|
||||
workflows = root / ".github" / "workflows"
|
||||
workflows.mkdir(parents=True)
|
||||
(workflows / "pr-gates.yml").write_text("# EX-0015 pkg:npm/uri-js is cited here\n" + gates, encoding="utf-8")
|
||||
|
||||
|
||||
class UriJsExceptionTests(unittest.TestCase):
|
||||
"""Allow the EX-0015 license exception only for the reviewed, unmodified, development-only uri-js."""
|
||||
|
||||
def test_reviewed_uri_js_with_exact_caller_is_accepted(self) -> None:
|
||||
"""Accept the reviewed package, installed license bytes and an exact caller list, mixed with NuGet entries."""
|
||||
for gates in (URI_GATES, URI_MIXED_GATES):
|
||||
with tempfile.TemporaryDirectory() as name, self.subTest(gates=gates):
|
||||
root = Path(name)
|
||||
uri_project(root, uri_lock(), gates)
|
||||
guard.check_uri_js_exception(root)
|
||||
|
||||
def test_other_version_integrity_scope_or_consumer_is_rejected(self) -> None:
|
||||
"""Reject another version, integrity, non-dev or direct use, a non-dev consumer and a second copy."""
|
||||
duplicate = uri_lock()
|
||||
duplicate["packages"]["node_modules/eslint/node_modules/uri-js"] = dict(duplicate["packages"]["node_modules/uri-js"])
|
||||
for lock in (uri_lock(version="4.4.2"), uri_lock(integrity="sha512-other"), uri_lock(dev=False), uri_lock(direct=True), uri_lock(consumer_dev=False), duplicate):
|
||||
with tempfile.TemporaryDirectory() as name, self.subTest(lock=lock), self.assertRaisesRegex(ValueError, "uri-js"):
|
||||
root = Path(name)
|
||||
uri_project(root, lock)
|
||||
guard.check_uri_js_exception(root)
|
||||
|
||||
def test_modified_installed_license_is_rejected(self) -> None:
|
||||
"""Reject installed license bytes that differ from the reviewed copy."""
|
||||
with tempfile.TemporaryDirectory() as name:
|
||||
root = Path(name)
|
||||
uri_project(root, uri_lock(), license_bytes=(ROOT / "node_modules" / "uri-js" / "LICENSE").read_bytes() + b"\n")
|
||||
with self.assertRaisesRegex(ValueError, "LICENSE bytes differ"):
|
||||
guard.check_uri_js_exception(root)
|
||||
|
||||
def test_caller_must_list_only_uri_js(self) -> None:
|
||||
"""Reject a caller that lists no npm exception, another package, a versioned form or a duplicate."""
|
||||
for gates in ("jobs:\n pr:\n uses: shared\n", URI_GATES.rstrip() + ", pkg:npm/other\n", "jobs:\n pr:\n with:\n allow-dependencies-licenses: pkg:npm/other\n", URI_GATES.rstrip() + "@4.4.1\n", URI_GATES.rstrip() + ", pkg:npm/uri-js\n"):
|
||||
with tempfile.TemporaryDirectory() as name, self.subTest(gates=gates), self.assertRaisesRegex(ValueError, "permits only"):
|
||||
root = Path(name)
|
||||
uri_project(root, uri_lock(), gates)
|
||||
guard.check_uri_js_exception(root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,6 +11,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py"))
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
HEADERS = importlib.util.module_from_spec(SPEC)
|
||||
@@ -31,7 +32,8 @@ limitations under the License."""
|
||||
|
||||
def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]:
|
||||
"""Use the production styles with a small explicit owned fixture scope."""
|
||||
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
|
||||
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"],
|
||||
"notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
|
||||
|
||||
|
||||
class HeaderTests(unittest.TestCase):
|
||||
@@ -49,23 +51,12 @@ class HeaderTests(unittest.TestCase):
|
||||
with self.subTest(name=name):
|
||||
HEADERS.check_notice(name, text, fixture_policy())
|
||||
|
||||
def test_year_range_separator_is_hyphen_or_en_dash_only(self) -> None:
|
||||
"""Accept a hyphen or en dash between years and reject any other separator."""
|
||||
for separator, accepted in (("-", True), (chr(0x2013), True), ("/", False), (chr(0x2014), False)):
|
||||
text = f"# Copyright 2025{separator}2026 James Maes\n# SPDX-License-Identifier: MIT"
|
||||
with self.subTest(separator=separator):
|
||||
if accepted:
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
else:
|
||||
with self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
|
||||
def test_required_first_lines_and_bom(self) -> None:
|
||||
"""Accept shebangs, PowerShell requirements and XML declarations first."""
|
||||
for name, prefix, body in (
|
||||
("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")),
|
||||
("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")),
|
||||
("src/a.xml", '<?xml version="1.0"?>\n', "<!--" + MIT + "--><root/>"),
|
||||
("src/a.xml", "<?xml version=\"1.0\"?>\n", "<!--" + MIT + "--><root/>"),
|
||||
("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
@@ -76,20 +67,21 @@ class HeaderTests(unittest.TestCase):
|
||||
for name, text in (
|
||||
("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"),
|
||||
("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"),
|
||||
("src/a.xml", "<!--" + MIT + '--><?xml version="1.0"?><root/>'),
|
||||
("src/a.xml", "<!--" + MIT + "--><?xml version=\"1.0\"?><root/>"),
|
||||
):
|
||||
with self.subTest(name=name), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice(name, text, fixture_policy())
|
||||
|
||||
def test_code_strings_and_late_comments_do_not_count(self) -> None:
|
||||
"""Reject docstrings and comments after executable declarations."""
|
||||
for text in ('notice = "' + MIT.replace("\n", "\\n") + '"', '"""' + MIT + '"""', "import os\n# " + MIT.replace("\n", "\n# ")):
|
||||
for text in ("notice = \"" + MIT.replace("\n", "\\n") + "\"",
|
||||
"\"\"\"" + MIT + "\"\"\"", "import os\n# " + MIT.replace("\n", "\n# ")):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.py", text, fixture_policy())
|
||||
|
||||
def test_wrong_syntax_and_unterminated_comments_fail(self) -> None:
|
||||
"""Notice text alone is insufficient without a complete language comment."""
|
||||
for text in ("# " + MIT, "/*" + MIT, 'string text = "' + MIT + '";'):
|
||||
for text in ("# " + MIT, "/*" + MIT, "string text = \"" + MIT + "\";"):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", text, fixture_policy())
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -97,7 +89,8 @@ class HeaderTests(unittest.TestCase):
|
||||
|
||||
def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None:
|
||||
"""Do not conflate repository licenses or accept conflicting identifiers."""
|
||||
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
|
||||
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"),
|
||||
MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy())
|
||||
|
||||
@@ -110,7 +103,8 @@ class HeaderTests(unittest.TestCase):
|
||||
def test_java_requires_full_apache_block(self) -> None:
|
||||
"""An abbreviated SPDX-only Java notice cannot replace scoped convention."""
|
||||
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
|
||||
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", APACHE.replace("limitations under the License.", "")):
|
||||
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0",
|
||||
APACHE.replace("limitations under the License.", "")):
|
||||
with self.subTest(notice=notice), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy)
|
||||
|
||||
@@ -127,7 +121,9 @@ class HeaderTests(unittest.TestCase):
|
||||
"""New scripts enter the guard; data, generated and upstream files do not."""
|
||||
self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY))
|
||||
self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY))
|
||||
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
|
||||
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json",
|
||||
"src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml",
|
||||
"src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
|
||||
with self.subTest(name=name):
|
||||
self.assertFalse(HEADERS.selected(name, POLICY))
|
||||
self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"]))
|
||||
@@ -144,11 +140,11 @@ class HeaderTests(unittest.TestCase):
|
||||
"""A tracked new source fails while untracked generated output is ignored."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory.
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
||||
root.joinpath("src").mkdir()
|
||||
source = root / "src/a.py"
|
||||
source.write_text("print(1)", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command.
|
||||
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True)
|
||||
with self.assertRaisesRegex(ValueError, "src/a.py"):
|
||||
HEADERS.check_repository(root, fixture_policy())
|
||||
source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8")
|
||||
|
||||
+11
-21
@@ -5,17 +5,17 @@ name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
product-version:
|
||||
type: string
|
||||
default: '1.2.1'
|
||||
default: '0.3.2'
|
||||
release-version:
|
||||
type: string
|
||||
default: '1.2.1'
|
||||
default: '0.3.2-rc.1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: QRun-IO/qqq-print-protocol
|
||||
ref: ac835f8b9396b641b525669249fdb4a1e2a50c99
|
||||
ref: f3cbc02e9b0fb4fdfefaa59d976c04df747cc589
|
||||
path: qqq-print-protocol
|
||||
persist-credentials: false
|
||||
- name: Set up .NET
|
||||
@@ -59,29 +59,19 @@ jobs:
|
||||
./build/Test-PackagingInputs.ps1
|
||||
- name: Build unsigned prerelease MSI
|
||||
env:
|
||||
PRODUCT_VERSION: ${{ inputs.product-version || '1.2.1' }}
|
||||
RELEASE_VERSION: ${{ inputs.release-version || '1.2.1' }}
|
||||
PRODUCT_VERSION: ${{ inputs.product-version || '0.3.2' }}
|
||||
RELEASE_VERSION: ${{ inputs.release-version || '0.3.2-rc.1' }}
|
||||
run: |
|
||||
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
# One fully branded installer per client edition (editions/<id>), named after its asset prefix.
|
||||
$installers = @('QqqPrintAgent')
|
||||
foreach ($folder in Get-ChildItem -LiteralPath editions -Directory | Sort-Object Name) {
|
||||
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -Edition $folder.Name
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
$installers += (Get-Content -LiteralPath (Join-Path $folder.FullName 'edition.json') -Raw | ConvertFrom-Json).assetPrefix
|
||||
}
|
||||
New-Item -ItemType Directory -Path artifacts/release -Force | Out-Null
|
||||
Remove-Item -LiteralPath artifacts/release/SHA256SUMS -Force -ErrorAction SilentlyContinue
|
||||
foreach ($prefix in $installers) {
|
||||
$source = "artifacts/installer/$prefix-$env:PRODUCT_VERSION-x64.msi"
|
||||
$name = "$prefix-v$env:RELEASE_VERSION-x64.msi"
|
||||
Copy-Item -LiteralPath $source -Destination "artifacts/release/$name"
|
||||
$hash = (Get-FileHash -LiteralPath "artifacts/release/$name" -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash $name" | Add-Content artifacts/release/SHA256SUMS -Encoding ascii
|
||||
}
|
||||
$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"
|
||||
Copy-Item -LiteralPath build/Install-Branded.ps1 -Destination artifacts/release/Install-Branded.ps1
|
||||
Copy-Item -LiteralPath docs/branding.example.json -Destination artifacts/release/branding.example.json
|
||||
$hash = (Get-FileHash -LiteralPath "artifacts/release/$name" -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash $name" | Set-Content artifacts/release/SHA256SUMS -Encoding ascii
|
||||
foreach ($file in @('Install-Branded.ps1', 'branding.example.json')) {
|
||||
$hash = (Get-FileHash -LiteralPath "artifacts/release/$file" -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash $file" | Add-Content artifacts/release/SHA256SUMS -Encoding ascii
|
||||
|
||||
@@ -116,9 +116,6 @@ jobs:
|
||||
# https://github.com/KofTwentyTwo/standards/blob/fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152/exceptions/register.md#ex-0014
|
||||
# Package-name license exceptions do not constrain versions; the required
|
||||
# SDK guard pins versions, content/archive hashes, terms and this finite list.
|
||||
# EX-0015: development-only uri-js 4.4.1 (ESLint transitive). check-script-quality.py pins its version,
|
||||
# lock integrity and installed license bytes and rejects any other pkg:npm entry in this list.
|
||||
# https://git.kof22.com/KofTwentyTwo/standards/src/commit/a18179e90f8e6d35ba9f5e583249885c066e52b8/exceptions/register.md#ex-0015
|
||||
allow-dependencies-licenses: >-
|
||||
pkg:nuget/Microsoft.Windows.AI.MachineLearning,
|
||||
pkg:nuget/Microsoft.Windows.SDK.BuildTools,
|
||||
@@ -134,8 +131,7 @@ jobs:
|
||||
pkg:nuget/Microsoft.WindowsAppSDK.Runtime,
|
||||
pkg:nuget/Microsoft.WindowsAppSDK.Search,
|
||||
pkg:nuget/Microsoft.WindowsAppSDK.Widgets,
|
||||
pkg:nuget/Microsoft.WindowsAppSDK.WinUI,
|
||||
pkg:npm/uri-js
|
||||
pkg:nuget/Microsoft.WindowsAppSDK.WinUI
|
||||
permissions:
|
||||
contents: read # read the commits being merged
|
||||
pull-requests: read # read the pull request title and dependency diff
|
||||
@@ -5,7 +5,7 @@ name: prerelease
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*-rc.*', 'v[0-9]+.[0-9]+.[0-9]+']
|
||||
tags: ['v*-rc.*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -21,38 +21,20 @@ jobs:
|
||||
outputs:
|
||||
product: ${{ steps.validate.outputs.product }}
|
||||
release: ${{ steps.validate.outputs.release }}
|
||||
stable: ${{ steps.validate.outputs.stable }}
|
||||
steps:
|
||||
- name: Validate release tag
|
||||
- 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 prerelease or vX.Y.Z stable tag.'
|
||||
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"
|
||||
# A stable tag has no prerelease suffix and must be signed; a prerelease stays unsigned.
|
||||
if [[ -z "${BASH_REMATCH[2]}" ]]; then echo "stable=true" >> "$GITHUB_OUTPUT"; else echo "stable=false" >> "$GITHUB_OUTPUT"; fi
|
||||
# A stable tag must be reachable from main, like MarkdownViewr.Windows. Every gate and the signing
|
||||
# job depend on this job, so an off-main stable tag is rejected before anything is built or signed.
|
||||
# Release-candidate tags keep their existing path and are not checked here.
|
||||
- name: Checkout main and tag history
|
||||
if: steps.validate.outputs.stable == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Require the stable tag to be on main
|
||||
if: steps.validate.outputs.stable == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||
verify:
|
||||
needs: version
|
||||
uses: ./.github/workflows/ci.yml
|
||||
@@ -78,212 +60,8 @@ jobs:
|
||||
uses: ./.github/workflows/scripts.yml
|
||||
permissions:
|
||||
contents: read
|
||||
# Stable tags only. Runs after every gate above, in the protected `release` environment (tags v* only),
|
||||
# and signs the owned binaries before the MSI is built and then the MSI itself, using the same Azure
|
||||
# Artifact Signing account and profile as MarkdownViewr.Windows. Pull requests never reach this job.
|
||||
# Order: verify configuration, locked restore and packaging checks, publish payload, stage owned files,
|
||||
# sign, accept only the exact publisher, build MSI from the signed payload, sign and verify the MSI,
|
||||
# then assemble the four release files with checksums computed after signing.
|
||||
# Lists the client editions (editions/<id>) so the signing job runs once for the generic build and once per edition.
|
||||
editions:
|
||||
needs: version
|
||||
if: needs.version.outputs.stable == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
list: ${{ steps.list.outputs.list }}
|
||||
steps:
|
||||
- name: Checkout tagged source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: List editions
|
||||
id: list
|
||||
shell: bash
|
||||
run: |
|
||||
list=$(find editions -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort | jq -R . | jq -sc '[""] + .')
|
||||
echo "list=$list" >> "$GITHUB_OUTPUT"
|
||||
echo "Editions: $list"
|
||||
sign:
|
||||
if: needs.version.outputs.stable == 'true'
|
||||
needs: [version, verify, security, codeql, scripts, editions]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
edition: ${{ fromJson(needs.editions.outputs.list) }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Azure OIDC for the signing identity; no secret or exported key is stored
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
env:
|
||||
PRODUCT_VERSION: ${{ needs.version.outputs.product }}
|
||||
RELEASE_VERSION: ${{ needs.version.outputs.release }}
|
||||
EDITION: ${{ matrix.edition }}
|
||||
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
|
||||
ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }}
|
||||
ARTIFACT_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT }}
|
||||
ARTIFACT_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE }}
|
||||
ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT }}
|
||||
steps:
|
||||
- name: Checkout tagged source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Require verified signing configuration
|
||||
run: ./build/Assert-ReleaseSigning.ps1
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
global-json-file: global.json
|
||||
- name: Locked restore and packaging checks
|
||||
run: |
|
||||
dotnet restore Qqq.PrintAgent.NativeCI.slnx '-p:Platform=x64' --locked-mode
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
./build/Test-WindowsSdkInventory.ps1 -VerifyPackages
|
||||
./build/Test-BrandedInstaller.ps1
|
||||
./build/Test-PackagingInputs.ps1
|
||||
- name: Resolve the edition
|
||||
run: |
|
||||
$args = @()
|
||||
$prefix = 'QqqPrintAgent'
|
||||
if ($env:EDITION) {
|
||||
$args = @('-Edition', $env:EDITION)
|
||||
$prefix = (Get-Content -LiteralPath "editions/$env:EDITION/edition.json" -Raw | ConvertFrom-Json).assetPrefix
|
||||
}
|
||||
if ($prefix -cnotmatch '^[A-Za-z][A-Za-z0-9]{1,39}$') { throw 'Invalid edition asset prefix.' }
|
||||
"EDITION_ARGS=$($args -join ' ')" | Add-Content -LiteralPath $env:GITHUB_ENV
|
||||
"MSI_PATH=artifacts/installer/$prefix-$env:PRODUCT_VERSION-x64.msi" | Add-Content -LiteralPath $env:GITHUB_ENV
|
||||
"ARTIFACT_NAME=windows-x64-installer-signed-$(if ($env:EDITION) { $env:EDITION } else { 'generic' })" | Add-Content -LiteralPath $env:GITHUB_ENV
|
||||
Write-Output "Edition: '$env:EDITION' -> $prefix"
|
||||
- name: Publish and check the payload
|
||||
run: |
|
||||
# Hashtable splat: an array splat binds "-Edition gg-forge" positionally (to CertificateThumbprint/TimestampServer) and trips the PublishOnly guard.
|
||||
$editionArgs = @{}
|
||||
if ($env:EDITION) { $editionArgs['Edition'] = $env:EDITION }
|
||||
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -PublishOnly -PayloadDirectory "$env:RUNNER_TEMP/payload" @editionArgs
|
||||
- name: Stage owned binaries
|
||||
run: ./build/Sign-Release.ps1 -Operation Stage -Kind Payload -Root "$env:RUNNER_TEMP/payload" -Stage "$env:RUNNER_TEMP/sign-payload"
|
||||
# The pinned signing action's own installer runs `dotnet tool install --add-source`, which the repository's
|
||||
# package source mapping (nuget.config) rejects. Install only the SignCLI version it expects, through that same
|
||||
# mapped config, into the exact folder layout the action looks for; it then finds the tool and skips its install.
|
||||
- name: Install the pinned SignCLI through the repository NuGet configuration
|
||||
run: |
|
||||
$version = '0.9.1-beta.26227.3'
|
||||
$versions = Join-Path $env:LOCALAPPDATA 'ArtifactSigning\sign'
|
||||
$install = Join-Path $versions "sign.$version"
|
||||
$dll = Join-Path $install 'tools\net8.0\any\sign.dll'
|
||||
if (Test-Path -LiteralPath $dll -PathType Leaf) { Write-Output "SignCLI $version is already installed."; return }
|
||||
# Install into a unique temporary folder, then copy the package contents into the expected folder. Nothing
|
||||
# already under ArtifactSigning (other versions, shims) is deleted or moved.
|
||||
$temp = Join-Path $env:RUNNER_TEMP "signcli-$([guid]::NewGuid().ToString('N'))"
|
||||
dotnet tool install sign --version $version --tool-path $temp --configfile (Join-Path $env:GITHUB_WORKSPACE 'nuget.config')
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet tool install sign $version failed." }
|
||||
$store = Join-Path $temp ".store\sign\$version\sign\$version"
|
||||
if (-not (Test-Path -LiteralPath $store -PathType Container)) { throw "SignCLI $version package content was not found." }
|
||||
New-Item -ItemType Directory -Path $install -Force | Out-Null
|
||||
Copy-Item -Path (Join-Path $store '*') -Destination $install -Recurse -Force
|
||||
if (-not (Test-Path -LiteralPath $dll -PathType Leaf)) { throw "SignCLI $version is missing tools\net8.0\any\sign.dll." }
|
||||
- name: Azure login (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Sign owned binaries
|
||||
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
|
||||
with:
|
||||
endpoint: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ vars.ARTIFACT_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ vars.ARTIFACT_SIGNING_PROFILE }}
|
||||
files-folder: ${{ runner.temp }}/sign-payload
|
||||
files-folder-filter: exe,dll
|
||||
files-folder-recurse: true
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
- name: Accept signed binaries for the exact publisher
|
||||
run: ./build/Sign-Release.ps1 -Operation Apply -Kind Payload -Root "$env:RUNNER_TEMP/payload" -Stage "$env:RUNNER_TEMP/sign-payload" -ExpectedSubject $env:ARTIFACT_SIGNING_SUBJECT
|
||||
- name: Build MSI from the signed payload
|
||||
run: |
|
||||
# Hashtable splat: an array splat binds "-Edition gg-forge" positionally (to CertificateThumbprint/TimestampServer) and trips the PublishOnly guard.
|
||||
$editionArgs = @{}
|
||||
if ($env:EDITION) { $editionArgs['Edition'] = $env:EDITION }
|
||||
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -SkipPublish -PayloadDirectory "$env:RUNNER_TEMP/payload" @editionArgs
|
||||
- name: Stage MSI
|
||||
run: ./build/Sign-Release.ps1 -Operation Stage -Kind Msi -Root $env:MSI_PATH -Stage "$env:RUNNER_TEMP/sign-msi"
|
||||
- name: Sign MSI
|
||||
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
|
||||
with:
|
||||
endpoint: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ vars.ARTIFACT_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ vars.ARTIFACT_SIGNING_PROFILE }}
|
||||
files-folder: ${{ runner.temp }}/sign-msi
|
||||
files-folder-filter: msi
|
||||
files-folder-recurse: false
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
- name: Accept signed MSI for the exact publisher
|
||||
run: ./build/Sign-Release.ps1 -Operation Apply -Kind Msi -Root $env:MSI_PATH -Stage "$env:RUNNER_TEMP/sign-msi" -ExpectedSubject $env:ARTIFACT_SIGNING_SUBJECT
|
||||
- name: Upload the signed installer of this edition
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: ${{ env.MSI_PATH }}
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
# Gathers the signed generic and edition installers, verifies each signature again, and writes the release
|
||||
# files with one SHA256SUMS computed after signing. Nothing is built or signed here.
|
||||
assemble:
|
||||
if: needs.version.outputs.stable == 'true'
|
||||
needs: [version, sign]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- name: Checkout tagged source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download the signed installers
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: windows-x64-installer-signed-*
|
||||
path: artifacts/installer
|
||||
merge-multiple: true
|
||||
- name: Assemble signed release files and checksums
|
||||
env:
|
||||
PRODUCT_VERSION: ${{ needs.version.outputs.product }}
|
||||
RELEASE_VERSION: ${{ needs.version.outputs.release }}
|
||||
ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT }}
|
||||
run: ./build/Package-Release.ps1 -AssembleOnly -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -ExpectedPublisherSubject $env:ARTIFACT_SIGNING_SUBJECT
|
||||
- name: Upload signed installers
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: windows-x64-installer-signed
|
||||
path: artifacts/release/*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
publish:
|
||||
needs: [version, verify, security, codeql, scripts, sign, assemble]
|
||||
# Every gate must pass; the signing and assembly jobs may be skipped only for an unsigned prerelease tag.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.version.result == 'success' && needs.verify.result == 'success'
|
||||
&& needs.security.result == 'success' && needs.codeql.result == 'success' && needs.scripts.result == 'success'
|
||||
&& (needs.version.outputs.stable != 'true' && needs.sign.result == 'skipped' && needs.assemble.result == 'skipped' || needs.version.outputs.stable == 'true' && needs.sign.result == 'success' && needs.assemble.result == 'success') }}
|
||||
needs: [version, verify, security, codeql, scripts]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
@@ -294,7 +72,7 @@ jobs:
|
||||
- name: Download verified installer
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: ${{ needs.version.outputs.stable == 'true' && 'windows-x64-installer-signed' || 'windows-x64-installer' }}
|
||||
name: windows-x64-installer
|
||||
path: release
|
||||
- name: Verify packaged checksums
|
||||
working-directory: release
|
||||
@@ -303,26 +81,7 @@ jobs:
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: release/*
|
||||
- name: Publish signed stable release
|
||||
if: needs.version.outputs.stable == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
cat > release-notes.md <<'NOTES'
|
||||
Windows x64 release of QQQ Print Agent for QQQ 4.1+. The MSI and the first-party service, companion and command-line binaries are Authenticode-signed with SHA256 and an RFC3161 timestamp by the project's Azure Artifact Signing publisher identity.
|
||||
|
||||
Verify the MSI against SHA256SUMS and its GitHub provenance attestation before installing; every asset was built and tested by this workflow after all security, CodeQL and script gates passed, and the checksums were generated after signing.
|
||||
|
||||
This client requires QQQ's semantic printing extension >=2.0.0 <3.0.0 and standard QQQ sign-in; selected-media jobs need printing 2.3+. Upgrade the server first. Existing pairings and history are preserved.
|
||||
|
||||
See CHANGELOG.md, docs/INSTALLATION.md and docs/ACCEPTANCE.md in the tagged source for scope, evidence and limitations. Spool acceptance alone does not establish physical output.
|
||||
NOTES
|
||||
gh release create "$RELEASE_TAG" release/* --verify-tag --latest --title "QQQ Print Agent $RELEASE_TAG" --notes-file release-notes.md
|
||||
- name: Publish unsigned prerelease
|
||||
if: needs.version.outputs.stable != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -336,8 +95,6 @@ jobs:
|
||||
|
||||
This client requires QQQ's semantic printing extension >=2.0.0 <3.0.0 and standard QQQ sign-in. When upgrading from 0.2, upgrade the server first: native API 2.0 makes a synchronized authenticated WebSocket mandatory for claims. An existing 0.3 server remains compatible with this client follow-up. Existing pairings and history are preserved; unbound devices require explicit account association.
|
||||
|
||||
New in 0.4.0: optional printer-media selection and 4 x 2 / 4 x 1 test-label choices. Printer profiles describe configured defaults, not physical stock sensing; jobs refuse a selected profile whose revision has drifted. Each job writes a phase-timing log line. Ordered numeric executable versions allow normal upgrades between release candidates. Upgrade the server first for selected-media jobs. The local rc.3 build of the same printing runtime passed installation and the operator's 4 x 1 test; Letter and 4 x 6 output were previously accepted. Physical 4 x 2 testing is deferred. This remains an unsigned prerelease, with no GA or stable 1.0 claim.
|
||||
|
||||
New in 0.3.2: a successful MSI result is accepted only when app, service and CLI have the requested release and identical full build versions. Missing, unreadable or mixed builds report an inconsistent installation without claiming rollback. The SDK credit references Microsoft's separate terms and installed notices. The native test host now matches production's PerMonitorV2 DPI declaration while retaining unelevated execution.
|
||||
|
||||
New in 0.3.1: the footer shows explicit WebSocket state, a server-settings link, live QQQ service upload/download payload rates and totals since service start. Lightweight local observations add no QQQ job/control polling. Windows-normalized startup account names are accepted only when they resolve to the exact expected SID; this preference controls the tray companion, independently of the automatic print service.
|
||||
@@ -348,7 +105,7 @@ jobs:
|
||||
|
||||
Existing features include standard QQQ sign-in and protected service sessions, PDF/ZPL printing and warehouse diagnostic profiles, exact owned spool cancellation, synchronized device/printer profiles, configurable logs, customer branding, AppKit settings/logs/update prompts, machine-wide MSI updates, tray status and explicit printer exposure. Replacement pairing and recovery from damaged saved credentials preserve history and printer choices without silently enabling printing.
|
||||
|
||||
The published 0.3.2 MSI was reached by a real in-app update from 0.3.1 with pairing, printer profiles and local receipts preserved, and its service restored the native QQQ session and WebSocket. The operator confirmed successful test prints on 0.3.1. Configured provider/browser callbacks also need end-to-end qualification against a compatible native-session API. See docs/ACCEPTANCE.md for version-specific evidence and remaining limits. Spool acceptance alone does not establish physical output or scanner readability; no stable 1.0 readiness is claimed.
|
||||
The published 0.3.1 MSI was installed with pairing, printer profiles, local receipts and log settings preserved; its service automatically restored the native QQQ session and WebSocket. The operator confirmed successful test prints and a responsive window. This 0.3.2 candidate still needs actual in-app update acceptance. Configured provider/browser callbacks also need end-to-end qualification against a compatible native-session API. See docs/ACCEPTANCE.md for version-specific evidence and remaining limits. Spool acceptance alone does not establish physical output or scanner readability; no stable 1.0 readiness is claimed.
|
||||
|
||||
See CHANGELOG.md, docs/INSTALLATION.md and docs/ACCEPTANCE.md in the tagged source for scope and limitations.
|
||||
NOTES
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
virtual-agent:
|
||||
name: virtual-agent
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -29,21 +29,6 @@ jobs:
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
- name: Restore locked JavaScript tools
|
||||
run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: '0.13.6'
|
||||
enable-cache: false
|
||||
- name: Restore locked Python tools
|
||||
run: uv sync --locked
|
||||
- name: Verify script quality gates and rejection paths
|
||||
run: |
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
- name: Verify owned source notices and rejection paths
|
||||
run: |
|
||||
python3 .github/scripts/test-source-headers.py
|
||||
|
||||
@@ -495,8 +495,3 @@ worker.lock
|
||||
# This vendor legal-file directory is not a generated .msix package.
|
||||
!licenses/Microsoft.Windows.SDK.BuildTools.MSIX/
|
||||
|
||||
|
||||
# Local script-quality environments and tool caches.
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
@@ -1 +0,0 @@
|
||||
3.13
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
This is QRun-IO/qqq-print-agent, built on KofTwentyTwo AppKit's Windows service variant. Follow the current user request and repository .editorconfig/central package versions.
|
||||
|
||||
- Gitea is primary for ordinary branches and pull requests. GitHub is the source shadow, with the authorized existing release/signing and CI/security workflows retained; the installed updater requires GitHub release assets. Do not remove that bridge as part of a source-host migration.
|
||||
- Verify with `./build/Verify.ps1`; package with `./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1`.
|
||||
- Verify with `./build/Verify.ps1`; package with `./build/Build-Installer.ps1 -ProductVersion 0.3.2 -ReleaseVersion 0.3.2-rc.1`.
|
||||
- Core owns protocol/state/policy; Windows owns OS adapters/storage/runtime; Service owns hosting; UI owns native views/tray plumbing.
|
||||
- Zero compiler/analyzer warnings; locked dependencies. Coverage is reported for the initial prerelease under docs/adr/0002-prerelease-quality-and-packaging.md.
|
||||
- Keep the service alive independently of the companion. Use AppKit update UX with the machine MSI adapter; update service, companion and CLI together. Do not install a per-user-only replacement over the machine package.
|
||||
|
||||
+1
-151
@@ -1,156 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 1.2.1
|
||||
|
||||
Release-pipeline fix only; same source as 1.2.0 otherwise. The stable release workflow's per-edition
|
||||
sign job passed the edition to `Build-Installer.ps1` through an array splat, which PowerShell binds
|
||||
positionally, so `-Edition gg-forge` landed in the signing-certificate parameters and the GG Forge
|
||||
sign job refused to publish; tag `v1.2.0` therefore produced no signed installer and must not be
|
||||
moved or reused. The workflow now splats a hashtable. No application or wire change.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
Client editions. A folder under `editions/<id>` (descriptor, SVG mark, ICO icon, optional logo) and
|
||||
`Build-Installer.ps1 -Edition <id>` produce a fully branded build and installer: the client's mark and
|
||||
icon on the splash, About, window, taskbar and Start menu; the client's product, company and file
|
||||
description in every executable; the client's product name, manufacturer and service display name in
|
||||
the MSI, forced over any stored labels; the MSI and release asset named after the edition's asset
|
||||
prefix; and the installed updater following that asset on the shared feed. CI builds every edition,
|
||||
and the stable release workflow signs and publishes one installer per edition beside the generic one
|
||||
with a single checksum list. The first edition is `gg-forge` (GG Forge Print Agent by Greater Goods).
|
||||
Deployment branding through `Install-Branded.ps1` is unchanged and still applies on top of an edition.
|
||||
Native browser sign-in sends the attempt's nonce as `expectedNonce` when the grant policy is REQUIRED or
|
||||
OPTIONAL or when discovery advertises `nativeSessionNonceValidation`; legacy servers keep the unchanged
|
||||
exchange body. `build/Build-CiRelease.ps1` is the shared-CI packaging entrypoint and delegates to
|
||||
`Package-Release.ps1`.
|
||||
|
||||
Printer approval per server. On a printing 2.4 server the client advertises `printer-approval-v1` and
|
||||
takes approval from the server's `PRINTERS` frame, pushed after each accepted inventory and whenever an
|
||||
administrator approves or disables one of this device's printers (protocol 1.1.0, QBit 1.1.0). On a 2.3
|
||||
server the service instead reads the operator printer catalog after each accepted inventory and about once
|
||||
a minute while connected. Either way the snapshot carries a per-printer server state:
|
||||
approved, awaiting approval, not reported (with the reason) or not checked. The Printers page shows it in a
|
||||
new Server column, in the summary line and in the expanded details, named for the connected server, so a
|
||||
printer shared before a server change is never mistaken for one the new server approved. The reading is
|
||||
reset with the connection. The activity log viewer has a Clear logs button, the connection dialog keeps its
|
||||
result banner above the form and shows a waiting state during browser sign-in, and the service's sign-in reply
|
||||
no longer repeats itself. No wire change.
|
||||
|
||||
## 1.1.1
|
||||
|
||||
Browser sign-in fix. The companion runs elevated, and its callback inbox pipe was keyed with
|
||||
PipeOptions.CurrentUserOnly to the elevated token's default owner (the Administrators group), so
|
||||
the protocol handler the browser launches as the ordinary user was refused with "Access to the
|
||||
path is denied" and no browser sign-in could ever complete. The inbox is now created with an
|
||||
explicit descriptor owned by and restricted to the user's own SID, and the handler verifies the
|
||||
companion by process image instead of the owner comparison. A protocol-handler trace in the
|
||||
per-user profile, copied into the activity log when a browser sign-in does not complete, makes
|
||||
the hand-off visible. No wire change.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
Diagnostics for new accounts and servers. The session client now narrates every step of discovery,
|
||||
authentication metadata, native grant validation, provider discovery, browser callback handling,
|
||||
password sign-in, session restore and renewal, identity checks, device binding and the realtime
|
||||
socket upgrade, and says exactly which server-side requirement is missing when it refuses a server;
|
||||
the service logs each sign-in, sign-out, enrollment, authorization and reconnect step and every
|
||||
failure with its inner causes; the companion logs its side and shows the failure reason in the
|
||||
dialog instead of a generic message. TLS certificate rejections are logged with the policy error,
|
||||
certificate identity and remedy. A new **Allow untrusted server certificate for this server** option (Connection
|
||||
settings, bound to one server origin, persisted in the protected store, honored by the service and
|
||||
companion, logged with a warning on every session) supports development servers with self-signed or
|
||||
private-authority certificates without weakening any other server connection.
|
||||
Log settings gain **Debug logging** (request routes, status codes, timings, realtime frames and
|
||||
full failure detail; never credentials) and replace the daily cap that silenced the log with size
|
||||
rollover: a file that reaches the limit (default 100 MiB, up to 1024) becomes a numbered segment and
|
||||
logging continues, keeping at most a configurable number of segments per day (default 10). No wire
|
||||
change; native printing stays 2.3 with wire payload v1.
|
||||
|
||||
## 1.0.1
|
||||
|
||||
Packaging fix; no application, wire or dependency change. The `v1.0.0` release run passed
|
||||
every build, test, security, CodeQL and script gate and the Azure OIDC login, then stopped
|
||||
before signing: the Artifact Signing action installs its SignCLI tool with
|
||||
`dotnet tool install --add-source`, which the repository's NuGet package source mapping
|
||||
rejects. No signed 1.0.0 installer was published and that tag is unchanged. The release
|
||||
workflow now installs only the pinned SignCLI (`0.9.1-beta.26227.3`) through the mapped
|
||||
repository `nuget.config` into the exact folder the action expects, before the first signing
|
||||
step, so the action finds it and the mapping is not weakened. Native printing stays 2.3 with
|
||||
wire payload v1.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Product version 1.0.0 for the same cohort as 0.4.0-rc.4. A stable `v1.0.0` tag runs the
|
||||
release workflow: after every existing gate it signs the service, companion and CLI binaries
|
||||
and then the MSI with Azure Artifact Signing, verifies the exact publisher, and publishes a
|
||||
non-prerelease with checksums and provenance generated after signing (see
|
||||
docs/RELEASE-SIGNING.md). The numeric file version is `1.0.0.65535`, above every earlier
|
||||
candidate, so the updater offers it over an installed 0.4.0-rc.4. Native printing stays 2.3
|
||||
with wire payload v1; 1.0 is not a wire change. No code or dependency change comes with the
|
||||
version. Physical acceptance covers 4 × 6, Letter and 4 × 1; 4 × 2 is deferred.
|
||||
|
||||
## 0.4.0-rc.4 — unsigned prerelease candidate
|
||||
|
||||
Release identity only: rc.4 is strictly newer than the local, unpublished 0.4.0-rc.3
|
||||
build and ships the same cohort, so the updater accepts it over an installed rc.3. The
|
||||
MSI ProductVersion and `VersionPrefix` stay 0.4.0; `VersionSuffix` and the packaging
|
||||
defaults move to `rc.4`, and the ordered numeric file version becomes `0.4.0.4`. There is
|
||||
no code, test or dependency change. Everything described under rc.3 below (media
|
||||
selection, 4 × 2 and 4 × 1 test labels, phase-timing log, ordered file version) applies.
|
||||
The rc.4 head is version metadata and docs on top of pre-release product source
|
||||
`5c2bfc9`, whose full hosted CI run 41381287355 passed 499 tests (166 Core + 333 CLI) with
|
||||
zero warnings and errors. rc.4 itself is not yet tested or published; its tag pipeline
|
||||
must pass all gates afresh before publication. Earlier operator acceptance covers 4 × 6
|
||||
and Letter output, and the 4 × 1 layout was confirmed on the local rc.3 build of the same
|
||||
product code; 4 × 2 is deferred. The MSI is unsigned and no GA or 1.0 readiness is
|
||||
claimed.
|
||||
|
||||
## 0.4.0-rc.3 — local candidate (superseded by rc.4)
|
||||
|
||||
Release identity: this is a strictly newer semantic version than 0.4.0-rc.1 and
|
||||
0.4.0-rc.2 with the same numeric core, so the MSI ProductVersion (0.4.0) and
|
||||
`VersionPrefix` are unchanged, `VersionSuffix` and the packaging defaults move to `rc.3`,
|
||||
and the updater (which needs a strictly newer version) and the installer's same-version
|
||||
upgrade support both accept it. The rc.2 MSI was installed, but Windows Installer skipped
|
||||
its executables because rc.1 and rc.2 shared the numeric file version 0.4.0.0 ("existing
|
||||
file is of an equal version"). Packaging now stamps each executable with an ordered
|
||||
numeric file version: `X.Y.Z-rc.N` becomes `X.Y.Z.N` (N 1..65534) and stable `X.Y.Z`
|
||||
becomes `X.Y.Z.65535`, and any other prerelease label is refused. Future upgrades replace
|
||||
files automatically. No behavior, dependency or lock change otherwise. The protocol
|
||||
package `0.4.0rc1` is an independent wire-compatibility version and is unchanged. Older
|
||||
notes below that name 0.4.0-rc.1 or rc.2 describe the same cohort.
|
||||
|
||||
This cohort goes beyond the older installed CI 0.4.0-rc.1 build. The rc.3 candidate
|
||||
was built and installed locally with a normal MSI upgrade (exit 0), matching all three
|
||||
components; no repair was needed. It is not publicly released or tagged. Hosted Windows
|
||||
CI and the update-feed handoff await the migration owner.
|
||||
Upgrade the server first: selected-media jobs require
|
||||
printing extension 2.3.0 (wire payload v1 unchanged). Against a 2.0, 2.1 or 2.2
|
||||
(rendering) server the client keeps its three-capability HELLO and offers no media
|
||||
profiles, so existing behavior is unchanged.
|
||||
|
||||
- Add 4 × 1 and 4 × 2 in test labels (ZPL at 203/300/600 dpi, one PDF choice each) to the existing Print test choices; the 4 × 6 choices and defaults are unchanged. These PDFs carry explicit actual-size job paper (101.6 × 25.4 / 50.8 mm) applied to a job-local copy of the queue defaults (so its Direct Thermal and other settings are kept) and checked against the device context before StartDoc. Needs the matching unreleased 0.4 server; older clients reject job paper. Load the correct stock first; nothing prints until the operator confirms.
|
||||
- Report each queue's machine-default paper dimensions and resolution as an opaque, revision-bound media profile, and advertise `printer-media-v1` only when the discovered server supports it. Defaults are configured facts, not physical stock sensing.
|
||||
- Refuse a selected-media job before spooling when the profile is unknown or its revision has drifted, and again at the native submission boundary. A proven pre-submit refusal is recorded as FAILED and is never treated as an uncertain outcome.
|
||||
- Normalize job-only driver copies and scale for selected PDFs and verify the effective device-context dimensions and resolution before StartDoc. ZPL (RAW) only rechecks the profile immediately before StartDocPrinter.
|
||||
- Log one phase-timing line per processed job (inventory, claim and receipt, download, revalidation, submit gate, native submit) with the job identifier only, to attribute print latency from the service log. No new request, payload or printer access.
|
||||
- Pin the printing protocol to the final reviewed contract, commit `42c0d249f81395f00408ec0f92638dcbbf5deb03` ([Gitea PR 2](https://git.kof22.com/QRun-IO/qqq-print-protocol/pulls/2)), in `.github/workflows/ci.yml`; native printing stays 2.3 with wire payload v1. The earlier pin `a26c18c9…` is historical.
|
||||
- Split `PrintWorker` job processing into named submission, revalidation, dispatch-gate and native-submit steps (analyzer MA0051 rejected the first form of the timing instrumentation). Behavior, state order and lease release are unchanged.
|
||||
- Document every type, method, constructor and non-auto property with how/why headers and apply the `.editorconfig` structural spacing. Each batch was token-compared as comment and blank-line only.
|
||||
|
||||
Evidence: this cohort includes changes beyond the installed CI 0.4 build, and the
|
||||
latest release head is neither installed nor tagged. On the merged integration tree the
|
||||
local gate passed: the production UI build under strict XML and analyzers with zero
|
||||
warnings, and the 35 existing paper/media tests; the server's strict full verify (R31)
|
||||
also passed. Core.Tests is not built locally
|
||||
because of a Defender quarantine and is verified only on hosted CI. The prior protected
|
||||
base `0336d5f` had 477 hosted tests; this source head still needs a normal Gitea
|
||||
Core, CLI and build run. The earlier PR-head result (35ed93c) is historical. The
|
||||
existing Auth/S3 qualification is accepted and no new desktop or provider proof is
|
||||
claimed. Still pending: hosted CI, installation of the new label implementation, and
|
||||
a small-stock paper test. No 1.0 claim.
|
||||
|
||||
## 0.3.2-rc.1 — 2026-11-01
|
||||
## 0.3.2-rc.1 — unreleased
|
||||
|
||||
- Verify that the app, service and CLI all have the requested release and identical full build versions before recording MSI update success. Report missing, unreadable or mixed builds without claiming rollback.
|
||||
- Correct the Windows App SDK credit to reference its Microsoft terms and installed third-party notices.
|
||||
|
||||
@@ -1,95 +1,3 @@
|
||||
# CLAUDE.md
|
||||
# Agent guidance
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Policy lives in [AGENTS.md](AGENTS.md) (repository rules: hosting split, authorization, logging, printing) and [CONTRIBUTING.md](CONTRIBUTING.md) (conventions, script quality). Read both; this file adds the commands and architecture they leave implicit.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Windows x64, PowerShell 7.4+, .NET SDK pinned by `global.json` (10.0.x).
|
||||
- `qqq-print-protocol` checked out as a **sibling directory** at the SHA pinned in `.github/workflows/ci.yml`. Only `Core.Tests` consumes it (it links `../qqq-print-protocol/fixtures/*.json` at build time; override with `-p:PrintingProtocolFixtures=<dir>` or `Verify.ps1 -ProtocolFixtures`), so the NativeCI solution builds without it.
|
||||
- Scripts tooling: `uv sync --locked` and `npm ci --ignore-scripts` (Node 24+).
|
||||
|
||||
## Commands
|
||||
|
||||
**Host restriction first.** `Qqq.PrintAgent.Core.Tests` is hosted-CI-only. On this development PC, Windows Defender quarantines the Core test assembly, so that project must not be built or run locally (see `docs/SESSION-STATE.md`; do not add exclusions or restore quarantine to work around it). Local work uses `Qqq.PrintAgent.NativeCI.slnx`, which is the full solution minus `Core.Tests`. Core test evidence comes from the hosted Windows CI run on the pushed commit.
|
||||
|
||||
Local default (locked restore, Release x64 build with `-warnaserror`, Windows/CLI tests), always with `-p:Platform=x64` because the solution maps test projects to AnyCPU and a direct `dotnet test` without it picks stale output. Never pass `--no-build` to `dotnet test`: the solution build writes the test DLL to the AnyCPU folder, so an x64 `--no-build` run executes whatever old x64 DLL is lying around and reports a pass against stale code.
|
||||
|
||||
```powershell
|
||||
dotnet restore Qqq.PrintAgent.NativeCI.slnx -p:Platform=x64 --locked-mode
|
||||
dotnet build Qqq.PrintAgent.NativeCI.slnx -c Release -p:Platform=x64 --no-restore -warnaserror
|
||||
dotnet test tests/Qqq.PrintAgent.Cli.Tests -c Release -p:Platform=x64 --no-restore
|
||||
```
|
||||
|
||||
Single test (xunit):
|
||||
|
||||
```powershell
|
||||
dotnet test tests/Qqq.PrintAgent.Cli.Tests -p:Platform=x64 --filter "FullyQualifiedName~AgentPipeSecurityTests"
|
||||
```
|
||||
|
||||
Full verification, **only on a permitted host or hosted CI** (adds Core.Tests, coverage and TRX output under `TestResults/verify-<guid>`; requires the sibling protocol checkout):
|
||||
|
||||
```powershell
|
||||
./build/Verify.ps1
|
||||
```
|
||||
|
||||
Packaging (unsigned self-contained x64 MSI; never installs or prints):
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
./build/Package-Release.ps1 # runs the CI packaging checks and assembles artifacts/release
|
||||
```
|
||||
|
||||
Script quality gates (CI runs the same):
|
||||
|
||||
```powershell
|
||||
./build/Analyze-Scripts.ps1 # PSScriptAnalyzer 1.25 over build/*.ps1, one rule at a time
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
python .github/scripts/check-source-headers.py # owned-file copyright/SPDX notices
|
||||
npm run typecheck; npm run lint; npm test # tools/virtual-agent (Node, ESLint Allman)
|
||||
```
|
||||
|
||||
Interactive-only suites (not run by hosted CI):
|
||||
|
||||
- **UI tests**: elevated interactive desktop; build the companion, optionally set `K22_UITEST_EXE`, then `dotnet test tests/Qqq.PrintAgent.UiTests -p:Platform=x64`.
|
||||
- **Layout tests**: `tests/Qqq.PrintAgent.LayoutTests/Run.ps1 -EvidenceDirectory <new dir>`; synthetic service only, not in the solution file.
|
||||
|
||||
Pre-commit hooks (`pre-commit install --hook-type pre-commit --hook-type commit-msg`) enforce Conventional Commit types, DCO sign-off (`git commit -s`), gitleaks, and no direct commits to `main`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Five projects, strict layering (Core → Windows → Service/UI/CLI). Business logic never lives above Core; OS calls never live in Core.
|
||||
|
||||
| Project | Owns | Key entry points |
|
||||
| --- | --- | --- |
|
||||
| `Qqq.PrintAgent.Core` | QQQ session auth, HTTP/WSS clients, realtime settings/policy, document streaming, job state machine | `Authentication/QqqSessionClient*`, `Printing/PrintApiClient*`, `Printing/PrintWorker`, `Printing/PrintingRealtimeChannel` |
|
||||
| `Qqq.PrintAgent.Windows` | Spooler/PDF/ZPL backends, SQLite + DPAPI store, named-pipe IPC, the long-running runtime | `AgentRuntime.*.cs` (partial class split by concern), `AgentPipe*`, `AgentStore` |
|
||||
| `Qqq.PrintAgent.Service` | Windows Service host only | `PrintService.cs` |
|
||||
| `Qqq.PrintAgent` | WinUI/AppKit companion: tray, Printers/Jobs/Logs/Settings views, MSI update UX | `MainWindow.*.cs` (partial class split by concern) |
|
||||
| `Qqq.PrintAgent.Cli` | Authenticated service management over the pipe; isolated legacy diagnostics | `Program.cs` |
|
||||
|
||||
Large classes are deliberately split into partial files by concern (`AgentRuntime.Realtime.cs`, `AgentRuntime.Jobs.cs`, `MainWindow.Updates.cs`, ...). Add new behavior to the matching partial rather than growing `AgentRuntime.cs` / `MainWindow.xaml.cs`.
|
||||
|
||||
**Runtime model.** The service is the only process holding credentials, the journal and printer access. It opens an outbound authenticated WebSocket (`/qqq/v1/qbit/printing/device/realtime`, subprotocol `qqq-printing.v1`) for commands, status and job notifications; documents stream separately over HTTPS into a protected staging directory with length + SHA-256 verification. There is intentionally **no polling fallback**. The companion and CLI talk to the service only through the `QqqPrintAgent` named pipe (administrator clients) and receive credential-free snapshots. Printing runs in an isolated child process with a deadline. See `docs/REALTIME.md` for the worker split, bounds and reconnect rules.
|
||||
|
||||
**Reliability boundary.** `PrintWorker` persists receipt and `SUBMITTING` before calling Windows, records `SPOOLED` plus the spooler job ID afterward, and replays pending cloud events. A crash between spool acceptance and recording is not atomic; the worker reports `OUTCOME_UNKNOWN` rather than auto-reprinting. Preserve this distinction in any retry/cancel change, and keep "spool accepted" and "paper came out" separate in docs and messages.
|
||||
|
||||
**Versions.** Product version (`VersionPrefix` in `Directory.Build.props`, release workflow overrides with `-p:Version`), native printing API (2.3), wire payload (v1) and the WebSocket subprotocol are independent. Do not conflate them when bumping.
|
||||
|
||||
**Updates.** `MsiUpdateService` + `GitHubMsiReleaseClient` read the GitHub release feed and apply a machine MSI through Windows Installer, updating service, companion and CLI together. That GitHub feed is why the GitHub shadow must stay even though Gitea is primary.
|
||||
|
||||
## Test layout
|
||||
|
||||
- `tests/Qqq.PrintAgent.Core.Tests`: protocol fixtures (`ProtocolFixtureTests`), loopback WebSocket peer, worker state machine, media contracts. Hosted CI only on this PC (see Commands); new Core tests are written here and validated by the CI run.
|
||||
- `tests/Qqq.PrintAgent.Cli.Tests`: despite the name, this is the Windows runtime suite (`AgentRuntime*`, `AgentRealtime*`, `AgentPipe*`, `AgentStore*`). Put Windows-layer tests here.
|
||||
- `tests/Qqq.PrintAgent.WorkerFixture`: child-process fixture used by runtime tests.
|
||||
|
||||
## Conventions worth knowing
|
||||
|
||||
- `.editorconfig`: 3-space indentation, Allman braces, everywhere (C#, PowerShell, scripts). Analyzers run in build; zero warnings is enforced by `TreatWarningsAsErrors`.
|
||||
- Every owned source file starts with the `Copyright (c) 2026 James Maes` / `SPDX-License-Identifier: MIT` header; `check-source-headers.py` fails otherwise. `tools/virtual-agent` keeps its QRun.IO attribution.
|
||||
- Package versions are central (`Directory.Packages.props`); after changing them, regenerate each affected `packages.lock.json` (`dotnet restore --force-evaluate`).
|
||||
- Coverage is reported, not gated, per `docs/adr/0002-prerelease-quality-and-packaging.md`.
|
||||
- `docs/SESSION-STATE.md` records the current release/verification state; consult it before claiming anything is verified.
|
||||
See [AGENTS.md](AGENTS.md) for repository instructions and [CONTRIBUTING.md](CONTRIBUTING.md) for development conventions.
|
||||
+4
-20
@@ -1,22 +1,20 @@
|
||||
# Contributing
|
||||
|
||||
QQQ Print Agent is developed in the [QRun-IO organization on Gitea](https://git.kof22.com/QRun-IO). It uses the KofTwentyTwo Windows AppKit service architecture. Contributions are accepted under the [MIT license](LICENSE); preserve existing attribution.
|
||||
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` (`42c0d249f81395f00408ec0f92638dcbbf5deb03`, [Gitea PR 2](https://git.kof22.com/QRun-IO/qqq-print-protocol/pulls/2)); its JSON fixtures are linked into the contract tests.
|
||||
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 1.0.1 -ReleaseVersion 1.0.1
|
||||
./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
|
||||
|
||||
Publish ordinary branches and pull requests to the [primary Gitea repository](https://git.kof22.com/QRun-IO/qqq-print-agent). GitHub is a source shadow; the existing authorized release/signing workflow and CI/security workflows remain there. GitHub release assets also serve the installed updater. See [Releasing](docs/RELEASING.md) before changing either distribution path.
|
||||
|
||||
- Branch from `main`, use Conventional Commit titles, and include a DCO sign-off (`git commit -s`). Sign commits where your account supports signing.
|
||||
- Keep business/protocol logic in Core, Windows integration in Windows, hosting in Service, and view/tray plumbing in the AppKit companion.
|
||||
- Use central versions in `Directory.Packages.props`; regenerate affected `packages.lock.json` files when dependencies change.
|
||||
@@ -36,18 +34,4 @@ A passing unit suite does not establish printer compatibility. Record Windows ve
|
||||
|
||||
## Issues and releases
|
||||
|
||||
Open ordinary bugs or proposals in [Issues](https://git.kof22.com/QRun-IO/qqq-print-agent/issues). Use [private security reporting](SECURITY.md) for vulnerabilities. Release authorization and tagging belong to maintainers; see [RELEASING.md](docs/RELEASING.md).
|
||||
|
||||
## Script quality
|
||||
|
||||
Owned scripts follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (configs adapted from its `python` and `typescript` profiles): three-space indentation, Ruff 0.17.3 format/lint (locked in `uv.lock`) and strict Pyright 1.1.414 (Microsoft's npm package, locked in `package-lock.json` and run through Node) for `.github/scripts/*.py`, and ESLint 10 with Stylistic (Allman) plus strict `tsc` over JSDoc for `tools/**/*.mjs` and `eslint.config.mjs`. Restore with `uv sync --locked` and `npm ci --ignore-scripts`, then run:
|
||||
|
||||
```text
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
|
||||
`check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or JavaScript outside the `tsconfig.json` include list, and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print`/`console.log` only in `check-*.py` and `tools/`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass. `package.json` overrides `minimatch` to 11.0.1, the patched release for [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) (affected before 11.0.1); keep it until ESLint's own dependency range includes a patched release. The only npm license exception, [EX-0015](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0015) for development-only `uri-js` 4.4.1, is enforced by `check-script-quality.py` (version, lock integrity, installed license bytes and the exact `pkg:npm/uri-js` caller entry); changing that package or the entry needs renewed approval. The virtual agent's existing QRun.IO attribution is unchanged.
|
||||
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).
|
||||
@@ -60,19 +60,14 @@
|
||||
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
|
||||
CLI's version output show as a short hash. -->
|
||||
<PropertyGroup>
|
||||
<VersionPrefix>1.2.1</VersionPrefix>
|
||||
<VersionSuffix></VersionSuffix>
|
||||
<VersionPrefix>0.3.2</VersionPrefix>
|
||||
<VersionSuffix>rc.1</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Product metadata stamped into every assembly. A client edition build (build/Build-Installer.ps1 -Edition)
|
||||
passes BrandProduct and BrandCompany, so each executable's product, company and file description carry the
|
||||
client's brand; author and copyright stay with the source. -->
|
||||
<!-- Product metadata stamped into every assembly. -->
|
||||
<PropertyGroup>
|
||||
<Authors>James Maes</Authors>
|
||||
<Product>QQQ Print Agent</Product>
|
||||
<Product Condition="'$(BrandProduct)' != ''">$(BrandProduct)</Product>
|
||||
<AssemblyTitle Condition="'$(BrandProduct)' != ''">$(BrandProduct)</AssemblyTitle>
|
||||
<Company Condition="'$(BrandCompany)' != ''">$(BrandCompany)</Company>
|
||||
<Copyright>Copyright (c) 2026 James Maes</Copyright>
|
||||
<RepositoryUrl>https://github.com/QRun-IO/qqq-print-agent</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<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.Cli.Tests/Qqq.PrintAgent.Cli.Tests.csproj" />
|
||||
<Project Path="tests/Qqq.PrintAgent.UiTests/Qqq.PrintAgent.UiTests.csproj" />
|
||||
<Project Path="tests/Qqq.PrintAgent.WorkerFixture/Qqq.PrintAgent.WorkerFixture.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -4,40 +4,55 @@
|
||||
<!-- markdownlint-disable-next-line MD033 -->
|
||||
<img src="src/Qqq.PrintAgent/Assets/Brand/app-preview.png" width="96" alt="QQQ Print Agent cloud and printer icon">
|
||||
|
||||
[](https://github.com/QRun-IO/qqq-print-agent/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
Print from **QQQ 4.1+** to local Windows printers without opening inbound network ports. The Windows service opens an authenticated two-way WebSocket to QQQ for commands, status and job notifications. Documents download separately over HTTPS, so a large transfer does not block the control connection. There is no job/control polling fallback.
|
||||
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.
|
||||
|
||||
**Current stable release: [1.2.1](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** (generic and GG Forge edition installers). The service, companion, CLI and MSI are signed with Azure Artifact Signing. The matching server release is [qbit-printing 1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0), using QQQ **4.1.0-RC.21** for native session login, with [protocol 1.1.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0); 1.2.1 also works unchanged against qbit-printing 1.0.0. Product versions are separate from native printing API **2.4** (2.3 compatible), wire payload **v1** and WebSocket subprotocol `qqq-printing.v1`.
|
||||
The native setup and status application uses the same [KofTwentyTwo AppKit](https://github.com/KofTwentyTwo/AppKit) foundation as GCLO and MarkdownViewr.Windows.
|
||||
|
||||
The setup/status application uses the same [KofTwentyTwo AppKit](https://git.kof22.com/KofTwentyTwo/AppKit) foundation as GCLO and MarkdownViewr.Windows. QQQ's mascot Query appears in About and branded warehouse test documents.
|
||||
> **Published and installed prerelease: [0.3.2-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.2-rc.1).** All four assets, checksums and provenance attestations were verified. The real in-app update completed with matching app/service/CLI builds, retained identity and history, and an automatically reopened Connected app. The installer remains unsigned.
|
||||
|
||||
## Install and connect
|
||||
**0.3.2-rc.1** checks the app, service and CLI for the same complete
|
||||
build version after an update, reports an inconsistent installation explicitly,
|
||||
and corrects the SDK license credit. Its native test host now uses production's
|
||||
PerMonitorV2 DPI declaration. The original 0.3.0 layout-cycle cause remains
|
||||
unproven; current operator acceptance and repeated native replays pass. The
|
||||
existing fatal-error handling is a mitigation, not an underlying layout fix.
|
||||
See [changelog](CHANGELOG.md) and [session state](docs/SESSION-STATE.md).
|
||||
|
||||
1. Download `QqqPrintAgent-v1.2.1-x64.msi` (or a client edition such as `GgForgePrintAgent-v1.2.1-x64.msi`; one edition per PC) and `SHA256SUMS` from the [stable release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1). Check the checksum and Authenticode signature as described in [Installation](docs/INSTALLATION.md).
|
||||
2. Install with administrator rights. The MSI installs the automatic Windows service and adds **QQQ Print Agent** under **Print Connectors** in Start.
|
||||
3. Open the app and accept its administrator prompt. Choose **Settings → Server connection → Sign in / Connection settings**, enter the QQQ HTTPS server address and select **Discover sign-in options**. Sign in using the advertised username/password or browser flow.
|
||||
4. For a new device, create an enrollment code in QQQ under **Printing → Print devices → Actions → Enroll a print device**, then enter it in the client. Existing pairings are reused or explicitly associated with the signed-in account; routine upgrades do not need a replacement pairing.
|
||||
5. In **Printers**, choose which queues to share. Enable the intended discovered queue in QQQ, then submit an authorized test print matched to the loaded paper or labels.
|
||||
The operator used **Help → Check for updates**, chose **Not now** and confirmed
|
||||
the app remained responsive, then selected **Update and restart**. Independent
|
||||
before/after reports verified the published 0.3.2 bytes and preserved earlier
|
||||
jobs, pairing and profiles; the operator confirmed the reopened Connected app
|
||||
and both printers. Successful paper output was previously confirmed on
|
||||
**0.3.1-rc.1** and remains separate evidence; see [acceptance notes](docs/ACCEPTANCE.md).
|
||||
|
||||
The service uses machine-installed printers available to its LocalSystem account. A printer mapped only in a user's profile may be unavailable. The QQQ endpoint needs outbound HTTPS and WebSocket upgrades; HTTP/WS is accepted only for loopback development. No inbound firewall rule is installed.
|
||||
The 0.3 client requires discovered printing extension `>=2.0.0 <3.0.0` and standard QQQ sign-in. Upgrade the server before adopting it; a 1.0 server cannot provide realtime printing. The service opens authenticated WSS for controls, receipts, status and job notifications, while documents stream separately over HTTPS. There is no job/control polling or fallback. Existing unbound pairings require explicit account association, not automatic replacement. See [realtime operation](docs/REALTIME.md) and [sign-in and migration](docs/QQQ-SIGN-IN.md). Real Authentik login and renewal passed in isolated development containers against an unreleased QQQ API 1.9 candidate. Released RC.20 lacks that native-session grant; installed browser/private-callback acceptance still requires a compatible published runtime and configured HTTPS provider.
|
||||
|
||||
See [User guide](docs/USER-GUIDE.md), [QQQ sign-in](docs/QQQ-SIGN-IN.md) and [Troubleshooting](docs/TROUBLESHOOTING.md).
|
||||
## Current source features
|
||||
|
||||
## Daily use
|
||||
|
||||
- **Printers:** share queues, sort columns, expand a selected row, or double-click to edit its name, description and location. The same metadata is reported to QQQ.
|
||||
- **Jobs:** sort and search paged history, expand a selected row for recent events, or double-click for full details. Printers default to Name ascending; jobs default to Created descending. Reprint and cancellation actions require confirmation.
|
||||
- **Logs:** service and companion events share the AppKit viewer, with live follow, error filtering, copying and folder access. Settings controls daily rotation, retention, size limits and clearing diagnostic logs.
|
||||
- **Settings:** select the server and sign in, edit computer details, control the Windows service, choose theme and startup preferences, or check for updates.
|
||||
- **Footer and tray:** show connection/printer health. The footer exposes the server address, reconnect/log shortcuts and live application upload/download counters. Printer status is an observation from Windows, not proof of paper output.
|
||||
- **Background printing:** the Windows service keeps working when the companion is closed or the user logs out. Optional tray startup is a separate setting.
|
||||
|
||||
QQQ can see devices, exposed printers, status, jobs and queue history. Its authorized device actions control dispatch, disconnect/reconnect operation, refresh inventory and revoke credentials. Current QQQ user, tenant, table, process and record permissions apply to client operations; a device token does not replace user authority.
|
||||
- Discover Windows print queues and explicitly choose which ones to expose to QQQ.
|
||||
- Use native Printers, Jobs, Logs and Settings navigation, persistent status, reconnect and a visible health tray icon.
|
||||
- Browse compact printer/job tables with full details and confirmed test/reprint/cancel actions. Expand a selected row for printer information or recent job history; double-click for printer settings or full job details.
|
||||
- Sort table headers in either direction. Printers default to Name ascending; jobs default to immutable Created time descending. Job sorting applies across the matching result set before paging.
|
||||
- Page through local history in groups of 50, searching by job ID, document-title or printer-name prefix.
|
||||
- Edit computer/printer names, descriptions and locations locally and synchronize them to QQQ.
|
||||
- Configure daily log retention/size limits, inspect usage and explicitly clear diagnostic logs.
|
||||
- Start/restart the service and opt into tray startup at sign-in; service and companion share the AppKit log viewer.
|
||||
- Check for updates manually or at startup using AppKit; confirm a machine MSI upgrade of the service, companion and CLI together.
|
||||
- Set theme and splash duration, and open project/help/release links and dependency credits.
|
||||
- Print packing slips and other PDFs through the installed Windows driver.
|
||||
- Send printer-ready ZPL to Zebra/ZDesigner queues.
|
||||
- Generate one QQQ-branded warehouse diagnostic at a time: Letter, A4 or 4 × 6-inch PDF; 4 × 6-inch ZPL at 203, 300 or 600 dpi. Native choices prefer ZPL on compatible label queues, with a driver-name DPI suggestion that the operator verifies.
|
||||
- Sign in using the QQQ server's advertised authentication, then associate an existing pairing or enroll a new device with a short-lived code; the service protects its session and secondary device credential.
|
||||
- Keep printing when the desktop app is closed or the user logs out.
|
||||
- Preserve job receipts and outgoing status events across service restarts.
|
||||
- Avoid automatically printing a job again when a previous submission is uncertain.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q[QQQ printing QBit] <-->|Client-initiated WSS: commands and receipts| S[Windows service]
|
||||
Q[QQQ 4.1+ printing QBit] <-->|Client-initiated WSS: controls and receipts| S[Windows service]
|
||||
Q -->|Separate streamed HTTPS documents| S
|
||||
U[AppKit setup and status app] -->|Local administrator pipe| S
|
||||
S --> W[Windows spooler]
|
||||
@@ -45,59 +60,88 @@ flowchart LR
|
||||
W --> Z[Zebra printer: ZPL]
|
||||
```
|
||||
|
||||
## Documents and printer media
|
||||
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.
|
||||
|
||||
The service prints PDFs through the Windows driver and sends printer-ready ZPL to compatible Zebra/ZDesigner queues. Test prints offer Letter and A4 PDF, plus 4 × 6, 4 × 2 and 4 × 1-inch PDF/ZPL labels; ZPL offers 203, 300 and 600 dpi. Match the actual stock and printer resolution before printing. Query branding, warehouse-readable characters and alignment marks make the diagnostics easy to identify.
|
||||
## Install and connect
|
||||
|
||||
The client reports the Windows queue's machine-default media as a revision-bound profile. It does not detect which stock is physically loaded. Selected media is checked before submission; a stale selection is refused. PDF jobs can instead request an explicit width/height pair for that job without changing the queue defaults. The short-label PDF tests use this path. See [Printer media](docs/PRINTER-MEDIA.md).
|
||||
1. Download the x64 MSI and `SHA256SUMS` from [Releases](https://github.com/QRun-IO/qqq-print-agent/releases). Verify the checksum before installing.
|
||||
2. Install the MSI with administrator rights. It installs the service and adds **QQQ Print Agent**, or your configured customer label, under **Print Connectors** in the Start menu.
|
||||
3. Open **QQQ Print Agent**, accept the administrator prompt and use **Settings → Sign in / Connection settings** to discover the QQQ server's sign-in options and authenticate.
|
||||
4. For a new pairing, create an enrollment code in QQQ and enter it after sign-in. For an existing unbound pairing, explicitly associate it with your account; do not create a replacement merely to upgrade.
|
||||
5. In **Printers**, select which queues to expose to QQQ. Then enable the intended discovered queue in QQQ and submit an authorized test document.
|
||||
|
||||
`SPOOLED` means Windows accepted the job; it does not prove that paper came out. `OUTCOME_UNKNOWN` means a submission may have reached the printer. Inspect the output before requesting another copy. Retained receipts prevent uncertain jobs from being silently printed again.
|
||||
The QQQ URL must use HTTPS, with outbound WebSocket upgrades allowed to the same origin. HTTP/WS is accepted only for explicit loopback development. No inbound firewall rule is installed. Existing outbound proxy/network policies and local printer connectivity still apply. The previous 0.2 installer uses its earlier HTTP transport; coordinate the 0.3 server and client upgrade because native API 2.0 requires an active synchronized channel for claims.
|
||||
|
||||
## Requirements and limits
|
||||
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.
|
||||
|
||||
| Area | Stable 1.2.1 behavior |
|
||||
## Requirements and supported behavior
|
||||
|
||||
| Area | 0.3 release candidate behavior |
|
||||
| --- | --- |
|
||||
| Server | QQQ 4.1+; recommended cohort QQQ 4.1.0-RC.21 and qbit-printing 1.0.0, native printing API 2.3 |
|
||||
| Server | QQQ 4.1+ with printing semantic extension 2.0 and wire-v1 payloads |
|
||||
| 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 the service account |
|
||||
| Documents | PDF and ZPL; default limits 100 MiB PDF and 20 MiB ZPL, configurable by authorized QQQ administrators |
|
||||
| Transport | Outbound authenticated WSS plus same-origin HTTPS document streams, with automatic reconnect |
|
||||
| Capacity | Bounded download/print concurrency, staging, bandwidth and timeouts; one download and one print lane by default, ordered per printer |
|
||||
| Local history | 50-row pages; default detailed retention 30 days or latest 300,000 detailed jobs, with active/uncertain evidence and duplicate-suppression records retained |
|
||||
| Diagnostic logs | Daily rotation plus size rollover to numbered segments; defaults 30 days, 100 MiB per file and 10 segments per day, with optional debug logging, configurable in the client |
|
||||
| Printers | Installed Windows queues accessible to LocalSystem, the default service account |
|
||||
| Documents | PDF and ZPL; administrator defaults are 100 MiB PDF and 20 MiB ZPL, with validated configurable limits |
|
||||
| PDF options | Copies, actual size or fit; queue-default paper/tray settings |
|
||||
| ZPL options | Copies; label dimensions/DPI encoded in printer-ready ZPL |
|
||||
| Transport | Persistent outbound authenticated WSS plus separate same-origin HTTPS streams; no polling fallback |
|
||||
| Capacity | Configurable bounded download/print lanes, staging, bandwidth and timeouts; one download/print lane by default and ordered submission per printer |
|
||||
|
||||
The client supports native printing extension `>=2.0.0 <3.0.0`; optional media negotiation requires stable extension 2.3 or later in that major. Limits are resource bounds, not a printer-throughput guarantee. See [Realtime settings](docs/REALTIME.md) and [History and log limits](docs/SCALABILITY.md).
|
||||
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.
|
||||
|
||||
## Updates and customer branding
|
||||
`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.
|
||||
|
||||
Use **Help → Check for updates** or the same button in Settings. Startup checks are optional; installation requires confirmation and updates the service, companion and CLI together. Stable installations receive newer stable releases. The installed updater reads the project's [GitHub release feed](https://github.com/QRun-IO/qqq-print-agent/releases); this feed remains available alongside the primary Gitea releases.
|
||||
## Validation so far
|
||||
|
||||
Customers can supply a name, company, support link, colors and local artwork during installation using the shared MSI and `Install-Branded.ps1`. No custom build is needed. Branding preserves the software's signing identity, protocol and update feed. See [Customer branding](docs/BRANDING.md).
|
||||
The 0.3.2 Release gate passed **426 tests** with zero warnings/errors/skips.
|
||||
[Release workflow 41324312106](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41324312106)
|
||||
completed all nine jobs and published the release for the signed tag at protected main
|
||||
`268f951b6728b0e69fedaf46769785c3f5ae55f4`. All four release assets and their
|
||||
attestations passed verification. Static MSI inspection confirmed product
|
||||
version `0.3.2`, matching app/service/CLI source versions, WinUI resources,
|
||||
service configuration and callback registration. The separately observed
|
||||
0.3.1-to-0.3.2 in-app update returned MSI exit 0, restored the service's WebSocket
|
||||
and automatically reopened the companion. Actual failed/canceled updates,
|
||||
restart-required/reboot behavior and installed provider-browser acceptance
|
||||
remain open. See [session state](docs/SESSION-STATE.md).
|
||||
|
||||
## Development and contributions
|
||||
### Installed published 0.2.0 — 2026-10-31
|
||||
|
||||
Source, branches, issues and pull requests are maintained on [Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent). GitHub is the automatic source shadow and the existing authorized signing/update distribution bridge; do not push ordinary source changes there.
|
||||
The exact published 0.2.0-rc.1 MSI installed successfully with pairing, printer exposure and history retained. Native QQQ table-based sign-in, existing-device association, server controls and inventory refresh succeeded. The user confirmed that the Letter PDF and 4 × 6-inch Zebra ZPL printed correctly, and manually confirmed the app's Connected footer and both printers. This is local 0.2 acceptance, not installed or physical-output evidence for 0.3, Authentik or a remote production deployment. See [acceptance notes](docs/ACCEPTANCE.md).
|
||||
|
||||
Use Windows x64 and the .NET 10 SDK selected by `global.json`. Clone the protocol repository beside the client and check out the fixture commit pinned by CI:
|
||||
### Historical 0.1.9 build and installed checks
|
||||
|
||||
The 0.1.9 source gate passed **255 tests (66 Core + 189 Windows/CLI)** with zero warnings/errors, and the full solution/UI build passed. [CI build 41185716708](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41185716708) and the required security, CodeQL and script gates passed. The CI MSI upgraded the installed service, companion and CLI with exit 0 and no reboot, preserving protected credentials and journal history. Authenticated management, connection and history checks succeeded; all **16 installed UI checks passed in one run**. No print was submitted by the upgrade or UI tests.
|
||||
|
||||
[Release workflow 41190815995](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41190815995) published v0.1.9-rc.1 from protected main revision `cf0e362`. The MSI, `SHA256SUMS`, `Install-Branded.ps1` and `branding.example.json` were verified, including provenance attestations for all four assets. The published MSI differs from the installed CI MSI; runtime sources are unchanged, but the release package has not been separately reinstalled. Their full hashes and verification scopes are in [acceptance notes](docs/ACCEPTANCE.md).
|
||||
|
||||
These results cover the local installed service/app upgrade. They do not establish remote deployment, Windows reboot/recovery, updater-initiated elevation, hostile-tree rollback, broad driver qualification or every accessibility scenario. Build attestations establish provenance, not publisher signing; the MSI remains unsigned. The earlier clean service stop still lacks a confirmed cause.
|
||||
|
||||
### Historical 0.1.7 hardware acceptance — 2026-10-30
|
||||
|
||||
The user confirmed laser PDF, Zebra ZPL and Zebra PDF output on 0.1.7. The first Zebra PDF was accepted by the spooler but did not print until machine defaults were corrected to 4 × 6-inch direct thermal and the hardware pause was cleared. A fresh reviewed PDF then printed fully and correctly. Barcode scanning and measured alignment remain unverified. This dated evidence does not claim fresh physical output from 0.1.9; the [configuration diagnosis and earlier results](docs/ACCEPTANCE.md) retain their original scope.
|
||||
|
||||
## Development
|
||||
|
||||
Requires Windows x64 and the .NET 10 SDK selected by `global.json`. Check out this repository and the protocol repository as siblings; use the pinned protocol commit in `.github/workflows/ci.yml`.
|
||||
|
||||
```powershell
|
||||
git clone https://git.kof22.com/QRun-IO/qqq-print-agent.git
|
||||
git clone https://git.kof22.com/QRun-IO/qqq-print-protocol.git
|
||||
git -C qqq-print-protocol checkout 42c0d249f81395f00408ec0f92638dcbbf5deb03
|
||||
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 1.2.1 -ReleaseVersion 1.2.1
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.3.2 -ReleaseVersion 0.3.2-rc.1
|
||||
```
|
||||
|
||||
Run verification only on a permitted Windows test host; host-specific restrictions remain in force. Local packaging is unsigned unless explicitly signed; stable release signing runs through the protected workflow. Neither command installs the service or sends a physical print.
|
||||
[Realtime architecture and settings](docs/REALTIME.md) · [History and log limits](docs/SCALABILITY.md) · [Customer branding](docs/BRANDING.md) · [Developer guide](docs/DEVELOPMENT.md) · [Administrator guide](docs/ADMINISTRATION.md) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [Release process](docs/RELEASING.md) · [Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
|
||||
|
||||
[Contributing](CONTRIBUTING.md) · [Developer guide](docs/DEVELOPMENT.md) · [Administrator guide](docs/ADMINISTRATION.md) · [Release process](docs/RELEASING.md) · [Security policy](SECURITY.md)
|
||||
## Current limits
|
||||
|
||||
## Release evidence
|
||||
|
||||
The signed 1.2.1 release passed its protected build, test, security, CodeQL, script and per-edition signing workflow (run 42223525626); checksums, Authenticode signatures and provenance attestations were verified on the published generic and GG Forge installers, and the GG Forge installer was upgraded in place on the development PC with pairing, session and settings preserved. The earlier signed 1.0.1 release passed its protected build, test, security, CodeQL, script and signing workflow. Published checksums, signatures and provenance were verified. The local 1.0.1 MSI upgrade completed successfully on 2026-11-03 with matching app/service/CLI versions and a running service. Dated hardware and earlier upgrade evidence remains in [Acceptance](docs/ACCEPTANCE.md); current release status is in [Release readiness](docs/RELEASE-READINESS.md). Earlier test results retain their original version and scope.
|
||||
Realtime transport requires a matching server and network path that permits WSS; unsupported servers and failed connections do not enable polling. Custom PDF paper dimensions are rejected; use the Windows queue's default media. Physical completion telemetry, custom media and production signing remain outside the deployment scope. Update checks are implemented; installation requires operator confirmation and administrator rights. Configurable limits are resource bounds, not printer-throughput guarantees. Review the [roadmap](docs/TODO.md) before a production rollout.
|
||||
|
||||
## License and acknowledgments
|
||||
|
||||
[MIT](LICENSE). Original Windows template and AppKit notices are preserved in [NOTICE](NOTICE). QQQ and its printing QBit are separate projects with their own licenses. Microsoft Windows SDK components retain their [separate terms](docs/WINDOWS-SDK-LICENSES.md); the installed app includes their license texts and notices.
|
||||
[MIT](LICENSE). The original Windows template and AppKit copyright notices are preserved. See [NOTICE](NOTICE) for the foundation and bundled dependency acknowledgments. QQQ and the printing QBit are separate projects with their own licenses.
|
||||
|
||||
Microsoft Windows SDK components retain their [separate terms and distribution scope](docs/WINDOWS-SDK-LICENSES.md). The installed App directory includes all five distinct SDK license texts and their notices; these components are not relicensed as MIT.
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
Do not publish a security vulnerability as a normal issue. Use [GitHub private vulnerability reporting](https://github.com/QRun-IO/qqq-print-agent/security/advisories/new). Include the affected version, reproduction steps, impact and sanitized evidence. Do not send live enrollment codes, device credentials, customer documents or local print databases.
|
||||
|
||||
Fixes target the latest stable 1.0 release. There is no long-term support commitment or independently audited production security certification. Gitea is the primary source repository; the established private reporting channel above remains available on GitHub.
|
||||
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
|
||||
|
||||
@@ -16,6 +16,6 @@ Remote QQQ endpoints require HTTPS. Plain HTTP is allowed only on loopback for d
|
||||
|
||||
## Distribution
|
||||
|
||||
Stable **1.0.1** publishes an Azure Artifact Signing-signed MSI and first-party executables, `SHA256SUMS` and build provenance. Download from the [official release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) and verify both checksum and Authenticode signature. The installed updater uses the matching GitHub release assets. Earlier unsigned prereleases retain their historical status; signing does not establish printer compatibility or make arbitrary printer documents safe. See [Installation](docs/INSTALLATION.md) and [release signing](docs/RELEASE-SIGNING.md).
|
||||
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.
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
# Support
|
||||
|
||||
Use [Gitea Issues](https://git.kof22.com/QRun-IO/qqq-print-agent/issues) for bugs, setup questions and feature requests. Check the [installation](docs/INSTALLATION.md) and [troubleshooting](docs/TROUBLESHOOTING.md) guides first. Include version, Windows build, printer/driver and sanitized error details.
|
||||
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](SECURITY.md). No commercial support SLA is implied.
|
||||
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.
|
||||
@@ -1,36 +0,0 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Rejects a stable release when the protected signing configuration is missing or malformed.
|
||||
.DESCRIPTION
|
||||
Checks the non-secret protected-environment variables before any signing step runs. This is
|
||||
configuration validation only; it does not prove Azure access or a valid signature.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$guidVariables = @('AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID')
|
||||
foreach($name in @($guidVariables) + @('ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT', 'ARTIFACT_SIGNING_PROFILE', 'ARTIFACT_SIGNING_SUBJECT'))
|
||||
{
|
||||
$value = [Environment]::GetEnvironmentVariable($name)
|
||||
if([string]::IsNullOrWhiteSpace($value)) { throw "Missing verified signing variable: $name" }
|
||||
if($name -in $guidVariables)
|
||||
{
|
||||
$identifier = [guid]::Empty
|
||||
if(-not [guid]::TryParseExact($value, 'D', [ref]$identifier) -or $identifier -eq [guid]::Empty) { throw "Invalid signing identifier: $name" }
|
||||
}
|
||||
}
|
||||
$endpoint = $null
|
||||
if(-not [uri]::TryCreate($env:ARTIFACT_SIGNING_ENDPOINT, [UriKind]::Absolute, [ref]$endpoint) -or $endpoint.Scheme -ne 'https' -or -not $endpoint.Host.EndsWith('.codesigning.azure.net', [StringComparison]::OrdinalIgnoreCase))
|
||||
{
|
||||
throw 'The verified Artifact Signing endpoint must use HTTPS on codesigning.azure.net.'
|
||||
}
|
||||
if($env:ARTIFACT_SIGNING_SUBJECT -notmatch '^CN=.+')
|
||||
{
|
||||
throw 'ARTIFACT_SIGNING_SUBJECT must be the full X.500 certificate subject, not just a display name.'
|
||||
}
|
||||
Write-Output 'Windows release signing configuration is present; Azure access and final signatures still require verification.'
|
||||
@@ -1,24 +0,0 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Repository-owned CI packaging entrypoint for the shared Gitea .NET CI.
|
||||
.DESCRIPTION
|
||||
The shared workflow runs this after verification with only the typed versions. It delegates to Package-Release.ps1,
|
||||
the single packaging path that CI, the release workflow and local packaging share: SDK inventory, branded-installer
|
||||
dry run, packaging-input checks, the unsigned generic MSI and one unsigned MSI per client edition, and the release
|
||||
bundle with SHA256SUMS. It takes no certificate, timestamp server or payload input and never signs or installs anything.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
||||
[string] $ProductVersion = '1.2.1',
|
||||
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
|
||||
[string] $ReleaseVersion = '1.2.1'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
& (Join-Path $PSScriptRoot 'Package-Release.ps1') -ProductVersion $ProductVersion -ReleaseVersion $ReleaseVersion
|
||||
+7
-117
@@ -5,18 +5,13 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
||||
[string] $ProductVersion = '1.0.1',
|
||||
[string] $ProductVersion = '0.3.2',
|
||||
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
|
||||
[string] $ReleaseVersion = '1.0.1',
|
||||
[string] $ReleaseVersion = '0.3.2-rc.1',
|
||||
[string] $PayloadDirectory,
|
||||
[switch] $SkipPublish,
|
||||
[switch] $PublishOnly,
|
||||
[string] $CertificateThumbprint,
|
||||
[string] $TimestampServer,
|
||||
# A client edition folder name under editions/ (see editions/README.md): the build ships that client's mark, icon and
|
||||
# names in every executable and the installer, and names the MSI after the edition's asset prefix.
|
||||
[ValidatePattern('^[a-z0-9][a-z0-9-]{0,39}$')]
|
||||
[string] $Edition
|
||||
[string] $TimestampServer
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -36,54 +31,10 @@ if($versionProperties.SelectSingleNode('/Project/PropertyGroup/VersionPrefix').I
|
||||
{
|
||||
throw 'ProductVersion must match the reviewed source VersionPrefix in Directory.Build.props.'
|
||||
}
|
||||
# Windows Installer replaces a versioned file only when its numeric file version is higher, and the
|
||||
# .NET SDK would give every 0.4.0-rc.N the same 0.4.0.0. Only rc.N (revision N, 1..65534) and stable
|
||||
# (revision 65535, above every rc) have an ordered mapping; any other label is refused, never guessed.
|
||||
$releaseMatch = [regex]::Match($ReleaseVersion, '^\d+\.\d+\.\d+(?:-rc\.(?<revision>[1-9][0-9]{0,4}))?$')
|
||||
$revision = if($releaseMatch.Success -and $releaseMatch.Groups['revision'].Success) { [int]$releaseMatch.Groups['revision'].Value } else { 65535 }
|
||||
if(-not $releaseMatch.Success -or $revision -gt 65535 -or ($releaseMatch.Groups['revision'].Success -and $revision -gt 65534))
|
||||
{
|
||||
throw 'Unsupported ReleaseVersion: use X.Y.Z-rc.N (N 1..65534) or stable X.Y.Z so the numeric file version orders upgrades.'
|
||||
}
|
||||
$fileVersion = "$ProductVersion.$revision"
|
||||
$brand = $null
|
||||
if($Edition)
|
||||
{
|
||||
$editionRoot = Join-Path $projectRoot "editions\$Edition"
|
||||
$editionFile = Join-Path $editionRoot 'edition.json'
|
||||
if(-not (Test-Path -LiteralPath $editionFile -PathType Leaf)) { throw "Unknown edition '$Edition': $editionFile does not exist." }
|
||||
$brand = Get-Content -LiteralPath $editionFile -Raw | ConvertFrom-Json
|
||||
foreach($required in @('id', 'displayName', 'companyName', 'assetPrefix'))
|
||||
{
|
||||
if(-not ($brand.PSObject.Properties.Name -contains $required) -or [string]::IsNullOrWhiteSpace($brand.$required)) { throw "edition.json is missing '$required'." }
|
||||
}
|
||||
if($brand.id -cne $Edition) { throw "edition.json id '$($brand.id)' must equal the folder name '$Edition'." }
|
||||
if($brand.assetPrefix -cnotmatch '^[A-Za-z][A-Za-z0-9]{1,39}$') { throw 'assetPrefix must be 2 to 40 letters or digits starting with a letter.' }
|
||||
foreach($label in @($brand.displayName, $brand.companyName))
|
||||
{
|
||||
if($label.Length -gt 180 -or $label -match '[\x00-\x1F;=]') { throw 'Edition names must be at most 180 characters without control characters, semicolons or equals signs.' }
|
||||
}
|
||||
foreach($asset in @('app-icon.svg', 'app.ico'))
|
||||
{
|
||||
if(-not (Test-Path -LiteralPath (Join-Path $editionRoot $asset) -PathType Leaf)) { throw "Edition asset missing: editions\$Edition\$asset" }
|
||||
}
|
||||
}
|
||||
$msiBaseName = if($brand) { $brand.assetPrefix } else { 'QqqPrintAgent' }
|
||||
$brandPublishArgs = @()
|
||||
$brandInstallerArgs = @()
|
||||
if($brand)
|
||||
{
|
||||
$brandPublishArgs = @("-p:BrandEdition=$Edition", "-p:BrandProduct=$($brand.displayName)", "-p:BrandCompany=$($brand.companyName)")
|
||||
$brandInstallerArgs = @("-p:BrandName=$($brand.displayName)", "-p:BrandCompany=$($brand.companyName)", '-p:BrandForced=1', "-p:MsiBaseName=$msiBaseName")
|
||||
}
|
||||
if($SkipPublish -and -not $PayloadDirectory)
|
||||
{
|
||||
throw 'SkipPublish requires an explicit reviewed PayloadDirectory.'
|
||||
}
|
||||
if($PublishOnly -and ($SkipPublish -or -not $PayloadDirectory -or $CertificateThumbprint))
|
||||
{
|
||||
throw 'PublishOnly requires a new PayloadDirectory and cannot be combined with SkipPublish or a local signing certificate.'
|
||||
}
|
||||
$payloadRoot = if($PayloadDirectory) { [IO.Path]::GetFullPath($PayloadDirectory) } else { Join-Path $projectRoot ('artifacts\package\' + [Guid]::NewGuid().ToString('N')) }
|
||||
if(-not $SkipPublish -and (Test-Path -LiteralPath $payloadRoot))
|
||||
{
|
||||
@@ -104,31 +55,9 @@ if(-not $SkipPublish)
|
||||
}
|
||||
foreach($entry in $projects.GetEnumerator())
|
||||
{
|
||||
& dotnet publish (Join-Path $projectRoot $entry.Value) --configuration Release --runtime win-x64 --self-contained true --output (Join-Path $payloadRoot $entry.Key) '-p:Platform=x64' '-p:RestoreLockedMode=true' "-p:Version=$ReleaseVersion" "-p:FileVersion=$fileVersion" @brandPublishArgs
|
||||
& dotnet publish (Join-Path $projectRoot $entry.Value) --configuration Release --runtime win-x64 --self-contained true --output (Join-Path $payloadRoot $entry.Key) '-p:Platform=x64' '-p:RestoreLockedMode=true' "-p:Version=$ReleaseVersion"
|
||||
if($LASTEXITCODE -ne 0) { throw "Publishing $($entry.Key) failed." }
|
||||
}
|
||||
if($brand)
|
||||
{
|
||||
# The companion carries the descriptor through its project; the service and CLI read the same file beside their executables.
|
||||
foreach($target in @('service', 'cli'))
|
||||
{
|
||||
Copy-Item -LiteralPath (Join-Path $projectRoot "editions\$Edition\edition.json") -Destination (Join-Path $payloadRoot "$target\edition.json") -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
if($brand)
|
||||
{
|
||||
foreach($target in @('app', 'service', 'cli'))
|
||||
{
|
||||
if(-not (Test-Path -LiteralPath (Join-Path $payloadRoot "$target\edition.json") -PathType Leaf)) { throw "The payload does not carry edition '$Edition' in $target; publish it with the same -Edition." }
|
||||
}
|
||||
foreach($asset in @('app\Assets\Brand\app-icon.svg', 'app\Assets\Brand\app.ico'))
|
||||
{
|
||||
if((Get-FileHash -LiteralPath (Join-Path $payloadRoot $asset) -Algorithm SHA256).Hash -cne (Get-FileHash -LiteralPath (Join-Path $projectRoot "editions\$Edition\$(Split-Path $asset -Leaf)") -Algorithm SHA256).Hash)
|
||||
{
|
||||
throw "The published $asset is not edition '$Edition' artwork."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$executables = @(
|
||||
@@ -144,14 +73,6 @@ foreach($executable in $executables)
|
||||
{
|
||||
throw "Published executable version does not match ReleaseVersion: $executable"
|
||||
}
|
||||
if($brand -and (Get-Item -LiteralPath $executable).VersionInfo.ProductName -cne $brand.displayName)
|
||||
{
|
||||
throw "Published executable does not carry edition '$Edition' product metadata: $executable"
|
||||
}
|
||||
if((Get-Item -LiteralPath $executable).VersionInfo.FileVersion -cne $fileVersion)
|
||||
{
|
||||
throw "Published executable file version does not match $fileVersion; Windows Installer would not replace an older equal file: $executable"
|
||||
}
|
||||
}
|
||||
|
||||
# A successful .NET publish does not guarantee a usable unpackaged WinUI application.
|
||||
@@ -167,14 +88,6 @@ foreach($resource in @('App.xbf', 'MainWindow.xbf', 'Qqq.PrintAgent.pri'))
|
||||
# Verify actual third-party terms and exclude build-only SDK payloads before WiX.
|
||||
& (Join-Path $PSScriptRoot 'Test-WindowsSdkInventory.ps1') -PayloadDirectory $payloadRoot
|
||||
|
||||
# The release workflow signs the published owned binaries between this stage and the MSI build,
|
||||
# then reruns this script with -SkipPublish so every check above also covers the signed payload.
|
||||
if($PublishOnly)
|
||||
{
|
||||
Write-Output "Published and checked payload for signing: $payloadRoot"
|
||||
return
|
||||
}
|
||||
|
||||
# A supplied certificate opts into signing; unsigned local builds are clearly labelled.
|
||||
if($CertificateThumbprint)
|
||||
{
|
||||
@@ -188,35 +101,12 @@ if($CertificateThumbprint)
|
||||
}
|
||||
|
||||
# WiX incremental inputs do not reliably invalidate a changed version/payload path.
|
||||
# WiX validates the MSI (ICE checks) through the Windows Installer service. A runner that logs on as a batch account cannot
|
||||
# open that service, so WiX fails every ICE action with WIX0217 ("The Windows Installer Service could not be accessed").
|
||||
# Validate whenever the service is reachable. Skip validation only when it is not, with a loud warning, and never for a
|
||||
# signed release build: that must be built on a host where validation works.
|
||||
$installerValidationArguments = @()
|
||||
try
|
||||
{
|
||||
$installerService = Get-Service -Name msiserver -ErrorAction Stop
|
||||
if($installerService.Status -ne 'Running')
|
||||
{
|
||||
$installerService.Start()
|
||||
$installerService.WaitForStatus('Running', [TimeSpan]::FromSeconds(30))
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if($CertificateThumbprint)
|
||||
{
|
||||
throw "The Windows Installer service is not reachable, so the MSI cannot be validated; refusing to build a signed release without validation. $($_.Exception.Message)"
|
||||
}
|
||||
Write-Warning "The Windows Installer service is not reachable from this session; building the MSI WITHOUT ICE validation (not a signed release build). $($_.Exception.Message)"
|
||||
$installerValidationArguments = @('-p:SuppressValidation=true')
|
||||
}
|
||||
& dotnet build $installerProject --configuration Release -t:Rebuild '-p:RestoreLockedMode=true' "-p:ProductVersion=$ProductVersion" "-p:PayloadRoot=$payloadRoot" "-p:OutputPath=$buildDirectory\" @brandInstallerArgs @installerValidationArguments
|
||||
& dotnet build $installerProject --configuration Release -t:Rebuild '-p:RestoreLockedMode=true' "-p:ProductVersion=$ProductVersion" "-p:PayloadRoot=$payloadRoot" "-p:OutputPath=$buildDirectory\"
|
||||
if($LASTEXITCODE -ne 0) { throw 'MSI build failed.' }
|
||||
$builtMsi = Join-Path $buildDirectory "$msiBaseName-$ProductVersion-x64.msi"
|
||||
$builtMsi = Join-Path $buildDirectory "QqqPrintAgent-$ProductVersion-x64.msi"
|
||||
if(-not (Test-Path -LiteralPath $builtMsi)) { throw 'MSI output was not produced.' }
|
||||
# Keep earlier verified installers available for recovery when WiX cleans its build outputs.
|
||||
$msi = Join-Path $outputDirectory "$msiBaseName-$ProductVersion-x64.msi"
|
||||
$msi = Join-Path $outputDirectory "QqqPrintAgent-$ProductVersion-x64.msi"
|
||||
Copy-Item -LiteralPath $builtMsi -Destination $msi -Force
|
||||
|
||||
if($CertificateThumbprint)
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $ProductVersion = '1.2.1',
|
||||
[string] $ReleaseVersion = '1.2.1',
|
||||
# Assemble artifacts/release from an already built MSI (the signed stable flow builds and signs it in
|
||||
# separate workflow steps). With ExpectedPublisherSubject the MSI must carry that publisher's signature.
|
||||
[switch] $AssembleOnly,
|
||||
[string] $ExpectedPublisherSubject
|
||||
)
|
||||
|
||||
# Repository entry point for the unsigned prerelease package: the same packaging checks and
|
||||
# artifact layout as .github/workflows/ci.yml, without building or running Core.Tests.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Get-Edition
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Lists the client editions under editions/ (folder id and release asset prefix), in name order.
|
||||
#>
|
||||
$root = Join-Path $projectRoot 'editions'
|
||||
if(-not (Test-Path -LiteralPath $root -PathType Container)) { return @() }
|
||||
$editions = @()
|
||||
foreach($folder in (Get-ChildItem -LiteralPath $root -Directory | Sort-Object Name))
|
||||
{
|
||||
$descriptor = Join-Path $folder.FullName 'edition.json'
|
||||
if(-not (Test-Path -LiteralPath $descriptor -PathType Leaf)) { throw "Edition folder $($folder.Name) has no edition.json." }
|
||||
$edition = Get-Content -LiteralPath $descriptor -Raw | ConvertFrom-Json
|
||||
if($edition.id -cne $folder.Name) { throw "edition.json id '$($edition.id)' must equal the folder name '$($folder.Name)'." }
|
||||
if($edition.assetPrefix -cnotmatch '^[A-Za-z][A-Za-z0-9]{1,39}$') { throw "Edition $($folder.Name) has an invalid assetPrefix." }
|
||||
$editions += [pscustomobject]@{ Id = $folder.Name; AssetPrefix = $edition.assetPrefix }
|
||||
}
|
||||
return $editions
|
||||
}
|
||||
|
||||
if($ExpectedPublisherSubject -and -not $AssembleOnly)
|
||||
{
|
||||
throw 'ExpectedPublisherSubject applies only to -AssembleOnly; signing runs in the release workflow between publish and MSI build.'
|
||||
}
|
||||
Push-Location $projectRoot
|
||||
try
|
||||
{
|
||||
# LASTEXITCODE is only set by native commands, so reset it before each script call: the checks
|
||||
# below then see this call's result (and the variable exists under StrictMode), never a stale value.
|
||||
# The scripts also throw on failure, which $ErrorActionPreference = 'Stop' turns into a failure here.
|
||||
if(-not $AssembleOnly)
|
||||
{
|
||||
$global:LASTEXITCODE = 0
|
||||
& (Join-Path $PSScriptRoot 'Test-WindowsSdkInventory.ps1') -VerifyPackages
|
||||
if($LASTEXITCODE -ne 0) { throw 'Windows SDK inventory verification failed.' }
|
||||
$global:LASTEXITCODE = 0
|
||||
& (Join-Path $PSScriptRoot 'Test-BrandedInstaller.ps1')
|
||||
if($LASTEXITCODE -ne 0) { throw 'Branded installer dry run failed.' }
|
||||
$global:LASTEXITCODE = 0
|
||||
& (Join-Path $PSScriptRoot 'Test-PackagingInputs.ps1')
|
||||
if($LASTEXITCODE -ne 0) { throw 'Packaging input checks failed.' }
|
||||
$global:LASTEXITCODE = 0
|
||||
& (Join-Path $PSScriptRoot 'Build-Installer.ps1') -ProductVersion $ProductVersion -ReleaseVersion $ReleaseVersion
|
||||
if($LASTEXITCODE -ne 0) { throw 'Installer build failed.' }
|
||||
foreach($edition in Get-Edition)
|
||||
{
|
||||
$global:LASTEXITCODE = 0
|
||||
& (Join-Path $PSScriptRoot 'Build-Installer.ps1') -ProductVersion $ProductVersion -ReleaseVersion $ReleaseVersion -Edition $edition.Id
|
||||
if($LASTEXITCODE -ne 0) { throw "Installer build failed for edition $($edition.Id)." }
|
||||
}
|
||||
}
|
||||
# The generic installer plus one per client edition (editions/<id>, named after its asset prefix). Every one
|
||||
# must exist and, for a signed release, carry the publisher's signature; an edition is never silently dropped.
|
||||
$installers = @([pscustomobject]@{ Prefix = 'QqqPrintAgent'; Label = 'generic' }) + @(Get-Edition | ForEach-Object { [pscustomobject]@{ Prefix = $_.AssetPrefix; Label = "edition $($_.Id)" } })
|
||||
$releaseDirectory = Join-Path $projectRoot 'artifacts/release'
|
||||
New-Item -ItemType Directory -Path $releaseDirectory -Force | Out-Null
|
||||
$sums = Join-Path $releaseDirectory 'SHA256SUMS'
|
||||
Remove-Item -LiteralPath $sums -Force -ErrorAction SilentlyContinue
|
||||
foreach($installer in $installers)
|
||||
{
|
||||
$builtMsi = Join-Path $projectRoot "artifacts/installer/$($installer.Prefix)-$ProductVersion-x64.msi"
|
||||
if(-not (Test-Path -LiteralPath $builtMsi -PathType Leaf)) { throw "Missing installer for the $($installer.Label) build: $builtMsi" }
|
||||
if($ExpectedPublisherSubject)
|
||||
{
|
||||
& (Join-Path $PSScriptRoot 'Sign-Release.ps1') -Operation Verify -Kind Msi -Root $builtMsi -ExpectedSubject $ExpectedPublisherSubject
|
||||
}
|
||||
$name = "$($installer.Prefix)-v$ReleaseVersion-x64.msi"
|
||||
Copy-Item -LiteralPath $builtMsi -Destination (Join-Path $releaseDirectory $name) -Force
|
||||
$hash = (Get-FileHash -LiteralPath (Join-Path $releaseDirectory $name) -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash $name" | Add-Content -LiteralPath $sums -Encoding ascii
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'Install-Branded.ps1') -Destination (Join-Path $releaseDirectory 'Install-Branded.ps1') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $projectRoot 'docs/branding.example.json') -Destination (Join-Path $releaseDirectory 'branding.example.json') -Force
|
||||
foreach($file in @('Install-Branded.ps1', 'branding.example.json'))
|
||||
{
|
||||
$hash = (Get-FileHash -LiteralPath (Join-Path $releaseDirectory $file) -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash $file" | Add-Content -LiteralPath $sums -Encoding ascii
|
||||
}
|
||||
Write-Output "$(if($ExpectedPublisherSubject) { 'Signed release' } else { 'Unsigned prerelease' }) package assembled in $releaseDirectory"
|
||||
}
|
||||
finally
|
||||
{
|
||||
Pop-Location
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stages, accepts and verifies the owned binaries and MSI of a signed stable release.
|
||||
.DESCRIPTION
|
||||
The release workflow signs files in a staging folder with the pinned Azure Artifact Signing action,
|
||||
which can only select by extension. Stage copies exactly the owned files, so a third-party signature
|
||||
is never replaced. Apply accepts a staged file only when it carries a valid, timestamped signature
|
||||
for the exact expected publisher subject, then copies it back over the original. Verify checks the
|
||||
final files in place. Nothing here signs, contacts Azure or needs a secret.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('Stage', 'Apply', 'Verify')]
|
||||
[string] $Operation,
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('Payload', 'Msi')]
|
||||
[string] $Kind,
|
||||
# Payload directory (service, app and cli folders) or the MSI file.
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Root,
|
||||
[string] $Stage,
|
||||
# Full X.500 subject of the verified publisher certificate.
|
||||
[string] $ExpectedSubject
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-OwnedFile
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Lists the files this repository owns and signs, relative to the root.
|
||||
#>
|
||||
param([string] $Kind, [string] $Root)
|
||||
if($Kind -eq 'Msi')
|
||||
{
|
||||
if(-not (Test-Path -LiteralPath $Root -PathType Leaf) -or [IO.Path]::GetExtension($Root) -cne '.msi') { throw 'Root must be an existing .msi file.' }
|
||||
return @([pscustomobject]@{ Relative = [IO.Path]::GetFileName($Root); Path = [IO.Path]::GetFullPath($Root) })
|
||||
}
|
||||
$base = [IO.Path]::GetFullPath($Root)
|
||||
$required = @('service/Qqq.PrintAgent.Service.exe', 'app/Qqq.PrintAgent.exe', 'cli/qqq-print-agent.exe')
|
||||
foreach($relative in $required)
|
||||
{
|
||||
if(-not (Test-Path -LiteralPath (Join-Path $base $relative) -PathType Leaf)) { throw "Missing owned executable: $relative" }
|
||||
}
|
||||
$found = foreach($directory in @('service', 'app', 'cli'))
|
||||
{
|
||||
foreach($pattern in @('Qqq.PrintAgent*.exe', 'Qqq.PrintAgent*.dll', 'qqq-print-agent.exe', 'qqq-print-agent.dll', 'KofTwentyTwo.AppKit*.dll'))
|
||||
{
|
||||
Get-ChildItem -LiteralPath (Join-Path $base $directory) -Filter $pattern -File -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@($found | Sort-Object FullName -Unique | ForEach-Object { [pscustomobject]@{ Relative = [IO.Path]::GetRelativePath($base, $_.FullName).Replace('\', '/'); Path = $_.FullName } })
|
||||
}
|
||||
|
||||
function Get-SignTool
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Finds the Windows SDK SignTool, preferring PATH and then the newest installed x64 copy.
|
||||
#>
|
||||
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
|
||||
if($command) { return $command.Source }
|
||||
$kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/bin'
|
||||
$candidate = Get-ChildItem -Path (Join-Path $kits '*/x64/signtool.exe') -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if(-not $candidate) { throw 'signtool.exe was not found; install the Windows SDK signing tools.' }
|
||||
$candidate.FullName
|
||||
}
|
||||
|
||||
function Assert-Signed
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Requires a trusted, SHA256, RFC3161-timestamped signature by exactly the expected publisher subject.
|
||||
#>
|
||||
param([string] $Path)
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $Path
|
||||
if($signature.Status -ne 'Valid') { throw "Signature is not valid ($($signature.Status)): $Path" }
|
||||
if($signature.SignerCertificate.Subject -cne $ExpectedSubject) { throw "Signer subject does not match the verified publisher: $Path" }
|
||||
if($null -eq $signature.TimeStamperCertificate) { throw "Signature has no trusted timestamp: $Path" }
|
||||
$global:LASTEXITCODE = 0
|
||||
$output = (& (Get-SignTool) verify /pa /all /v $Path 2>&1) -join "`n"
|
||||
if($LASTEXITCODE -ne 0) { throw "signtool verify /pa /all /v failed: $Path" }
|
||||
# Match only the file-digest lines ("Hash of file (sha256): ..."), never text that names an algorithm
|
||||
# elsewhere, such as the certificate chain. Exactly one signature with no warnings or errors is required.
|
||||
$digests = @([regex]::Matches($output, '(?m)^\s*Hash of file \((?<algorithm>[^)\r\n]+)\):').ForEach({ $_.Groups['algorithm'].Value }))
|
||||
if($digests.Count -eq 0 -or @($digests | Where-Object { $_ -cne 'sha256' }).Count -gt 0) { throw "File digest is not SHA256 on every signature: $Path" }
|
||||
if($output -notmatch '(?m)^Number of signatures successfully Verified: 1\s*$' -or $output -notmatch '(?m)^Number of warnings: 0\s*$' -or $output -notmatch '(?m)^Number of errors: 0\s*$')
|
||||
{ throw "signtool did not report exactly one verified signature with zero warnings and errors: $Path" }
|
||||
}
|
||||
|
||||
if($Operation -ne 'Stage' -and [string]::IsNullOrWhiteSpace($ExpectedSubject))
|
||||
{
|
||||
throw 'ExpectedSubject (the full verified certificate subject) is required to accept or verify signatures.'
|
||||
}
|
||||
$files = @(Get-OwnedFile -Kind $Kind -Root $Root)
|
||||
if($files.Count -eq 0) { throw 'No owned files were found to sign.' }
|
||||
|
||||
switch($Operation)
|
||||
{
|
||||
'Stage'
|
||||
{
|
||||
if(-not $Stage) { throw 'Stage requires a Stage directory.' }
|
||||
$stageRoot = [IO.Path]::GetFullPath($Stage)
|
||||
if((Test-Path -LiteralPath $stageRoot) -and @(Get-ChildItem -LiteralPath $stageRoot -Force).Count -gt 0) { throw 'Stage must be a new or empty directory.' }
|
||||
foreach($file in $files)
|
||||
{
|
||||
$destination = Join-Path $stageRoot $file.Relative
|
||||
New-Item -ItemType Directory -Path (Split-Path $destination -Parent) -Force | Out-Null
|
||||
Copy-Item -LiteralPath $file.Path -Destination $destination
|
||||
}
|
||||
Write-Output "Staged $($files.Count) owned file(s) for signing."
|
||||
}
|
||||
'Apply'
|
||||
{
|
||||
if(-not $Stage) { throw 'Apply requires the signed Stage directory.' }
|
||||
$stageRoot = [IO.Path]::GetFullPath($Stage)
|
||||
$staged = @(Get-ChildItem -LiteralPath $stageRoot -Recurse -File | ForEach-Object { [IO.Path]::GetRelativePath($stageRoot, $_.FullName).Replace('\', '/') })
|
||||
if((@($staged | Sort-Object) -join '|') -cne (@($files.Relative | Sort-Object) -join '|')) { throw 'The signed stage does not contain exactly the staged owned files.' }
|
||||
foreach($file in $files)
|
||||
{
|
||||
$signed = Join-Path $stageRoot $file.Relative
|
||||
Assert-Signed $signed
|
||||
if((Get-FileHash -LiteralPath $signed -Algorithm SHA256).Hash -ceq (Get-FileHash -LiteralPath $file.Path -Algorithm SHA256).Hash) { throw "Staged file was not changed by signing: $($file.Relative)" }
|
||||
}
|
||||
foreach($file in $files) { Copy-Item -LiteralPath (Join-Path $stageRoot $file.Relative) -Destination $file.Path -Force }
|
||||
foreach($file in $files) { Assert-Signed $file.Path }
|
||||
Write-Output "Applied and verified $($files.Count) signed file(s)."
|
||||
}
|
||||
'Verify'
|
||||
{
|
||||
foreach($file in $files) { Assert-Signed $file.Path }
|
||||
Write-Output "Verified $($files.Count) signed file(s)."
|
||||
}
|
||||
}
|
||||
@@ -26,44 +26,9 @@ function Assert-PackageRejected([string] $Product, [string] $Release, [string] $
|
||||
Assert-PackageRejected -Product $current -Release '999.0.0-rc.1' -Expected 'numeric core'
|
||||
Assert-PackageRejected -Product '256.0.0' -Release '256.0.0-rc.1' -Expected 'Windows Installer limits'
|
||||
Assert-PackageRejected -Product '0.0.0' -Release '0.0.0-rc.1' -Expected 'reviewed source VersionPrefix'
|
||||
Assert-PackageRejected -Product $current -Release "$current-beta.1" -Expected 'Unsupported ReleaseVersion'
|
||||
Assert-PackageRejected -Product $current -Release "$current-rc.65535" -Expected 'Unsupported ReleaseVersion'
|
||||
Assert-PackageRejected -Product $current -Release "$current-rc.0" -Expected 'Unsupported ReleaseVersion'
|
||||
Assert-PackageRejected -Product $current -Release "$current-rc.1" -Expected 'explicit reviewed PayloadDirectory'
|
||||
$message = ''
|
||||
try { & $builder -ProductVersion $current -ReleaseVersion "$current-rc.1" -PayloadDirectory $PSScriptRoot }
|
||||
catch { $message = $_.Exception.Message }
|
||||
if($message -notlike '*requires a new PayloadDirectory*') { throw 'Packaging did not reject an existing publish directory.' }
|
||||
$message = ''
|
||||
try { & $builder -ProductVersion $current -ReleaseVersion "$current-rc.1" -PublishOnly }
|
||||
catch { $message = $_.Exception.Message }
|
||||
if($message -notlike '*PublishOnly requires a new PayloadDirectory*') { throw 'Packaging did not reject PublishOnly without a payload directory.' }
|
||||
$message = ''
|
||||
try { & (Join-Path $PSScriptRoot 'Sign-Release.ps1') -Operation Verify -Kind Payload -Root $PSScriptRoot }
|
||||
catch { $message = $_.Exception.Message }
|
||||
if($message -notlike '*ExpectedSubject*required*') { throw 'Signature verification did not require the verified publisher subject.' }
|
||||
# Stage synthetic file names only: the owned set includes the hyphenated CLI managed DLL and excludes third-party files.
|
||||
$fake = Join-Path ([IO.Path]::GetTempPath()) ('qqq-sign-stage-' + [Guid]::NewGuid().ToString('N'))
|
||||
try
|
||||
{
|
||||
foreach($name in @('service/Qqq.PrintAgent.Service.exe', 'app/Qqq.PrintAgent.exe', 'cli/qqq-print-agent.exe', 'cli/qqq-print-agent.dll', 'cli/Third.Party.dll'))
|
||||
{
|
||||
$path = Join-Path (Join-Path $fake 'payload') $name
|
||||
New-Item -ItemType Directory -Path (Split-Path $path -Parent) -Force | Out-Null
|
||||
Set-Content -LiteralPath $path -Value 'synthetic'
|
||||
}
|
||||
& (Join-Path $PSScriptRoot 'Sign-Release.ps1') -Operation Stage -Kind Payload -Root (Join-Path $fake 'payload') -Stage (Join-Path $fake 'stage') | Out-Null
|
||||
$staged = @(Get-ChildItem -LiteralPath (Join-Path $fake 'stage') -Recurse -File | ForEach-Object { [IO.Path]::GetRelativePath((Join-Path $fake 'stage'), $_.FullName).Replace('\', '/') } | Sort-Object)
|
||||
if(($staged -join '|') -cne 'app/Qqq.PrintAgent.exe|cli/qqq-print-agent.dll|cli/qqq-print-agent.exe|service/Qqq.PrintAgent.Service.exe')
|
||||
{ throw 'Signing stage must include the hyphenated CLI managed DLL and exclude third-party files.' }
|
||||
}
|
||||
finally
|
||||
{
|
||||
# Delete only a resolved absolute path strictly beneath the resolved temp directory; otherwise keep it and fail.
|
||||
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
|
||||
$resolved = [IO.Path]::GetFullPath($fake)
|
||||
if(-not $resolved.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or $resolved.Length -le $tempRoot.Length)
|
||||
{ throw "Refusing to delete a synthetic stage outside the temp directory: $resolved" }
|
||||
if(Test-Path -LiteralPath $resolved) { Remove-Item -LiteralPath $resolved -Recurse -Force }
|
||||
}
|
||||
Write-Output 'Packaging rejects inconsistent source/release/MSI versions before publishing payloads.'
|
||||
@@ -86,26 +86,6 @@ Describe 'Exact Windows SDK inventory guard' `
|
||||
{ & $script:Guard -RepositoryRoot $script:Fixture } | Should -Throw '*only the 15 exact reviewed package names*'
|
||||
}
|
||||
|
||||
It 'rejects any other npm or versioned entry beside the reviewed EX-0015 uri-js: <Replacement>' -ForEach @(
|
||||
@{ Replacement = 'pkg:npm/other' },
|
||||
@{ Replacement = 'pkg:npm/uri-js@4.4.1' },
|
||||
@{ Replacement = 'pkg:pypi/uri-js' },
|
||||
@{ Replacement = "pkg:npm/uri-js,`n pkg:npm/uri-js" },
|
||||
@{ Replacement = "pkg:npm/uri-js,`n pkg:npm/other" }
|
||||
) `
|
||||
{
|
||||
$path = Join-Path $script:Fixture '.github/workflows/pr-gates.yml'
|
||||
[IO.File]::WriteAllText($path, [IO.File]::ReadAllText($path).Replace(' pkg:npm/uri-js', " $Replacement"))
|
||||
{ & $script:Guard -RepositoryRoot $script:Fixture } | Should -Throw '*plus the EX-0015 pkg:npm/uri-js entry*'
|
||||
}
|
||||
|
||||
It 'rejects the SDK allowance without the reviewed EX-0015 entry' `
|
||||
{
|
||||
$path = Join-Path $script:Fixture '.github/workflows/pr-gates.yml'
|
||||
[IO.File]::WriteAllText($path, [regex]::Replace([IO.File]::ReadAllText($path), ',\s*pkg:npm/uri-js\b', ''))
|
||||
{ & $script:Guard -RepositoryRoot $script:Fixture } | Should -Throw '*plus the EX-0015 pkg:npm/uri-js entry*'
|
||||
}
|
||||
|
||||
It 'rejects a changed manifest even when locks are changed to match' `
|
||||
{
|
||||
($script:Manifest.packages | Where-Object id -EQ 'Microsoft.WindowsAppSDK.ML').version = '2.1.95'
|
||||
|
||||
@@ -67,11 +67,9 @@ $allowanceBlock = [regex]::Matches($workflow, '(?m)^ allow-dependencies-lic
|
||||
if($allowanceKeys.Count -ne 1 -or $allowanceBlock.Count -ne 1)
|
||||
{ throw 'Exactly one explicit SDK license allowance block is required.' }
|
||||
$allowed = @($allowanceBlock[0].Groups[1].Value.Split(',') | ForEach-Object Trim | Sort-Object -CaseSensitive)
|
||||
# EX-0015 adds exactly one development-only npm purl; check-script-quality.py pins its version,
|
||||
# lock integrity and license bytes. Any other ecosystem, package or versioned purl stays rejected here.
|
||||
$expectedAllowances = @(@($expectedIds | ForEach-Object { "pkg:nuget/$_" }) + 'pkg:npm/uri-js' | Sort-Object -CaseSensitive)
|
||||
$expectedAllowances = @($expectedIds | ForEach-Object { "pkg:nuget/$_" } | Sort-Object -CaseSensitive)
|
||||
if(($allowed -join ',') -cne ($expectedAllowances -join ','))
|
||||
{ throw 'SDK license allowance must contain only the 15 exact reviewed package names plus the EX-0015 pkg:npm/uri-js entry.' }
|
||||
{ throw 'SDK license allowance must contain only the 15 exact reviewed package names.' }
|
||||
[string[]]$lockedIds = @($expectedIds | Where-Object { $_ -cne $referenceId })
|
||||
[xml]$central = Get-Content -LiteralPath (Join-Path $root 'Directory.Packages.props') -Raw
|
||||
foreach($id in $directIds)
|
||||
|
||||
@@ -14,13 +14,6 @@ if(-not (Test-Path -LiteralPath $protocolFixtures))
|
||||
{
|
||||
throw 'Check out the pinned qqq-print-protocol repository beside qqq-print-agent before running verification.'
|
||||
}
|
||||
foreach($fixture in @('claim.response.json', 'enroll.response.json', 'control-poll.response.json', 'printing-session.response.json', 'printing-session-bound.response.json'))
|
||||
{
|
||||
if(-not (Test-Path -LiteralPath (Join-Path $ProtocolFixtures $fixture) -PathType Leaf))
|
||||
{
|
||||
throw "Required protocol fixture is missing: $fixture. Use the protocol revision pinned in .github/workflows/ci.yml, or pass its fixtures directory with -ProtocolFixtures."
|
||||
}
|
||||
}
|
||||
Push-Location $projectRoot
|
||||
try
|
||||
{
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
is run separately and must not be inferred from a successful unit-test run.
|
||||
Report each suite separately; duplicate package measurements must not be averaged. -->
|
||||
<RunSettings>
|
||||
<!-- A test run that executes zero tests (for example, a test assembly blocked from loading) must fail, not pass. -->
|
||||
<RunConfiguration>
|
||||
<TreatNoTestsAsError>true</TreatNoTestsAsError>
|
||||
</RunConfiguration>
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<DataCollector friendlyName="XPlat Code Coverage">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Administrator operations
|
||||
|
||||
These instructions describe client **1.0.1** with qbit-printing **1.0.0**, native printing API **2.3** and wire payload **v1**. See [realtime architecture and settings](REALTIME.md) for the coordinated server requirement.
|
||||
These transport and capacity instructions describe release candidate `0.3.0-rc.1`.
|
||||
The published `0.2.0-rc.1` installer uses its earlier native HTTP transport.
|
||||
See [realtime architecture and settings](REALTIME.md) for the coordinated server requirement.
|
||||
|
||||
## Service and data
|
||||
|
||||
@@ -12,8 +14,7 @@ These instructions describe client **1.0.1** with qbit-printing **1.0.0**, nativ
|
||||
| Credentials | Device proof and QQQ session protected separately with Windows DPAPI CurrentUser under LocalSystem |
|
||||
| Submission journal | `journal.db` and SQLite WAL/SHM files |
|
||||
| Worker coordination | Exclusive `worker.lock` in the data directory |
|
||||
| Desktop preferences | AppKit's per-user `qqq-print-agent` directory |
|
||||
| Shared service/companion logs | `%ProgramData%\QqqPrintAgent\logs` |
|
||||
| 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.
|
||||
|
||||
@@ -26,7 +27,7 @@ Get-Service QqqPrintAgent, Spooler
|
||||
|
||||
Use the actual install path if it differs. Normal CLI commands and the companion use the authenticated installed service. Its printer list reflects the service identity; explicit isolated legacy diagnostics may see different queues under their invoking account.
|
||||
|
||||
Use the built-in AppKit activity log for shared service and companion events, with source labels and severity filtering. Windows Event Viewer can also help diagnose service-host startup failures. Record relevant timestamps, job IDs and error text without customer document contents.
|
||||
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
|
||||
|
||||
|
||||
+2
-18
@@ -1,22 +1,6 @@
|
||||
# Customer branding
|
||||
|
||||
There are two levels. A **client edition** is a separate, fully branded build and installer: its own mark, icon and logo, the client's product and company in every executable and in Windows, and its own installer and update asset. **Deployment branding** applies labels, a colour and artwork to the shared installer at install time. Editions are for clients who get their own installer; deployment branding is for customers of the shared one.
|
||||
|
||||
## Client editions
|
||||
|
||||
Add a folder under `editions/` (see [editions/README.md](../editions/README.md)) with `edition.json`, `app-icon.svg`, `app.ico` and an optional `logo.png`, then build with:
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.2.0 -ReleaseVersion 1.2.0 -Edition gg-forge
|
||||
```
|
||||
|
||||
The result is `artifacts/installer/<assetPrefix>-<version>-x64.msi`, for example `GgForgePrintAgent-1.2.0-x64.msi`. Installed, it shows the client's name and mark on the splash, About, window title, tray tooltip, Start menu, Programs list and service; Task Manager and file properties show the client's product, company and description; and the in-app updater follows `<assetPrefix>-v<version>-x64.msi` on the release feed. CI builds every edition's unsigned installer, and the stable release workflow signs and publishes one MSI per edition beside the generic one, all listed in the same `SHA256SUMS`. An edition installer forces its client's labels over any labels an earlier install stored.
|
||||
|
||||
An edition's descriptor can also name the server as its users know it (`serverName`, used in sign-in and status text instead of QQQ) and leave the Query mascot and License sections out of About (`aboutMascot`, `aboutLicense`); the generic build keeps all of them. Editions share application id, data paths, executable file names, the install directory, the protocol handler, the MSI upgrade identity and the signing identity, so an edition installer upgrades the generic build and other editions in place and keeps the pairing and history. Deployment branding below still applies on top of an edition.
|
||||
|
||||
## Deployment branding
|
||||
|
||||
Use the same [signed stable machine MSI](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) for every customer. No customer-specific build is required. The deployment wrapper stages an optional machine branding file and local assets, then supplies customer labels to Windows Installer. The companion, service display name, installed-product labels and all-users Start menu shortcut use that identity.
|
||||
Use the same released machine MSI for every customer. No customer-specific build is required. The deployment wrapper stages an optional machine branding file and local assets, then supplies customer labels to Windows Installer. The companion, service display name, installed-product labels and all-users Start menu shortcut use that identity.
|
||||
|
||||
## Install with a customer name
|
||||
|
||||
@@ -37,7 +21,7 @@ Use PowerShell 7.4 or newer. First validate a downloaded MSI and configuration w
|
||||
./build/Install-Branded.ps1 -MsiPath 'C:\Deployment\QqqPrintAgent.msi' -BrandingFile './docs/branding.example.json' -WhatIf
|
||||
```
|
||||
|
||||
Run the same command **without `-WhatIf` from an elevated administrator PowerShell** to install. The script does not silently elevate or bypass UAC. Supply `-ExpectedSHA256 '<64 hexadecimal digits>'` when you have the expected MSI checksum from your approved release source. The wrapper verifies it before installation and keeps the source MSI read-locked through execution. A checksum does not substitute for publisher signing; verify the stable MSI signature as described in [Installation](INSTALLATION.md#download-and-verify). Customer branding does not change the Authenticode publisher or update feed.
|
||||
Run the same command **without `-WhatIf` from an elevated administrator PowerShell** to install. The script does not silently elevate or bypass UAC. Supply `-ExpectedSHA256 '<64 hexadecimal digits>'` when you have the expected MSI checksum from your approved release source. The wrapper verifies it before installation and keeps the source MSI read-locked through execution. A checksum does not substitute for publisher signing.
|
||||
|
||||
The wrapper launches `msiexec.exe` with `/i`, `/passive` and `/norestart`, using separate arguments. It accepts success/reboot codes 0, 1641 and 3010 and reports the result. It creates `%ProgramData%\QqqPrintAgent` with SYSTEM/Administrators permissions when absent, or rejects unsafe existing permissions. It never broadens access to existing device state. It copies validated assets under unique names, replaces `branding.json`, and restores the previous configuration if Windows Installer fails. The client/service/enrollment remain one coherent MSI installation.
|
||||
|
||||
|
||||
+4
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
## Architecture
|
||||
|
||||
Current source is version `1.0.1` (a packaging fix; see CHANGELOG.md), with native printing 2.3 (wire payload v1).
|
||||
Current source prepares release candidate `0.3.0-rc.1`, with printing extension 2.0.
|
||||
See [realtime architecture](REALTIME.md) for worker separation, policy, streaming
|
||||
and reconnect rules. The service never falls back from WSS to job/control polling.
|
||||
|
||||
@@ -16,7 +16,7 @@ and reconnect rules. The service never falls back from WSS to job/control pollin
|
||||
|
||||
The service owns the credential, journal and print operations. The UI is optional at runtime and sees only credential-free snapshots. Service IPC uses the `QqqPrintAgent` named pipe and accepts administrator clients. `snapshot` returns connection health, all locally discovered printers with exposure state, the latest 50 jobs and the latest 100 in-memory log entries. `set-exposure` persists a selected queue and returns a fresh snapshot. Legacy `status`, `printers` and `enroll` operations remain available.
|
||||
|
||||
The cloud API lives in [qqq-print-protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol). Its inventory is the complete currently exposed printer set. The server separately owns authorization/approval. Removing local exposure filters inventory and prevents new local submission to that queue. It cannot retract a job already handed to Windows.
|
||||
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
|
||||
|
||||
@@ -30,7 +30,7 @@ This performs locked restore, Release solution build, core tests and Windows/CLI
|
||||
|
||||
## Local server and CLI
|
||||
|
||||
Use a disposable native QQQ host from the sibling qbit-printing project, using qbit-printing 1.0.0 on QQQ 4.1.0-RC.21 and advertising native printing API 2.3. Sign in through the companion's advertised QQQ authentication flow before using normal CLI commands. The service owns the session, device proof and unattended printing. A bearer-only demo is not a compatible realtime host.
|
||||
Use a disposable native QQQ host from the sibling qbit-printing project, advertising printing extension 2.0. Sign in through the companion's advertised QQQ authentication flow before using normal CLI commands. The service owns the session, device proof and unattended printing. A bearer-only demo is not a compatible realtime host.
|
||||
|
||||
```powershell
|
||||
$agent = '.\src\Qqq.PrintAgent.Cli\bin\x64\Debug\net10.0-windows10.0.19041.0\qqq-print-agent.exe'
|
||||
@@ -58,15 +58,11 @@ The worker persists receipt and SUBMITTING before invoking Windows. It records S
|
||||
## Packaging
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.3.0 -ReleaseVersion 0.3.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.
|
||||
|
||||
`./build/Package-Release.ps1` (defaults `1.0.1` / `1.0.1`) is the repository entry point for the unsigned local package; the signed stable package is built by the release workflow. It runs the same packaging checks as `.github/workflows/ci.yml` (`Test-WindowsSdkInventory.ps1 -VerifyPackages`, `Test-BrandedInstaller.ps1`, `Test-PackagingInputs.ps1`, `Build-Installer.ps1`) and assembles `artifacts/release` with the versioned MSI, `Install-Branded.ps1`, `branding.example.json` and `SHA256SUMS`. It does not build or run Core.Tests.
|
||||
|
||||
`Qqq.PrintAgent.NativeCI.slnx` is `Qqq.PrintAgent.slnx` without the `Core.Tests` project, for hosts that must not build that assembly (for example `dotnet build Qqq.PrintAgent.NativeCI.slnx --configuration Release '-p:Platform=x64' -warnaserror`, then `dotnet test tests/Qqq.PrintAgent.Cli.Tests`). `Qqq.PrintAgent.slnx` and the test projects are unchanged, so `Verify.ps1` still covers Core.Tests; run those tests on a clean CI runner.
|
||||
|
||||
## Machine update integration
|
||||
|
||||
`MainWindow.Updates` wires AppKit `UpdateCoordinator`/`ContentDialogPrompter` to Windows `MsiUpdateService : IUpdateService`. The persisted startup-check preference, version/channel display, splash duration and help/credits use the shared shell. The UI serializes manual/startup checks; confirmation is required before apply.
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# Printing cohort handoff for the Forge agent — 2026-11-05
|
||||
|
||||
Reusable changes were made in the printer client and the printing QBit repositories. Existing
|
||||
authentication, discovery, enrollment, inventory and printing contracts are preserved; there are
|
||||
**no breaking changes**. Nothing was installed over Forge's working client or dependencies; adoption,
|
||||
pinning and the pairing tests (browser sign-in, session resume, enrollment, server printing,
|
||||
TC22 → Echo → Z2) are yours. The previous cohort stays available for rollback.
|
||||
|
||||
## Exact versions and artifacts
|
||||
|
||||
| Component | Version | Tag / source | Artifact | SHA-256 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Protocol (contract + fixtures) | 1.1.0 | `v1.1.0` at `5005f3c9e27053b6c327bb923695012832c0fdcc` | https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0 — `qqq-print-protocol-1.1.0.tar.gz` | `541f0f3054cbfe9ce16e7dea58fad01b55a7f0e9246ade8f30df1bb826940703` |
|
||||
| Printing QBit (server) | **1.1.2**, native printing extension **2.4.0**, on QQQ **4.1.0-RC.22** from the Gitea registry (1.1.1 is the same code on RC.21 from Maven Central; 1.1.0 is superseded: do not adopt it) | `v1.1.2` at `f5639a98be9a2f15d4c61cf0955fe4ecef85824f`, push build 6435 + tag build 6475 | Maven `com.kingsrook.qbits:qbit-printing:1.1.2` from `https://git.kof22.com/api/packages/QRun-IO/maven` (repository id `qrun-io`, reader token); same bytes on https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2 | JAR `786e6e570c5df29b01529562efb246a4f4562bb1d9fff6bea77d264e0d7536ce`, POM `7691a4e1cb7b03c7a559b0e2b92eacd92f86545b98bddafac86fc70f8602b03d` |
|
||||
| Windows print client | **1.2.1** (1.2.0 content + release-pipeline fix; tag v1.2.0 produced no installer) | `v1.2.1` at `3b9d75d18f9cc8b80f4cf6d5aae81cccf8d61243`, GitHub release run 42223525626 | Signed installers: `QqqPrintAgent-v1.2.1-x64.msi` (generic) and `GgForgePrintAgent-v1.2.1-x64.msi` (GG Forge edition), `SHA256SUMS`, on https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1 and byte-identical on https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1 | generic `09920872b3447f8679b404f2b5ebd7d9fe82204b8007db63d58e4287f436f2eb`, GG Forge `5ce489092c1403449174b8a35eda039cd452e8685cbfe436d99aea3e15a5d8b4`; Authenticode valid (CN=James Maes, Microsoft timestamp); GitHub provenance attestation verified |
|
||||
|
||||
Previous cohort for rollback: protocol 1.0.0, QBit 1.0.0 (extension 2.3.0), Windows 1.0.1 / 1.1.1.
|
||||
|
||||
**2026-11-06 update (QBit 1.1.1).** Forge's qualification of 1.1.0 found that the realtime upgrade fails before WELCOME behind an ingress that lowercases header names (the device id was read case-sensitively from the WebSocket context; the same defect exists in 1.0.0, which Forge ran with a local patch). 1.1.1 resolves the device once on the authorized upgrade and carries it on the connection, with a regression test that connects with lowercase header names. No contract change: the pairing matrix and everything else in this handoff is unchanged; replace the `1.0.0-forge…headers1` coordinate with the published 1.1.1 and drop the local patch.
|
||||
|
||||
**2026-11-06 update (QBit 1.1.2, Gitea-only runtime).** James's direction: every QRun artifact resolves from the QRun-IO Gitea registry, no more Maven Central. 1.1.2 is the 1.1.1 code certified on QQQ **4.1.0-RC.22** (the core version Forge already pins; it exists only in the Gitea registry) with `-Pui-demo verify`, 200 tests, 0 failures, and published by the shared `ci-workflows` pipeline. `qqq-frontend-next` 1.0.0-RC.37 is also mirrored into the registry byte-for-byte from Central. Pin `com.kingsrook.qbits:qbit-printing:1.1.2` together with core 4.1.0-RC.22 and read both from `https://git.kof22.com/api/packages/QRun-IO/maven`; third-party libraries still come through the Maven Central mirror until the registry proxies them. No contract change; pairing matrix unchanged.
|
||||
|
||||
## What changed, by contract
|
||||
|
||||
- **Printer approval over the realtime channel (extension 2.4.0, additive).** Client advertises
|
||||
`printer-approval-v1` in HELLO only when discovery shows a stable printing 2.x with minor >= 4.
|
||||
Server then sends a `PRINTERS` frame (`{ "printers": [ { "printerId", "enabled" } ] }`, the complete
|
||||
approval state of the device's exposed printers) after each accepted INVENTORY and whenever an
|
||||
administrator approves or disables one of that device's printers. Not acknowledged. A 2.3 peer on
|
||||
either side keeps the exact 1.0 wire: 1.0.x/1.1.x clients never advertise it and never receive it;
|
||||
the 1.2.1 client against QBit 1.0.0 reads the operator catalog instead (user-scoped; shows "Not
|
||||
reported" without operator permission).
|
||||
- **Remove printers / Remove device (QBit).** Archives with `removed_at`; hidden from lists, catalog,
|
||||
claims and the PRINTERS frame; a re-shared printer returns as awaiting approval; retention purges
|
||||
removed rows that no job references. QQQ 4.1 has no table-level default filter, so the raw Printers
|
||||
/ Print devices tables still show removed rows until a "Removed at is empty" saved view is added.
|
||||
- **Client sign-in nonce.** Sends `expectedNonce` under REQUIRED, OPTIONAL and the discovery capability
|
||||
`nativeSessionNonceValidation`; legacy servers unchanged (PR 11 content, already merged earlier).
|
||||
- **Client UX/ops.** Server column on the Printers page; verbose sign-in diagnostics with DEBUG level;
|
||||
log size rollover; per-server untrusted-certificate option (development only, origin-bound);
|
||||
connection dialog rework; Clear logs button in the log viewer; client editions (GG Forge build is a
|
||||
separate MSI with its own update asset; one edition per PC, shared UpgradeCode).
|
||||
|
||||
## Pairing matrix (what must hold in your tests)
|
||||
|
||||
| Server | Client | Expected |
|
||||
| --- | --- | --- |
|
||||
| QBit 1.0.0 (2.3.0) | 1.2.1 | No `printer-approval-v1` in HELLO; catalog fallback; everything else as 1.1.1 |
|
||||
| QBit 1.1.2 (2.4.0) | 1.0.1 / 1.1.1 | Four-capability HELLO at most; no PRINTERS frame; unchanged behaviour |
|
||||
| QBit 1.1.2 (2.4.0) | 1.2.1 | Five-capability HELLO; PRINTERS after inventory and on approval change; Printers page shows Approved / Awaiting approval live |
|
||||
|
||||
## Evidence
|
||||
|
||||
- Protocol: `npm run check` (79 schema definitions, 82 fixtures), lint, typecheck, quality all green on 5005f3c.
|
||||
- QBit: `mvnw -Pui-demo verify` 199 tests, 0 failures, 0 Checkstyle; main run 5473 verify + secrets + gate + post-publish green; registry files read back byte-for-byte.
|
||||
- Client: hosted GitHub CI run 42116396114 on the PR head (197 Core + 362 Windows tests, 0 failures); Gitea required checks green on both merges; release run 42223525626 (verify, security, CodeQL, sign generic + GG Forge, assemble, publish).
|
||||
|
||||
## Notes for adoption
|
||||
|
||||
- Pin all three together. The client's CI fixture pin is `ac835f8b9396b641b525669249fdb4a1e2a50c99`; the QBit's is `5005f3c9e27053b6c327bb923695012832c0fdcc` (docs PRs 29 and 12 pending).
|
||||
- Forge must approve the shared printers on the server after the first inventory from a 1.2.0 client, as before; the difference is that the client now shows that state.
|
||||
- Coordinate installation on the Forge print station yourself; the GG Forge rc.3 build on James's PC is a local test build (file version 1.2.0.3) and will update to the signed 1.2.1 from its own GG Forge asset.
|
||||
+11
-12
@@ -2,15 +2,14 @@
|
||||
|
||||
## Download and verify
|
||||
|
||||
Download the x64 MSI and `SHA256SUMS` from the same [stable Gitea release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1). Matching assets remain on [GitHub](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) for the installed updater. Check the file locally:
|
||||
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-v1.0.1-x64.msi
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath .\QqqPrintAgent-v0.3.0-rc.1-x64.msi
|
||||
Get-Content .\SHA256SUMS
|
||||
Get-AuthenticodeSignature -LiteralPath .\QqqPrintAgent-v1.0.1-x64.msi | Format-List Status, SignerCertificate, TimeStamperCertificate
|
||||
```
|
||||
|
||||
Use the filename of the release you downloaded and compare the complete hash with its `SHA256SUMS` entry. Stable **1.0.1** signs the MSI, companion, service and CLI through Azure Artifact Signing. Authenticode verification should report **Valid** with publisher **James Maes** and a timestamp. The release also provides build-provenance attestations. Checksums, publisher signatures and provenance establish different facts; see [Release signing](RELEASE-SIGNING.md). Earlier unsigned prereleases retain their historical status. Download only official assets and follow your organization's software-install policy.
|
||||
Use the filename of the release you downloaded and compare the complete hash with its `SHA256SUMS` entry. Current release candidate: [0.3.0-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.0-rc.1). Checksums detect changes after publication; they do not replace publisher signing. The MSI is **unsigned**, with no Authenticode identity. The current release workflow also produces GitHub build-provenance attestations, which are verified separately from checksums and are not code signing. Older releases may lack those attestations; their presence must be checked for the exact downloaded asset. Download only from the project's release page and follow your organization's software-install policy.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -21,7 +20,7 @@ Run the MSI as an administrator. Windows Installer deploys one coherent applicat
|
||||
- The `qqq-print-agent.exe` diagnostic CLI.
|
||||
- A restricted `%ProgramData%\QqqPrintAgent` directory for enrollment and the job journal.
|
||||
|
||||
There is no inbound firewall rule or local HTTP server. The client requires outbound WSS upgrades and HTTPS to the QQQ server; update checks/downloads use HTTPS to GitHub release services. Use the matching qbit-printing 1.0.0 server with native printing API 2.3. There is no job/control polling fallback. The UI requests administrator elevation because its management pipe permits only SYSTEM and local administrators.
|
||||
There is no inbound firewall rule or local HTTP server. The 0.3 realtime client requires outbound WSS upgrades and HTTPS to the QQQ server; update checks/downloads use HTTPS to GitHub release services. Upgrade the matching server first; it must advertise printing extension 2.0. The previous 0.2 transport is HTTP-based. The UI requests administrator elevation because its management pipe permits only SYSTEM and local administrators.
|
||||
|
||||
## Service recovery
|
||||
|
||||
@@ -55,13 +54,13 @@ This bounds a blocked printer driver without stopping cloud connectivity permane
|
||||
|
||||
## Prepare printers
|
||||
|
||||
Install each printer and its appropriate vendor driver in Windows. Print a normal Windows test page, then discover the queues through QQQ Print Agent. The installed service must have access; a queue mapped only under a user's profile is insufficient. Use machine-installed USB/TCP/IP queues where possible. Network share authentication may require a deliberately configured service identity; check access under that identity before using it for unattended printing.
|
||||
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 machine default paper/tray/orientation for PDF printing. Jobs can use an advertised media profile or a complete explicit PDF width/height pair; job-specific choices do not change the shared queue. See [Printer media](PRINTER-MEDIA.md). For ZPL, configure compatible Zebra/ZDesigner drivers and generate ZPL matching the printer's DPI and label stock. Ribbonless printers require the appropriate direct-thermal mode.
|
||||
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.
|
||||
|
||||
## Sign in and pair
|
||||
|
||||
Use qbit-printing 1.0.0 on QQQ 4.1.0-RC.21 or a compatible newer server; the released combination advertises native printing API 2.3. In the companion's connection settings, enter its origin, discover the advertised sign-in options and sign in through the configured QQQ authentication. See [QQQ sign-in](QQQ-SIGN-IN.md) for browser-provider requirements and recovery.
|
||||
Upgrade the QQQ printing server first; realtime source requires printing extension 2.0. In the companion's connection settings, enter its origin, discover the advertised sign-in options and sign in through the configured QQQ authentication. See [QQQ sign-in](QQQ-SIGN-IN.md) for browser-provider requirements and recovery.
|
||||
|
||||
For an existing unbound installation, choose **Associate the existing pairing with my account**; preserve its device record and history. For a new installation, complete sign-in and enter a single-use enrollment code created in QQQ. Enrollment runs in the service, protecting its device proof and QQQ session under the service identity. The desktop does not retain the password or device token. **Set up a new pairing** is an explicit replacement flow, not routine upgrade migration.
|
||||
|
||||
@@ -71,13 +70,13 @@ Normal CLI setup, printer exposure and connection commands use the authenticated
|
||||
|
||||
In the MSI-installed companion, open Settings and select **Check for updates**. The **Check for updates on startup** preference enables a quiet check after startup; it does not authorize installation. AppKit's `UpdateCoordinator` and native prompts handle offers and confirmation. Development/portable copies cannot apply machine updates. The elevated companion and apply helper require local administrator rights; there is no persistent privileged update daemon or UAC bypass.
|
||||
|
||||
The feed is fixed to [QRun-IO/qqq-print-agent releases](https://github.com/QRun-IO/qqq-print-agent/releases), independently of customer branding or the QQQ server. Stable builds accept newer stable versions; prerelease builds may accept newer RC or stable versions. Offers below core version 0.1.3 are rejected. Downloads require the expected versioned MSI and same-release `SHA256SUMS`. SHA-256 checks establish consistency with that release, not independent publisher identity. Stable 1.0.1 is signed; earlier unsigned prereleases remain distinguishable by their exact release.
|
||||
The feed is fixed to [QRun-IO/qqq-print-agent releases](https://github.com/QRun-IO/qqq-print-agent/releases), independently of customer branding or the QQQ server. Stable builds accept newer stable versions; prerelease builds may accept newer RC or stable versions. Offers below core version 0.1.3 are rejected. Downloads require the expected versioned MSI and same-release `SHA256SUMS`. SHA-256 checks establish consistency with that release, not independent publisher identity or signing; the prerelease remains unsigned.
|
||||
|
||||
After confirmation, the adapter downloads into a protected unique folder under `%ProgramData%\QqqPrintAgent\updates`, verifies size/hash and prepares a copied CLI helper. The service pauses new claims and waits for active native submissions; connection/control traffic remains separate. The helper waits for the companion to exit, rechecks the package, then runs one MSI for service, companion and CLI. It preserves enrollment and journal evidence. Windows Installer is not forcibly terminated. A failed or abandoned handoff does not leave claims paused permanently.
|
||||
|
||||
The helper records a dated result in `updates\last-result.json`, retains a private installation log and attempts to reopen the companion. Settings displays the last result. Exit 3010 means a Windows restart is required, not that the new app is fully ready. On failure, inspect Settings/Logs and the installed version before retrying. Completed update folders, helper copies and install logs are retained for administrator review; there is no automatic cleanup policy for completed stages. Remove an old stage only after confirming its installer/helper has exited and its evidence is no longer needed; never remove enrollment or journal files.
|
||||
|
||||
The signed 1.0.1 MSI is installed locally; the November 3, 2026 check found matching companion, service and CLI versions with the service running. The earlier in-app update from 0.3.1 to 0.3.2-rc.1 completed and reopened Connected, as confirmed by the operator. Those are distinct observations; see [acceptance evidence](ACCEPTANCE.md) and [release readiness](RELEASE-READINESS.md) for their exact scope. A nonworking older companion can be repaired using the official MSI directly.
|
||||
The published 0.2 MSI has completed a state-preserving local upgrade, native QQQ sign-in and device-control checks. The user confirmed Letter PDF and Zebra ZPL output plus the Connected footer and both printers. The earlier 0.1.9 automated UI run and 0.1.7 hardware checks retain their own scope. None of these establishes installed 0.3 realtime behavior, a complete in-app updater handoff or restart-required recovery. See [acceptance evidence](ACCEPTANCE.md) and [session state](SESSION-STATE.md) for exact versions and remaining checks. A nonworking older companion can be repaired using the reviewed MSI directly.
|
||||
|
||||
## Upgrade and remove
|
||||
|
||||
@@ -85,8 +84,8 @@ Install a newer MSI as administrator. The installer stops and replaces the servi
|
||||
|
||||
Use **Settings → Apps → Installed apps → QQQ Print Agent → Uninstall** to remove application files and the service. Enrollment and journal data are retained to support recovery. After revoking/decommissioning the device in QQQ and resolving all outstanding jobs, an administrator may deliberately remove the retained data. Never delete a live journal as a troubleshooting shortcut: it is evidence used to avoid duplicate printing.
|
||||
|
||||
Local MSI upgrades, updater handoff and installed UI checks are recorded by version in the acceptance evidence. An MSI exit code alone does not establish reboot recovery or compatibility with every printer/driver combination.
|
||||
Local MSI upgrades and installed UI checks are recorded by version in the acceptance evidence. Reboot/recovery, a complete in-app upgrade and broader driver combinations still require qualification; an MSI exit code alone does not establish those results.
|
||||
|
||||
## Select exposed printers
|
||||
|
||||
New installations share no queues. Open the companion's Printers page and enable **Shared** for each intended printer, then approve/enable it in QQQ. Only the selected set is sent to the cloud; all discovered queues remain visible locally for setup. A missing formerly exposed queue is shown as offline.
|
||||
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.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Printer approval as seen by the agent
|
||||
|
||||
Sharing a printer is a local choice; approving it is the server's. The agent must show both for the
|
||||
server it is connected to, so a printer shared before a server change is never mistaken for one the
|
||||
new server approved.
|
||||
|
||||
## Printing extension 2.4: the server pushes approval (client 1.2.0, QBit 1.1.0, protocol 1.1.0)
|
||||
|
||||
When discovery advertises a stable printing `2.4.0` or newer extension, the client adds
|
||||
`printer-approval-v1` to its HELLO capabilities. The server then sends a `PRINTERS` frame
|
||||
(`{ "printers": [ { "printerId", "enabled" } ] }`, the complete approval state of every printer
|
||||
in the device's latest accepted inventory) after each accepted INVENTORY and whenever an
|
||||
administrator approves or disables one of this device's printers. The frame is authoritative:
|
||||
enabled printers are approved, a shared printer listed as disabled or not listed yet is awaiting
|
||||
approval, and the operator catalog is not read on such a server. The reading is reset with the
|
||||
connection like every other negotiated state. The contract is in the protocol repository
|
||||
(`docs/REALTIME.md`, `RealtimePrinters` in the schema, `fixtures/realtime-printers.message.json`).
|
||||
|
||||
## Printing extension 2.3: the client reads the operator catalog
|
||||
|
||||
- The inventory snapshot (`INVENTORY` over the realtime channel, `PUT /device/printers` otherwise) is
|
||||
resent on every new connection, so a server that has never seen this computer's printers receives
|
||||
them as soon as the agent connects. New printers are disabled on the server until approved.
|
||||
- The device routes never report approval, so after each accepted inventory, and about once a
|
||||
minute while connected, the service reads the operator printer catalog (`GET /operator/printers`,
|
||||
printing extension 2.3) and keeps the entries whose `deviceId` is this device. That catalog lists
|
||||
only approved, exposed destinations the signed-in user may use, so an entry is the server's
|
||||
confirmation of approval. The reading is kept in memory and reset with the connection.
|
||||
- Each printer in the status snapshot carries a `serverState`: `APPROVED`, `AWAITING_APPROVAL`,
|
||||
`NOT_SHARED`, `NOT_REPORTED` (the catalog could not be read; the reason is in `printerSync.detail`
|
||||
and the activity log) or `NOT_CHECKED` (no server asked yet). The Printers page shows it in the
|
||||
Server column, the summary line and the expanded details.
|
||||
|
||||
## Why both paths exist
|
||||
|
||||
The catalog is user-scoped. On a 2.3 server, a signed-in user who is not permitted to read it leaves
|
||||
every shared printer at `NOT_REPORTED` with the reason in the tooltip and the activity log. The 2.4
|
||||
PRINTERS frame is device-scoped and pushed on change, so it needs no operator permission and
|
||||
updates the Printers page the moment an approval changes. Pair the 1.2.0 client with QBit 1.1.0
|
||||
(extension 2.4.0) for that behaviour; against QBit 1.0.0 (2.3.0) the client stays on the catalog
|
||||
and the wire is unchanged. The compatibility matrix and the negotiation rule (`printer-approval-v1`
|
||||
minimum 2.4.0, capabilities re-evaluated on every socket) are in the protocol repository's
|
||||
`docs/VERSIONS.md`.
|
||||
@@ -1,91 +0,0 @@
|
||||
# Windows queue-default media profiles
|
||||
|
||||
Client 1.0.1 advertises `printer-media-v1` only when standard discovery
|
||||
reports a stable printing extension version of at least 2.3.0 within major two.
|
||||
Printing 2.0, 2.1 and 2.2 (rendering, no media negotiation) servers retain the original three realtime capabilities and
|
||||
legacy unselected jobs. Each WebSocket establishment refreshes standard discovery
|
||||
once under the authenticated session gate, then uses that refreshed snapshot
|
||||
for HELLO. This handles same-session server upgrades and downgrades without an
|
||||
HTTP polling schedule. An unverified test or
|
||||
custom transport does not claim media support.
|
||||
|
||||
The client includes optional `mediaProfiles` in exposed-printer inventory. It reports at most one profile per
|
||||
queue: the machine-wide Windows driver default. It does not enumerate stock that
|
||||
an operator might have available, infer dimensions from document pages, or claim
|
||||
that the configured stock is physically loaded.
|
||||
|
||||
Discovery reads `GetPrinter` level 8, rather than the current user's level 9
|
||||
preferences. Custom paper dimensions come from both valid DEVMODE width/length
|
||||
fields, converted from tenths of a millimeter to integer microns. Otherwise, a
|
||||
single matching paper ID in the driver's `DeviceCapabilities` paper/size arrays
|
||||
is required. Landscape defaults swap the effective page axes. Positive explicit
|
||||
DEVMODE resolutions are retained; symbolic quality values require resolution
|
||||
from a driver DC created with that exact machine DEVMODE. Unknown, incomplete,
|
||||
ambiguous or out-of-contract values produce no profile, without guessing.
|
||||
|
||||
Each profile has a stable opaque queue/default ID, a lowercase SHA-256 revision,
|
||||
a display name, width/height in microns, horizontal/vertical DPI,
|
||||
`source: WINDOWS_QUEUE_DEFAULT` and `applyMode: DRIVER_DEFAULT`. The revision
|
||||
includes driver identity, resolved dimensions/resolution, and the complete
|
||||
returned public/private DEVMODE bytes. This is deliberately conservative: even a
|
||||
driver-private setting change invalidates an old selection. Drivers returning
|
||||
volatile private bytes may require reselection; the client does not guess which
|
||||
vendor fields can be ignored. The fingerprint is not a credential.
|
||||
|
||||
Consumers submit `options.mediaProfile: {id, revision}` exactly as advertised.
|
||||
It cannot be combined with `paperWidthMm` or `paperHeightMm`. Those two values
|
||||
(a complete pair, PDF only) request explicit actual-size job paper instead: the
|
||||
agent edits a copy of the queue's machine defaults (user paper size, tenths of a
|
||||
millimeter, form name ignored, one copy, 100 percent) and refuses the job before
|
||||
StartDoc unless the device context reports that physical size and resolution. The
|
||||
queue default is never changed. The worker checks
|
||||
the current selection after download and before its submission state. The
|
||||
native helper checks again immediately before opening a spool document. A known
|
||||
media refusal is propagated across the helper boundary and recorded as FAILED
|
||||
with no document submitted; arbitrary native failures and timeouts retain their
|
||||
existing uncertain-outcome handling and are not retried automatically.
|
||||
|
||||
For selected PDF jobs, the captured machine DEVMODE remains the selection
|
||||
fingerprint. A separate job-only copy requests one driver copy and 100% driver
|
||||
scale, only for fields supported by the captured defaults, through
|
||||
`DocumentProperties(DM_IN_BUFFER | DM_OUT_BUFFER)`. Unsupported fields remain
|
||||
absent; drivers without native scale support use the software renderer's scale.
|
||||
This allows
|
||||
the driver to reconcile its private settings without prompting or changing
|
||||
queue defaults. Software remains responsible for requested copies and
|
||||
ACTUAL_SIZE/FIT rendering. The normalized result must confirm these overrides
|
||||
and preserve the original support flags and selected geometry/DPI; otherwise
|
||||
submission is refused. A previously supported override cannot silently disappear.
|
||||
`CreateDC` receives those normalized bytes, never a null/current-user default.
|
||||
Before `StartDoc`, the effective DC must also report matching physical page
|
||||
dimensions and exact horizontal/vertical DPI. Dimension tolerance is one device
|
||||
pixel plus 50 microns (half the DEVMODE 0.1 mm form unit), including landscape
|
||||
axes. Unknown or substituted dimensions/DPI are refused before spooling.
|
||||
RAW ZPL has no DEVMODE application mechanism: the helper rechecks the current
|
||||
profile immediately before `StartDocPrinter`, but cannot lock loaded stock or
|
||||
prevent a physical/device-setting change afterward. `DRIVER_DEFAULT` never
|
||||
changes queue settings, calibrates hardware or loads stock. Such changes require
|
||||
operator verification and fresh discovery. Legacy jobs without a media selection
|
||||
retain their existing path.
|
||||
|
||||
The candidate's actual read-only probe reported the configured label default as
|
||||
101,600 × 152,400 microns at 300 × 300 DPI, with an identical fingerprint across
|
||||
three separate helper launches. Synthetic tests also cover 101,600 × 25,400 and
|
||||
101,600 × 50,800 microns; these are not advertised as current configuration.
|
||||
Physical queue names, driver selectors and inventory evidence remain private.
|
||||
No print, installation, queue change or service operation was performed for this
|
||||
feature's discovery qualification. End-to-end server acceptance and physical
|
||||
printing of a profile-selected job remain separate gates.
|
||||
|
||||
A subsequent read-only probe of the compiled corrected adapter successfully
|
||||
normalized the configured label driver's supported copy field while retaining
|
||||
its absent native scale field. The resulting DC reported 1,200 × 1,800 physical
|
||||
pixels at 300 × 300 DPI. It invoked no StartDoc and changed no queue defaults.
|
||||
All 24 focused adapter regression cases pass; this does not resolve the separate
|
||||
Core test-assembly quarantine or qualify a physical profile-selected print.
|
||||
|
||||
Native references: [machine-wide PRINTER_INFO_8 defaults](https://learn.microsoft.com/en-us/windows/win32/printdocs/printer-info-8),
|
||||
[DEVMODE dimensions and resolution](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodew),
|
||||
and [driver paper capabilities](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-devicecapabilitiesw).
|
||||
Job-only driver normalization follows [DocumentProperties](https://learn.microsoft.com/en-us/windows/win32/printdocs/documentproperties);
|
||||
effective page checks use [GetDeviceCaps](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-getdevicecaps).
|
||||
+3
-5
@@ -1,20 +1,18 @@
|
||||
# QQQ sign-in
|
||||
|
||||
QQQ Print Agent **1.0.1** uses standard QQQ authentication. The matching released server is qbit-printing **1.0.0** on QQQ **4.1.0-RC.21**, advertising native printing API **2.3**. Realtime dispatch requires a compatible 2.x printing API and an authenticated WebSocket; an older API login never enables job polling. Product, native API and wire payload versions are separate. See [Realtime architecture](REALTIME.md).
|
||||
Standard QQQ sign-in shipped in `0.2.0-rc.1`. Release candidate `0.3.0-rc.1` requires printing extension `>=2.0.0 <3.0.0` before opening its realtime channel or dispatching work. The shared authentication client may discover and sign in to compatible 1.x or 2.x servers; a 1.x login does not authorize realtime dispatch or polling fallback. Extension 2.0 makes the authenticated channel mandatory for claims. This guide is not installed/provider acceptance evidence.
|
||||
|
||||
Open **Settings → Sign in / Connection settings**, enter the QQQ server origin and choose **Discover sign-in options**. Discovery must advertise a compatible QQQ UI API and the printing extension before credentials are sent. Table-based authentication presents username/password fields; configured external authentication presents a browser button only for an explicitly advertised native session grant. Passwords remain in the companion and are sent only to the selected QQQ server, never stored or sent to the service.
|
||||
|
||||
The service receives the opaque QQQ session through its existing authenticated administrator pipe. It validates and resumes that session, verifies the authoritative user/tenant/device binding, and protects the session with service-identity DPAPI. Provider access and refresh tokens remain on the QQQ server. Normal printing uses `/qqq/v1/qbit/printing` with the current user session; the device ID/token are secondary possession proof, not independent user authority. Session resume runs periodically while printing continues. Expired or denied authorization stops new traffic until sign-in is restored, preserving receipts and pairing.
|
||||
|
||||
Older pairings without a bound QQQ user require sign-in and association. Routine upgrades preserve a valid saved session and pairing. To associate an existing unbound pairing, explicitly select **Associate the existing pairing with my account**. A new pairing still requires an enrollment code after sign-in. Sign-out preserves the device credential, printer choices and print journal, while removing the locally stored QQQ session. It does not recall pages already sent to a printer.
|
||||
Existing installations require sign-in after upgrading. To associate an existing unbound pairing, explicitly select **Associate the existing pairing with my account**. A new pairing still requires an enrollment code after sign-in. Sign-out preserves the device credential, printer choices and print journal, while removing the locally stored QQQ session. It does not recall pages already sent to a printer.
|
||||
|
||||
The installed browser callback is exactly `io.qrun.qqq-print-agent:/oauth/callback`. The host must advertise and approve this redirect for its configured QQQ OAuth2 session client; the separate public bearer client is not substituted for the server-managed session exchange. MSI registers the stable scheme independently of display branding and removes its owned registration on uninstall. The browser launches the quoted installed CLI callback helper, which forwards only to an active companion attempt in the same Windows user/session, after checking the companion executable. The companion validates the single-use state and PKCE exchange using the standard QQQ grant. No callback starts a new sign-in attempt. No provider registration, registry changes or installed browser sign-in are performed by source tests.
|
||||
|
||||
**Shared server with the Android scanner.** One QQQ host and one OAuth2 provider/`clientId` can serve both clients. The native grant's `redirectUris` must contain both exact callbacks, `io.qrun.qqq-print-agent:/oauth/callback` (print agent) and `io.qrun.mobile.scanner.oauth://callback/oauth2redirect` (scanner), and the provider must allow both exactly (no wildcards). The host must also advertise the `api` and `android` client channels and both the `printing` and `mobile-scanners` extensions. Nonce handling is negotiated per server: when the grant descriptor has `nonceValidation: REQUIRED` (which the scanner demands) or `OPTIONAL`, or when discovery advertises the `nativeSessionNonceValidation` capability, the agent sends that attempt's own authorization nonce as `expectedNonce` in the `manageSession` exchange. When neither is present (older servers) the exchange body is unchanged. Any other non-empty policy value is refused as a server configuration problem before a browser opens. The nonce is never taken from the callback, regenerated or logged. Human permissions (`printing_admin` versus the scanner's `mobile.*`) and device credentials stay separate. Validation against a real shared Authentik host is pending; this is not released.
|
||||
|
||||
Normal CLI `printers`, `enroll`, `expose`, `run` and `sign-out` commands use the installed authenticated service. `run` requests a connection refresh; the service owns unattended printing. Direct bearer-based foreground diagnostics require explicit `--legacy-compat` and a separate user data directory; they cannot use `--service`. They are a compatibility diagnostic, not the app's sign-in or fallback path.
|
||||
|
||||
The server and its identity provider govern session lifetime, renewal and current permissions. Unattended service operation does not promise an indefinite authorization lifetime: expiration, revocation or a policy change can require a new sign-in. Configure the advertised native session grant and approved callback for each deployment.
|
||||
Pending qualification includes the configured provider's grant, callback registration in the packaged installation, current-user permission changes and long-lived provider renewal. No successful synthetic test substitutes for those deployment checks.
|
||||
|
||||
## Recovering unreadable local credentials
|
||||
|
||||
|
||||
+7
-9
@@ -1,16 +1,14 @@
|
||||
# Realtime architecture and operation
|
||||
|
||||
The realtime transport was introduced in release candidate [0.3.0-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.0-rc.1) as native printing 2.0, and the published `0.2.0-rc.1`
|
||||
polling transport and its acceptance evidence remain historical. The released 1.0.1 client speaks native printing 2.3 with
|
||||
wire payload v1 and the same realtime model; media negotiation is offered only to a stable 2.3+ server. The authoritative
|
||||
[contract at the consumer pin](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/commit/42c0d249f81395f00408ec0f92638dcbbf5deb03/docs/REALTIME.md)
|
||||
(commit `42c0d249f81395f00408ec0f92638dcbbf5deb03`, [Gitea PR 2](https://git.kof22.com/QRun-IO/qqq-print-protocol/pulls/2))
|
||||
defines exact fields, bounds and required cross-runtime tests. The earlier 0.3.0 pin was `f3cbc02e9b0fb4fdfefaa59d976c04df747cc589`, kept as history.
|
||||
This describes release candidate [0.3.0-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.0-rc.1). The published `0.2.0-rc.1`
|
||||
transport and its acceptance evidence remain historical. The authoritative
|
||||
[contract at the consumer pin](https://github.com/QRun-IO/qqq-print-protocol/blob/f3cbc02e9b0fb4fdfefaa59d976c04df747cc589/docs/REALTIME.md)
|
||||
defines exact fields, bounds and required cross-runtime tests.
|
||||
|
||||
The 2.0 transport origin: native printing became version 2.0 because claiming then required a synchronized
|
||||
authenticated WebSocket; clients of native printing API 1.0 could claim using HTTP alone.
|
||||
Native printing is version 2.0 because claiming now requires a synchronized
|
||||
authenticated WebSocket; published 1.0 clients could claim using HTTP alone.
|
||||
This is a breaking behavioral change even though the route and domain payloads
|
||||
are retained. Product version 1.0.1, framework `/qqq/v1`, wire payload v1 and the
|
||||
are retained. Product version 0.3, framework `/qqq/v1`, wire payload v1 and the
|
||||
`qqq-printing.v1` subprotocol have separate version meanings.
|
||||
|
||||
## Connection and work
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Release readiness — signed 1.0 closeout
|
||||
|
||||
Updated 2026-11-04. The requested product release and local installation are complete. The dated records below distinguish the stable release from earlier candidate evidence; they are not a claim that every printer or deployment has been qualified.
|
||||
|
||||
## Current release
|
||||
|
||||
- **Windows:** signed [1.0.1 on Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) and matching [GitHub updater assets](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1), from source `5bf00f3291923f7afb34c6cce61276e69649689b`.
|
||||
- **Release gate:** [run 41532214447](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41532214447) passed the build/test/package, security, CodeQL, script, Azure OIDC signing and publication gates. Published checksums, signatures and provenance were verified. See [release signing](RELEASE-SIGNING.md).
|
||||
- **Installed:** the November 3 check recorded MSI exit 0, app/service/CLI product version `1.0.1+5bf00f3291923f7afb34c6cce61276e69649689b`, file version `1.0.1.65535`, and service **Running**. No new runtime check is implied by this documentation update.
|
||||
- **Server and contract:** [qbit-printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), [protocol 1.0.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.0.0) and QQQ **4.1.0-RC.21** are released. Native printing API **2.3**, payload **v1** and WebSocket subprotocol `qqq-printing.v1` retain their own version meanings.
|
||||
- **Physical scope:** Letter and 4 × 6 output were accepted earlier; the operator confirmed 4 × 1 on RC3 with the same relevant print path. Physical 4 × 2 was deferred and is not a release blocker. Dated evidence remains in [Acceptance](ACCEPTANCE.md).
|
||||
- **Documentation:** current install, sign-in, settings, media, logs, branding and release instructions reflect the stable release. Ordinary source changes use Gitea; the authorized GitHub signing and updater bridge remains in place.
|
||||
|
||||
No product implementation, release publication or installation task remains in this closeout. Shared CI-platform migration is separate from the shipped product. Historical unchecked items below do not reopen cancelled work or authorize physical tests.
|
||||
|
||||
## Historical scope decision — 2026-11-02
|
||||
|
||||
The post-print native UI layout exception (WinUI `LayoutCycleException` after a print, recovered by the existing mitigation) was removed from the 1.0 blocker list by the user on 2026-11-02 and is accepted as a known limitation. It is **not fixed**, and the history below is preserved unchanged. It is not carried as another mandatory qualification.
|
||||
|
||||
## Historical cancelled work, not active
|
||||
|
||||
Repeat Auth/S3 qualification, the print-latency investigation, and the MSI comparison/install-free upgrade analysis were cancelled by the user. Their recorded evidence is historical; do not resume them.
|
||||
|
||||
---
|
||||
|
||||
# Historical matrix — candidate 0.4.0-rc.1 (superseded 2026-11-02)
|
||||
|
||||
The sections below were the working matrix on 2026-11-01. They are kept as evidence only and are **stale** where they list work as pending; the current truth is above.
|
||||
|
||||
## Observed gates
|
||||
|
||||
Historical (superseded heads, kept as evidence): Windows PR #16 head `35ed93c7…` hosted run 41340470827 passed Core 154 and CLI/Windows 317 with 0 warnings; server PR #30 head `4eee28e9…` passed 183 reported / 182 executed / 1 opt-in skip / Checkstyle 0. Protected main then advanced (server rendering at printing 2.2.0; client virtual-agent), so those heads are not the candidate.
|
||||
|
||||
Current candidate, local results only:
|
||||
|
||||
| Tree | Gate | Result |
|
||||
| --- | --- | --- |
|
||||
| Windows | First focused build | **Rejected**: `PrintWorker.cs(96,29)` MA0051 (method 67 lines, maximum 60), caused by the phase-timing instrumentation. Log preserved. An earlier sandbox build had stalled with orphaned build workers; that was environmental, not source. |
|
||||
| Windows | After extracting `SubmitJobAsync`, `RequireCurrentPrinter`, `RequireSubmitAllowed`, `SubmitNativeAsync` and `JobAttempt` (no analyzer waiver) | Focused negotiation/runtime run **16 of 16 passed**, no warnings, no analyzer errors. Core.Tests was not built. |
|
||||
| Windows | Roslyn token compare against merge base `c4c663a` | Exactly the four intended semantic files differ (`PrintWorker.cs`, `PrintWorkerTests.cs`, `QqqApiDiscovery.cs`, `AgentRealtimeMediaCapabilitiesTests.cs`); no fifth file. The worker refactor was reviewed: same reservation, staged-file and submission disposal inside the same catch, same authority checks and state order. |
|
||||
| Server | Full verify, sandbox | 188 reported, 0 assertion failures, 11 loopback setup errors, 1 skip. Environmental; log preserved. |
|
||||
| Server | Full verify, actual user, before fix | 188 reported, 0 assertion failures, **1 error** (`PrintingRealtimeTest.mediaCapabilityIsReplacedOnEveryAuthenticatedHello`, temp-directory cleanup). Cause: `PrintingRealtimeRoutes.close()` did not wait for in-flight channel drains, which recreated the stopped file database. Log preserved. |
|
||||
| Server | After `stopWorkers()` fix and regression `closeWaitsForChannelWorkersAlreadyRunning` | `PrintingRealtimeTest` five consecutive runs, 4 tests each, no empty-database warnings. Full verify **189 reported / 188 executed / 1 intentional opt-in workload skip / 0 failures / 0 errors, Checkstyle 0**. Source-header guard over 146 tracked files passed; staged whitespace checks passed. |
|
||||
| Protocol | Check | 76 definitions and 78 fixtures passed locally; compatibility docs published as a signed draft PR #15 (commit `1916324e6d2c0dac0fd48f2401b654303ab228f9`). |
|
||||
|
||||
**Not yet observed:** hosted CI on the final signed candidate heads (Windows Core.Tests, the new `RunOnce_LogsOnePhaseTimingLinePerJobWithoutDocumentContent`, Cli.Tests totals, MSI build at ProductVersion 0.4.0; server verify). Hosted passes do not clear the local Defender quarantine of the Core test assembly, which stays unbuilt and unrestored. No installer, installed-upgrade, physical-output or published-cohort native-callback result exists for this candidate.
|
||||
|
||||
Status words are strict. **Proven** names evidence that was observed for the stated scope. **Partial** means part of the requirement has evidence. **Incomplete** means no qualifying evidence exists yet, including where only source or a plan exists.
|
||||
|
||||
| Requirement | Status | Evidence on record | Open gap / exact next evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| AppKit parity (menu, About, settings, theme, logs, tray, update) | Partial | [APPKIT-PARITY.md](APPKIT-PARITY.md); real in-app 0.3.1 → 0.3.2 update accepted ([ACCEPTANCE.md](ACCEPTANCE.md)); 426-test Release gate at 0.3.2 | Update download/installer failure, cancel and reboot-required paths; high contrast, Narrator, scaling and small-screen matrix. Not re-run for this candidate. |
|
||||
| Realtime, reconnect, large-file control concurrency | Partial | Source tests: `PauseAndCancelRemainResponsiveDuringLargeDownloadWithoutHttpPolling`, `NativeCookieAndDeviceProofUpgradeKeepsReceivingDuringStalledDocument`, ordering/generation/bandwidth suites; installed 0.3.x reconnect observed ([REALTIME.md](REALTIME.md)) | [TODO.md](TODO.md) still lists the combined runtime review (stalled transfer, lost ACK, reconnect) as unchecked. Needs the hosted Core + Windows gate on the exact head, then an installed reconnect check on a compatible server. |
|
||||
| Jobs table, details, sorting, 10k/day scale | Partial | `JobSortingTests.TenThousandJobs_AllColumnsSortAcrossPagesAndInterleaveBoundedCloudQueue`; `AgentStoreScaleTests`; 375,000-job store measurements in [SCALABILITY.md](SCALABILITY.md); installed UI checks at 0.1.9/0.3.1 | Measurements are local storage with fake printers. GUI rendering at scale, service scheduling and printer throughput are unmeasured. Not a throughput claim. |
|
||||
| Printer media (this candidate) | Partial | Read-only real-driver/DC normalization probe 4×6 in / 300 DPI with no queue change ([PRINTER-MEDIA.md](PRINTER-MEDIA.md)); media is offered only to stable printing 2.3+ in major 2 (2.2 rendering stays a three-capability peer), covered by the 16 passing focused negotiation/runtime tests (HELLO per version, reconnect upgrade and downgrade) | The earlier hosted Core pass was on a superseded head. Final-head hosted Core is pending and does not clear the local Defender quarantine. No installed profile-selected print has occurred. |
|
||||
| Print latency (measured 5–7 s ZPL test print) | Not pursued | Historical: source trace of the per-job path and a diagnostic plan (`LATENCY-DIAGNOSTIC-PLAN.md` in the coordination outbox); the candidate logs one phase-timing line per job | The user cancelled further latency work; the cause stays unproven and nothing is claimed. The log line remains available. |
|
||||
| Branding and install | Partial | [BRANDING.md](BRANDING.md); branding tests; MSI payload/resource/notice checks recorded for 0.3.2 | Candidate MSI is built only by the hosted job on the final head (pending). Needs MSI resource comparison against the 0.3.2 baseline after CI, then the install-free plan in [UPGRADE-QUALIFICATION.md](UPGRADE-QUALIFICATION.md). |
|
||||
| Status and log rotation | Partial | `AgentLogSettingsTests` (10k jobs × 3 entries inside the default daily budget); daily-cap marker and clear-logs behavior in [SCALABILITY.md](SCALABILITY.md) | Installed rotation across a local-midnight boundary and cap visibility in the UI are not observed. |
|
||||
| Native auth, permissions, recovery | Partial | Isolated real Authentik login, permission, logout, expiry/renewal and revocation against an unreleased API 1.9 candidate in local Docker; recovery regressions in [QQQ-SIGN-IN.md](QQQ-SIGN-IN.md) | Installed browser/private-callback and long-lived grant need a compatible published API 1.9+ cohort. The local server 1.8 does not qualify. **Incomplete** until that cohort exists. |
|
||||
| Retained-source and S3 retention boundaries | Partial | Server local full verify on the merged candidate: 189 reported / 188 executed / 1 intentional opt-in 10k skip / 0 failures, Checkstyle 0; callback authority and accepted-row history fixes independently reviewed; the server's `docs/STORAGE-AND-RETENTION.md` states the layout, outbox-copy and versioning boundaries | Final-head server hosted CI pending. Production IAM, bucket policy, noncurrent-version retention and cross-platform key migration are deployment work, not proven. Echo's experimental core tuple is unqualified; no silent repin. |
|
||||
| Packaging, CI/CD, notices, About | Partial | Notice and Query About guards merged into both bases; release workflow pins actions and protocol; candidate version inputs (0.4.0 / 0.4.0-rc.1) and the reviewed workflow patch are applied by the coordinator, with action pins unchanged | The protocol pin in `ci.yml` is the final commit `42c0d249f81395f00408ec0f92638dcbbf5deb03` ([Gitea PR 2](https://git.kof22.com/QRun-IO/qqq-print-protocol/pulls/2)); earlier pins are historical. Candidate identity is 0.4.0-rc.2 (ProductVersion 0.4.0). Hosted packaging steps on the final head are pending. MSI remains unsigned. |
|
||||
| C# source standards (XML how/why headers, member layout) | Partial | License headers on all tracked C# (IDE0073, [SOURCE-HEADER-COMPLIANCE.md](SOURCE-HEADER-COMPLIANCE.md)); how/why headers and structural spacing completed in batches A to D, each token-compared as comment and blank-line only; Roslyn helper controls passed | Source cleanup is complete and merged. A final inventory over the merged tree has not been reported, and the merged UI build and 35 paper/media tests passed locally; hosted CI is pending. |
|
||||
|
||||
## Order of closure
|
||||
|
||||
1. Done locally: both trees pass their focused/full local gates (table above). Pending: signed functional commits and exact-head hosted CI for the Windows and server candidate heads.
|
||||
2. Done: the Roslyn inventory/compare helper passed its positive and negative controls.
|
||||
3. After the functional baseline is committed: documentation-only C# standards batches of at most 8 files, each with token-equivalence proof and never mixed with semantic fixes.
|
||||
4. Version inputs (`Directory.Build.props`, `build/Build-Installer.ps1`, README, CHANGELOG) and the `ci.yml`/`release.yml` patch are applied for the candidate. No tag.
|
||||
5. Normal Gitea Core/CLI/build CI on the integration head, then installation of the new label implementation and a small-stock paper test.
|
||||
6. Historical, no longer active: the install-free upgrade plan in [UPGRADE-QUALIFICATION.md](UPGRADE-QUALIFICATION.md) and the latency diagnostic plan were cancelled by the user; their recorded evidence is kept.
|
||||
|
||||
## Not claimed (historical, 2026-11-01)
|
||||
|
||||
Authenticode signing, physical-stock sensing, installed profile-selected printing, desktop/native OAuth callback or long-lived grant on a published cohort, remote cloud deployment, broad driver matrix, and any 1.0 readiness.
|
||||
@@ -1,89 +0,0 @@
|
||||
# Stable release signing
|
||||
|
||||
Stable `vX.Y.Z` tags publish a signed MSI; `vX.Y.Z-rc.N` tags stay unsigned prereleases. Signing uses the
|
||||
same Azure Artifact Signing account, profile and publisher as
|
||||
[MarkdownViewr.Windows](https://github.com/KofTwentyTwo/MarkdownViewr.Windows) (its `docs/azure-signing-admin.md`
|
||||
and `docs/release-signing.md`), with the same pinned `azure/login` and `azure/artifact-signing-action`. This
|
||||
repository adds no signing service, key export or secret.
|
||||
|
||||
## What the release workflow does
|
||||
|
||||
`.github/workflows/release.yml` first validates the tag; for a stable tag its `version` job also requires the tagged
|
||||
commit to be an ancestor of `origin/main` (as MarkdownViewr.Windows does), so an off-main stable tag stops before
|
||||
any gate or signing. It then runs every existing gate (Windows build/test/package, security, CodeQL, scripts).
|
||||
Release-candidate tags skip the ancestry check and stay unsigned. For a stable tag the `sign` job then runs in the protected `release` environment (tags `v*` only) with
|
||||
OIDC and no stored secret:
|
||||
|
||||
1. `build/Assert-ReleaseSigning.ps1` checks the protected variables below (configuration only).
|
||||
2. Locked restore, `Test-WindowsSdkInventory -VerifyPackages`, `Test-BrandedInstaller`, `Test-PackagingInputs`.
|
||||
3. `Build-Installer.ps1 -PublishOnly` publishes and checks the service, companion and CLI payloads (versions,
|
||||
file versions, WinUI resources, SDK inventory) without building the MSI.
|
||||
4. `Sign-Release.ps1 -Operation Stage` copies only the owned files into a staging folder: the three executables
|
||||
plus `Qqq.PrintAgent*.dll` and `KofTwentyTwo.AppKit*.dll` beside them. Third-party binaries keep their own
|
||||
signatures.
|
||||
5. `azure/login`, then `azure/artifact-signing-action` signs the stage (SHA256, RFC3161 timestamp).
|
||||
6. `Sign-Release.ps1 -Operation Apply` accepts a staged file only if it changed, passes `signtool verify /pa /all /v`
|
||||
and `Get-AuthenticodeSignature` (valid, timestamped) and has exactly `ARTIFACT_SIGNING_SUBJECT`. The signtool
|
||||
output must show exactly one verified signature, zero warnings and errors, and every `Hash of file (...)` line
|
||||
must be `sha256` (certificate-chain text naming an algorithm is not counted). Only then are the files copied
|
||||
back, and the payload is verified again in place.
|
||||
7. `Build-Installer.ps1 -SkipPublish` builds the MSI from the signed payload (all payload checks run again).
|
||||
8. The MSI is staged, signed by the same action, and accepted by the same publisher check.
|
||||
9. `Package-Release.ps1 -AssembleOnly -ExpectedPublisherSubject` re-verifies the MSI and writes the four release
|
||||
files (versioned MSI, `Install-Branded.ps1`, `branding.example.json`, `SHA256SUMS`). Checksums are computed after
|
||||
signing.
|
||||
|
||||
The `publish` job requires every gate and, for a stable tag, a successful `sign` job. It then verifies the
|
||||
checksums, attests the artifacts and runs `gh release create --verify-tag --latest`, which cannot overwrite an
|
||||
existing release. An unsigned prerelease path is unchanged.
|
||||
|
||||
## Required protected-environment variables (non-secret)
|
||||
|
||||
Set in the GitHub `release` environment of this repository (see the MarkdownViewr runbook for the procedure):
|
||||
|
||||
| Variable | Value |
|
||||
| --- | --- |
|
||||
| `AZURE_TENANT_ID` | Tenant GUID (MarkdownViewr reference: `4ebeb0ad-5782-487a-a1b4-5080255774cf`) |
|
||||
| `AZURE_SUBSCRIPTION_ID` | Signing resource's subscription (reference: `8ef2479e-3346-4cba-93ad-101c6cd237a0`) |
|
||||
| `AZURE_CLIENT_ID` | A client GUID with trust for **this** repository (below); never copy another repository's |
|
||||
| `ARTIFACT_SIGNING_ENDPOINT` | Regional endpoint (reference: `https://eus.codesigning.azure.net/`) |
|
||||
| `ARTIFACT_SIGNING_ACCOUNT` | Account (reference: `kof22signing`) |
|
||||
| `ARTIFACT_SIGNING_PROFILE` | Active PublicTrust profile (reference: `releases`) |
|
||||
| `ARTIFACT_SIGNING_SUBJECT` | Full certificate subject (reference: `CN=James Maes, O=James Maes, L=Chester, S=il, C=US`) |
|
||||
|
||||
The reference values are copied from the MarkdownViewr runbook; read the deployed resource back before use.
|
||||
|
||||
## Identity binding for this repository
|
||||
|
||||
The CI identity must be an approved single-tenant Entra application and service principal with **no client secret**,
|
||||
a federated credential for the exact release-environment subject, and the `Artifact Signing Certificate Profile Signer`
|
||||
role scoped to the profile only:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "qqq-print-agent-release",
|
||||
"issuer": "https://token.actions.githubusercontent.com",
|
||||
"subject": "repo:QRun-IO@<owner-id>/qqq-print-agent@<repo-id>:environment:release",
|
||||
"audiences": ["api://AzureADTokenExchange"]
|
||||
}
|
||||
```
|
||||
|
||||
Read the immutable owner and repository IDs from `gh api repos/QRun-IO/qqq-print-agent/actions/oidc/customization/sub`
|
||||
(and confirm `use_immutable_subject`). Trust must not be broadened to pull requests or other environments. MarkdownViewr's
|
||||
and gclo's identities must not be reused for this repository without separate approval and their own trust.
|
||||
|
||||
## Published stable result — 2026-11-03
|
||||
|
||||
The authorized GitHub signing bridge completed [release run 41532214447](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41532214447) for `v1.0.1`, source `5bf00f3291923f7afb34c6cce61276e69649689b`. Its build, test, security, CodeQL, script, OIDC signing and publication gates passed. The signed MSI and matching assets are published on [Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) and [GitHub](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1); the latter remains the installed updater feed.
|
||||
|
||||
The downloaded MSI's checksum matched `SHA256SUMS`; Authenticode reported **Valid**, the exact configured publisher `CN=James Maes, O=James Maes, L=Chester, S=il, C=US`, and a timestamp. Release provenance was verified. The local installation on November 3 exited 0 with companion, service and CLI all reporting `1.0.1+5bf00f3291923f7afb34c6cce61276e69649689b`, file version `1.0.1.65535`, and service state **Running**. See [release readiness](RELEASE-READINESS.md).
|
||||
|
||||
The printing-only Azure client uses the exact immutable repository/environment subject, a profile-scoped signer role and protected environment variables, with no stored client secret. The same signing account/profile and verification rules described above remain in use.
|
||||
|
||||
## Historical bootstrap failure and limits
|
||||
|
||||
The first hosted stable attempt, `v1.0.0`, passed its prerequisite gates and Azure OIDC login but stopped before signing because the action's SignCLI installer conflicted with NuGet package source mapping. No signed 1.0.0 installer was published. Version 1.0.1 fixes the bootstrap by preinstalling only pinned SignCLI `0.9.1-beta.26227.3` through the mapped `nuget.config` into the action's expected `%LOCALAPPDATA%\ArtifactSigning\sign\sign.<version>` layout. The 1.0.0 tag is unchanged.
|
||||
|
||||
The earlier local signing proof used copies of the RC4 payload and MSI; it was not the hosted release. The successful 1.0.1 run now supplies the CI identity/signing evidence. QQQ 4.1.0-RC.21 and qbit-printing 1.0.0 provide the released native-authentication cohort; the older RC20 prerequisite is superseded.
|
||||
|
||||
`Apply` still requires exactly the staged files, and publisher matching remains an exact string comparison. `Install-Branded.ps1` and `branding.example.json` are not Authenticode-signed; their integrity rests on release checksums and provenance. Signing establishes publisher identity, not physical printer compatibility or universal deployment qualification.
|
||||
+10
-26
@@ -1,13 +1,5 @@
|
||||
# Release process
|
||||
|
||||
Gitea is primary for source branches, reviews and normal pull requests. GitHub is the automatic source shadow. The existing authorized [GitHub release workflow](../.github/workflows/release.yml), its CI/security dependencies and the GitHub release assets remain the signing and installed-updater bridge. Do not retire those Windows workflows or remove the feed as part of ordinary source migration.
|
||||
|
||||
Current stable release: **[1.2.1 on Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** and the matching **[GitHub assets](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** (generic and GG Forge installers). The protected [release run](https://github.com/QRun-IO/qqq-print-agent/actions/runs/42223525626) completed signing and publication; checksums, Authenticode signatures and provenance attestations were verified on the downloaded assets (generic `09920872…`, GG Forge `5ce48909…`). The earlier immutable `v1.0.0` and `v1.2.0` tags produced no signed installer (`v1.2.0`: the edition sign job's array splat, fixed in 1.2.1); they must not be moved or reused. Previous stable: 1.1.1.
|
||||
|
||||
## Stable releases
|
||||
|
||||
The release workflow runs on a stable `vX.Y.Z` tag. After every gate below it signs the service, companion and CLI binaries, builds the MSI from them, signs the MSI with the project's Azure Artifact Signing identity, verifies the exact publisher, and publishes a non-prerelease with checksums computed after signing. Prerelease tags are unaffected and stay unsigned. Setup, ordering and required variables are in [RELEASE-SIGNING.md](RELEASE-SIGNING.md).
|
||||
|
||||
## Prereleases
|
||||
|
||||
The [`prerelease` workflow](../.github/workflows/release.yml) runs on tags matching `vX.Y.Z-rc.N`, or can be dispatched against an existing matching tag. It validates the tag and requires the Windows build/test/package, security, CodeQL and PowerShell script gates. It then downloads that run's installer artifact, verifies every packaged checksum, attests the artifacts and creates a GitHub prerelease containing:
|
||||
@@ -19,36 +11,28 @@ The [`prerelease` workflow](../.github/workflows/release.yml) runs on tags match
|
||||
|
||||
Source contents are read-only outside the publication job. That final job receives release-write, OIDC and attestation permissions; security analysis receives only the permissions needed to report its results. Action/reusable-workflow dependencies are pinned by commit. The protocol fixture checkout is pinned to a reviewed commit. Any failed prerequisite or checksum/attestation step prevents publication.
|
||||
|
||||
Release-candidate MSIs remain explicitly unsigned. GitHub provenance identifies the build workflow and artifact digest; it is not Authenticode publisher signing or production qualification. Verify attestations for the exact published files using `gh attestation verify <asset-path> --repo QRun-IO/qqq-print-agent`, and record the result. Older releases may lack attestations; the current workflow does not retroactively attest them. No signing secret or winget submission is implied. Observed in-app update evidence is in [ACCEPTANCE.md](ACCEPTANCE.md). The final job refuses to overwrite an existing GitHub release; use a new prerelease tag for changed binaries.
|
||||
|
||||
## Client editions
|
||||
|
||||
The stable release signs and publishes one installer per folder under `editions/` beside the generic one (`<assetPrefix>-vX.Y.Z-x64.msi`), each built from the same tagged source with that edition's branding, and lists them all in `SHA256SUMS`. The `editions` job enumerates the folders, the `sign` job runs once per edition, and the `assemble` job re-verifies every signature before writing the release files. An installed edition updates only from its own asset name. See [BRANDING.md](BRANDING.md#client-editions).
|
||||
|
||||
## Version identity constraint
|
||||
|
||||
Windows Installer replaces a versioned file only when its numeric file version is higher, and the .NET SDK would give every `X.Y.Z-rc.N` the same `X.Y.Z.0`. `build/Build-Installer.ps1` therefore stamps all three executables with `X.Y.Z.N` for `X.Y.Z-rc.N` (N from 1 to 65534) and `X.Y.Z.65535` for stable `X.Y.Z`, and fails if an executable reports a different file version. Only `rc.N` and stable are supported; any other prerelease label (for example `beta.1`) is refused rather than mapped to an unordered number, so a new label scheme needs a reviewed mapping first. The MSI ProductVersion stays `X.Y.Z`, and the updater still compares the semantic version string.
|
||||
The MSI remains explicitly unsigned. GitHub provenance identifies the build workflow and artifact digest; it is not Authenticode publisher signing or production qualification. Verify attestations for the exact published files using `gh attestation verify <asset-path> --repo QRun-IO/qqq-print-agent`, and record the result. Older releases may lack attestations; the current workflow does not retroactively attest them. No signing secret, winget submission or completed in-app upgrade qualification 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, channel-specific signing notice and release scope.
|
||||
3. Inspect the corresponding successful CI run, or run `./build/Verify.ps1` and build the MSI on a permitted Windows test host. Host restrictions remain in force.
|
||||
4. Obtain explicit release authorization, create the immutable stable or prerelease tag from reviewed primary source, and use the authorized GitHub bridge to run the workflow. Do not push ordinary branches to the GitHub shadow.
|
||||
5. Verify every published filename/checksum, applicable Authenticode signature and provenance attestation. Publish the same reviewed release assets on primary Gitea without rebuilding or changing them. Record the workflow URL and version-specific installed/hardware evidence.
|
||||
2. Review the protocol pin, dependency lock files, unsigned-release notice and open limitations.
|
||||
3. Run `./build/Verify.ps1` and build the MSI locally or inspect the corresponding green CI run.
|
||||
4. Obtain explicit release authorization, create/push the immutable prerelease tag, and wait for the workflow.
|
||||
5. Verify every published filename/checksum and its provenance attestation. Record the workflow URL and version-specific installed/hardware acceptance, distinguishing observed results from pending qualification.
|
||||
|
||||
If publishing the initial release manually, attach only reviewed artifacts and disclose whether they were built locally. Do not claim GitHub build provenance for a local MSI.
|
||||
|
||||
## Signing
|
||||
## Production signing follow-up
|
||||
|
||||
Stable tags are signed by the release workflow with Azure Artifact Signing (see [Stable releases](#stable-releases) and [RELEASE-SIGNING.md](RELEASE-SIGNING.md)); release candidates stay unsigned. `Build-Installer.ps1` also accepts a certificate thumbprint and timestamp server for local signing only. The CI identity completed OIDC authentication and signed stable 1.0.1. Current evidence is in [RELEASE-READINESS.md](RELEASE-READINESS.md).
|
||||
`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.
|
||||
|
||||
## Update-feed contract
|
||||
|
||||
The companion uses AppKit update prompts with a machine MSI adapter. Release assets are also updater inputs: preserve the exact versioned MSI filename and matching `SHA256SUMS` entry. The updater checks release identity, size and SHA-256; provenance verification is a separate release/operator check. The fixed feed is this repository's GitHub releases; branding and QQQ configuration cannot redirect it. Stable installations ignore prereleases, while RC installations may receive a newer RC or stable version. The minimum offered core version is 0.1.3. Never replace a published release's binary under the same version.
|
||||
|
||||
MSI has a numeric three-part ProductVersion; informational version preserves `-rc.N`. Same-numeric-version MSI upgrades are enabled for RC-to-RC/stable transitions, while the updater enforces a strictly newer semantic release. This intentional authoring requires a narrow ICE61 exception alongside the existing Microsoft-resource ICE03 exception; no other MSI checks are disabled. Manual MSI deployments must follow semantic release order because MSI alone cannot order RC suffixes. See [ADR-0002](adr/0002-prerelease-quality-and-packaging.md). The helper verifies the installed component versions against the requested release after exit 0. Exit 3010 remains restart-required.
|
||||
MSI has a numeric three-part ProductVersion; informational version preserves `-rc.N`. Same-numeric-version MSI upgrades are enabled for RC-to-RC/stable transitions, while the updater enforces a strictly newer semantic release. This intentional authoring requires a narrow ICE61 exception alongside the existing Microsoft-resource ICE03 exception; no other MSI checks are disabled. Manual MSI deployments must follow semantic release order because MSI alone cannot order RC suffixes. See [ADR-0002](adr/0002-prerelease-quality-and-packaging.md). The helper verifies the installed companion's actual ProductVersion against the requested release after exit 0. Exit 3010 remains restart-required.
|
||||
|
||||
Same-feed SHA-256 establishes asset consistency, not independent publisher authentication; stable releases additionally carry Authenticode signatures on the first-party binaries and the MSI, while release candidates do not. The helper requires administrator rights, installs service/UI/CLI together and retains credentials/journal; no per-user updater or privileged persistent daemon is shipped. Completed staging folders and install logs are retained for administrator review.
|
||||
Same-feed SHA-256 establishes asset consistency, not independent publisher authentication. Signed first-party binaries/MSI and signing automation remain separate work. The helper requires administrator rights, installs service/UI/CLI together and retains credentials/journal; no per-user updater or privileged persistent daemon is shipped. Completed staging folders and install logs are retained for administrator review.
|
||||
|
||||
Historical: the 0.1.3 source gate passed 163 tests with zero warnings/errors and locked restore. A real in-app update from 0.3.1 to 0.3.2 has since been accepted with retained identity, history and profiles, matching component versions and an automatically reopened companion; see [ACCEPTANCE.md](ACCEPTANCE.md) and [RELEASE-READINESS.md](RELEASE-READINESS.md). Packaging or UI test compilation alone does not establish update acceptance. Stable 1.0.1 publication and its separate local MSI installation are recorded above.
|
||||
The 0.1.3 source gate passed 163 tests with zero warnings/errors and locked restore. Before claiming update acceptance, record a real installed upgrade, version coherence, retained state, cancellation/failure handling, companion relaunch and reboot-required behavior. Current installed UI and actual in-app upgrade acceptance are pending; UI test compilation and packaging alone are insufficient.
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# History and diagnostic limits
|
||||
|
||||
Client `1.0.1` uses event-driven claim drains and direct realtime control
|
||||
Release candidate `0.3.0-rc.1` uses event-driven claim drains and direct realtime control
|
||||
traffic, with separately bounded document downloads and native submissions.
|
||||
Default document limits are 100 MiB PDF and 20 MiB ZPL; administrators configure
|
||||
these and concurrency, staging, bandwidth and deadlines. There is no periodic
|
||||
@@ -50,7 +50,7 @@ Creation time is separate from the latest status-update time. Receipt captures t
|
||||
|
||||
## Logs
|
||||
|
||||
AppKit retains its local-midnight daily filename rotation and shared log viewer. Machine-wide settings default to 30 days retention, 100 MiB per file and 10 rolled-over segments per day; supported ranges are 1–365 days, 1–1024 MiB and 1–100 segments. A file that reaches its limit is renamed to `qqq-print-agent-yyyy-MM-dd.N.log` and a fresh file continues, so the bound per day is the file size times the kept segments plus the live file, and no entry is ever suppressed. Debug logging (off by default) adds request-level detail. Service and companion use one cross-process lock for appends, rollover, settings, retention and clearing.
|
||||
AppKit retains its local-midnight daily filename rotation and shared log viewer. Machine-wide settings default to 30 days and 20 MiB per daily file; supported ranges are 1–365 days and 1–100 MiB. Service and companion use one cross-process lock for appends, settings, retention and clearing.
|
||||
|
||||
At the daily limit, the backend writes a warning when space permits and persists a cap marker. Settings exposes current bytes and whether that day's limit was reached. Further diagnostics are suppressed until the next local day, an increased limit or explicit clearing; printing continues. The cap is visible rather than silently discarding ongoing errors. Detailed high-frequency logs may need a higher limit than the normal three status entries per job used by the synthetic workload.
|
||||
|
||||
|
||||
+1
-224
@@ -1,227 +1,4 @@
|
||||
# Session state — 2026-11-06
|
||||
|
||||
## Current — 1.2.1 released; printing cohort protocol 1.1.0 / QBit 1.1.0 / client 1.2.1 complete
|
||||
|
||||
Client 1.2.1 (source `3b9d75d18f9cc8b80f4cf6d5aae81cccf8d61243`, GitHub release run 42223525626) is published with signed generic and GG Forge installers on GitHub and, byte-identical, on Gitea; see [RELEASING](RELEASING.md). Protocol 1.1.0 and QBit 1.1.0 (extension 2.4.0, including Remove printers / Remove device) are published. The handoff for the Forge agent, with every version, artifact and hash, is [FORGE-HANDOFF-2026-11-05.md](FORGE-HANDOFF-2026-11-05.md); Forge adoption, pairing tests and installation on its station are that lane's. The installed GG Forge rc.3 on this PC updates to 1.2.1 from its own asset through the in-app updater (not yet exercised). Open elsewhere: Gitea CI quota/runner-fleet work (Praetor lane); docs PRs qbit-printing 29 and qqq-print-protocol 12.
|
||||
|
||||
|
||||
## 1.2.1 — release-pipeline fix after the v1.2.0 tag produced no signed installer
|
||||
|
||||
Protocol 1.1.0 and QBit 1.1.0 are published (see below). PR 10 merged as `c8ea97e` and tag `v1.2.0` was mirrored; GitHub release run 42128511730 failed in `sign (gg-forge)` at "Publish and check the payload": the workflow splatted `-Edition gg-forge` as an array, which PowerShell binds positionally into `CertificateThumbprint`/`TimestampServer`, so `Build-Installer.ps1` refused `-PublishOnly`. Fixed with a hashtable splat (local reproduction of the step publishes the GG Forge payload); version bumped to 1.2.1 because `v1.2.0` is immutable and must not be reused. Next: merge, tag `v1.2.1` on Gitea, mirror, signed release, Gitea mirror of the assets, Forge handoff.
|
||||
|
||||
## Printer approval over the realtime channel (extension 2.4.0) — implemented across three repositories, merges pending
|
||||
|
||||
- **Client (this repository), PR 10 head `f1ec404`** on `feat/client-editions-20261105`: operator-catalog fallback (2.3), `printer-approval-v1` negotiation and the authoritative `PRINTERS` frame (2.4), per-printer server state on the Printers page (Server column, summary, details), Clear logs button in the log viewer, pinned sign-in feedback banner, non-repeating sign-in reply. Hosted CI run 42116396114: 197 Core + 362 Windows tests, zero failures. Protocol fixture pin moved to `ac835f8b9396b641b525669249fdb4a1e2a50c99`.
|
||||
- **Protocol 1.1.0**, [PR 11](https://git.kof22.com/QRun-IO/qqq-print-protocol/pulls/11) (`ac835f8`): capability, `RealtimePrinters` schema, fixtures, REALTIME/VERSIONS docs; all gates green.
|
||||
- **QBit 1.1.0 (extension 2.4.0)**, [PR 25](https://git.kof22.com/QRun-IO/qbit-printing/pulls/25) (`3fa78de`): 195 tests green; [PR 26](https://git.kof22.com/QRun-IO/qbit-printing/pulls/26) (`c5a9c6d`, stacked on 25) adds Remove printers / Remove device as archives (`removed_at`) purged by retention when no job references them; 199 tests green.
|
||||
- Merge order: protocol 11, then QBit 25 and 26, then client 10. Then tag protocol `v1.1.0`, QBit `v1.1.0` (registry publication through the main run), client `v1.2.0` on Gitea with GitHub mirror sync. Forge adoption and pairing tests belong to the Forge agent; current working versions (protocol 1.0.0, QBit 1.0.0, client 1.1.1 / GG Forge rc.3) stay for rollback.
|
||||
- Local test builds today: 1.2.0-rc.1..rc.3 under `artifacts/share/`; GG Forge rc.3 (commit 887c58c, before the 2.4 negotiation) is installed on this PC.
|
||||
|
||||
# Session state — 2026-11-05
|
||||
|
||||
## Current status: 1.1.1 signed; client editions (1.2.0) complete in PR 10 — 2026-11-05
|
||||
|
||||
Signed **1.1.0** (sign-in diagnostics, debug logging, log rollover, per-server untrusted-certificate exception) and **1.1.1** (browser sign-in fix: the elevated companion's callback inbox pipe refused the unelevated protocol handler) are published on GitHub from Gitea tags through the mirror. Browser sign-in against the local Forge server (API 1.11.0, `nonceValidation: REQUIRED`) was confirmed working by the operator on the 1.1.1 build; PR 11 (required-nonce exchange) is on main.
|
||||
|
||||
[PR 10](https://git.kof22.com/QRun-IO/qqq-print-agent/pulls/10) (`feat/client-editions-20261105`, version 1.2.0) adds client editions (`editions/<id>`, first `gg-forge`): fully branded builds and installers named after the edition's asset prefix, an updater that follows only that asset, a release workflow that signs one installer per edition from one tag, edition options for server wording and About sections, nonce sending under OPTIONAL and the `nativeSessionNonceValidation` capability, and `build/Build-CiRelease.ps1` delegating to `Package-Release.ps1`. GitHub CI is green on the branch (182 Core, 352 Windows/CLI tests). Gitea refuses the merge only because its shared ".NET CI" jobs never start while the Windows runner is down; the operator is distributing local rc-numbered builds (`artifacts/share/print-agent-1.2.0-rc.1`) meanwhile. PRs 1 (stale dependabot) and 6 (superseded CI entrypoint) were closed. Next: when the runner returns, merge PR 10, tag `v1.2.0` on Gitea, synchronize the mirror, verify the first matrix release (generic + GG Forge), publish the same assets on Gitea.
|
||||
|
||||
# Previous session state — 2026-11-05
|
||||
|
||||
## Active scoped work: required-nonce native sign-in fix — 2026-11-05
|
||||
|
||||
Branch `fix/native-login-required-nonce`, integrated on main `f954f11`, makes native sign-in honor the server's `NativeGrant.NonceValidation` policy. One nonce is created per authorization attempt and kept in the attempt only when the policy is `REQUIRED`; the same value is sent as `expectedNonce` at `manageSession`. An absent (legacy) policy keeps the exact old four-field body, and an unknown non-empty policy is refused before any provider or browser step. The callback never supplies the nonce, and state, issuer, PKCE, single-use and expiry checks are unchanged.
|
||||
|
||||
The shared-server scanner Authentik requires this fix. The prior stable printing release below remains historical accepted evidence and is not reopened.
|
||||
|
||||
Observed so far:
|
||||
|
||||
- Previous-base solution build and 333 CLI tests passed locally. Core tests executed 0 tests because Windows security blocked the assembly; that is hosted-CI-only and is **not a pass**.
|
||||
- The integrated NativeCI Release build passed with zero warnings/errors after extracting the code-exchange body into a private helper to meet the existing method-length limit. All 352 Windows/CLI tests passed with no failures/skips; the source-notice check passed for 300 tracked files. Core.Tests was excluded as required on this workstation.
|
||||
- Root verified, in an isolated shared scanner/Printing 1.0.0 fixture with `REQUIRED` nonce, that both exact native callback registrations and real server HTTPS discovery work. This is discovery only, not native login proof.
|
||||
|
||||
Pending: hosted CI (including Core.Tests), real shared-provider native login acceptance, and the final published build.
|
||||
|
||||
## Historical: released and installed — 2026-11-04
|
||||
|
||||
Signed Windows **1.0.1** is published on primary Gitea and GitHub, from source `5bf00f3291923f7afb34c6cce61276e69649689b`. [Release run 41532214447](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41532214447) passed its protected gates, OIDC signing and publication. Checksums, signatures and provenance were verified. The November 3 local MSI installation exited 0; app/service/CLI matched 1.0.1 (`1.0.1.65535`) and the service was Running. This docs-only update does not imply a new runtime check.
|
||||
|
||||
The matching released cohort is **qbit-printing 1.0.0**, **protocol 1.0.0** and **QQQ 4.1.0-RC.21**, with native printing API 2.3 and wire payload v1. The supported native sign-in prerequisite and protected release merges are complete. See [Release readiness](RELEASE-READINESS.md).
|
||||
|
||||
Current README and operator/maintainer guides have been refreshed against the released source. Gitea owns ordinary branches and PRs. The authorized Windows GitHub signing/CI workflows and GitHub release feed remain necessary; the installed updater uses that feed. Server/protocol Actions retirement does not retire the Windows bridge or remove the pinned protocol fixtures.
|
||||
|
||||
The user explicitly authorized publishing this documentation update after being informed that a Markdown-only push triggers the retained Microsoft-hosted workflow's full `Core.Tests` build. The branch `docs/printing-release-closeout-20261104` uses the normal Gitea PR and required checks. This authorization covers that hosted documentation build only; it does not clear local/VM6218 builds, quarantine restoration, exclusions or changes to protections. The prior local publication hold is superseded for this documentation update.
|
||||
|
||||
The requested product implementation, release and local installation are closed. No new builds, print tests, auth/storage qualification, UI-waiver work or Defender investigation belong to this documentation closeout. Earlier checkpoints below retain their original evidence and pending language as history, not active tasks.
|
||||
|
||||
## Historical: stable 1.0 closeout checkpoint — 2026-11-02
|
||||
|
||||
The official 0.4.0-rc.4 build is installed with all three components; the normal MSI
|
||||
upgrade exited 0 and the service is Running. The latest source is
|
||||
`53fe1b8281806eda7adc314f92c8205aefcb51ce`: clean hosted Windows CI run 41442796961 on
|
||||
that exact head passed 499 tests (166 Core + 333 CLI) with zero failures and skips, and the
|
||||
build and MSI had zero warnings and errors. Core.Tests is never built on the local host;
|
||||
that clean hosted pass is its evidence. The earlier run 41384467500 on `b5eb5d73` (499
|
||||
tests) is historical. The primary Gitea feed and the public feed carry all three
|
||||
prereleases (Windows 0.4.0-rc.4; server and protocol 0.4.0-rc.1), with nine assets
|
||||
checked and provenance verified. The source-header inventory (289 tracked files, 231
|
||||
C#) reported zero issues.
|
||||
|
||||
Physical output: the operator confirmed the 4 × 1 label on RC3 with the same relevant
|
||||
printing runtime; 4 × 6 and Letter were accepted earlier. Physical 4 × 2 was deferred by
|
||||
the operator and is not a stable blocker.
|
||||
|
||||
Stable signing is implemented (not in progress): the release workflow signs the owned
|
||||
binaries and the MSI with Azure Artifact Signing, as for MarkdownViewr Windows. All 19
|
||||
first-party binary copies of an installed RC4 payload, including `Cli/qqq-print-agent.dll`,
|
||||
and a disposable official RC4 MSI were Azure-signed and strictly verified (exact publisher,
|
||||
SHA256, timestamp). That is a local administrator proof; the new CI OIDC identity has not
|
||||
been exercised. See [RELEASE-SIGNING.md](RELEASE-SIGNING.md).
|
||||
|
||||
Remaining stable-release work: (1) the Windows release branch (Gitea PR 2) normal protected
|
||||
merge, which awaits the central native 1725 repair for Windows ACL and file sharing on
|
||||
Linux; (2) a supported, published QQQ core with native session login, still awaited (the
|
||||
last official release, RC20, does not have it). Installed and published remains RC4, not
|
||||
1.0. No new stable tag exists and this file makes no 1.0 claim.
|
||||
|
||||
Accepted known limitation: the post-print WinUI `LayoutCycleException` recorded under
|
||||
"Installed realtime release and companion follow-up" was removed from the 1.0 blocker
|
||||
list by the user on 2026-11-02. It is not fixed, and that history is preserved. The repeat
|
||||
Auth/S3 qualification, the latency investigation and the MSI comparison analysis were
|
||||
cancelled by the user and must not be resumed; their evidence is historical. All sections
|
||||
below are historical checkpoints; where they list work as pending they are superseded by
|
||||
this section. See [RELEASE-READINESS.md](RELEASE-READINESS.md).
|
||||
|
||||
## Historical: rc.3 ordered file version — 2026-11-01
|
||||
|
||||
Source `e10d251` implements the standard ordered numeric PE FileVersion: `X.Y.Z-rc.N`
|
||||
becomes `X.Y.Z.N` and stable becomes `X.Y.Z.65535` (details in
|
||||
[RELEASING.md](RELEASING.md)). The packaging-input checks and the actual rc.3
|
||||
production packaging passed: all three payloads report file version `0.4.0.3` and
|
||||
semantic version `0.4.0-rc.3+e10d251`. The local rc.2 installation was verified. The
|
||||
rc.3 normal MSI upgrade completed with exit 0; all three installed executables match
|
||||
the rc.3 source and report numeric file version `0.4.0.3`, with no repair required. The
|
||||
server was updated to printing 2.3.0 with pairing and history preserved.
|
||||
|
||||
Printing observation: the operator confirmed the 4 × 1 test printed perfectly after
|
||||
the initial media-alignment issue. The operator chose to keep 4 × 1 stock loaded;
|
||||
the implemented 4 × 2 test option is awaiting a future stock change, with no physical
|
||||
result claimed for that size. No further test jobs will be sent until requested.
|
||||
|
||||
Not claimed: no GA. The Gitea release artifact, update feed and shared Windows CI stay
|
||||
with the migration owner. Core.Tests is not built or run on this host, which includes the
|
||||
Gitea runner 6; it is not scheduled here.
|
||||
|
||||
## Historical: candidate 0.4.0-rc.1 integration with protected main — 2026-11-01
|
||||
|
||||
Protected main advanced during qualification (client virtual-agent behavior; server
|
||||
printing 2.2.0 rendering), so the earlier green PR heads are historical evidence
|
||||
only. The media/operator/retained feature moved to printing **2.3.0**; this client
|
||||
offers `printer-media-v1` only to a stable 2.3+ server in major two and stays a
|
||||
three-capability peer on 2.0, 2.1 and 2.2. Product candidate 0.4.0-rc.1, wire
|
||||
payload v1, no tag.
|
||||
|
||||
Observed locally (no hosted result for the final heads yet):
|
||||
|
||||
- A focused build was **rejected** by analyzer MA0051 on `PrintWorker.ProcessJobAsync`
|
||||
(67 lines, maximum 60), caused by the new per-job phase-timing instrumentation.
|
||||
It was fixed by extracting `SubmitJobAsync`, `RequireCurrentPrinter`,
|
||||
`RequireSubmitAllowed`, `SubmitNativeAsync` and a nested `JobAttempt`, with no
|
||||
analyzer waiver. Reservation, staged-file and submission leases are still
|
||||
released inside the same catch, and the authority checks and state order are
|
||||
unchanged (reviewed by the coordinator).
|
||||
- The focused negotiation/runtime run then passed **16 of 16** with no warnings or
|
||||
analyzer errors. Core.Tests was not built locally (Defender quarantine unchanged).
|
||||
- The Roslyn token compare against the merge base found exactly the four intended
|
||||
semantic files changed and no fifth file.
|
||||
- Server: the merged candidate's full verify passed 189 reported / 188 executed /
|
||||
1 intentional opt-in skip / 0 failures, Checkstyle 0, after fixing a real
|
||||
lifecycle defect (realtime channel workers outliving the stopped host).
|
||||
|
||||
Pending, not claimed: hosted CI on the final signed heads (including Core.Tests and
|
||||
the MSI build), profile-selected physical output, and installed browser/private-callback
|
||||
sign-in against a published API 1.9+ server. The earlier plans for repeat Auth/S3
|
||||
qualification, ZPL test-print latency measurement and installer/MSI analysis were
|
||||
cancelled by the user and are not active work; the evidence recorded earlier in this
|
||||
file and in [UPGRADE-QUALIFICATION.md](UPGRADE-QUALIFICATION.md) is historical and kept.
|
||||
No 1.0 claim. See [RELEASE-READINESS.md](RELEASE-READINESS.md).
|
||||
|
||||
## Historical: final pin and release identity — 2026-11-01
|
||||
|
||||
The final protocol pin is `42c0d249f81395f00408ec0f92638dcbbf5deb03`
|
||||
([Gitea PR 2](https://git.kof22.com/QRun-IO/qqq-print-protocol/pulls/2)); root updated
|
||||
`.github/workflows/ci.yml` and the Gitea standards-exception (EX-0015) citation. The
|
||||
[standards PR 54](https://github.com/KofTwentyTwo/standards/pull/54) is recorded as
|
||||
merged after CI 777778 passed. The earlier pins (`a26c18c9…`, `df3729ac…`, `f3cbc02e…`)
|
||||
are historical evidence. Because a 0.4.0-rc.1 build is already installed locally and the
|
||||
updater needs a strictly newer semantic version, the candidate identity moves to
|
||||
**0.4.0-rc.2**: `VersionSuffix` and the `Build-Installer.ps1` default change, while
|
||||
`VersionPrefix`/ProductVersion stay 0.4.0 (the installer already allows same-version
|
||||
upgrades, so RC to RC upgrades in place). No behavior, dependency or lock change. The
|
||||
`ci.yml` release-version defaults are protected and are proposed as a patch for root
|
||||
review. Server strict full verify (R31) passed; the merged UI build and 35 paper/media
|
||||
tests passed locally. Hosted Windows CI and the update-feed handoff await the migration
|
||||
owner; nothing is installed or tagged.
|
||||
|
||||
## Historical: release cohort integration — 2026-11-01
|
||||
|
||||
Integration branch `release/printing-cohort-20261101` merges the completed C# source
|
||||
documentation cleanup (every type, method, constructor and non-auto property has a
|
||||
how/why header and the `.editorconfig` structural spacing, proven comment-and-blank-line
|
||||
only by the Roslyn token compare per batch) with the small test-print label change
|
||||
(4 × 6, 4 × 2 and 4 × 1 label stock at 203/300/600 dpi, job-only actual-size paper for
|
||||
short PDF labels, and the 2000 mm paper bound). The three merge conflicts
|
||||
(`PrintOptions.cs`, `TestPrintEditor.cs`, `TestPrintProfileTests.cs`) were resolved by
|
||||
keeping both: the new behavior and the headers. No behavior was rolled back.
|
||||
|
||||
Observed locally on the merged tree (R39): the production UI build under strict XML and
|
||||
analyzers passed with zero warnings, and the 35 existing paper/media tests passed.
|
||||
Core.Tests is hosted-only (Defender quarantine unchanged). The prior protected base
|
||||
`0336d5f` had 477 hosted tests; this head still needs a normal Gitea Core, CLI and build
|
||||
run. Pending: that CI run, installation of the new label implementation, and a
|
||||
small-stock paper test. The label implementation is **not installed** and has produced no
|
||||
physical output; the user's 4 × 6 stock is still loaded and unchanged. Native printing
|
||||
stays 2.3 with wire payload v1; the protocol pin awaits root's final commit. The existing
|
||||
Auth/S3 qualification is accepted and no new desktop or provider proof is claimed. The product stays
|
||||
0.4.0-rc.1, unreleased; no version bump or new updater design is part of the Gitea
|
||||
migration. Publishing and update-feed dependency: the AppKit updater reads the fixed
|
||||
GitHub release channel, so a Gitea move needs a decision on where releases are published
|
||||
before any update is offered from there.
|
||||
|
||||
## Candidate Echo media discovery — 2026-11-01
|
||||
|
||||
The isolated candidate implements `printer-media-v1`, machine-wide queue-default
|
||||
profiles, immutable media selection and pre-spool drift refusal. Selected PDFs
|
||||
use a driver-normalized job-only copy of the captured DEVMODE; RAW's physical-stock limitation is explicit
|
||||
in `docs/PRINTER-MEDIA.md`. Read-only native discovery produced a stable configured
|
||||
4 × 6 inch, 300 × 300 DPI profile across three helper launches. No live component
|
||||
was changed and no physical output was submitted.
|
||||
|
||||
Core/Windows/CLI and synthetic test projects compile without warnings. Seven
|
||||
initial adapter cases and one isolated-worker refusal case passed. Review added
|
||||
driver-normalized copies/scale overrides and effective DC geometry/DPI validation
|
||||
before StartDoc; all 24 focused Windows adapter cases now pass with zero build
|
||||
warnings. The original queue fingerprint remains unchanged, and the driver may
|
||||
update private settings only in the job's in-memory buffer. Unsupported override
|
||||
fields stay absent. A read-only probe of the corrected production adapter passed
|
||||
on the configured label driver, including its absent native-scale flag and an
|
||||
effective 1,200 × 1,800 pixel DC at 300 DPI, without StartDoc or queue changes.
|
||||
A Core run executed
|
||||
150 cases, with 147 passing (including the new media/worker cases) and three
|
||||
failing because the new worktree's linked fixtures were missing. The subsequent
|
||||
full rerun is blocked by local Defender quarantine of the Core test assembly;
|
||||
no protection changes, exclusions or quarantine restoration were performed.
|
||||
This is not a clean full-suite or release qualification. Parent review owns the
|
||||
security provenance investigation and the remaining server/hardware gates.
|
||||
The shared selection validator also enforces protocol IDs of at most 128 ASCII
|
||||
URL-safe characters, profile names of 1–512 characters and at most 32 profiles.
|
||||
New Core boundary regressions are present for hosted validation and were not
|
||||
locally compiled or run after the quarantine.
|
||||
Capability negotiation requires discovered stable printing 2.3+ within major
|
||||
two (2.2 is the rendering extension without media negotiation); older 2.0/2.1/2.2 servers retain the original three-capability HELLO. The real
|
||||
session/loopback-WebSocket compatibility tests cover same-session upgrade and
|
||||
downgrade, refreshing discovery once per socket (separate from initial sign-in
|
||||
discovery), with zero forbidden HTTP polls. The combined focused Windows gate
|
||||
passed 40 cases with zero build warnings/errors.
|
||||
# Session state — 2026-11-01
|
||||
|
||||
## Query in About — 2026-11-01
|
||||
|
||||
|
||||
+1
-77
@@ -1,81 +1,5 @@
|
||||
# TODO
|
||||
|
||||
## Released: 1.2.1 — 2026-11-06
|
||||
|
||||
Client 1.2.1 (source `3b9d75d`, GitHub release run 42223525626) is the current stable: signed generic and GG Forge installers on GitHub and, byte-identical, on Gitea. See [RELEASING](RELEASING.md).
|
||||
|
||||
- [x] Nonce fix PR 11 (`abc3d0d`) and the OPTIONAL/discovery nonce change (`c59814d`) shipped in `v1.2.0`/`v1.2.1`.
|
||||
- [x] Release run 42128511730 on `v1.2.0` failed in `sign (gg-forge)`: `release.yml` array-splatted `-Edition`, binding it positionally to `-CertificateThumbprint`. Fixed in PR 12 (`3b9d75d`); `v1.2.0` stays immutable and unpublished.
|
||||
- [x] `v1.2.1` tagged on the merge commit, mirrored to GitHub, signed, assembled and published; checksums, signatures and provenance verified; same assets attached to the Gitea release.
|
||||
- [x] Post-merge main checks (ci, codeql, security, scripts, scorecard) green on `b583130`.
|
||||
- [ ] Actual native sign-in/sign-out acceptance against the shared provider (the shared-fixture discovery pass is not acceptance).
|
||||
|
||||
## Historical: required-nonce native sign-in fix — 2026-11-05 (published in 1.2.x)
|
||||
|
||||
The stable printing release below remains accepted historical evidence. The shared-server scanner Authentik requires this fix (`fix/native-login-required-nonce`, integrated on main `f954f11`).
|
||||
|
||||
- [x] Implement per-attempt nonce kept only under `REQUIRED`, sent as `expectedNonce`; legacy four-field body preserved; unknown policy refused before provider/browser.
|
||||
- [x] Root verified exact native callback registrations and real server HTTPS discovery in an isolated shared scanner/Printing 1.0.0 fixture with `REQUIRED` nonce (discovery only, not login proof).
|
||||
- [x] Integrated NativeCI Release build: zero warnings/errors; 352 Windows/CLI tests passed with no failures/skips; source notices passed for 300 tracked files. Existing method-length limit retained; Core.Tests excluded locally.
|
||||
- [x] Hosted CI including Core.Tests: GitHub CI on the editions branch carrying this fix passed 182 Core and 352 Windows/CLI tests (run 42078731426, 2026-11-05).
|
||||
- [ ] Real shared-provider native login acceptance.
|
||||
- [x] Final published build: 1.2.1 (see above).
|
||||
|
||||
## Closed product release — 2026-11-04
|
||||
|
||||
- [x] Publish signed Windows 1.0.1 on Gitea and the GitHub updater feed; release run 41532214447 passed its gates and Azure OIDC signing.
|
||||
- [x] Verify release assets, signatures and provenance; install the official MSI locally. November 3 receipt: exit 0, matching app/service/CLI 1.0.1, service Running.
|
||||
- [x] Publish qbit-printing 1.0.0 and protocol 1.0.0 with QQQ 4.1.0-RC.21 native sign-in support.
|
||||
- [x] Refresh current README, operator and maintainer documentation against the stable source; preserve dated evidence.
|
||||
|
||||
The requested product work is complete. Shared CI migration is separate. Everything below is historical or unscheduled backlog; unchecked boxes do not reopen work or authorize more tests. See [Release readiness](RELEASE-READINESS.md).
|
||||
|
||||
## Documentation publication scope
|
||||
|
||||
The user authorized publishing `docs/printing-release-closeout-20261104` through a normal Gitea PR, including its automatic Microsoft-hosted checks. The previous publication hold is superseded for that scope. No code or workflow change, local/VM6218 build, quarantine restoration or protection exception is part of this update. The PR records publication and merge status.
|
||||
|
||||
## Historical stable 1.0 checkpoint — 2026-11-02
|
||||
|
||||
Active stable-release work is only the two open items below. Every other unchecked item in this file is historical or unscheduled backlog, not a stable-1.0 blocker.
|
||||
|
||||
- [x] Official 0.4.0-rc.4 installed (all three components); normal MSI upgrade exit 0 and the service Running.
|
||||
- [x] Clean hosted Windows CI run 41442796961 on source `53fe1b8281806eda7adc314f92c8205aefcb51ce`: 499 tests (166 Core + 333 CLI), zero failures and skips; build and MSI zero warnings and errors. Core.Tests is never built locally; this hosted pass is its evidence. (Earlier run 41384467500 on `b5eb5d73` is historical.)
|
||||
- [x] Stable signing implemented (workflow, `Sign-Release.ps1`, packaging). All 19 first-party binary copies of an installed RC4 payload, including `Cli/qqq-print-agent.dll`, and a disposable official RC4 MSI were Azure-signed and strictly verified; this is a local administrator proof and the new CI OIDC identity is not yet exercised. Installed and published remains RC4; no new stable tag.
|
||||
- [x] Primary Gitea feed and public feed carry all three prereleases (Windows 0.4.0-rc.4; server and protocol 0.4.0-rc.1); nine assets checked and provenance verified.
|
||||
- [x] Physical 4 × 1 passed by the operator on RC3 (same relevant printing runtime); 4 × 6 and Letter accepted earlier. Physical 4 × 2 is deferred by the operator and is not a stable blocker.
|
||||
- [x] Source-header inventory (289 tracked files, 231 C#) reported zero issues.
|
||||
- [ ] Windows release branch (Gitea PR 2): normal protected merge, awaiting the central native 1725 repair for Windows ACL and file sharing on Linux.
|
||||
- [ ] Supported, published QQQ core with native session login (the last official release, RC20, does not have it); stable tag publication, including the first CI OIDC signing run, follows it.
|
||||
- Accepted known limitation (user decision 2026-11-02, not a blocker, not fixed): the post-print native UI layout exception; see the historical entries below.
|
||||
- Cancelled by the user, do not resume: repeat Auth/S3 qualification, the latency investigation and MSI comparison analysis.
|
||||
|
||||
## Historical rc.3 status (superseded by rc.4 and the closeout above)
|
||||
|
||||
- [x] Implement ordered numeric FileVersion (rc.N revision N, stable 65535) in packaging; source `e10d251`, packaging-input checks and rc.3 production packaging passed (all payloads `0.4.0.3`, `0.4.0-rc.3+e10d251`).
|
||||
- [x] Complete the local rc.3 installation: normal MSI upgrade exit 0; app, service and CLI match rc.3 and numeric file version `0.4.0.3`, with no repair required.
|
||||
- [x] Confirm the 4 × 1 label prints correctly after media alignment; operator reports it printed perfectly.
|
||||
- [ ] Physical 4 × 2 test deferred by the operator, who chose to keep 4 × 1 loaded. The test option is implemented; do not send another label until requested. Not a stable blocker.
|
||||
- [x] Gitea release artifact and update feed: published for rc.4 (see the closeout above). Shared Windows CI remains the active protected-merge item; no GA claim. Core.Tests is not run on this host (including Gitea runner 6).
|
||||
|
||||
## Script quality gates
|
||||
|
||||
- [x] Pin Ruff, strict Pyright, ESLint/Stylistic and strict checkJs with locked restores; reject untracked-scope Python/JavaScript and zero-file Pyright runs; negative tool probes pass locally; virtual-agent Downloads/identity behavior preserved.
|
||||
- [ ] Observe the new `virtual-agent` job steps pass on hosted CI before relying on them.
|
||||
|
||||
## Candidate Echo printer media
|
||||
|
||||
- [x] Discover truthful machine-default dimensions/DPI and expose an opaque, revision-bound profile.
|
||||
- [x] Reject stale/unknown selection before spool; pass captured validated defaults to selected PDF DCs.
|
||||
- [x] Normalize supported job-only driver copies/scale and verify effective DC dimensions/DPI before StartDoc (24 focused Windows tests plus read-only actual driver/DC probe).
|
||||
- [x] Verify synthetic 4 × 1 / 4 × 2 / 4 × 6 profiles, driver-private drift, unknown DPI and isolated-worker refusal.
|
||||
- [x] Verify configured 4 × 6 / 300 DPI fingerprint stability across three read-only helper launches.
|
||||
- [x] Complete the C# source standards cleanup (how/why headers and structural spacing) and merge it with the short-label change; token-compared as comment/blank-line only per batch.
|
||||
- [x] Merged integration tree: production UI build (strict, 0 warnings) and the 35 existing paper/media tests passed locally (R39).
|
||||
- [x] Hosted CI passed (499 tests, run 41384467500), the label implementation is installed in rc.4, and the 4 × 1 stock printed correctly (4 × 2 deferred, not a blocker). Earlier text here said "not installed yet"; that is historical.
|
||||
- [x] Local Core test-assembly quarantine: not pursued. Core.Tests is never built locally; the clean hosted pass is the evidence and no protection was disabled.
|
||||
- [ ] Qualify the matching server/consumer catalog, immutable selection and authorized retained-document submission.
|
||||
- [ ] Perform separately authorized installed/profile-selected physical acceptance; available alternative stock is not current queue configuration.
|
||||
|
||||
## Query in About
|
||||
|
||||
- [x] Place the official mascot and dogfooding/Query links directly below the
|
||||
@@ -95,7 +19,7 @@ Active stable-release work is only the two open items below. Every other uncheck
|
||||
- [ ] Qualify the installed Windows browser/private callback against a compatible published QQQ runtime and configured HTTPS provider; the container HTTPS callback does not establish this installed flow.
|
||||
- [x] Publish and verify all three 0.3.0-rc.1 releases; install the matched local server/client and verify retained state and service restart recovery.
|
||||
- [x] Capture the operator's post-print LayoutCycleException and recover only the companion; retain existing print receipts.
|
||||
- [ ] Native post-print layout cycle: reproduce and fix with a nonprinting host. **Accepted known limitation for 1.0 (user decision 2026-11-02): not fixed and not a blocker.** Left unchecked as history.
|
||||
- [ ] Reproduce and fix the native post-print layout cycle with a nonprinting host and verify the actual dialog/action/refresh path.
|
||||
- [x] Verify actual native fatal-layout exception logging, handler order and natural diagnostic termination; keep this mitigation distinct from fixing the original trigger.
|
||||
- [x] Accept scheduler-normalized account names only when they resolve to the exact expected startup SID; verify rejection of other identities and the unchanged actual task.
|
||||
- [x] Show explicit WebSocket state, server endpoint, live upload/download rates and service-lifetime application-traffic totals in the footer; verify actual native presentation with synthetic traffic.
|
||||
|
||||
@@ -3,31 +3,29 @@
|
||||
| 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. |
|
||||
| Sign-in or discovery fails | Open **View logs**: every step (discovery, authentication metadata, native grant validation, provider discovery, browser callback, session verification by the service, device binding, identity check) is logged with the reason it stopped, and the dialog shows the same reason. Turn on **Debug logging** in Log settings for request routes, status codes, timings and realtime frames. |
|
||||
| Sign-in works in the companion but "Service authorization not completed" | The service verifies the session itself as LocalSystem. A self-signed or private-authority certificate (for example a local Caddy root) that your user account trusts is not in the machine store, so the service's TLS check fails; the log shows `TLS certificate for host rejected: RemoteCertificateChainErrors`. Install the issuing root in **Local Machine → Trusted Root Certification Authorities**, or enter the server address and enable **Allow untrusted server certificate for this server** in Connection settings for a development server (the exception is bound to that one server). |
|
||||
| Enrollment fails | Complete standard QQQ sign-in, then check the server URL, fresh/unconsumed code, device/facility configuration and server logs. Remote endpoints require HTTPS. The log records each enrollment step and the server's answer. |
|
||||
| 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 | Share the queue locally, enable the matching discovered printer in QQQ and submit to that printer on the intended device. |
|
||||
| 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, authenticated realtime connection, current user/device authority, server pause/disconnect state, queue approval/exposure and configured capacity. The cloud preview is bounded; inspect server records for the full queue. |
|
||||
| Realtime connection cannot start | Use the released native printing API 2.3 server and allow outbound WSS upgrades to the configured origin. Inspect the connection/policy error; a legacy native API 1.x server or blocked socket does not enable HTTP polling fallback. |
|
||||
| Realtime connection cannot start | Require printing extension 2.0 and allow outbound WSS upgrades to the configured origin. Inspect the safe connection/policy error; a 1.0 server or blocked socket does not enable HTTP polling fallback. |
|
||||
| CONFIGURATION_REQUIRED | Have the QQQ administrator correct the complete effective policy or upgrade the incompatible client. Limits must fit declared client ceilings; reconnect does not silently lower them. |
|
||||
| SPOOLED but no output | Check machine Printing Defaults, hardware media/method, pause and paper/label/ribbon conditions. A clear Windows queue or READY status is not proof of physical output. |
|
||||
| OUTCOME_UNKNOWN | Inspect output and the Windows queue before reprinting. Do not delete the journal or assume failure. |
|
||||
| PDF clipped or wrong size | Select actual size versus fit deliberately. Match the machine default, advertised media profile or explicit per-job PDF dimensions to the loaded stock; see [Printer media](PRINTER-MEDIA.md). |
|
||||
| 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, DPI, stock dimensions and actual printer mode. Inspect the specific document; previously confirmed output does not qualify every format/driver combination. |
|
||||
| Protected configuration cannot be opened | It belongs to another Windows identity, or the service account changed. Re-enroll through the service after resolving old jobs; do not copy a user-enrolled DPAPI file. |
|
||||
| Another worker owns the directory | Stop the duplicate CLI worker. Only one worker may own a given journal/data directory. |
|
||||
| MSI publisher warning | Stable 1.0.1 is signed. Verify the exact official release checksum and Authenticode signature as described in [Installation](INSTALLATION.md). Earlier prereleases may be unsigned. |
|
||||
| MSI publisher warning | Current prerelease builds are unsigned. Verify the release checksum and follow local installation policy. |
|
||||
|
||||
## ZPL prints but PDF does not
|
||||
|
||||
The paths use different settings: RAW ZPL carries its format instructions, while PDF is rendered through the Windows driver using its default or the requested per-job media. A diagnostic media choice does not rewrite the machine driver's shared configuration; short-label PDF tests send explicit job dimensions. A default that works in a signed-in user's **Printing Preferences** may differ from the defaults used by the Windows service.
|
||||
The paths use different settings: RAW ZPL carries its format instructions, while PDF is rendered through the Windows driver using its defaults. The diagnostic media choice controls the generated document, not the machine driver's configuration. A default that works in a signed-in user's **Printing Preferences** may differ from the defaults used by the Windows service.
|
||||
|
||||
For 4 × 6-inch direct-thermal stock with no ribbon, inspect **Printer properties → Advanced → Printing Defaults** for the queue. Use **4.00 × 6.00 inches** and **Thermal Direct**, with the printer's correct DPI; preserve other settings. Other supplies may require different dimensions or Thermal Transfer. Also verify the physical printer's print method and pause/error state. Do not assume Windows READY or an empty queue rules out a hardware problem.
|
||||
|
||||
During 0.1.7 acceptance the machine defaults were 4.09 × 2-inch Thermal Transfer. The ZPL label printed, but the PDF did not despite SPOOLED. Correcting the machine defaults and hardware state allowed one fresh reviewed PDF to print fully and correctly; the user confirmed it. No application-code fix was needed. Inspect the existing job/output before retrying to avoid duplicates. See [acceptance evidence](ACCEPTANCE.md).
|
||||
|
||||
For a non-security bug, include Windows version, application version, printer model/driver, the stage that failed, job ID and sanitized error text in a [bug report](https://git.kof22.com/QRun-IO/qqq-print-agent/issues). Never attach credentials, enrollment codes, customer PDFs, address labels, journal databases or raw diagnostic archives without reviewing them.
|
||||
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).
|
||||
@@ -1,43 +0,0 @@
|
||||
# Upgrade-preservation qualification plan — candidate 0.4.0-rc.1
|
||||
|
||||
Status: **plan only.** Nothing here has been run. The installed baseline is 0.3.2-rc.1 (MSI SHA-256 `1191C337265469EB06136AF232DB57FAC6BC5014357521D80B35E5CCA5AF6E18`) against the local QQQ on port 18081 (API 1.8 / printing 2.0). This plan follows the observer pattern recorded for the 0.3.1 and 0.3.2 acceptances in [ACCEPTANCE.md](ACCEPTANCE.md). It needs explicit user authorization before any installation, and it never submits a job.
|
||||
|
||||
## Preconditions (all must hold before an authorized install)
|
||||
|
||||
1. Final candidate head has a green hosted gate, including the Core test job. A green hosted gate does not clear the local Defender detection of the Core test assembly.
|
||||
2. The candidate MSI comes from that exact head's CI artifact. Record its SHA-256 and compare with `SHA256SUMS` in the artifact.
|
||||
3. Static MSI inspection (no install): ProductVersion `0.4.0`; app, service and CLI payloads all carry the same full informational version `0.4.0-rc.1+<head sha>`; Windows App SDK resources, service configuration (LocalSystem, automatic start, two 30-second recovery restarts), callback registration and the packaged notice files match the 0.3.2 baseline except for intended differences. Stable component identities must match the baseline so Windows Installer upgrades rather than duplicates.
|
||||
4. A read-only "before" report exists for the installed 0.3.2 state (below).
|
||||
|
||||
## Before/after observations (read-only, no secrets)
|
||||
|
||||
Capture the same fields before and after. Use hashes or counts for protected material; never print credentials, device keys, tokens or document payloads.
|
||||
|
||||
| Item | Method | Pass condition |
|
||||
| --- | --- | --- |
|
||||
| Component versions | File version/ProductVersion of app, service and CLI | After: all three equal `0.4.0-rc.1+<head>`; bytes equal the CI payload |
|
||||
| Machine credential file | Existence and SHA-256 only | Unchanged by the upgrade |
|
||||
| Pairing and identity | Device ID presence and server URL (no token) | Unchanged |
|
||||
| Local job history | Per-row digest of job ID, state, creation time, printer ID | Every prior row present with identical digest; additions accounted for separately |
|
||||
| Server job history | Same digests from the server view | Prior rows unchanged |
|
||||
| Printer exposure and profiles | Exposed set and per-printer profile fields | Unchanged |
|
||||
| Log settings | Retention days and daily cap | Unchanged |
|
||||
| Service configuration | SCM start type, account, recovery actions | Unchanged |
|
||||
| Connectivity | Native QQQ session and authenticated WebSocket state | Restored without operator action |
|
||||
| Companion | In-app update path (Not now, then Update and restart) | Same handoff as 0.3.2; app reopens Connected |
|
||||
|
||||
## Compatibility checks specific to this candidate
|
||||
|
||||
- **Older server, new client.** Against the local printing 2.0 server the HELLO must carry exactly the three pre-existing capabilities and no `printer-media-v1`, inventory must carry no `mediaProfiles`, and existing jobs must behave as before. This is the expected state on the installed local server and is itself a required qualification result.
|
||||
- **Capability only on 2.3+.** `printer-media-v1` and `mediaProfiles` appear only after the client discovers a stable printing 2.3+ server in major 2. A printing 2.2 (rendering) server must leave the client a three-capability peer. Qualify against a server that actually runs the 2.3.0 candidate, in an isolated environment, not the installed 18081 instance.
|
||||
- **Latency diagnostic line.** After upgrade, an authorized later job (not part of this plan) writes one `timing` line per job to the service log with the job ID only. Confirm that the line contains no document text.
|
||||
|
||||
## Failure handling
|
||||
|
||||
- Windows Installer same-upgrade-code downgrade is not supported. Keep the 0.3.2 MSI and its hash so a manual return can be planned, and treat that as a separate authorized operation.
|
||||
- An MSI exit code of 3010 means restart required; report it as such. Do not delete journal, staging or credential files to recover.
|
||||
- A mismatch in any prior-row digest, credential hash or exposure set stops qualification and is reported as a failure with the earlier evidence preserved.
|
||||
|
||||
## Explicitly not covered here
|
||||
|
||||
Physical output with a selected media profile, Authenticode signing, update download or installer failure/cancellation, reboot recovery, installed browser/private-callback sign-in against a published API 1.9+ server, and any 1.0 claim. Each needs its own authorization and evidence.
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
# Using QQQ Print Agent
|
||||
|
||||
This guide describes **1.0.1**, the signed stable Windows release. Use it with qbit-printing **1.0.0** on QQQ **4.1.0-RC.21** or a compatible newer server. See [Acceptance](ACCEPTANCE.md) for dated installation and physical-print evidence.
|
||||
This guide describes installed 0.1.7. Its 227-test source gate and all 16 installed UI checks passed. Laser PDF and Zebra ZPL/PDF output were confirmed; barcode scanning, measured alignment and broader accessibility remain separate. See [Acceptance](ACCEPTANCE.md).
|
||||
|
||||
## Menus
|
||||
|
||||
@@ -10,19 +10,19 @@ This guide describes **1.0.1**, the signed stable Windows release. Use it with q
|
||||
|
||||
## Connect and select printers
|
||||
|
||||
Open **QQQ Print Agent** from Start. In **Settings → Server connection → Sign in / Connection settings**, enter your HTTPS QQQ server address and select **Discover sign-in options**. Every step of discovery, sign-in, the hand-off to the service and the service's own verification is written to the activity log, and a failure's reason is shown in the dialog as well as logged; open **View logs** when something stops. For a development server with a self-signed or private-authority certificate, enter its address and turn on **Allow untrusted server certificate for this server**; both the companion and the Windows service then accept that one server's certificate, and no other server is affected. The service runs as LocalSystem and trusts only the machine certificate store, so a certificate that your user account trusts can still be refused by the service; either install the issuing root in the Local Machine Trusted Root store or use this option. With it on, any certificate is accepted and every session is logged with a warning, so turn it off for production servers. Sign in with the advertised username/password or browser flow. **Reuse the saved pairing** is the default; an older unbound device can be explicitly associated with your account. For a new pairing, obtain an enrollment code from QQQ and choose **Connect this device**. The Windows service holds the device credential; the companion never receives it. **Reconnect** requests another connection attempt. **Sign in again** restores user authentication while preserving the saved pairing and history. Changing servers or replacing a revoked pairing requires the explicit **Set up a new pairing** choice; see [QQQ sign-in](QQQ-SIGN-IN.md).
|
||||
Open **QQQ Print Agent** from Start. In **Settings → Server connection**, choose **Connect this device**, enter your HTTPS QQQ server address and a fresh enrollment code, then connect. The Windows service holds the device credential; the companion never receives it. **Reconnect** immediately requests another connection attempt. **Sign in again** replaces the connection using a fresh code while preserving prior job evidence.
|
||||
|
||||
If enrollment times out, check QQQ before trying again: the code may already have been consumed. Generate a fresh code and revoke an unused/orphaned device through your administrator. An unreadable saved credential can be repaired by explicit enrollment; the previous encrypted file and journal are preserved.
|
||||
|
||||
In **Printers**, use the **Shared** checkbox for each intended queue, then approve it on the server. Local sharing and server approval are separate, and the **Server** column shows the second one for the server this computer is connected to: **Approved** (the server's printer catalog lists the printer for this computer), **Awaiting approval** (the server received the inventory but an administrator has not approved the printer), **Not reported** (the server could not be asked: an older server, or the signed-in user may not read the operator printer catalog; the reason is in the tooltip and the activity log) or **Not checked** (not connected yet). The service asks the server after each inventory and about once a minute while connected, so after a sign-in to a different server every shared printer starts as awaiting approval there until that server approves it. Select a printer to expand its information directly below that row. **Settings** and **Print test** appear at the top of the expansion; there is no fixed printer-details area below the list. The description preview is limited to three lines, and **Windows diagnostics** expands the driver, port and identifier.
|
||||
In **Printers**, use the **Shared** checkbox for each intended queue, then approve it in QQQ. Local sharing and server approval are separate. Select a printer to expand its information directly below that row. **Settings** and **Print test** appear at the top of the expansion; there is no fixed printer-details area below the list. The description preview is limited to three lines, and **Windows diagnostics** expands the driver, port and identifier.
|
||||
|
||||
Double-click the compact printer row, or select it and choose **Settings**, to open a wider editor. Text selection, sharing checkboxes and controls inside the expansion retain their own behavior. Read or edit the full description in Settings. **Print test** asks for confirmation, format and stock/resolution before sending one real test through QQQ. It offers Letter/A4 PDF and 4 × 6, 4 × 2 or 4 × 1-inch PDF/ZPL labels, with 203/300/600 dpi choices for ZPL. Compatible label queues default to ZPL; the suggested DPI must be checked against the printer. Virtual PDF, XPS, fax and save-to-file queues that need interaction are unavailable to the service. **Windows printer settings** opens the native queue setup page.
|
||||
Double-click the compact printer row, or select it and choose **Settings**, to open a wider editor. Text selection, sharing checkboxes and controls inside the expansion retain their own behavior. Read or edit the full description in Settings. **Print test** asks for confirmation, format and stock/resolution before sending one real test through QQQ. It offers Letter/A4/4 × 6-inch PDF and 4 × 6-inch ZPL at 203/300/600 dpi. Compatible label queues default to ZPL; the suggested DPI must be checked against the printer. Virtual PDF, XPS, fax and save-to-file queues that need interaction are unavailable to the service. **Windows printer settings** opens the native queue setup page.
|
||||
|
||||
Printers initially sort by **Name ascending**. Click Name, Status, Location, Formats or Shared to sort that column; click it again to reverse direction. The active header shows an arrow and an accessible direction label. Long table values use ellipses and full tooltips; the expanded fields and editor expose the values separately from technical queue identity.
|
||||
|
||||
## Set machine media defaults before printing labels
|
||||
|
||||
PDF can use the machine default, an advertised media profile or explicit per-job dimensions. These job choices do not rewrite the shared Windows queue. Keep the machine default matched to the usual stock; see [Printer media](PRINTER-MEDIA.md). For a ribbonless direct-thermal printer loaded with 4 × 6-inch labels, open **Windows printer settings → your queue → Printer properties → Advanced → Printing Defaults**. Set width **4.00 inches**, height **6.00 inches**, and the printing method to **Thermal Direct** (driver wording may vary). Preserve unrelated settings. **Printing Preferences** for the signed-in user may not affect the LocalSystem service.
|
||||
PDF uses the Windows driver's machine defaults; choosing a diagnostic document size does not rewrite them. For a ribbonless direct-thermal printer loaded with 4 × 6-inch labels, open **Windows printer settings → your queue → Printer properties → Advanced → Printing Defaults**. Set width **4.00 inches**, height **6.00 inches**, and the printing method to **Thermal Direct** (driver wording may vary). Preserve unrelated settings. **Printing Preferences** for the signed-in user may not affect the LocalSystem service.
|
||||
|
||||
Use the method appropriate to your actual printer and supplies; Thermal Transfer expects ribbon. Check the printer's own mode and pause/error state as well as Windows. After correcting a mismatch, inspect existing output/queue state before requesting one new test. A job marked **SPOOLED** can still fail to produce paper or a label. See [label troubleshooting](TROUBLESHOOTING.md#zpl-prints-but-pdf-does-not).
|
||||
|
||||
@@ -32,7 +32,7 @@ The Jobs table aligns document title, printer, state, format and **Created** tim
|
||||
|
||||
Click Document, Printer, Status, Format or Created to change sorting; click the active header again to reverse it. Sorting applies to the entire matching result set before 50-row paging, including the bounded cloud queue preview. Changing sort returns to the first page. The direction indicator describes the last successfully loaded order; a failed refresh leaves the old rows with an unavailable/stale explanation.
|
||||
|
||||
Select a row to expand its details, recent events and actions directly below that row. Double-click the row or choose **Details** to open a wider dialog with labeled document/printer/outcome fields and a structured event history. The dialog follows the available window size and scrolls vertically. Previous and Next browse 50 results at a time. Search matches the beginning of a document title, printer name or job ID; choose a state or Needs attention to narrow results. Long table text is abbreviated with full tooltips; Details exposes selectable full values. Older jobs retain their IDs when their title was never recorded.
|
||||
Select a row to enable its actions. Double-click the row or choose **Details** to open a wider dialog with labeled document/printer/outcome fields and a structured event history. The dialog follows the available window size and scrolls vertically. Previous and Next browse 50 results at a time. Search matches the beginning of a document title, printer name or job ID; choose a state or Needs attention to narrow results. Long table text is abbreviated with full tooltips; Details exposes selectable full values. Older jobs retain their IDs when their title was never recorded.
|
||||
|
||||
- **Details** shows local events and whether each was reported to QQQ.
|
||||
- **Print again** creates a new server job after confirmation. Inspect output first, especially for uncertain or previously spooled work. The original job is never reset.
|
||||
@@ -58,11 +58,11 @@ Use **Settings → Computer details** to edit this installation’s display name
|
||||
|
||||
## Tray, logs and service
|
||||
|
||||
The footer and tray use text plus color: green for connected/ready, orange for exposed printer issues or no shared queues, and red for disconnection, failures or unreviewed uncertain jobs. The footer also shows the WebSocket connection, configured server and live upload/download rates, with totals since service start in the traffic tooltip. Select the server to open connection settings, or use **Reconnect** and **View logs**. These counters describe application traffic, not all network traffic on the computer. Printer status is a Windows observation, not guaranteed hardware telemetry.
|
||||
The footer and tray use text plus color: green for connected/ready, orange for exposed printer issues or no shared queues, and red for disconnection, failures or unreviewed uncertain jobs. Printer status is a Windows observation, not guaranteed hardware telemetry.
|
||||
|
||||
**Logs**, **View logs**, and **Open activity log** all open the same built-in AppKit viewer. Service and companion activity are together, persisted under the protected machine data directory with configurable daily rotation and retention. Use **Follow**, **Errors only**, **Copy**, and **Open logs folder**. Credentials and document payloads must never be logged; local identifiers remain operational data.
|
||||
|
||||
**Settings → Diagnostic logs → Log settings** controls retention (1–365 days, default 30), the file size at which a day's log rolls over (1–1024 MiB, default 100), how many rolled-over segments of a day are kept (1–100, default 10) and **Debug logging**. A file that reaches the size limit is renamed to a numbered segment (`qqq-print-agent-yyyy-MM-dd.1.log`, `.2.log`, …) and logging continues in a fresh file; logging never stops because of size, and the oldest segments beyond the kept count are removed. Debug logging adds every request route, status code and timing, every realtime frame and full failure detail; credentials, documents and enrollment codes are never logged at any level. **Clear local logs** confirms removal of diagnostic logs only, preserving credentials, jobs, settings and journal history. The same controls are available on Logs.
|
||||
**Settings → Diagnostic logs → Log settings** controls retention (1–365 days, default 30) and daily size (1–100 MiB, default 20). It shows usage and whether the daily cap was reached. At the cap, printing continues but diagnostics pause until the next local day, an increased limit or explicit clearing. **Clear local logs** confirms removal of diagnostic logs only, preserving credentials, jobs, settings and journal history. The same controls are available on Logs.
|
||||
|
||||
Settings shows actual Windows service state and provides **Start service**, **Restart service**, **Windows Services**, and an installer link. Restarting can interrupt a submission; wait for active work when possible. The service continues printing when the companion exits.
|
||||
|
||||
@@ -72,8 +72,8 @@ Administrators can apply [customer branding](BRANDING.md) without rebuilding. Ap
|
||||
|
||||
## Branded warehouse test prints
|
||||
|
||||
Select a printer and choose **Print test**. Choose a document format and stock from the native option boxes, then confirm **Print one test**. PDF offers Letter, A4 or a 4 × 6, 4 × 2 or 4 × 1-inch label. ZPL offers those label sizes at 203, 300 or 600 dpi. Match the loaded stock and actual device resolution; the driver-name suggestion is only a hint. The default stays 4 × 6 until you pick another size, so load 4 × 1 or 4 × 2 stock before choosing it. PDF stock choices do not change Windows queue defaults; a 4 × 2 or 4 × 1 PDF sends its own actual-size paper for that job only, with the released qbit-printing 1.0.0 server.
|
||||
Select a printer and choose **Print test**. Choose a document format and stock from the native option boxes, then confirm **Print one test**. PDF offers Letter, A4 or a 4 × 6-inch label. ZPL offers 4 × 6-inch labels at 203, 300 or 600 dpi. Match the loaded stock and actual device resolution; the driver-name suggestion is only a hint. PDF stock choices do not change Windows queue defaults.
|
||||
|
||||
QQQ generates the same branded diagnostics for desktop requests and its own printer record/table actions. The pages use Query, QQQ's mascot, large warehouse identifiers, dotted zeros and distinct 1/I/l glyphs from embedded JetBrains Mono Bold. PDF embeds the font; ZPL carries rasterized glyphs in bounded document graphics, so neither depends on a resident printer font. Label diagnostics include a Code 128 barcode on the ZPL path, and all layouts include size/alignment checks. Paper output and scanner readability must be checked physically.
|
||||
QQQ generates the same branded diagnostics for desktop requests and its own printer record/table actions. The pages use the QQQ logo, large warehouse identifiers, dotted zeros and distinct 1/I/l glyphs from embedded JetBrains Mono Bold. PDF embeds the font; ZPL carries rasterized glyphs in bounded document graphics, so neither depends on a resident printer font. Label diagnostics include a Code 128 barcode on the ZPL path, and all layouts include size/alignment checks. Paper output and scanner readability must be checked physically.
|
||||
|
||||
Use qbit-printing 1.0.0 for the complete set of branded profiles and per-job media options. Older clients can omit the profile, defaulting to Letter PDF or 203-dpi 4 × 6 ZPL. Native QQQ actions can process selected printers; group devices with the same stock and label resolution. Connecting or discovering a device never automatically sends a test.
|
||||
Use qbit-printing 0.1.3-rc.1 or later for these branded profiles. Older clients can omit the profile, defaulting to Letter PDF or 203-dpi 4 × 6 ZPL. Native QQQ actions can process selected printers; group devices with the same stock and label resolution. Connecting or discovering a device never automatically sends a test.
|
||||
@@ -70,11 +70,7 @@ The maintainer accepted this exact 15-package scope on 2026-11-01.
|
||||
[EX-0014](https://github.com/KofTwentyTwo/standards/blob/fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152/exceptions/register.md#ex-0014)
|
||||
is the approved record, merged through the normal protected workflow in
|
||||
[standards PR 56](https://github.com/KofTwentyTwo/standards/pull/56). The caller
|
||||
allows only these 15 package names through the automated license check, plus the
|
||||
one separately approved EX-0015 development-only `pkg:npm/uri-js` entry. The SDK
|
||||
guard accepts exactly that finite union; any other npm or other-ecosystem purl, or
|
||||
a versioned or duplicate entry, is rejected, and `check-script-quality.py` pins the
|
||||
uri-js version, lock integrity and license bytes.
|
||||
allows only these 15 package names through the automated license check.
|
||||
|
||||
EX-0014 is limited to these exact package/version/artifact tuples through
|
||||
2027-04-30 UTC inclusive. The required CI and pre-MSI guard rejects use from
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
# Threat model: QQQ Print Agent
|
||||
|
||||
## Current release boundary — 1.0.1
|
||||
|
||||
The signed 1.0.1 release uses the realtime and native-authentication boundaries described here. Stable distribution now includes Azure-signed first-party binaries and MSI, checksums and provenance; the earlier unsigned audit baseline below is retained as history. Gitea is the primary source; the authorized GitHub signing workflow and fixed GitHub updater feed remain trusted distribution infrastructure. See [release signing](../RELEASE-SIGNING.md) and [current release status](../RELEASE-READINESS.md).
|
||||
|
||||
## Realtime boundary
|
||||
## Realtime source boundary — 0.3.0-rc.1 release candidate
|
||||
|
||||
The remote-control adapter accepts only inventory refresh and cancellation of one locally journaled spooler job. Desired pause/disconnect state is revisioned and persists by canonical server URL and device identity; older or conflicting revisions cannot resume a device. An independently processed authenticated WSS channel carries controls and receipts while documents stream over separate same-origin HTTPS requests. There is no job/control polling fallback. A paused device claims no new work. A disconnected device retains control traffic and in-flight receipts; already-submitted printing is not recalled. Reconnect is a transport refresh, never an OS or service restart. See [the realtime contract and operational limits](../REALTIME.md).
|
||||
|
||||
The native QQQ session adapter requires discovery before credentials and never treats a native control 404 as legacy permission. After support is negotiated, 404 does not remove restrictions. Authorization denial becomes authorization-required, without inferring that every 401/403 proves revocation. New claims wait for an authenticated, bound QQQ user and valid control state. Existing installations without a protected QQQ session wait for explicit sign-in; device proof alone cannot authorize normal service printing. Explicit isolated CLI diagnostics may use the compatibility bearer adapter with `--legacy-compat`; it is not a fallback from native authentication.
|
||||
|
||||
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. Passwords stay in the companion; provider refresh/access tokens remain server-side. Normal CLI operations use the existing authenticated service pipe. The private browser callback is fixed and MSI-owned, forwarding bounded data only into an active same-user/session companion inbox after executable verification. It carries a short-lived authorization code, never a session cookie or PKCE verifier. Core validates exact advertised redirect, random state, PKCE and single-use expiry; when the server advertises `nonceValidation: REQUIRED` it also sends only that attempt's own authorization nonce as `expectedNonce` (never a callback-supplied or regenerated value) and refuses any other non-empty nonce policy; no callback creates an authentication attempt. Provider registration, installed callback delivery and long-duration renewal remain deployment qualifications.
|
||||
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. Passwords stay in the companion; provider refresh/access tokens remain server-side. Normal CLI operations use the existing authenticated service pipe. The private browser callback is fixed and MSI-owned, forwarding bounded data only into an active same-user/session companion inbox after executable verification. It carries a short-lived authorization code, never a session cookie or PKCE verifier. Core validates exact advertised redirect, random state, PKCE and single-use expiry; no callback creates an authentication attempt. Provider registration, installed callback delivery and long-duration renewal remain deployment qualifications.
|
||||
|
||||
Realtime frames and queues have administrator-configured byte/count bounds validated against declared client ceilings. Defaults are 1 MiB per complete frame, 64 queued frames and 4 MiB queued bytes. Fragmentation does not bypass the complete-message limit. The journal admits at most 4,096 command records and 128 retained connection states, failing closed when capacity is exhausted. A durable marker precedes any effect. Crash recovery produces OUTCOME_UNKNOWN; duplicate delivery reuses the exact saved receipt rather than repeating cancellation. No raw executable, printer name or arbitrary spool ID comes from the server: CANCEL_SPOOL resolves its job ID through the current connection's local receipt and verifies the exact QQQ document at the Windows spooler boundary.
|
||||
|
||||
@@ -18,7 +14,7 @@ Successful cancellation means Windows accepted the request, not that printed pap
|
||||
|
||||
## Historical audit baseline
|
||||
|
||||
Historical baseline: 0.1.7-rc.1, reviewed 2026-10-30. The inventory and source checkpoints below retain their original evidence and pending language; they do not describe the current release status or reopen qualification work. Current distribution and acceptance are summarized above and in [Release readiness](../RELEASE-READINESS.md).
|
||||
Installed baseline: 0.1.7-rc.1; audit source changes pending release. Reviewed: 2026-10-30. This is an implementation-level inventory. The separate independent deep scan has not yet returned its findings.
|
||||
|
||||
## Boundaries and assets
|
||||
|
||||
@@ -35,15 +31,14 @@ The QQQ server supplies assigned document bytes over HTTPS. The Windows LocalSys
|
||||
| Rendering/driver execution | Native Windows PDF/GDI/spooler APIs; bounded render dimensions | Disposable child with bounded timeouts and parent-exit cleanup; child retains service privileges, so this is fault isolation, not a security sandbox |
|
||||
| RAW ZPL | Explicit compatible queue, trusted server workflow | ZPL can contain printer-control commands; not a sandboxed format |
|
||||
| Duplicate output | Persistent receipts and events; uncertain submissions require review | Database and physical printer cannot share a transaction |
|
||||
| Tray snapshot/logs | No device credential or document bytes; bounded recent jobs/logs; debug logging adds routes, status codes, timings, frame types and certificate identities, never secrets | IDs/errors remain operational data and must be reviewed before sharing |
|
||||
| Server certificate trust | Windows machine-store validation for the service and user-store validation for the companion; rejections are logged with the policy error and certificate identity | An administrator may enable "Allow untrusted server certificate" for one named server origin (scheme, host and port) for development; it accepts any certificate (including a forged one) from that server only until turned off, is persisted in the protected store, never applies to another server, and every session created under it is logged with a warning |
|
||||
| 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 and exact-release attestations | Published prereleases remain unsigned; source checks do not qualify installed behavior |
|
||||
|
||||
## Data handling
|
||||
|
||||
The service owns `connection.dpapi`, `journal.db` and its WAL. The MSI retains state through uninstall/reinstall. Revoke/decommission the device and resolve pending jobs before deliberately deleting retained state. Only delivered terminal detail whose failures were reviewed is pruned; final-state evidence is retained. Never publish raw state or customer documents.
|
||||
|
||||
Service and companion persist serialized, credential-redacted AppKit logs with 30-day retention, rolling a day's file over to numbered segments at a configurable size (default 100 MiB, at most 10 segments kept per day) rather than suppressing entries; recent job snapshots come from durable connection-scoped metadata. Tray colors describe observed connectivity/queue state, not a guarantee of physical printer readiness or completed output.
|
||||
Service and companion persist serialized, credential-redacted AppKit logs with 30-day retention; recent job snapshots come from durable connection-scoped metadata. Tray colors describe observed connectivity/queue state, not a guarantee of physical printer readiness or completed output.
|
||||
|
||||
## Follow-up
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Client editions
|
||||
|
||||
One folder per client. `./build/Build-Installer.ps1 -Edition <folder>` builds a fully branded
|
||||
installer from the same source: the companion ships the folder's mark and icon, every executable
|
||||
carries the client's product and company metadata, the MSI's product name, manufacturer, service
|
||||
display name and Start menu entry use the client's name, the MSI is named `<assetPrefix>-<version>-x64.msi`,
|
||||
and the installed updater follows that asset name on the release feed.
|
||||
|
||||
Each folder holds:
|
||||
|
||||
| File | Required | Use |
|
||||
| --- | --- | --- |
|
||||
| `edition.json` | yes | Names, accent colour and asset prefix (fields below) |
|
||||
| `app-icon.svg` | yes | Splash and About mark |
|
||||
| `app.ico` | yes | Window, taskbar, executable and Start menu icon (multi-size, 16 to 256 px) |
|
||||
| `logo.png` | no | Client logo in Settings → About and help (PNG, at most 2048 × 2048) |
|
||||
|
||||
`edition.json` fields: `id` (must equal the folder name; lowercase letters, digits, hyphens), `displayName`
|
||||
and `companyName` (at most 180 characters), optional `tagline` and `description`, optional `supportUrl`
|
||||
(HTTPS), optional `accentColor` (`#RRGGBB`, used only when white text stays readable on it) and
|
||||
`assetPrefix` (2 to 40 letters or digits; the MSI and release asset name). Optional `serverName` is what
|
||||
user-facing text calls the server ("Sign in to GG Forge", "GG Forge authorization required"); the generic
|
||||
build says QQQ. Optional `aboutMascot` and `aboutLicense` (default true) set to `false` leave the Query
|
||||
mascot section and the License section out of the About dialog; the header (name, tagline, version) and the
|
||||
Open source components credits always remain, as does the copyright in the installed LICENSE and NOTICE.
|
||||
|
||||
An edition changes presentation and labels only. Application id, data paths, executable file names,
|
||||
the install directory, the protocol handler, the MSI upgrade identity and the signing identity are
|
||||
shared with every other edition, so editions upgrade each other and the generic build in place.
|
||||
See docs/BRANDING.md.
|
||||
@@ -1,12 +0,0 @@
|
||||
<svg width="78" height="78" viewBox="0 0 78 78" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_8440_398)">
|
||||
<path d="M39.0011 0H38.9989C17.4604 0 0 17.4604 0 38.9989V39.0011C0 60.5396 17.4604 78 38.9989 78H39.0011C60.5396 78 78 60.5396 78 39.0011V38.9989C78 17.4604 60.5396 0 39.0011 0Z" fill="#E2AB44"/>
|
||||
<path d="M55.3877 26.4839L53.9782 26.0657C53.8211 26.0214 53.6574 26.1144 53.6131 26.2737C51.9957 31.1283 45.2624 32.9007 40.3059 34.2593L39.4718 34.4673C39.3678 34.5204 39.2638 34.4142 39.2638 34.2593C39.6289 32.6927 41.1423 26.9021 41.5606 25.4926C41.6048 25.3355 41.5119 25.1717 41.3526 25.1275L39.9431 24.6584C39.786 24.6141 39.6222 24.7071 39.578 24.8664C39.578 24.9195 39.5249 24.9704 39.5249 25.0235C39.5005 25.0899 39.4275 25.1253 39.3611 25.1009C39.3456 25.0943 39.3301 25.0854 39.3169 25.0744C39.0226 24.6761 38.6708 24.3243 38.2725 24.03C35.6084 22.1005 31.2803 23.5609 28.6693 27.3181C26.0606 31.0221 26.1646 35.5648 28.9304 37.4943C29.207 37.6824 29.5057 37.8395 29.8177 37.9634C29.904 38.0054 29.9416 38.1072 29.8996 38.1957C29.8819 38.2311 29.8531 38.2599 29.8177 38.2776C28.1405 39.3176 26.6292 40.6031 25.3282 42.0879C23.7129 43.9156 22.0932 47.2036 22.4583 50.1775C22.6087 51.6777 23.3633 53.0518 24.5449 53.9878C25.4454 54.6627 26.5496 55.0123 27.6758 54.9791C29.5035 54.9791 31.329 54.1449 32.7385 53.0474C36.3917 50.1222 36.9648 46.6283 37.487 43.2894C37.5401 42.9774 38.5823 37.1845 38.5823 37.1845C38.6331 36.95 38.8168 36.7663 39.0513 36.7154C39.6244 36.5583 40.1997 36.4034 40.8237 36.2463C46.2537 34.7859 53.664 32.8011 55.5957 26.8534C55.64 26.6963 55.5471 26.5326 55.3877 26.4883C55.3877 26.4883 55.3855 26.4883 55.3833 26.4883L55.3877 26.4839ZM35.5044 42.9752C34.9822 46.2101 34.5662 48.976 31.4861 51.4299C29.8686 52.6823 27.4147 53.5187 25.7973 52.3172C25.0648 51.7264 24.6112 50.8568 24.5449 49.9164C24.3369 47.9847 25.3282 45.2719 26.8948 43.4465C28.4348 41.7206 30.2736 40.2889 32.3225 39.218C33.5727 38.5675 34.8826 38.043 36.2368 37.6514C36.3408 37.5983 36.4448 37.7045 36.4448 37.8085C36.1328 39.2689 35.5575 42.7141 35.5066 42.9752H35.5044ZM37.0179 34.8346C36.9714 35.0537 36.8121 35.2329 36.5997 35.3037C36.0266 35.5117 34.8782 35.8768 34.8273 35.8768C31.9574 36.815 30.5479 36.1379 30.0788 35.8237C28.3065 34.5713 28.4082 31.2301 30.3399 28.5174C32.2716 25.8046 35.3495 24.55 37.1219 25.7515C38.2172 26.4817 38.6354 28.0483 38.2703 29.8737C37.8012 31.6483 37.3299 33.5269 37.0179 34.8324V34.8346Z" fill="#FFFFFF"/>
|
||||
<path d="M49.4369 41.8775C50.78 41.8643 52.0811 42.3488 53.0901 43.2361C53.2096 43.3401 53.3888 43.3401 53.5083 43.2361L54.5527 42.1917C54.6567 42.0723 54.6567 41.893 54.5527 41.7735C51.5633 38.9966 46.8879 39.167 44.1088 42.1563C41.3318 45.1457 41.5022 49.8211 44.4915 52.6003C45.9829 53.9855 47.9788 54.6957 50.01 54.5608C51.6452 54.4324 53.1963 53.7929 54.4465 52.7309C54.6036 52.6003 54.6965 52.4122 54.7076 52.2087V46.834C54.6965 46.6658 54.5616 46.5331 54.3956 46.522H49.386C49.2179 46.5331 49.0851 46.668 49.0741 46.834V48.2944C49.0851 48.4625 49.2201 48.5953 49.386 48.6064H52.309C52.4772 48.6174 52.61 48.7524 52.621 48.9184V51.162C52.6409 51.4143 52.4927 51.6466 52.2559 51.7351C51.4306 52.2131 50.4946 52.4653 49.5431 52.4653C46.618 52.4941 44.2238 50.1464 44.1928 47.2212C44.1641 44.296 46.5117 41.9019 49.4369 41.8709V41.8775Z" fill="#FFFFFF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_8440_398">
|
||||
<rect width="78" height="78" fill="#E2AB44"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"id": "gg-forge",
|
||||
"displayName": "GG Forge Print Agent",
|
||||
"companyName": "Greater Goods",
|
||||
"tagline": "Cloud printing for GG Forge",
|
||||
"description": "Connect local printers to GG Forge.",
|
||||
"accentColor": "#424242",
|
||||
"assetPrefix": "GgForgePrintAgent",
|
||||
"serverName": "GG Forge",
|
||||
"aboutMascot": false,
|
||||
"aboutLicense": false
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,84 +0,0 @@
|
||||
// Copyright (c) 2026 James Maes
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Based on K22 fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152.
|
||||
// KofTwentyTwo TypeScript / JavaScript lint configuration (K22-CODE-TS).
|
||||
// The Kingsrook layout (3-space indent, Allman braces, if(...) with no space, no more than
|
||||
// one blank line inside code) is enforced by ESLint Stylistic rather than Prettier, which
|
||||
// cannot express Allman braces. Type-aware strict rules come from typescript-eslint.
|
||||
// Install: npm i -D eslint @eslint/js typescript-eslint @stylistic/eslint-plugin typescript
|
||||
import js from "@eslint/js";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["**", "!tools/", "!tools/**", "!eslint.config.mjs"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
tseslint.configs.strictTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { process: "readonly", console: "readonly", Buffer: "readonly", URL: "readonly", fetch: "readonly", WebSocket: "readonly", setTimeout: "readonly", clearTimeout: "readonly", structuredClone: "readonly", performance: "readonly", AbortController: "readonly", Headers: "readonly", FormData: "readonly", setInterval: "readonly", clearInterval: "readonly" },
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: { "@stylistic": stylistic },
|
||||
rules: {
|
||||
// Kingsrook layout
|
||||
"@stylistic/indent": ["error", 3, { SwitchCase: 1 }],
|
||||
"@stylistic/brace-style": ["error", "allman", { allowSingleLine: false }],
|
||||
"@stylistic/keyword-spacing": ["error", {
|
||||
before: true,
|
||||
after: true,
|
||||
overrides: {
|
||||
if: { after: false },
|
||||
for: { after: false },
|
||||
while: { after: false },
|
||||
switch: { after: false },
|
||||
catch: { after: false },
|
||||
},
|
||||
}],
|
||||
// 1 blank line inside code; up to 3 so methods can be separated by 3.
|
||||
"@stylistic/no-multiple-empty-lines": ["error", { max: 3, maxBOF: 0, maxEOF: 0 }],
|
||||
"@stylistic/lines-between-class-members": ["error", "always", { exceptAfterSingleLine: true }],
|
||||
"@stylistic/semi": ["error", "always"],
|
||||
"@stylistic/quotes": ["error", "double", { avoidEscape: true }],
|
||||
"@stylistic/comma-dangle": ["error", "always-multiline"],
|
||||
"@stylistic/eol-last": ["error", "always"],
|
||||
"@stylistic/no-tabs": "error",
|
||||
"@stylistic/no-trailing-spaces": "error",
|
||||
"@stylistic/operator-linebreak": ["error", "before"],
|
||||
"@stylistic/dot-location": ["error", "property"],
|
||||
"@stylistic/max-len": "off",
|
||||
|
||||
// Kingsrook non-layout rules
|
||||
"curly": ["error", "all"],
|
||||
"eqeqeq": ["error", "always"],
|
||||
"no-console": "error",
|
||||
"no-warning-comments": ["error", { terms: ["todo", "fixme"], location: "start" }],
|
||||
"@typescript-eslint/explicit-function-return-type": "error",
|
||||
"@typescript-eslint/naming-convention": ["error",
|
||||
{ selector: "default", format: ["camelCase"] },
|
||||
{ selector: "variable", modifiers: ["const", "global"], format: ["camelCase", "UPPER_CASE"] },
|
||||
{ selector: "typeLike", format: ["PascalCase"] },
|
||||
{ selector: "enumMember", format: ["PascalCase", "UPPER_CASE"] },
|
||||
{ selector: "import", format: null },
|
||||
{ selector: "objectLiteralProperty", format: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.js", "**/*.mjs", "**/*.cjs"],
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
// Checked JavaScript uses JSDoc plus strict tsc; TS-only return syntax is unavailable.
|
||||
rules: { "@typescript-eslint/explicit-function-return-type": "off" },
|
||||
},
|
||||
{
|
||||
files: ["tools/**/*.mjs"],
|
||||
// These command-line tools intentionally print their results and diagnostics.
|
||||
rules: { "no-console": ["error", { allow: ["log", "error"] }] },
|
||||
},
|
||||
);
|
||||
@@ -4,21 +4,15 @@
|
||||
-->
|
||||
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
|
||||
<Package Name="$(BrandName)" Manufacturer="$(BrandCompany)" Version="$(ProductVersion)"
|
||||
<Package Name="QQQ Print Agent" Manufacturer="QRun-IO" Version="$(ProductVersion)"
|
||||
UpgradeCode="D761CD03-F616-4C98-9A94-9F590A36E449" Scope="perMachine" InstallerVersion="500">
|
||||
<!-- Execute the protected-state preflight before removing the previous product.
|
||||
Deferred actions are queued until InstallExecute, not run at their table slot.
|
||||
Stable component GUIDs preserve shared files/service during old-product removal. -->
|
||||
<MajorUpgrade AllowSameVersionUpgrades="yes" Schedule="afterInstallExecute" DowngradeErrorMessage="A newer print connector is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
|
||||
<?if $(BrandForced) = 1 ?>
|
||||
<!-- An edition build carries its client's identity and applies it over any labels an earlier install stored. -->
|
||||
<Property Id="BRANDNAME" Secure="yes" Value="$(BrandName)" />
|
||||
<Property Id="BRANDCOMPANY" Secure="yes" Value="$(BrandCompany)" />
|
||||
<?else?>
|
||||
<Property Id="BRANDNAME" Secure="yes" />
|
||||
<Property Id="BRANDCOMPANY" Secure="yes" />
|
||||
<?endif?>
|
||||
<Property Id="STORED_BRAND_NAME">
|
||||
<RegistrySearch Id="FindBrandName" Root="HKLM" Key="SOFTWARE\QRunIO\PrintAgent" Name="DisplayName" Type="raw" Bitness="always64" />
|
||||
</Property>
|
||||
@@ -26,9 +20,9 @@
|
||||
<RegistrySearch Id="FindBrandCompany" Root="HKLM" Key="SOFTWARE\QRunIO\PrintAgent" Name="CompanyName" Type="raw" Bitness="always64" />
|
||||
</Property>
|
||||
<SetProperty Id="BRANDNAME" Action="UseStoredBrandName" Value="[STORED_BRAND_NAME]" After="AppSearch" Condition="NOT BRANDNAME AND STORED_BRAND_NAME" />
|
||||
<SetProperty Id="BRANDNAME" Action="UseDefaultBrandName" Value="$(BrandName)" After="UseStoredBrandName" Condition="NOT BRANDNAME" />
|
||||
<SetProperty Id="BRANDNAME" Action="UseDefaultBrandName" Value="QQQ Print Agent" After="UseStoredBrandName" Condition="NOT BRANDNAME" />
|
||||
<SetProperty Id="BRANDCOMPANY" Action="UseStoredBrandCompany" Value="[STORED_BRAND_COMPANY]" After="UseDefaultBrandName" Condition="NOT BRANDCOMPANY AND STORED_BRAND_COMPANY" />
|
||||
<SetProperty Id="BRANDCOMPANY" Action="UseDefaultBrandCompany" Value="$(BrandCompany)" After="UseStoredBrandCompany" Condition="NOT BRANDCOMPANY" />
|
||||
<SetProperty Id="BRANDCOMPANY" Action="UseDefaultBrandCompany" Value="QRun-IO" After="UseStoredBrandCompany" Condition="NOT BRANDCOMPANY" />
|
||||
<SetProperty Id="ProductName" Action="SetBrandedProductName" Value="[BRANDNAME]" After="UseDefaultBrandCompany" />
|
||||
<SetProperty Id="Manufacturer" Action="SetBrandedManufacturer" Value="[BRANDCOMPANY]" After="SetBrandedProductName" />
|
||||
<CustomAction Id="ValidateMachineStateBeforeFolders" Directory="System64Folder" ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -Command "$ErrorActionPreference='Stop'; $trusted=@('S-1-5-18','S-1-5-32-544','S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'); function AssertNode([\[]string[\]]$p) [\{] $i=Get-Item -LiteralPath $p -Force; if($i.Attributes -band [\[]IO.FileAttributes[\]]::ReparsePoint) [\{] throw 'Machine print state contains a link. Installation refused.' [\}]; $a=$i.GetAccessControl(); if($a.GetOwner([\[]Security.Principal.SecurityIdentifier[\]]).Value -notin $trusted) [\{] throw 'Machine print state has an untrusted owner. Installation refused.' [\}]; $raw=[\[]Security.AccessControl.RawSecurityDescriptor[\]]::new($a.GetSecurityDescriptorBinaryForm(),0); if($null -eq $raw.DiscretionaryAcl) [\{] throw 'Machine print state has no ACL.' [\}]; foreach($r in $a.GetAccessRules($true,$true,[\[]Security.Principal.SecurityIdentifier[\]])) [\{] if($r.AccessControlType -eq 'Allow' -and [\[]int[\]]$r.FileSystemRights -ne 0 -and $r.IdentityReference.Value -notin $trusted) [\{] throw 'Machine print state permits untrusted access. Installation refused.' [\}] [\}]; if($i.PSIsContainer) [\{] foreach($c in [\[]IO.Directory[\]]::EnumerateFileSystemEntries($p)) [\{] AssertNode $c [\}] [\}] [\}]; try [\{] $root=Join-Path ([\[]Environment[\]]::GetFolderPath('CommonApplicationData')) 'QqqPrintAgent'; for($p=$root;$p;$p=[\[]IO.Path[\]]::GetDirectoryName($p)) [\{] if(Test-Path -LiteralPath $p) [\{] if((Get-Item -LiteralPath $p -Force).Attributes -band [\[]IO.FileAttributes[\]]::ReparsePoint) [\{] throw 'Machine print state path contains a link.' [\}] [\}] [\}]; if(-not [\[]IO.Directory[\]]::Exists($root)) [\{] $acl=[\[]Security.AccessControl.DirectorySecurity[\]]::new(); $acl.SetSecurityDescriptorSddlForm('O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)'); [\[]IO.DirectoryInfo[\]]::new($root).Create($acl) [\}]; AssertNode $root; exit 0 [\}] catch [\{] [\[]Console[\]]::Error.WriteLine($_.Exception.Message); exit 1 [\}]"" Execute="deferred" Impersonate="no" Return="check" />
|
||||
|
||||
@@ -9,14 +9,8 @@
|
||||
<InstallerPlatform>x64</InstallerPlatform>
|
||||
<ProductVersion Condition="'$(ProductVersion)' == ''">0.3.0</ProductVersion>
|
||||
<PayloadRoot Condition="'$(PayloadRoot)' == ''">$(MSBuildThisFileDirectory)..\artifacts\package</PayloadRoot>
|
||||
<!-- Client editions: the build script passes the edition's names, forces them over any stored labels and names the
|
||||
MSI after the edition's asset prefix; the generic build keeps the defaults below. -->
|
||||
<MsiBaseName Condition="'$(MsiBaseName)' == ''">QqqPrintAgent</MsiBaseName>
|
||||
<BrandName Condition="'$(BrandName)' == ''">QQQ Print Agent</BrandName>
|
||||
<BrandCompany Condition="'$(BrandCompany)' == ''">QRun-IO</BrandCompany>
|
||||
<BrandForced Condition="'$(BrandForced)' == ''">0</BrandForced>
|
||||
<OutputName>$(MsiBaseName)-$(ProductVersion)-x64</OutputName>
|
||||
<DefineConstants>ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot);BrandName=$(BrandName);BrandCompany=$(BrandCompany);BrandForced=$(BrandForced)</DefineConstants>
|
||||
<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.
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@
|
||||
Build the service, AppKit companion and CLI into one x64 machine MSI:
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.1.8 -ReleaseVersion 0.1.8-rc.1
|
||||
```
|
||||
|
||||
The build publishes self-contained .NET applications into a fresh per-run directory under `artifacts/package` and builds WiX 6.0.2 authoring. An explicit publish directory must not already exist; this prevents obsolete files from entering the harvested MSI. Existing reviewed outputs can be packaged using `-SkipPublish -PayloadDirectory <path>`; all component versions must match the requested release and source version. The resulting MSI is local development output and is **unsigned unless a signing certificate is explicitly provided**.
|
||||
@@ -11,10 +11,10 @@ The build publishes self-contained .NET applications into a fresh per-run direct
|
||||
For a signing certificate already installed in the Windows certificate store:
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1 -CertificateThumbprint '<thumbprint>' -TimestampServer 'https://your-approved-timestamp-service'
|
||||
./build/Build-Installer.ps1 -ProductVersion 0.1.3 -ReleaseVersion 0.1.3-rc.1 -CertificateThumbprint '<thumbprint>' -TimestampServer 'https://your-approved-timestamp-service'
|
||||
```
|
||||
|
||||
Signing needs `signtool.exe` on PATH. The script signs the first-party executables before packaging and signs/verifies the resulting MSI. Stable releases are signed by the release workflow with Azure Artifact Signing instead; see [RELEASE-SIGNING.md](../docs/RELEASE-SIGNING.md). Never store signing secrets here.
|
||||
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.
|
||||
|
||||
@@ -36,4 +36,4 @@ The installer suppresses MSI validator ICE03 because the bundled Microsoft WinUI
|
||||
|
||||
References: [WiX service installation](https://docs.firegiant.com/wix/schema/wxs/serviceinstall/), [file harvesting](https://docs.firegiant.com/wix/schema/wxs/files/), [MSI directory security](https://docs.firegiant.com/wix/schema/wxs/permissionex/).
|
||||
|
||||
The signed stable [1.0.1 MSI](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1) is published and installed. See [release readiness](../docs/RELEASE-READINESS.md) for the dated release and installation evidence. Same-numeric-version upgrades support RC transitions; the updater separately enforces a strictly newer semantic version.
|
||||
The 0.1.3-rc.1 source gate passed 163 tests with zero warnings/errors. The new MSI is being packaged; installed startup/UI and actual in-app upgrade acceptance remain pending. Same-numeric-version upgrades support RC transitions; the updater separately enforces a strictly newer semantic version.
|
||||
Generated
-1466
File diff suppressed because it is too large.
Load diff
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"packageManager": "npm@11.19.1",
|
||||
"name": "@qrun-io/qqq-print-agent-tools",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"typescript-eslint": "8.72.1",
|
||||
"typescript": "6.0.3",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"eslint": "10.14.0",
|
||||
"@types/node": "24.10.1",
|
||||
"pyright": "1.1.414"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "11.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test tools/virtual-agent/test/virtual-agent.test.mjs",
|
||||
"lint": "eslint . --max-warnings 0"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
[project]
|
||||
name = "qqq-print-agent-script-quality"
|
||||
version = "0.0.0"
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["ruff==0.17.3"]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
@@ -1,56 +0,0 @@
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Based on K22 fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152.
|
||||
# KofTwentyTwo Python lint + format configuration (K22-CODE-PY). Copy to the repo root
|
||||
# (or merge into [tool.ruff] in pyproject.toml). `ruff format --check` and `ruff check`
|
||||
# run in CI and pre-commit with zero findings allowed.
|
||||
target-version = "py313"
|
||||
include = [".github/scripts/*.py"]
|
||||
extend-exclude = [".venv", "node_modules", "dist", "target", "bin", "obj"]
|
||||
# Kingsrook: no practical line-length limit (ruff's maximum is 320).
|
||||
line-length = 320
|
||||
# Kingsrook: 3-space indentation. Ruff's formatter supports it fully; pycodestyle's
|
||||
# "multiple of four" rules (E111/E114) are therefore not selected.
|
||||
indent-width = 3
|
||||
|
||||
[format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
line-ending = "lf"
|
||||
docstring-code-format = true
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
"F", # pyflakes
|
||||
"E4", "E7", "E9", "W", # pycodestyle errors that are layout-independent
|
||||
"I", # isort: no wildcard imports, ordered
|
||||
"N", # pep8-naming
|
||||
"UP", # pyupgrade
|
||||
"B", # bugbear
|
||||
"S", # bandit security
|
||||
"BLE", # blind except
|
||||
"T20", # print statements: use logging (Kingsrook logging rule)
|
||||
"ERA", # commented-out code (Kingsrook: no zombie code)
|
||||
"FIX", # FIXME/TODO must be resolved or tracked
|
||||
"TD", # TODO format: author and issue link
|
||||
"PTH", # pathlib over os.path
|
||||
"RET", "SIM", "C4", "PIE", "PL", "RUF",
|
||||
"ANN", # type annotations on every function
|
||||
"D", # docstrings on modules, classes, functions
|
||||
"LOG", "G", # logging hygiene
|
||||
]
|
||||
ignore = [
|
||||
"D203", # conflicts with D211
|
||||
"D213", # conflicts with D212
|
||||
"TD003", # issue link is enforced by review, not regex
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
".github/scripts/check-*.py" = ["T201"] # CLI diagnostics are the public stdout contract, not application logging.
|
||||
".github/scripts/test-*.py" = ["S101", "PLR2004"] # Synthetic assertions and explicit boundary values.
|
||||
|
||||
[lint.pydocstyle]
|
||||
convention = "pep257"
|
||||
|
||||
[lint.flake8-tidy-imports]
|
||||
ban-relative-imports = "all"
|
||||
@@ -12,29 +12,18 @@ namespace Qqq.PrintAgent.Cli;
|
||||
/// <summary>Scriptable setup, printer selection and foreground diagnostics.</summary>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Entry point. Three argument shapes are internal and fixed: the isolated printer worker (a child process the service
|
||||
/// starts for each native printer call), the MSI update helper, and the browser sign-in callback forwarder. Everything
|
||||
/// else is an operator command.
|
||||
/// </summary>
|
||||
public static Task<int> Main(string[] args) => args switch
|
||||
{
|
||||
["--printer-worker"] => PrinterWorkerHost.RunAsync(),
|
||||
["--apply-update", string request] => ExecuteAsync(() => MsiUpdateInstaller.ApplyAsync(request)),
|
||||
["--auth-callback", string callback] => ExecuteAsync(async () =>
|
||||
{
|
||||
await QqqBrowserCallback.ForwardFromHandlerAsync(callback);
|
||||
await QqqBrowserCallback.ForwardAsync(callback);
|
||||
return 0;
|
||||
}),
|
||||
_ => ExecuteAsync(() => DispatchAsync(args)),
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Runs a command and turns failures into a one-line message on standard error and exit code 1. A timeout or cancellation
|
||||
/// also exits 1, so a script never mistakes an operation that did not finish for one that succeeded.
|
||||
/// </summary>
|
||||
internal static async Task<int> ExecuteAsync(Func<Task<int>> command)
|
||||
{
|
||||
try
|
||||
@@ -49,13 +38,6 @@ public static class Program
|
||||
catch(Exception exception) { Console.Error.WriteLine(exception.Message); return 1; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Routes an operator command. Without the explicit legacy flag, printing commands go to the installed service over its
|
||||
/// authenticated pipe, because the service owns the signed-in session; only with the flag do they use a separate
|
||||
/// per-user data directory and the old device-token transport, and that is announced on every run.
|
||||
/// </summary>
|
||||
internal static async Task<int> DispatchAsync(string[] args, Func<PipeRequest, Task<string>>? serviceCommand = null)
|
||||
{
|
||||
string command = args.FirstOrDefault() ?? "help";
|
||||
@@ -90,9 +72,6 @@ public static class Program
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reports whether the legacy flag was given, and refuses it combined with --service, because legacy mode must never touch the installed service's state.</summary>
|
||||
internal static bool UseLegacyCompatibility(string[] args)
|
||||
{
|
||||
bool legacy = args.Contains("--legacy-compat", StringComparer.Ordinal);
|
||||
@@ -103,13 +82,6 @@ public static class Program
|
||||
return legacy;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Sends a normal command to the installed service and prints its reply, exiting 1 when the reply is an error. Options
|
||||
/// that only make sense for the isolated legacy mode (a data directory, run once) are refused here with an explanation,
|
||||
/// so a script cannot silently run against the wrong state.
|
||||
/// </summary>
|
||||
private static async Task<int> DispatchServiceAsync(string[] args, Func<PipeRequest, Task<string>> send)
|
||||
{
|
||||
if(args.Contains("--data-dir", StringComparer.Ordinal) || args.Contains("--once", StringComparer.Ordinal))
|
||||
@@ -129,9 +101,6 @@ public static class Program
|
||||
return response.StartsWith("Error:", StringComparison.Ordinal) ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the one-time enrollment code from the environment, or prompts for it, so the code never appears in the command line or shell history.</summary>
|
||||
private static string ReadEnrollmentCode()
|
||||
{
|
||||
string? code = Environment.GetEnvironmentVariable("QQQ_PRINT_ENROLLMENT_CODE");
|
||||
@@ -145,19 +114,12 @@ public static class Program
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the value following a named option, or null when the option is absent or has no value.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Chooses the data directory. --service selects the installed machine directory; otherwise a per-user directory is used,
|
||||
/// and the machine directory is refused there, so a foreground command can never overwrite the service's credentials by accident.
|
||||
/// </summary>
|
||||
internal static string ResolveDataDirectory(string[] args)
|
||||
{
|
||||
if(args.Contains("--service", StringComparer.Ordinal))
|
||||
@@ -172,26 +134,16 @@ public static class Program
|
||||
return directory;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Takes the exclusive lock on a data directory so two foreground commands (or one beside the service) cannot write the same journal at once.</summary>
|
||||
internal static FileStream AcquireWorkerLock(string directory)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
return new FileStream(Path.Combine(directory, "worker.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the isolated printer backend that runs each native call in a child copy of this executable, bounding any blocking driver call.</summary>
|
||||
private static IsolatedPrinterBackend CreatePrinterBackend() => new(Environment.ProcessPath ?? throw new InvalidOperationException("Cannot locate the printer helper executable."));
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Runs one legacy diagnostic claim pass. Continuous legacy polling is gone, so only --once is accepted. Ctrl+C ends the
|
||||
/// pass cleanly (exit 0); any other cancellation is a failure. It uses a separate data directory and the device token.
|
||||
/// </summary>
|
||||
private static async Task<int> RunAsync(string[] args, string directory)
|
||||
{
|
||||
if(!args.Contains("--once", StringComparer.Ordinal))
|
||||
@@ -213,10 +165,6 @@ public static class Program
|
||||
finally { Console.CancelKeyPress -= cancelHandler; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Shares or hides one printer, through the service when --service is given and otherwise in the separate legacy directory after validating against current discovery.</summary>
|
||||
private static async Task<int> SetExposureAsync(string[] args, string directory)
|
||||
{
|
||||
string id = Option(args, "--printer") ?? throw new ArgumentException("--printer is required.", nameof(args));
|
||||
@@ -239,11 +187,6 @@ public static class Program
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Enrolls this computer with a one-time code read from the environment or a prompt, never from the command line. With
|
||||
/// --service the installed service does it; otherwise the legacy path enrolls into a separate directory and saves the
|
||||
/// device connection protected for the current user.
|
||||
/// </summary>
|
||||
private static async Task<int> EnrollAsync(string[] args, string directory)
|
||||
{
|
||||
string server = Option(args, "--server") ?? throw new ArgumentException("--server is required.", nameof(args));
|
||||
@@ -272,3 +215,4 @@ public static class Program
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,11 @@ namespace Qqq.PrintAgent.Core;
|
||||
/// The application's identity, declared once and shared by the window, the command line,
|
||||
/// and the tests. AppKit derives the data folder (%LOCALAPPDATA%\qqq-print-agent), the data-folder
|
||||
/// override variable, the log file names, and the credential prefix from <see cref="Info"/>.
|
||||
/// A compiled-in client <see cref="Edition"/> changes names, colours and the update asset; machine
|
||||
/// branding applies on top of that; the id and paths never change.
|
||||
/// </summary>
|
||||
public static class AppIdentity
|
||||
{
|
||||
/// <summary>The generic identity before any edition or machine branding: id, names, links, credits, and brand.</summary>
|
||||
public static AppInfo BaseInfo { get; } = new()
|
||||
/// <summary>Who the app is: id, names, links, credits, and brand.</summary>
|
||||
public static AppInfo DefaultInfo { get; } = new()
|
||||
{
|
||||
Id = "qqq-print-agent",
|
||||
DisplayName = "QQQ Print Agent",
|
||||
@@ -52,33 +50,7 @@ public static class AppIdentity
|
||||
|
||||
|
||||
|
||||
/// <summary>The client edition compiled into this build, or null for the generic build.</summary>
|
||||
public static Edition? Edition { get; } = Edition.LoadInstalled();
|
||||
|
||||
/// <summary>The identity this build ships with: the edition's presentation over <see cref="BaseInfo"/>, before machine branding.</summary>
|
||||
public static AppInfo DefaultInfo { get; } = Edition?.Apply(BaseInfo) ?? BaseInfo;
|
||||
|
||||
/// <summary>What user-facing text calls the server: the edition's name for it, or QQQ in the generic build.</summary>
|
||||
public static string ServerName => Edition?.ServerName ?? Core.Edition.DefaultServerName;
|
||||
|
||||
/// <summary>The release asset prefix the installed updater looks for, so an edition keeps updating on its own line.</summary>
|
||||
public static string UpdateAssetPrefix => Edition?.AssetPrefix ?? Core.Edition.DefaultAssetPrefix;
|
||||
|
||||
/// <summary>The edition's logo shipped beside the executable, when the edition supplies one and no machine branding logo overrides it.</summary>
|
||||
public static string? EditionLogoPath
|
||||
{
|
||||
get
|
||||
{
|
||||
if(Edition is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string path = Path.Combine(AppContext.BaseDirectory, "Assets", "Brand", "logo.png");
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
}
|
||||
|
||||
public static RuntimeBranding Branding { get; } = RuntimeBranding.LoadMachine(includeInstallerLabels: Edition is null);
|
||||
public static RuntimeBranding Branding { get; } = RuntimeBranding.LoadMachine();
|
||||
public static AppInfo Info { get; } = Branding.Apply(DefaultInfo);
|
||||
|
||||
/// <summary>Where the app keeps its per-user data; honors the data-folder override variable.</summary>
|
||||
|
||||
@@ -8,81 +8,20 @@ namespace Qqq.PrintAgent.Core.Authentication;
|
||||
/// <summary>UI and service seam for standard QQQ sessions. Implementations do not persist passwords or provider tokens.</summary>
|
||||
public interface IQqqSessionClient : IDisposable
|
||||
{
|
||||
/// <summary>The one server origin this session belongs to; every request is confined to it.</summary>
|
||||
Uri Origin { get; }
|
||||
|
||||
/// <summary>True while a session token is held. It says nothing about whether the server still accepts that token.</summary>
|
||||
bool IsAuthenticated { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the server's discovery document: API version, features and the printing extension this agent negotiates against.</summary>
|
||||
Task<QqqApiDiscovery> DiscoverAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns how the server authenticates users, so the caller can offer password sign-in or the browser flow that matches.</summary>
|
||||
Task<QqqAuthenticationMetadata> GetAuthenticationMetadataAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>Signs in with a table-based user name and password, which are used for that request only and never kept.</summary>
|
||||
Task SignInWithPasswordAsync(string username, string password, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>Starts the browser sign-in for a redirect address the caller will receive, returning the attempt to complete later.</summary>
|
||||
Task<QqqBrowserLogin> BeginBrowserLoginAsync(Uri redirectUri, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>Finishes a browser sign-in from the callback address the provider redirected to, checked against the attempt that began it.</summary>
|
||||
Task CompleteBrowserLoginAsync(QqqBrowserLogin attempt, Uri callback, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>Re-adopts a saved session credential after a restart, after the server confirms it still accepts it.</summary>
|
||||
Task RestoreSessionAsync(QqqSessionCredential credential, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>Renews the held session so the user is not asked to sign in again; the credential may change and must be saved again.</summary>
|
||||
Task ResumeSessionAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>Ends the session on the server where possible and always forgets it locally.</summary>
|
||||
Task SignOutAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the credential that lets a later start restore this session; it is the only secret the caller persists.</summary>
|
||||
QqqSessionCredential ExportCredential();
|
||||
|
||||
|
||||
|
||||
/// <summary>Asks the server who the session belongs to, so a changed user, tenant or device can be detected and refused.</summary>
|
||||
Task<QqqSessionBinding> GetSessionIdentityAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Binds the paired device to this session and returns the confirmed identity. A legacy device can be claimed by this
|
||||
/// call when the caller explicitly asks for it; otherwise an existing binding is only verified.
|
||||
/// </summary>
|
||||
Task<QqqSessionBinding> BindDeviceAsync(QqqDeviceCredential credential, bool claimLegacyDevice = false, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends a request to a native printing route under the session, and returns the response for the caller to dispose; other routes are refused.</summary>
|
||||
Task<HttpResponseMessage> SendPrintingAsync(HttpMethod method, string relativePath, HttpContent? content = null, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the authenticated realtime WebSocket. The default throws, so a transport that cannot carry realtime printing
|
||||
/// fails loudly instead of leaving the service silently unable to claim work.
|
||||
/// </summary>
|
||||
Task<System.Net.WebSockets.WebSocket> ConnectPrintingAsync(TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("This session transport does not support realtime printing.");
|
||||
}
|
||||
@@ -10,56 +10,19 @@ namespace Qqq.PrintAgent.Core.Authentication;
|
||||
/// <summary>Keeps the redirect-refusing HTTP transport alive until the upgraded socket is disposed.</summary>
|
||||
internal sealed class OwnedPrintingSocket(WebSocket socket, HttpMessageInvoker invoker) : WebSocket
|
||||
{
|
||||
/// <summary>Why the wrapped socket closed, passed straight through so callers see the real close status.</summary>
|
||||
public override WebSocketCloseStatus? CloseStatus => socket.CloseStatus;
|
||||
|
||||
|
||||
|
||||
/// <summary>The wrapped socket's close description.</summary>
|
||||
public override string? CloseStatusDescription => socket.CloseStatusDescription;
|
||||
|
||||
|
||||
|
||||
/// <summary>The wrapped socket's state.</summary>
|
||||
public override WebSocketState State => socket.State;
|
||||
|
||||
|
||||
|
||||
/// <summary>The negotiated subprotocol of the wrapped socket.</summary>
|
||||
public override string? SubProtocol => socket.SubProtocol;
|
||||
|
||||
|
||||
|
||||
/// <summary>Drops the connection immediately without a close handshake.</summary>
|
||||
public override void Abort() => socket.Abort();
|
||||
|
||||
|
||||
|
||||
/// <summary>Closes the wrapped socket with the full close handshake.</summary>
|
||||
public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
|
||||
=> socket.CloseAsync(closeStatus, statusDescription, cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>Closes only the sending side of the wrapped socket.</summary>
|
||||
public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
|
||||
=> socket.CloseOutputAsync(closeStatus, statusDescription, cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>Receives the next message fragment from the wrapped socket.</summary>
|
||||
public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken)
|
||||
=> socket.ReceiveAsync(buffer, cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends a message fragment on the wrapped socket.</summary>
|
||||
public override Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken)
|
||||
=> socket.SendAsync(buffer, messageType, endOfMessage, cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>Disposes the socket and then the HTTP transport that carried its upgrade, which must stay alive until now because disposing it earlier would end the connection.</summary>
|
||||
public override void Dispose()
|
||||
{
|
||||
socket.Dispose();
|
||||
|
||||
@@ -15,115 +15,26 @@ public sealed class QqqApiDiscovery
|
||||
public IReadOnlyList<string> ClientChannels { get; init; } = [];
|
||||
public IReadOnlyList<Extension> Extensions { get; init; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Enables additive media negotiation only for one standard-path stable printing 2.3-or-newer extension within major two. Printing 2.2 is
|
||||
/// the rendering extension and carries no media negotiation, so a 2.2 server must keep this client a three-capability peer.
|
||||
/// </summary>
|
||||
public bool SupportsPrinterMedia() => HasStandardPrinting(minimumMinor: 3);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Enables the printer-approval-v1 capability (the server's PRINTERS frame) only for one standard-path stable printing 2.4-or-newer
|
||||
/// extension within major two. Older servers keep the client on the operator catalog for approval, which is the 2.3 behaviour.
|
||||
/// </summary>
|
||||
public bool SupportsPrinterApproval() => HasStandardPrinting(minimumMinor: 4);
|
||||
|
||||
|
||||
|
||||
/// <summary>True when exactly one printing extension is advertised, at the standard path, as a plain 2.x release at or above the given minor.</summary>
|
||||
private bool HasStandardPrinting(uint minimumMinor) => Extensions is not null
|
||||
&& Extensions.Count(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)) == 1
|
||||
&& Extensions.Any(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)
|
||||
&& string.Equals(extension.PathPrefix, "/qbit/printing", StringComparison.Ordinal) && SupportedVersion(extension.Version, minimumMinor, "2"));
|
||||
|
||||
|
||||
|
||||
/// <summary>The UI API capability that says the server validates the native sign-in nonce at the code exchange.</summary>
|
||||
public const string NonceValidationFeature = "nativeSessionNonceValidation";
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the server advertises that it validates the native sign-in nonce at exchange.</summary>
|
||||
public bool ValidatesNativeSessionNonce => Features?.Contains(NonceValidationFeature, StringComparer.Ordinal) == true;
|
||||
|
||||
|
||||
|
||||
/// <summary>One extension the server advertises: its name, semantic version and the path prefix its routes live under.</summary>
|
||||
public sealed record Extension(string Name, string Version, string PathPrefix);
|
||||
|
||||
|
||||
|
||||
/// <summary>A one-line, credential-free summary for the log: API version and every advertised extension with its version and path.</summary>
|
||||
public string Describe()
|
||||
internal void Validate(bool nativeSession = false)
|
||||
{
|
||||
string extensions = Extensions is null || Extensions.Count == 0 ? "no extensions"
|
||||
: string.Join(", ", Extensions.Where(extension => extension is not null).Select(extension => $"{extension.Name} {extension.Version} at {extension.PathPrefix}"));
|
||||
return $"QQQ API {(string.IsNullOrEmpty(ApiVersion) ? "(missing)" : ApiVersion)}, {Features?.Count ?? 0} feature(s), client channels [{string.Join(", ", ClientChannels ?? [])}], {extensions}.";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the server offers what this agent needs: an API version of 1.8 or later (1.9 when native browser sign-in is
|
||||
/// required, together with that feature), the core features the agent calls, the api client channel, and exactly one printing
|
||||
/// extension at the standard path in major version 1 or 2. Anything else throws a configuration error, so an unsuitable
|
||||
/// server is refused with one clear message instead of failing later in some other way.
|
||||
/// </summary>
|
||||
internal void Validate(bool nativeSession = false, Action<string>? report = null)
|
||||
{
|
||||
List<string> problems = Problems(nativeSession);
|
||||
if(problems.Count != 0)
|
||||
string[] required = ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
|
||||
if(!SupportedVersion(ApiVersion, nativeSession ? 9u : 8u) || Features is null
|
||||
|| required.Any(feature => !Features.Contains(feature, StringComparer.Ordinal))
|
||||
|| ClientChannels is null || !ClientChannels.Contains("api", StringComparer.Ordinal)
|
||||
|| Extensions is null || Extensions.Count(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)) != 1)
|
||||
{
|
||||
throw Configuration();
|
||||
}
|
||||
Extension printing = Extensions.Single(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal));
|
||||
if(!(SupportedVersion(printing.Version, 0) || SupportedVersion(printing.Version, 0, "2")) || !string.Equals(printing.PathPrefix, "/qbit/printing", StringComparison.Ordinal)
|
||||
|| (nativeSession && !Features.Contains("nativeSessionLogin", StringComparer.Ordinal)))
|
||||
{
|
||||
report?.Invoke("Discovery rejected: " + string.Join("; ", problems) + ".");
|
||||
throw Configuration();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Lists every way the discovery document falls short of what this agent needs, in plain words, so the log can say which
|
||||
/// server-side requirement is missing. The thrown failure stays generic; only the local log sees this detail.
|
||||
/// </summary>
|
||||
private List<string> Problems(bool nativeSession)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
string[] required = ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
|
||||
uint minimumMinor = nativeSession ? 9u : 8u;
|
||||
if(!SupportedVersion(ApiVersion, minimumMinor))
|
||||
{ problems.Add($"API version '{ApiVersion}' is not a plain 1.{minimumMinor}+ release{(nativeSession ? " (1.9 is needed for native browser sign-in)" : "")}"); }
|
||||
if(Features is null)
|
||||
{ problems.Add("no features list"); }
|
||||
else
|
||||
{
|
||||
string[] missing = [.. required.Where(feature => !Features.Contains(feature, StringComparer.Ordinal))];
|
||||
if(missing.Length != 0)
|
||||
{ problems.Add("missing feature(s) " + string.Join(", ", missing)); }
|
||||
if(nativeSession && !Features.Contains("nativeSessionLogin", StringComparer.Ordinal))
|
||||
{ problems.Add("the nativeSessionLogin feature is not advertised, so browser sign-in cannot be used"); }
|
||||
}
|
||||
if(ClientChannels is null || !ClientChannels.Contains("api", StringComparer.Ordinal))
|
||||
{ problems.Add("the 'api' client channel is not offered"); }
|
||||
int printingCount = Extensions?.Count(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)) ?? 0;
|
||||
if(printingCount != 1)
|
||||
{ problems.Add(printingCount == 0 ? "no printing extension is advertised (is the printing QBit installed and enabled?)" : $"{printingCount} printing extensions are advertised, expected exactly one"); }
|
||||
else
|
||||
{
|
||||
Extension printing = Extensions!.Single(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal));
|
||||
if(!(SupportedVersion(printing.Version, 0) || SupportedVersion(printing.Version, 0, "2")))
|
||||
{ problems.Add($"printing extension version '{printing.Version}' is not a plain 1.x or 2.x release"); }
|
||||
if(!string.Equals(printing.PathPrefix, "/qbit/printing", StringComparison.Ordinal))
|
||||
{ problems.Add($"printing extension path '{printing.PathPrefix}' is not /qbit/printing"); }
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Accepts only a plain MAJOR.MINOR.PATCH version (digits only, no leading zeros, no pre-release suffix) in the given major with at least the given minor, so an ambiguous or pre-release string never enables a capability.</summary>
|
||||
private static bool SupportedVersion(string? value, uint minimumMinor, string major = "1")
|
||||
{
|
||||
string[] parts = value?.Split('.') ?? [];
|
||||
@@ -133,9 +44,6 @@ public sealed class QqqApiDiscovery
|
||||
&& uint.Parse(parts[1], CultureInfo.InvariantCulture) >= minimumMinor;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The single failure used for an unsupported or misconfigured server; its message is fixed and safe to show, and it reveals nothing about what the server sent.</summary>
|
||||
internal static QqqSessionException Configuration() => new(QqqSessionException.FailureKind.ConfigurationRequired,
|
||||
"This server does not advertise the required QQQ printing and sign-in capabilities.");
|
||||
}
|
||||
@@ -13,9 +13,6 @@ public sealed class QqqAuthenticationMetadata
|
||||
public string Type { get; init; } = "";
|
||||
public ProviderValues? Values { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The provider settings the server publishes: client identifier, scopes, base address and, when the server supports it, the native browser sign-in grant.</summary>
|
||||
public sealed class ProviderValues
|
||||
{
|
||||
public string ClientId { get; init; } = "";
|
||||
@@ -24,9 +21,6 @@ public sealed class QqqAuthenticationMetadata
|
||||
public NativeGrant? NativeSessionLogin { get; init; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The server's declaration that a native app may sign a user in through the browser with PKCE and receive a server-managed session: flow name, challenge method, prompt, issuer, discovery address and the approved redirect addresses.</summary>
|
||||
public sealed class NativeGrant
|
||||
{
|
||||
public string Flow { get; init; } = "";
|
||||
@@ -34,13 +28,6 @@ public sealed class QqqAuthenticationMetadata
|
||||
public string Prompt { get; init; } = "";
|
||||
public string Issuer { get; init; } = "";
|
||||
public string DiscoveryUrl { get; init; } = "";
|
||||
|
||||
/// <summary>The server's nonce policy for the code exchange. Absent on older servers; <see cref="NonceRequired"/> means the exchange must carry the attempt's own nonce as expectedNonce.</summary>
|
||||
public string? NonceValidation { get; init; }
|
||||
public const string NonceRequired = "REQUIRED";
|
||||
|
||||
/// <summary>The server validates the nonce when one is sent; sending the attempt's own nonce is always correct under this policy.</summary>
|
||||
public const string NonceOptional = "OPTIONAL";
|
||||
public IReadOnlyList<string> RedirectUris { get; init; } = [];
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,8 @@ namespace Qqq.PrintAgent.Core.Authentication;
|
||||
/// <summary>An in-memory, single-use browser attempt. The host owns callback delivery, never provider tokens.</summary>
|
||||
public sealed class QqqBrowserLogin
|
||||
{
|
||||
/// <summary>Records one sign-in attempt: the address to open in the browser, the registered redirect, the random state and PKCE verifier that only this attempt knows, the expected issuer, and when it started so it can expire.</summary>
|
||||
internal QqqBrowserLogin(Uri authorizationUri, Uri redirectUri, string state, string verifier, string issuer, long startedAt, string? expectedNonce = null)
|
||||
internal QqqBrowserLogin(Uri authorizationUri, Uri redirectUri, string state, string verifier, string issuer, long startedAt)
|
||||
{
|
||||
ExpectedNonce = expectedNonce;
|
||||
AuthorizationUri = authorizationUri;
|
||||
RedirectUri = redirectUri;
|
||||
State = state;
|
||||
@@ -20,20 +18,11 @@ public sealed class QqqBrowserLogin
|
||||
StartedAt = startedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Uri AuthorizationUri { get; }
|
||||
public Uri RedirectUri { get; }
|
||||
internal string State { get; }
|
||||
internal string Verifier { get; }
|
||||
internal string Issuer { get; }
|
||||
|
||||
/// <summary>This attempt's own authorization nonce, kept only when the server advertised that it validates it at exchange; null otherwise. Never taken from a callback.</summary>
|
||||
internal string? ExpectedNonce { get; }
|
||||
internal long StartedAt { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying an attempt can never reveal its state, nonce or verifier.</summary>
|
||||
public override string ToString() => "QQQ browser sign-in attempt (redacted)";
|
||||
}
|
||||
@@ -8,7 +8,6 @@ namespace Qqq.PrintAgent.Core.Authentication;
|
||||
/// <summary>Secondary device possession proof. It never replaces the current QQQ user session.</summary>
|
||||
public sealed class QqqDeviceCredential
|
||||
{
|
||||
/// <summary>Accepts a device identifier of letters, digits, dot, underscore and hyphen (at most 128) and a visible-ASCII token (at most 4096), so a malformed value can never become a header injection or a path.</summary>
|
||||
public QqqDeviceCredential(string deviceId, string deviceToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
|
||||
@@ -25,13 +24,7 @@ public sealed class QqqDeviceCredential
|
||||
DeviceToken = deviceToken;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public string DeviceId { get; }
|
||||
public string DeviceToken { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying the credential can never reveal the device token.</summary>
|
||||
public override string ToString() => "QQQ device credential (redacted)";
|
||||
}
|
||||
@@ -9,33 +9,35 @@ using System.Text.Json;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Browser sign-in part of the session client: authorization code with PKCE against the provider the server advertises. The
|
||||
/// server stays in charge of the session; this client only proves the exchange, checks the provider's own discovery, and never
|
||||
/// keeps the code, the verifier or a provider token beyond the one attempt.
|
||||
/// </summary>
|
||||
public sealed partial class QqqSessionClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts a browser sign-in for an approved redirect address. The server's native grant must be valid and list that exact
|
||||
/// redirect, and the provider's discovery document must name the same issuer, an HTTPS authorization endpoint on the issuer's
|
||||
/// origin and S256 support. It then builds the authorization address with fresh random state, nonce and PKCE challenge,
|
||||
/// clears any current session, and remembers the attempt so only its callback can complete it.
|
||||
/// </summary>
|
||||
public async Task<QqqBrowserLogin> BeginBrowserLoginAsync(Uri redirectUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(redirectUri);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in requested with callback {redirectUri.AbsoluteUri}.");
|
||||
QqqAuthenticationMetadata metadata = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
QqqAuthenticationMetadata.NativeGrant grant = ValidateNativeGrant(metadata);
|
||||
RequireListedRedirect(grant, redirectUri);
|
||||
Uri authorizationUri = await ProviderAuthorizationEndpointAsync(grant, cancellationToken).ConfigureAwait(false);
|
||||
if(!ValidRedirect(redirectUri) || !grant.RedirectUris.Contains(redirectUri.AbsoluteUri, StringComparer.Ordinal))
|
||||
{ throw QqqApiDiscovery.Configuration(); }
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(grant.DiscoveryUrl), HttpMethod.Get, null, MetadataLimit,
|
||||
null, null, null, cancellationToken, providerDiscovery: true).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
using JsonDocument document = ParseDocument(response);
|
||||
JsonElement provider = document.RootElement;
|
||||
if(!provider.TryGetProperty("issuer", out JsonElement issuer) || issuer.ValueKind != JsonValueKind.String
|
||||
|| !string.Equals(issuer.GetString(), grant.Issuer, StringComparison.Ordinal)
|
||||
|| !provider.TryGetProperty("authorization_endpoint", out JsonElement authorization) || authorization.ValueKind != JsonValueKind.String
|
||||
|| !TryHttps(authorization.GetString(), out Uri? authorizationUri)
|
||||
|| !SameOrigin(authorizationUri!, new Uri(grant.Issuer))
|
||||
|| !provider.TryGetProperty("code_challenge_methods_supported", out JsonElement methods) || methods.ValueKind != JsonValueKind.Array
|
||||
|| !methods.EnumerateArray().Any(value => value.ValueKind == JsonValueKind.String && string.Equals(value.GetString(), "S256", StringComparison.Ordinal)))
|
||||
{
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
string state = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
string verifier = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
string nonce = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var query = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["client_id"] = metadata.Values!.ClientId,
|
||||
@@ -43,93 +45,22 @@ public sealed partial class QqqSessionClient
|
||||
["redirect_uri"] = redirectUri.AbsoluteUri,
|
||||
["scope"] = metadata.Values.Scopes,
|
||||
["state"] = state,
|
||||
["nonce"] = nonce,
|
||||
["nonce"] = Base64Url(RandomNumberGenerator.GetBytes(32)),
|
||||
["prompt"] = grant.Prompt,
|
||||
["code_challenge_method"] = "S256",
|
||||
["code_challenge"] = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))),
|
||||
};
|
||||
var builder = new UriBuilder(authorizationUri)
|
||||
var builder = new UriBuilder(authorizationUri!)
|
||||
{
|
||||
Query = string.Join("&", query.Select(pair => Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))),
|
||||
};
|
||||
ClearSession();
|
||||
string? nonceSignal = NonceSignal(grant);
|
||||
_diagnostics.Info(nonceSignal is null
|
||||
? "The server does not validate the sign-in nonce at exchange (legacy server); the exchange body stays unchanged."
|
||||
: $"The server validates the sign-in nonce at exchange ({nonceSignal}); this attempt's nonce will be sent as expectedNonce.");
|
||||
// The per-attempt nonce is kept only when the server validates it at exchange; legacy servers keep the exact old exchange.
|
||||
_browserLogin = new QqqBrowserLogin(builder.Uri, redirectUri, state, verifier, grant.Issuer, _timeProvider.GetTimestamp(), nonceSignal is null ? null : nonce);
|
||||
_diagnostics.Info($"Browser sign-in attempt started: the authorization page is {QqqSessionDiagnostics.Describe(builder.Uri)} (query not logged); waiting for the callback, which expires after five minutes.");
|
||||
_browserLogin = new QqqBrowserLogin(builder.Uri, redirectUri, state, verifier, grant.Issuer, _timeProvider.GetTimestamp());
|
||||
return _browserLogin;
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Browser sign-in start", exception))
|
||||
{ throw; }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Refuses a callback address that is not an app link or private scheme, or that the server's native grant does not list exactly, saying which in the log.</summary>
|
||||
private void RequireListedRedirect(QqqAuthenticationMetadata.NativeGrant grant, Uri redirectUri)
|
||||
{
|
||||
if(!ValidRedirect(redirectUri))
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in refused locally: the callback {redirectUri.AbsoluteUri} is not an HTTPS app link or dotted private scheme.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(!grant.RedirectUris.Contains(redirectUri.AbsoluteUri, StringComparer.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in refused: the server's native grant does not list the callback {redirectUri.AbsoluteUri}; it lists [{string.Join(", ", grant.RedirectUris)}]. Add this callback to the QQQ native session grant.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the identity provider's discovery document and returns its authorization endpoint, after checking that the
|
||||
/// provider names the same issuer QQQ advertises, an HTTPS endpoint on that issuer's origin, and S256 support. Each
|
||||
/// shortfall is logged before the generic configuration failure is thrown.
|
||||
/// </summary>
|
||||
private async Task<Uri> ProviderAuthorizationEndpointAsync(QqqAuthenticationMetadata.NativeGrant grant, CancellationToken cancellationToken)
|
||||
{
|
||||
_diagnostics.Info($"Fetching the identity provider's discovery document from {QqqSessionDiagnostics.Describe(new Uri(grant.DiscoveryUrl))}.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(grant.DiscoveryUrl), HttpMethod.Get, null, MetadataLimit,
|
||||
null, null, null, cancellationToken, providerDiscovery: true).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
using JsonDocument document = ParseDocument(response);
|
||||
JsonElement provider = document.RootElement;
|
||||
string? providerIssuer = provider.TryGetProperty("issuer", out JsonElement issuer) && issuer.ValueKind == JsonValueKind.String ? issuer.GetString() : null;
|
||||
string? providerAuthorization = provider.TryGetProperty("authorization_endpoint", out JsonElement authorization) && authorization.ValueKind == JsonValueKind.String ? authorization.GetString() : null;
|
||||
bool s256 = provider.TryGetProperty("code_challenge_methods_supported", out JsonElement methods) && methods.ValueKind == JsonValueKind.Array
|
||||
&& methods.EnumerateArray().Any(value => value.ValueKind == JsonValueKind.String && string.Equals(value.GetString(), "S256", StringComparison.Ordinal));
|
||||
_diagnostics.Info($"Provider discovery: issuer {providerIssuer ?? "(missing)"}, authorization endpoint {providerAuthorization ?? "(missing)"}, S256 {(s256 ? "supported" : "not listed")}.");
|
||||
if(!string.Equals(providerIssuer, grant.Issuer, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in refused: the provider's issuer '{providerIssuer}' does not exactly match the issuer QQQ advertises '{grant.Issuer}'.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(!TryHttps(providerAuthorization, out Uri? authorizationUri) || !SameOrigin(authorizationUri!, new Uri(grant.Issuer)))
|
||||
{
|
||||
_diagnostics.Info("Browser sign-in refused: the provider's authorization endpoint is not an HTTPS address on the issuer's origin.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(!s256)
|
||||
{
|
||||
_diagnostics.Info("Browser sign-in refused: the provider does not list S256 among its supported code challenge methods.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
return authorizationUri!;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Finishes a browser sign-in from the provider's redirect. The attempt is consumed whatever happens, so a callback can be
|
||||
/// used once; it must be the pending attempt, less than five minutes old, with no fragment or credentials and exactly the
|
||||
/// registered redirect. The state must match, a provider error or a different issuer is refused, and only then is the code,
|
||||
/// with its PKCE verifier, exchanged for a server-managed session.
|
||||
/// </summary>
|
||||
public async Task CompleteBrowserLoginAsync(QqqBrowserLogin attempt, Uri callback, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attempt);
|
||||
@@ -140,164 +71,58 @@ public sealed partial class QqqSessionClient
|
||||
QqqBrowserLogin? pending = _browserLogin;
|
||||
_browserLogin = null;
|
||||
TimeSpan age = _timeProvider.GetElapsedTime(attempt.StartedAt);
|
||||
_diagnostics.Info($"Browser callback received at {QqqSessionDiagnostics.Describe(callback)} after {age.TotalSeconds:0} s.");
|
||||
if(!ReferenceEquals(pending, attempt))
|
||||
{
|
||||
_diagnostics.Info("Browser callback refused: it does not belong to the pending sign-in attempt (a new attempt was started, or the attempt was already used).");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(age < TimeSpan.Zero || age > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
_diagnostics.Info("Browser callback refused: the attempt is older than five minutes. Start the browser sign-in again.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(!callback.IsAbsoluteUri || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0
|
||||
if(!ReferenceEquals(pending, attempt)
|
||||
|| age < TimeSpan.Zero || age > TimeSpan.FromMinutes(5)
|
||||
|| !callback.IsAbsoluteUri || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0
|
||||
|| !string.Equals(callback.GetLeftPart(UriPartial.Path), attempt.RedirectUri.AbsoluteUri, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Browser callback refused: its address does not match the registered callback {attempt.RedirectUri.AbsoluteUri} exactly, or it carries a fragment or user information.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
Dictionary<string, string> values = CallbackValues(callback);
|
||||
_diagnostics.Debug(() => $"Browser callback parameters: [{string.Join(", ", values.Keys)}] (values not logged).");
|
||||
if(values.TryGetValue("error", out string? providerError))
|
||||
if(values.ContainsKey("error") || !values.TryGetValue("state", out string? state) || !string.Equals(state, attempt.State, StringComparison.Ordinal)
|
||||
|| !values.TryGetValue("code", out string? code) || string.IsNullOrWhiteSpace(code) || code.Length > 4096
|
||||
|| (values.TryGetValue("iss", out string? issuer) && !string.Equals(issuer, attempt.Issuer, StringComparison.Ordinal)))
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in refused by the identity provider: error '{providerError}'{(values.TryGetValue("error_description", out string? description) ? " (" + description + ")" : "")}.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(!values.TryGetValue("state", out string? state) || !string.Equals(state, attempt.State, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info("Browser callback refused: the state parameter is missing or does not match this attempt.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(!values.TryGetValue("code", out string? code) || string.IsNullOrWhiteSpace(code) || code.Length > 4096)
|
||||
{
|
||||
_diagnostics.Info("Browser callback refused: no usable authorization code was returned.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(values.TryGetValue("iss", out string? issuer) && !string.Equals(issuer, attempt.Issuer, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Browser callback refused: the issuer '{issuer}' differs from the expected '{attempt.Issuer}'.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
_diagnostics.Info("Browser callback validated; exchanging the authorization code with QQQ for a server-managed session.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post,
|
||||
JsonBody(NativeCodeExchange(attempt, code)),
|
||||
JsonBody(new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow }),
|
||||
MetadataLimit, null, null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
AdoptManagedSession(response);
|
||||
_diagnostics.Info("Browser sign-in accepted; a server-managed session is now held.");
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Browser sign-in completion", exception))
|
||||
{ throw; }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Says why the exchange will carry the attempt's nonce, or null for a legacy server: the grant's policy REQUIRED, the grant's
|
||||
/// policy OPTIONAL (the server validates a nonce when one is sent, so sending it is always right), or the discovery
|
||||
/// capability nativeSessionNonceValidation on a server whose grant does not state a policy.
|
||||
/// </summary>
|
||||
private string? NonceSignal(QqqAuthenticationMetadata.NativeGrant grant)
|
||||
{
|
||||
if(string.Equals(grant.NonceValidation, QqqAuthenticationMetadata.NativeGrant.NonceRequired, StringComparison.Ordinal))
|
||||
{ return "grant policy REQUIRED"; }
|
||||
if(string.Equals(grant.NonceValidation, QqqAuthenticationMetadata.NativeGrant.NonceOptional, StringComparison.Ordinal))
|
||||
{ return "grant policy OPTIONAL"; }
|
||||
return _discovery?.ValidatesNativeSessionNonce == true ? "discovery capability " + QqqApiDiscovery.NonceValidationFeature : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Builds the manageSession code-exchange body. A legacy attempt (no stored nonce) keeps the exact four-field body; an attempt
|
||||
/// made under the server's REQUIRED policy adds that attempt's own nonce as expectedNonce. The nonce comes only from the attempt,
|
||||
/// never from the callback, and is not logged.
|
||||
/// </summary>
|
||||
private static object NativeCodeExchange(QqqBrowserLogin attempt, string code) => attempt.ExpectedNonce is null
|
||||
? new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow }
|
||||
: new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow, expectedNonce = attempt.ExpectedNonce };
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the server's native-session grant is one this agent can use safely: an HTTPS server with OAuth2, the expected
|
||||
/// flow, S256 and a forced login prompt, HTTPS issuer and discovery addresses, a client identifier, the openid scope, and a
|
||||
/// bounded list of valid redirect addresses. Anything else is reported as a server configuration problem.
|
||||
/// </summary>
|
||||
private QqqAuthenticationMetadata.NativeGrant ValidateNativeGrant(QqqAuthenticationMetadata metadata)
|
||||
{
|
||||
_discovery!.Validate(nativeSession: true, report: _diagnostics.Info);
|
||||
_discovery!.Validate(nativeSession: true);
|
||||
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
|
||||
var problems = new List<string>();
|
||||
if(!string.Equals(Origin.Scheme, "https", StringComparison.Ordinal))
|
||||
{ problems.Add("the server origin is not HTTPS"); }
|
||||
if(!string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
|
||||
{ problems.Add($"authentication type is '{metadata.Type}', not OAUTH2"); }
|
||||
if(grant is null)
|
||||
{ problems.Add("no nativeSessionLogin grant is published"); }
|
||||
else
|
||||
if(!string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) || !string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal)
|
||||
|| grant is null || !string.Equals(grant.Flow, QqqAuthenticationMetadata.NativeSessionFlow, StringComparison.Ordinal)
|
||||
|| !string.Equals(grant.CodeChallengeMethod, "S256", StringComparison.Ordinal) || !string.Equals(grant.Prompt, "login", StringComparison.Ordinal)
|
||||
|| !TryHttps(grant.Issuer, out _) || !TryHttps(grant.DiscoveryUrl, out _)
|
||||
|| string.IsNullOrWhiteSpace(metadata.Values!.ClientId) || metadata.Values.ClientId.Length > 1024
|
||||
|| string.IsNullOrWhiteSpace(metadata.Values.Scopes) || !metadata.Values.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains("openid", StringComparer.Ordinal)
|
||||
|| grant.RedirectUris is null || grant.RedirectUris.Count is < 1 or > 32
|
||||
|| grant.RedirectUris.Any(value => !Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) || !ValidRedirect(uri)))
|
||||
{
|
||||
if(!string.Equals(grant.Flow, QqqAuthenticationMetadata.NativeSessionFlow, StringComparison.Ordinal))
|
||||
{ problems.Add($"grant flow is '{grant.Flow}', expected {QqqAuthenticationMetadata.NativeSessionFlow}"); }
|
||||
if(!string.Equals(grant.CodeChallengeMethod, "S256", StringComparison.Ordinal))
|
||||
{ problems.Add($"code challenge method is '{grant.CodeChallengeMethod}', expected S256"); }
|
||||
if(!string.Equals(grant.Prompt, "login", StringComparison.Ordinal))
|
||||
{ problems.Add($"prompt is '{grant.Prompt}', expected login"); }
|
||||
// Absent means a legacy server; REQUIRED and OPTIONAL both mean the nonce is sent; any other value is a policy this client cannot honor, so refuse before any provider or browser step.
|
||||
if(!string.IsNullOrEmpty(grant.NonceValidation) && !string.Equals(grant.NonceValidation, QqqAuthenticationMetadata.NativeGrant.NonceRequired, StringComparison.Ordinal)
|
||||
&& !string.Equals(grant.NonceValidation, QqqAuthenticationMetadata.NativeGrant.NonceOptional, StringComparison.Ordinal))
|
||||
{ problems.Add($"nonce validation policy '{grant.NonceValidation}' is not one this client supports (expected REQUIRED, OPTIONAL or absent)"); }
|
||||
if(!TryHttps(grant.Issuer, out _))
|
||||
{ problems.Add($"issuer '{grant.Issuer}' is not a plain HTTPS address"); }
|
||||
if(!TryHttps(grant.DiscoveryUrl, out _))
|
||||
{ problems.Add($"discovery address '{grant.DiscoveryUrl}' is not a plain HTTPS address"); }
|
||||
if(grant.RedirectUris is null || grant.RedirectUris.Count is < 1 or > 32)
|
||||
{ problems.Add($"{grant.RedirectUris?.Count ?? 0} redirect address(es) listed; expected 1 to 32"); }
|
||||
else
|
||||
{
|
||||
string[] invalid = [.. grant.RedirectUris.Where(value => !Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) || !ValidRedirect(uri))];
|
||||
if(invalid.Length != 0)
|
||||
{ problems.Add("redirect address(es) not an HTTPS app link or dotted private scheme: " + string.Join(", ", invalid)); }
|
||||
}
|
||||
}
|
||||
if(string.IsNullOrWhiteSpace(metadata.Values?.ClientId) || metadata.Values.ClientId.Length > 1024)
|
||||
{ problems.Add("client id is missing or too long"); }
|
||||
if(string.IsNullOrWhiteSpace(metadata.Values?.Scopes) || !metadata.Values.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains("openid", StringComparer.Ordinal))
|
||||
{ problems.Add($"scopes '{metadata.Values?.Scopes}' do not include openid"); }
|
||||
if(problems.Count != 0)
|
||||
{
|
||||
_diagnostics.Info("Native browser sign-in grant rejected: " + string.Join("; ", problems) + ".");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
_diagnostics.Info($"Native browser sign-in grant accepted: issuer {grant!.Issuer}, {grant.RedirectUris!.Count} redirect address(es).");
|
||||
return grant;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Parses an address and accepts it only when it is an absolute HTTPS address with a host that also passes the configuration-address safety checks.</summary>
|
||||
private static bool TryHttps(string? value, out Uri? uri) => Uri.TryCreate(value, UriKind.Absolute, out uri)
|
||||
&& string.Equals(uri.Scheme, "https", StringComparison.Ordinal) && uri.Host.Length != 0 && SafeConfigurationUri(uri);
|
||||
|
||||
|
||||
|
||||
/// <summary>Rejects addresses carrying credentials, a query, a fragment, port zero, wildcards or backslashes, or longer than 2048 characters, so server-supplied configuration cannot smuggle extra meaning into a URL.</summary>
|
||||
private static bool SafeConfigurationUri(Uri uri) => uri.IsAbsoluteUri && uri.UserInfo.Length == 0 && uri.Query.Length == 0 && uri.Fragment.Length == 0
|
||||
&& uri.Port != 0 && uri.AbsoluteUri.Length <= 2048 && !uri.OriginalString.Contains('*', StringComparison.Ordinal) && !uri.OriginalString.Contains('\\', StringComparison.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>Accepts a redirect address only if it is an HTTPS app link or a dotted private scheme (never plain http), as the server's own native-session grant requires.</summary>
|
||||
// Mirror the central native-session grant: HTTPS app links or explicit dotted private schemes; no inferred loopback exception.
|
||||
private static bool ValidRedirect(Uri uri) => SafeConfigurationUri(uri) && uri.AbsolutePath.Length != 0
|
||||
&& (string.Equals(uri.Scheme, "https", StringComparison.Ordinal)
|
||||
|| (!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) && uri.Scheme.Contains('.', StringComparison.Ordinal)));
|
||||
|
||||
|
||||
|
||||
/// <summary>Splits the callback query into decoded name and value pairs, refusing an oversize query, a malformed pair, control characters or a repeated name, because a repeated parameter could make two readers disagree about the code or state.</summary>
|
||||
private static Dictionary<string, string> CallbackValues(Uri callback)
|
||||
{
|
||||
if(callback.Query.Length is < 2 or > 16_384)
|
||||
@@ -316,8 +141,5 @@ public sealed partial class QqqSessionClient
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Encodes bytes as unpadded URL-safe base64, the form PKCE and the state parameter require.</summary>
|
||||
private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
@@ -7,16 +7,12 @@ using System.Net.WebSockets;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Realtime socket part of the session client. The socket is opened here, because only this class holds the session cookie and device
|
||||
/// proof, and they are sent to the discovered origin and nowhere else.
|
||||
/// </summary>
|
||||
public sealed partial class QqqSessionClient
|
||||
{
|
||||
/// <summary>Refreshes standard discovery once per socket establishment, then connects only to that origin with the current session and approved device proof.</summary>
|
||||
/// <summary>Connects only to the discovered origin, using the current native session and approved device proof.</summary>
|
||||
public async Task<WebSocket> ConnectPrintingAsync(TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout, CancellationToken cancellationToken = default)
|
||||
{
|
||||
HttpMessageInvoker invoker = CreateSocketInvoker();
|
||||
var invoker = new HttpMessageInvoker(new SocketsHttpHandler { AllowAutoRedirect = false, UseCookies = false });
|
||||
var socket = new ClientWebSocket();
|
||||
long generation;
|
||||
try
|
||||
@@ -25,119 +21,54 @@ public sealed partial class QqqSessionClient
|
||||
try
|
||||
{
|
||||
RequireAuthenticated();
|
||||
_diagnostics.Info("Preparing the realtime printing socket: refreshing discovery first.");
|
||||
await RefreshPrintingDiscoveryAsync(cancellationToken).ConfigureAwait(false);
|
||||
QqqApiDiscovery discovery = _discovery ?? throw QqqApiDiscovery.Configuration();
|
||||
QqqApiDiscovery.Extension extension = discovery.Extensions.Single(item => string.Equals(item.Name, "printing", StringComparison.Ordinal));
|
||||
if(!Version.TryParse(extension.Version, out Version? version) || version.Major != 2)
|
||||
{
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.ConfigurationRequired, "Upgrade the server to printing extension 2.x for realtime printing.");
|
||||
}
|
||||
if(_device is null || _binding?.HasApprovedDevice != true)
|
||||
{
|
||||
_diagnostics.Info("Realtime socket refused locally: no approved device binding is held. Enroll or re-associate this computer first.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "An approved device binding is required.");
|
||||
}
|
||||
generation = _generation;
|
||||
ConfigureSocket(socket, _device, heartbeatInterval, heartbeatTimeout);
|
||||
socket.Options.AddSubProtocol("qqq-printing.v1");
|
||||
socket.Options.CollectHttpResponseDetails = true;
|
||||
socket.Options.SetRequestHeader("Cookie", CookieHeader());
|
||||
socket.Options.SetRequestHeader("Origin", Origin.GetLeftPart(UriPartial.Authority));
|
||||
socket.Options.SetRequestHeader("X-QQQ-Client", ClientChannel);
|
||||
socket.Options.SetRequestHeader("X-QQQ-Device-Id", _device.DeviceId);
|
||||
socket.Options.SetRequestHeader("X-QQQ-Device-Token", _device.DeviceToken);
|
||||
socket.Options.KeepAliveInterval = heartbeatInterval;
|
||||
socket.Options.KeepAliveTimeout = heartbeatTimeout;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
var uri = new UriBuilder(new Uri(Origin, PrintingPath + "device/realtime")) { Scheme = string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) ? "wss" : "ws" };
|
||||
_diagnostics.Info($"Opening the realtime socket to {QqqSessionDiagnostics.Describe(uri.Uri)} with subprotocol qqq-printing.v1 (session cookie and device proof attached; heartbeat every {heartbeatInterval.TotalSeconds:0} s, timeout {heartbeatTimeout.TotalSeconds:0} s).");
|
||||
long started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
await socket.ConnectAsync(uri.Uri, invoker, cancellationToken).ConfigureAwait(false);
|
||||
_diagnostics.Info($"Realtime socket upgraded in {Elapsed(started)}; server selected subprotocol {socket.SubProtocol ?? "(none)"}.");
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if(generation != _generation)
|
||||
{
|
||||
_diagnostics.Info("Realtime socket discarded: the session changed while it was connecting.");
|
||||
throw AuthenticationRequired();
|
||||
}
|
||||
{ throw AuthenticationRequired(); }
|
||||
if(!string.Equals(socket.SubProtocol, "qqq-printing.v1", StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info("Realtime socket refused: the server did not select the qqq-printing.v1 subprotocol.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return new OwnedPrintingSocket(socket, invoker);
|
||||
}
|
||||
catch(WebSocketException exception) when(socket.HttpStatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden)
|
||||
catch(WebSocketException) when(socket.HttpStatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
_diagnostics.Info($"Realtime socket upgrade refused with HTTP {(int)socket.HttpStatusCode} ({exception.WebSocketErrorCode}); authorization is required.");
|
||||
socket.Dispose();
|
||||
invoker.Dispose();
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.AuthenticationRequired, "QQQ authorization is required to restore the realtime connection.");
|
||||
}
|
||||
catch(Exception exception)
|
||||
catch
|
||||
{
|
||||
ReportSocketFailure(exception, socket);
|
||||
socket.Dispose();
|
||||
invoker.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP invoker for the WebSocket upgrade: no redirects, no cookie jar (the cookie is attached by hand) and the same
|
||||
/// certificate policy as the HTTP client, so a rejected or explicitly allowed certificate is logged the same way. See the
|
||||
/// MA0039 note on the constructor.
|
||||
/// </summary>
|
||||
private HttpMessageInvoker CreateSocketInvoker()
|
||||
{
|
||||
#pragma warning disable MA0039
|
||||
return new HttpMessageInvoker(new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => EvaluateCertificate(Origin.Authority,
|
||||
certificate is null ? null : certificate as System.Security.Cryptography.X509Certificates.X509Certificate2 ?? new System.Security.Cryptography.X509Certificates.X509Certificate2(certificate), errors),
|
||||
},
|
||||
});
|
||||
#pragma warning restore MA0039
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sets the subprotocol, session cookie, origin, client channel, device proof and keep-alive on a socket before it connects; the caller holds the gate.</summary>
|
||||
private void ConfigureSocket(ClientWebSocket socket, QqqDeviceCredential device, TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout)
|
||||
{
|
||||
socket.Options.AddSubProtocol("qqq-printing.v1");
|
||||
socket.Options.CollectHttpResponseDetails = true;
|
||||
socket.Options.SetRequestHeader("Cookie", CookieHeader());
|
||||
socket.Options.SetRequestHeader("Origin", Origin.GetLeftPart(UriPartial.Authority));
|
||||
socket.Options.SetRequestHeader("X-QQQ-Client", ClientChannel);
|
||||
socket.Options.SetRequestHeader("X-QQQ-Device-Id", device.DeviceId);
|
||||
socket.Options.SetRequestHeader("X-QQQ-Device-Token", device.DeviceToken);
|
||||
socket.Options.KeepAliveInterval = heartbeatInterval;
|
||||
socket.Options.KeepAliveTimeout = heartbeatTimeout;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Logs why the socket could not be established: the WebSocket error and any HTTP status from the upgrade, a cancellation, or the exception type and message.</summary>
|
||||
private void ReportSocketFailure(Exception exception, ClientWebSocket socket)
|
||||
{
|
||||
int status = (int)socket.HttpStatusCode;
|
||||
_diagnostics.Info(exception is WebSocketException webSocket
|
||||
? $"Realtime socket upgrade failed: {webSocket.WebSocketErrorCode}{(status == 0 ? "" : ", HTTP " + status.ToString(System.Globalization.CultureInfo.InvariantCulture))}: {webSocket.Message}"
|
||||
: exception is OperationCanceledException ? "Realtime socket connection was cancelled or timed out."
|
||||
: "Realtime socket connection failed: " + QqqSessionDiagnostics.Describe(exception));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Refreshes discovery under the caller-held session gate and rejects incompatible printing majors before sending any WebSocket credentials.</summary>
|
||||
private async Task RefreshPrintingDiscoveryAsync(CancellationToken token)
|
||||
{
|
||||
_discovery = null;
|
||||
QqqApiDiscovery discovery = await DiscoverCoreAsync(token).ConfigureAwait(false);
|
||||
QqqApiDiscovery.Extension extension = discovery.Extensions.Single(item => string.Equals(item.Name, "printing", StringComparison.Ordinal));
|
||||
if(!Version.TryParse(extension.Version, out Version? version) || version.Major != 2)
|
||||
{
|
||||
_diagnostics.Info($"Realtime printing needs printing extension 2.x; the server advertises {extension.Version}.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.ConfigurationRequired, "Upgrade the server to printing extension 2.x for realtime printing.");
|
||||
}
|
||||
_diagnostics.Info($"Printing extension {extension.Version} supports realtime; media negotiation {(discovery.SupportsPrinterMedia() ? "available" : "not available (needs 2.3+)")}.");
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Transport;
|
||||
@@ -24,7 +22,6 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
private readonly HttpClient _http;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ApplicationTrafficMeter? _traffic;
|
||||
private readonly QqqSessionDiagnostics _diagnostics;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private CookieContainer _cookies = new();
|
||||
private QqqApiDiscovery? _discovery;
|
||||
@@ -35,20 +32,9 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
private QqqBrowserLogin? _browserLogin;
|
||||
private long _generation;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates a client bound to one server origin. The origin must be an absolute https URL with no credentials, path,
|
||||
/// query or fragment (plain http only when explicitly allowed and loopback). The handler never follows redirects or
|
||||
/// manages cookies, because redirects are refused and the session cookie is attached by hand to each request, and the
|
||||
/// HTTP client has no overall timeout because every request sets its own bound.
|
||||
/// </summary>
|
||||
public QqqSessionClient(Uri origin, HttpMessageHandler? handler = null, TimeProvider? timeProvider = null, bool allowLoopbackHttp = false, ApplicationTrafficMeter? traffic = null,
|
||||
QqqSessionDiagnostics? diagnostics = null, bool allowUntrustedCertificate = false)
|
||||
public QqqSessionClient(Uri origin, HttpMessageHandler? handler = null, TimeProvider? timeProvider = null, bool allowLoopbackHttp = false, ApplicationTrafficMeter? traffic = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(origin);
|
||||
_diagnostics = diagnostics ?? QqqSessionDiagnostics.None;
|
||||
AllowsUntrustedCertificate = allowUntrustedCertificate;
|
||||
if(!origin.IsAbsoluteUri || origin.UserInfo.Length != 0 || origin.Query.Length != 0 || origin.Fragment.Length != 0
|
||||
|| !string.Equals(origin.AbsolutePath, "/", StringComparison.Ordinal)
|
||||
|| (!string.Equals(origin.Scheme, "https", StringComparison.Ordinal)
|
||||
@@ -59,67 +45,15 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
Origin = new Uri(origin.GetLeftPart(UriPartial.Authority) + "/", UriKind.Absolute);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_traffic = traffic;
|
||||
// MA0039: the callback keeps the platform's verdict (it accepts only SslPolicyErrors.None) unless the administrator
|
||||
// explicitly allowed untrusted certificates for a development server; it exists so a rejection is logged with its
|
||||
// reason instead of failing silently, and so the opt-in is visible in the log on every connection.
|
||||
#pragma warning disable MA0039
|
||||
_http = new HttpClient(handler ?? new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) => EvaluateCertificate(request.RequestUri?.Authority ?? Origin.Authority, certificate, errors),
|
||||
})
|
||||
_http = new HttpClient(handler ?? new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false })
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan,
|
||||
};
|
||||
#pragma warning restore MA0039
|
||||
_diagnostics.Info($"Session client created for {Origin.Scheme}://{Origin.Authority} (plain HTTP {(string.Equals(Origin.Scheme, "http", StringComparison.Ordinal) ? "allowed for loopback" : "not allowed")}; untrusted server certificates {(allowUntrustedCertificate ? "ALLOWED" : "refused")}).");
|
||||
if(allowUntrustedCertificate)
|
||||
{ _diagnostics.Info($"WARNING: certificate validation is relaxed for {Origin.Authority}. Any certificate, including a forged one, will be accepted. Use this only for development servers."); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Uri Origin { get; }
|
||||
public bool IsAuthenticated => _sessionToken is not null;
|
||||
|
||||
/// <summary>True when this client was told to accept a server certificate that Windows does not trust (self-signed or private authority), for development servers only.</summary>
|
||||
public bool AllowsUntrustedCertificate { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a TLS server certificate is accepted and, either way, says why in the log. A certificate Windows trusts
|
||||
/// is accepted silently (details at debug level). One it does not trust is rejected with the exact policy errors, the
|
||||
/// subject, issuer and validity, and the remedy, unless untrusted certificates were explicitly allowed for this client,
|
||||
/// in which case it is accepted with a warning naming the certificate so the exception is never silent.
|
||||
/// </summary>
|
||||
internal bool EvaluateCertificate(string host, X509Certificate2? certificate, SslPolicyErrors errors)
|
||||
{
|
||||
string summary = certificate is null ? "no certificate presented"
|
||||
: $"subject '{certificate.Subject}', issuer '{certificate.Issuer}', valid {certificate.NotBefore.ToUniversalTime():yyyy-MM-dd} to {certificate.NotAfter.ToUniversalTime():yyyy-MM-dd}, thumbprint {certificate.Thumbprint}";
|
||||
if(errors == SslPolicyErrors.None)
|
||||
{
|
||||
_diagnostics.Debug(() => $"TLS certificate for {host} accepted by Windows: {summary}.");
|
||||
return true;
|
||||
}
|
||||
if(AllowsUntrustedCertificate)
|
||||
{
|
||||
_diagnostics.Info($"TLS certificate for {host} is NOT trusted by Windows ({errors}) but was accepted because untrusted certificates are allowed for this server: {summary}.");
|
||||
return true;
|
||||
}
|
||||
_diagnostics.Info($"TLS certificate for {host} rejected: {errors}. Certificate: {summary}. "
|
||||
+ (errors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors)
|
||||
? "Windows does not trust the issuing authority for the account this process runs as (the service runs as LocalSystem and reads the machine store, not your user store). Install the issuer's root certificate in the Local Machine Trusted Root store, or enable 'Allow untrusted server certificate' in Connection settings for a development server."
|
||||
: errors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch)
|
||||
? "The certificate is not issued for this host name or address; use the address the certificate names, or reissue it."
|
||||
: "The server presented no usable certificate."));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the validated discovery document, fetching it when none is cached; the client gate makes concurrent callers share one fetch.</summary>
|
||||
public async Task<QqqApiDiscovery> DiscoverAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -128,9 +62,6 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the server's authentication type (table-based, or OAuth2 with a checked native grant), fetching and validating it on first use.</summary>
|
||||
public async Task<QqqAuthenticationMetadata> GetAuthenticationMetadataAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -139,13 +70,6 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Signs in with a table-based user name and password. Input is validated first (bounded length, no control characters,
|
||||
/// no colon in the name) because the pair travels as HTTP Basic. Only a TABLE_BASED server accepts it, any previous
|
||||
/// session is cleared, and the password is used for this one request and never stored.
|
||||
/// </summary>
|
||||
public async Task SignInWithPasswordAsync(string username, string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(username);
|
||||
@@ -161,81 +85,48 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_diagnostics.Info("Password sign-in requested.");
|
||||
_diagnostics.Debug(() => $"Password sign-in user name: '{username}' ({username.Length} characters); password {password.Length} characters (never logged).");
|
||||
QqqAuthenticationMetadata authentication = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
if(!string.Equals(authentication.Type, "TABLE_BASED", StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Password sign-in refused locally: the server authenticates with {authentication.Type}, not TABLE_BASED.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
{ throw QqqApiDiscovery.Configuration(); }
|
||||
ClearSession();
|
||||
string basic = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + password));
|
||||
_diagnostics.Info("Sending password sign-in to /qqq/v1/manageSession (HTTP Basic, this request only).");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post, JsonBody(new { }),
|
||||
MetadataLimit, null, null, basic, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
AdoptManagedSession(response);
|
||||
_diagnostics.Info("Password sign-in accepted; a server-managed session is now held.");
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Password sign-in", exception))
|
||||
{ throw; }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Re-adopts a saved session after a restart. The credential must belong to this origin and the server must confirm it
|
||||
/// with a resume request; any failure clears the session so a stale token is never kept.
|
||||
/// </summary>
|
||||
public async Task RestoreSessionAsync(QqqSessionCredential credential, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(credential);
|
||||
if(credential.Origin != Origin)
|
||||
{
|
||||
_diagnostics.Info($"Saved session refused: it belongs to {QqqSessionDiagnostics.Describe(credential.Origin)}, not {QqqSessionDiagnostics.Describe(Origin)}.");
|
||||
throw new ArgumentException("The session belongs to another server.", nameof(credential));
|
||||
}
|
||||
{ throw new ArgumentException("The session belongs to another server.", nameof(credential)); }
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_diagnostics.Info($"Restoring a saved session for {Origin.Authority}: checking discovery and authentication metadata first.");
|
||||
await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
ClearSession();
|
||||
SetSession(credential.SessionToken);
|
||||
_diagnostics.Info("Asking the server to confirm the saved session.");
|
||||
await ResumeCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
_diagnostics.Info("Saved session confirmed by the server.");
|
||||
}
|
||||
catch(Exception exception)
|
||||
catch
|
||||
{
|
||||
ReportFailure("Session restore", exception);
|
||||
ClearSession();
|
||||
throw;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Renews the current session with the server and, when an identity is bound, re-checks that it has not changed.</summary>
|
||||
public async Task ResumeSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_diagnostics.Info("Renewing the session with the server.");
|
||||
await ResumeCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Session renewal", exception))
|
||||
{ throw; }
|
||||
{ await ResumeCoreAsync(cancellationToken).ConfigureAwait(false); }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Ends the session on the server when one exists and always clears it locally, even if the server call fails.</summary>
|
||||
public async Task SignOutAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -243,17 +134,11 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
{
|
||||
if(_sessionToken is not null)
|
||||
{
|
||||
_diagnostics.Info($"Signing out of {Origin.Authority}: ending the server session.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/logout"), HttpMethod.Post, JsonBody(new { }),
|
||||
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
_diagnostics.Info("Server session ended.");
|
||||
}
|
||||
else
|
||||
{ _diagnostics.Info("Sign-out requested with no held session; clearing local state only."); }
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Sign-out", exception))
|
||||
{ throw; }
|
||||
finally
|
||||
{
|
||||
ClearSession();
|
||||
@@ -261,14 +146,8 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the credential a later start can restore this session from; throws when not signed in.</summary>
|
||||
public QqqSessionCredential ExportCredential() => new(Origin, _sessionToken ?? throw AuthenticationRequired());
|
||||
|
||||
|
||||
|
||||
/// <summary>Asks the server who the session belongs to: user, tenant and, when a device is bound, that device.</summary>
|
||||
public async Task<QqqSessionBinding> GetSessionIdentityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -277,13 +156,6 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Binds the paired device to the signed-in session. A legacy device is first claimed on the server; the identity the
|
||||
/// server then reports must name the same device or the bind is rejected. A successful bind advances the generation, so
|
||||
/// requests started under the previous binding are refused instead of completing against the new one.
|
||||
/// </summary>
|
||||
public async Task<QqqSessionBinding> BindDeviceAsync(QqqDeviceCredential credential, bool claimLegacyDevice = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(credential);
|
||||
@@ -291,33 +163,23 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
try
|
||||
{
|
||||
RequireAuthenticated();
|
||||
_diagnostics.Info($"Binding device {credential.DeviceId} to the session{(claimLegacyDevice ? ", claiming it as a legacy device first" : "")}.");
|
||||
if(claimLegacyDevice)
|
||||
{
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, PrintingPath + "device/bind"), HttpMethod.Post, JsonBody(new { }),
|
||||
MetadataLimit, CookieHeader(), credential, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
_diagnostics.Info($"Server accepted the legacy claim for device {credential.DeviceId}.");
|
||||
}
|
||||
QqqSessionBinding identity = await IdentityCoreAsync(credential, cancellationToken).ConfigureAwait(false);
|
||||
if(!string.Equals(identity.DeviceId, credential.DeviceId, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Device binding refused: the server reports device {identity.DeviceId ?? "(none)"} for this session, not {credential.DeviceId}.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
{ throw InvalidResponse(); }
|
||||
_device = credential;
|
||||
_binding = identity;
|
||||
_generation++;
|
||||
_diagnostics.Info($"Device {credential.DeviceId} bound to principal {identity.PrincipalId} in tenant {identity.TenantId}.");
|
||||
return identity;
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Device binding", exception))
|
||||
{ throw; }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only native printing routes are accepted. Network I/O does not hold the state lock or delay control traffic behind printing.</summary>
|
||||
public async Task<HttpResponseMessage> SendPrintingAsync(HttpMethod method, string relativePath, HttpContent? content = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -334,14 +196,10 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
device = relativePath.StartsWith("device/", StringComparison.Ordinal) ? _device : null;
|
||||
if(relativePath.StartsWith("device/", StringComparison.Ordinal) && (device is null || _binding?.HasApprovedDevice != true))
|
||||
{
|
||||
_diagnostics.Info($"Printing request {relativePath} refused locally: no approved device binding is held.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "An approved device binding is required.");
|
||||
}
|
||||
// Device and admin routes, enrollment, and reading the operator printer catalog (the agent's only view of server-side
|
||||
// approval); operator submission stays off this client.
|
||||
bool catalogRead = method == HttpMethod.Get && (string.Equals(relativePath, "operator/printers", StringComparison.Ordinal) || relativePath.StartsWith("operator/printers?", StringComparison.Ordinal));
|
||||
if(!relativePath.StartsWith("device/", StringComparison.Ordinal) && !relativePath.StartsWith("admin/", StringComparison.Ordinal)
|
||||
&& !string.Equals(relativePath, "devices/enroll", StringComparison.Ordinal) && !catalogRead)
|
||||
&& !string.Equals(relativePath, "devices/enroll", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Only native printing operations are accepted.", nameof(relativePath));
|
||||
}
|
||||
@@ -372,49 +230,30 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
finally { if(acquired) { _gate.Release(); } }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Fetches and validates the discovery document and caches it; callers hold the client gate.</summary>
|
||||
private async Task<QqqApiDiscovery> DiscoverCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if(_discovery is not null)
|
||||
{ return _discovery; }
|
||||
_diagnostics.Info($"Discovering QQQ at {Origin.Authority}: requesting /qqq/v1/version.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/version"), HttpMethod.Get, null, MetadataLimit,
|
||||
null, null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
QqqApiDiscovery discovery = Parse<QqqApiDiscovery>(response);
|
||||
_diagnostics.Info("Discovery received: " + discovery.Describe());
|
||||
_diagnostics.Debug(() => $"Discovery features: [{string.Join(", ", discovery.Features ?? [])}]; client channels: [{string.Join(", ", discovery.ClientChannels ?? [])}].");
|
||||
discovery.Validate(report: _diagnostics.Info);
|
||||
discovery.Validate();
|
||||
_discovery = discovery;
|
||||
return discovery;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the authentication metadata after discovery and caches it. Only table-based and OAuth2 are accepted, and an
|
||||
/// OAuth2 server must offer the native-client grant this agent can use, otherwise the server is reported as unsupported.
|
||||
/// </summary>
|
||||
private async Task<QqqAuthenticationMetadata> AuthenticationCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await DiscoverCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
if(_authentication is not null)
|
||||
{ return _authentication; }
|
||||
_diagnostics.Info("Requesting authentication metadata from /qqq/v1/metaData/authentication.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/metaData/authentication"), HttpMethod.Get, null, MetadataLimit,
|
||||
null, null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
QqqAuthenticationMetadata metadata = Parse<QqqAuthenticationMetadata>(response);
|
||||
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
|
||||
_diagnostics.Info($"Authentication metadata: type {(string.IsNullOrEmpty(metadata.Type) ? "(missing)" : metadata.Type)}, provider '{metadata.Name}', native browser sign-in {(grant is null ? "not offered" : "offered")}.");
|
||||
_diagnostics.Debug(() => grant is null
|
||||
? $"Provider values present: {metadata.Values is not null}; client id {(string.IsNullOrEmpty(metadata.Values?.ClientId) ? "(none)" : "present")}; scopes '{metadata.Values?.Scopes}'."
|
||||
: $"Native grant: flow {grant.Flow}, challenge {grant.CodeChallengeMethod}, prompt {grant.Prompt}, issuer {grant.Issuer}, discovery {grant.DiscoveryUrl}, {grant.RedirectUris?.Count ?? 0} redirect address(es) [{string.Join(", ", grant.RedirectUris ?? [])}], scopes '{metadata.Values?.Scopes}'.");
|
||||
if(!string.Equals(metadata.Type, "TABLE_BASED", StringComparison.Ordinal) && !string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Authentication type '{metadata.Type}' is not supported; only TABLE_BASED and OAUTH2 are.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
|
||||
@@ -423,36 +262,20 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
return metadata;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Asks the server to renew the managed session, adopts the returned token, and re-verifies a bound identity.</summary>
|
||||
private async Task ResumeCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RequireAuthenticated();
|
||||
string? previous = _sessionToken;
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post, JsonBody(new { }),
|
||||
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
AdoptManagedSession(response);
|
||||
_diagnostics.Info($"Session renewed; the session token {(string.Equals(previous, _sessionToken, StringComparison.Ordinal) ? "is unchanged" : "was rotated by the server")}.");
|
||||
if(_binding is not null)
|
||||
{
|
||||
_diagnostics.Info("Re-checking the bound identity after renewal.");
|
||||
await IdentityCoreAsync(_device, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
{ await IdentityCoreAsync(_device, cancellationToken).ConfigureAwait(false); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the printing session identity and refuses any change. Once a principal and tenant are bound, a response naming
|
||||
/// a different one, or a different device than the credential's, clears the session and reports permission denied, so
|
||||
/// the agent can never silently continue as another user.
|
||||
/// </summary>
|
||||
private async Task<QqqSessionBinding> IdentityCoreAsync(QqqDeviceCredential? credential, CancellationToken cancellationToken)
|
||||
{
|
||||
RequireAuthenticated();
|
||||
_diagnostics.Info($"Reading the session identity from the printing session route{(credential is null ? "" : " with device proof for " + credential.DeviceId)}.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, PrintingPath + "session"), HttpMethod.Get, null,
|
||||
MetadataLimit, CookieHeader(), credential, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
@@ -462,14 +285,10 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
string tenant = IdentityValue(root, "tenantId");
|
||||
string? device = root.TryGetProperty("deviceId", out JsonElement value) && value.ValueKind != JsonValueKind.Null ? IdentityValue(root, "deviceId") : null;
|
||||
var identity = new QqqSessionBinding(Origin, principal, tenant, device);
|
||||
_diagnostics.Info($"Session identity: principal {principal}, tenant {tenant}, device {device ?? "(none)"}.");
|
||||
if((_binding is not null && (!string.Equals(_binding.PrincipalId, principal, StringComparison.Ordinal)
|
||||
|| !string.Equals(_binding.TenantId, tenant, StringComparison.Ordinal)))
|
||||
|| (credential is not null && !string.Equals(credential.DeviceId, device, StringComparison.Ordinal)))
|
||||
{
|
||||
_diagnostics.Info(_binding is not null && (!string.Equals(_binding.PrincipalId, principal, StringComparison.Ordinal) || !string.Equals(_binding.TenantId, tenant, StringComparison.Ordinal))
|
||||
? $"Identity changed: the session was bound to principal {_binding.PrincipalId} in tenant {_binding.TenantId}; the server now reports principal {principal} in tenant {tenant}. Session cleared."
|
||||
: $"Device mismatch: proof was sent for device {credential!.DeviceId} but the server reports device {device ?? "(none)"}. Session cleared.");
|
||||
ClearSession();
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The server returned a different principal or device binding.");
|
||||
}
|
||||
@@ -477,70 +296,15 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
return identity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The single place a request leaves this client. It refuses any URL outside the server origin (except provider
|
||||
/// discovery), marks the request as this agent's channel, attaches the session cookie, device headers or Basic
|
||||
/// credential that the caller passed, refuses redirects, and either buffers the body within the size bound or, for a
|
||||
/// successful document download, hands back the live stream. The default 20 second limit is skipped only when the caller
|
||||
/// bounds the request itself.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> SendCoreAsync(Uri uri, HttpMethod method, HttpContent? content, int maximumBytes, string? cookie,
|
||||
QqqDeviceCredential? device, string? basic, CancellationToken cancellationToken, bool providerDiscovery = false, bool streamContent = false, bool boundedByCaller = false)
|
||||
{
|
||||
if(!providerDiscovery && !SameOrigin(uri, Origin))
|
||||
{
|
||||
_diagnostics.Info($"Refused a request to {QqqSessionDiagnostics.Describe(uri)}: it is not on the session origin {Origin.Authority}.");
|
||||
throw new ArgumentException("Cross-origin QQQ requests are forbidden.", nameof(uri));
|
||||
}
|
||||
string route = QqqSessionDiagnostics.Describe(uri);
|
||||
long started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
_diagnostics.Debug(() => $"HTTP {method} {route}: session cookie {(cookie is not null ? "yes" : "no")}, device proof {(device is not null ? "yes" : "no")}, basic credential {(basic is not null ? "yes" : "no")}, body {(content is null ? "none" : content.Headers.ContentType?.MediaType ?? "present")}, limit {(boundedByCaller ? "caller" : "20 s")}, response bound {maximumBytes} bytes.");
|
||||
{ throw new ArgumentException("Cross-origin QQQ requests are forbidden.", nameof(uri)); }
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
if(!boundedByCaller)
|
||||
{ timeout.CancelAfter(TimeSpan.FromSeconds(20)); }
|
||||
using HttpRequestMessage request = BuildRequest(uri, method, content, cookie, device, basic, providerDiscovery);
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{ response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); }
|
||||
catch(Exception exception) when(exception is HttpRequestException or OperationCanceledException or IOException)
|
||||
{
|
||||
ReportSendFailure(method, route, started, exception, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
try
|
||||
{
|
||||
_diagnostics.Debug(() => $"HTTP {method} {route} -> {(int)response.StatusCode} {response.ReasonPhrase} in {Elapsed(started)}; content type {response.Content.Headers.ContentType?.MediaType ?? "(none)"}, length {response.Content.Headers.ContentLength?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unknown"}, Set-Cookie {(response.Headers.Contains("Set-Cookie") ? "present" : "absent")}.");
|
||||
if(!providerDiscovery && _traffic is not null)
|
||||
{ response.Content = new TrafficHttpContent(response.Content, _traffic.RecordDownload); }
|
||||
if((int)response.StatusCode is >= 300 and < 400)
|
||||
{
|
||||
_diagnostics.Info($"HTTP {method} {route} answered {(int)response.StatusCode} (redirect); redirects are refused, so the request failed. Check the server address: it must be the exact HTTPS origin QQQ serves from.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "Redirected authentication or API requests are refused.");
|
||||
}
|
||||
if(streamContent && response.IsSuccessStatusCode)
|
||||
{
|
||||
response.RequestMessage = null;
|
||||
return response;
|
||||
}
|
||||
await BufferResponseAsync(response, maximumBytes, timeout.Token).ConfigureAwait(false);
|
||||
response.RequestMessage = null;
|
||||
return response;
|
||||
}
|
||||
catch
|
||||
{
|
||||
response.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds the outgoing request: no-store caching, this agent's channel header, and only the cookie, origin, Basic credential and device proof the caller passed.</summary>
|
||||
private HttpRequestMessage BuildRequest(Uri uri, HttpMethod method, HttpContent? content, string? cookie, QqqDeviceCredential? device, string? basic, bool providerDiscovery)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, uri)
|
||||
using var request = new HttpRequestMessage(method, uri)
|
||||
{
|
||||
Content = !providerDiscovery && _traffic is not null && content is not null ? new TrafficHttpContent(content, _traffic.RecordUpload) : content,
|
||||
};
|
||||
@@ -562,34 +326,35 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
request.Headers.Add("X-QQQ-Device-Id", device.DeviceId);
|
||||
request.Headers.Add("X-QQQ-Device-Token", device.DeviceToken);
|
||||
}
|
||||
return request;
|
||||
HttpResponseMessage response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if(!providerDiscovery && _traffic is not null)
|
||||
{ response.Content = new TrafficHttpContent(response.Content, _traffic.RecordDownload); }
|
||||
if((int)response.StatusCode is >= 300 and < 400)
|
||||
{
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "Redirected authentication or API requests are refused.");
|
||||
}
|
||||
if(streamContent && response.IsSuccessStatusCode)
|
||||
{
|
||||
response.RequestMessage = null;
|
||||
return response;
|
||||
}
|
||||
await BufferResponseAsync(response, maximumBytes, timeout.Token).ConfigureAwait(false);
|
||||
response.RequestMessage = null;
|
||||
return response;
|
||||
}
|
||||
catch
|
||||
{
|
||||
response.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Logs a request that produced no response headers: cancelled by the caller, timed out, or a connection failure with its inner cause (DNS, TLS, refused).</summary>
|
||||
private void ReportSendFailure(HttpMethod method, string route, long started, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
_diagnostics.Info(cancellationToken.IsCancellationRequested
|
||||
? $"HTTP {method} {route} was cancelled by the caller after {Elapsed(started)}."
|
||||
: exception is OperationCanceledException
|
||||
? $"HTTP {method} {route} timed out after {Elapsed(started)} with no response headers."
|
||||
: $"HTTP {method} {route} could not be sent after {Elapsed(started)}: {QqqSessionDiagnostics.Describe(exception)}{(exception.InnerException is null ? "" : " (" + QqqSessionDiagnostics.Describe(exception.InnerException) + ")")}.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads a response body into memory but never more than the stated limit, whether announced by Content-Length or
|
||||
/// discovered while reading, so a hostile or broken server cannot exhaust memory. The buffered content keeps the original headers.
|
||||
/// </summary>
|
||||
private async Task BufferResponseAsync(HttpResponseMessage response, int maximumBytes, CancellationToken token)
|
||||
private static async Task BufferResponseAsync(HttpResponseMessage response, int maximumBytes, CancellationToken token)
|
||||
{
|
||||
if(response.Content.Headers.ContentLength > maximumBytes)
|
||||
{
|
||||
_diagnostics.Info($"Response rejected: announced length {response.Content.Headers.ContentLength} bytes exceeds the {maximumBytes} byte bound.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
{ throw InvalidResponse(); }
|
||||
await using Stream input = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
|
||||
using var output = new MemoryStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
@@ -597,10 +362,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
while((count = await input.ReadAsync(buffer, token).ConfigureAwait(false)) != 0)
|
||||
{
|
||||
if(output.Length + count > maximumBytes)
|
||||
{
|
||||
_diagnostics.Info($"Response rejected: the body exceeded the {maximumBytes} byte bound while reading.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
{ throw InvalidResponse(); }
|
||||
output.Write(buffer, 0, count);
|
||||
}
|
||||
var buffered = new ByteArrayContent(output.ToArray());
|
||||
@@ -610,12 +372,6 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
response.Content = buffered;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Takes the session token from a manage-session response, from its body, its cookie or both. When both are present they
|
||||
/// must agree and the token must be well formed, otherwise the session is cleared and the response rejected.
|
||||
/// </summary>
|
||||
private void AdoptManagedSession(HttpResponseMessage response)
|
||||
{
|
||||
using JsonDocument document = ParseDocument(response);
|
||||
@@ -623,32 +379,13 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
? value.ValueKind == JsonValueKind.String ? value.GetString() : throw InvalidResponse() : null;
|
||||
string? cookie = ReadSessionCookie(response);
|
||||
if(body is not null && cookie is not null && !string.Equals(body, cookie, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info("Session response rejected: the session cookie and the body's session id disagree.");
|
||||
ClearSession();
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(cookie is null && body is null && _sessionToken is null)
|
||||
{ _diagnostics.Info("Session response rejected: no session cookie, no session id in the body and no session already held."); }
|
||||
{ ClearSession(); throw InvalidResponse(); }
|
||||
string token = cookie ?? body ?? _sessionToken ?? throw InvalidResponse();
|
||||
try
|
||||
{ SetSession(new QqqSessionCredential(Origin, token).SessionToken); }
|
||||
catch(ArgumentException)
|
||||
{
|
||||
_diagnostics.Info("Session response rejected: the session id is not well formed.");
|
||||
ClearSession();
|
||||
throw InvalidResponse();
|
||||
}
|
||||
_diagnostics.Debug(() => $"Session token adopted from {(cookie is not null && body is not null ? "cookie and body (agreeing)" : cookie is not null ? "the Set-Cookie header" : body is not null ? "the response body" : "the existing session")}; {token.Length} characters; session generation {_generation}.");
|
||||
catch(ArgumentException) { ClearSession(); throw InvalidResponse(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the session token from Set-Cookie headers, strictly. Headers are size-bounded, the cookie must be for this
|
||||
/// host with path "/", and repeated session cookies must agree. An expired, empty or deleted session cookie means the
|
||||
/// server ended the session, so the local session is cleared and sign-in is required.
|
||||
/// </summary>
|
||||
private string? ReadSessionCookie(HttpResponseMessage response)
|
||||
{
|
||||
if(!response.Headers.TryGetValues("Set-Cookie", out IEnumerable<string>? headers))
|
||||
@@ -665,7 +402,6 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
string name = header.Split('=', 2)[0].Trim();
|
||||
if((name is "sessionUUID" or "sessionId") && parsed.GetAllCookies().Count == 0)
|
||||
{
|
||||
_diagnostics.Info($"The server deleted the {name} cookie; the session has ended and sign-in is required.");
|
||||
ClearSession();
|
||||
throw AuthenticationRequired();
|
||||
}
|
||||
@@ -679,23 +415,13 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
throw InvalidResponse();
|
||||
}
|
||||
if(cookie.Expired || string.IsNullOrEmpty(cookie.Value))
|
||||
{
|
||||
_diagnostics.Info($"The server returned an {(cookie.Expired ? "expired" : "empty")} {cookie.Name} cookie; the session has ended and sign-in is required.");
|
||||
ClearSession();
|
||||
throw AuthenticationRequired();
|
||||
}
|
||||
{ ClearSession(); throw AuthenticationRequired(); }
|
||||
token = cookie.Value;
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Makes a token the current session and rebuilds the cookie jar under both cookie names QQQ has used. The generation
|
||||
/// advances only when the token changed, so a renewal to the same token does not invalidate in-flight requests.
|
||||
/// </summary>
|
||||
private void SetSession(string token)
|
||||
{
|
||||
if(!string.Equals(_sessionToken, token, StringComparison.Ordinal))
|
||||
@@ -706,9 +432,6 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
_cookies.Add(Origin, new Cookie("sessionId", token, "/") { HttpOnly = true, Secure = string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) });
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Forgets the token, cookies, device and bound identity and advances the generation, so every request already in flight is refused when it returns.</summary>
|
||||
private void ClearSession()
|
||||
{
|
||||
_sessionToken = null;
|
||||
@@ -719,66 +442,30 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
_generation++;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The Cookie header value for the current session, scoped to this origin.</summary>
|
||||
private string CookieHeader() => _cookies.GetCookieHeader(Origin);
|
||||
|
||||
|
||||
|
||||
/// <summary>Throws the sign-in-required failure when no session exists.</summary>
|
||||
private void RequireAuthenticated() { if(_sessionToken is null) { throw AuthenticationRequired(); } }
|
||||
|
||||
|
||||
|
||||
/// <summary>The failure meaning the user must sign in again; the message is safe to show and carries no server detail.</summary>
|
||||
private static QqqSessionException AuthenticationRequired() => new(QqqSessionException.FailureKind.AuthenticationRequired, "Sign in to QQQ to continue.");
|
||||
|
||||
|
||||
|
||||
/// <summary>The failure for any response that breaks the expected shape; deliberately generic so a hostile body is never echoed.</summary>
|
||||
private static QqqSessionException InvalidResponse() => new(QqqSessionException.FailureKind.InvalidResponse, "The server returned an invalid authentication or API response.");
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps 401 to a cleared session and sign-in required, and 403 to permission denied. They are kept apart because a
|
||||
/// withdrawn session can be repaired by signing in, whereas a denied permission cannot.
|
||||
/// </summary>
|
||||
private void CheckAuthorization(HttpResponseMessage response)
|
||||
{
|
||||
if(response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_diagnostics.Info("The server answered 401 Unauthorized: the session is no longer accepted. Local session cleared; sign-in is required.");
|
||||
ClearSession();
|
||||
throw AuthenticationRequired();
|
||||
}
|
||||
{ ClearSession(); throw AuthenticationRequired(); }
|
||||
if(response.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
_diagnostics.Info("The server answered 403 Forbidden: the signed-in user or device is not permitted to perform this operation. Signing in again will not help; check permissions and device approval in QQQ.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The current QQQ user is not permitted to perform this operation.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies the authorization mapping first, then treats any other non-success status as a refused request with a generic message.</summary>
|
||||
private void RequireSuccess(HttpResponseMessage response)
|
||||
{
|
||||
CheckAuthorization(response);
|
||||
if(!response.IsSuccessStatusCode)
|
||||
{
|
||||
_diagnostics.Info($"The server answered {(int)response.StatusCode} {response.ReasonPhrase}; the request did not succeed.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "The QQQ request did not succeed.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Builds a URL under the printing route from a caller-supplied relative path, rejecting absolute, backslash, fragment,
|
||||
/// percent-encoded, dot-segment and empty-segment paths so a caller cannot reach another route or origin by crafting the path.
|
||||
/// </summary>
|
||||
private Uri PrintingUri(string path)
|
||||
{
|
||||
if(string.IsNullOrEmpty(path) || path.StartsWith('/') || path.Contains('\\', StringComparison.Ordinal)
|
||||
@@ -793,40 +480,21 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
return uri;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Compares scheme, host and port only, ignoring case; used to refuse cross-origin requests.</summary>
|
||||
private static bool SameOrigin(Uri left, Uri right) => left.IsAbsoluteUri && right.IsAbsoluteUri
|
||||
&& string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(left.IdnHost, right.IdnHost, StringComparison.OrdinalIgnoreCase) && left.Port == right.Port;
|
||||
|
||||
|
||||
|
||||
/// <summary>Serializes a request body as JSON with the client's strict, case-sensitive options.</summary>
|
||||
private static StringContent JsonBody<T>(T body) => new(JsonSerializer.Serialize(body, JsonOptions), Encoding.UTF8, "application/json");
|
||||
|
||||
|
||||
|
||||
/// <summary>Deserializes a response into a type, reporting a missing or malformed body as the generic invalid-response failure.</summary>
|
||||
private T Parse<T>(HttpResponseMessage response)
|
||||
private static T Parse<T>(HttpResponseMessage response)
|
||||
{
|
||||
using JsonDocument document = ParseDocument(response);
|
||||
try
|
||||
{ return document.RootElement.Deserialize<T>(JsonOptions) ?? throw InvalidResponse(); }
|
||||
catch(JsonException exception)
|
||||
{
|
||||
_diagnostics.Info($"Response rejected: the JSON did not match the expected {typeof(T).Name} shape ({exception.Message}).");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
catch(JsonException) { throw InvalidResponse(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Parses a response as a JSON object with a depth limit and no duplicate property names (a common smuggling trick for
|
||||
/// disagreeing parsers). Anything else is an invalid response; the caller owns the returned document.
|
||||
/// </summary>
|
||||
private JsonDocument ParseDocument(HttpResponseMessage response)
|
||||
private static JsonDocument ParseDocument(HttpResponseMessage response)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -835,25 +503,15 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
try
|
||||
{
|
||||
if(document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
_diagnostics.Info($"Response rejected: expected a JSON object but received {document.RootElement.ValueKind} (content type {response.Content.Headers.ContentType?.MediaType ?? "none"}). A sign-in page or proxy error page in place of the API is a common cause.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
{ throw InvalidResponse(); }
|
||||
RejectDuplicateProperties(document.RootElement);
|
||||
return document;
|
||||
}
|
||||
catch { document.Dispose(); throw; }
|
||||
}
|
||||
catch(JsonException exception)
|
||||
{
|
||||
_diagnostics.Info($"Response rejected: the body is not valid JSON ({exception.Message}; content type {response.Content.Headers.ContentType?.MediaType ?? "none"}). A sign-in page or proxy error page in place of the API is a common cause.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
catch(JsonException) { throw InvalidResponse(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Walks objects and arrays and rejects any object that repeats a property name, ignoring case.</summary>
|
||||
private static void RejectDuplicateProperties(JsonElement element)
|
||||
{
|
||||
if(element.ValueKind == JsonValueKind.Object)
|
||||
@@ -873,44 +531,16 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads a required identity string that must be non-blank, at most 1024 characters and free of control characters.</summary>
|
||||
private string IdentityValue(JsonElement root, string name)
|
||||
private static string IdentityValue(JsonElement root, string name)
|
||||
{
|
||||
if(!root.TryGetProperty(name, out JsonElement value) || value.ValueKind != JsonValueKind.String
|
||||
|| string.IsNullOrWhiteSpace(value.GetString()) || value.GetString()!.Length > 1024 || value.GetString()!.Any(char.IsControl))
|
||||
{
|
||||
_diagnostics.Info($"Identity response rejected: '{name}' is missing, blank, not a string, too long or contains control characters.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
return value.GetString()!;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Records why an operation failed (type and message only) and returns false, so it can sit in an exception filter and
|
||||
/// leave the exception, its stack and the catch order untouched. Cancellation requested by the caller is noted, not reported as a failure.
|
||||
/// </summary>
|
||||
private bool ReportFailure(string operation, Exception exception)
|
||||
{
|
||||
_diagnostics.Info(exception is OperationCanceledException
|
||||
? operation + " was cancelled."
|
||||
: exception is QqqSessionException session
|
||||
? $"{operation} failed ({session.Failure}): {session.Message}"
|
||||
: $"{operation} failed: {QqqSessionDiagnostics.Describe(exception)}");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Milliseconds since a Stopwatch timestamp, for request timing in the log.</summary>
|
||||
private static string Elapsed(long started) => System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + " ms";
|
||||
|
||||
|
||||
|
||||
/// <summary>Clears the session, so a late response is refused, and releases the HTTP client and gate.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
ClearSession();
|
||||
|
||||
@@ -8,7 +8,6 @@ namespace Qqq.PrintAgent.Core.Authentication;
|
||||
/// <summary>An opaque QQQ session for protected storage by its host. It contains no provider refresh token.</summary>
|
||||
public sealed class QqqSessionCredential
|
||||
{
|
||||
/// <summary>Binds a session token to the one server origin it belongs to. The token must be visible ASCII of at most 4096 characters without separators, quotes or backslashes, so it can be placed in a cookie header without changing its meaning.</summary>
|
||||
public QqqSessionCredential(Uri origin, string sessionToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(origin);
|
||||
@@ -21,13 +20,7 @@ public sealed class QqqSessionCredential
|
||||
SessionToken = sessionToken;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Uri Origin { get; }
|
||||
public string SessionToken { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying the credential can never reveal the session token.</summary>
|
||||
public override string ToString() => "QQQ session credential (redacted)";
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Credential-free progress reporting for the session client. Info entries describe each step of discovery, sign-in,
|
||||
/// session restore, identity checks and the realtime upgrade so an operator can see where a new server or account
|
||||
/// stops working; debug entries add request-level detail (route, status, timing, header names) and are produced only
|
||||
/// while the host says debug logging is on. Callers never receive tokens, cookies, passwords, codes or device proof.
|
||||
/// </summary>
|
||||
public sealed class QqqSessionDiagnostics(Action<string>? info = null, Action<string>? debug = null, Func<bool>? debugEnabled = null)
|
||||
{
|
||||
/// <summary>A sink that discards everything, for callers that have no log.</summary>
|
||||
public static QqqSessionDiagnostics None { get; } = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>True when a debug sink exists and the host currently wants debug detail.</summary>
|
||||
public bool DebugEnabled => debug is not null && (debugEnabled?.Invoke() ?? true);
|
||||
|
||||
|
||||
|
||||
/// <summary>Records one step of normal operation.</summary>
|
||||
public void Info(string message) => info?.Invoke(message);
|
||||
|
||||
|
||||
|
||||
/// <summary>Records request-level detail when debug logging is on; the message is built only in that case.</summary>
|
||||
public void Debug(Func<string> message)
|
||||
{
|
||||
if(DebugEnabled)
|
||||
{ debug!.Invoke(message()); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Describes an address without its query, fragment or user information, so a logged route can never carry a code, state or credential.</summary>
|
||||
public static string Describe(Uri uri) => uri.IsAbsoluteUri ? uri.Scheme + "://" + uri.Authority + uri.AbsolutePath : uri.ToString();
|
||||
|
||||
|
||||
|
||||
/// <summary>Describes a failure by type and message, followed by each inner cause the same way, so "see inner exception" never hides the real reason; stack traces belong to the host's error sink.</summary>
|
||||
public static string Describe(Exception exception)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
var parts = new List<string>();
|
||||
for(Exception? current = exception; current is not null && parts.Count < 4; current = current.InnerException)
|
||||
{ parts.Add(current.GetType().Name + ": " + current.Message); }
|
||||
return string.Join(" <- ", parts);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The human-readable reason for a failure: the message and each inner cause, without type names, for an on-screen explanation.</summary>
|
||||
public static string Explain(Exception exception)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
var parts = new List<string>();
|
||||
for(Exception? current = exception; current is not null && parts.Count < 4; current = current.InnerException)
|
||||
{
|
||||
string message = current.Message.Trim();
|
||||
if(message.Length != 0 && !parts.Contains(message, StringComparer.Ordinal))
|
||||
{ parts.Add(message); }
|
||||
}
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ namespace Qqq.PrintAgent.Core.Authentication;
|
||||
/// <summary>A sanitized failure classification; response bodies and credentials are never included.</summary>
|
||||
public sealed class QqqSessionException(QqqSessionException.FailureKind failure, string message) : IOException(message)
|
||||
{
|
||||
/// <summary>The reason a session request failed, which the caller maps to guidance without needing server text.</summary>
|
||||
public enum FailureKind
|
||||
{
|
||||
ConfigurationRequired,
|
||||
@@ -18,7 +17,5 @@ public sealed class QqqSessionException(QqqSessionException.FailureKind failure,
|
||||
TransportRefused,
|
||||
}
|
||||
|
||||
|
||||
|
||||
public FailureKind Failure { get; } = failure;
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using KofTwentyTwo.AppKit;
|
||||
|
||||
|
||||
namespace Qqq.PrintAgent.Core;
|
||||
|
||||
/// <summary>
|
||||
/// A client edition compiled into a build. The build script copies one folder under <c>editions/</c> into the companion
|
||||
/// (its mark, icon and this descriptor as <c>edition.json</c> beside the executable), stamps the client's product and company
|
||||
/// into every executable, and names the installer and release asset after <see cref="AssetPrefix"/>, so the installed
|
||||
/// updater follows the client's own asset. An edition changes presentation and labels only: application id, data paths,
|
||||
/// executable names, the install directory, the protocol handler and the MSI upgrade identity stay shared. Machine
|
||||
/// branding (<see cref="RuntimeBranding"/>) still applies on top of an edition.
|
||||
/// </summary>
|
||||
public sealed partial record Edition(string Id, string DisplayName, string CompanyName, string? Tagline, string? Description, Uri? SupportUrl, string? AccentColor, string AssetPrefix,
|
||||
string ServerName = Edition.DefaultServerName, bool ShowMascot = true, bool ShowLicense = true)
|
||||
{
|
||||
/// <summary>What the generic build calls the server in user-facing text.</summary>
|
||||
public const string DefaultServerName = "QQQ";
|
||||
|
||||
/// <summary>The descriptor's file name, next to the executable in an edition build.</summary>
|
||||
public const string FileName = "edition.json";
|
||||
|
||||
/// <summary>The release asset prefix of the generic build, used when no edition is compiled in.</summary>
|
||||
public const string DefaultAssetPrefix = "QqqPrintAgent";
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the edition compiled into this build, or null for the generic build.</summary>
|
||||
public static Edition? LoadInstalled() => Load(Path.Combine(AppContext.BaseDirectory, FileName));
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads an edition descriptor. A missing file means the generic build; a malformed one (bad id or asset prefix, missing
|
||||
/// names, oversized, not an object) is treated the same way rather than half-applied, and an accent colour is kept only
|
||||
/// when white text stays readable on it.
|
||||
/// </summary>
|
||||
public static Edition? Load(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
using FileStream stream = File.OpenRead(path);
|
||||
if(stream.Length > 16 * 1024)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions { MaxDepth = 4 });
|
||||
JsonElement root = document.RootElement;
|
||||
if(root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string? id = RuntimeBranding.ReadText(root, "id", 40);
|
||||
string? name = RuntimeBranding.ReadText(root, "displayName", 180);
|
||||
string? company = RuntimeBranding.ReadText(root, "companyName", 180);
|
||||
string? prefix = RuntimeBranding.ReadText(root, "assetPrefix", 40);
|
||||
if(id is null || !IdPattern().IsMatch(id) || name is null || company is null || prefix is null || !AssetPrefixPattern().IsMatch(prefix))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string? support = RuntimeBranding.ReadText(root, "supportUrl", 2048);
|
||||
Uri? supportUri = Uri.TryCreate(support, UriKind.Absolute, out Uri? candidate)
|
||||
&& string.Equals(candidate.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal) && string.IsNullOrEmpty(candidate.UserInfo) ? candidate : null;
|
||||
string? accent = RuntimeBranding.ReadText(root, "accentColor", 7);
|
||||
return new(id, name, company, RuntimeBranding.ReadText(root, "tagline", 180), RuntimeBranding.ReadText(root, "description", 180), supportUri,
|
||||
RuntimeBranding.HasReadableWhiteText(accent) ? accent : null, prefix,
|
||||
RuntimeBranding.ReadText(root, "serverName", 60) ?? DefaultServerName, ReadFlag(root, "aboutMascot"), ReadFlag(root, "aboutLicense"));
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies the edition's presentation to the generic identity without touching id, author, copyright, repository or attributions.</summary>
|
||||
public AppInfo Apply(AppInfo defaults)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(defaults);
|
||||
return defaults with
|
||||
{
|
||||
DisplayName = DisplayName,
|
||||
Tagline = Tagline ?? CompanyName,
|
||||
Description = Description ?? defaults.Description,
|
||||
Website = SupportUrl ?? defaults.Website,
|
||||
Brand = defaults.Brand with
|
||||
{
|
||||
GradientStart = AccentColor ?? defaults.Brand.GradientStart,
|
||||
GradientEnd = AccentColor ?? defaults.Brand.GradientEnd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads an optional boolean switch; anything but an explicit false keeps the generic behaviour.</summary>
|
||||
private static bool ReadFlag(JsonElement root, string key)
|
||||
=> !(root.TryGetProperty(key, out JsonElement field) && field.ValueKind == JsonValueKind.False);
|
||||
|
||||
|
||||
|
||||
/// <summary>An edition id is a folder name: lowercase letters, digits and hyphens, at most 40 characters.</summary>
|
||||
[GeneratedRegex("^[a-z0-9][a-z0-9-]{0,39}$", RegexOptions.CultureInvariant, 1000)]
|
||||
private static partial Regex IdPattern();
|
||||
|
||||
|
||||
|
||||
/// <summary>An asset prefix becomes a file name on the release feed: 2 to 40 letters or digits starting with a letter.</summary>
|
||||
[GeneratedRegex("^[A-Za-z][A-Za-z0-9]{1,39}$", RegexOptions.CultureInvariant, 1000)]
|
||||
private static partial Regex AssetPrefixPattern();
|
||||
}
|
||||
@@ -15,18 +15,9 @@ public static class MachineStateDirectory
|
||||
{
|
||||
private static readonly Lock Gate = new();
|
||||
private static bool _validated;
|
||||
|
||||
|
||||
|
||||
/// <summary>The machine state folder under ProgramData, the only location whose contents are protected and validated before privileged code opens them.</summary>
|
||||
public static string Root => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "QqqPrintAgent");
|
||||
|
||||
|
||||
|
||||
private const string Error = "The machine print data directory is not trusted. Installation or startup was stopped without adopting its contents. Ask an administrator to inspect its owner, permissions and links before repairing the MSI.";
|
||||
|
||||
|
||||
|
||||
/// <summary>Private diagnostic paths remain independent; canonical machine state is always protected.</summary>
|
||||
public static void EnsureForPath(string path)
|
||||
{
|
||||
@@ -57,8 +48,6 @@ public static class MachineStateDirectory
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Validate existing branding without creating state merely to read optional settings.</summary>
|
||||
public static void ValidateExistingForPath(string path)
|
||||
{
|
||||
@@ -68,17 +57,12 @@ public static class MachineStateDirectory
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when a path is the machine state folder or inside it, compared on whole path segments and ignoring case, so only canonical machine state is protected and private diagnostic folders are left alone.</summary>
|
||||
private static bool Contains(string path)
|
||||
{
|
||||
string full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
|
||||
return string.Equals(full, Root, StringComparison.OrdinalIgnoreCase) || full.StartsWith(Root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Inspect every adopted descendant; never follow junctions or normalize hostile ownership.</summary>
|
||||
public static void ValidateTree(string directory)
|
||||
{
|
||||
@@ -102,8 +86,6 @@ public static class MachineStateDirectory
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Verify a newly created staging file before its privileged consumer opens it.</summary>
|
||||
public static void ValidateFile(string path)
|
||||
{
|
||||
@@ -112,9 +94,6 @@ public static class MachineStateDirectory
|
||||
ValidateAcl(new FileInfo(path).GetAccessControl());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Walks from the path up to the drive root and rejects any component that is a link or junction, because a redirected folder could make privileged code read or write somewhere else.</summary>
|
||||
private static void RejectLinks(string path)
|
||||
{
|
||||
for(string? current = Path.GetFullPath(path); current is not null; current = Path.GetDirectoryName(current))
|
||||
@@ -126,9 +105,6 @@ public static class MachineStateDirectory
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Requires an explicit access list, a trusted owner, and no allow rule (inherit-only ones included) for any account other than SYSTEM, Administrators or the Windows installer service, so no ordinary user can alter what the service later trusts.</summary>
|
||||
internal static void ValidateAcl(FileSystemSecurity security)
|
||||
{
|
||||
var descriptor = new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0);
|
||||
@@ -146,8 +122,5 @@ public static class MachineStateDirectory
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True only for the three accounts trusted to own or write machine state: SYSTEM, the Administrators group and the Windows installer service.</summary>
|
||||
private static bool Trusted(string? sid) => sid is "S-1-5-18" or "S-1-5-32-544" or "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464";
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>The saved server address, device identity and poll interval for one enrollment; the device token is the secret, so this is stored only in protected machine state and never logged.</summary>
|
||||
public sealed record AgentConfiguration(string ServerUrl, string DeviceId, string DeviceToken, int PollIntervalSeconds = 5);
|
||||
|
||||
|
||||
@@ -5,5 +5,4 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>One operator command from the server (for example pause or cancel a job). The ids make it idempotent and the expiry makes a stale command ignorable, so a late delivery cannot act on a situation that has changed.</summary>
|
||||
public sealed record DeviceControlCommand(string CommandId, string RequestId, string Action, string CreatedAt, string ExpiresAt, string? JobId = null);
|
||||
@@ -5,5 +5,4 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>The server's answer to a control poll: the control version, the desired state and any pending commands. The version lets the agent skip a reply older than what it already applied.</summary>
|
||||
public sealed record DeviceControlPoll(int ControlVersion, string ServerTime, int PollIntervalSeconds, DeviceDesiredState DesiredState, IReadOnlyList<DeviceControlCommand> Commands);
|
||||
@@ -5,5 +5,4 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>The agent's report of what happened to a control command, keyed by a receipt id so a repeated report is recognised and the operator sees one outcome per command.</summary>
|
||||
public sealed record DeviceControlReceipt(string ReceiptId, string Outcome, string CompletedAt, string? Message = null);
|
||||
@@ -7,5 +7,4 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>What the operator wants for this device (dispatch paused or not, connection state), versioned by revision. Every field is required so a reply missing one is rejected instead of silently resuming dispatch.</summary>
|
||||
public sealed record DeviceDesiredState([property: JsonRequired] long Revision, [property: JsonRequired] bool DispatchPaused, [property: JsonRequired] string ConnectionState);
|
||||
@@ -10,9 +10,6 @@ public sealed class DownloadedDocument(string path, FileStream lease) : IDisposa
|
||||
{
|
||||
public string Path { get; } = path;
|
||||
|
||||
|
||||
|
||||
/// <summary>Releases the lease and then deletes the staging file. Deletion problems are swallowed because cleanup must not change an already committed spool receipt, and leftovers are removed at the next service start.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lease.Dispose();
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>What this computer sends to pair itself: the one-time code, a display name and the agent version, so the server can check compatibility before issuing a credential.</summary>
|
||||
public sealed record EnrollmentRequest(string EnrollmentCode, string DeviceName, string AgentVersion);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>The server's enrollment result: protocol version, the new device identity and its secret token, and the poll interval. The token is shown once, so it must be stored immediately and never logged.</summary>
|
||||
public sealed record EnrollmentResponse(int ProtocolVersion, string DeviceId, string DeviceToken, int PollIntervalSeconds);
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ 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) : IFilePrinterBackend
|
||||
{
|
||||
/// <summary>Returns only printers the operator chose to share and that have at least one usable format, so nothing else is ever offered to QQQ.</summary>
|
||||
public IReadOnlyList<PrinterInfo> Discover()
|
||||
{
|
||||
IReadOnlySet<string> exposed = exposedIds();
|
||||
@@ -17,7 +16,6 @@ public sealed class ExposedPrinterBackend(IPrinterBackend inner, Func<IReadOnlyS
|
||||
|
||||
|
||||
|
||||
/// <summary>Prints only if the job's printer is still shared on this computer, rechecked at submission time because the operator may have unshared it since the job was claimed.</summary>
|
||||
public Task<string> PrintAsync(PrintJob job, byte[] content, CancellationToken cancellationToken)
|
||||
{
|
||||
if(!exposedIds().Contains(job.PrinterId))
|
||||
@@ -27,15 +25,9 @@ public sealed class ExposedPrinterBackend(IPrinterBackend inner, Func<IReadOnlyS
|
||||
return inner.PrintAsync(job, content, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Prints a staged file with the default two minute native submission limit.</summary>
|
||||
public Task<string> PrintFileAsync(PrintJob job, string path, CancellationToken cancellationToken)
|
||||
=> PrintFileAsync(job, path, TimeSpan.FromMinutes(2), cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>Prints a staged file only if the printer is still shared, and throws when the inner backend cannot take files, so neither condition is silently skipped.</summary>
|
||||
public Task<string> PrintFileAsync(PrintJob job, string path, TimeSpan submissionTimeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if(!exposedIds().Contains(job.PrinterId))
|
||||
|
||||
@@ -8,12 +8,7 @@ namespace Qqq.PrintAgent.Core.Printing;
|
||||
/// <summary>Consumes a verified file while its owner retains the read-only lease; no document travels in a control message.</summary>
|
||||
public interface IFilePrinterBackend : IPrinterBackend
|
||||
{
|
||||
/// <summary>Prints the verified file at the path and returns the spooler receipt; the caller keeps its read-only lease until this returns, so the content cannot change underneath.</summary>
|
||||
Task<string> PrintFileAsync(PrintJob job, string path, CancellationToken cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>Same as the plain overload but with a bound on spooler submission; the default ignores the bound for backends that cannot enforce one, so older backends keep working.</summary>
|
||||
Task<string> PrintFileAsync(PrintJob job, string path, TimeSpan submissionTimeout, CancellationToken cancellationToken)
|
||||
=> PrintFileAsync(job, path, cancellationToken);
|
||||
}
|
||||
@@ -5,39 +5,13 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// The durable record the worker depends on. Every state is written here before it is reported, and a reported event is
|
||||
/// acknowledged here only after the server accepted it, so a crash or an outage can be recovered without losing an outcome
|
||||
/// or printing a job twice. The service implements it with SQLite; tests supply an in-memory version.
|
||||
/// </summary>
|
||||
public interface IJobJournal
|
||||
{
|
||||
/// <summary>Stores a claimed job's display facts (printer, title, format, creation time) so history stays readable.</summary>
|
||||
void TrackJob(PrintJob job, string? printerName);
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the last recorded state of a job, or null when it is unknown, so a finished job is never printed again.</summary>
|
||||
string? GetState(string jobId);
|
||||
|
||||
|
||||
|
||||
/// <summary>Appends an event and updates the job's state together, so the outbox and the visible state cannot disagree.</summary>
|
||||
void Record(string jobId, PrintEvent printEvent);
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns events not yet acknowledged by the server, oldest first and in bounded batches, for delivery.</summary>
|
||||
IReadOnlyList<(string JobId, PrintEvent Event)> Pending();
|
||||
|
||||
|
||||
|
||||
/// <summary>Marks one event delivered, after the server accepted it.</summary>
|
||||
void Acknowledge(string eventId);
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns jobs recorded as received or submitting that never finished, so a restart can report them instead of leaving them unknown.</summary>
|
||||
IReadOnlyList<(string JobId, string State)> Incomplete();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,9 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// What the worker needs from a printing platform: list the printers it can use and submit one document. Windows, the isolated helper,
|
||||
/// the exposure filter and test fakes all implement it, so the worker's safety rules are written once.
|
||||
/// </summary>
|
||||
public interface IPrinterBackend
|
||||
{
|
||||
/// <summary>Lists the printers this backend can print to, with their formats, status and media profiles.</summary>
|
||||
IReadOnlyList<PrinterInfo> Discover();
|
||||
|
||||
|
||||
|
||||
/// <summary>Submits one document to the job's printer and returns the spooler receipt; a failure after submission began means the outcome is uncertain.</summary>
|
||||
Task<string> PrintAsync(PrintJob job, byte[] content, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>The result of asking for work: a job claimed for this device, or none. A claim is exclusive, so only this agent may print that job.</summary>
|
||||
public sealed record JobClaim(PrintJob? Job);
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// One destination in the server's operator printer catalog. The catalog lists only printers that are approved on the server,
|
||||
/// present in their device's latest exposure snapshot and visible to the signed-in user, so an entry for this device's printer
|
||||
/// is the server's confirmation that the printer is approved. Extra members the server adds are ignored.
|
||||
/// </summary>
|
||||
public sealed record OperatorPrinter(string PrinterRowId, string DeviceId, string PrinterId, string Name, string DeviceName, string Status, string[] Formats);
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Catalog part of the API client: reads the operator printer catalog so the agent can tell which of its shared printers the
|
||||
/// server has approved. The device-facing routes never report approval, and the catalog is the one authenticated view a
|
||||
/// signed-in user has of it.
|
||||
/// </summary>
|
||||
public sealed partial class PrintApiClient
|
||||
{
|
||||
/// <summary>Catalog page size; the contract allows 1 to 200.</summary>
|
||||
internal const int CatalogPageSize = 200;
|
||||
|
||||
/// <summary>Upper bound on catalog pages read in one call, so a server that keeps returning cursors cannot keep the agent busy.</summary>
|
||||
internal const int CatalogPageLimit = 10;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the whole operator printer catalog (every page until the server returns no cursor, at most ten pages). It needs the
|
||||
/// signed-in session: the catalog is user-scoped, so a device bearer token cannot read it. A 404 means the server does not
|
||||
/// offer the catalog, a 401 or 403 that the signed-in user may not read it; both are reported as such rather than hidden.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<OperatorPrinter>> OperatorPrintersAsync(CancellationToken token)
|
||||
{
|
||||
if(!_nativeSession)
|
||||
{
|
||||
throw new InvalidOperationException("The operator printer catalog needs a signed-in session.");
|
||||
}
|
||||
List<OperatorPrinter> printers = [];
|
||||
string? cursor = null;
|
||||
for(int page = 0; page < CatalogPageLimit; page++)
|
||||
{
|
||||
string path = "operator/printers?limit=" + CatalogPageSize + (cursor is null ? "" : "&after=" + Uri.EscapeDataString(cursor));
|
||||
using HttpResponseMessage response = await _http.GetAsync(path, token).ConfigureAwait(false);
|
||||
if(response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
throw new NotSupportedException("The server does not offer the operator printer catalog (printing extension 2.3 or later).");
|
||||
}
|
||||
if(response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
throw new UnauthorizedAccessException($"The signed-in user may not read the operator printer catalog (HTTP {(int)response.StatusCode}).");
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
OperatorPrinterList result = await response.Content.ReadFromJsonAsync<OperatorPrinterList>(JsonOptions, token).ConfigureAwait(false)
|
||||
?? throw new InvalidDataException("Missing operator printer catalog.");
|
||||
printers.AddRange(result.Printers ?? []);
|
||||
cursor = result.NextCursor;
|
||||
if(string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
return printers;
|
||||
}
|
||||
}
|
||||
throw new InvalidDataException($"The operator printer catalog did not end after {CatalogPageLimit} pages.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>One catalog page: its printers and the cursor for the next page, or null at the end.</summary>
|
||||
private sealed record OperatorPrinterList(OperatorPrinter[]? Printers, string? NextCursor);
|
||||
}
|
||||
@@ -10,23 +10,11 @@ using System.Text.Json;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Device control part of the API client: polling for desired state and commands over HTTP (the compatibility path), the
|
||||
/// strict validation applied to every command whatever its transport, and delivery of command receipts. Responses are
|
||||
/// bounded and every command's identity, action, lifetime and target are checked before anything acts on it.
|
||||
/// </summary>
|
||||
public sealed partial class PrintApiClient
|
||||
{
|
||||
private static readonly string[] ControlCapabilities = ["REFRESH_INVENTORY", "CANCEL_SPOOL"];
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies the same validation to a single command that arrived over the realtime channel as an HTTP poll response would get, so both transports accept exactly the same commands.</summary>
|
||||
public static void ValidateRealtimeCommand(DeviceControlCommand command)
|
||||
=> ValidateControlPoll(new DeviceControlPoll(1, DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), 5, new DeviceDesiredState(0, false, "CONNECTED"), [command]));
|
||||
|
||||
|
||||
|
||||
/// <summary>A 404 means legacy only before this connection has negotiated control support.</summary>
|
||||
public async Task<DeviceControlPoll?> ControlPollAsync(long revision, CancellationToken token)
|
||||
{
|
||||
@@ -58,13 +46,6 @@ public sealed partial class PrintApiClient
|
||||
return poll;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks a control response in full: version 1, a poll interval of 1 to 300 seconds, a valid desired state and server time, at
|
||||
/// most 20 commands, and for each command a safe unique identifier, a bounded action, a lifetime of at most 15 minutes, and a
|
||||
/// job identifier when the action cancels a spool job. Anything else is rejected as an invalid response, not partly applied.
|
||||
/// </summary>
|
||||
private static void ValidateControlPoll(DeviceControlPoll poll)
|
||||
{
|
||||
if(poll.ControlVersion != 1 || poll.PollIntervalSeconds is < 1 or > 300 || poll.DesiredState is null
|
||||
@@ -88,18 +69,8 @@ public sealed partial class PrintApiClient
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True for a non-empty identifier of at most 200 characters made only of letters, digits and the characters - _ . :, so it cannot carry path or markup characters into later use.</summary>
|
||||
private static bool ValidControlId(string? value) => !string.IsNullOrEmpty(value) && value.Length <= 200 && value.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_' or '.' or ':');
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Delivers one command receipt: over the realtime channel with a stable message identity and the server's acknowledgement
|
||||
/// when attached, otherwise by HTTP, where the server must answer that it accepted the receipt. A receipt the server did not
|
||||
/// accept throws, so the caller keeps it pending and sends it again instead of treating it as delivered.
|
||||
/// </summary>
|
||||
public async Task ControlReceiptAsync(string commandId, DeviceControlReceipt receipt, CancellationToken token)
|
||||
{
|
||||
if(_realtime is not null)
|
||||
|
||||
@@ -8,18 +8,8 @@ using System.Security.Cryptography;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Streaming download part of the API client: documents go straight into a protected staging file while their size and SHA-256 are
|
||||
/// checked, with total, connect and idle time limits and an optional shared bandwidth throttle. Nothing reaches the printer unless the
|
||||
/// whole file verified.
|
||||
/// </summary>
|
||||
public sealed partial class PrintApiClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Downloads a job's document into a new uniquely named file in the staging folder and returns it open for reading. The
|
||||
/// settings must be valid and the declared size within the format and staging limits before any bytes are requested; a failed
|
||||
/// download deletes its partial file, so only a verified file is ever returned.
|
||||
/// </summary>
|
||||
public async Task<DownloadedDocument> DownloadFileAsync(PrintJob job, string directory, RealtimeSettings settings, CancellationToken token, Func<int, CancellationToken, Task>? throttle = null)
|
||||
{
|
||||
settings.Validate();
|
||||
@@ -41,13 +31,6 @@ public sealed partial class PrintApiClient
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Requests the document (through the signed-in session when there is one) under a total time limit and a shorter connect limit,
|
||||
/// refuses a response whose declared length differs from the manifest, and copies it into the file with verification. The file is
|
||||
/// created exclusively, so an existing file can never be overwritten.
|
||||
/// </summary>
|
||||
private async Task DownloadIntoFileAsync(PrintJob job, string path, RealtimeSettings settings, Func<int, CancellationToken, Task>? throttle, CancellationToken token)
|
||||
{
|
||||
using var total = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
@@ -69,13 +52,6 @@ public sealed partial class PrintApiClient
|
||||
await output.FlushAsync(total.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Copies the stream to the file while hashing it, stopping with an error the moment more than the declared length arrives, when no
|
||||
/// data arrives within the idle limit, or when the final length or SHA-256 does not match. A shared throttle, or else the per-download
|
||||
/// rate limit, paces the copy so the aggregate bandwidth setting holds.
|
||||
/// </summary>
|
||||
private static async Task CopyVerifiedAsync(Stream source, Stream output, PrintJob job, RealtimeSettings settings, Func<int, CancellationToken, Task>? throttle, CancellationToken token)
|
||||
{
|
||||
using var digest = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user