Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3396e6da8c
|
||
|
|
feea631d17
|
||
|
|
e1961e6bd0
|
||
|
|
e5846df715 |
No files matched your search
@@ -0,0 +1,128 @@
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Check leading notices in explicitly owned, Git-tracked source families."""
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def selected(name: str, policy: dict[str, Any]) -> bool:
|
||||
"""Select owned paths, honoring documented exact/pattern exclusions first."""
|
||||
if any(fnmatch.fnmatchcase(name, item["pattern"]) for item in policy["exclusions"]):
|
||||
return False
|
||||
return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"])
|
||||
|
||||
|
||||
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():]
|
||||
elif text.startswith("#!"):
|
||||
if style not in ("hash", "powershell", "slash"):
|
||||
raise ValueError("Unexpected interpreter directive")
|
||||
text = text.partition("\n")[2]
|
||||
while re.match(r"#Requires\b", text, re.IGNORECASE):
|
||||
if style != "powershell":
|
||||
raise ValueError("Unexpected PowerShell directive")
|
||||
text = text.partition("\n")[2]
|
||||
comments = []
|
||||
while text.strip():
|
||||
text = text.lstrip()
|
||||
if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE):
|
||||
raise ValueError("Required declaration must precede the notice")
|
||||
if style in ("hash", "powershell") and text.startswith("#"):
|
||||
line, _, text = text.partition("\n")
|
||||
comments.append(line[1:])
|
||||
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("<#"))):
|
||||
opening, closing = {"slash": ("/*", "*/"), "xml": ("<!--", "-->"), "powershell": ("<#", "#>")}[style]
|
||||
body, separator, rest = text[len(opening):].partition(closing)
|
||||
if not separator:
|
||||
raise ValueError("Unterminated leading comment")
|
||||
comments.append(body)
|
||||
text = rest
|
||||
else:
|
||||
break
|
||||
return "\n".join(re.sub(r"^\s*[*~]\s?", "", line).strip() for line in "\n".join(comments).splitlines())
|
||||
|
||||
|
||||
def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
|
||||
"""Require an approved holder/license pair in real leading comment syntax."""
|
||||
suffix = Path(name).suffix
|
||||
style = policy["styles"].get(suffix)
|
||||
if Path(name).name.startswith("Dockerfile."):
|
||||
style = "hash"
|
||||
if style is None:
|
||||
raise ValueError("Selected path has no configured comment syntax")
|
||||
comments = leading_comments(text, style)
|
||||
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}}(?:[-–]\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.",
|
||||
))
|
||||
if suffix == ".java" and not apache:
|
||||
continue
|
||||
if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache):
|
||||
return
|
||||
raise ValueError("Missing or mismatched leading copyright/license notice")
|
||||
|
||||
|
||||
def check_repository(root: Path, policy: dict[str, Any]) -> int:
|
||||
"""Check tracked sources without scanning build outputs or executing source."""
|
||||
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):
|
||||
continue
|
||||
count += 1
|
||||
try:
|
||||
check_notice(name, (root / name).read_text(encoding="utf-8-sig"), policy)
|
||||
except (ValueError, OSError) as error:
|
||||
failures.append(f"{name}: {error}")
|
||||
if count == 0:
|
||||
raise ValueError("No owned source files selected")
|
||||
if failures:
|
||||
raise ValueError("\n".join(failures))
|
||||
return count
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Load the repository-specific reviewed scope and fail CI on a bad notice."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
policy = json.loads((root / ".github/source-headers.json").read_text(encoding="utf-8"))
|
||||
count = check_repository(root, policy)
|
||||
print(f"Leading source notices verified: {count} tracked files")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,156 @@
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Exercise notice parsing, provenance, scope and real Git inventory rejection."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
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)
|
||||
SPEC.loader.exec_module(HEADERS)
|
||||
POLICY = json.loads(Path(__file__).parents[1].joinpath("source-headers.json").read_text(encoding="utf-8"))
|
||||
MIT = "Copyright (c) 2026 James Maes\nSPDX-License-Identifier: MIT"
|
||||
APACHE = """Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
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/*"]}]}
|
||||
|
||||
|
||||
class HeaderTests(unittest.TestCase):
|
||||
"""Ensure policy failures cannot pass through text-only copyright searches."""
|
||||
|
||||
def test_each_real_comment_syntax(self) -> None:
|
||||
"""Recognize hash, slash, block and XML notices in their own languages."""
|
||||
for name, text in (
|
||||
("src/a.py", "# " + MIT.replace("\n", "\n# ") + "\nprint(1)"),
|
||||
("src/a.cs", "// " + MIT.replace("\n", "\n// ") + "\nclass A {}"),
|
||||
("src/a.mjs", "/*\n * " + MIT.replace("\n", "\n * ") + "\n */\nexport {};"),
|
||||
("src/a.xml", "<!--\n" + MIT + "\n-->\n<root/>"),
|
||||
("src/a.ps1", "<#\n" + MIT + "\n#>\nWrite-Output 1"),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
HEADERS.check_notice(name, 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.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
HEADERS.check_notice(name, "\ufeff" + prefix + body, fixture_policy())
|
||||
|
||||
def test_misordered_required_first_lines_fail(self) -> None:
|
||||
"""A notice must not displace an interpreter or XML declaration."""
|
||||
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/>"),
|
||||
):
|
||||
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# ")):
|
||||
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 + "\";"):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", text, fixture_policy())
|
||||
with self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.py", "<#" + MIT + "#>", fixture_policy())
|
||||
|
||||
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]):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy())
|
||||
|
||||
def test_complete_legacy_apache_without_spdx(self) -> None:
|
||||
"""Retain valid legacy year ranges and complete Apache notices."""
|
||||
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
|
||||
HEADERS.check_notice("src/A.java", "/*" + APACHE + "*/\nclass A {}", policy)
|
||||
HEADERS.check_notice("src/a.xml", "<!--" + APACHE + "--><root/>", policy)
|
||||
|
||||
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.", "")):
|
||||
with self.subTest(notice=notice), self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy)
|
||||
|
||||
def test_virtual_agent_provenance_is_path_scoped(self) -> None:
|
||||
"""Only the owned virtual-agent family may use its existing QRun.IO holder."""
|
||||
policy = fixture_policy()
|
||||
policy["notices"].append({"holder": "QRun.IO, Inc.", "license": "MIT", "paths": ["tools/virtual-agent/*"]})
|
||||
text = "/*Copyright 2026 QRun.IO, Inc. Licensed under the MIT License (see LICENSE).\nSPDX-License-Identifier: MIT*/"
|
||||
HEADERS.check_notice("tools/virtual-agent/a.mjs", text, policy)
|
||||
with self.assertRaises(ValueError):
|
||||
HEADERS.check_notice("src/a.mjs", text, policy)
|
||||
|
||||
def test_current_scope_and_documented_exclusions(self) -> None:
|
||||
"""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"):
|
||||
with self.subTest(name=name):
|
||||
self.assertFalse(HEADERS.selected(name, POLICY))
|
||||
self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"]))
|
||||
|
||||
def test_guard_sources_match_this_repository_provenance(self) -> None:
|
||||
"""Check copied guard files against local policy even before Git staging."""
|
||||
for filename in ("check-source-headers.py", "test-source-headers.py"):
|
||||
path = Path(__file__).with_name(filename)
|
||||
name = ".github/scripts/" + filename
|
||||
self.assertTrue(HEADERS.selected(name, POLICY))
|
||||
HEADERS.check_notice(name, path.read_text(encoding="utf-8"), POLICY)
|
||||
|
||||
def test_real_git_inventory_rejects_missing_tracked_header(self) -> None:
|
||||
"""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)
|
||||
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)
|
||||
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")
|
||||
root.joinpath("src/untracked.py").write_text("print(2)", encoding="utf-8")
|
||||
self.assertEqual(1, HEADERS.check_repository(root, fixture_policy()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"description": "Scope of the reviewed 2026-11-01 owned-source notice pass. Python fnmatch patterns match slash-containing relative paths. Native C# IDE0073 and full Java Apache conventions remain in force.",
|
||||
"includes": [
|
||||
".github/*.yml",
|
||||
".github/*.yaml",
|
||||
".github/scripts/*.py",
|
||||
".pre-commit-config.yaml",
|
||||
"Directory.Build.props",
|
||||
"Directory.Packages.props",
|
||||
"PSScriptAnalyzerSettings.psd1",
|
||||
"build/*.ps1",
|
||||
"installer/*.props",
|
||||
"installer/*.wxs",
|
||||
"installer/*.wixproj",
|
||||
"src/*.cs",
|
||||
"src/*.xaml",
|
||||
"src/*.csproj",
|
||||
"tests/*.cs",
|
||||
"tests/*.csproj",
|
||||
"tests/*.ps1",
|
||||
"tools/virtual-agent/*.mjs"
|
||||
],
|
||||
"exclusions": [
|
||||
{
|
||||
"pattern": "*.json",
|
||||
"reason": "Schema, fixtures, manifests and lock data: repository LICENSE/NOTICE, no comments."
|
||||
},
|
||||
{
|
||||
"pattern": "*.lock",
|
||||
"reason": "Dependency lock data, not owned executable source."
|
||||
},
|
||||
{
|
||||
"pattern": "*/generated/*",
|
||||
"reason": "Explicit generated-code directory."
|
||||
},
|
||||
{
|
||||
"pattern": "*/bin/*",
|
||||
"reason": "Generated build output."
|
||||
},
|
||||
{
|
||||
"pattern": "*/obj/*",
|
||||
"reason": "Generated build output."
|
||||
},
|
||||
{
|
||||
"pattern": "*/node_modules/*",
|
||||
"reason": "Third-party dependency output."
|
||||
},
|
||||
{
|
||||
"pattern": ".mvn/*",
|
||||
"reason": "Upstream Maven wrapper provenance."
|
||||
},
|
||||
{
|
||||
"pattern": "mvnw*",
|
||||
"reason": "Upstream Maven wrapper provenance."
|
||||
},
|
||||
{
|
||||
"pattern": "Assets/Legal/*",
|
||||
"reason": "Byte-exact third-party legal files."
|
||||
},
|
||||
{
|
||||
"pattern": "src/main/resources/*",
|
||||
"reason": "Bundled font/legal assets and OpenAPI schema data."
|
||||
},
|
||||
{
|
||||
"pattern": "src/test/resources/openapi/*",
|
||||
"reason": "Historical contract schema fixtures."
|
||||
},
|
||||
{
|
||||
"pattern": "*.slnx",
|
||||
"reason": "Solution metadata outside the completed source-header pass; root LICENSE/NOTICE applies."
|
||||
},
|
||||
{
|
||||
"pattern": "*.manifest",
|
||||
"reason": "Application activation metadata outside this pass; root LICENSE/NOTICE applies."
|
||||
},
|
||||
{
|
||||
"pattern": "*.runsettings",
|
||||
"reason": "Test runner metadata outside this pass; root LICENSE/NOTICE applies."
|
||||
},
|
||||
{
|
||||
"pattern": ".editorconfig",
|
||||
"reason": "Style configuration outside this pass; root LICENSE/NOTICE applies."
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
".py": "hash",
|
||||
".ps1": "powershell",
|
||||
".psd1": "powershell",
|
||||
".sh": "hash",
|
||||
".yml": "hash",
|
||||
".yaml": "hash",
|
||||
".cs": "slash",
|
||||
".java": "slash",
|
||||
".mjs": "slash",
|
||||
".js": "slash",
|
||||
".xml": "xml",
|
||||
".props": "xml",
|
||||
".csproj": "xml",
|
||||
".wxs": "xml",
|
||||
".wixproj": "xml",
|
||||
".xaml": "xml"
|
||||
},
|
||||
"notices": [
|
||||
{
|
||||
"paths": [
|
||||
".github/*",
|
||||
".pre-commit-config.yaml",
|
||||
"Directory.*.props",
|
||||
"PSScriptAnalyzerSettings.psd1",
|
||||
"build/*",
|
||||
"installer/*",
|
||||
"src/*",
|
||||
"tests/*"
|
||||
],
|
||||
"license": "MIT",
|
||||
"holder": "James Maes"
|
||||
},
|
||||
{
|
||||
"paths": [
|
||||
"tools/virtual-agent/*"
|
||||
],
|
||||
"license": "MIT",
|
||||
"holder": "QRun.IO, Inc."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -29,6 +29,10 @@ jobs:
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
- name: Verify owned source notices and rejection paths
|
||||
run: |
|
||||
python3 .github/scripts/test-source-headers.py
|
||||
python3 .github/scripts/check-source-headers.py
|
||||
- name: Verify virtual-agent protocol and HTTP boundaries
|
||||
run: node --test tools/virtual-agent/test/virtual-agent.test.mjs
|
||||
|
||||
|
||||
@@ -26,3 +26,7 @@ redistribution or end-user protection obligation.
|
||||
|
||||
The QQQ framework, qbit-printing and qqq-print-protocol are separate repositories;
|
||||
this notice does not change their licensing.
|
||||
|
||||
The About dialog bundles Query, the QQQ and QRun.IO dog mascot, supplied by
|
||||
QRun.IO. The unmodified artwork and provenance are in Assets/Query in the
|
||||
application payload and src/Qqq.PrintAgent/Assets/Query in this repository.
|
||||
@@ -11,9 +11,9 @@ Print from **QQQ 4.1+** to printers at a local facility without opening inbound
|
||||
|
||||
The native setup and status application uses the same [KofTwentyTwo AppKit](https://github.com/KofTwentyTwo/AppKit) foundation as GCLO and MarkdownViewr.Windows.
|
||||
|
||||
> **Published prerelease: [0.3.1-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.1-rc.1).** Its published assets and attestations were verified, and the exact MSI was installed with pairing, printer profiles and local history preserved. The operator confirmed successful test prints with a responsive window. The installer remains unsigned. See [acceptance notes](docs/ACCEPTANCE.md) for the scope of that evidence.
|
||||
> **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.
|
||||
|
||||
The upcoming **0.3.2-rc.1** checks the app, service and CLI for the same complete
|
||||
**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
|
||||
@@ -21,7 +21,14 @@ 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).
|
||||
|
||||
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). Configured Authentik/provider renewal and installed browser-callback acceptance remain separate qualification work.
|
||||
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 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.
|
||||
|
||||
## Current source features
|
||||
|
||||
@@ -87,7 +94,17 @@ A printer visible only in a signed-in user's profile may not be available to the
|
||||
|
||||
## Validation so far
|
||||
|
||||
The realtime contract and Jobs UI are included in the 0.3 release candidate. Focused application/UI-test builds passed with zero warnings/errors; three headless request-generation tests passed. They do not execute the desktop assertions or qualify installed realtime behavior. Full consumer, slow-transfer/control, reconnect/replay and installed acceptance are tracked separately in [session state](docs/SESSION-STATE.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).
|
||||
|
||||
### Installed published 0.2.0 — 2026-10-31
|
||||
|
||||
|
||||
+51
-7
@@ -1,6 +1,52 @@
|
||||
# Acceptance evidence
|
||||
|
||||
## Installed published 0.3.1 and next updater candidate — 2026-11-01
|
||||
## Installed 0.3.2 through the real in-app update — 2026-11-01
|
||||
|
||||
The [0.3.2 release](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.2-rc.1)
|
||||
was verified against signed tag source
|
||||
`268f951b6728b0e69fedaf46769785c3f5ae55f4`, all four assets/checksums and
|
||||
GitHub attestations. [Release run 41324312106](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41324312106)
|
||||
completed all nine jobs. Published and installed MSI SHA-256:
|
||||
`1191C337265469EB06136AF232DB57FAC6BC5014357521D80B35E5CCA5AF6E18`.
|
||||
The MSI remains unsigned; source signatures and build attestations are separate
|
||||
from publisher code signing.
|
||||
|
||||
The user operated the installed companion's **Help → Check for updates**, chose
|
||||
**Not now** and confirmed continued responsiveness, then chose **Update and
|
||||
restart**. The actual coordinator handed off to the copied installed 0.3.1 CLI
|
||||
helper, MSI returned 0, and the companion automatically reopened. The user
|
||||
confirmed its **Connected** state and both printers.
|
||||
|
||||
Independent before/after observations verified each App, Service and CLI
|
||||
executable against the published payload bytes and identical full ProductVersion
|
||||
`0.3.2-rc.1+268f951b6728b0e69fedaf46769785c3f5ae55f4`. The retained update request
|
||||
matched the original companion identity, copied helper and published MSI. The
|
||||
new service was Running with automatic LocalSystem configuration and restored
|
||||
the native QQQ session and connected WebSocket. Stable identity, pairing,
|
||||
printer exposure and profiles were preserved. All 38 prior local job rows and
|
||||
37 prior server job rows retained their recorded field digests; one additional
|
||||
row in each view was accounted for separately, with no missing or changed old
|
||||
rows. The observers submitted no jobs and did not repeat the installation.
|
||||
|
||||
The diagnostic launcher's final summary failed after its observer child exited
|
||||
successfully: reading stdout while its writer remained open caused a Windows
|
||||
file-sharing violation. The original failed launcher record was preserved.
|
||||
Both original child reports passed, and their hash chain, published-byte matches
|
||||
and preservation comparisons were independently revalidated. This acceptance
|
||||
uses those independent reports and the operator's UI confirmation; it does not
|
||||
claim that the diagnostic launcher passed.
|
||||
|
||||
The old 0.3.1 helper executed this update. The new three-component guard is
|
||||
covered by source regressions and protects future updates. Actual download or
|
||||
installer failure/cancellation, restart-required reporting and reboot recovery
|
||||
remain separate cases. Successful paper output was confirmed on 0.3.1 below;
|
||||
this update check does not claim new 0.3.2 physical-output qualification. The
|
||||
historical layout-cycle cause remains unproven. Real Authentik container checks
|
||||
use an unreleased QQQ API 1.9 candidate and an HTTPS probe callback; installed
|
||||
Windows browser/private-callback acceptance still requires a compatible
|
||||
published QQQ runtime and configured HTTPS provider. No stable 1.0 claim is made.
|
||||
|
||||
## Installed published 0.3.1 — 2026-11-01
|
||||
|
||||
The [0.3.1 release](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.1-rc.1)
|
||||
was verified against its signed tag, source
|
||||
@@ -26,12 +72,10 @@ outer window size. The original layout-cycle cause remains unproven. The exact
|
||||
fatal-error mitigation was separately qualified with a real induced native
|
||||
exception; this is not an underlying layout fix.
|
||||
|
||||
The next updater candidate rejects missing, old, unreadable and mixed full build
|
||||
versions across app, service and CLI. It passed 73 focused updater cases and the
|
||||
integrated 426-test Release gate. Direct MSI evidence does not qualify the actual
|
||||
AppKit update confirmation, helper handoff or automatic reopen. Real provider
|
||||
native-session acceptance also remains separate; the frozen QQQ RC.20 runtime
|
||||
does not advertise the required API 1.9 grant. No 1.0 sign-off is claimed here.
|
||||
This 0.3.1 checkpoint used a direct MSI installation. The subsequent real AppKit
|
||||
transition to 0.3.2 is recorded above. Its new component-coherence guard passed
|
||||
73 focused updater cases and the integrated 426-test Release gate. The frozen
|
||||
QQQ RC.20 runtime does not advertise the required API 1.9 native-session grant.
|
||||
|
||||
## Installed published 0.2.0 and confirmed output — 2026-10-31
|
||||
|
||||
|
||||
+61
-11
@@ -1,8 +1,52 @@
|
||||
# Session state — 2026-10-31
|
||||
# Session state — 2026-11-01
|
||||
|
||||
## Installed 0.3.1 and 0.3.2 qualification — 2026-11-01
|
||||
## Query in About — 2026-11-01
|
||||
|
||||
The published 0.3.1 MSI was installed with exit 0 and no reboot. App, service and
|
||||
The shared AppKit About dialog now places Query and its public links in a compact
|
||||
two-column section just after the brand header. The original header logo/text
|
||||
are reparented into an Auto/Star Grid so long customer names wrap without
|
||||
clipping; identity, version, license and credits remain supplied by AppKit.
|
||||
The PNG is bundled unmodified with provenance on a 128-pixel white card.
|
||||
|
||||
The Release gate passes 426 tests (145 Core, 281 Windows/CLI), with zero build
|
||||
warnings/errors. The isolated native host passed image decoding, rendered
|
||||
contrast, initial mascot/link/name visibility, scrolling, keyboard focus and
|
||||
Close checks in both themes with a long synthetic customer name at observed
|
||||
125% DPI; full dialog captures verify the refinement. The private publish output contains
|
||||
the exact PNG, provenance and required WinUI resources. These About changes are
|
||||
not yet released or installed; the native host uses synthetic service responses.
|
||||
|
||||
## Published and installed 0.3.2 — 2026-11-01
|
||||
|
||||
[0.3.2-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.2-rc.1)
|
||||
is published from protected main `268f951b6728b0e69fedaf46769785c3f5ae55f4`.
|
||||
[Release workflow 41324312106](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41324312106)
|
||||
completed all nine jobs. The signed tag, all four assets, checksums and GitHub
|
||||
provenance attestations were verified. Read-only MSI inspection passed for
|
||||
product version `0.3.2`, coherent app/service/CLI ProductVersion
|
||||
`0.3.2-rc.1+268f951b6728b0e69fedaf46769785c3f5ae55f4`, WinUI resources,
|
||||
service/callback registration and packaged notices. Published MSI SHA-256:
|
||||
`1191C337265469EB06136AF232DB57FAC6BC5014357521D80B35E5CCA5AF6E18`.
|
||||
It remains unsigned by the existing prerelease decision.
|
||||
|
||||
The actual AppKit 0.3.1-to-0.3.2 update completed with MSI exit 0. The user chose
|
||||
Help → Check for updates → Not now and confirmed continued responsiveness,
|
||||
then Update and restart. The companion automatically reopened, and the user
|
||||
confirmed Connected and both printers. Independent read-only observations
|
||||
verified all three installed executable hashes and full versions against the
|
||||
published MSI, the original companion/copied 0.3.1 helper handoff, an automatic
|
||||
LocalSystem service and restored WebSocket. All 38 earlier local and 37 earlier
|
||||
server job rows remained unchanged; one additional row in each view was
|
||||
accounted for separately. Native identity, pairing, exposure and profiles were
|
||||
preserved. The observers submitted no print jobs and did not retry the installer.
|
||||
|
||||
The diagnostic launcher's final summary failed after its child succeeded because
|
||||
it reopened stdout while holding its writer. The original failed record is
|
||||
preserved. Both original child reports passed and were independently revalidated,
|
||||
including their hashes and preservation comparisons; the launcher itself is
|
||||
not reported as passing. See [acceptance evidence](ACCEPTANCE.md).
|
||||
|
||||
Earlier, the published 0.3.1 MSI was installed with exit 0 and no reboot. App, service and
|
||||
CLI product versions exactly match source `346fc285198908c47a2ee9db7b9adb53d5e88c3c`.
|
||||
The service restored its native QQQ session and connected WebSocket. Pairing,
|
||||
printer exposure/profiles, log policy and all 28 existing local job receipts
|
||||
@@ -10,22 +54,28 @@ were preserved. A separate authenticated comparison proved that the original
|
||||
27 server jobs remained unchanged while new jobs arrived. The operator confirmed
|
||||
test prints and a responsive installed window.
|
||||
|
||||
The next candidate checks all three installed components and identical complete
|
||||
The published 0.3.2 checks all three installed components and identical complete
|
||||
build metadata after MSI success. Focused red/green regressions cover missing,
|
||||
old, unreadable and mixed-source builds; 73 focused cases pass. The integrated
|
||||
426-test Release gate passes with zero warnings/errors/skips. Actual in-app
|
||||
coordinator handoff remains unqualified until the next published update is used.
|
||||
426-test Release gate passes with zero warnings/errors/skips. The installed
|
||||
0.3.1 helper performed this transition; the new guard protects subsequent
|
||||
updates. Actual failed/canceled update, restart-required and reboot recovery
|
||||
paths remain unqualified. Prior paper confirmation remains specific to 0.3.1.
|
||||
|
||||
The native test host now matches production's PerMonitorV2 declaration while
|
||||
retaining asInvoker. Original 0.3.0 and current 0.3.1 UI replays passed at observed
|
||||
retaining asInvoker. Original 0.3.0 and then-current 0.3.1 UI replays passed at observed
|
||||
125% DPI, including repeated banner dismissal and printer status transitions.
|
||||
These results and operator acceptance do not prove the historical cause fixed.
|
||||
No speculative product layout change was made.
|
||||
|
||||
Local Docker development qualification is being added to the server repository.
|
||||
The frozen QQQ RC.20 runtime lacks the advertised native-session grant required
|
||||
for Windows OAuth. Real Authentik browser/session qualification needs the central
|
||||
API 1.9 implementation; the client must keep its HTTPS and typed-grant checks.
|
||||
The repository-owned local Docker reference environment passed real Authentik
|
||||
login, permission checks, logout replay denial, server renewal after expiry and
|
||||
provider revocation denial using production client session code. This isolated
|
||||
proof uses unreleased QQQ API 1.9 candidate `ecf0f550` and an HTTPS probe callback,
|
||||
with trust confined to containers. Released RC.20 lacks the required native grant.
|
||||
Installed Windows browser/private-callback acceptance still needs a compatible
|
||||
published QQQ runtime and properly configured HTTPS provider; no installed OAuth
|
||||
or long-running production qualification is claimed.
|
||||
|
||||
## Installed realtime release and companion follow-up
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Owned source notice check
|
||||
|
||||
Run these commands from the repository root with the existing Python 3 interpreter:
|
||||
|
||||
```text
|
||||
python3 .github/scripts/test-source-headers.py
|
||||
python3 .github/scripts/check-source-headers.py
|
||||
```
|
||||
|
||||
The standard-library checker enforces the scope of the reviewed 2026-11-01 header pass, not an estate-wide licensing or formatting audit. `.github/source-headers.json` names the owned source/config path families, comment syntax, accepted holder/license pairs and explicit exclusion reasons. Python `fnmatch` patterns match slash-containing relative Git paths; new files in those families are covered after they are tracked. Empty selections fail. The checker does not scan untracked build output or modify files.
|
||||
|
||||
Only leading language comments count. Copyright text in a string, Python docstring or comment after code is rejected. UTF-8 BOMs, initial shebangs, PowerShell `#Requires` directives and XML declarations are handled before the notice. These declarations cannot be displaced beneath a new notice. PowerShell block comments are recognized only for PowerShell files. Existing years and holders are not rewritten. Complete canonical Apache notices are valid without redundant SPDX; Java still requires the full Apache block. A contradictory SPDX identifier fails.
|
||||
|
||||
JSON/schema/fixture/lock data, explicit generated output directories, vendor legal assets and upstream Maven wrappers are excluded with reasons in the policy. Solution/activation/test-runner metadata and `.editorconfig` remain outside this completed pass; their applicable root LICENSE/NOTICE remains authoritative. No broad claim about third-party licensing follows from this check. New owned file types or provenance must be reviewed before changing the allowlist; do not add blanket exclusions to make a failure disappear.
|
||||
|
||||
The implementation and its negative tests are intentionally maintained copies across the three printing repositories, with each repository's own copyright/license header and independently reviewed scope. They do not fetch policy or execute source. CI invokes them in existing jobs using the runner's available Python, with no new package or workflow context. Existing native C# IDE0073 and Java Checkstyle conventions remain unchanged.
|
||||
This client accepts James Maes / MIT in its reviewed source/build/config families and preserves QRun.IO, Inc. / MIT solely in tools/virtual-agent. The existing scripts / virtual-agent job runs the guard.
|
||||
+14
-4
@@ -1,19 +1,29 @@
|
||||
# TODO
|
||||
|
||||
## Query in About
|
||||
|
||||
- [x] Place the official mascot and dogfooding/Query links directly below the
|
||||
shared About header, preserving customer logos, identity and credits.
|
||||
- [x] Verify both native themes, initial image/link visibility, unclipped names and
|
||||
Close; pass the 426-test Release gate and exact publish-payload asset check.
|
||||
- [ ] Package, publish and verify the About change in an installed follow-up.
|
||||
|
||||
## Companion layout, live transport status and startup follow-up
|
||||
|
||||
- [x] Install the attested published 0.3.1 MSI; verify all three exact component versions, automatic WebSocket restoration and preserved local receipts, pairing and profiles.
|
||||
- [x] Record operator-confirmed test prints and responsive installed 0.3.1 window; verify repeated native dialog/refresh/banner/status replays at observed 125% DPI.
|
||||
- [x] Reject a successful MSI outcome when the app, service or CLI has a missing, old or different full build version; pass the integrated 426-test gate.
|
||||
- [ ] Qualify the actual AppKit in-app update to the next published candidate, including decline, helper handoff and reopen; direct MSI installation is separate evidence.
|
||||
- [ ] Finish real-provider native-session qualification against a compatible QQQ API 1.9 runtime and the local Docker reference environment.
|
||||
- [x] Publish [0.3.2-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.3.2-rc.1); verify all nine [release jobs](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41324312106), four assets/attestations and static MSI version, resources, service/callback registration and notices. The MSI remains unsigned.
|
||||
- [x] Qualify the actual AppKit 0.3.1-to-0.3.2 update: operator-confirmed Not now/responsiveness, Update and restart, verified old-helper handoff, MSI exit 0, exact published component bytes, preserved identity/profiles/earlier jobs, restored WebSocket and automatic visible reopen.
|
||||
- [x] Pass isolated real Authentik native-session login, permission, logout, expiry/renewal and revocation checks with production client code against unreleased QQQ API 1.9 candidate `ecf0f550` in the local Docker reference environment.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
- [ ] Publish and install the verified client follow-up, then complete UI and remaining physical acceptance without resubmitting old intents.
|
||||
- [x] Preserve the original diagnostic launcher's finalization failure and independently validate both passing child reports; no installer retry or observer print was needed. Keep 0.3.1 physical acceptance separate.
|
||||
|
||||
## Realtime and Jobs UI — 0.3.0-rc.1 release candidate
|
||||
|
||||
@@ -109,7 +119,7 @@ The following entries preserve earlier implementation checkpoints.
|
||||
|
||||
## Remaining installed qualification
|
||||
|
||||
- [ ] Qualify real in-app update: consent, failed/canceled download or elevation, retained state, helper handoff, coherent versions, relaunch and restart-required reporting.
|
||||
- [ ] Qualify actual update download/installer failure and cancellation, restart-required reporting and reboot recovery; the successful 0.3.2 update above does not establish these cases.
|
||||
- [ ] Review completed protected update stages/logs manually; no automatic completed-stage retention policy is implemented.
|
||||
- [ ] Qualify remaining input-dependent selection/focus, long-value and metadata synchronization scenarios; passing UI checks do not cover every interaction.
|
||||
- [ ] Qualify installed credential recovery, queued/retry/cancel job controls, stale service state and bounded SCM recovery.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Query artwork
|
||||
|
||||
`query-mascot.png` is the official QQQ and QRun.IO dog mascot, supplied by
|
||||
QRun.IO and bundled unmodified for the About dialog.
|
||||
|
||||
- Source: [QRun-IO/marketing-website](https://github.com/QRun-IO/marketing-website),
|
||||
revision `5ff7c1564f4fe28a7ce75436cf7c4a6eeaa4bf3f`.
|
||||
- PNG: [`assets/Puppy Mark/exports/PNG/qqq-puppy@4x.png`](https://github.com/QRun-IO/marketing-website/blob/5ff7c1564f4fe28a7ce75436cf7c4a6eeaa4bf3f/assets/Puppy%20Mark/exports/PNG/qqq-puppy%404x.png).
|
||||
- Related website artwork: `public/images/qqq-puppy.svg` at the same revision.
|
||||
- Dimensions: 1326 × 1394 pixels, transparent PNG.
|
||||
- SHA-256: `385a053e15e0a9fc6f8a965257e3917db827594ab46ecf8cdddb847770a25dbb`.
|
||||
|
||||
The About dialog scales the image uniformly on a white card. It does not
|
||||
download or alter the artwork, replace customer branding, or change the
|
||||
application and tray icons. Query remains QRun.IO/QQQ branding; this provenance
|
||||
note does not grant separate trademark permissions or relicense the artwork.
|
||||
|
||||
Public pages: [dogfooding](https://www.qrun.io/dogfooding) and
|
||||
[Query](https://www.qrun.io/query).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -662,6 +662,7 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
{
|
||||
body.Children.Insert(0, new Image { Source = CustomerLogo.Source, Width = 128, Height = 64, Stretch = Stretch.Uniform, HorizontalAlignment = HorizontalAlignment.Left });
|
||||
}
|
||||
QueryAboutSection.InsertAfterHeader(body);
|
||||
}
|
||||
await DialogGuard.ShowAsync(dialog);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<Content Include="Assets\Brand\app-icon.svg" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="Assets\Brand\app.ico" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="Assets\Brand\tray-*.png" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="Assets\Query\query-mascot.png;Assets\Query\README.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<Manifest Include="$(ApplicationManifest)" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Text;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Automation;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
|
||||
namespace Qqq.PrintAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Adds QQQ's mascot and public project links without replacing AppKit's identity or credits.
|
||||
/// </summary>
|
||||
internal static class QueryAboutSection
|
||||
{
|
||||
/// <summary>Places Query after the brand header while retaining any preceding customer logo and all existing sections.</summary>
|
||||
internal static Grid InsertAfterHeader(StackPanel body)
|
||||
{
|
||||
Border header = body.Children.OfType<Border>().First();
|
||||
ConstrainHeader(header);
|
||||
Grid section = Create();
|
||||
body.Children.Insert(body.Children.IndexOf(header) + 1, section);
|
||||
return section;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Gives the original name a finite width without replacing AppKit's logo, text, fonts or gradient.</summary>
|
||||
private static void ConstrainHeader(Border header)
|
||||
{
|
||||
if(header.Child is not StackPanel { Children.Count: > 0 } content
|
||||
|| content.Children[0] is not StackPanel { Orientation: Orientation.Horizontal, Children.Count: 2 } title
|
||||
|| title.Children[0] is not Image logo || title.Children[1] is not TextBlock name)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var row = new Grid { ColumnSpacing = title.Spacing };
|
||||
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
title.Children.Clear();
|
||||
name.TextWrapping = TextWrapping.Wrap;
|
||||
Grid.SetColumn(name, 1);
|
||||
row.Children.Add(logo);
|
||||
row.Children.Add(name);
|
||||
content.Children[0] = row;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Keeps the mascot and wrapped links together in a compact native layout.</summary>
|
||||
private static Grid Create()
|
||||
{
|
||||
var text = new StackPanel { Spacing = 4, VerticalAlignment = VerticalAlignment.Center };
|
||||
text.Children.Add(new TextBlock { Text = "Query", FontSize = 20, FontWeight = FontWeights.SemiBold });
|
||||
text.Children.Add(new TextBlock { Text = "QQQ and QRun.IO’s dog mascot.", TextWrapping = TextWrapping.Wrap });
|
||||
text.Children.Add(Link("Dogfooding at QRun.IO", "https://www.qrun.io/dogfooding", "AboutDogfoodingLink"));
|
||||
text.Children.Add(Link("Meet Query", "https://www.qrun.io/query", "AboutQueryLink"));
|
||||
var section = new Grid { ColumnSpacing = 12 };
|
||||
section.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
section.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
Grid.SetColumn(text, 1);
|
||||
section.Children.Add(CreateCard());
|
||||
section.Children.Add(text);
|
||||
return section;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Keeps the original transparent black artwork visible in either application theme.</summary>
|
||||
private static Border CreateCard()
|
||||
{
|
||||
var image = new Image
|
||||
{
|
||||
Source = new BitmapImage(new Uri("ms-appx:///Assets/Query/query-mascot.png")),
|
||||
Stretch = Stretch.Uniform,
|
||||
};
|
||||
AutomationProperties.SetName(image, "Query, the QQQ and QRun.IO dog mascot");
|
||||
AutomationProperties.SetAutomationId(image, "AboutQueryMascot");
|
||||
|
||||
return new Border
|
||||
{
|
||||
Width = 128,
|
||||
Height = 128,
|
||||
Padding = new Thickness(8),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Background = new SolidColorBrush(Colors.White),
|
||||
Child = image,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Uses native hyperlink navigation so links remain keyboard and screen-reader accessible.</summary>
|
||||
private static HyperlinkButton Link(string label, string address, string automationId)
|
||||
{
|
||||
var link = new HyperlinkButton
|
||||
{
|
||||
Content = new TextBlock { Text = label, TextWrapping = TextWrapping.Wrap },
|
||||
NavigateUri = new Uri(address),
|
||||
Padding = new Thickness(0),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
HorizontalContentAlignment = HorizontalAlignment.Left,
|
||||
};
|
||||
AutomationProperties.SetAutomationId(link, automationId);
|
||||
return link;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ internal static class LayoutScenario
|
||||
await Task.Delay(250);
|
||||
root.UpdateLayout();
|
||||
await VerifyFooterAsync(root, service);
|
||||
await QueryAboutScenario.RunAsync(root);
|
||||
var feedback = (InfoBar)root.FindName("FeedbackBar");
|
||||
if(!feedback.IsOpen || !string.Equals(feedback.Title, "Request completed", StringComparison.Ordinal))
|
||||
{
|
||||
@@ -63,6 +64,7 @@ internal static class LayoutScenario
|
||||
service.TransportReads,
|
||||
trafficRateDecayVerified = true,
|
||||
serverLinkOpensSettingsOnly = true,
|
||||
queryAboutNativeRenderingVerified = true,
|
||||
heldForTimerRefresh = Program.RefreshDuringDialog,
|
||||
width = root.XamlRoot.Size.Width,
|
||||
height = root.XamlRoot.Size.Height,
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<ApplicationDefinition Include="../../src/Qqq.PrintAgent/App.xaml" Link="App.xaml" />
|
||||
<Page Include="../../src/Qqq.PrintAgent/MainWindow.xaml" Link="MainWindow.xaml" />
|
||||
<Content Include="../../src/Qqq.PrintAgent/Assets/Brand/*" Link="Assets/Brand/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="../../src/Qqq.PrintAgent/Assets/Query/*" Link="Assets/Query/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Using Include="Qqq.PrintAgent.Core" />
|
||||
<ProjectReference Include="../../src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj" />
|
||||
<ProjectReference Include="../../src/Qqq.PrintAgent.Windows/Qqq.PrintAgent.Windows.csproj" />
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using KofTwentyTwo.AppKit;
|
||||
using KofTwentyTwo.AppKit.WinUI;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Automation;
|
||||
using Microsoft.UI.Xaml.Automation.Peers;
|
||||
using Microsoft.UI.Xaml.Automation.Provider;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Windows.Foundation;
|
||||
using Windows.Graphics.Imaging;
|
||||
using Windows.Storage;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Qqq.PrintAgent;
|
||||
|
||||
/// <summary>Exercises the bundled image and scrollable shared dialog on a real, isolated WinUI surface.</summary>
|
||||
internal static class QueryAboutScenario
|
||||
{
|
||||
/// <summary>Checks both themes with long customer identity text without opening a browser or service connection.</summary>
|
||||
internal static async Task RunAsync(FrameworkElement root)
|
||||
{
|
||||
AppInfo identity = AppIdentity.DefaultInfo with
|
||||
{
|
||||
DisplayName = "North Distribution Centre — Customer Warehouse Printing Operations",
|
||||
Description = "A long customer description remains in the shared About dialog together with the version, license and existing component credits.",
|
||||
};
|
||||
foreach(ElementTheme theme in new[] { ElementTheme.Light, ElementTheme.Dark })
|
||||
{
|
||||
var dialog = new AboutDialog(identity) { XamlRoot = root.XamlRoot, RequestedTheme = theme };
|
||||
var scroll = (ScrollViewer)dialog.Content;
|
||||
var body = (StackPanel)scroll.Content;
|
||||
UIElement[] originalContent = body.Children.ToArray();
|
||||
Grid section = QueryAboutSection.InsertAfterHeader(body);
|
||||
Border card = section.Children.OfType<Border>().Single();
|
||||
var image = (Image)card.Child;
|
||||
bool imageOpened = false;
|
||||
bool imageFailed = false;
|
||||
image.ImageOpened += (_, _) => imageOpened = true;
|
||||
image.ImageFailed += (_, _) => imageFailed = true;
|
||||
Task<ContentDialogResult?> showing = DialogGuard.ShowAsync(dialog);
|
||||
try
|
||||
{
|
||||
await WaitAsync(() => imageOpened || imageFailed);
|
||||
VerifyImage(image, imageFailed);
|
||||
if(!body.Children.Where(element => !ReferenceEquals(element, section)).SequenceEqual(originalContent))
|
||||
{
|
||||
throw new InvalidOperationException("The existing About content changed.");
|
||||
}
|
||||
await VerifyOpeningAsync(dialog, scroll, section, identity.DisplayName, theme);
|
||||
HyperlinkButton query = Descendants(section).OfType<HyperlinkButton>().Last();
|
||||
Rect bounds = query.TransformToVisual(scroll).TransformBounds(new Rect(0, 0, query.ActualWidth, query.ActualHeight));
|
||||
if(bounds.Top < -1 || bounds.Bottom > scroll.ViewportHeight + 1
|
||||
|| bounds.Left < -1 || bounds.Right > scroll.ViewportWidth + 1 || !query.Focus(FocusState.Keyboard)
|
||||
|| !string.Equals(query.NavigateUri.AbsoluteUri, "https://www.qrun.io/query", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The Query link was not keyboard reachable within the native scroll viewport.");
|
||||
}
|
||||
HyperlinkButton dogfooding = Descendants(section).OfType<HyperlinkButton>().First();
|
||||
if(!string.Equals(dogfooding.NavigateUri.AbsoluteUri, "https://www.qrun.io/dogfooding", StringComparison.Ordinal)
|
||||
|| string.IsNullOrWhiteSpace(AutomationProperties.GetName(image)))
|
||||
{
|
||||
throw new InvalidOperationException("The public links or mascot accessibility description were unavailable.");
|
||||
}
|
||||
await CaptureAsync(card, $"about-query-{theme}.png", requireContrast: true);
|
||||
scroll.ChangeView(null, scroll.ScrollableHeight, null, disableAnimation: true);
|
||||
await WaitAsync(() => scroll.VerticalOffset > 0 && Math.Abs(scroll.VerticalOffset - scroll.ScrollableHeight) < 1);
|
||||
if(!VisibleIn((FrameworkElement)body.Children.Last(), scroll))
|
||||
{
|
||||
throw new InvalidOperationException("The original AppKit credits were not available after scrolling.");
|
||||
}
|
||||
CloseThroughButton(dialog);
|
||||
await showing;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dialog.Hide();
|
||||
await showing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Requires a decoded bundled asset rather than a correctly named but missing resource.</summary>
|
||||
private static void VerifyImage(Image image, bool failed)
|
||||
{
|
||||
if(failed || image.ActualWidth <= 0 || image.ActualHeight <= 0
|
||||
|| image.Source is not BitmapImage { PixelWidth: 1326, PixelHeight: 1394 })
|
||||
{
|
||||
throw new InvalidOperationException("The native resource loader did not decode the official mascot dimensions.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Captures the initial native layout before scrolling can hide a clipped name or an offscreen link.</summary>
|
||||
private static async Task VerifyOpeningAsync(ContentDialog dialog, ScrollViewer scroll, FrameworkElement section, string displayName, ElementTheme theme)
|
||||
{
|
||||
dialog.UpdateLayout();
|
||||
TextBlock name = Descendants(dialog).OfType<TextBlock>().Single(text => string.Equals(text.Text, displayName, StringComparison.Ordinal));
|
||||
FrameworkElement[] required = Descendants(section).OfType<FrameworkElement>()
|
||||
.Where(element => element is Image or HyperlinkButton).ToArray();
|
||||
bool queryVisible = required.Length == 3 && required.All(element => VisibleIn(element, scroll));
|
||||
bool nameVisible = VisibleIn(name, scroll) && !name.IsTextTrimmed && name.DesiredSize.Width <= name.ActualWidth + 1;
|
||||
File.WriteAllText(Path.Combine(Program.EvidenceDirectory, $"about-initial-{theme}.json"), JsonSerializer.Serialize(new
|
||||
{
|
||||
queryVisible,
|
||||
nameVisible,
|
||||
nameWidth = name.ActualWidth,
|
||||
nameHeight = name.ActualHeight,
|
||||
scroll.ViewportWidth,
|
||||
scroll.ViewportHeight,
|
||||
scroll.VerticalOffset,
|
||||
}));
|
||||
await CaptureAsync(dialog, $"about-dialog-{theme}.png", requireContrast: false);
|
||||
if(!queryVisible || !nameVisible || scroll.VerticalOffset != 0)
|
||||
{
|
||||
throw new InvalidOperationException("The initial About viewport clipped its customer name, mascot or public links.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Checks the actual arranged rectangle, rather than trusting wrapping or visibility properties.</summary>
|
||||
private static bool VisibleIn(FrameworkElement element, ScrollViewer scroll)
|
||||
{
|
||||
Rect bounds = element.TransformToVisual(scroll).TransformBounds(new Rect(0, 0, element.ActualWidth, element.ActualHeight));
|
||||
return bounds.Top >= -1 && bounds.Left >= -1 && bounds.Bottom <= scroll.ViewportHeight + 1
|
||||
&& bounds.Right <= scroll.ViewportWidth + 1 && bounds.Width > 0 && bounds.Height > 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Checks optional card contrast and retains only synthetic native-dialog pixels for visual review.</summary>
|
||||
private static async Task CaptureAsync(FrameworkElement target, string fileName, bool requireContrast)
|
||||
{
|
||||
var rendering = new RenderTargetBitmap();
|
||||
await rendering.RenderAsync(target);
|
||||
IBuffer buffer = await rendering.GetPixelsAsync();
|
||||
byte[] pixels = new byte[checked((int)buffer.Length)];
|
||||
using(var reader = DataReader.FromBuffer(buffer))
|
||||
{
|
||||
reader.ReadBytes(pixels);
|
||||
}
|
||||
int white = 0;
|
||||
int black = 0;
|
||||
for(int index = 0; index < pixels.Length; index += 4)
|
||||
{
|
||||
if(pixels[index + 3] == byte.MaxValue)
|
||||
{
|
||||
if(pixels[index] > 245 && pixels[index + 1] > 245 && pixels[index + 2] > 245)
|
||||
{
|
||||
white++;
|
||||
}
|
||||
if(pixels[index] < 32 && pixels[index + 1] < 32 && pixels[index + 2] < 32)
|
||||
{
|
||||
black++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(requireContrast && (white < pixels.Length / 8 || black < 20))
|
||||
{
|
||||
throw new InvalidOperationException("The rendered Query card did not retain visible black artwork on white.");
|
||||
}
|
||||
StorageFolder folder = await StorageFolder.GetFolderFromPathAsync(Program.EvidenceDirectory);
|
||||
StorageFile file = await folder.CreateFileAsync(fileName, CreationCollisionOption.FailIfExists);
|
||||
using IRandomAccessStream output = await file.OpenAsync(FileAccessMode.ReadWrite);
|
||||
BitmapEncoder encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, output);
|
||||
encoder.SetPixelData(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied,
|
||||
checked((uint)rendering.PixelWidth), checked((uint)rendering.PixelHeight), 96, 96, pixels);
|
||||
await encoder.FlushAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Uses the framework's actual close action after scrolling instead of assuming its template stays usable.</summary>
|
||||
private static void CloseThroughButton(ContentDialog dialog)
|
||||
{
|
||||
Button close = Descendants(dialog).OfType<Button>().Single(button => string.Equals(button.Name, "CloseButton", StringComparison.Ordinal));
|
||||
AutomationPeer peer = FrameworkElementAutomationPeer.CreatePeerForElement(close)
|
||||
?? throw new InvalidOperationException("The shared About close button has no native automation peer.");
|
||||
if(!close.Focus(FocusState.Keyboard) || peer.GetPattern(PatternInterface.Invoke) is not IInvokeProvider invoke)
|
||||
{
|
||||
throw new InvalidOperationException("The shared About close button was not keyboard accessible.");
|
||||
}
|
||||
invoke.Invoke();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Finds realized native controls without external input or desktop automation.</summary>
|
||||
private static IEnumerable<DependencyObject> Descendants(DependencyObject root)
|
||||
{
|
||||
for(int index = 0; index < VisualTreeHelper.GetChildrenCount(root); index++)
|
||||
{
|
||||
DependencyObject child = VisualTreeHelper.GetChild(root, index);
|
||||
yield return child;
|
||||
foreach(DependencyObject descendant in Descendants(child))
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Fails the isolated host promptly when a native image or scrolling event never completes.</summary>
|
||||
private static async Task WaitAsync(Func<bool> condition)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while(!condition())
|
||||
{
|
||||
await Task.Delay(25, timeout.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,12 @@ No real printing, connection enrollment, settings commits, service restart, star
|
||||
|
||||
The footer check uses the real `ApplicationTrafficMeter` with synthetic byte increments, verifies positive rates followed by zero after an idle window while totals remain unchanged, and invokes the actual server-link peer. It requires the existing connection dialog's native Opened/Closed events and a null NavigateUri; cleanup uses ContentDialog.Hide. It does not sign in or navigate to an external website.
|
||||
|
||||
The About check renders the shared AppKit dialog with a long synthetic customer
|
||||
name and the production Query section in both light and dark themes. It requires
|
||||
the bundled PNG to decode and verifies rendered black artwork on white. The
|
||||
mascot, both links and full customer name must fit the opening viewport before
|
||||
scrolling; the original credits and Close action remain reachable afterward.
|
||||
It retains full synthetic dialog and card captures for review, without
|
||||
activating the website links.
|
||||
|
||||
This is a manual native desktop gate. It has no external input or printing dependency, but it is not a headless unit test and has not been qualified on a GitHub-hosted runner. It requires PowerShell 7, the repository's .NET 10 build prerequisites and a working WinUI desktop. The native Windows App SDK components are copied by the build; the executable still uses the installed .NET runtime. No mandatory CI step is added until that runner environment is independently qualified.
|
||||
@@ -61,6 +61,7 @@ try {
|
||||
$result = Get-Content -LiteralPath (Join-Path $evidence 'result.json') -Raw | ConvertFrom-Json
|
||||
if(-not $result.passed -or $result.Submissions -ne 1 -or $result.DialogSnapshots -lt 1 -or
|
||||
-not $result.trafficRateDecayVerified -or -not $result.serverLinkOpensSettingsOnly -or
|
||||
-not $result.queryAboutNativeRenderingVerified -or
|
||||
(Test-Path -LiteralPath (Join-Path $evidence 'unhandled.txt'))) {
|
||||
throw 'The native host did not provide complete passing evidence.'
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user