Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a475d376
|
||
|
|
6f2c17b9a6
|
||
|
|
17698dd357
|
||
|
|
c3bf23d0bc
|
||
|
|
b86a971725
|
||
|
|
4c9b3686d3
|
||
|
|
02f34c3214
|
||
|
|
2e08344d5f
|
||
|
|
4e01519556
|
||
|
|
db45420b14
|
||
|
|
ebea8613eb
|
||
|
|
65a99d3049
|
||
|
|
7b99a29d77
|
||
|
|
fe95a9fcae
|
||
|
|
f481c43eba
|
||
|
|
6ad3001d1e
|
||
|
|
db6ee016da
|
||
|
|
e2ca086711
|
||
|
|
077148ff41
|
||
|
|
b5dfb2c1a6
|
||
|
|
6f0237dde8
|
||
|
|
c3efed46cc
|
||
|
|
06ab0a88ba
|
||
|
|
b868e9e792
|
||
|
|
7610dec07e
|
||
|
|
2375195937
|
||
|
|
1dfad0ba1d
|
||
|
|
783a64297b
|
||
|
|
6abe865da4
|
||
|
|
f78943e5cb
|
||
|
|
43f7de0b2b
|
||
|
|
ce585706d8
|
||
|
|
6fed9909be
|
||
|
|
17797cd89a
|
||
|
|
1431d8c328
|
||
|
|
a695996091
|
||
|
|
5d4b7f43ce
|
No files matched your search
@@ -3,9 +3,9 @@
|
||||
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` (the version comes from `VersionPrefix` in `Directory.Build.props`; add `-ReleaseVersion X.Y.Z-rc.N` for a test build).
|
||||
- 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.
|
||||
- Zero compiler/analyzer warnings; locked dependencies. Line coverage is gated per assembly by `build/Verify.ps1` (docs/adr/0003-version-2.md); raise a minimum in `build/minimum-coverage.json` with the tests that earn it and never lower one.
|
||||
- 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.
|
||||
- Never log or publish credentials, enrollment codes, print documents or local journals. Update docs/security/threat-model.md for boundary changes.
|
||||
- Do not physically print without the user's authorization. Describe spool acceptance separately from confirmed paper output.
|
||||
|
||||
+766
@@ -1,5 +1,771 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
A Start menu shortcut that another program holds no longer fails an uninstall, an install or an upgrade (2.0). The
|
||||
installer creates, renames and removes the shortcut under **Start → Print Connectors** in a required step, and
|
||||
Windows Installer undoes the whole install, upgrade or uninstall when that step fails. Until now it failed whenever
|
||||
the shortcut could not be opened or deleted because a virus scanner, a backup or the Start menu had the file open
|
||||
for a moment. 1.2.1 failed in the same places, more bluntly: it took a shortcut it could not open for someone
|
||||
else's. **No real install, upgrade or uninstall has exercised this change**: it is shown by tests that hold real
|
||||
files in temporary folders, and the cases are on the list for the upgrade qualification
|
||||
([TODO](docs/TODO.md), [audit](docs/AUDIT-2026-11-10.md) W50, W120 to W122).
|
||||
|
||||
- **A file in use is tried again.** A shortcut file that Windows reports as in use (a sharing violation or a lock
|
||||
violation) is tried nine times over two seconds before anything below applies. A file that is refused for
|
||||
another reason is not tried again.
|
||||
- **Uninstall.** No shortcut file fails an uninstall any more.
|
||||
- A recorded shortcut that is still held after the two seconds, or that cannot be opened or deleted for another
|
||||
reason, is left where it is. A file that cannot be opened is not judged, so it is never deleted.
|
||||
- A temporary file a failed earlier run left behind (`.qqq-` and 32 hexadecimal digits, `.lnk`) is removed also
|
||||
when it is marked read-only; one that is held is left where it is.
|
||||
- A **Print Connectors** folder that can no longer be trusted (an ordinary user may write to it, or a link
|
||||
leads to it) is not worked in at all: nothing in it is opened or deleted, and the uninstall goes on. It used
|
||||
to fail the uninstall, which protected nothing more and kept the product installed.
|
||||
- What is left behind is a shortcut to a program that is no longer installed. The registry no longer names it:
|
||||
delete it by hand, or install again under the same display name, which takes it over. An install under
|
||||
another display name does not find it.
|
||||
- An uninstall still fails, before it has deleted anything, for three reasons that are not about one shortcut
|
||||
file: the step does not run as an elevated administrator, the registry record cannot be changed, or the
|
||||
folder cannot be listed.
|
||||
- **Install, upgrade and repair.**
|
||||
- When the product's own shortcut under the display name being applied is still held after the two seconds, the
|
||||
install goes on and leaves that shortcut exactly as it is. It starts the same companion at the same path, so
|
||||
it keeps working; a changed icon appears with the next install, upgrade or repair, which replaces it. A
|
||||
previous display name's shortcut is not removed in that run either.
|
||||
- A previous display name's shortcut that cannot be opened no longer stops a rename: the new shortcut is created,
|
||||
the old one stays for the moment, and the next install, upgrade, repair or uninstall removes it.
|
||||
- An install still fails, before it has changed anything, when a file that is not this product's shortcut has
|
||||
the name the shortcut needs. That now includes a file under that name that the registry does not record and
|
||||
that cannot be opened: it cannot be judged, so it is not overwritten. The failure says so; close the program
|
||||
that holds the file and run the installer again.
|
||||
- **Where the reason is.** Windows Installer shows only its general message when the step fails. The reason is in
|
||||
the service's activity log, `%ProgramData%\QqqPrintAgent\logs\qqq-print-agent-<date>.log`, in a line that starts
|
||||
with `[Installer]`: an error when the step failed, a warning for each file that was left where it is. The
|
||||
warnings, and the failure over a file that cannot be judged, name the shortcut's file name and the kind of the
|
||||
failure (in use, access denied, or Windows' result code). They no longer repeat Windows' own message, which
|
||||
carries a full path.
|
||||
- This replaces what the entry "Branded Start menu shortcut" below says about a shortcut that cannot be opened or
|
||||
deleted; that entry is corrected where it said otherwise.
|
||||
|
||||
The server tree, the footer and the tray for several servers exist as models (2.0). The window does not use them
|
||||
yet, so nothing an operator sees changes: it still shows the one connection of 1.x. The window is rebuilt on these
|
||||
models, and on the connection wizard, in a later change.
|
||||
|
||||
- **The tree.** One node for every server, in the service's order, each with Printers, Jobs and Connection; then
|
||||
Add server, Computer, Logs and Settings. A server keeps its node from one snapshot to the next, so what is
|
||||
selected and what is expanded stay put. When the server on screen is removed, its neighbour is shown and a
|
||||
message says which server went. That is said only when a server was removed: when the servers are not listed
|
||||
because the service's stored data is not ready, the Computer page says why and nothing says that one was
|
||||
removed. A server that is being removed stays in the tree, says so and offers no command. Add server is
|
||||
disabled, with the reason, at eight servers and while the service is not available; signing in again and
|
||||
pairing a server that exists are not held to that limit.
|
||||
- **Health in words.** Every server shows its health as a glyph and words as well as a colour. A health this
|
||||
version does not know is shown as unknown, never as healthy, and so is every server while the local service is
|
||||
not answering.
|
||||
- **The footer.** The worst health across the servers, how many servers are in each state, and a link to the first
|
||||
one that needs attention. With one server it is that server's own sentence. For a paired server in the states
|
||||
1.x had, that is the sentence the 1.x footer shows; before the first answer, while the local service is not
|
||||
answering and in states 1.x did not have it reads differently, and ADR-0005 lists how. A paired server nobody
|
||||
is signed in to says "Sign in required" beside its name. While printing is paused for an update the footer says
|
||||
so and is not green. With no server it is red and says to add one. It is announced to a screen reader once per
|
||||
change.
|
||||
- **The tray.** The tooltip stays within the 127 characters Windows shows: the count in each state, then as many
|
||||
servers as fit, the ones that need attention first, and how many were left out. When no server fits it is the
|
||||
counts alone. It says what the footer says, where the 1.x tooltip carried the health's own sentence. The menu
|
||||
lists every server.
|
||||
- **The local service.** Not reached, an older or a newer service, an upgrade from 1.x that did not finish (with
|
||||
the file it stopped at), data written by a newer build (with both versions) and an update's pause each have
|
||||
their own sentence and guidance, on the Computer page. The wizard is shown only when the service answered and
|
||||
its stored data is ready.
|
||||
- **One command at a time per server.** A second command on a server that is busy is refused with a message that
|
||||
says what is still running; a command on another server is not affected, and refreshing never stops. A
|
||||
snapshot that is older than a change made from the window is not shown for the server that changed. Whether
|
||||
it is older is decided by when its request was sent, which the client's result now says, and not by when the
|
||||
window asked: the wizard has snapshots of its own in flight, and the window can be handed one of them.
|
||||
- **Messages.** A message is shown on the page that caused it. One that has not been on screen is never replaced:
|
||||
the results of commands that ended while the operator was elsewhere wait under their page, in order, and are
|
||||
shown one after the other when the page is opened. When the operator leaves a page, only the message that was
|
||||
on screen is dropped. When the window itself changes the page (it opens where 1.x opened, a wizard ends, the
|
||||
server on screen is removed), an error that was on screen is kept and shown when the operator next opens that
|
||||
page. A failure about a server that has since been removed is shown on the page that took its place, and says
|
||||
which server it was about.
|
||||
|
||||
Every control an operator acts on in the companion's own views now has an automation identifier, and the
|
||||
identifiers are recorded with the control each is on (2.0). Nothing an operator sees or hears changes. Fourteen
|
||||
controls that had no identifier were given one: the navigation pane (`Navigation`) and the Settings page's support
|
||||
link (`SupportLink`), which get the names Windows already reported for them; the empty Printers page's "Connection
|
||||
settings" button (`PrintersConnectionSettings`); on the Settings page "Windows Services" (`WindowsServices`), "Get
|
||||
the installer" (`GetInstaller`) and "Exit companion" (`ExitCompanion`); a printer row's share checkbox
|
||||
(`SharePrinter`) and its "Windows diagnostics" expander (`PrinterDiagnostics`); a job row's event history expander
|
||||
(`JobEventHistory`); the About dialog's "Contact support" link (`AboutSupportLink`); and the four controls of the
|
||||
Log settings dialog (`LogRetentionDays`, `LogMaxFileMegabytes`, `LogSegmentsPerDay`, `LogDebugLogging`). The 107
|
||||
identifiers that existed keep their spelling and their controls. What is covered is every element of the
|
||||
companion's markup and every control its code creates. Four things are not, and have no identifier of the
|
||||
companion's: the choices inside a combo box (one of the fifteen in the markup has an identifier), which are found
|
||||
through their box; the buttons of a dialog, which are the platform's and carry its names; the views of AppKit (the
|
||||
About dialog's frame, the splash, the log viewer), which are AppKit's to identify: it sets two identifiers,
|
||||
`AboutVersionText` on the About dialog's version line and `AppKitSplash` on the splash, and none in the log viewer,
|
||||
whose toolbar the UI tests find by name (one button in that toolbar is the companion's and has an identifier: the
|
||||
companion inserts "Clear logs" there, `ClearLogsFromViewer`); and the tray icon's menu, which Windows draws as a
|
||||
native menu.
|
||||
|
||||
A new test in the presentation suite, `XamlContractTests`, reads the companion's markup and code-behind as text. It
|
||||
fails when a control an operator acts on has no identifier, in markup or created in code (a button, a list, a menu
|
||||
item and the like, or any other element that is given a handler for a press or a key); when a kind of element or a
|
||||
type created in code is one the test has no list for; when the identifiers, or the control any of them is on, differ
|
||||
from `tests/Qqq.PrintAgent.Presentation.Tests/Views/automation-ids.txt`, so that a renamed, removed or moved
|
||||
identifier is a changed line in a reviewed file; when an identifier is not written out, other than the positional
|
||||
forms ADR-0005 gives the tree and the rows (`Server.<n>`, `Printer.<n>`, `Job.<n>`), which nothing produces yet;
|
||||
when an identifier would reach an automation client without being written on its control (a style that sets one, or
|
||||
a name that code gives an element from a value, which Windows reports as the identifier);
|
||||
when a view stops setting an identifier or an element name that the UI tests or the layout harness look up
|
||||
(`interactive-test-ids.txt` beside it records the 68 identifiers they use), or a suite looks one up in a form the
|
||||
test cannot read; when a named text is not listed as a status text, as changing without being announced, or as never
|
||||
changing, or a status text loses its live-region declaration; or when the window stops raising the one announcement
|
||||
it makes, for the footer's status line. It does not see a control that comes from another assembly's factory, a text
|
||||
that code creates (a row's cells), or what a screen reader actually says; those are for the interactive run. It is
|
||||
the baseline for the tree and the wizard of [ADR-0005](docs/adr/0005-connection-wizard-and-server-tree.md), which
|
||||
rewrite these views.
|
||||
|
||||
No live region was added. The footer's status line, the main window's message bar and the two update texts already
|
||||
declare one. The connection dialog's message bar is left as it is and goes with the dialog. Screen readers therefore
|
||||
announce what they announced before, which leaves three results unannounced: the Jobs page's summary line, the text
|
||||
under the sign-in startup switch, and a new result in the connection dialog once its bar has been opened (audit
|
||||
U28, to be decided where the views are rewritten).
|
||||
|
||||
The connection wizard's frame and its Server step exist in the presentation assembly (2.0;
|
||||
[ADR-0005](docs/adr/0005-connection-wizard-and-server-tree.md)). The window does not use them yet: it still shows
|
||||
the connection dialog, so nothing an operator sees changes. The steps after the first (sign in, pair, printers,
|
||||
done) are not built; the frame knows them and shows nothing for them.
|
||||
|
||||
- **The frame.** The five steps with the step indicator, the primary action and Cancel (Enter and Escape), Back
|
||||
where the step before stored nothing, where the focus belongs, and how a run ends: cancelled, on the Computer
|
||||
page, or on a server. The wizard is not started without an answer from the service, while the stored data is not
|
||||
ready, or at eight servers, counting one that is being removed, and it says which.
|
||||
- **The Server step.** The address is read by the contract's address rule, and the name follows the contract's
|
||||
proposal for the address until one is typed; a name that breaks the name rule disables Next and says which part
|
||||
of the rule it breaks. A check is one request to the service. Its result shows the five stages in order, each
|
||||
passed, failed with its reasons, or not reached; the sign-in methods; or that the address is already a server,
|
||||
with a way to open it. A stage shows at most five reasons and then says how many more there are, and a stage
|
||||
the service reported twice shows the worse outcome. A check that was not answered says so and can be repeated;
|
||||
a second check while one runs is refused out loud; Cancel during a check stops waiting. Editing the address
|
||||
removes the result, and a check is only ever sent for the address that is in the box. A check that passes
|
||||
while the typed name breaks the name rule says what is wrong with the name and does not point at Next. Back
|
||||
from the next step says the result again.
|
||||
- **A refused certificate.** The certificate is shown with the address that presented it, its subject, issuer,
|
||||
validity, fingerprint and why Windows refused it. For each reason the step says what can be done: installing
|
||||
the issuing authority's root certificate is named only for an authority Windows does not trust; for a
|
||||
certificate issued for another name, for no certificate and for a reason this version does not know, the
|
||||
operator is sent to the address or to the server's administrator. The development-only exception is offered
|
||||
only when the reasons include an authority Windows does not trust. Its warning names the server's address and
|
||||
says that the sign-in provider's address follows after the check, or names both when the provider is already
|
||||
known; the re-check shows every address the exception covers before Next can be used. An acceptance is for
|
||||
one check: a check under it that does not pass, an edit of the address and declining all forget it, and
|
||||
nothing a warning said is remembered for a later check. A sign-in provider is shown, and can be accepted, only
|
||||
when the contract takes it for an origin: the wizard reads it with the contract's own rule
|
||||
(`SignInProviderOrigin.TryRead`, now public), so an address no request could state, such as an IPv6 literal
|
||||
with a zone, is never shown with part of it dropped. Nothing is stored by this step.
|
||||
- **Every refusal has a reaction.** One table says what the wizard does with each code of the contract for a
|
||||
check, and a test walks the contract's own list, so a code added later fails the build until the wizard has a
|
||||
reaction to it.
|
||||
- **Said once.** A result is announced once, after the stages and the buttons are in place. `Announcement` (a
|
||||
text and one event per thing said, which carries its sentence) and `OperationGate` (one command at a time per
|
||||
server, for the computer and for the wizard; a second command is refused with a sentence, never dropped or
|
||||
queued) were added for this and for the server tree.
|
||||
- **Text from the service.** A stage's reasons and a certificate's subject, issuer and fingerprint are shown in
|
||||
the contract's printable form, which the contract now lets a client apply (`ManagementText.Bounded`). The
|
||||
wizard holds no password, enrollment code or credential, and its log entries name the server only by the origin
|
||||
the address rule returned.
|
||||
|
||||
The companion has a client for version 2 of the management pipe (2.0). The window does not use it yet: it still
|
||||
talks to the service through the version 1 client, and no service answers version 2 yet, so nothing an operator
|
||||
sees changes. The connection wizard and the server tree will be built on it.
|
||||
|
||||
- **One method for each operation.** The client sends the 26 operations the companion sends, each through one
|
||||
method that takes plain values and returns the contract's own result record; nothing above it spells an
|
||||
operation's name or reads a reply. The other four are not the companion's to send: `servers.list` and
|
||||
`computer.getProfile`, because the snapshot carries both, and `update.prepare` and `update.resume`, which the
|
||||
update service sends itself. What each method sends is held to the contract's golden files. A method takes the
|
||||
contract's own type where there is one: the log limits as the contract's record, a server's address as the origin
|
||||
the contract's address rule returned, and a sign-in repair as one of the contract's two values.
|
||||
- **What the contract refuses is not sent.** A request is put to the contract's own check and its arguments are
|
||||
read back the way the service reads them, so a server that is not named by its full identifier, a value a
|
||||
request needs and was not given, and an address the address rule refuses are answered at once as
|
||||
`invalid_request`. The client adds one rule of its own to the contract's, about the check the operator saw
|
||||
(below): it must be given wherever something is accepted, and a server is not added with one that failed.
|
||||
- **A reply is read only for what was asked.** A rename answered with another server, an add answered with
|
||||
another address, a reorder answered with other servers and job details for another job are unreadable replies,
|
||||
so nothing is shown under the wrong server or row.
|
||||
- **A refusal carries the contract's code.** A result says why the service refused as one of the contract's
|
||||
codes, with typed details for the codes that have them, so nothing is decided by a message's text. A code this
|
||||
version does not know is handled as `internal_error` and shows the service's message; the log warns of it once
|
||||
while it repeats.
|
||||
- **An older and a newer service are told apart, and neither is taken as proof that nothing happened.** A
|
||||
reply that is not JSON, which is what a service that speaks version 1 sends, and a reply in a later version
|
||||
each say which side has to be updated. For a read that is all. For every other operation the result also says
|
||||
that the operation may have been carried out and must be checked before it is repeated, because the form of a
|
||||
reply does not show what the service did. A reply of this version that cannot be read, and JSON without a
|
||||
version, are reported the same way for every operation.
|
||||
- **The sign-in provider's address comes from the check the operator saw.** Adding a server and switching a
|
||||
certificate exception on take the result of the check the operator was shown, as an argument that cannot be left
|
||||
out, and send the provider's address that check reported, or none when it named none. They take no address as
|
||||
text, which could hold another read of the provider. Switching an exception on without a check is refused
|
||||
before anything is sent; switching it off needs none. Adding a server with a check that failed is refused too.
|
||||
- **Limits.** The client has at most twelve requests in flight, of the sixteen clients the pipe serves at once.
|
||||
Over that, the three reads the window repeats on a timer (the snapshot, the transport read, a page of jobs) are
|
||||
skipped and asked again on the next tick, and every other request waits its turn. The snapshot and the
|
||||
transport read are single-flight: a second caller shares the answer of the one in flight. A request whose
|
||||
caller stops waiting before its turn comes is not sent.
|
||||
- **A snapshot that is newer than a change.** A caller that reads the state to learn the outcome of a change can
|
||||
ask for a snapshot that is sent after its call. It is never answered from a snapshot sent earlier: it waits
|
||||
out one that is in flight and then sends another, and over the limit it waits its turn instead of being
|
||||
skipped. A snapshot's result says which of the client's snapshots it is, by number, and the result of a change
|
||||
says how many snapshots had been sent when its reply was read, so a caller can tell a snapshot that was sent
|
||||
after a change was answered from one that may have been sent before it.
|
||||
- **Logging.** A log entry names the operation, the server's identifier and the kind of failure. It holds
|
||||
nothing from a request's arguments and, of a reply, at most the code of a refusal this version does not know,
|
||||
so a session credential, an enrollment code and the identity provider's sign-out request reach no log line.
|
||||
The entry for a failure of the pipe itself also carries the exception's own description (its type names and
|
||||
messages), and at debug level the exception's full text with its stack. That is why the function that sends a
|
||||
request over the pipe is held to a stated contract: no exception it throws may carry text of a request or of a
|
||||
reply in its message.
|
||||
|
||||
A failed browser start no longer writes the sign-in address to the companion log or the dialog (2.0). When Windows
|
||||
could not start the default browser for a browser sign-in, the companion's log entry and the dialog's "Browser
|
||||
sign-in not completed" message repeated Windows' own error text, and that text names what it could not start: the
|
||||
whole sign-in address, with that attempt's state, nonce and code challenge. When the browser could not be started
|
||||
for the identity provider's sign-out, the log entry named the provider's sign-out address the same way, with its ID
|
||||
token hint. Both now say only that the default browser could not be opened, with the Windows error number, and what
|
||||
to check: that a default browser is set in Windows Settings under Apps, Default apps. After a sign-out whose
|
||||
provider page could not be opened, the dialog used to say that the provider's sign-out "was opened in your browser
|
||||
but did not confirm"; it now says that the page could not be opened, that the provider's session may therefore
|
||||
still be there, and what to check. Sign-in and sign-out are otherwise unchanged. Releases 1.1.0, 1.1.1, 1.2.0 and
|
||||
1.2.1 have the sign-in case. The seven from 0.2.0-rc.1 to 1.0.1 start the browser the same way but neither log nor
|
||||
show the address: a failed start is answered with a fixed text (0.1.x has no browser sign-in). The sign-out case is
|
||||
in no release. Both cases are in the development snapshot `snapshot-2.0.0-rc.1`, which was not installed anywhere,
|
||||
and on the unreleased `release/1.2.2` branch; 2.0 builds after that snapshot have neither
|
||||
([audit](docs/AUDIT-2026-11-10.md), U22).
|
||||
|
||||
An unexpected failure of the browser callback inbox no longer leaves the connection dialog's browser sign-in as an
|
||||
unhandled error (2.0). The inbox gives an argument error when the process that sent a callback has already exited,
|
||||
which the dialog's flow did not expect. It is now reported as "Browser sign-in not completed", with the kind of
|
||||
failure as the reason.
|
||||
|
||||
Sign-in in the companion, rebuilt for several servers (2.0; not used by the window yet). Three types were added to
|
||||
the presentation assembly for the connection wizard and the per-server Connection page of
|
||||
[ADR-0005](docs/adr/0005-connection-wizard-and-server-tree.md). Nothing the operator sees changes through them in
|
||||
this build: the connection dialog still runs on the existing flow, whose only changes are the entries above.
|
||||
|
||||
- `CompanionSignIn` is the part of signing in that needs a session and runs as the signed-in Windows user. It reads
|
||||
the server's sign-in description, signs in by password or through the browser, and returns the session credential
|
||||
to its caller. It hands nothing to the service, enrolls nothing and keeps no certificate setting: the exception to
|
||||
read under is given with each read. It offers only the methods that the service's check and its own read both
|
||||
found, and logs a difference. It stops when the description names another sign-in provider than the check
|
||||
reported. When this Windows account refuses a certificate the service accepted, or the sign-in provider's
|
||||
certificate, it says which of the two it was and keeps the refused certificate for the wizard to show. A browser
|
||||
sign-in that is cancelled or times out before its callback has been handed to the session keeps the session, so
|
||||
the next attempt needs no new read. Once the callback has been handed over the server may complete the sign-in,
|
||||
so an attempt that is cancelled, times out or is stopped after that discards the session.
|
||||
- `BrowserCallbackDispatcher` owns the one browser callback inbox. A sign-in or a provider sign-out registers the
|
||||
OAuth state it sent and waits for the callback that carries it, so two servers can be signing in at once. A
|
||||
callback that carries no state, more than one, or a state nobody waits for is dropped, and so is one the session
|
||||
would refuse to read (an entry without a value, a repeated name, a control character, a fragment); the log says
|
||||
only that one was dropped. An inbox is open exactly while something waits. When the inbox fails, everything that
|
||||
waits fails once and the next registration opens a new inbox. Whatever the inbox fails with, each sign-in or
|
||||
sign-out that waits ends as a failure it reports: a failure of a kind they do not expect (the inbox gives an
|
||||
argument error when the process that sent a callback has already exited) reaches them as one that names only its
|
||||
kind. A log that cannot be written to does not stop the dispatcher.
|
||||
- `ProviderSignOut` is the identity provider's half of a sign-out: it opens the provider's sign-out page and waits
|
||||
up to two minutes for the browser to return with that sign-out's own state. Its result says how it ended:
|
||||
confirmed, not opened (the browser or the callback inbox could not be opened, so the operator saw no page), not
|
||||
confirmed (the page was opened and the provider did not confirm), or cancelled by its caller.
|
||||
- When Windows cannot start the browser, its failure text names what it could not start, which is the whole sign-in
|
||||
or sign-out address. The new types report only the kind of failure, so no state and no token hint can reach the
|
||||
log or the screen that way.
|
||||
- The sign-in session seen by the presentation assembly (`ISignInSession`) now also gives the certificate it refused
|
||||
last and the origin that presented it.
|
||||
- The presentation log (`IPresentationLog`) can be written to with a message template and its values, which the new
|
||||
types use for every entry. A host that implements only the four text methods, as the companion does, gets the
|
||||
finished text.
|
||||
|
||||
In-app update, service controls and the pipe's identity check (2.0). The installed product behaves as before. This
|
||||
change does not alter the Windows Installer command line, the files, owners and access lists that are checked or
|
||||
the order they are checked in, what an installer exit code means, the calls made to the service control manager,
|
||||
or the decision whether the other end of the management pipe is the installed service. It changes where that code
|
||||
lives, so that tests can run it:
|
||||
|
||||
- The update steps (the staging checks, preparing the helper, the hand-off, applying and recording the outcome)
|
||||
moved from `MsiUpdateInstaller` into `MsiUpdateSteps`, which reaches Windows only through `IPathSecurity` (owners
|
||||
and access lists) and the new `IProcessHost` (the current process, a process by its id, a program started, a
|
||||
file's version). `MsiUpdateInstaller` keeps the two entry points the command line tool and the companion call,
|
||||
and the rules that need nothing from Windows.
|
||||
- `MsiUpdateService` is given its release source (`IMsiReleaseSource`, which `GitHubMsiReleaseClient` implements),
|
||||
the pipe and the update steps. The companion's constructor binds the real ones, as before.
|
||||
- `AgentServiceControl` passes its four actions to `AgentServiceActions`, which reaches the service control
|
||||
manager through `IServiceManager` and measures its 30 second wait on a clock it is given.
|
||||
- `AgentPipePeer` makes its decision from facts it is given through `IPipePeerFacts`: which process serves the
|
||||
pipe, the service's state and process, and that process's account and program.
|
||||
- `MsiUpdateInstaller.PrepareHelper`, `StartHelper`, `ValidateStagingDirectory` and `UpdatesDirectory` are no
|
||||
longer public members. Nothing outside the Windows library called them.
|
||||
- Tests run each of these against hand-written fakes in temporary folders, and the real Windows adapters against
|
||||
a harmless child program, a pipe the test serves itself, the test process and a built-in Windows service that
|
||||
is only read. No test starts Windows Installer, asks the release feed, connects to the installed service's
|
||||
pipe or starts or stops a service. The minimum line coverage of the Windows library in
|
||||
`build/minimum-coverage.json` rises from 87 to 95. With this change the library measured between 96.06%
|
||||
and 96.14% of its lines over four runs (4,539 to 4,543 of 4,725), which puts it above its release bar of 90.
|
||||
|
||||
Version 2 of the management pipe: the contract's gaps are closed (2.0). The contract is a set of types that
|
||||
nothing serves or sends yet, so none of the first seven items changes what the installed product does; they change
|
||||
what the service and its clients will be able to build on. The last two are about logging.
|
||||
|
||||
- **Error messages.** An error carries a message that is printable and at most 512 UTF-16 units, however the reply
|
||||
is built and however it is read; this used to hold only for replies built by two functions. A cut adds no mark
|
||||
(a message could be 515 units, and an identity value 203), a value that is missing is absent instead of the words
|
||||
"(missing)", and a failure with no message cannot be built.
|
||||
- **Error codes.** A failure can be built only with a code from the contract's table and with exactly the details
|
||||
that table names for the code. A client still reads a code it does not know.
|
||||
- **The envelope.** A request or a reply is read only by the contract's two parsing functions and written only by
|
||||
its writing function. The JSON serializer refuses both under the default options and under any options that do
|
||||
not bring a converter of their own for the envelope; it used to refuse reading for the contract's own options
|
||||
only. A request and a reply are built only by functions that check them: a request names its operation by a
|
||||
well-formed name and a server by its full identifier, and a reply holds exactly one of a result and an error.
|
||||
The text on the wire is unchanged. The parsing functions no longer throw
|
||||
for a text that holds a surrogate without its partner, as itself or as an escape (`\ud800` on its own): it is
|
||||
refused like anything else that is wrong in the same place.
|
||||
- **Accounts.** The principal and the tenant are carried one way wherever a reply names an account (a snapshot,
|
||||
the result of adding a server or signing in, the details of a changed identity): printable, at most 200 units,
|
||||
left out when missing. The message beside an account is printable and at most 512 units, like an error's.
|
||||
- **Server names and addresses.** A name is 1 to 64 characters of visible text after trimming, of which at least
|
||||
one draws something (a name made only of spaces, combining marks and characters that draw nothing, such as
|
||||
U+3164, is refused), without square brackets (ASCII or fullwidth), and not shaped like a server identifier or the
|
||||
first eight or more characters of one. `servers.add` and
|
||||
`servers.rename` refuse any other name as `invalid_request`. The rule is one function for the service and the
|
||||
companion, it says which part a name breaks, and it proposes a name from a server's address that always passes
|
||||
(the host; "Server" in front of a host that looks like an identifier). The rule for an address is beside it:
|
||||
HTTPS, or plain HTTP for this computer itself, with no user name, query, fragment or path, and a host that has
|
||||
an ASCII name and is not an IPv6 literal with a zone (the stored form of an address cannot hold the zone, so two
|
||||
zones would be one server). The existing checks of an address are unchanged.
|
||||
- **Adding a server, and switching the certificate exception on.** `servers.add` and `trust.set` carry the sign-in
|
||||
provider's address that `servers.check` reported, for comparison only; leaving it out, or giving null, says that
|
||||
the check named no provider, and text that names no address is refused as `invalid_request`. When the service
|
||||
reads another provider from the server, or one where the check named none, or none where it named one, nothing
|
||||
is stored or changed and the answer is the new error code `provider_changed`; the client checks the server
|
||||
again.
|
||||
- **Printer discovery.** The operation table marks `printers.discover` as single-flight: one discovery runs at a
|
||||
time, and a request that arrives during it shares its result. The discovery itself is built with the pipe.
|
||||
- **Log redaction recognises more shapes.** As with the two shapes below, this changes what a log line shows only
|
||||
for a line that holds one of them, and no line the product writes today does. The named values,
|
||||
whose value is replaced by `[REDACTED]` while the name stays, now include every name under which the product's
|
||||
own requests and responses carry a secret. `enrollmentCode`, `deviceToken` and the `X-QQQ-Device-Token` header
|
||||
were covered; added are a session response's `uuid`, `sessionUUID`, `sessionId`, the code verifier and the
|
||||
ID-token hint in every spelling (`codeVerifier`, `code_verifier`, `code-verifier`; `idTokenHint`,
|
||||
`id_token_hint`, `id-token-hint`), and `code` in a message that also names a code verifier. Each named value is
|
||||
recognised as a member of a JSON object, as `name=value` or `name: value`, and now also with the equals sign
|
||||
percent-encoded (`name%3Dvalue`). A name that is itself percent-encoded, the rest of a value after a comma, a
|
||||
semicolon or a space, and a nonce are not covered. An `Authorization:` or `Proxy-Authorization:` header line
|
||||
loses its whole value to the end of the line,
|
||||
whatever its scheme; a line that began with `Authorization: Bearer` and went on with other text used to keep
|
||||
that text. A callback's `code` and `state` are also redacted when the value is quoted, when the callback is
|
||||
percent-encoded inside another address, and when its ampersands are written `&`. A cookie or authorization
|
||||
header is also recognised when its value stands on the next line, under the names `HTTP_COOKIE` and
|
||||
`HTTP_AUTHORIZATION`, with a no-break or zero-width space before the colon, and, for the authorization header,
|
||||
with an equals sign in place of the colon. What is still not recognised, and what is hidden although it is no
|
||||
secret, is listed in audit W41 and held by tests.
|
||||
- **Service log.** The entries the service keeps in memory are stamped by the service's own clock, and a
|
||||
per-server view of the log, which nothing uses yet, would prefix a server's lines with the first eight characters
|
||||
of its identifier, never with its name.
|
||||
|
||||
One printer whose driver does not answer no longer stops jobs for the other printers (2.0). What such a printer
|
||||
still affects is listed at the end of this entry; one case is not covered at all, a printer reached through a
|
||||
print server that is switched off or unreachable. Until now the service asked Windows about every printer
|
||||
installed on the computer three times for each job (before it took the job, after it downloaded the document, and
|
||||
once more in the print helper), and each time it read every printer's settings through that printer's driver. One
|
||||
printer whose driver hung or failed was enough to stop every job on the station, label printing included.
|
||||
|
||||
- **A job reads the settings of its own printer only.** Before a job is taken, the service asks Windows for the
|
||||
list of printers and reads no printer's settings. After the download it reads the settings of the job's own
|
||||
printer, and the print helper reads them again before it prints. The service opens no other printer for the job
|
||||
and asks no other printer's driver anything. A job is still refused before anything is sent to Windows, with the
|
||||
same reasons as before, when its printer is gone or no longer shared, no longer offers the document's format, or
|
||||
changed its selected media.
|
||||
- **For the printer that prints, the print is unchanged.** What the print helper asks Windows for that printer,
|
||||
and in what order, is what it was. Before the print, its settings are now read once by the service, after the
|
||||
download, where they were read twice (also before the job was taken). The other printers are left alone.
|
||||
- **The connection to the server survives a printer list that cannot be read.** The station's own list of
|
||||
printers still reads every printer's settings, and the service reports it to the server after every change.
|
||||
When that list could not be read, the connection to the server was ended and made again each time the list
|
||||
was due, and jobs stopped. Now the service writes one error to the log ("The printer inventory could not be
|
||||
read, so none was sent to the server"), sends the server nothing in place of the list and keeps the
|
||||
connection. The list the server already has stays in force, so when the cause is one printer's settings the
|
||||
service goes on taking and printing jobs for the other printers. When the cause is one that jobs share (the
|
||||
Windows print spooler cannot list its printers, or the service cannot read its own stored data), jobs stop
|
||||
for that reason as they did before, and the log has "Print lane stopped" as well; the error line gives the
|
||||
reason and does not promise that jobs go on. The log says once when the inventory can be read again.
|
||||
- **Cancelling and test prints.** Cancelling a job Windows already holds, from the companion or from the server,
|
||||
and sending a test print, no longer read any printer's settings, so they work while a printer's driver does not
|
||||
answer.
|
||||
- **What a broken printer still affects.**
|
||||
- **Not covered: a printer on a print server that is switched off or cannot be reached.** Some printers are not
|
||||
installed on the station itself; they are connections to a printer that another computer, a print server,
|
||||
shares. Microsoft documents that the Windows call the service lists printers with opens every such
|
||||
connection, and waits for a timeout when the connection's print server is switched off or cannot be reached,
|
||||
or the printer on it no longer exists. The service asks for that list before it takes each job, with a limit
|
||||
of 20 seconds, again when it looks the job's printer up, and again in the print helper. So one such
|
||||
connection can still hold up the list, the lookup and the print for every printer on the station. If Windows
|
||||
waits longer than the limit, no job is taken for as long as it lasts (the log has "Print lane stopped: The
|
||||
printer operation timed out"); a job already taken is refused before anything is sent when its lookup runs
|
||||
out of time, and is recorded with an unknown outcome when its print does. The fix in this entry therefore
|
||||
covers a printer on the station whose driver is broken, and does not cover a print server that is dead. This
|
||||
was so before this change, which does not alter it. It rests on Microsoft's documentation and on reading the
|
||||
code: it has not been tried on a real station, and how long Windows waits is not known. Bringing the print
|
||||
server back, or removing that printer connection from Windows, ends it.
|
||||
- The companion's picture of the service (its status line, the Printers view and the recent jobs come from one
|
||||
request that includes the whole printer list), the `printers`, `snapshot` and `service-status` commands, and
|
||||
sharing or unsharing a printer read the whole list. While one printer does not answer, each can take up to 20
|
||||
seconds and then fails with the reason, so the companion shows a service problem (red, with that reason) for
|
||||
as long as it lasts, although jobs for the other printers keep printing. The Jobs page and the connection
|
||||
figures use other requests and keep working.
|
||||
- After the service is restarted, or after it reconnects to the server, no job is taken until the whole list
|
||||
has been read once and the server has accepted it. A printer whose driver fails every time therefore still
|
||||
stops printing from the next restart or reconnect on, until that printer is repaired or removed from
|
||||
Windows. The log line says so ("No job is claimed on this connection until the inventory can be read"). A
|
||||
change that reports such a printer as unusable and lets the others print is decided
|
||||
([ADR-0004](docs/adr/0004-multi-server-runtime.md)) and is not in this build.
|
||||
- While the list cannot be read, nothing about any printer is reported to the server. A healthy printer that
|
||||
goes offline or comes back, or whose paper size or resolution is changed in Windows, is not reported until
|
||||
the list can be read again, and the server goes on showing what it was last told. Each job is still checked
|
||||
against its own printer after the download, so a job for a printer that was removed, unshared or given other
|
||||
media in the meantime is refused before anything is sent.
|
||||
- A Windows print spooler that cannot list its printers at all still stops every job, as before.
|
||||
- A job for the printer whose driver does not answer is refused before anything is sent, with the reason.
|
||||
|
||||
The journal has a schema version (2.0). Printing, pairing and history are unchanged.
|
||||
|
||||
- The service brings the journal up to date once, when it starts, in one transaction: either every pending change
|
||||
is made or none is, and a failed start leaves the journal as it was. Until now each part of the service that
|
||||
opened the journal created and altered tables as it went. A journal left by any 1.x release, and by the
|
||||
prereleases before it, is brought to the same schema; no row is changed, except that job events written before
|
||||
events recorded which pairing they belong to are given the saved pairing's, as 1.x did each time it started.
|
||||
- When the journal cannot be brought up to date the service does not start, as before. The log now has one line
|
||||
for the operator: the journal's folder, the kind of failure (the file could not be opened or written, another
|
||||
program has it in use, it is damaged, the disk is full), that the journal was not changed, and what to check.
|
||||
Until now the log had only SQLite's own message.
|
||||
- An older version installed over data that a newer version wrote no longer runs on it. This can happen between
|
||||
release candidates, which share a version number. The service leaves the data exactly as it found it, contacts
|
||||
no server, and reports "A newer version of this application wrote the print data on this computer, and this
|
||||
version cannot use it. Nothing was changed. Install the newer version again to continue printing." Status, log
|
||||
settings and updates stay available, so the newer version can be installed again; every other operation is
|
||||
refused with that sentence. The log has the reason once, as an error written when the service starts. A refused
|
||||
operation is recorded only as debug detail, so a companion that keeps asking does not push the reason out of
|
||||
the log entries it shows.
|
||||
|
||||
Servers that run as several nodes (2.0; the 1.2.x fix carried over). A load balancer in front of such a server
|
||||
names the node it chose in a cookie; the client attached only the session cookie, so its realtime socket could open
|
||||
on one node while its claims and downloads went to another, and a server whose claim gate needs the socket on the
|
||||
same node (QQQ Printing 1.1.x) answered 409. The client now keeps the Application Load Balancer's cookies (names
|
||||
beginning `AWSALB`) from any answer of the server, the WebSocket upgrade answer included, and sends them on every
|
||||
later request and on the upgrade, so everything reaches the node that holds the socket. When that node stops, the
|
||||
load balancer rewrites the cookie on the next answer, an error answer or a refused upgrade included, and the client
|
||||
follows it. The cookies are held in memory only, never saved with the session and never shown in a log, limited to
|
||||
eight bounded values scoped to the server's host and root path, and sent nowhere but the server: never to the
|
||||
sign-in provider. Cookies under other names no longer decide whether a session answer is valid, because a load
|
||||
balancer adds its own to every answer. Nothing changes against a single-node server. No change to pairing, history
|
||||
or the printing protocol.
|
||||
|
||||
The development-only certificate exception now covers two origins at most (2.0, narrows behavior).
|
||||
|
||||
- "Allow untrusted server certificate" used to make a session accept an untrusted certificate from every host it
|
||||
contacted. It now accepts one only from the server's own origin and, during sign-in in the companion, from the
|
||||
origin of the sign-in provider that server names. A certificate Windows does not trust from any other host is
|
||||
refused, and the log names the certificate and the origins the exception covers.
|
||||
- A development setup whose sign-in provider has an untrusted certificate on a different origin from the server still
|
||||
signs in: the companion reads the server's sign-in description under the exception for the server, then continues
|
||||
with an exception that also names the provider, and logs both origins. In the service the exception names the
|
||||
server only, so signing out of such a setup ends the QQQ session and signs out locally, but the provider's
|
||||
sign-out page cannot be prepared and the sign-out reports that failure. Installing the provider's root certificate
|
||||
in the Local Machine trusted root store avoids this. A later change records the provider's origin with the server.
|
||||
- A server check now reports the origin of the sign-in provider the server names whenever its sign-in description
|
||||
could be read, so both addresses can be shown before an exception is saved.
|
||||
- The provider's address is written by the server, so its origin is kept, compared and shown by the host's ASCII
|
||||
name, the name the connection is really made to. A name with a look-alike letter appears as its `xn--` form, a
|
||||
zero-width character is not in it, and a host that has no ASCII name (one holding a line separator, for example)
|
||||
is not an origin: nothing is added to the exception for it and nothing of it is shown. A name written in Unicode
|
||||
and the same name in `xn--` form are one origin. The log line for a refused or excepted certificate names the
|
||||
host the same way, and so does every other line that names the provider's address during sign-in and sign-out,
|
||||
in the service's log and the companion's. Such a line leaves out a query, a fragment and user information and
|
||||
says by kind that it did, and a provider's authorization endpoint that is refused is refused by naming the rule
|
||||
it broke.
|
||||
- A refused certificate is reported with the origin that presented it and the time, so a caller can tell a refusal
|
||||
from the server from one from its sign-in provider. The management contract's certificate description takes both
|
||||
from that record and no longer accepts them from its caller.
|
||||
- A server that puts a host the platform cannot name (one holding a line separator) into its sign-in provider's
|
||||
issuer, discovery address or endpoints is refused as a configuration problem, and a server check offers no
|
||||
browser sign-in for such an issuer or discovery address. Before, sign-in or sign-out stopped with an internal
|
||||
address-format error.
|
||||
- The service's log no longer says "The server session could not be ended" when a sign-out with a sign-in provider
|
||||
failed after the server session was ended; it says that one of the two steps failed and points to the session
|
||||
entries that say which.
|
||||
- Servers with trusted certificates, and setups without the exception, are not affected.
|
||||
|
||||
Two cases the print worker now settles with a known outcome (2.0).
|
||||
|
||||
- A printer that stops being shared while jobs wait behind a print no longer leaves those jobs with an unknown
|
||||
outcome. The filter that checks sharing at the moment of submission refuses before anything is handed to Windows,
|
||||
and its refusal now has a type of its own, so the worker records the job as FAILED with a reason ending "no bytes
|
||||
were submitted" where it used to record OUTCOME_UNKNOWN. History shows RECEIVED, SUBMITTING, FAILED. The printer
|
||||
is still checked once per job after the download, as before, and not a second time while the job holds the
|
||||
printer's turn. A selected media default that changed during the wait is refused by the print helper before it
|
||||
prints and is FAILED, as before. A queue that was removed from Windows during the wait is still
|
||||
OUTCOME_UNKNOWN: the print helper finds it missing before it sends anything but cannot yet say so in a form the
|
||||
worker can rely on.
|
||||
- A job the server offers again while a lane of this computer still holds it is left to that lane. The log says so
|
||||
in plain words, nothing is recorded for the replay, and the lane ends its pass. Before, the replay was stopped
|
||||
only by an internal duplicate check that logged "An item with the same key has already been added". A lane
|
||||
also lets go of a job whose receipt was interrupted when the journal then fails to record that failure; before,
|
||||
the job stayed marked as held until the service restarted, and every later offer of it was turned away.
|
||||
|
||||
Branded Start menu shortcut (2.0). The installer step that creates, renames and removes the shortcut under
|
||||
**Start → Print Connectors** now records, by name, every shortcut it has put there and may still own, and a
|
||||
recorded file that is no longer ours no longer fails an install or an uninstall. A file that is not ours under the
|
||||
name a new shortcut needs still refuses an install, as below. Nothing changes for an installation whose shortcut
|
||||
was never touched by anything else.
|
||||
|
||||
What counts as ours, here and below: a shortcut that starts the installed companion with no arguments and carries
|
||||
this installer's description. Someone else's shortcut, a file that is not a shortcut at all (empty, or other
|
||||
content) and a link are not ours. A file that cannot be opened, because another program holds it, is neither: it is
|
||||
not judged, since it may well be ours.
|
||||
|
||||
- **Install, upgrade or repair with a changed display name.** The new shortcut is written first and the previous
|
||||
brand's shortcut is removed afterwards, as before. What is different:
|
||||
- If the previous shortcut cannot be deleted, or cannot be opened for the look taken just before the delete,
|
||||
the install now succeeds. It used to fail, with both shortcuts on the Start menu and nothing left to remove
|
||||
the old one later. The old shortcut stays for the moment, a warning in the activity log names it and the
|
||||
reason, and the next install, upgrade, repair or uninstall removes it.
|
||||
- The previous shortcut is looked at again immediately before it is deleted, and is left alone if it was
|
||||
replaced in the meantime.
|
||||
- If the file under the previous name is not ours, or the recorded name is not a shortcut file name in that
|
||||
folder, it is left unchanged with a warning and the new shortcut is created. This used to fail the install
|
||||
after the new shortcut had been written, and every install after it.
|
||||
- **Any install, upgrade or repair.**
|
||||
- A file that is not ours under the name the shortcut needs is never overwritten. The name is looked at before
|
||||
the shortcut is written and again immediately before it is put in place; 1.2.1 looked once, and overwrote a
|
||||
file that appeared or was replaced in between.
|
||||
- A shortcut of ours that is marked read-only is replaced, or removed, like any other. It used to fail every
|
||||
install, upgrade, repair and uninstall until the mark was cleared by hand. A read-only file that is not ours
|
||||
keeps its mark.
|
||||
- A temporary file a killed or failed earlier run left in the folder (`.qqq-` and 32 hexadecimal digits,
|
||||
`.lnk`) is removed. Only a file with exactly such a name is removed for this reason, and such a name is never
|
||||
recorded. One that cannot be removed is noted as a warning and tried again by the next run.
|
||||
- An install fails before it has changed anything when a file that is not ours has the name the shortcut
|
||||
needs, or when the record in the registry cannot be opened for writing. A recorded shortcut that cannot be
|
||||
opened no longer fails an install; see the first entry of this list of changes. In 1.2.1 a same-brand upgrade
|
||||
over a held shortcut failed saying that a different shortcut already used the name.
|
||||
- An install can still fail after that. If the new shortcut cannot be written, the record is put back as it
|
||||
was found, except that the new name stays on record when a shortcut of ours already has that name or when
|
||||
the temporary file could not be removed. If the shortcut is written and the record then cannot be, the
|
||||
shortcut stays and its name is already on record, so a later install or the uninstall finds it.
|
||||
- **Uninstall.** A recorded file that is not ours no longer fails the uninstall: it is left unchanged, a warning
|
||||
in the activity log says so, and the record is removed. The same holds for a recorded name that is not a
|
||||
shortcut file name in the folder. Both used to stop the uninstall. Shortcuts an earlier install could not
|
||||
remove, and temporary files left behind, are removed with the current shortcut. An uninstall still fails,
|
||||
before it has deleted anything, when the record cannot be changed. A shortcut that cannot be opened or deleted,
|
||||
a temporary file that cannot be deleted and a shortcut folder that can no longer be trusted no longer fail it;
|
||||
see the first entry of this list of changes.
|
||||
- **Registry.** `HKLM\SOFTWARE\QRunIO\PrintAgent` keeps `LastShortcutName` as before and may now also hold
|
||||
`PendingShortcutNames` (multi-string): the names of a shortcut that is being written and of shortcuts that are
|
||||
still to be removed. It is normally absent and is removed at uninstall. A value of either name that is of
|
||||
another kind is not used and is reported as a warning in the activity log each time it is read; an
|
||||
expandable string under `LastShortcutName` is read as stored, without expansion.
|
||||
- A warning that cannot be written to the activity log never fails an install or an uninstall.
|
||||
- **Sign-in startup.** When the task scheduler's program cannot be started at all, the companion's preference
|
||||
and the uninstall cleanup now say "Could not start Windows Task Scheduler administration." They used to show
|
||||
the raw Windows error. Nothing else about the sign-in task changes.
|
||||
|
||||
Removed the code the service never runs (2.0). The installed product behaves as before; the Core and Windows
|
||||
libraries lose every path that existed only for the polling transport and the `--legacy-compat` mode.
|
||||
|
||||
- The API client can be built only from a signed-in session. Its device-token parameter and bearer header, the
|
||||
control poll, the HTTP command receipt and the HTTP fallbacks for inventory, heartbeat and job events are gone.
|
||||
Enrollment is unchanged: it posts to the session's `devices/enroll` route, as the service always did; only the
|
||||
`enroll` route a client without a session used is gone. Inventory, job events and command receipts travel only
|
||||
over the realtime channel.
|
||||
- The print worker is the lane the service runs: it no longer has a mode that recovers the journal, sends its own
|
||||
heartbeat and inventory, or downloads and submits a document from memory. Everything the runtime hands it is
|
||||
required.
|
||||
- A printer backend prints a verified staged file and nothing else. `IFilePrinterBackend` is folded into
|
||||
`IPrinterBackend`, the in-memory print is removed from the Windows adapter and the isolated backend, and the
|
||||
print helper no longer accepts a `print` operation that carries the document in the request.
|
||||
- The update installer's message for a missing helper names the command line tool instead of a "diagnostic CLI".
|
||||
|
||||
Core hardening for 2.0. No change to pairing, history or the wire protocol.
|
||||
|
||||
- A job the server sends in a form the agent cannot use no longer blocks a device. The agent identifies the job
|
||||
and refuses it: history shows RECEIVED and then FAILED with a fixed reason ending "No bytes were submitted",
|
||||
nothing is downloaded or printed, and the next job is taken. This covers a missing or unusable member, a member
|
||||
given twice in any letter case, options nested deeper than 64 levels, and text the agent cannot read (a
|
||||
surrogate escape without its partner, or bytes that are not UTF-8) in a title, in another member it reads or in
|
||||
a member name. Only a claim that names no job at all, or whose job identifier is itself unreadable, still stops
|
||||
the device's queue until that job is cancelled on the server.
|
||||
- Extra fields in a job, including any a submitter adds to its options, are ignored. A job title may be 1,024
|
||||
characters of any script, and a byte-order mark in front of a server response is accepted.
|
||||
- A job is never printed twice because the server offered it again. One that is still being submitted is left to
|
||||
finish, and the log says so. A job stopped after its SUBMITTING receipt and before Windows was asked is FAILED
|
||||
with a reason that says no bytes were submitted; a job Windows has accepted stays SPOOLED even if releasing its
|
||||
staging space or printer turn fails afterwards.
|
||||
- A realtime message that breaks the contract ends the connection with a reason naming what was wrong, and the
|
||||
agent reconnects.
|
||||
- Core can now check a server before sign-in and report each problem by kind (unreachable, certificate refused
|
||||
with the certificate, not a QQQ server, printing extension missing or too old, no usable sign-in). The check
|
||||
sends no credential, follows no redirect and never signs the service out.
|
||||
- Text that comes from a server, an identity provider, a browser callback or a certificate is shown and logged
|
||||
only in printable, bounded form, so a server cannot forge a log line or flood the log. A long value is cut and
|
||||
ends in "...", and a long list is counted.
|
||||
|
||||
Log redaction recognises two more shapes (2.0). This changes what a log line shows, in the service's log, the
|
||||
companion's log and the shared activity log file alike, but only for a line that holds one of them; no line the
|
||||
product writes today does (the companion completes the browser sign-in and hands the service a session
|
||||
credential, addresses are logged without their query, and cookies are named, never shown), so this is a second
|
||||
line of defense.
|
||||
|
||||
- The value of a `Cookie:` or `Set-Cookie:` header line is replaced by `[REDACTED]`, to the end of that line.
|
||||
The header's name stays.
|
||||
- The values of `code` and `state` in the query or fragment of an address (straight after `?`, `&` or `#`) are
|
||||
replaced by `[REDACTED]`, as in `/oauth/callback?code=[REDACTED]&state=[REDACTED]`. The names stay.
|
||||
- `code` and `state` are not keywords on their own. Lines such as "status code: 401", "exit code=1" and
|
||||
"state=Paused" are written as before.
|
||||
|
||||
Sign-out now ends the identity provider's browser session as well as the QQQ session, and every browser
|
||||
sign-in sends `max_age=0` beside `prompt=login`, so the next sign-in always asks for credentials (the provider
|
||||
must stay invisible to users). The companion's Sign out asks the service to log out with the application's
|
||||
registered callback; when the server advertises `nativeSessionLogout` (UI API 1.10 or later) and lists that
|
||||
callback, the server returns the provider's end-session data, Core checks it against the issuer QQQ advertises
|
||||
and the provider's own discovery (HTTPS end-session endpoint on the issuer's origin, exact callback, bounded
|
||||
hint), and the companion opens it in the browser and waits for the state-matched return. A server without the
|
||||
capability gets the ordinary logout, and the log says the provider session was not ended. The command-line
|
||||
`sign-out` cannot open the companion's callback inbox, so it still ends only the QQQ session and says so.
|
||||
No change to pairing, history or the printing protocol.
|
||||
|
||||
Command line (2.0, breaking). `qqq-print-agent.exe` now only manages the installed service over its pipe.
|
||||
|
||||
- The `--legacy-compat` mode is removed with everything that existed for it: the separate per-user data directory
|
||||
(`--data-dir`), the command line's worker lock, the run-once claim pass (`run --once`), the isolated enroll and
|
||||
expose paths, and the `--service` flag. It was the last path the shipped programs could reach that sent a device
|
||||
token outside a QQQ session. A command line that uses one of the four options is refused with a sentence saying
|
||||
they were removed in 2.0. State the mode left in `%LOCALAPPDATA%\QqqPrintAgent` is no longer read; see
|
||||
[Administrator operations](docs/ADMINISTRATION.md#command-line).
|
||||
- The executable is no longer a printer worker: `--printer-worker` is an unknown command. Only the removed mode
|
||||
ever started it that way; the service starts its own executable as the worker, in 1.x as now. Its two remaining
|
||||
internal roles (MSI update helper, browser sign-in callback) are unchanged.
|
||||
- Help: `--help` and `-h` print the usage on standard output and exit 0 (they used to exit 1), as `help` and no
|
||||
arguments already did, and so does `--help` or `-h` after a command (`printers --help`). The usage text is new:
|
||||
one line per command, where the enrollment code comes from, and the exit codes. It is written with the
|
||||
platform's line ends.
|
||||
- Refusals: an unknown command, and any command line a command does not accept, exits 1 with one line saying what
|
||||
is wrong followed by the usage, on standard error (an unknown command used to print the old usage on standard
|
||||
output). The reasons are new and fixed: for example `--server is required.`, `--printer needs a value.`,
|
||||
`--exposed may be given only once.`, `'run' takes no arguments.`. A refusal never repeats what was typed.
|
||||
- Arguments are strict. A command accepts exactly its own options, each once, each with a value that is not blank
|
||||
and does not begin with `--`; extra words used to be ignored. `--exposed` takes `true` or `false` in any letter
|
||||
case and nothing else (padded values used to be accepted). Nothing is sent, and no enrollment code is asked
|
||||
for, when a command line is refused.
|
||||
- Enrollment code: there is a prompt only when the code is typed at a Windows console. It is written to standard
|
||||
error, not standard output, the code is not shown while it is typed, and the prompt line is ended afterwards.
|
||||
When the code is piped in or comes from `QQQ_PRINT_ENROLLMENT_CODE` nothing is prompted: standard output holds
|
||||
only the service's reply and a successful enrollment writes nothing to standard error. A terminal that gives
|
||||
programs a pipe instead of a console (the default Git Bash window) is read as redirected input and may echo
|
||||
what is typed; an unattended caller whose console nobody types at must set the variable or redirect input.
|
||||
White space around the code and a leading byte-order mark are dropped, whether it comes from the variable or
|
||||
standard input. Piped input is read as bytes and decoded as UTF-8 on every console code page, so the mark
|
||||
Windows PowerShell 5.1 puts in front of piped text is recognized whether the console code page is UTF-8 or
|
||||
not; input that begins with a UTF-16 mark, such as a file Windows PowerShell 5.1 wrote with `>`, is read as
|
||||
UTF-16. UTF-16 text without a mark is not recognized; use the variable for it.
|
||||
- `service-status` and `snapshot` exit 1 when the service replies with `Error:`, as the other commands already
|
||||
did. The reply itself is still printed on standard output.
|
||||
|
||||
The companion's presentation logic moved out of the views into a new UI-free assembly,
|
||||
`Qqq.PrintAgent.Presentation`, gated at 100% line coverage ([ADR-0003](docs/adr/0003-version-2.md)): one typed
|
||||
client for the local service pipe, the texts and enablement rules of the printer, job, footer and settings
|
||||
views, the Jobs page's paging and sorting state, and the connection dialog's flow (discovery, sign-in, sign-out,
|
||||
enrollment, certificate exception) as a state machine. The pipe's wire format is unchanged except for one member:
|
||||
the `control` object of the status snapshot (also printed by the command line's `snapshot`) no longer carries
|
||||
`authorizationRequired`. Only the polling prerelease ever set it; it had been always false since, and the footer
|
||||
sentence it selected, "Server authorization required — sign in again", could no longer appear. Missing authorization
|
||||
is shown from the authentication status, as before.
|
||||
What the operator sees is the same except for the following, each of them deliberate:
|
||||
|
||||
- **The companion keeps no session after sign-in.** Once the service confirms that it took over the session, the
|
||||
companion discards its own copy, so the service is the only process holding it. It also discards it when the
|
||||
hand-over was sent and could not be confirmed, because the service may hold it by then. The password form and the
|
||||
browser button disappear with it; signing in again starts with **Discover sign-in options**. They also disappear
|
||||
after **Sign out** and after a cancelled or timed-out browser sign-in, where they used to stay visible without
|
||||
doing anything.
|
||||
- **"Not sent" and "sent, result unknown" are told apart.** The pipe client now reports whether a failed request
|
||||
ever left (`AgentPipeClient.SendReportingPhaseAsync`, `AgentPipeNotSentException`), and only that report counts
|
||||
as "not sent". An enrollment that never reached the service (nothing listening, pipe refused, service not
|
||||
verified) is shown as **Service unavailable**, because the code was not used; it used to say the code might have
|
||||
been consumed. A request that left and brought no usable answer back is never called a failure or a
|
||||
cancellation, and its message starts with what really happened (the request timed out, the connection closed
|
||||
before the answer arrived, the answer was too large, the answer could not be read):
|
||||
- enrollment says **Enrollment could not be confirmed** (a lost reply used to say the service was unavailable,
|
||||
and every cause used to be worded as a time limit);
|
||||
- a sign-in whose hand-over to the service left says **Sign-in could not be confirmed** and to check the
|
||||
connection status before trying again. This replaces **Browser sign-in canceled. No new sign-in was accepted.**
|
||||
after a browser sign-in whose hand-over timed out, and **Sign-in not completed** / **Browser sign-in not
|
||||
completed** after a hand-over whose reply was lost: the service may complete the sign-in in all of these.
|
||||
"Canceled" is now only said when nothing was handed to the service;
|
||||
- a sign-out says **Sign-out could not be confirmed** and that the session may have ended, instead of **Service
|
||||
unavailable**. This includes a sign-out the service answered with a document the companion cannot read;
|
||||
- a change to the certificate exception says **Certificate exception could not be confirmed**, instead of **Service
|
||||
unavailable** with the checkbox put back. The service may hold either value, so the checkbox is shown
|
||||
undetermined and its label says the last change was not confirmed; the companion applies no exception meanwhile,
|
||||
and the setting is read again as soon as the service answers a status request;
|
||||
- an action on a printer or a job (test print, retry, cancel, saving details) that never left this computer says
|
||||
**Request not sent. Nothing was sent to the local service, so nothing was changed**, with the reason. It used to
|
||||
say **Result could not be confirmed** and that the request might already have reached the printer, which stays
|
||||
the message for an action that did leave.
|
||||
- **Pipe failures the window did not handle are handled.** A failure while verifying the service, an oversize pipe
|
||||
message, and a malformed callback written to the browser sign-in inbox now show the same "service unavailable"
|
||||
or "not completed" message as the other failures instead of escaping the handler. The identity check itself now
|
||||
always answers with one of two sentences, in the command-line tool and the in-app update as well as the
|
||||
companion (a check that could not be carried out used to show the underlying Windows or .NET message): **The
|
||||
local endpoint is not the installed Windows print service...** for a process that is running and is not the
|
||||
service, and **The Windows print service stopped or restarted before it could be verified... Wait a moment and
|
||||
try again** when the process that served the pipe has ended, which is what a service restart between the
|
||||
connection and the check looks like. Exit codes and the update's behavior are unchanged.
|
||||
- **A click that cannot run says so.** While one operation of the connection dialog is running, another one is
|
||||
refused with **Another operation is still running** and a log line; it used to do nothing. A refused enrollment
|
||||
or password sign-in keeps the code or password that was typed, because it was not used. This is reachable
|
||||
because saving the certificate checkbox does not block the dialog's other buttons.
|
||||
- **Closing the window during an operation is quiet.** The operation ends by itself; it used to be able to fail
|
||||
with "Cannot access a disposed object", shown and logged as a discovery or sign-in failure, and could still send
|
||||
its request to the service after the window had closed.
|
||||
- **An update the service is not ready for says why.** When jobs claimed before the update's pause are still
|
||||
running after the preparation limit, the in-app update shows the service's own sentence ("Not ready for update:
|
||||
1 job(s) were still in progress after 25 seconds. They will finish; try the update again afterwards.") after "The
|
||||
update was not started, and the current version remains installed." A preparation that was interrupted (printing
|
||||
was resumed, or a sign-in began, while it waited) is shown the same way, in the service's words. Any other answer
|
||||
is shown as an answer the update could not use. All of these used to follow "Let active printing finish and try
|
||||
again", whatever the service said.
|
||||
- **Full details** no longer opens a dialog for a details reply that names another job or has no job or events;
|
||||
the message was already shown, and the dialog used to open anyway.
|
||||
- A service error shown as **Action could not be completed** no longer starts with a space. A sign-out reply
|
||||
that is a document without a message, or with an incomplete identity-provider part, is treated as unreadable
|
||||
and never shown as text (it is the reply that carries the identity provider's token hint). A timestamp without
|
||||
an offset is read as UTC, not as this computer's local time; the service writes offsets, so this is a safeguard.
|
||||
A log-settings reply of JSON `null` shows **Log settings unavailable** instead of doing nothing.
|
||||
|
||||
Activity log changes: the first failed request to the local service per operation is a warning that names the
|
||||
cause and whether the request was sent, the same failure repeating is a debug line, each failure keeps its full
|
||||
exception as debug detail, and the first answer afterwards is noted once, so the status polls do not fill the log.
|
||||
An answer that arrived and could not be read is logged as that, not as a request that did not complete. Discovery
|
||||
logs the entered address as scheme, host, port and path only, and does not log an address it cannot read. A
|
||||
hand-over, a sign-out or an enrollment that left without a usable answer is logged as unconfirmed with the same
|
||||
sentence the operator sees; a browser sign-in is logged as cancelled only when nothing was handed over, and the log
|
||||
no longer claims that no callback arrived when one did. A refused operation is a warning that names it. An
|
||||
operation ended by closing the window or by its caller is one debug line. "Print device enrollment completed." now
|
||||
carries the `[Companion]` prefix.
|
||||
|
||||
## 1.2.1
|
||||
|
||||
Release-pipeline fix only; same source as 1.2.0 otherwise. The stable release workflow's per-edition
|
||||
|
||||
@@ -12,14 +12,15 @@ Policy lives in [AGENTS.md](AGENTS.md) (repository rules: hosting split, authori
|
||||
|
||||
## 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.
|
||||
**Host note first.** On some machines Windows Defender's heuristics quarantine the compiled `Qqq.PrintAgent.Core.Tests` assembly. Where the owner has said `Core.Tests` may be built, `build/Verify.ps1` runs in full. Anywhere else, build `Qqq.PrintAgent.NativeCI.slnx` (the full solution minus `Core.Tests`) and take Core evidence from CI. How a machine's protection is configured is the owner's decision and is not recorded in this repository: if a `Core.Tests` build is quarantined, stop and ask rather than adding an exclusion or restoring the file.
|
||||
|
||||
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.
|
||||
Quick local loop without `Core.Tests` (locked restore, Release x64 build with `-warnaserror`, Windows/CLI and presentation 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
|
||||
dotnet test tests/Qqq.PrintAgent.Presentation.Tests -c Release -p:Platform=x64 --no-restore
|
||||
```
|
||||
|
||||
Single test (xunit):
|
||||
@@ -28,16 +29,19 @@ Single test (xunit):
|
||||
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):
|
||||
Full verification (adds `Core.Tests`, the coverage gate and TRX output under `TestResults/verify-<guid>`). It needs the protocol fixtures at the commit pinned in `.github/workflows/ci.yml`: either the sibling checkout at that commit, or an export of it passed with `-ProtocolFixtures`:
|
||||
|
||||
```powershell
|
||||
./build/Verify.ps1
|
||||
git -C ../qqq-print-protocol archive <pinned-sha> fixtures | tar -x -C <dir>; ./build/Verify.ps1 -ProtocolFixtures <dir>/fixtures
|
||||
```
|
||||
|
||||
The gate (`build/Assert-Coverage.ps1`) merges the three suites' reports line by line and fails when an assembly is below its minimum in `build/minimum-coverage.json`. Minimums only move up, in the change that adds the tests; see `docs/adr/0003-version-2.md` for the release bar.
|
||||
|
||||
Packaging (unsigned self-contained x64 MSI; never installs or prints):
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
./build/Build-Installer.ps1 # version from Directory.Build.props; -ReleaseVersion X.Y.Z-rc.N for a test build
|
||||
./build/Package-Release.ps1 # runs the CI packaging checks and assembles artifacts/release
|
||||
```
|
||||
|
||||
@@ -60,15 +64,16 @@ Pre-commit hooks (`pre-commit install --hook-type pre-commit --hook-type commit-
|
||||
|
||||
## Architecture
|
||||
|
||||
Five projects, strict layering (Core → Windows → Service/UI/CLI). Business logic never lives above Core; OS calls never live in Core.
|
||||
Six projects, strict layering (Core → Windows → Presentation → UI, and Core → Windows → Service/CLI). Business logic never lives above Core; OS calls never live in Core; the companion's decisions live in Presentation, not in its views.
|
||||
|
||||
| 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` |
|
||||
| `Qqq.PrintAgent.Presentation` | The companion's logic without a window: the typed client for the service pipe, what each view shows and enables, Jobs paging and sorting state, the sign-in flow as a state machine. UI-free and held at 100% line coverage | `Service/ServiceClient*.cs`, `SignIn/SignInFlow*.cs`, `Shell/`, `Jobs/`, `Printers/`, `Settings/` |
|
||||
| `Qqq.PrintAgent` | WinUI/AppKit companion: tray, Printers/Jobs/Logs/Settings views, MSI update UX. Views call the presentation assembly and hold no decisions | `MainWindow.*.cs` (partial class split by concern) |
|
||||
| `Qqq.PrintAgent.Cli` | Service management over the pipe, nothing else; the same executable is also the MSI update helper and the browser sign-in callback forwarder | `CommandLine.cs` (`Program.cs` only binds the real console, pipe and helpers) |
|
||||
|
||||
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`.
|
||||
|
||||
@@ -76,14 +81,17 @@ Large classes are deliberately split into partial files by concern (`AgentRuntim
|
||||
|
||||
**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.
|
||||
|
||||
**One composition.** Core has only the shape the service builds; do not add optional paths beside it. `PrintWorker` is one lane and everything in `PrintWorkerOptions` except the clock is required (the runtime, not the worker, recovers unfinished jobs and sends inventory and heartbeats). `IPrinterBackend` is three reads plus `PrintFileAsync(job, path, submissionTimeout, token)`: a document is always a verified staged file, and wrappers forward it. The reads are named for what they cost Windows: `Discover` reads every queue's settings (inventory only), `ListWithoutMedia` reads none (before the claim, cancellations, test print) and `FindWithMedia` reads one queue's (the recheck after the download, and inside the print). A print job must never call `Discover`: one queue whose driver hangs would stop every job. `PrintApiClient` is built only by `ForSession`; inventory, job events and command receipts go only over the realtime channel.
|
||||
|
||||
**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.Core.Tests`: protocol fixtures (`ProtocolFixtureTests`), loopback WebSocket peer, worker state machine, media contracts. Worker tests compose the worker as the service does through `WorkerHarness`; an API client over a test's own fake server comes from `FakeSessionClient.ApiOver`. New Core tests are written here (see the host note under Commands for where it builds).
|
||||
- `tests/Qqq.PrintAgent.Cli.Tests`: despite the name, this is mostly the Windows runtime suite (`AgentRuntime*`, `AgentRealtime*`, `AgentPipe*`, `AgentStore*`). Put Windows-layer tests here. The command-line tests are its `Cli*Tests.cs` files: they drive `CommandLine` through `CliFixture` (fakes for console, environment, pipe and helpers), and `CliProcessTests` starts the built executable.
|
||||
- `tests/Qqq.PrintAgent.Presentation.Tests`: the presentation assembly's suite, on hand-written fakes under `Fakes/` (`SignInFixture`, `FakeSession`, `FakeInbox`, `PumpContext`, `ManualTime`); no pipe, browser or network. Every line added to the assembly needs a test here, because its minimum is 100.
|
||||
- `tests/Qqq.PrintAgent.WorkerFixture`: child-process fixture used by runtime tests.
|
||||
|
||||
## Conventions worth knowing
|
||||
@@ -91,5 +99,6 @@ Large classes are deliberately split into partial files by concern (`AgentRuntim
|
||||
- `.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`.
|
||||
- Coverage is gated per assembly (`docs/adr/0003-version-2.md`): never lower a minimum in `build/minimum-coverage.json`, and raise it with the tests that earn it.
|
||||
- 2.0 work lands as pull requests against `release/2.0`; `main` stays 1.2.x until the release.
|
||||
- `docs/SESSION-STATE.md` records the current release/verification state; consult it before claiming anything is verified.
|
||||
+5
-5
@@ -4,21 +4,21 @@ QQQ Print Agent is developed in the [QRun-IO organization on Gitea](https://git.
|
||||
|
||||
## 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` (the `ref` of its "Checkout pinned protocol fixtures" step); its JSON fixtures are linked into the contract tests. To leave an existing protocol checkout where it is, export that commit's `fixtures` folder elsewhere and pass it with `./build/Verify.ps1 -ProtocolFixtures <dir>`.
|
||||
|
||||
```powershell
|
||||
./build/Verify.ps1
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
./build/Build-Installer.ps1
|
||||
```
|
||||
|
||||
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.
|
||||
Verification uses locked NuGet restore, a warnings-as-errors solution build, the three unit suites (`tests/Qqq.PrintAgent.Core.Tests`, `tests/Qqq.PrintAgent.Cli.Tests` for the Windows layer and CLI, `tests/Qqq.PrintAgent.Presentation.Tests` for the companion's presentation assembly) and a coverage gate. 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.
|
||||
- Keep business/protocol logic in Core, Windows integration in Windows, hosting in Service, what the companion's views decide and say in `Qqq.PrintAgent.Presentation` (no WinUI reference, tested in `tests/Qqq.PrintAgent.Presentation.Tests`), and only view/tray plumbing in the AppKit companion.
|
||||
- Use central versions in `Directory.Packages.props`; regenerate affected `packages.lock.json` files when dependencies change.
|
||||
- Follow `.editorconfig`: three-space indentation, Allman braces and the repository's C# analyzers. Builds must have zero warnings.
|
||||
- Add tests for behavior and failure boundaries. Run the protocol fixtures when changing the wire contract.
|
||||
@@ -26,7 +26,7 @@ Publish ordinary branches and pull requests to the [primary Gitea repository](ht
|
||||
- Do not include secrets, document contents, customer labels or local journals in issues, fixtures or commits.
|
||||
- Disclose substantial AI assistance in the PR and include a model-naming Co-Authored-By trailer when appropriate.
|
||||
|
||||
For the initial prerelease, coverage is reported rather than forced to the template's unattained 100% target. This is an explicit temporary exception documented in [ADR-0002](docs/adr/0002-prerelease-quality-and-packaging.md), not a claim of full coverage. Elevated desktop tests are separate from hosted CI because they need an interactive administrator session.
|
||||
Line coverage is gated per assembly ([ADR-0003](docs/adr/0003-version-2.md)). `build/Verify.ps1` merges the suites' reports and fails when an assembly is below its minimum in `build/minimum-coverage.json`. The minimums are a floor that only moves up: when a change adds tests, raise the entry in the same change to the measured percentage rounded down, less one point (a few timing-dependent lines differ between runs); never lower one to make a change pass. An assembly that has reached its release bar is recorded at the bar, with no point of slack: that is why Core, the CLI and the presentation assembly stand at 100. The Windows layer's bar is 90, and the layer is above it: there the minimum keeps following the rule, and it never drops below 90. Every line added to `src/Qqq.PrintAgent.Presentation` needs a test in `tests/Qqq.PrintAgent.Presentation.Tests` that asserts its behavior, with hand-written fakes, an injected clock and no real pipe (quick loop: `dotnet test tests/Qqq.PrintAgent.Presentation.Tests -c Release -p:Platform=x64`). The release bar is 100% for Core, the presentation assembly and the CLI and 90% for the Windows layer. Every assembly is at or above its bar: the Windows layer's minimum is 95, set when it measured between 96.06% and 96.14% over four runs (4,539 to 4,543 of 4,725 lines). A green gate therefore means "at or above the release bar", which for the Windows layer is not "fully covered". Code a test cannot reach (native calls, process hooks) goes in a thin adapter marked `[ExcludeFromCodeCoverage(Justification = "...")]`, with the decisions around it kept in covered code. Elevated desktop tests are separate from hosted CI because they need an interactive administrator session.
|
||||
|
||||
## UI and hardware tests
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
|
||||
CLI's version output show as a short hash. -->
|
||||
<PropertyGroup>
|
||||
<VersionPrefix>1.2.1</VersionPrefix>
|
||||
<VersionPrefix>2.0.0</VersionPrefix>
|
||||
<VersionSuffix></VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
<Platform Solution="*|x86" Project="x86" />
|
||||
</Project>
|
||||
<Project Path="src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj" />
|
||||
<Project Path="src/Qqq.PrintAgent.Presentation/Qqq.PrintAgent.Presentation.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.Presentation.Tests/Qqq.PrintAgent.Presentation.Tests.csproj" />
|
||||
<Project Path="tests/Qqq.PrintAgent.UiTests/Qqq.PrintAgent.UiTests.csproj" />
|
||||
<Project Path="tests/Qqq.PrintAgent.WorkerFixture/Qqq.PrintAgent.WorkerFixture.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<Platform Solution="*|x86" Project="x86" />
|
||||
</Project>
|
||||
<Project Path="src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj" />
|
||||
<Project Path="src/Qqq.PrintAgent.Presentation/Qqq.PrintAgent.Presentation.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" />
|
||||
@@ -18,6 +19,7 @@
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Qqq.PrintAgent.Core.Tests/Qqq.PrintAgent.Core.Tests.csproj" />
|
||||
<Project Path="tests/Qqq.PrintAgent.Cli.Tests/Qqq.PrintAgent.Cli.Tests.csproj" />
|
||||
<Project Path="tests/Qqq.PrintAgent.Presentation.Tests/Qqq.PrintAgent.Presentation.Tests.csproj" />
|
||||
<Project Path="tests/Qqq.PrintAgent.UiTests/Qqq.PrintAgent.UiTests.csproj" />
|
||||
<Project Path="tests/Qqq.PrintAgent.WorkerFixture/Qqq.PrintAgent.WorkerFixture.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -87,7 +87,7 @@ git clone https://git.kof22.com/QRun-IO/qqq-print-protocol.git
|
||||
git -C qqq-print-protocol checkout 42c0d249f81395f00408ec0f92638dcbbf5deb03
|
||||
Set-Location qqq-print-agent
|
||||
./build/Verify.ps1
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.2.1 -ReleaseVersion 1.2.1
|
||||
./build/Build-Installer.ps1
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -31,6 +31,52 @@ BeforeAll `
|
||||
"@
|
||||
Set-Content -LiteralPath (Join-Path $Directory 'coverage.cobertura.xml') -Value $report
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes one suite's report for a four-line file, covering only the named lines.
|
||||
#>
|
||||
function Write-SuiteReport
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Directory,
|
||||
[Parameter(Mandatory)] [string] $Suite,
|
||||
[Parameter(Mandatory)] [string] $Package,
|
||||
[Parameter(Mandatory)] [AllowEmptyCollection()] [int[]] $CoveredLines,
|
||||
[string] $FileName = 'Shared.cs',
|
||||
[string[]] $SourceRoot = @()
|
||||
)
|
||||
|
||||
$suiteDirectory = Join-Path $Directory $Suite
|
||||
New-Item -ItemType Directory -Path $suiteDirectory | Out-Null
|
||||
$lines = (1..4 | ForEach-Object { '<line number="{0}" hits="{1}" />' -f $_, [int] ($_ -in $CoveredLines) }) -join ''
|
||||
$sources = if ($SourceRoot.Count -gt 0) { '<sources>' + (($SourceRoot | ForEach-Object { "<source>$_</source>" }) -join '') + '</sources>' } else { '' }
|
||||
$report = @"
|
||||
<coverage>$sources<packages><package name="$Package" line-rate="0"><classes>
|
||||
<class filename="$FileName"><lines>$lines</lines></class>
|
||||
</classes></package></packages></coverage>
|
||||
"@
|
||||
Set-Content -LiteralPath (Join-Path $suiteDirectory 'coverage.cobertura.xml') -Value $report
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes a thresholds file and returns its path.
|
||||
#>
|
||||
function Write-Threshold
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Directory,
|
||||
[Parameter(Mandatory)] [string] $Json
|
||||
)
|
||||
|
||||
$path = Join-Path $Directory 'thresholds.json'
|
||||
Set-Content -LiteralPath $path -Value $Json
|
||||
return $path
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Required coverage gate' `
|
||||
@@ -71,4 +117,159 @@ Describe 'Required coverage gate' `
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
$output | Should -Match 'Coverage gate passed'
|
||||
}
|
||||
|
||||
It 'counts a line as covered when either suite executed it' `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'unit' -Package 'Probe' -CoveredLines 1, 2
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'integration' -Package 'Probe' -CoveredLines 3, 4
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
$output | Should -Match 'Probe line coverage: 100% \(4 of 4 lines'
|
||||
}
|
||||
|
||||
It 'reports the lines no suite reached as ranges' `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'unit' -Package 'Probe' -CoveredLines 1
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'integration' -Package 'Probe' -CoveredLines @()
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match 'uncovered: Shared.cs lines 2-4'
|
||||
}
|
||||
|
||||
It 'holds each package to its own minimum from the thresholds file' `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'core' -Package 'Strict' -CoveredLines 1, 2, 3
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'windows' -Package 'Lenient' -CoveredLines 1, 2, 3
|
||||
$thresholds = Write-Threshold -Directory $TestDrive -Json '{ "Strict": 100, "Lenient": 75 }'
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -ThresholdsPath $thresholds 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match 'Coverage gate FAILED: Strict is below 100%'
|
||||
$output | Should -Not -Match 'Coverage gate FAILED: Lenient'
|
||||
}
|
||||
|
||||
It 'accepts packages that meet their own minimums' `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'windows' -Package 'Lenient' -CoveredLines 1, 2, 3
|
||||
$thresholds = Write-Threshold -Directory $TestDrive -Json '{ "Lenient": 75 }'
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -ThresholdsPath $thresholds 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
$output | Should -Match 'Lenient line coverage: 75% \(3 of 4 lines; required 75%\)'
|
||||
}
|
||||
|
||||
It 'rejects a measured package the thresholds file does not list' `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'core' -Package 'Listed' -CoveredLines 1, 2, 3, 4
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'new' -Package 'Unlisted' -CoveredLines 1, 2, 3, 4
|
||||
$thresholds = Write-Threshold -Directory $TestDrive -Json '{ "Listed": 100 }'
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -ThresholdsPath $thresholds 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match "Package 'Unlisted' is measured but has no entry"
|
||||
}
|
||||
|
||||
It 'rejects a listed package that no report measured' `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'core' -Package 'Listed' -CoveredLines 1, 2, 3, 4
|
||||
$thresholds = Write-Threshold -Directory $TestDrive -Json '{ "Listed": 100, "Gone": 100 }'
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -ThresholdsPath $thresholds 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match "Package 'Gone' was not found"
|
||||
}
|
||||
|
||||
It 'reports separate gaps as separate ranges' `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'unit' -Package 'Probe' -CoveredLines 2
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match 'uncovered: Shared.cs lines 1, 3-4'
|
||||
}
|
||||
|
||||
It 'merges one file that two reports name relative to different source roots' `
|
||||
{
|
||||
$tree = Join-Path $TestDrive ([guid]::NewGuid().ToString())
|
||||
New-Item -ItemType Directory -Path (Join-Path $tree 'src/Core') | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $tree 'src/Core/Worker.cs') -Value '// synthetic'
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'unit' -Package 'Probe' -CoveredLines 1, 2 -FileName 'Worker.cs' -SourceRoot (Join-Path $tree 'src/Core/')
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'integration' -Package 'Probe' -CoveredLines 3 -FileName 'Core/Worker.cs' -SourceRoot (Join-Path $tree 'src/')
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe -MinimumPercent 75 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
$output | Should -Match 'Probe line coverage: 75% \(3 of 4 lines'
|
||||
}
|
||||
|
||||
It 'rejects a report whose source file is not on disk under its source roots' `
|
||||
{
|
||||
$tree = Join-Path $TestDrive ([guid]::NewGuid().ToString())
|
||||
New-Item -ItemType Directory -Path $tree | Out-Null
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'unit' -Package 'Probe' -CoveredLines 1, 2, 3, 4 -FileName 'Moved.cs' -SourceRoot $tree
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match "Source file 'Moved.cs' was found 0 times"
|
||||
}
|
||||
|
||||
It 'rejects a report whose source file exists under more than one of its source roots' `
|
||||
{
|
||||
$tree = Join-Path $TestDrive ([guid]::NewGuid().ToString())
|
||||
foreach ($root in 'first', 'second')
|
||||
{
|
||||
New-Item -ItemType Directory -Path (Join-Path $tree $root) | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $tree "$root/Twin.cs") -Value '// synthetic'
|
||||
}
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'unit' -Package 'Probe' -CoveredLines 1, 2, 3, 4 -FileName 'Twin.cs' -SourceRoot (Join-Path $tree 'first'), (Join-Path $tree 'second')
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match "Source file 'Twin.cs' was found 2 times"
|
||||
}
|
||||
|
||||
It 'rejects a report that is <Case>' -ForEach @(
|
||||
@{ Case = 'not XML'; Content = 'not xml' }
|
||||
@{ Case = 'empty'; Content = '' }
|
||||
@{ Case = 'XML without a coverage element'; Content = '<other />' }
|
||||
) `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'good' -Package 'Probe' -CoveredLines 1, 2, 3, 4
|
||||
New-Item -ItemType Directory -Path (Join-Path $script:ReportsDirectory 'bad') | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $script:ReportsDirectory 'bad/coverage.cobertura.xml') -Value $Content
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match 'is not usable'
|
||||
}
|
||||
|
||||
It 'reports its own result when called in-process after another command failed' `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'unit' -Package 'Probe' -CoveredLines 1, 2, 3, 4
|
||||
& pwsh -NoProfile -Command 'exit 3'
|
||||
$LASTEXITCODE | Should -Be 3
|
||||
$output = (& $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
$output | Should -Match 'Coverage gate passed'
|
||||
}
|
||||
|
||||
It 'refuses a thresholds file together with a uniform minimum' `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'core' -Package 'Listed' -CoveredLines 1, 2, 3, 4
|
||||
$thresholds = Write-Threshold -Directory $TestDrive -Json '{ "Listed": 50 }'
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -ThresholdsPath $thresholds -MinimumPercent 100 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Not -Be 0
|
||||
$output | Should -Not -Match 'Coverage gate passed'
|
||||
}
|
||||
|
||||
It 'rejects a thresholds file that is <Case>' -ForEach @(
|
||||
@{ Case = 'not JSON'; Json = 'not json' }
|
||||
@{ Case = 'empty'; Json = '{}' }
|
||||
@{ Case = 'out of range'; Json = '{ "Listed": 101 }' }
|
||||
@{ Case = 'not a number'; Json = '{ "Listed": "high" }' }
|
||||
@{ Case = 'null for a package'; Json = '{ "Listed": null }' }
|
||||
@{ Case = 'an empty string for a package'; Json = '{ "Listed": "" }' }
|
||||
@{ Case = 'false for a package'; Json = '{ "Listed": false }' }
|
||||
@{ Case = 'the text NaN for a package'; Json = '{ "Listed": "NaN" }' }
|
||||
@{ Case = 'listing a package twice'; Json = '{ "Listed": 100, "Listed": 0 }' }
|
||||
@{ Case = 'listing a package twice in different case'; Json = '{ "Listed": 100, "listed": 0 }' }
|
||||
@{ Case = 'a list rather than an object'; Json = '[ 100 ]' }
|
||||
) `
|
||||
{
|
||||
Write-SuiteReport -Directory $script:ReportsDirectory -Suite 'core' -Package 'Listed' -CoveredLines 1, 2, 3, 4
|
||||
$thresholds = Write-Threshold -Directory $TestDrive -Json $Json
|
||||
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -ThresholdsPath $thresholds 2>&1) -join "`n"
|
||||
$LASTEXITCODE | Should -Be 1
|
||||
$output | Should -Match 'is not usable'
|
||||
}
|
||||
}
|
||||
+220
-35
@@ -4,32 +4,143 @@
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Fails when any named assembly is below the required line coverage.
|
||||
Fails when any gated assembly is below its required line coverage.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads every coverage.cobertura.xml under -ResultsDirectory (one per test project
|
||||
run) and, for each package, takes the best line rate any report measured. A package
|
||||
can be split across test projects, and the report that exercises it most is
|
||||
authoritative. Without -Packages, every package the reports measured is gated:
|
||||
coverage.runsettings already limits measurement to the assemblies held to the bar.
|
||||
Exits 1 on any shortfall, or when a package named in -Packages is missing.
|
||||
run) and merges them line by line: a line counts as covered when any test project
|
||||
executed it. An assembly exercised by two suites is therefore measured once, on
|
||||
the union of what they reach, rather than by whichever suite happens to reach
|
||||
more or by an average of the two. Point -ResultsDirectory at the folder of one
|
||||
verification run; reports of older runs in the same folder would be merged too.
|
||||
|
||||
With -ThresholdsPath, the JSON file maps each gated package to its minimum
|
||||
percentage, and a measured package the file does not list fails the gate, so a
|
||||
new assembly cannot ship ungated. Without it, -Packages (or every measured
|
||||
package) is held to -MinimumPercent.
|
||||
|
||||
Run the gate on the machine and checkout that produced the reports. Each report
|
||||
names files relative to its own source roots, and the merge matches a file across
|
||||
reports by finding it on disk; a file that cannot be found, or is found under more
|
||||
than one root, fails the gate rather than being counted twice or not at all.
|
||||
|
||||
Exits 1 on any shortfall, a missing or unlisted package, an unusable report or an
|
||||
unusable thresholds file.
|
||||
|
||||
.EXAMPLE
|
||||
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults
|
||||
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults/verify-0123abcd -ThresholdsPath build/minimum-coverage.json
|
||||
|
||||
.EXAMPLE
|
||||
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages Qqq.PrintAgent.Core
|
||||
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults/verify-0123abcd -Packages Qqq.PrintAgent.Core
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(DefaultParameterSetName = 'Uniform')]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $ResultsDirectory,
|
||||
[string[]] $Packages = @(),
|
||||
[ValidateRange(0, 100)] [double] $MinimumPercent = 100
|
||||
[Parameter(ParameterSetName = 'Uniform')] [string[]] $Packages = @(),
|
||||
[Parameter(ParameterSetName = 'Uniform')] [ValidateRange(0, 100)] [double] $MinimumPercent = 100,
|
||||
[Parameter(Mandatory, ParameterSetName = 'PerPackage')] [string] $ThresholdsPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reads the per-package minimums, accepting only an object of in-range numbers.
|
||||
|
||||
.DESCRIPTION
|
||||
A lenient reader would turn null, an empty string or false into a minimum of zero
|
||||
and let a repeated name override an earlier one, each of which silently switches
|
||||
the gate off for that package. Anything but a JSON number between 0 and 100 under
|
||||
a name that appears once (ignoring case) is refused.
|
||||
#>
|
||||
function Read-Threshold
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([System.Collections.Generic.Dictionary[string, double]])]
|
||||
param([Parameter(Mandatory)] [string] $Path)
|
||||
|
||||
$document = [System.Text.Json.JsonDocument]::Parse((Get-Content -LiteralPath $Path -Raw))
|
||||
try
|
||||
{
|
||||
if ($document.RootElement.ValueKind -ne [System.Text.Json.JsonValueKind]::Object)
|
||||
{
|
||||
throw 'it must hold one JSON object of package names and percentages.'
|
||||
}
|
||||
$minimums = [System.Collections.Generic.Dictionary[string, double]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($property in $document.RootElement.EnumerateObject())
|
||||
{
|
||||
if ($minimums.ContainsKey($property.Name)) { throw "'$($property.Name)' is listed more than once." }
|
||||
if ($property.Value.ValueKind -ne [System.Text.Json.JsonValueKind]::Number) { throw "'$($property.Name)' must be a number." }
|
||||
$value = $property.Value.GetDouble()
|
||||
if ($value -lt 0 -or $value -gt 100) { throw "'$($property.Name)' must be between 0 and 100." }
|
||||
$minimums[$property.Name] = $value
|
||||
}
|
||||
if ($minimums.Count -eq 0) { throw 'no packages are listed.' }
|
||||
return $minimums
|
||||
}
|
||||
finally
|
||||
{
|
||||
$document.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Names a source file the same way in every report.
|
||||
|
||||
.DESCRIPTION
|
||||
Each test run records file names relative to its own source roots, so the same
|
||||
file can appear as "Printing/Worker.cs" in one report and "Core/Printing/Worker.cs"
|
||||
in another. The file's full path on disk is the key. A report without source roots
|
||||
(a synthetic one) keeps the recorded name. A report with roots whose file is found
|
||||
under none of them, or under more than one, cannot be merged correctly and throws.
|
||||
#>
|
||||
function Resolve-SourceKey
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $Roots,
|
||||
[Parameter(Mandatory)] [string] $FileName
|
||||
)
|
||||
|
||||
if ($Roots.Count -eq 0) { return $FileName.Replace('\', '/') }
|
||||
$candidates = if ([System.IO.Path]::IsPathRooted($FileName)) { @($FileName) } else { @($Roots | ForEach-Object { Join-Path $_ $FileName }) }
|
||||
$found = @($candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | ForEach-Object { (Resolve-Path -LiteralPath $_).Path } | Sort-Object -Unique)
|
||||
if ($found.Count -ne 1)
|
||||
{
|
||||
throw "Source file '$FileName' was found $($found.Count) times under the report's source roots ($($Roots -join '; ')). Run the gate on the machine and checkout that produced the reports."
|
||||
}
|
||||
return $found[0]
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Turns sorted line numbers into compact ranges such as "12-18, 40".
|
||||
#>
|
||||
function Format-LineRange
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([Parameter(Mandatory)] [int[]] $Numbers)
|
||||
|
||||
$ranges = [System.Collections.Generic.List[string]]::new()
|
||||
$start = $Numbers[0]
|
||||
$previous = $Numbers[0]
|
||||
foreach ($number in ($Numbers | Select-Object -Skip 1))
|
||||
{
|
||||
if ($number -ne $previous + 1)
|
||||
{
|
||||
$ranges.Add($(if ($start -eq $previous) { "$start" } else { "$start-$previous" }))
|
||||
$start = $number
|
||||
}
|
||||
$previous = $number
|
||||
}
|
||||
$ranges.Add($(if ($start -eq $previous) { "$start" } else { "$start-$previous" }))
|
||||
return $ranges -join ', '
|
||||
}
|
||||
|
||||
# `pwsh -File` passes "A,B" as one string; accept that as well as a real array.
|
||||
$Packages = @($Packages | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
|
||||
@@ -40,50 +151,124 @@ if ($files.Count -eq 0)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$reports = foreach ($file in $files) { [xml](Get-Content -LiteralPath $file.FullName -Raw) }
|
||||
if ($Packages.Count -eq 0)
|
||||
$thresholds = [System.Collections.Generic.Dictionary[string, double]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
if ($ThresholdsPath)
|
||||
{
|
||||
$Packages = @($reports | ForEach-Object { @($_.coverage.packages.package) } | ForEach-Object { $_.name } | Where-Object { $_ } | Sort-Object -Unique)
|
||||
if ($Packages.Count -eq 0)
|
||||
try
|
||||
{
|
||||
Write-Error 'The coverage reports measured no packages; check the Include filter in coverage.runsettings.' -ErrorAction Continue
|
||||
$thresholds = Read-Threshold -Path $ThresholdsPath
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Error "Coverage thresholds file '$ThresholdsPath' is not usable: $($_.Exception.Message)" -ErrorAction Continue
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
$required = $MinimumPercent / 100
|
||||
$failed = $false
|
||||
|
||||
foreach ($name in $Packages)
|
||||
###########################################################################
|
||||
# measured[package][file][line] is true once any report executed the line #
|
||||
###########################################################################
|
||||
$measured = @{}
|
||||
foreach ($file in $files)
|
||||
{
|
||||
$rates = foreach ($doc in $reports)
|
||||
try
|
||||
{
|
||||
$package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
|
||||
if ($package) { [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture) }
|
||||
$document = [xml](Get-Content -LiteralPath $file.FullName -Raw)
|
||||
if ($null -eq $document -or $null -eq $document.SelectSingleNode('/coverage')) { throw 'it has no coverage element.' }
|
||||
$roots = @($document.SelectNodes('/coverage/sources/source') | ForEach-Object { $_.InnerText } | Where-Object { $_ })
|
||||
foreach ($package in $document.SelectNodes('/coverage/packages/package'))
|
||||
{
|
||||
$name = $package.GetAttribute('name')
|
||||
if (-not $measured.ContainsKey($name)) { $measured[$name] = @{} }
|
||||
foreach ($class in $package.SelectNodes('classes/class'))
|
||||
{
|
||||
$key = Resolve-SourceKey -Roots $roots -FileName $class.GetAttribute('filename')
|
||||
if (-not $measured[$name].ContainsKey($key)) { $measured[$name][$key] = @{} }
|
||||
$lines = $measured[$name][$key]
|
||||
foreach ($line in $class.SelectNodes('lines/line'))
|
||||
{
|
||||
$number = [int] $line.GetAttribute('number')
|
||||
$hit = [long] $line.GetAttribute('hits') -gt 0
|
||||
$lines[$number] = $hit -or ($lines.ContainsKey($number) -and $lines[$number])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $rates)
|
||||
catch
|
||||
{
|
||||
Write-Error "Package '$name' not found in any coverage report." -ErrorAction Continue
|
||||
Write-Error "Coverage report '$($file.FullName)' is not usable: $($_.Exception.Message)" -ErrorAction Continue
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($thresholds.Count -gt 0)
|
||||
{
|
||||
$gated = @($thresholds.Keys | Sort-Object)
|
||||
}
|
||||
elseif ($Packages.Count -gt 0)
|
||||
{
|
||||
$gated = $Packages
|
||||
}
|
||||
else
|
||||
{
|
||||
$gated = @($measured.Keys | Sort-Object)
|
||||
if ($gated.Count -eq 0)
|
||||
{
|
||||
Write-Error 'The coverage reports measured no packages; check the filters in coverage.runsettings.' -ErrorAction Continue
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$failed = $false
|
||||
if ($thresholds.Count -gt 0)
|
||||
{
|
||||
foreach ($name in @($measured.Keys | Sort-Object))
|
||||
{
|
||||
if (-not $thresholds.ContainsKey($name))
|
||||
{
|
||||
Write-Error "Package '$name' is measured but has no entry in '$ThresholdsPath'. Add its minimum, or exclude it from measurement in coverage.runsettings." -ErrorAction Continue
|
||||
$failed = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($name in $gated)
|
||||
{
|
||||
$required = if ($thresholds.ContainsKey($name)) { $thresholds[$name] } else { $MinimumPercent }
|
||||
$total = 0
|
||||
$covered = 0
|
||||
if ($measured.ContainsKey($name))
|
||||
{
|
||||
foreach ($lines in $measured[$name].Values)
|
||||
{
|
||||
$total += $lines.Count
|
||||
$covered += @($lines.Values | Where-Object { $_ }).Count
|
||||
}
|
||||
}
|
||||
if ($total -eq 0)
|
||||
{
|
||||
$reason = if ($measured.ContainsKey($name)) { 'has no measurable lines in any coverage report' } else { 'was not found in any coverage report' }
|
||||
Write-Error "Package '$name' $reason." -ErrorAction Continue
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
|
||||
$best = ($rates | Measure-Object -Maximum).Maximum
|
||||
Write-Output ("{0} line coverage: {1}% (required {2}%)" -f $name, [math]::Round($best * 100, 2), $MinimumPercent)
|
||||
if ($best -lt $required)
|
||||
$percent = 100 * $covered / $total
|
||||
# Shown rounded down, so a value just under the minimum never prints as meeting it.
|
||||
$shown = [math]::Floor($percent * 100) / 100
|
||||
Write-Output ("{0} line coverage: {1}% ({2} of {3} lines; required {4}%)" -f $name, $shown, $covered, $total, $required)
|
||||
if ($percent -lt $required)
|
||||
{
|
||||
foreach ($doc in $reports)
|
||||
foreach ($key in @($measured[$name].Keys | Sort-Object))
|
||||
{
|
||||
$package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
|
||||
foreach ($class in @($package.classes.class))
|
||||
{
|
||||
$missed = @($class.lines.line) | Where-Object { $_.hits -eq '0' } | ForEach-Object { $_.number }
|
||||
if ($missed) { Write-Output " uncovered: $($class.filename) lines $($missed -join ', ')" }
|
||||
}
|
||||
$missed = @($measured[$name][$key].GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { [int] $_.Key } | Sort-Object)
|
||||
if ($missed.Count -gt 0) { Write-Output " uncovered: $key lines $(Format-LineRange -Numbers $missed)" }
|
||||
}
|
||||
Write-Error "Coverage gate FAILED: $name is below $MinimumPercent%." -ErrorAction Continue
|
||||
Write-Error "Coverage gate FAILED: $name is below $required%." -ErrorAction Continue
|
||||
$failed = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed) { exit 1 }
|
||||
Write-Output "Coverage gate passed."
|
||||
exit 0
|
||||
@@ -6,19 +6,21 @@
|
||||
.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 shared workflow runs this after verification. A branch build names no version, so the branch's own source
|
||||
version is packaged; a tag build passes the tag's version. 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(
|
||||
# Left out, both are the reviewed source version, resolved by Package-Release.ps1.
|
||||
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
||||
[string] $ProductVersion = '1.2.1',
|
||||
[string] $ProductVersion,
|
||||
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
|
||||
[string] $ReleaseVersion = '1.2.1'
|
||||
[string] $ReleaseVersion
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
& (Join-Path $PSScriptRoot 'Package-Release.ps1') -ProductVersion $ProductVersion -ReleaseVersion $ReleaseVersion
|
||||
& (Join-Path $PSScriptRoot 'Package-Release.ps1') @PSBoundParameters
|
||||
@@ -4,10 +4,13 @@
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Left out, ProductVersion is the reviewed source version (Get-SourceVersion.ps1) and ReleaseVersion is
|
||||
# ProductVersion, so a caller that names no version cannot disagree with the source. Name
|
||||
# -ReleaseVersion X.Y.Z-rc.N for a test build.
|
||||
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
||||
[string] $ProductVersion = '1.0.1',
|
||||
[string] $ProductVersion,
|
||||
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
|
||||
[string] $ReleaseVersion = '1.0.1',
|
||||
[string] $ReleaseVersion,
|
||||
[string] $PayloadDirectory,
|
||||
[switch] $SkipPublish,
|
||||
[switch] $PublishOnly,
|
||||
@@ -22,6 +25,9 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$sourceVersion = & (Join-Path $PSScriptRoot 'Get-SourceVersion.ps1')
|
||||
if(-not $ProductVersion) { $ProductVersion = $sourceVersion }
|
||||
if(-not $ReleaseVersion) { $ReleaseVersion = $ProductVersion }
|
||||
if($ReleaseVersion.Split('-')[0] -cne $ProductVersion)
|
||||
{
|
||||
throw 'The MSI ProductVersion must match the numeric core of ReleaseVersion.'
|
||||
@@ -31,8 +37,7 @@ if($numericVersion.Major -gt 255 -or $numericVersion.Minor -gt 255 -or $numericV
|
||||
{
|
||||
throw 'ProductVersion exceeds the Windows Installer limits (255.255.65535).'
|
||||
}
|
||||
[xml]$versionProperties = Get-Content -LiteralPath (Join-Path $projectRoot 'Directory.Build.props') -Raw
|
||||
if($versionProperties.SelectSingleNode('/Project/PropertyGroup/VersionPrefix').InnerText -cne $ProductVersion)
|
||||
if($sourceVersion -cne $ProductVersion)
|
||||
{
|
||||
throw 'ProductVersion must match the reviewed source VersionPrefix in Directory.Build.props.'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prints the reviewed source version: VersionPrefix in Directory.Build.props.
|
||||
.DESCRIPTION
|
||||
The packaging scripts take their default version from here, so a caller that names no version cannot disagree with
|
||||
the source, and the next version bump is one edit. The value is refused, with the file named, when the file or the
|
||||
element is missing or when it is not a numeric X.Y.Z version: a parameter default is not checked by the parameter's
|
||||
own validation, so the check has to happen here.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[string] $PropertiesPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'Directory.Build.props')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
if(-not (Test-Path -LiteralPath $PropertiesPath -PathType Leaf))
|
||||
{
|
||||
throw "Cannot read the source version: $PropertiesPath does not exist."
|
||||
}
|
||||
[xml]$properties = Get-Content -LiteralPath $PropertiesPath -Raw
|
||||
$node = $properties.SelectSingleNode('/Project/PropertyGroup/VersionPrefix')
|
||||
if($null -eq $node -or [string]::IsNullOrWhiteSpace($node.InnerText))
|
||||
{
|
||||
throw "VersionPrefix not found in $PropertiesPath."
|
||||
}
|
||||
$version = $node.InnerText.Trim()
|
||||
if($version -cnotmatch '^\d+\.\d+\.\d+$')
|
||||
{
|
||||
throw "VersionPrefix '$version' in $PropertiesPath is not a numeric X.Y.Z version."
|
||||
}
|
||||
Write-Output $version
|
||||
@@ -4,8 +4,11 @@
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $ProductVersion = '1.2.1',
|
||||
[string] $ReleaseVersion = '1.2.1',
|
||||
# Left out, both are the reviewed source version (Get-SourceVersion.ps1); Build-Installer.ps1 checks them.
|
||||
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
||||
[string] $ProductVersion,
|
||||
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
|
||||
[string] $ReleaseVersion,
|
||||
# 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,
|
||||
@@ -17,6 +20,8 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
if(-not $ProductVersion) { $ProductVersion = & (Join-Path $PSScriptRoot 'Get-SourceVersion.ps1') }
|
||||
if(-not $ReleaseVersion) { $ReleaseVersion = $ProductVersion }
|
||||
|
||||
function Get-Edition
|
||||
{
|
||||
|
||||
@@ -8,8 +8,8 @@ param()
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$builder = Join-Path $PSScriptRoot 'Build-Installer.ps1'
|
||||
[xml]$properties = Get-Content -LiteralPath (Join-Path (Split-Path $PSScriptRoot -Parent) 'Directory.Build.props') -Raw
|
||||
$current = $properties.SelectSingleNode('/Project/PropertyGroup/VersionPrefix').InnerText
|
||||
$sourceVersion = Join-Path $PSScriptRoot 'Get-SourceVersion.ps1'
|
||||
$current = & $sourceVersion
|
||||
|
||||
function Assert-PackageRejected([string] $Product, [string] $Release, [string] $Expected)
|
||||
{
|
||||
@@ -23,6 +23,30 @@ function Assert-PackageRejected([string] $Product, [string] $Release, [string] $
|
||||
if($message -notlike "*$Expected*") { throw "Packaging did not reject the expected boundary: $Expected. Actual: $message" }
|
||||
}
|
||||
|
||||
function Assert-SourceVersionRejected([string] $Content, [string] $Expected)
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Confirm the source-version reader refuses a properties file it cannot take a numeric version from.
|
||||
#>
|
||||
$file = Join-Path ([IO.Path]::GetTempPath()) ('qqq-source-version-' + [Guid]::NewGuid().ToString('N') + '.props')
|
||||
$message = ''
|
||||
try
|
||||
{
|
||||
if($Content) { Set-Content -LiteralPath $file -Value $Content }
|
||||
try { & $sourceVersion -PropertiesPath $file | Out-Null }
|
||||
catch { $message = $_.Exception.Message }
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(Test-Path -LiteralPath $file -PathType Leaf) { Remove-Item -LiteralPath $file -Force }
|
||||
}
|
||||
if($message -notlike "*$Expected*") { throw "The source version reader did not refuse: $Expected. Actual: $message" }
|
||||
}
|
||||
|
||||
Assert-SourceVersionRejected -Content '' -Expected 'does not exist'
|
||||
Assert-SourceVersionRejected -Content '<Project><PropertyGroup /></Project>' -Expected 'VersionPrefix not found'
|
||||
Assert-SourceVersionRejected -Content '<Project><PropertyGroup><VersionPrefix>2.0.0-beta</VersionPrefix></PropertyGroup></Project>' -Expected 'not a numeric X.Y.Z version'
|
||||
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'
|
||||
|
||||
+5
-1
@@ -28,13 +28,17 @@ try
|
||||
if($LASTEXITCODE -ne 0) { throw 'Locked dependency restore failed.' }
|
||||
& dotnet build Qqq.PrintAgent.slnx --configuration Release '-p:Platform=x64' "-p:PrintingProtocolFixtures=$ProtocolFixtures" --no-restore -warnaserror
|
||||
if($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||
foreach($project in @('tests/Qqq.PrintAgent.Core.Tests', 'tests/Qqq.PrintAgent.Cli.Tests'))
|
||||
foreach($project in @('tests/Qqq.PrintAgent.Core.Tests', 'tests/Qqq.PrintAgent.Cli.Tests', 'tests/Qqq.PrintAgent.Presentation.Tests'))
|
||||
{
|
||||
# Solution mappings use AnyCPU for these projects; direct x64 test invocations
|
||||
# must build their own matching output instead of selecting a stale x64 DLL.
|
||||
& dotnet test $project --configuration Release '-p:Platform=x64' "-p:PrintingProtocolFixtures=$ProtocolFixtures" --no-restore --settings coverage.runsettings --logger trx --collect 'XPlat Code Coverage' --results-directory $resultsDirectory
|
||||
if($LASTEXITCODE -ne 0) { throw "Tests failed: $project" }
|
||||
}
|
||||
# Each assembly must stay at or above its recorded minimum (docs/adr/0003-version-2.md). The minimums
|
||||
# only move up: raise an entry in minimum-coverage.json in the change that adds the tests.
|
||||
& (Join-Path $PSScriptRoot 'Assert-Coverage.ps1') -ResultsDirectory $resultsDirectory -ThresholdsPath (Join-Path $PSScriptRoot 'minimum-coverage.json')
|
||||
if($LASTEXITCODE -ne 0) { throw 'Coverage gate failed.' }
|
||||
Write-Output "Current verification evidence: $resultsDirectory"
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Qqq.PrintAgent.Core": 100,
|
||||
"Qqq.PrintAgent.Presentation": 100,
|
||||
"Qqq.PrintAgent.Windows": 95,
|
||||
"qqq-print-agent": 100
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Coverage reporting for the prerelease exception in docs/adr/0002-prerelease-quality-and-packaging.md.
|
||||
This collection configuration does not enforce a 100% coverage gate.
|
||||
<!-- Coverage collection for the gate in docs/adr/0003-version-2.md. This file only decides what is
|
||||
measured; build/Assert-Coverage.ps1 enforces the per-assembly minimums in
|
||||
build/minimum-coverage.json, and build/Verify.ps1 runs it after the tests.
|
||||
There is deliberately no Include filter:
|
||||
coverlet measures the projects the unit tests reference whose sources are in this
|
||||
repository (including Core, Windows and CLI), never NuGet packages, and an Include
|
||||
@@ -9,9 +10,12 @@
|
||||
- GeneratedCodeAttribute: source-generated JSON contexts and MVVM Toolkit members.
|
||||
- ExcludeFromCodeCoverageAttribute: thin adapters a test cannot reach, each with a
|
||||
justification at its declaration.
|
||||
The WinUI project is not measured: its views need a live compositor. FlaUI acceptance
|
||||
- Qqq.PrintAgent.WorkerFixture: the child-process fixture the runtime tests launch. It
|
||||
is test support, not shipped code.
|
||||
The WinUI project is not measured: its views need a live compositor. What those views
|
||||
decide and say lives in Qqq.PrintAgent.Presentation, which is measured. FlaUI acceptance
|
||||
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. -->
|
||||
Each suite writes its own report; the gate merges them line by line and never averages. -->
|
||||
<RunSettings>
|
||||
<!-- A test run that executes zero tests (for example, a test assembly blocked from loading) must fail, not pass. -->
|
||||
<RunConfiguration>
|
||||
@@ -23,6 +27,7 @@
|
||||
<Configuration>
|
||||
<Format>cobertura</Format>
|
||||
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
|
||||
<Exclude>[Qqq.PrintAgent.WorkerFixture]*</Exclude>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
</DataCollectors>
|
||||
|
||||
+26
-1
@@ -24,10 +24,35 @@ Get-Service QqqPrintAgent, Spooler
|
||||
& 'C:\Program Files\QQQ Print Agent\Cli\qqq-print-agent.exe' service-status
|
||||
```
|
||||
|
||||
Use the actual install path if it differs. 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 actual install path if it differs. CLI commands and the companion use the authenticated installed service, so the printer list reflects the service identity, not the account that runs the command.
|
||||
|
||||
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.
|
||||
|
||||
## Command line
|
||||
|
||||
`qqq-print-agent.exe` manages the installed service and nothing else. Each command is one request over the administrator-only pipe, so run it from an elevated prompt with the service running. Sign in through the companion first: enrollment and the connection commands act on the service's QQQ session.
|
||||
|
||||
| Command | What the service does |
|
||||
| --- | --- |
|
||||
| `printers` | Lists the printers it can see, as JSON |
|
||||
| `enroll --server URL` | Pairs this computer with a QQQ server using a one-time enrollment code |
|
||||
| `expose --printer ID --exposed true\|false` | Shares a printer with QQQ, or stops sharing it |
|
||||
| `run` | Refreshes its connection to QQQ |
|
||||
| `sign-out` | Ends its QQQ session (the identity provider's browser session is ended only by Sign out in the companion) |
|
||||
| `service-status` | Reports its connection status |
|
||||
| `snapshot` | Reports its state as JSON: connection, printers, recent jobs and logs |
|
||||
| `help`, `--help`, `-h`, or no arguments; `--help` or `-h` after a command | Nothing; the usage is printed |
|
||||
|
||||
The enrollment code is read from the `QQQ_PRINT_ENROLLMENT_CODE` environment variable. When the variable is not set it is read from standard input, in one of two ways. When input is redirected, one line is read with no prompt, so a script can pipe the code in: standard output holds only the service's reply and, on success, standard error stays empty (when a script redirects or captures standard error, with `2>&1`, a file, the ISE or remoting, Windows PowerShell turns each line a native program writes there into an error). When input is a Windows console, the prompt `Enrollment code: ` is written to standard error and the code is typed without being shown (Backspace corrects, Enter ends). Redirected input is read as bytes and decoded as UTF-8, whatever the console code page is; a code is plain ASCII, which is the same bytes in every code page. White space around the code is dropped, and so is a leading byte-order mark. Windows PowerShell 5.1 puts a UTF-8 mark in front of piped text when the console code page is UTF-8 or when `$OutputEncoding` is UTF-8 (two marks when both are), and all of these are recognized. Input that begins with a UTF-16 mark, such as a file Windows PowerShell 5.1 wrote with `>`, is read as UTF-16. UTF-16 text with no mark at its start is not recognized; what is read is then not the code, so set the variable instead. The code is never accepted as an argument and never printed.
|
||||
|
||||
Two limits of that rule. "Not shown" holds at a Windows console. A terminal that gives native programs a pipe instead of a console (the default Git Bash window, for example) counts as redirected input: there is no prompt, the tool cannot stop the terminal echoing what is typed, and the line may stay on the screen; use the variable there. And an unattended caller that has a console nobody can type at (a scheduled task, for example) must set the variable or redirect input, or the command waits at the prompt for a key that never comes.
|
||||
|
||||
The exit code is 0 on success and 1 otherwise: wrong arguments, a service that cannot be reached or is not the installed one, a timeout, or a reply from the service that begins with `Error:`. Standard output carries what the service replied, including an `Error:` reply (the exit code, not the stream, tells a script it failed), and usage that was asked for. Standard error carries the command line's own failures (the service unreachable, a timeout, no enrollment code), the enrollment prompt when the code is typed at a console, and the reason and usage for a refused command line; a command that succeeds without that prompt writes nothing to it.
|
||||
|
||||
A command accepts exactly the options shown, each once, in any order, each with a value that is not blank and does not begin with `--`. `--exposed` takes the word `true` or `false` in any letter case. Anything else is refused before the service is contacted and before a code is asked for, with one line saying what is wrong (`--server is required.`, `--printer needs a value.`, `'run' takes no arguments.` and so on) followed by the usage. The refusal names only the tool's own commands and options and never repeats what was typed, because a mistyped command line can contain an enrollment code.
|
||||
|
||||
Version 2.0 removed `--legacy-compat` and the options that existed for it (`--data-dir`, `--once`, `--service`); a command line that uses one is refused with a sentence saying so. The executable is also no longer a printer worker (`--printer-worker`): the service starts its own executable for that. If that mode was ever used on a machine, its separate per-user state is still in `%LOCALAPPDATA%\QqqPrintAgent` (`connection.dpapi`, `journal.db`, `worker.lock`). Nothing reads it any more; delete it once the device it enrolled has been revoked in QQQ. Leave `auth-callback.log` in that folder: the browser sign-in helper still writes it.
|
||||
|
||||
## Credentials and network
|
||||
|
||||
Sign in through standard QQQ authentication, then associate an existing unbound pairing or enroll a new device explicitly. Every normal printing request requires the current QQQ user session and secondary device proof; server user, tenant and record permissions still apply. Session renewal does not change the journal's device identity. Revoke the device in QQQ when decommissioning a machine. Revocation cannot recall a job already accepted by the Windows spooler.
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# Baseline audit for 2.0 — 2026-11-10
|
||||
|
||||
Status: baseline. Four independent read-only reviews of `main` at `c4b1496` (four commits behind the release at the time; the missing commits added provider sign-out), against the KofTwentyTwo standards at revision `707bbd33f0b9` (2026-11-08). This file is the rubric and the starting point; it is updated as findings close, and the re-audit is appended at the end.
|
||||
|
||||
## The release rule
|
||||
|
||||
2.0 is done only when fresh reviewers, using the rubric below, score 9 or better on every criterion in the three code areas: Core, Windows, and the companion, command line and service host. Those eighteen criteria are what "code, design, architecture and security" means here; they include test quality and accessibility, because those are part of the code. The six Repository criteria are scored and reported in the same re-audit and are not part of the rule: several depend on CI, which is owned by separate work.
|
||||
|
||||
## How to re-run it
|
||||
|
||||
- One reviewer per area, read-only, on the head of `release/2.0` at the time; the re-audit records that commit and the standards revision it used.
|
||||
- A reviewer is given the sections from here down to the end of "Rubric", and nothing after it: not the baseline scores, and not the findings and their statuses, until their own scores and findings are written.
|
||||
- Each reads the standards first (`coding/README.md`, `coding/csharp-dotnet.md`, `testing.md`; the repository reviewer also reads `ci-cd.md`, `repository.md`, `releases.md`, `dependencies.md`), together with the exceptions register, then every source file in scope.
|
||||
- Each scores every criterion from 1 to 10 and lists findings with a severity and a `file:line` citation. A finding must be verified by reading the code, not inferred from a name. Reviewers say what they did not read.
|
||||
- Afterwards the reviewer's findings are compared with the open findings below; anything still open that the reviewer missed is put to them before the score stands.
|
||||
|
||||
### What a severity means
|
||||
|
||||
| Severity | Meaning |
|
||||
| --- | --- |
|
||||
| High | Can lose or duplicate a print, expose a credential or cross a security boundary; or the structure prevents a 2.0 requirement from being built or tested. |
|
||||
| Medium | Wrong behavior in a case that can be reached but is uncommon; or a requirement of the standards that is not met. |
|
||||
| Low | A local quality or consistency problem with no effect on behavior. |
|
||||
| Structural | A measurement of size or shape recorded as evidence for a design score; closed when the structure changes. |
|
||||
|
||||
The reviews of individual changes use blocker, should-fix and nit. A should-fix carried into this file counts as Medium and a nit as Low.
|
||||
|
||||
### What a score means
|
||||
|
||||
A score follows from the findings against that criterion, so two reviewers with the same findings give the same score.
|
||||
|
||||
| Score | Findings against the criterion |
|
||||
| --- | --- |
|
||||
| 10 | None. |
|
||||
| 9 | No High or Medium. Low findings only, few and not a pattern; every unmet requirement is covered by a recorded exception. |
|
||||
| 7–8 | No High. One or two Medium (8), or several (7). |
|
||||
| 5–6 | One High (6), or more than one, or Medium findings that form a pattern (5). |
|
||||
| 3–4 | The criterion's main requirement is not met: for example, logic that cannot be tested without the interactive desktop. |
|
||||
| 1–2 | Nothing in place. |
|
||||
|
||||
## Rubric
|
||||
|
||||
| Area | Criterion | Gate |
|
||||
| --- | --- | --- |
|
||||
| Core (`src/Qqq.PrintAgent.Core`, `tests/Qqq.PrintAgent.Core.Tests`) | Conformance to K22-CODE and K22-CODE-CS: naming, one type per file, doc comments, `var`, suppressions, logging abstraction, async rules | 9 or better |
|
||||
| | Design: cohesion and size of types, separation of protocol, transport and state, dependency direction, hidden global state, single-server assumptions | 9 or better |
|
||||
| | Testability: injected time, randomness, HTTP and socket seams | 9 or better |
|
||||
| | Error handling and resilience, including the `PrintWorker` reliability boundary | 9 or better |
|
||||
| | Security of untrusted input: frames, HTTP bodies, callback addresses, size bounds, negative tests, fuzz targets | 9 or better |
|
||||
| | Test quality: behavior asserted, hermetic, realistic doubles | 9 or better |
|
||||
| Windows (`src/Qqq.PrintAgent.Windows`, `tests/Qqq.PrintAgent.Cli.Tests`, `tests/Qqq.PrintAgent.WorkerFixture`) | Conformance to K22-CODE and K22-CODE-CS | 9 or better |
|
||||
| | Design and cohesion | 9 or better |
|
||||
| | Testability and isolation of native code | 9 or better |
|
||||
| | Error handling, concurrency and cancellation correctness | 9 or better |
|
||||
| | Security: pipe access, DPAPI scope, redaction, updater path and access checks | 9 or better |
|
||||
| | Test quality and realism: hermetic, no timing-based waits, no shared state | 9 or better |
|
||||
| Companion, command line, service host (`src/Qqq.PrintAgent`, `src/Qqq.PrintAgent.Cli`, `src/Qqq.PrintAgent.Service`, UI and layout tests) | Conformance to K22-CODE and K22-CODE-CS | 9 or better |
|
||||
| | Presentation architecture and separation of concerns | 9 or better |
|
||||
| | Testability and automated coverage of UI logic | 9 or better |
|
||||
| | Error handling and async correctness | 9 or better |
|
||||
| | Accessibility and UX consistency | 9 or better |
|
||||
| | Command-line design and tests | 9 or better |
|
||||
| Repository | Testing standard | Reported |
|
||||
| | CI/CD standard | Reported |
|
||||
| | Repository standard | Reported |
|
||||
| | Releases standard | Reported |
|
||||
| | Dependencies standard | Reported |
|
||||
| | Documentation | Reported |
|
||||
|
||||
## Baseline scores
|
||||
|
||||
These are the scores the four reviewers gave on 2026-11-10, before the table of what a score means existed. They are kept as given and not recomputed, so some do not follow that table (Core design is 6 with two High findings; the command line is 4 on the strength of its missing tests, not of U12). The re-audit uses the table. Findings below are grouped by area, not by criterion; in the re-audit each reviewer names the criterion every finding counts against.
|
||||
|
||||
| Area | Criterion | Baseline |
|
||||
| --- | --- | --- |
|
||||
| Core | Conformance to K22-CODE and K22-CODE-CS | 6 |
|
||||
| | Design | 6 |
|
||||
| | Testability | 6 |
|
||||
| | Error handling and resilience, including the `PrintWorker` reliability boundary | 7 |
|
||||
| | Security of untrusted input | 7 |
|
||||
| | Test quality | 6 |
|
||||
| Windows | Conformance to K22-CODE and K22-CODE-CS | 6 |
|
||||
| | Design and cohesion | 5 |
|
||||
| | Testability and isolation of native code | 5 |
|
||||
| | Error handling, concurrency and cancellation correctness | 5 |
|
||||
| | Security | 7 |
|
||||
| | Test quality and realism | 6 |
|
||||
| Companion, command line, service host | Conformance to K22-CODE and K22-CODE-CS | 7 |
|
||||
| | Presentation architecture and separation of concerns | 3 |
|
||||
| | Testability and automated coverage of UI logic | 2 |
|
||||
| | Error handling and async correctness | 6 |
|
||||
| | Accessibility and UX consistency | 6 |
|
||||
| | Command-line design and tests | 4 |
|
||||
| Repository | Testing standard | 4 |
|
||||
| | CI/CD standard | 6 |
|
||||
| | Repository standard | 4 |
|
||||
| | Releases standard | 5 |
|
||||
| | Dependencies standard | 7 |
|
||||
| | Documentation | 4 |
|
||||
|
||||
Measured on the release branch point (`a695996`), merged across both suites: Core 87% of 1,920 lines, Windows 69% of 3,799, command line 38% of 115; the companion unmeasured. 197 Core and 377 Windows tests.
|
||||
|
||||
What was already sound: the print reliability boundary (SUBMITTING recorded before the spooler call; an interrupted submission becomes OUTCOME_UNKNOWN), the pipe access list, DPAPI use and the updater's path checks, size bounds on server input, a warning-free build, and doc comments on nearly every type and member.
|
||||
|
||||
## Findings
|
||||
|
||||
Progress, measured by `build/Verify.ps1` on the reviewed head of pull request 53 (`0b9102e`, merged by squash as `02f34c3` with the same tree, 2026-11-11), as the description of that pull request records it: Core 100% of 2,311 lines, the presentation assembly 100% of 1,373, Windows 96.08% of 4,725 (4,540), and the command line at 100% (120 lines when last listed, with pull request 46; its source has not changed since); 1,174 Core, 2,321 Windows and command-line and 537 presentation tests, none failed. The minimums in `build/minimum-coverage.json` are 100 for Core, the presentation assembly and the command line and 95 for Windows. Twenty-nine pull requests are merged: 20, 21, 25, 24, 27, 28, 29, 30, 31, 33, 34, 35 and 37 to 53. The change that wrote this paragraph changed documents only, and nothing was built or run for it. Before it, this paragraph gave the figures of the head of pull request 42 (`e2ca086`): Core 100% of 2,141 lines and Windows 87.47% of 4,384, with 773, 1,521 and 352 tests.
|
||||
|
||||
The statuses below were brought up to the merges through pull request 42 on 2026-11-10. Each status changed then was checked against the code, at `6f0237d` and again at `077148f` and `e2ca086` for what pull requests 40, 41 and 42 touched, not taken from a pull request's title. Pull requests 47 and 49 to 53 changed this file themselves, each for the rows it closed or added; pull requests 44, 45, 46 and 48 did not change it. On 2026-11-11 the statuses of C9, C15, C24, U20, U21, W4, W25, W26 and W41 were brought up to those merges, each read against the code at `02f34c3`; no other status was read again then, except for two corrections that the review of that change led to the same day: a line reference in C8, with two sentences added to that row, and in U22 which released versions have the sign-in case.
|
||||
|
||||
Severity is the reviewer's, in the terms above. Status: open, in progress with the branch or pull request that is doing it, or closed with the change that closed it. "ADR-0004" and "ADR-0005" mean the finding is closed by building that design.
|
||||
|
||||
A row's number is an identifier and says nothing about when the row was written: each change takes the next free numbers or a block set aside for it, and a gap between two numbers is not a missing row. By the integrator's record of 2026-11-11, W82 to W89 were set aside for two changes that were still in review that day, W96 upward for the fix round of one of them, and W100 upward for a third change that was being written, so the W rows of the table recorded on 2026-11-11 below are W90 to W95.
|
||||
|
||||
### Core
|
||||
|
||||
| ID | Severity | Finding | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| C1 | High | Single-server state: one settings object, one configuration record, one state root with a one-shot static validation flag, a journal keyed by job only | Open (ADR-0004) |
|
||||
| C2 | High | Compatibility reasons from discovery exist only as log text and a fixed exception message; the wizard needs a typed result | Closed by pull request 29 (`6abe865`): `CheckCompatibilityAsync` returns typed stages and problems |
|
||||
| C3 | High | Logging is pre-formatted strings through `Action<string>` at 89 call sites (K22-CODE-CS-10) | Open |
|
||||
| C4 | High | `QqqSessionClient` (1,386 lines over three partials) owns origin policy, TLS, discovery, PKCE, cookies, binding and socket connect; `PrintApiClient` switches mode through flags set after construction | Open |
|
||||
| C5 | Medium | Six catch bodies are unreachable behind a filter that always returns false; `ClientWebSocket` and `SocketsHttpHandler` are built inline | Closed by pull request 29: the unreachable catch bodies are gone and both transports sit behind seams |
|
||||
| C6 | Medium | Nine timeout and delay sites use the wall clock | Partly closed by pull request 29: the delay and timeout sites read the injected clock, except `HttpClient.Timeout` (`PrintApiClient.Realtime.cs:29`) and the platform socket's keep-alive options, which stay on the system clock |
|
||||
| C7 | Medium | Claimed jobs are not validated at the boundary; missing options reach `PrintWorker` as a null reference | Closed by pull request 29: a claimed job is validated at the claim and a bad one is refused as FAILED |
|
||||
| C8 | Medium | ACK and ERROR payloads are read unguarded; a bad frame surfaces as four exception types | Partly closed by pull request 29. In Core, ACK and ERROR payloads and the content of a COMMAND are validated and fail as one exception type. A COMMAND of the wrong JSON shape is deserialized in the Windows runtime before that validation runs (`AgentRuntime.Realtime.cs:412`) and still fails there as `JsonException`, or as `InvalidDataException` when the payload is null; the queued command is deserialized a second time when it is run (`AgentRuntime.RealtimeStatus.cs:184`); by reading, a frame from the socket never has a null payload, because the frame reader refuses a payload that is not an object (`RealtimeEnvelope.cs:35-36`). The second reference was corrected on 2026-11-11: it had read `:136`, which was the line until pull request 50 (`db45420`) moved it |
|
||||
| C9 | Medium | The untrusted-certificate opt-in accepts any host, including the identity provider fetch; the discovery address is not bound to the issuer origin | First half closed by pull request 47 (`7b99a29`), the per-origin certificate exception (`QqqCertificateExceptionOrigins`; changelog entry "The development-only certificate exception now covers two origins at most"): an untrusted certificate is accepted only for a request or socket upgrade going to an origin the exception names (`QqqSessionClient.cs:123`, `QqqCertificateExceptionOrigins.Covers`), which is the server's own origin or the sign-in provider that server names, and from no other host; `QqqCertificateExceptionTests.UntrustedCertificateIsAcceptedOnlyForARequestToANamedOrigin` holds it. Today the service builds its exception for the server's origin alone (`AgentRuntime.Authentication.cs:51`) and the companion's dialog adds the provider (`SignInFlow.cs:192-198`). Second half open: the provider's discovery address has to be a plain HTTPS address (`QqqSessionClient.OAuth.cs:311`) and is not yet required to be on the issuer's origin |
|
||||
| C10 | Medium | Test gaps: the live OUTCOME_UNKNOWN branch, worker option hooks, file backend, enrollment, queued jobs, control receipts, realtime command validation; one catalog test uses a stub the real session never produces | Closed by pull requests 24 and 29 |
|
||||
| C11 | Low | Socket 403 mapped to authentication-required; unguarded error-body parse; a null extension entry throws; exception text sent to the server; no inner-cause constructor on the session exception | Partly closed by pull request 29 (a null extension entry no longer throws); the rest is re-checked at the re-audit |
|
||||
| C12 | Low | PascalCase private statics; ConfigureAwait analyzer off for Core; type initialization reads ProgramData and the registry; loopback tests with real waits | Partly closed by pull request 29 (machine state has a seam); the rest open |
|
||||
|
||||
### Windows
|
||||
|
||||
| ID | Severity | Finding | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| W1 | High | A job notification arriving while settings are applied can be discarded with no later wake (read, not reproduced) | Open |
|
||||
| W2 | High | Printer serialization lives inside the one runtime, keyed by job only | Open (ADR-0004) |
|
||||
| W3 | High | The store's scope is implicit and printer sharing is machine-wide | Open (ADR-0004) |
|
||||
| W4 | High | The schema is migrated on every store open, under a process-wide lock, with no version | Closed by pull request 49 (`ebea861`), the change that gives the journal a schema version and a one-time migration (plan step 5 of the 2.0 build): the journal carries a version (`PRAGMA user_version`), the service migrates it once at start in one transaction (`AgentRuntime.cs:67`, `JournalSchema.cs:155-179`), and opening a store checks the version and changes nothing (`JournalSchema.cs:114-137`). The statements that create or alter a table are in `JournalMigration.V1.cs` and in no other file under `src` (by search). Held by `JournalMigrationTests.Migrate_StepsAndVersion_AreOneTransactionInTheLog`, `JournalMigrationTests.ServiceStart_MigratesTheJournalBeforeItOpensAStore` and `JournalOpenTests.Open_AndEveryStoreCall_LeaveSqliteMasterUnchanged`. What its review left open is W91 to W93 |
|
||||
| W5 | High | Any of four tasks ending stops the service; the pipe is created and accepted outside the guarded region | Open (ADR-0004) |
|
||||
| W6 | High | The pipe contract has no version, no server identity and no typed reply; replies are matched by prefix at ten sites | Open (ADR-0004). The version 2 contract exists as types since pull request 37; nothing serves or sends it, and the service still speaks version 1 |
|
||||
| W7 | Medium | Log-file and SQLite I/O run under the state lock | Open |
|
||||
| W8 | Medium | Enrollment replaced the stored pairing before the bind check, while logging that it was unchanged | Closed by pull request 21 (`17797cd`): the pairing and its session are replaced as one staged step after the bind |
|
||||
| W9 | Medium | The updater verifies a hash from the same release but never the MSI's signer | Open |
|
||||
| W10 | Medium | A failed claim ends each of sixteen drain lanes, after each has made its own claim and logged its own error, and nothing retries until the next wake | Closed by pull request 24 (`ce58570`): a failed claim ends the wave, with one claim and one log line per wake-up |
|
||||
| W11 | Low | About ten fields are read outside the state lock | Open |
|
||||
| W12 | Low | A dropped exception and an empty catch without a reason | Open |
|
||||
| W13 | Structural | 65 runtime fields, 51 of them per-connection; a legacy polling path of about 270 lines kept only for tests; 32 tests enter through it | Partly closed by pull request 24: the polling path and its tests are gone; the per-connection fields remain (ADR-0004) |
|
||||
| W14 | Structural | 31 native calls with decisions inline; the clock injected at one site against 25 direct readings; eight fixed sleeps used as assertions | Partly closed by pull request 24: session renewal, backoff, the update pause, the catalog interval and command expiry read the injected clock. Pull request 41 (`077148f`) put the native calls of the sign-in startup task, the branded shortcut and the browser hand-off behind adapters (`ITaskScheduler`, `IShortcutShell`, `IShortcutRecord`, `IPathSecurity`, `ITraceFile`), with the rules tested against fakes. Pull request 42 (`e2ca086`) did the same for every Windows printing call: the spooler, GDI, printer settings, PDF rendering, the spooler's job queue and the helper's parent watch are six adapters, and the decisions around them are tested against fakes. Native imports outside adapters remain in `AgentPipePeer.cs` and `QqqBrowserCallback.cs` (found by search, not reviewed). The clock readings were not counted again, and W28 remains |
|
||||
|
||||
### Companion, command line, service host
|
||||
|
||||
| ID | Severity | Finding | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| U1 | High | Handlers catch only I/O, access and cancellation; the pipe client also throws `Win32Exception` and `InvalidDataException`, which escape through timer callbacks | Closed by pull request 34 (`7610dec`): every pipe request the views make goes through the typed service client, which turns `Win32Exception` and `InvalidDataException` into a result (`ServiceClient.cs:88-115`), so neither reaches a handler or a timer callback. Update prepare and resume go straight to the pipe from `MsiUpdateService.cs:86` and `:193`, each inside its own catch of every exception |
|
||||
| U2 | High | One global busy flag suppresses refresh for up to three minutes of browser sign-in and silently drops other commands | Open |
|
||||
| U3 | High | A serial pipe with a three-second connect timeout plus per-view polling will not scale to eight servers | Open (ADR-0004) |
|
||||
| U4 | Medium | Replies are parsed three different ways; success is detected by prefix | Partly closed by pull request 34: one typed client reads every reply (`ServiceClient.cs`, `ServiceClient.Operations.cs`). Success is still detected by prefix inside it (`ServiceClient.cs:186`, `:191`), because version 1 of the pipe answers with text; that ends when the companion speaks version 2 (ADR-0004) |
|
||||
| U5 | Medium | About ten catches discard the exception unlogged | Partly closed by pull request 34: the catches around pipe requests and sign-in moved to the presentation assembly, where the service client and the sign-in flow log what they catch. Six catches remain in the views that write nothing to the log (`MainWindow.Actions.cs:113`, `:152`, `:198`, `:231`, `MainWindow.Menu.cs:57`, `MainWindow.xaml.cs:307`); the first four show the exception's message, the last two show fixed text |
|
||||
| U6 | Medium | Presentation unit tests live in the interactive UI test project and are never gated | Closed by pull request 34: the presentation tests are `tests/Qqq.PrintAgent.Presentation.Tests`, run by `build/Verify.ps1` and gated at 100% line coverage; the interactive project holds only the tests that drive the window |
|
||||
| U7 | Medium | The UI tests drive the real service and machine log; five need real printers or jobs | Open |
|
||||
| U8 | Medium | Feedback is mirrored into a never-closed dialog bar, so the dialog opens showing stale messages | Open |
|
||||
| U9 | Medium | The live-region change is raised before the footer text is final | Open |
|
||||
| U10 | Medium | Selecting a job row expands it and requests details; rows have no accessible name; inner identifiers repeat | Open |
|
||||
| U11 | Medium | Layout-cycle suspects around expanded job rows, row re-insertion and dialog width (unverified) | Open |
|
||||
| U12 | Low | Command line: `--help` exits 1, unreachable branches, a duplicated read, a hard-coded version | Closed by pull request 28 (`f78943e`) |
|
||||
| U13 | Structural | About 1,300 of 3,976 lines in view classes are decision or formatting logic; zero bindings | Partly closed by pull request 34: decisions and formatting moved to `Qqq.PrintAgent.Presentation` (3,573 lines of C#, every coverable line covered). The companion project's C# files still total 3,690 lines and its XAML has no bindings; how much decision logic remains in the views is for the re-audit to measure |
|
||||
|
||||
Found by the review of the presentation assembly on 2026-11-10 and carried into the wizard and tree work, because each is a shape that one server and one dialog hide:
|
||||
|
||||
| ID | Severity | Finding | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| U14 | Should-fix | The browser callback inbox is one pipe per user session with one redirect address, but each sign-in flow opens its own; two servers signing in at once collide, and a late callback for one can fail another. It needs one dispatcher that routes by OAuth state | Open |
|
||||
| U15 | Should-fix | The sign-in flow also holds enrollment and certificate trust, which use neither its session nor its stage | Open |
|
||||
| U16 | Should-fix | The server address is validated in three places by three different rules | Open |
|
||||
| U17 | Should-fix | Sign-in messages name the controls of the current dialog | Open |
|
||||
| U18 | Should-fix | The typed service client exposes the pipe's request type in its public surface, and its server name is the edition's name rather than a server's | Open |
|
||||
| U19 | Should-fix | One certificate exception is held per flow | Open |
|
||||
|
||||
Found by the reviews of the legacy polling removal, the enrollment fix, the design records and Core hardening:
|
||||
|
||||
| ID | Severity | Finding | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| W15 | Should-fix | Applying a control command reads the control state and writes it back after a disk save; a desired-state frame arriving in between can be overwritten, losing a pause until the server resends it, or leaving the connection silent until the server gives up on it (read, not reproduced) | Closed by pull request 24: the command's result is merged into the state in force under one persistence lock |
|
||||
| W16 | Nit | A session save establishes its order against a staged session after the save is committed, and the staging stamp sits outside its cleanup | Open |
|
||||
| W17 | Nit | The companion's enrollment-failure text says the previous connection is retained unless enrollment completes, which is untrue once the new pairing has been saved | Open |
|
||||
| W18 | Should-fix | Preparing for an update waits only for prints already submitted; a job that is claimed and still downloading is recorded FAILED when the installer stops the service | Closed by pull request 24: preparation waits for claimed work, up to 25 seconds, and says so when it gives up |
|
||||
| W19 | Should-fix | Control-state cleanup deletes other scopes' rows and its two limits count the whole table, so with several servers one could erase or block another's control state | Open (ADR-0004) |
|
||||
| W20 | Should-fix | A reconnect request to a service whose authorization is blocked answers that a reconnect was requested and does nothing | Closed by pull request 24 |
|
||||
| W21 | Should-fix | The compatibility check runs in the companion under the user's certificate store, while the service connects as LocalSystem, so the two can disagree | Open (ADR-0004) |
|
||||
| W22 | Nit | Two constants, an enum and a property have no doc comment (`AgentPipeFraming.cs:13-14`, `QqqSessionException.cs:12-18`) | Open |
|
||||
| W23 | Should-fix | Once a claim wave has seen the queue empty it claims nothing more, and the next wave starts only when every job of the current one has finished. With more than one download lane, a job announced for an idle printer waits behind another printer's download and submission. Nothing is lost, reordered or reprinted | Open. To be fixed in the claim path, not by the printer queues: lanes claim while there is demand and a free slot, instead of in waves that must all finish (ADR-0004, "Resolved while planning the build"). The wave is still in the code at `077148f` |
|
||||
| W24 | Should-fix | Microsoft.Data.Sqlite 10.0.0 can hand one pooled connection to two threads that open the same journal at the same moment, and the service opens its store from several tasks on one signal. Not reproduced; the worst case traced is an autocommit write undone by another store's transaction, or a job reported FAILED or OUTCOME_UNKNOWN needlessly | Closed by pull request 33 (`2375195`): every open of a journal's connection happens inside the lock that is at least as wide as the connection pool (`AgentStore.cs:92-95`), with `AgentStoreOpenRaceTests` |
|
||||
| W25 | Should-fix | A replay of a job that another lane holds is stopped only by the ticket registry's duplicate-key check, which logs a confusing error and has no test | Closed by pull request 44 (`6ad3001`): a job that a lane of this computer holds is recognised before a ticket is registered for it (`PrintWorker.cs:84`, `:118-124`, asking the runtime's registry, `AgentRuntime.Pipeline.cs:161-165`); the pass ends with one line in plain words and nothing is recorded for the job. `PrintWorkerOutcomeTests.ClaimOfAJobInHandEndsThePassRecordsNothingAndLogsOneLine` holds the worker's side, and `AgentRealtimeInHandTests.JobOfferedAgainWhileALaneHoldsItIsLeftToThatLane` the runtime's, for a job that is still downloading and for one that is being submitted |
|
||||
| W26 | Should-fix | Whether a printer is shared is checked before the wait for the printer's turn, so unsharing a printer while jobs queue behind a print marks each of them OUTCOME_UNKNOWN although Windows was never asked | Partly closed by pull request 44 (`6ad3001`): closed in the worker. When it was confirmed by retrospective review B (its finding 8), the exposure filter refused inside the print call, after the attempt was marked as submitting, and no worker test covered it. The filter still refuses there, before it calls the backend behind it, and its refusal now has a type of its own (`ExposedPrinterBackend.cs:56-63`), which the worker records as FAILED, with the words that no bytes were submitted, however late it comes (`PrintWorker.cs:211`). The printer is checked once, after the download (`PrintWorker.cs:242`); a printer that stops being shared during the wait for its turn is refused by the filter. `PrintWorkerOutcomeTests.ChangeToThePrinterWhileWaitingForItsTurnIsSettledAfterSubmittingByWhoeverCanTell` and `PrinterWithdrawnAfterTheSubmittingReceiptIsRefusedByTheFilterAndFailedWithNothingSent` hold it in the worker. Open: the service's half. The runtime composes the same filter (`AgentRuntime.Pipeline.cs:181`), by reading; no test unshares a printer while jobs wait behind a print in the service's own pipeline. The plan of the multi-server build gives that test (`PrinterUnsharedWhileJobsWait_TheyAreFailed_WindowsAskedOnce`, which does not exist yet) to the printer-queue step (plan step 18) |
|
||||
| W27 | Nit | The journal cuts a title at 1,024 UTF-16 units and can split a surrogate pair; display only | Open |
|
||||
| W28 | Nit | Five fixed sleeps remain in runtime tests (`AgentRealtimeStartupTests`, `AgentRealtimeTransitionTests`, `AgentRealtimeOrderingTests` twice, `AgentRuntimePipeResponseTests`); the handshake, acknowledgement and pipe timeouts run on real time | Open. Pull request 38 added a sixth timed wait (W42), and pull request 41 a test that depends on a child process starting within six seconds (W53) |
|
||||
| W29 | Nit | The fake print server keeps a job claimed after a terminal event, which a real server does not | Open |
|
||||
| W30 | Nit | Code that only the removed legacy mode and the removed polling path reached still ships in Core and Windows: the bearer-token transport, the in-memory download and submit, the HTTP fallbacks for a missing realtime channel | Closed by pull request 35 (`b868e9e`): the bearer-token transport, the in-memory download and submit and the HTTP fallbacks are deleted; the API client is built only from a signed-in session |
|
||||
| C13 | Low | A claim nested past 1,024 levels cannot be identified, so it is never refused and stops the lane. The server's 2,000-character cap on options cannot produce one; a depth cap on the server would be the durable fix | Open (server) |
|
||||
|
||||
Found by the retrospective reviews of 2026-11-10 and by the review of the printing seams step, or seen while verifying. Pull requests 28, 29, 35, 37 and 38 had merged with a final fix round their reviewer had not seen, and pull requests 30 and 31 with no review (ADR-0004, "About the process"). Review A covered pull requests 37, 38, 31 and 30; review B covered the last fix rounds of pull requests 29, 35 and 28; both read `release/2.0` at `c3efed4`. Neither found a blocker. The printing seams step merged afterwards as pull request 42 (`e2ca086`). The review of its first head found five pieces of older behavior (W44 to W46, W57 and W58); the step changed nothing that reaches Windows, so all five are in `release/2.0` as they were. Every citation below was read at `6f0237d` and holds at `e2ca086`. Pull requests 40 and 41 changed none of the source files cited by line. Pull request 42 rewrote `WindowsPrinters*.cs` and `PrinterWorkerHost.cs`, so the rows that cite them (W44, W46, W48, W57, W58) were given the line numbers of `e2ca086`, as the two `CHANGELOG.md` citations carry those of `077148f`, which pull request 42 did not move. Some of those rows have since been brought up to a later tree; the end of this paragraph says which. Severity is the reviewer's. "Not rated" marks an item that no reviewer has rated. Review A's findings about documents (its 14 to 21) are answered in ADR-0004, "Resolved after the retrospective reviews", and by the status corrections in this file. Review B's finding 8 is W26. C22, C23 and W49 to W55 are the nits left open by the reviews of pull requests 40 and 41, which merged afterwards with the reviewed head and the verdict in their descriptions. Those descriptions name no places; where a row gives one, it was found by reading the code. W56 to W58 and C24 were added in the fix round after the review of the change that wrote this table. W59 to W64 are what pull request 42 left open that had no row; C25 and W65 are two further places with the effect of W44. W66 to W69 were found while W44 was fixed, were not there before as rows, and no reviewer has rated them; each says whether it was tested or only read. W70 to W72 come from the review of that fix (no blocker, 3 should-fix, 9 nits): its should-fix S1 and its nits 2 and 9, with the reviewer's severity. That fix, the change that gave a printer backend three reads, moved lines in `IsolatedPrinterBackend.cs`, `WindowsPrinters.cs`, `WindowsPrinters.Files.cs`, `PrinterWorkerHost.cs` and the worker fixture's `Program.cs`. The open rows that cite those files by line (W45, W46, W48 and W57 to W61) therefore carry the line numbers of that change's tree, the head its review's fix round ended on, and no longer those of `e2ca086`; so do W70, W71 and W73. Only the numbers were changed in those rows. The closed rows W44 and C25, and W65, keep the numbers of `e2ca086`, at which they say they were read. C24 is not touched: its numbers were already behind `release/2.0` before that change. W73 comes from the confirmation review of that fix round (no blocker, 1 should-fix, 4 nits): its nit 1. "The contract and redaction fixes that followed the retrospective review" in a status below is one change, merged as one pull request: the fixes to the version 2 pipe contract and to the log's redaction that closed W31 to W42, C14 and W56, together with the fix round after their own review. W74 to W76 were found by that review, which found no blocker; what it found in rows that already existed is recorded in their statuses. W77 to W81 are what the review of the update, service-control and pipe-peer seams (plan step 8 of the build) left open after its fix round; their line numbers are those of that step's head.
|
||||
|
||||
| ID | Severity | Finding | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| W31 | Should-fix | The bound and the printable form of an error's message are applied by the two `ManagementReply.Failure` factories and nowhere else. A reply built through the public record constructors or a `with` expression is written as given (`ManagementJson.cs:436-443`, `ManagementReply.cs:17`, `ManagementError.cs:17`), and `ManagementOperations.Check` returns a bare `ManagementError` that no factory takes (`ManagementOperations.cs:235-257`). A probe wrote, and read back whole, a message of 2,028 units holding U+202E and a newline. ADR-0004 says the wire enforces the form (review A, 1) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the message is given its wire form in `ManagementError` itself, which has no member that can be set afterwards. The two factories, `ManagementOperations.Check` and the parser all build through it, for an error that is written and for one that is read, and `ManagementReply.Failure` takes an existing error, so the routing refusal needs no reply built by hand |
|
||||
| W32 | Should-fix | `Failure<TDetails>` accepts any details record for any code, and both factories accept any text as a code (`ManagementReply.cs:33-40`). Details written that way are then read by the client through the private constructor that stores what the wire carried (`IdentityChangedDetails.cs:23-28`), unbounded; `busy` can be sent with details although its table entry gives it none (review A, 2) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the service builds an error through the constructor, for a code that carries no details, or through `ManagementError.WithDetails`, which takes exactly the record the table names for the code. Text that is not in `ManagementErrorCode.All`, details for a code without any, and a missing or foreign details record are refused. Reading stays lenient, so a client still reads a code a newer service added |
|
||||
| W33 | Should-fix | The four envelope types refuse the serializer only when it is given one of the contract's own two options objects: the refusing converters are added to those options and not declared on the types (`ManagementJson.cs:178-181`). With default or web options an envelope is read with no envelope check, and the test tries only the contract's options (`ManagementEnvelopeTests.cs:688`). ADR-0004, `ManagementJson.cs:26-27` and `docs/DEVELOPMENT.md:20` say the refusal cannot be walked around. Nothing in production reads an envelope yet (review A, 3) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the four envelope types name a refusing converter in an attribute on the type, so the serializer neither reads nor writes them under the default options or any options object that does not bring a converter of its own for the type; `ManagementJson.Write` writes an envelope directly, as the same text as before. The test tries the default, web, indenting and case-insensitive options and the contract's own two. The refusal does not hold against options that carry a converter written for an envelope type, which the serializer asks before the type; that takes a converter written on purpose, nothing in the product has one, and a test holds where the refusal ends (review of the contract and redaction fixes, nit 4) |
|
||||
| W34 | Should-fix | Core's printable-text function was written for sentence fragments and is used as the form of typed wire members (`QqqCompatibilityProblem.cs:67-79`, called at `ManagementReply.cs:50` and `IdentityChangedDetails.cs:44`). A missing identity value and an empty message are sent as the text `(missing)`, which a client cannot tell from a value; a cut value carries a three-character mark, so a message can be 515 units and an identity value 203 where ADR-0004 says 512 and 200; and applying the function twice changes the text (review A, 4) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the contract has its own rule for text on the wire, `ManagementText` (ADR-0004, "Text on the wire"). A cut keeps 512 and 200 exactly and adds no mark; the rule applied twice changes nothing; a missing identity value is absent (the two members of `IdentityChangedDetails` may be null and are then left out); and an error with nothing to show cannot be built. A client still reads a reply whose message is empty |
|
||||
| W35 | Should-fix | The same identity values are treated two ways. `IdentityChangedDetails` cuts the principal and the tenant at 200 units and makes them printable; the snapshot's authentication status carries them as Core holds them (`AgentAuthenticationStatus.cs:9`, inside `ServerSnapshot`). Core admits identity values of up to 1,024 units and rejects only control characters (`QqqSessionClient.cs:930-933`), so U+202E and U+2028 pass, while `ManagementProtocol.cs:47-48` says the QQQ contract limits such a value to 200. The same question is open for the other text a server can influence in results: status, printer sync detail, last command and outcome, job titles and messages (review A, 5) | Partly closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the snapshot, the result of `servers.add` and the result of `session.signIn` name the account through a record of the contract, `ServerAuthentication`, which gives the principal and the tenant the form `IdentityChangedDetails` gives them, when it is built and when it is read; a test fails if a reply names an account through any other record. Since the fix round after the review of those fixes, the message beside the account (`ServerAuthentication.Message`), which was held as given when built and when read, has the form of an error's message (printable, at most 512 units) in both directions. The comment that claimed a limit of 200 in the QQQ contract says what the session client admits. Open: Core still admits an identity value of up to 1,024 units and excludes only control characters, so the version 1 snapshot carries such a value as before; and the other text a server can influence in results is decided when the handlers are written |
|
||||
| W36 | Should-fix | `printers.discover` is a read (`ManagementOperations.cs:192`) and reads never wait, but it runs a driver discovery in a helper process, and nothing said whether concurrent requests share one. A hung driver and a client that retries could hold several pipe instances for 30 seconds each (review A, 6) | Partly closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the operation table marks `printers.discover` as single-flight (`ManagementOperationInfo.SingleFlight`), and the comment on reads says what is true of them. Open: the discovery itself is made single-flight where the pipe server is built |
|
||||
| C14 | Nit | The printable-text function is a public static on an authentication record (`QqqCompatibilityProblem.cs:67`), and the pipe contract depends on that type for the form of its messages. Its default limit is an internal constant of 80 in a public signature, and its comment says every character that is not visible becomes a space although other space separators (U+00A0, U+3000) are kept (review A, 7) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): which characters are shown is one definition in a neutral Core type, `ServerText.IsShown`; the pipe contract's rule is its own, `ManagementText` in the Management namespace; `QqqCompatibilityProblem.Printable` stays for Core's sentences, is internal again, and its comment says that other space separators are kept |
|
||||
| W37 | Nit | The walk that refuses a null inside a known list handles lists and records only (`ManagementJson.cs:384-393`); a dictionary-valued member and a nullable-struct member are not walked. No contract type has either today (review A, 8) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the walk follows the values of a dictionary, which are held to the rule a list's items are, and a value type that may be null; a test with records of its own holds both |
|
||||
| W38 | Nit | Smaller gaps in the contract. `ManagementPayload.From` accepts a value that is not an object (`ManagementPayload.cs:33-35`), which `ParseReply` then reports as an incomplete reply. No test puts a repeated member through `ParseReply`, although it is refused. A reply with `ok: true`, a result and an error is accepted as a success and the error is dropped (review A, 9) | Partly closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): `ManagementPayload.From` refuses a value that is not written as an object, and six cases put a repeated member through `ParseReply`. Open, as the review left it with no fix proposed: a reply with `ok: true`, a result and an error is read as a success and the error is dropped, and the reverse |
|
||||
| W39 | Should-fix | The service log's per-server view takes any string as the server's name (`ServiceLog.cs:152-159`), and the contract takes any string as a name (`ServersAddArgs.cs:16`, `ServersRenameArgs.cs:9`). The activity log file redacts the joined line a second time, so a name holding a credential keyword hides that server's lines: a server named `Cookie:` is written as `[Service] [Cookie:[REDACTED]`. Square brackets in a name forge structure inside a line. The view's own character rule is a second, weaker copy of Core's (UTF-16 units, four categories, a cut at 64 that can split a surrogate pair), although its comment says it is Core's rule (`ServiceLog.cs:164-170`). Nothing calls the view yet (review A, 10) | Closed in the contract and the log by the contract and redaction fixes that followed the retrospective review (2026-11-10): `ServerName` is the rule ADR-0004 decided, as one function the service and the companion both call (`Check`, which says which part of the rule a name breaks); `ServersAddArgs` and `ServersRenameArgs` cannot be built with a name that breaks it, so a request that gives one is refused when its arguments are read; `ServerName.Propose` proposes a name from an address that always passes; and `ServiceLog.ForServer` takes a server's identifier and prefixes its first eight characters, so a name never stands in a log line. The fullwidth square brackets (U+FF3B, U+FF3D) are refused with the ASCII pair since the fix round after the review of those fixes: a person reads them as the same brackets. In the same round the rule gained one condition: a name must hold at least one character that draws something (C27). The rule for an address is beside it (`ServerAddress.TryRead`), held by test to what the session client accepts and refuses. Open: the upgrade and the wizard, which will call these, are not built; the session client's own check and the sign-in flow's copies of the address rule remain until their callers move; nothing calls the view yet |
|
||||
| W40 | Should-fix | Redaction gaps (`AgentActivityLog.cs:122-156`): `id_token_hint=`, an `Authorization:` header of a scheme other than Bearer and `code_verifier=` pass unchanged, and so do bare `sessionUUID=` and `sessionId=`. The review traced that no log line can carry any of them today; they are values the product handles, and redaction is the second line of defense (review A, 11) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10) for the shapes named here, and no further. Every name under which the product's own requests and responses carry a secret (read from the session client's sign-in, code exchange, session renewal and sign-out and from the API client's enrollment) is redacted as a named value: `enrollmentCode`; `deviceToken`, with the `X-QQQ-Device-Token` header; the session response's `uuid`; `sessionUUID`, `sessionId` and `sessionToken`; the code verifier and the ID-token hint in every spelling and letter case (`codeVerifier`, `code_verifier`, `code-verifier`; `idTokenHint`, `id_token_hint`, `id-token-hint`); and `code` in a message that also names a code verifier, which is the code-exchange request. Each is recognised as a member of a JSON object, as `name=value` or `name: value`, and with the equals sign percent-encoded (`name%3Dvalue`), anywhere in a line. An `Authorization` header line of any scheme loses its value to the end of the line. Not covered, and held as known behaviour by `ServiceLogTests`: a name that is itself percent-encoded, a JSON object percent-encoded as a whole, a name with no separator after it, the rest of a value after a comma, a semicolon or a space, and a nonce (`nonce`, `expectedNonce`), which is not a credential. The first fix for this row covered `code_verifier`, which the product never writes, and not `codeVerifier`, which it does (review of the contract and redaction fixes, S1) |
|
||||
| W41 | Nit | Redaction edges (`AgentActivityLog.cs:144`, `:155`). Over-redaction: a line with "cookie:" as ordinary words loses the rest of the line, and `?state=QUEUED` or `?code=E1234` in a logged address loses its value. Under-redaction: a quoted callback value, a folded cookie header, a cookie header with other spacing, and a percent-encoded `code%3D`. No line the product writes today has these shapes (review A, 12) | Partly closed by the contract and redaction fixes that followed the retrospective review (2026-11-10). Redacted now: a quoted callback value; a percent-encoded callback (`%3Fcode%3D`, `%26state%3D`); a callback whose ampersands are written as HTML writes them (`&code=`); the session cookie's two names in a folded or differently named header; a cookie or an authorization header whose value stands on the next line, the header's own line holding nothing after the name; an authorization header written with an equals sign; either header under the name a web server's environment gives it (`HTTP_AUTHORIZATION`, `HTTP_COOKIE`); and a no-break or zero-width space between a name and its separator. Open, and held as known behaviour by `ServiceLogTests`. Not redacted: a cookie of another name under a header name that only ends in "Cookie", with an equals sign in place of the colon, or as a member of a JSON object; the second line of a header whose value begins on the header's own line and is continued on the next; a callback value that opens a quote and never closes it; a value after an arrow (`=>`); what follows an escaped quote inside a quoted value; and the rest of a value after a comma, a semicolon or a space. Hidden although not a secret: a sentence with a colon after "cookie" or "authorization", and the line after one that ends there; "authorization" before an equals sign; `sessionId: 12`; the word `uuid` with a value; an ordinary `?state=` or `?code=` parameter; and every `code` with a value in a message that names a code verifier. No line the product writes has these shapes. One more case is not redacted, found by the last review of pull request 51 and read at `02f34c3`: the `code` of a code exchange in a message longer than 8,192 units when the verifier's name stands after the cut, because the name is looked for in the text as it was cut (`AgentActivityLog.cs:111`, `:121`). No test holds it (no test names the mark of the cut). That review found no line that logs such a body; that was not traced again |
|
||||
| W42 | Nit | Three small items in the log. The runtime does not pass its time provider to `ServiceLog` (`AgentRuntime.cs:27`), so the ring's stamps read the system clock (the direction of W14). `ServiceLogTests.cs:626` uses a 200 ms real wait as an assertion, a timed wait of the kind W28 counts. The summary of `ServiceLog` says the ring is what the companion shows (`ServiceLog.cs:12-14`), which it is not (review A, 13) | Closed by the contract and redaction fixes that followed the retrospective review (2026-11-10): the runtime hands its time provider to `ServiceLog`; the summary of `ServiceLog` says the ring is what the version 1 snapshot carries, not what the companion shows; the 200 ms wait stays, because it waits for something that must not happen, and its summary says so (it stays counted under W28) |
|
||||
| U20 | Should-fix | The command line drops a byte-order mark in front of an enrollment code only when the console input code page is UTF-8. It strips U+FEFF (`CommandLine.cs:269`), but `Console.ReadLine` (`Program.cs:21`) decodes redirected input in the console code page and detects no mark, so under another code page (437 was tried) the three bytes arrive as three other characters, stay in front of the code, and the server rejects it. Seen with Windows PowerShell 5.1 when `$OutputEncoding` is UTF-8 on an OEM console. The single-use code is not consumed. `CHANGELOG.md:153` says the mark is dropped whether it comes from the variable or standard input, which is more than the code does (review B, 1) | Closed by pull request 46 (`fe95a9f`): redirected input is read as bytes from the standard input stream and decoded as UTF-8, and a byte-order mark at the very start is taken for what it says (`CommandLine.cs:274-278`, `Program.cs:21`), so the console code page has no part in it. `CliPipedInputTests.PipedCode_ReachesTheServiceWhateverMarkIsInFrontOfIt` holds it against a fake host and `CliProcessTests.Enroll_ReadsPipedBytesAsUtf8OrAsTheirMarkSays` against the real executable. Three inputs are deliberately not repaired, and what is then sent is not the code (`CliPipedInputTests.PipedBytesThatAreNotUtf8AndCarryNoMarkAtTheStart_AreNotRepaired`). What its review left open is U26 |
|
||||
| C15 | Should-fix | A claim that names its job can still be neither refused nor answered. A lone-surrogate escape (`\ud800`) in a title or in a member name makes the string reads outside the serializer throw `InvalidOperationException` (`PrintJobContract.cs:66`, `:219`; `ProtocolJson.cs:118`, `:134-137`). That is not a protocol violation and carries no stand-in for the job, so the lane stops and the server offers the same job again (`AgentRuntime.Pipeline.cs:100-104`). The review did not show that a submitter can produce it through the current server: by reading only, the server's serializer should not emit the escape. No test covers it, and there is no fuzz target for the claim parser (R2). `docs/security/threat-model.md:29` and `:33`, "Malformed job" in `docs/REALTIME.md` and `CHANGELOG.md:83` say more than the code does (review B, 2) | Closed by pull request 45 (`f481c43`): text the JSON library cannot read no longer keeps a job from being identified. An unreadable member name beside the job, or where the agent reads the job's members, refuses the claim with its stand-in, and an unreadable text member the agent reads is left empty in the stand-in (`PrintJobContract.cs:70-72`, `:243`; `ProtocolJson.cs:145-185`). The worker then records RECEIVED and FAILED, downloads and submits nothing, and claims the job behind it (`ClaimUnreadableTextTests.WorkerRefusesTheJobOnceAndGoesOnToTheJobBehindIt`, over five forms of such text in fourteen places). A job whose own identifier is the unreadable text still names no job and stops the lane, which is the documented case (`ClaimUnreadableTextTests.UnreadableJobIdentifierNamesNoJobAndTheViolationReachesTheCaller`). The same change rewrote the passages of `docs/security/threat-model.md`, `docs/REALTIME.md` and `CHANGELOG.md` this row names; they were not read again for this status. Still open: no fuzz target for the claim parser (R2), and what the review of that change left, which is C28 |
|
||||
| C16 | Nit | The comment on the parse depth reads as if the cost were flat in depth (`ProtocolJson.cs:37-41`). The parse is not recursive, but its cost grows with depth: about 0.9 seconds and 63 MiB for a hostile 2 MiB response at 1,024 levels, which only the server can send (review B, 3) | Open |
|
||||
| C17 | Nit | The depth test's wording and one assertion name the wrong boundary (`ClaimedJobContractTests.cs:336`, `:349`): 980 arrays fit the server's cap with that test's shape and 985 with the cheapest shape, not 970. The limit itself is pinned on both sides (review B, 4) | Open |
|
||||
| W43 | Nit | No log sink writes the long lines whole. The service cuts at 1,000 units, 4,000 for debug detail, with no mark (`ServiceLog.cs:91-93`); redaction cuts at 8,192 and marks it (`AgentActivityLog.cs:104`); both cut by index and can split a surrogate pair. The session client's longest refusal line is about 16.8K units, so in the companion's log (`QqqSignInSessionFactory.cs:25`) its tail, the later callbacks and the closing instruction, is lost (review B, 5) | Open |
|
||||
| C18 | Nit | The log hygiene tests leave three things unpinned (`QqqSessionLogHygieneTests.cs:28-29`): `LongestLine` is 17,000 where the debug "Native grant" line can reach about 18,000 (`QqqSessionClient.cs:469`); the device identifier's long limit (`QqqSessionClient.cs:512`, `:519`); and two identity values that are alike for their first 512 units print the same (review B, 6) | Open |
|
||||
| C19 | Nit | A retry or a test print of a job nested deeper than 64 levels reports a JSON error although the server created the job, because the operator-action response is parsed at the default depth (`PrintApiClient.cs:169`). Such a job is refused when it is claimed (review B, 7) | Open |
|
||||
| U21 | Nit | Two byte-order marks in front of an enrollment code are handled (`CommandLine.cs:269`), but no test row has more than one (`CliEnrollmentCodeTests.cs:179-182`) (review B, 9) | Closed by pull request 46 (`fe95a9f`): rows with two marks exist, with a code behind them and with none (`CliPipedInputTests.cs:39`, `:99`; `CliProcessTests.cs:140`) |
|
||||
| C20 | Nit | `ValidControlId` accepts `.` and `..` (`PrintApiClient.Controls.cs:45`), and an identifier made only of dots is path-normalized when the address is built (`PrintApiClient.Download.cs:57`), so "safe in a route" in `docs/security/threat-model.md:27` is not quite true. Only the server writes job identifiers, and the request stays inside the printing extension's path (review B, 10) | Open |
|
||||
| W44 | Should-fix | The print helper runs a full printer discovery for every job and never uses its media profiles (`WindowsPrinters.Files.cs:39`), after the service has already run one for the same job. Other queues' drivers are therefore loaded in the helper that is about to print. A hung unrelated driver fails every job; a slow or crashing one inside the print helper lands after SUBMITTING, so the job is recorded OUTCOME_UNKNOWN although nothing reached the spooler (printing seams review, 3; older behavior) | Closed by the change that gave a printer backend three reads (ADR-0004, "About printing"): the print helper looks up the job's own queue (`FindWithMedia` in `WindowsPrinters.PrintFileAsync`), which is the spooler's enumeration and the settings read of that one queue, and opens no other queue. The enumeration itself can still be held by a printer connection to a print server that does not answer, which is W70. What it asks Windows for the job's own queue is unchanged, the settings read before the document included; that read is still discarded, which is W66. The author found two further places with the same effect, C25 and W65. Shown on hand-written fakes only; the change is inside the hardware check (ADR-0003) |
|
||||
| W45 | Should-fix | The service reads the print helper's standard output and standard error whole and checks the 1 MiB limit afterwards (`IsolatedPrinterBackend.cs:108-120`). A helper that floods them, which a driver can do because it shares the helper's handles, grows the service until the time limit ends the helper (printing seams review, 4; low urgency; older behavior) | Open. Pull request 42 left it unchanged; it bounded the other direction, the request the helper reads, at 1 MiB |
|
||||
| W46 | Nit | The bound on a rendered PDF page is in device-independent pixels (`WindowsPrinters.cs:297-308`), so the "5,000 pixels" cap is 5,000 times the display scale, and the size of the bitmap that comes back is not checked against its header before it is handed to GDI (`WindowsPrinters.cs:334`) (printing seams review, 5; older behavior) | Open. Pull request 42 left it unchanged |
|
||||
| W47 | Not rated | The activity log drops an entry when the ownership file cannot be had within one second: the wait gives up (`AgentActivityLog.Settings.cs:142-160`) and the write swallows the failure (`AgentActivityLog.cs:161-176`). Seen once under load, as `AgentActivityLogTests.IndependentWritersShareTheDailyLogAndPreserveSeverity` finding 61 of 62 entries | Open |
|
||||
| C21 | Not rated | The realtime channel's shutdown can report a deliberate stop as a failure (`PrintingRealtimeChannel.RunAsync`). The shutdown aborts the socket, and a loop that has checked the token and then uses the socket fails on it instead of seeing the cancellation, so the run throws although the caller asked for the stop. Seen once in a full verification run, in `ErrorFailsTheWaitingSendWithTheMatchingStatus` | Closed by pull request 40 (`b5dfb2c`): a failure is taken to be the stop only when it is one of the two ways an ended socket refuses a call and the loop is not known to have failed first; every other failure is still reported. Two nits from its review stay open (C22, C23) |
|
||||
| W48 | Not rated | The print helper has no error code for a queue that is not found: its reply names a code only for a media mismatch and for the two cancellation refusals (`PrinterWorkerHost.cs:84`). A queue removed while a job waits for the printer's turn makes the helper's lookup fail after SUBMITTING (`WindowsPrinters.Files.cs:46`), and the job ends as OUTCOME_UNKNOWN although no document was started | Open. Pull request 42 did not change it |
|
||||
| C22 | Nit | No test pins the cause given to a send that is still waiting for its acknowledgement when the caller stops the channel (`PrintingRealtimeChannel.cs:92-101`). Pull request 40 changed what that cause may be: a refusal the run took to be the stop is not passed on as the cause (review of pull request 40) | Open |
|
||||
| C23 | Nit | The scripted test socket does not model the platform socket's own abort when the token of a call in flight is cancelled (`ScriptedWebSocket.cs`), so the premise of one test is stated only in its comment (`RealtimeChannelEndingTests.cs:260-267`) (review of pull request 40) | Open |
|
||||
| W49 | Nit | At the second look at a shortcut, the order "judge whether it is ours, then clear its read-only mark" is not pinned by a test (`AgentBrandingShortcuts.Files.cs:70-74` before a replacement, `AgentBrandingShortcuts.cs:242-247` before a delete). Clearing first would change a file that is not ours (review of pull request 41) | Closed by the fix for a held shortcut (2026-11-11). The two places are now `PutInPlace` (`AgentBrandingShortcuts.Files.cs:95-112`) and `DeleteIfOurs` (`AgentBrandingShortcuts.cs:307-320`). `AgentBrandingShortcutsTests.ReadOnlyFileThatReplacedOursBeforeTheSecondLookKeepsItsMark` replaces our shortcut with a read-only file of someone else's between the two looks, under the wanted name and under the earlier one, and requires the mark to be kept; with the order reversed in either place it fails |
|
||||
| W50 | Nit | A leftover temporary shortcut file that is marked read-only fails an uninstall until the mark is cleared: the uninstall deletes leftovers without clearing the mark and without the note an install takes instead (`AgentBrandingShortcuts.cs:127-130`, against `:257-270`) (review of pull request 41) | Closed by the fix for a held shortcut (2026-11-11). An install and an uninstall remove leftovers through the same code (`RemoveLeftovers`, `AgentBrandingShortcuts.cs:330-350`): the read-only mark is cleared first, because such a file is ours by its name (a link is not changed), a file in use is tried nine times over two seconds, and one that still cannot be removed is noted and no longer fails an uninstall. Tests: `AgentBrandingHeldShortcutTests.ReadOnlyLeftoverTemporaryFileIsRemoved`, `.ReadOnlyLeftoverThatIsALinkKeepsItsMarkAndIsLeftWithANote`, `.UninstallWhileALeftoverTemporaryFileIsHeldLeavesItAndFinishes`. No real uninstall has exercised it (W121) |
|
||||
| W51 | Nit | A damaged shortcut of ours under the current name refuses every install, as in 1.2.1: a file that holds no readable shortcut is judged not ours (`AgentBrandingShortcuts.Files.cs:113-129`), and a file that is not ours under the wanted name stops the install. The case of a valid header followed by garbage has no test (review of pull request 41) | Open. The fix for a held shortcut (2026-11-11) left it as it is and moved the code: the judgement is `SaysOurs` (`AgentBrandingShortcuts.Files.cs:217-218`) and the refusal is in `OursToReplace` (`:190-207`). A file that cannot be opened is a different case and no longer refuses an install when the record names it |
|
||||
| W52 | Nit | The Windows reason a scheduler program could not be started is discarded: the adapter turns the failure into "nothing started" (`WindowsTaskScheduler.cs:73-76`) and the rules report the fixed text "Could not start Windows Task Scheduler administration." (`AgentStartupTasks.cs:330`) (review of pull request 41) | Open |
|
||||
| W53 | Nit | One timeout test depends on the child process starting within six seconds (`WindowsTaskSchedulerTests.cs:80-83`); on a busy machine it fails for that and not for its reason. It is a timed wait of the kind W28 counts (review of pull request 41) | Open |
|
||||
| W54 | Nit | Two sentences of the changelog entry for the branded shortcut disagree. The pull request does not say which; read at `077148f`, the opening says a file that is not ours no longer fails an install (`CHANGELOG.md:7`), and a later sentence says an install fails when a file that is not ours has the name the shortcut needs (`CHANGELOG.md:36-37`) (review of pull request 41) | Closed by the fix for a held shortcut (2026-11-11): the opening of that entry now says that a recorded file that is no longer ours does not fail an install or an uninstall, and that a file that is not ours under the name a new shortcut needs still refuses an install. That is what the code does (`OursToReplace`, and `StillOurs` for the recorded names) |
|
||||
| W55 | Nit | Two files keep two blank lines after their usings (`tests/Qqq.PrintAgent.Cli.Tests/AgentBrandingInstallerTests.cs`, `tests/Qqq.PrintAgent.WorkerFixture/Program.cs`) (review of pull request 41) | Open |
|
||||
| W56 | Should-fix | `servers.add` has no argument for the sign-in provider's origin, and the comment on `ServersAddArgs` says the provider's address "is not an argument" (`ServersAddArgs.cs:9-16`). ADR-0004 now says the request carries the origin that `servers.check` reported, for comparison, and that a difference is refused; the error code of that refusal is not settled. No handler exists yet. The code table and the golden files are closed by test, so the code must be settled when the argument is added (review of this documentation change, 3) | Closed in the contract by the contract and redaction fixes that followed the retrospective review (2026-11-10): `ServersAddArgs.ProviderOrigin` states the provider the check showed the operator, and `TrustSetArgs.ProviderOrigin` does the same for `trust.set`, which has the same race; `SignInProviderOrigin.Agrees` is the one rule for the comparison, in which an absent origin means that the check named no provider and is compared like any other value, so a server that names a provider only after the check is refused as one that names another; and the refusal has the code `provider_changed`, with no details, recorded in ADR-0004 with the two golden requests. The review of those fixes found two things in the rule, both closed in the fix round after it: text that names no origin was read as an argument and could then never agree, and is now refused as `invalid_request` when the arguments are read (a member left out and a null both state "none", by test); and two zones of one IPv6 literal agreed (C26). Open: the comparison is made where `servers.add` and `trust.set` are built |
|
||||
| W57 | Nit | A parent watch that fails for any reason other than a cancelled wait or a parent that is not running leaves the print helper unwatched: the watch's task fails, the helper carries out its operation with nobody watching the service, and the failure is seen only when the task is awaited after the operation (`ParentProcessWatch.cs:27-43`, `PrinterWorkerHost.cs:76`, `:90-93`) (printing seams review, 2, second part; older behavior) | Open |
|
||||
| W58 | Nit | A failing `SetStretchBltMode` is reported with a stale Windows error: its import has no `SetLastError` (`WindowsGdiPrinter.cs:150`), and the failure is turned into the thread's last error (`WindowsPrinters.cs:333`, `:342-348`) (printing seams review, section 1; older behavior) | Open |
|
||||
| C24 | Nit | A lane's ticket can leak. When the receipt of a claimed job cannot be confirmed, the worker records FAILED and then lets the job go (`PrintWorker.cs:104-109`); if recording FAILED throws, which needs a journal failure, the release is skipped and the runtime's ticket for the job stays registered (`AgentRuntime.Pipeline.cs:164-174`, `:328-347`). A later offer of that job then fails on the registry's duplicate key (W25), and a later job for the same printer is linked behind a ticket that nothing completes (noted as finding 4 in the review of an unmerged branch about jobs in hand; older behavior; the second effect is by reading the registry, not from that review) | Closed by pull request 44 (`6ad3001`), which that branch became: the lane lets go of the job whatever recording FAILED does (`PrintWorker.cs:93-100`), and the runtime completes the job's ticket when it is told so (`AgentRuntime.Pipeline.cs:169-179`, `:344-353`), so no ticket is left for a later offer of the job, or a later job for that printer, to meet. `PrintWorkerOutcomeTests.InterruptedReceiptLetsGoOfTheJobEvenWhenRecordingItsFailureFails` holds the worker's side; that the runtime's hook then completes the ticket is by reading |
|
||||
| W59 | Not rated | A PDF with no pages is not refused. It is started and ended as one empty document, the job gets that document's receipt, and the service records the job as spooled although nothing was drawn (the page loop at `WindowsPrinters.cs:267-277`). Older behavior; since pull request 42 a test pins it (`WindowsPrintersPdfTests.PdfWithoutPagesIsStartedAndEndedAsOneEmptyDocument`) (left open by pull request 42) | Open |
|
||||
| W60 | Not rated | A PDF page too small to cover one device pixel fails after the document has started, with an error that is not its own: the destination has no width or height, `StretchDIBits` copies no row, and the failure is reported as whatever Windows error was left behind; the document is aborted (`WindowsPrinters.cs:309-310`, `:334-335`). Older behavior; since pull request 42 a test pins it (`WindowsPrintersPdfTests.PageSmallerThanOneDevicePixelFailsTheJobAndAbortsTheDocument`) (left open by pull request 42) | Open |
|
||||
| W61 | Nit | The worker fixture's fake `OpenPrinter` leaves its marker error (0x0BADC0DE) as the last error for an unknown queue, where Windows sets an error of its own (`tests/Qqq.PrintAgent.WorkerFixture/Program.cs:156-161`) (review of pull request 42) | Open |
|
||||
| W62 | Nit | One test property has no documentation comment. The pull request does not name it; by search at `e2ca086` it is `PrinterWorkerIsolationTests.cs:21` (review of pull request 42) | Open |
|
||||
| W63 | Nit | In `WindowsPrinterSettings` an allocation and a pin happen before the `try` that frees them, so a pin that fails, which only running out of memory can cause, leaks the buffer (`WindowsPrinterSettings.cs:65-67`; two pins at `:91-93`). The code before the step had the same shape (review of pull request 42) | Open |
|
||||
| W64 | Nit | `WindowsSpoolerJobQueue` casts the buffer size unchecked (`WindowsSpoolerJobQueue.cs:36`), where `WindowsPrinterSettings.cs:32` checks it. Covered code caps the size, so neither can overflow today (review of pull request 42) | Open |
|
||||
| C25 | Not rated | The worker runs a full printer discovery on every pass of a lane, before it claims (`PrintWorker.cs:40`, in `RunOnceAsync`), so every claim waits on every queue's driver, the effect W44 describes for the print helper. Found by the author of the fix for W44 and read at `e2ca086` | Closed by the change that gave a printer backend three reads (ADR-0004, "About printing"): the pass lists the queues before the claim with the read that asks no driver (`ListWithoutMedia`, the spooler's enumeration alone), in the same place, so a spooler that cannot list its queues still stops the pass before it claims, and so does an enumeration held by a printer connection to a print server that does not answer (W70). The check after the download reads the job's own queue only (`FindWithMedia`). The worker no longer calls the full discovery |
|
||||
| W65 | Not rated | The inventory synchronizer runs a full printer discovery on every status round (`AgentRuntime.RealtimeStatus.cs:69`, in `RealtimeSynchronizeAsync`). A failure there is not caught in the loop, so it ends the realtime connection (`AgentRuntime.Realtime.cs:217`); and dispatch is held after every reconnect until one full discovery has been sent and accepted (`AgentRuntime.Realtime.cs:197`, `AgentRuntime.RealtimeStatus.cs:80`, `AgentRuntime.Controls.cs:64`). Found by the author of the fix for W44 and read at `e2ca086` | Partly closed by the change that gave a printer backend three reads (ADR-0004, "About printing"). Closed: a failed read no longer ends the connection. It is caught in the loop (`TryReadInventory`; cancellation excepted), logged as an error once per reason, nothing is sent in place of the inventory, and the inventory the connection already delivered stays in force, so jobs go on being claimed when the cause is one queue's settings; a cause the lanes share, the spooler's list or the journal, stops them too. Open: the synchronizer still runs the full discovery on every round, and dispatch is still held after every reconnect and every restart until one full discovery has been sent and accepted, so a queue whose driver fails every time still stops printing from the next reconnect or restart on. What is to be done about that is decided and not built (ADR-0004, "About printing": report such a queue as UNKNOWN with no formats, read each queue in its own helper when the helper for all of them fails) |
|
||||
| W66 | Not rated | The print helper reads the settings of the job's own queue before the document and discards the result: `PrintFileAsync` looks the queue up with its media and uses its name, driver, port and formats only; the selected-media check (`RequireCurrentMedia`) and the PDF path read what they need themselves. The read is redundant, but it is what reached Windows before, and removing it changes what Windows is asked for the printer being printed to (ADR-0004, "About printing"). Found while fixing W44 | Open. To be removed together with a check on real printers, not before |
|
||||
| W67 | Not rated | The companion shows a service problem (red, with the reason) while jobs print. Its picture of the service comes from the pipe's `snapshot` request, which carries the printer list and so runs the full discovery (`AgentRuntime.GetSnapshot`); `status`, `printers` and a sharing change do the same. With one queue whose driver hangs or fails, each of these fails with the reason after up to 20 seconds, and the companion shows a rejected snapshot as a service problem (`MainWindow.ApplyStatus`), although jobs for other printers keep printing since W44 and C25 were closed. The Jobs page and the connection figures use other requests. The pipe answering with the reason and going on is tested (`AgentRuntimePrinterReadsTests`); the companion's side is by reading. What the sharing change costs an operator who wants to withdraw a printer has its own row, W71. Found while fixing W44 | Open |
|
||||
| W68 | Not rated | The management pipe serves one request at a time (one pipe instance, served in a loop in `AgentRuntime.ServePipeAsync`), so a request behind a slow one waits for it. A `snapshot` that takes 20 seconds because of W67 delays whatever the operator asks next, a cancellation included, and the companion asks for a snapshot on a timer. By reading; older behavior. Found while fixing W44 | Open |
|
||||
| W69 | Not rated | The server's `REFRESH_INVENTORY` command runs the full discovery (`ExecuteControlCommandAsync`) while it holds the gate that commands execute under one at a time (`RealtimeCommandsAsync`). With a queue whose driver hangs, the gate is held for up to 20 seconds, during which a `CANCEL_SPOOL` waits, and the command is then answered OUTCOME_UNKNOWN; a discovery that fails with an I/O, Windows or invalid-operation error is answered FAILED, and a failure of another type (a helper answer that cannot be read) is not caught there and ends the connection, as any unexpected command failure does. The lanes are not stopped by the wait. By reading; older behavior. Found while fixing W44 | Open |
|
||||
| W70 | Should-fix | A printer connection to a print server that is switched off or unreachable can still stall the list, the lookup and the print, for every printer on the station. All three reads begin with the spooler's enumeration, `EnumPrinters` for local queues and connections (flags 6) at level 2, called once for the size and once for the list (`WindowsPrinters.cs:128`, `:135`, in `EnumerateQueues`; `WindowsSpooler.cs:19`, `:37`). Microsoft documents that an enumeration at level 2 performs an `OpenPrinter` on each remote connection and, when the connection is down, its server no longer exists or its printer no longer exists, waits for RPC to time out (the remarks on `PRINTER_INFO_4`). The list runs before every claim with a 20 second limit (`IsolatedPrinterBackend.cs:74`). A wait beyond the limit ends the pass before it claims, so no job is taken; a lookup that runs out of time fails its job before SUBMITTING; a print that runs out of time ends as OUTCOME_UNKNOWN. So closing W44 and C25 covers a broken driver on the station and does not cover a dead print server. The comments, the changelog, `docs/REALTIME.md` and ADR-0004 said that the list asks no queue anything and that no queue can slow it or fail it; they now say that the service's own process opens no queue, and state this limit. Older behavior: the full discovery made the same two calls. From Microsoft's documentation and by reading the code; not measured on Windows, including whether the service's identity has such connections on a station and how long the wait is (review of the one-queue change, S1) | Open. The enumeration call is deliberately unchanged. Listing at another level (level 4 is answered from the registry and opens nothing) and reading level 2 for one queue would change what reaches Windows, so it belongs with the hardware check (ADR-0003), whose list for the one-queue change has the connection case |
|
||||
| W71 | Nit | Unsharing a printer runs the full discovery although it needs no list. The pipe's `set-exposure` reads every queue before it changes anything (`AgentRuntime.cs:316`), and the store looks at that list only when a printer is being shared (`AgentStore.cs:280`). With one queue whose driver hangs or fails the request fails, so the operator cannot withdraw a printer locally, while jobs for it are still claimed and printed, as they are since W44 and C25 were closed. Leaving the list out when unsharing is not enough: the reply is a snapshot (`AgentRuntime.GetSnapshot`), which runs the full discovery again and fails (W67). That the request fails with the reason and changes nothing is tested (`AgentRuntimePrinterReadsTests.PipeRequestsThatReadTheInventoryFailWithTheReasonAndThePipeGoesOn`). Until this row it was recorded only inside W67 (review of the one-queue change, nit 2) | Open. Not changed in the fix round of that review |
|
||||
| W72 | Nit | The one-queue change is tested in parts and not in one piece. No test goes from the service through `IsolatedPrinterBackend` and a helper process to the real `WindowsPrinters` over the fake Windows. The whole-pass test (`AgentRuntimePrinterReadsTests.JobIsClaimedRecheckedAndSpooledWhileAnotherQueuesDriverDoesNotAnswer`) runs the real runtime over the real `WindowsPrinters` and the fake Windows in one process, without a helper; the helper boundary is tested against the worker fixture, whose helper answers from a fake backend and not from `WindowsPrinters`. Two queues with one identifier are tested at the Windows level only (`WindowsPrintersThreeReadsTests.TwoQueuesWithOneIdentifierAreRefusedBeforeEitherIsRead`), not through the helper or the worker. A test in one piece was not added in the fix round, because it is not small: the fake Windows is part of the test assembly and not of the fixture's executable, and the list, the lookup and the print of one job are three helper processes, so the fake's state would have to be carried from one process to the next (review of the one-queue change, nit 9) | Open |
|
||||
| W73 | Nit | A print helper's answer to the lookup whose entry names the queue asked for and has no formats is not refused in words of its own. The lookup checks the entry's identifier and returns the entry as it was answered (`IsolatedPrinterBackend.cs:63`, `:67`); the exposure filter in front of it then fails on the missing formats (`ExposedPrinterBackend.cs:68`). The job ends FAILED before SUBMITTING, and Windows is never asked to print, but the reason recorded is the platform's text for a missing value ("Object reference not set to an instance of an object."), where an entry that is the JSON word null is refused as "Printer worker returned a printer that was not asked for." Only an answer that is not the shipped helper's can look like this, because the shipped helper writes the formats of every queue it answers with. Pinned by two tests (`IsolatedPrinterBackendTests.AnswerWhoseEntryHasNoFormatsIsReturnedAsAnsweredAndTheExposureFilterFailsOnIt`, `PrintWorkerPrinterReadsTests.LookupThatAnswersWithAPrinterWithoutFormatsFailsTheJobBeforeSubmitting`) (confirmation review of the one-queue change, nit 1) | Open |
|
||||
| W74 | Should-fix | `ManagementJson.ParseRequest` and `ParseReply` throw instead of answering. JSON's grammar lets a string hold the escape of a surrogate without its partner (`\ud800` on its own, six ASCII characters on the wire). In the operation, the server selector, a member's name, or an error's code or message it makes the parse throw `InvalidOperationException`, and text that holds such a surrogate itself makes it throw `ArgumentException`. The parser's summary says it tells apart everything that can be wrong, and "Text on the wire" says a surrogate without its partner becomes U+FFFD, which is true of a string and not of the escape. Older than the contract fixes. Any administrator's program can send it once the pipe is served. Since the envelope is written by hand, `ManagementJson.Write` of a payload read with such an escape in a value throws `InvalidOperationException` where the serializer threw `JsonException` (review of the contract and redaction fixes, S2) | Closed in the fix round after that review (2026-11-10). Neither function throws for anything a text holds. The escape is answered where it stands, by the distinction the parser already had for that place: a malformed operation, a malformed server, an incomplete reply for a code or a message (not "not JSON", which a client takes for a version 1 service), and not JSON for a member's name at any depth and for text that holds the surrogate itself. In a value of the arguments, a result or the details the envelope is read and the record is not, as before. `ManagementJson.Write` fails with `JsonException` again, on purpose. ADR-0004, "Text on the wire", and the comments say what holds for a string and what for the wire |
|
||||
| C26 | Nit | The text of an origin has no place for an IPv6 zone. `QqqOrigin.Of` returns an origin that keeps the zone in the host it connects to and compares, so the session client's same-origin rule and the certificate exception's cover tell two zones apart; but the origin's address and authority as text do not hold it (`QqqOrigin.cs:39-40`). Whatever shows or stores an origin as text therefore gives `[fe80::1%25eth0]`, `[fe80::1%25eth1]` and `[fe80::1]` as one place (`QqqOrigin.ShownAuthority`, the text form of `QqqCertificateExceptionOrigins`). The contract compared providers by that text, so `SignInProviderOrigin.Agrees` agreed for two zones of one address, and `ServerAddress.TryRead` returned a zoned address without its zone. It is the one case the review found in which two different places compared equal (review of the contract and redaction fixes, nit 1) | Closed in the contract in the fix round after that review (2026-11-10): an IPv6 literal with a zone is refused as a server's address (`HostNotNameable`) and names no provider origin, so none is stored, compared or shown through version 2. Core is unchanged, because it compares the origins it holds with their zone and the rule that needed the fix is the contract's. Open in Core: the shown form of a zoned origin, for an address that reaches the session client another way (the version 1 sign-in) |
|
||||
| W75 | Nit | Two ways around the contract's checks remained inside the assembly, outside what the fixes claimed. `ManagementError.FromWire` was internal and took a code, a message and details as given, so code in the service's own assembly could send text that is not a code, an empty message, or details for a code that carries none; only a comment said that the parser alone calls it. `ManagementReply` and `ManagementRequest` were positional records: a reply could be built with any version, with an error beside a result or with neither, and a request with any text as its operation or its server (review of the contract and redaction fixes, nit 5) | Closed in the fix round after that review (2026-11-10). A request and a reply have no public constructor and no member that can be set; they are built by `ManagementRequest.For` and by `ManagementReply.Success` and `Failure`, which the parser uses too. A request is always version 2, with an operation shaped like a name and a server named by a full identifier or not at all; a reply is always version 2, with exactly one of a result and an error. `ManagementOperations.Check` no longer asks about the version or the spelling of the identifier, because no request can have another. The parser's way into an error takes the error object as JSON and nothing else. The text on the wire is unchanged: no golden file changed. Left as it is: that way in is internal, so code in the assembly that builds a JSON object for the purpose can still have an error read from it; and the parser's two result records, `ManagementRequestParse` and `ManagementReplyParse`, can be built by hand, which nothing sends |
|
||||
| W76 | Nit | A server's name may begin with a hyphen, and may be spelled like an option of the command line: `-h`, `--server` and `-` all pass `ServerName.Check`, which matches the rule ADR-0004 states. The name rule is about what a name is; nothing in it knows the command line. The command line is where it matters: a name given as the value of `--server`, or as the new name in a rename, must be taken as a value and not as an option (review of the contract and redaction fixes, nit 10, second part) | Open, for the step that builds the command line's server commands: it either takes such a name as a value however it is spelled, or the rule is changed to refuse a leading hyphen. A test holds that the rule accepts these names today, so the choice is made on purpose |
|
||||
| C27 | Nit | Core's one definition of a shown character goes by Unicode category (`ServerText.cs:30-31`), and its comment says it is false for characters that hide text. By category some characters are shown that draw nothing: the Hangul fillers (U+3164, U+115F, U+1160, U+FFA0) are letters, the blank Braille pattern (U+2800) is a symbol, and the combining grapheme joiner (U+034F) and the variation selectors are marks. Each passes every rule built on the definition, so one of them can be a whole error message (the service's check that a message says something takes it for text), a whole identity value in a reply, or a whole value in the session client's problem details and log lines. Core's definition was not changed by the contract fixes (review of the contract and redaction fixes, nit 6) | Open for Core's definition, which that fix round did not change: a message and an identity value made only of such characters are still carried. Closed for a server's name, where it is a trap for the operator, by the contract's own rule: `ServerName.Check` refuses a name in which nothing draws (`ShowsNothing`), which is a name made only of those characters, of combining marks and of spaces |
|
||||
| W77 | Should-fix | No test can tell whose account is read. With the peer's process still held, reading the account from the calling process instead of from the peer passes every test (`WindowsPeerProcess.cs:35`, the handle given to `OpenProcessToken`); the same change made without holding the peer's process is caught, by the test that the process stays held. Every process a test without administrator rights can open runs under the test's own account, so the two readings agree. Only an authentication against a process of another account tells them apart: the first elevated authentication against the installed service, which runs as SYSTEM. The decision that the account must be SYSTEM is tested against the fake (review of plan step 8) | Open; in the upgrade qualification entry of `docs/TODO.md` |
|
||||
| W78 | Should-fix | No test fails when the real service handle's refresh does nothing (`WindowsManagedService.cs:27`). The adapter is read against a built-in service whose state does not change during the test, and controls are sent only to a name no service has. That the wait refreshes before each look is tested against the fake. Only starting or restarting a real service shows whether the adapter reads afresh. Read, not run: with a refresh that did nothing the wait would see the first state for 30 seconds and report that the service did not start (review of plan step 8) | Open; in the upgrade qualification entry of `docs/TODO.md` |
|
||||
| W79 | Should-fix | Each authentication of the pipe peer leaves two service handles to the finalizer. `WindowsPeerService.ReadStatus` reads `ServiceController.ServiceHandle` (`WindowsPeerService.cs:22`), which opens a new handle on every call and hands it to the caller; the status is read twice per authentication and neither handle is closed. Disposing the controller does not close them. The code is as it was in 1.2.1 (`AgentPipePeer.ReadStatus` there). Read, not measured: a companion that asks the service every few seconds holds open service handles until the garbage collector runs. Found by the author of plan step 8 and confirmed by its review | Open |
|
||||
| W80 | Nit | When the update session's folder fails its second check, the one made after the folder is created, the empty folder is left under the updates folder (`MsiUpdateService.cs:186-187`): nothing was downloaded into it, and removing it is refused by the same check. `NothingIsDownloadedIntoAFolderThatCannotBeTrusted` pins this. An administrator finds an empty folder with a random name (found by the author of plan step 8 and confirmed by its review) | Open |
|
||||
| W81 | Nit | The link check of the recorded outcome before it is written (`MsiUpdateSteps.Apply.cs:201`, on `last-result.json`) has no refusing test: with the check removed every test passes. The path is a file, so showing the refusal needs a file symbolic link, which Windows lets a test create only with a privilege or in developer mode; the other link checks are shown with junctions, which are folders (review of plan step 8) | Open |
|
||||
|
||||
Found while building the companion's sign-in for the connection wizard (2026-11-10), by its author:
|
||||
|
||||
| ID | Severity | Finding | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| U22 | Not rated | A browser that cannot be started puts the address it was asked to open into the companion's log and, at sign-in, into the dialog. The companion starts the browser through the Windows shell, and when that fails .NET's failure text names what could not be started: "An error occurred trying to start process '<the whole address>' ..." (seen on .NET 10.0.14 with a shell start of a file that does not exist; no browser was involved). At a browser sign-in the address is the provider's authorization address, with that attempt's `state`, `nonce` and `code_challenge`; the text was logged at error level and shown as the reason of "Browser sign-in not completed", and the whole failure was logged again as debug detail (`SignInFlow.Browser.cs:98-103`, started at `:152`, read at `ebea861`). At a provider sign-out the address is the provider's end-session address, with the `id_token_hint` (the provider's ID token) and the sign-out's `state`; the text was logged as a warning and not shown (`SignInFlow.SignOut.cs:121-125`, started at `:114`). When this was found (at `ebea861`) the log file's own redaction removed the value of `state` from such a line in 2.0 and nothing else of it: `id_token_hint=` passed unchanged, which was the gap W40 recorded, and W40's note that no log line can carry it did not hold for this one. The redaction fixes that closed W40 now remove the ID token hint as well; a sign-in address still keeps its `nonce` and `code_challenge` (run against 2.0 at `4e01519`, with the text Windows gives for a failed start). The dialog's message is not redacted at all. It takes a failed browser start to happen: no default browser, or a broken handler for web addresses. Tags (from `git tag --contains` and the code at each tag): the sign-in case is in the four version tags `v1.1.0`, `v1.1.1`, `v1.2.0` and `v1.2.1` and in `snapshot-2.0.0-rc.1`. It came with `aaa5051` (2026-11-05), which added the log call and the "Reason:" text to the catch; in `v1.2.1` the start is `src/Qqq.PrintAgent/MainWindow.Authentication.cs:339` and the catch that logs and shows the text is at `:294-298`, and the log of these four versions has no rule for `state`, so an installed 1.1.x or 1.2.x system writes and shows the address whole. The seven version tags from `v0.2.0-rc.1` to `v1.0.1` start the browser the same way, since `2c1c5f1` (2026-10-31), but they catch the failed start and show a fixed text, and neither log nor show the address (in `v1.0.1` the start is `MainWindow.Authentication.cs:191` and the catch `:168-171`; the catch is the same at all seven, and the file has no log call at any of them). The four 0.1.x tags have no browser sign-in. Corrected on 2026-11-11, after the review of that day's documentation change read the code at each tag: this row had said that the sign-in case is in the eleven version tags from `v0.2.0-rc.1` to `v1.2.1` and that it was introduced by `2c1c5f1`. The sign-out case came with `5d4b7f4` (2026-11-09) and is in one tag, `snapshot-2.0.0-rc.1`, and in no version tag. That snapshot (commit `6f0237d`) has both cases unguarded (`SignInFlow.Browser.cs:152`, `SignInFlow.SignOut.cs:114`); `docs/SESSION-STATE.md` records it as a Gitea prerelease published on 2026-11-10 as a development snapshot and not installed anywhere. The unreleased `release/1.2.2` branch, which is in no tag, has both cases too (`MainWindow.Authentication.cs:303`, `:311` for the sign-out; `:395`, `:354` for the sign-in). Read from the code in each case, not run | Closed for 2.0 builds after `snapshot-2.0.0-rc.1` by the change that adds this row: every browser start in the presentation assembly goes through `BrowserLaunch`, which replaces the failure with fixed text (that the default browser could not be opened, the kind of failure or the Windows error number, and what to check), in the dialog's flow and in the new `CompanionSignIn` and `ProviderSignOut`. The snapshot itself has both cases and stays as published. Open for 1.2.x: `release/1.2.2` still has both cases, and 1.2.1 as installed has the sign-in case, as 1.1.0, 1.1.1 and 1.2.0 do |
|
||||
|
||||
Recorded on 2026-11-11: what the reviews of pull requests 45 to 53 left open and no row held, one test seen to fail under load, and one finding of the plan for the wizard stage. Every row was read against `release/2.0` at `02f34c3` that day. Nothing was built or run for this table; each row says what a reviewer ran and what is by reading only. Severity is the reviewer's where a reviewer rated the item, and "Not rated" where none did. The descriptions of those pull requests list their open items in a few words and name no places; where a row gives a place, it was found by reading the code. The numbers start at W90, C28 and U23. Three listed items have no row. Two could not be established from what is on record: the two sentences of documentation and the one comment in the realtime channel that the review of pull request 45 found too broad or untrue are not named anywhere, and neither is the one nit of the last review of pull request 50. (A comment in the realtime channel that is untrue was found on 2026-11-11 by the review of the change that added this table and is in C28, item 4; whether it is the comment the review of pull request 45 meant is not established.) The third is a typo in `docs/ADMINISTRATION.md`, which the description of pull request 46 lists without saying where: the one found on 2026-11-11 in the sentence that pull request added (a comma in front of a semicolon) was corrected in the change that added this table. U28 was added afterwards, by the change that answered the review of the identifier baseline (plan step 11 of the build): its severity is that review's, and its places were read in that change's tree.
|
||||
|
||||
| ID | Severity | Finding | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| W90 | Should-fix (rated on 2026-11-11 by the review of the documentation change that added this row; the review of pull request 48 classed it "record, fix later" and called it the most important of its nine) | Nothing reconnects the realtime socket when the load balancer moves this client's HTTP requests to another node while the socket stays open on the first. The session client keeps the balancer's cookies and follows a rewrite on its next request (`QqqSessionClient.cs:578-579`, `QqqSessionClient.Affinity.cs:47-87`), and it sends them on the next upgrade (`QqqSessionClient.Realtime.cs:86`). Nothing outside the session client sees that a cookie changed (by search: the cookies are private to it, and their count, the one thing it gives out, is read by its own log line and by tests). `docs/REALTIME.md`, "A server that runs as several nodes", says what follows: a server at QQQ Printing 1.1.x refuses a claim unless the socket is on the node that received it, so from then on every claim is refused until the socket closes for another reason, and the client does not close it on this account. No job is lost or printed twice. By reading, the runtime does not close the socket after a refused claim: the claim request fails on any answer that is not a success (`PrintApiClient.cs:86-91`), the worker lets that failure pass (`PrintWorker.cs:80-83` catches only a refusal that names a job), and the lane that made the claim ends the wave with one error line and nothing else (`AgentRuntime.Pipeline.cs:100-104`, `StopLane` at `:116-131`). The review of the documentation change that added this row traced that, and it was read again when the row was corrected. The review of pull request 48 lists this as item 5 of nine things the original fix leaves, calls it the most important of them and would schedule it inside 2.0 | Open, and listed under "Open for 2.0" in `docs/TODO.md` since 2026-11-11. The fix belongs in the runtime: reconnect when a claim is refused for this reason, or when the balancer rewrites its cookie |
|
||||
| W91 | Nit | A journal whose schema version is negative is refused at every start, and the line the service logs gives advice that does not apply. Such a version is refused as one no build writes (`JournalSchema.cs:94-99`). The start logs the failure with the text for a journal that could not be brought up to date (`AgentRuntime.cs:94`, `JournalSchema.cs:36-39`), which names the kind as "the migration stopped with InvalidDataException" and tells the operator to check that the folder and the file can be read and written, that no other program has the file open and that the disk has free space, and says that every start tries again. None of that repairs a version. No build writes a negative one. By reading (review of pull request 49) | Open |
|
||||
| W92 | Nit | While the stored state cannot be used, because a newer build wrote it, every request the service refuses writes one line to the log at debug level that repeats the operation's name as the client sent it (`AgentRuntime.cs:349-353`). A client that repeats a refused request writes one such line each time. By reading (review of pull request 49) | Open |
|
||||
| W93 | Nit | Two things the tests around the journal's opening do not hold. First, `AgentStoreOpenRaceTests.StoreOpensItsJournalOnlyWhileItHoldsTheJournalsLock` shows that a store waits for the journal's lock and that the journal file is closed while it waits. A store that took the lock, released it and then opened would wait in the same way; the reviewer of pull request 49 made that change and saw it caught once, by the loop test beside it (`StoresOpenedTogetherOnOneJournal_EachWorkOnTheirOwnConnection`), and by nothing else. Second, two paths that only tests take open a store outside what the service does when it starts. The runtime takes its stored state for ready until `RunAsync` has migrated the journal (`AgentRuntime.cs:33`, `:67`), so a `GetSnapshot` called before that opens a store on a journal the service has not judged; in `src` it is called only from pipe requests, which `RunAsync` serves. And the tests' own way to a store migrates before every open (`TestState.cs:28-32`), so the read-only reading of the version runs beside stores that write wherever a test opens stores from several threads. The reviewer saw no failure from either. By reading, except the reviewer's one change | Open |
|
||||
| W94 | Nit | That the pipe client authenticates with the installed product's check, and with no other, is shown by reading only. The client binds the check in one place: the one a test passes in, or `AgentPipePeer.Installed` (`AgentPipeClient.cs:79`). One test reads what `AgentPipePeer.Installed` is bound to (`AgentPipePeerTrustTests.InstalledProductsCheckIsBoundToWindowsAndTheServiceProgramUnderProgramFiles`). Tests that pass no check of their own show that a pipe served by the test process is refused with nothing sent (`AgentPipeSecurityTests.SubstituteServerReceivesNoRequestBytes`, `SubstituteServerIsRefusedAsUntrustedAndReportedAsNotSent`). A client bound to some other check that also refuses such a pipe, one that trusted another service program for example, would pass all of them; no such change was tried. Only an authentication against the installed service tells the two apart, which is the elevated run W77 waits for (last review of plan step 8) | Open; shown by the first real authentication in the upgrade qualification entry of `docs/TODO.md` |
|
||||
| W95 | Not rated | `AgentRealtimeBandwidthTests.ConcurrentDownloadsShareOneConfiguredBandwidthBudget` runs on real time. It waits at most five seconds for both downloads to start, at most ten for two prints that need about four seconds at the configured rate, and at most five more for both jobs to be recorded as spooled (`AgentRealtimeBandwidthTests.cs:42`, `:45`, `:51`). It timed out once in sixteen full verification runs under machine load and passed when run alone (description of pull request 49); which of the waits ran out is not recorded. These are timed waits of the kind W28 counts. The other tests known to fail under load have a row or a fix: the activity log's shared writers (W47), the scheduler's timeout test (W53), and the process test whose child exits without reading its input (`CliProcessTests.PrinterWorkerArgument_IsAnUnknownCommand`), which the process tests' helper has tolerated since pull request 46 (`CliProcessTests.cs:231-238`) | Open |
|
||||
| C28 | Nit | Left by the review of pull request 45, which closed C15. (1) An unreadable member name beside the job, outside it, refuses a job that is itself intact (`PrintJobContract.cs:71`); that is stricter than answering for the job needs. (2) The test rows for an unreadable name inside the job, inside its options and inside its media selection put that name first only (`ClaimUnreadableTextTests.cs:66-68`). (3) The queued-job list, which is only displayed, treats such text unevenly: an unreadable name beside the list is passed over, one inside an entry is skipped or refuses the whole list depending on its form, and unreadable text in a title refuses the list (`ClaimUnreadableTextTests.QueuedListWithUnreadableTextIsShownOrRefusedAsAProtocolViolation` pins this). (4) The realtime frame reader still has the pattern the fix removed from the claim: while it looks for repeated fields it asks each member for its name directly (`ProtocolJson.cs:87-97`, called at `RealtimeEnvelope.cs:33`), and that throws for a name that is not readable text, and not as a protocol violation. The reader's own summary says otherwise, that anything the server gets wrong ends the reader as a `PrintingProtocolException` (`PrintingRealtimeChannel.cs:214-219`); by reading, that comment is untrue for a frame with such a name. It was found by the review of the documentation change that added this row, on 2026-11-11, and is not established as the comment the review of pull request 45 meant. What the runtime does with that failure was not traced here; the pull request's description says the connection is made again. (5) Each unreadable name costs one thrown exception every time it is asked for (`ProtocolJson.cs:158-164`), and the members of a claim are looked up by name many times. The reviewer measured up to about 8 seconds and 1.6 GiB of short-lived allocation for a hostile response of 2 MiB, which only the server can send, and noted that reading the names of each object once would remove it. Items 1 to 4 are by reading; the figures in item 5 are the reviewer's and were not measured again | Open |
|
||||
| C29 | Nit | Left by the review of pull request 47, which closed the first half of C9. (1) The saved exception is still matched against a server's address in the form it was typed: `ServerTrust.AppliesTo` compares scheme, host and port as written, in lower case (`ServerTrust.cs:19-33`), where the exception itself compares a host by its ASCII name. A difference leaves the exception off. (2) The `Origin` request header is built from the address as it was typed (`QqqSessionClient.cs:623`, `QqqSessionClient.Realtime.cs:87`; the address is kept at `QqqSessionClient.cs:80`). The reviewer read this and did not run it. (3) A typed host that has no ASCII name is accepted by the session client's constructor (`QqqSessionClient.cs:73-80`), and every request then fails as "Cross-origin QQQ requests are forbidden." (`:548-552`, `:887`), which does not say what is wrong with the address. Version 2 of the pipe refuses such an address when it is read (`ServerAddress.TryRead`, W39), so this is left to the version 1 sign-in. (4) Two of the three refusals of a provider sign-out give several possible reasons in one sentence and do not say which rule failed (`QqqSessionClient.ProviderLogout.cs:105`, `:151`). All by reading. The same review's nit about an IPv6 zone, kept for matching and dropped from what is shown, is C26 | Open |
|
||||
| C30 | Not rated | Three limits of the load balancer's cookies, which the port in pull request 48 left as the original fix had them. (1) Nothing bounds the total: eight cookies are kept, each with a name of up to 64 characters and a value of up to 4,096 (`QqqSessionClient.Affinity.cs:22-23`, `:83`, `:129`), so the Cookie header of every later request can reach about 33 KB, until the client object is replaced; the review states the consequence, that a broken or hostile server can in this way push every request past the load balancer's header limit (what that limit is was not checked for this row). (2) A cookie's `Domain` is compared with the host of the address as it was typed (`:73`), not with its ASCII name; a difference only drops the cookie. (3) The lock is taken for each `Set-Cookie` header and not for each answer (`:79-85`), so a request sent between two headers of one answer can carry a cookie for the new node beside one for the old, once. The first two are items 7 and 9 of the nine the review of pull request 48 lists, both classed "record, fix later"; the third is that review's nit 2, and the only one of the three a reviewer rated. By reading | Open |
|
||||
| U23 | Nit | In the connection dialog's sign-out, a failure of the browser callback inbox that is not one of five listed kinds leaves the flow as an error nobody handles. The provider's half of the sign-out catches cancellation, input or output errors, invalid data, refused access and Windows errors (`SignInFlow.SignOut.cs:133`); the flow's runner catches only a closed flow and its caller's cancellation (`SignInFlow.cs:403`); and the window's handler has no catch (`MainWindow.Authentication.cs:158-176`). The real inbox can fail in another way: it looks up the process that sent a callback, which gives an argument error when that process has already exited (`QqqBrowserCallback.cs:172`). By then the service has signed out, and the last thing the dialog said is to finish signing out on the page that opened in the browser. Pull request 53 closed the same hole for the dialog's browser sign-in (`SignInFlow.Browser.cs:178`) and in the types it added, and not here. The reviewer's probe showed an `ArgumentException` leaving the flow; read again at `02f34c3` (last review of pull request 53) | Open |
|
||||
| U24 | Nit | When the callback inbox itself cannot be opened for a provider sign-out, the dialog says that the provider's sign-out "was opened in your browser but did not confirm", although no page was opened. The inbox is opened before the browser is started (`SignInFlow.SignOut.cs:114`, `:120`), a failure to open it is returned as not confirmed (`:133-137`, as the method's summary says), and the text for that result is the one for a page that was opened (`SignInMessages.cs:161`). A browser that could not be started has had a text of its own since pull request 53 (`SignInMessages.cs:160`). By reading (last review of pull request 53) | Open |
|
||||
| U25 | Nit | The callback dispatcher reads a sign-out's callback more strictly than the rule the sign-out is matched by. `BrowserCallbackDispatcher.ReadState` applies what the session applies to a sign-in callback: no name twice, no control character in a name or a value, a query of at most 16,384 characters (`BrowserCallbackDispatcher.cs:113-135`). `QqqProviderLogout.Matches` asks for the registered address with no fragment and no user information and, of the query, only that every entry is a name and a value and that `state` appears once, with this sign-out's value (`QqqProviderLogout.cs:36-56`). A sign-out callback that repeats another name or holds a control character is therefore dropped by the dispatcher, and `ProviderSignOut` waits out its two minutes and ends as not confirmed, where its own rule would have confirmed it. The connection dialog's sign-out reads an inbox of its own and is not affected, and the window does not use `ProviderSignOut` yet. The fix round of pull request 53 wrote this down as a consequence in its commit message, and the reviewer's probe showed it; read again at `02f34c3`. The description of pull request 53 lists it as the wording of `ReadState` (last review of pull request 53) | Open |
|
||||
| U26 | Nit | Left by the review of pull request 46, which closed U20 and U21. (1) One older process test names a server address that could be sent (`CliProcessTests.cs:108-110`); it is safe only because it gives the program no input, so the program stops before it connects. The newer rows name an address too long for the pipe, which is refused before a connection is opened (`:25`, `:182-184`). (2) A code that holds a replacement character or a control character is sent to the service as it is, because the tool checks only that the code is not empty (`CommandLine.cs:243-259`); it could refuse such a code itself and say what to do. `CliPipedInputTests.PipedBytesThatAreNotUtf8AndCarryNoMarkAtTheStart_AreNotRepaired` pins what is sent. (3) The reader that decodes piped input takes its encoding from a byte-order mark (`CommandLine.cs:276`), and by that reader's documentation it honors a UTF-32 mark as well as the UTF-8 and UTF-16 ones. Neither the comment above it nor `docs/ADMINISTRATION.md` mentions UTF-32, and no test row has such a mark. (4) The process tests' helper skips closing the child's input when writing to it fails, because the close stands inside the `try` that the failed write leaves (`CliProcessTests.cs:231-238`). By reading; item 3 rests on the reader's documentation and was not run | Open |
|
||||
| U27 | Not rated | The older types of the presentation assembly log finished text that they build by joining strings: 36 log calls in `ServiceClient.cs` and the `SignInFlow` partials have a `+` or an interpolated string on the line of the call (a line search at `02f34c3`, which misses a call whose text begins on a later line). K22-CODE-CS-10 asks for message templates; this is the pattern of C3 in this assembly. The log has taken a template and its values since pull request 53 (`IPresentationLog.cs:38-53`), and the types that pull request added log through it. Found by the plan for the wizard stage (ADR-0005, G21) | Open. Not rewritten by decision: these types go when the window stops using them |
|
||||
| U28 | Should-fix | Three texts that carry a result are not announced, so an operator who cannot see the screen does not hear them. (1) The Jobs page's summary line is the search's result count, and it is where "History unavailable; check the service and refresh" and the note that the rows shown may be stale are written (`MainWindow.Jobs.cs:100`, `:114`); when the service cannot be reached nothing else reports it. (2) The text under the sign-in startup switch is the only place the result of the switch is written (`MainWindow.Actions.cs:229`); the message bar is used only when the change throws (`:233`), and the switch is then put back to the saved state (`:237`), so a switch that moved back is explained by this text alone. (3) The connection dialog's message bar cannot be closed (`MainWindow.xaml:140`) and the window only ever opens it (`MainWindow.xaml.cs:414`); WinUI announces a message bar when it opens and not when an open one changes, so after the bar's first opening a new result in the dialog is announced by nothing, and a second result while the main window's bar is already open is announced by neither. None of the three declares a live region, and the window raises the live-region event for the footer only. By reading (review of the identifier baseline, plan step 11 of the build); what Narrator says was not tried. `XamlContractTests` lists the three as not announced with these facts, and changes no behavior | Open. To be decided in the view step, with U9: the dialog goes with the wizard, and the other two are rewritten there |
|
||||
| W120 | Not rated | An uninstall that has to leave a shortcut of ours behind forgets it, and only an install under the same display name finds it again. By decision of 2026-11-11 no shortcut file fails an uninstall: a recorded shortcut that is held by another program after nine tries over two seconds, or that cannot be opened or deleted for another reason, is left where it is and the registry record is removed with the rest (`AgentBrandingShortcuts.RemoveInstalledShortcut`, `AgentBrandingShortcuts.cs:148-172`). The file is then a shortcut to a program that is gone. A later install under the same name takes it over (`OursToReplace`, `AgentBrandingShortcuts.Files.cs:190-207`); one under another name leaves it, and nothing ever removes it. The same holds for every recorded shortcut in a folder that is no longer trusted, in which the uninstall now opens and deletes nothing (`TrustedForRemoval`, `AgentBrandingShortcuts.Files.cs:287-299`). 1.2.1 and the 2.0 builds before this kept the record and failed the uninstall instead, which Windows Installer rolls back. Keeping the record through an uninstall would let a later install remove such a shortcut under any name; that was not chosen | Open. Accepted by the decision; written down in `installer/README.md` and `docs/TROUBLESHOOTING.md` |
|
||||
| W121 | Should-fix | No real install, upgrade or uninstall has exercised the handling of a held shortcut. The rules are shown by tests that hold real files in temporary folders, with fakes for folder security and the registry record, and through the real shortcut shell on the development PC only. Not shown: the two custom actions running as SYSTEM under Windows Installer (`installer/Package.wxs:37-38, 44-45`), what another program really does to a `.lnk` (a virus scanner, a backup, the Start menu), that eight pauses of 250 ms are enough for any of them, that the real pause is taken (`Thread.Sleep`, bound in `AgentBrandingInstaller.cs:23`), and what Windows Installer shows and logs when one of the remaining failures happens. The statement in the documents that it shows only its general message is from Windows Installer's rules, not from a run | Open. The cases are listed for the upgrade qualification in `docs/TODO.md`; they need an administrator and the owner |
|
||||
| W122 | Nit | Three places where a file that another program holds can still fail an install. (1) The temporary file the new shortcut is written to is deleted in a `finally` (`AgentBrandingShortcuts.Files.cs:75-81`) without the tries a file in use is given: when that delete fails, the run fails, also a run that had just left a held shortcut as it is. (2) A first install has nothing to fall back on: when the new shortcut cannot be moved into place after the tries, the install fails, as before. (3) When a shell that cannot do its work reads a recorded shortcut at the first look, the install stops (`Judge`, `AgentBrandingShortcuts.Files.cs:155-177`, passes an `InvalidOperationException` on), while an uninstall leaves the file and goes on. None of the three is new | Open |
|
||||
|
||||
|
||||
### Repository
|
||||
|
||||
| ID | Requirement | Finding | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| R1 | K22-TEST-10, -02 | Coverage collected, never gated; no exclusion attributes | Gate closed by pull request 20 (`1431d8c`). Core, the command line and the presentation assembly are at 100% and gated there (pull requests 29, 28 and 34). Windows was gated at 86 with pull request 42, when it measured 87.47% on that pull request's reviewed head, and at 87 with pull request 49. Since plan step 8 of the build (the update, service-control and pipe-peer seams) it is gated at 95 and measured between 96.06% and 96.14% over four runs (4,539 to 4,543 of 4,725 lines), above its release bar of 90; the exception record for that bar (EX-0016, standards pull request 58) has the status "Proposed, awaiting approval". Exclusions now exist and each carries a written reason: four adapter classes in Core; in the Windows layer nine one-line members (`AgentBrandingInstaller.cs`, `AgentStartup.cs`, `QqqBrowserCallback.Paths.cs`, `QqqBrowserCallback.Trace.cs`) and, since pull request 42, six adapter classes (`WindowsSpooler`, `WindowsGdiPrinter`, `WindowsPrinterSettings`, `WindowsPdfRenderer`, `WindowsSpoolerJobQueue`, `ParentProcessWatch`). Counted at `e2ca086`. Changed-line coverage unmeasured |
|
||||
| R2 | K22-TEST-25, -23 | No fuzz targets, `docs/security/fuzzing.md` or `assurance.json` | Open |
|
||||
| R3 | K22-TEST-03 | UI tests are not in the release checklist | Open |
|
||||
| R4 | K22-CI, K22-REPO | Requirements written for GitHub; the repository's primary host is Gitea, which ran no checks for it | Open; CI is owned by the Gitea CI work |
|
||||
| R5 | K22-REPO-02, -06, -11, K22-REL-20 | README and SECURITY content out of date; no support policy | Open |
|
||||
| R6 | K22-REL-02, -06, -12, K22-DEP-40 | Static release notes, no checklist, no SBOM attached | Open |
|
||||
| R7 | K22-DEP-10, -20 | Dependency updates do not cover npm and uv | Open |
|
||||
| R8 | Documentation | At least eleven files describe 1.0.1 as current; the session-state file is mostly history | Open |
|
||||
|
||||
## Re-audit
|
||||
|
||||
Not yet run.
|
||||
+5
-3
@@ -7,10 +7,10 @@ There are two levels. A **client edition** is a separate, fully branded build an
|
||||
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
|
||||
./build/Build-Installer.ps1 -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.
|
||||
The result is `artifacts/installer/<assetPrefix>-<version>-x64.msi`, for example `GgForgePrintAgent-2.0.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.
|
||||
|
||||
@@ -64,7 +64,9 @@ To provide customer artwork, add `"logoPath": "acme-logo.png"` and/or `"iconPath
|
||||
|
||||
The MSI persists `DisplayName` and `CompanyName` in `HKLM\SOFTWARE\QRunIO\PrintAgent`. Future installations of the ordinary shared MSI, including the in-app updater, recover these values when new `BRANDNAME`/`BRANDCOMPANY` properties are not supplied. ProgramData branding/assets and registry labels are retained through upgrades and full uninstall with device state. Removing `branding.json` therefore does **not** erase persisted customer labels.
|
||||
|
||||
The installer creates the companion shortcut under **Start → Print Connectors**. Unsafe filename characters are sanitized; truncated or sanitized names receive a stable hash suffix. Rename cleanup and full-uninstall cleanup only remove the tracked shortcut after verifying its exact installed target, arguments and ownership marker. Other shortcuts are left alone. An existing conflicting shortcut causes an explicit installation error rather than an overwrite.
|
||||
The installer creates the companion shortcut under **Start → Print Connectors**. Unsafe filename characters are sanitized; truncated or sanitized names receive a stable hash suffix. Rename cleanup and full-uninstall cleanup only remove a tracked shortcut after verifying its exact installed target, arguments and ownership marker, immediately before deleting it. Other shortcuts are left alone. An existing conflicting file under the wanted name causes an explicit installation error rather than an overwrite; from 2.0 the name is checked again immediately before the shortcut is put in place.
|
||||
|
||||
From 2.0 the installer tracks, by name, every branded shortcut it has put there, until that shortcut is gone, is found not to be its own any more, or the product is uninstalled, and treats what it finds under a tracked name as follows. A file that is not ours (someone else's shortcut, a file that is not a shortcut at all, or a link) is left unchanged, noted as a warning in the activity log and no longer tracked; neither an install nor an uninstall fails because of it. A file that cannot be opened, because another program holds it, is not judged, and so it is never deleted or overwritten. A file that Windows reports as in use is first tried nine times over two seconds. If a tracked shortcut still cannot be opened, deleted or replaced, neither an install nor an uninstall fails because of it (decided 2026-11-11; not yet exercised by a real installer run): an install, upgrade or repair leaves it as it is, keeps tracking it and replaces or removes it the next time, and an uninstall leaves it where it is and stops tracking it, so that it has to be deleted by hand unless the product is installed again under the same display name. Each case is a warning in the activity log with the file name and the kind of the failure. Only a file under the wanted name that is not tracked and cannot be opened still stops an install, before anything is changed: close the program that holds it and run the installer again. See the [installer notes](../installer/README.md) for the full rules and [Troubleshooting](TROUBLESHOOTING.md) for where the reason of a failed step is logged. A previous brand's shortcut that cannot be removed during a rename stays tracked, with a warning, and is removed by the next install, upgrade, repair or uninstall. A read-only mark on a shortcut of ours is cleared before the shortcut is replaced or removed. The temporary file a shortcut is written to before it is put in place is not tracked; one left in the folder by an interrupted run (`.qqq-` followed by 32 hexadecimal digits and `.lnk`) is removed by the next run or the uninstall.
|
||||
|
||||
For a customer rename, rerun the wrapper with revised JSON and the intended MSI. This updates service/product/shortcut labels together with runtime settings. To return to built-in labels, explicitly deploy `displayName: "QQQ Print Agent"` and `companyName: "QRun-IO"` through the wrapper. Omitting `-BrandingFile` preserves existing configuration. Software-management systems may instead supply `BRANDNAME` and `BRANDCOMPANY` as MSI public properties; JSON takes precedence for companion presentation, so keep both sources consistent.
|
||||
|
||||
|
||||
+71
-9
@@ -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).
|
||||
The source version is `VersionPrefix` in `Directory.Build.props` (see CHANGELOG.md for what each version changed), with native printing 2.3 (wire payload v1).
|
||||
See [realtime architecture](REALTIME.md) for worker separation, policy, streaming
|
||||
and reconnect rules. The service never falls back from WSS to job/control polling.
|
||||
|
||||
@@ -11,11 +11,14 @@ and reconnect rules. The service never falls back from WSS to job/control pollin
|
||||
| Qqq.PrintAgent.Core | QQQ authentication, HTTP/WSS clients, policy, streaming and submission state machine |
|
||||
| Qqq.PrintAgent.Windows | Native spooler/PDF adapter, SQLite/DPAPI store and local management runtime |
|
||||
| Qqq.PrintAgent.Service | .NET Windows Service host |
|
||||
| Qqq.PrintAgent | AppKit/WinUI setup, details and tray companion |
|
||||
| Qqq.PrintAgent.Cli | Authenticated service management and explicit isolated legacy diagnostics |
|
||||
| Qqq.PrintAgent.Presentation | The companion's presentation logic without a window: typed service client, texts, enablement rules, paging and sorting state, sign-in state machine |
|
||||
| Qqq.PrintAgent | AppKit/WinUI setup, details and tray companion; views call the presentation assembly |
|
||||
| Qqq.PrintAgent.Cli | Management of the installed service over its pipe; also the MSI update helper and browser sign-in callback forwarder |
|
||||
|
||||
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.
|
||||
|
||||
That paragraph describes version 1 of the pipe, which the service still speaks. Version 2 ([ADR-0004](adr/0004-multi-server-runtime.md)) exists so far as a contract only, in `src/Qqq.PrintAgent.Windows/Management`: the envelope (`ManagementRequest`, `ManagementReply`, `ManagementError`, with arguments, results and details held as a `ManagementPayload` whose text form is fixed so none of them can land in a log line), the one serializer (`ManagementJson`), the error codes (`ManagementErrorCode`), the operation table (`ManagementOperations`: scope, kind, time limit, argument and result record for each of the 30 operations, and which of them is single-flight), one argument record per operation that takes arguments, the result records, and `ServerSelector`, which resolves the command line's `--server`. Nothing serves or sends it yet. Its wire form is held by golden files in `tests/Qqq.PrintAgent.Cli.Tests/Fixtures/management`: one request and one reply per operation, one failure per error code that carries details, and two further snapshots as a client will really see them (two healthy servers; a service whose stored data is not ready). A change to a record changes its golden file and the matching example in `ManagementSamples.cs` in the same commit; the tests fail when an operation or such a code has no golden file, or a file has no operation. Additions to a result are compatible (readers skip unknown members, and treat an unknown value of a vocabulary as unknown); a request with an unknown member is refused; a member repeated at any depth, a missing required member and a null inside a list the record knows are refused in both directions. An envelope is read only by `ManagementJson.ParseRequest` and `ManagementJson.ParseReply`, which answer everything a text can hold and never throw (a surrogate without its partner included, as itself or as the escape `\ud800`), and written only by `ManagementJson.Write`; a request and a reply are built only by `ManagementRequest.For` and by `ManagementReply.Success` and `Failure`, which the parser uses too, so a request always names its operation by a well-formed name and a server by a full identifier, and a reply always holds exactly one of a result and an error; the envelope types refuse the serializer in both directions, under the default options and under any options that do not bring a converter of their own for an envelope type. Every error message is made printable and cut at 512 UTF-16 units by `ManagementError` itself, however the error is built or read, and a failure is built only with a code from the table and the details record the table names for it. Text on the wire has the contract's own rule, `ManagementText` (a cut is exact and unmarked, a missing value is absent, the rule applied twice changes nothing); it shares one thing with Core, the definition of a shown character (`ServerText.IsShown`). An account is named in a reply only by `ServerAuthentication` and `IdentityChangedDetails`, which bound its identity values the same way (and `ServerAuthentication` its message, as an error's), and a server's name enters only through `ServerName`'s rule, which also proposes a name from an address; the rule for the address an operator types is `ServerAddress.TryRead`, and a caller builds the session client from the origin it returns, not from the typed text. `servers.add` and `trust.set` carry the provider's origin the check reported (`SignInProviderOrigin.Agrees` is the comparison, `provider_changed` its refusal; text that names no origin is refused as `invalid_request` when the arguments are read).
|
||||
|
||||
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.
|
||||
|
||||
## Reproducible checks
|
||||
@@ -26,7 +29,58 @@ Check out this repository and the protocol repository as siblings, then pin the
|
||||
./build/Verify.ps1
|
||||
```
|
||||
|
||||
This performs locked restore, Release solution build, core tests and Windows/CLI tests, with coverage/TRX output under TestResults. UI tests compile in the solution but are not automatically run by hosted CI. Use an elevated interactive desktop and `K22_UITEST_EXE` for those tests.
|
||||
This performs locked restore, Release solution build, core tests, Windows/CLI tests and presentation tests, with coverage/TRX output under TestResults, and then fails if an assembly's merged line coverage is below its minimum in `build/minimum-coverage.json`. For a change to the companion's presentation logic the quick loop is `dotnet test tests/Qqq.PrintAgent.Presentation.Tests -c Release -p:Platform=x64`; that suite needs no service, pipe or desktop and its assembly is gated at 100%. UI tests compile in the solution but are not automatically run by hosted CI. Use an elevated interactive desktop and `K22_UITEST_EXE` for those tests.
|
||||
|
||||
## Testing the service runtime
|
||||
|
||||
`AgentRuntime` reaches QQQ in one way: a signed-in session and the realtime channel opened through it. Its constructor takes no API client, so a test cannot make it do anything the installed service does not. Windows-layer tests (`tests/Qqq.PrintAgent.Cli.Tests`) use hand-written fakes at the boundaries:
|
||||
|
||||
- `RealtimeRuntimeFixture` runs a runtime on a temporary store, signs it in through a real `QqqSessionClient` whose HTTP goes to a scripted handler, and connects it over a loopback WebSocket to a scripted peer. A test sends the server's frames (WELCOME, DESIRED_STATE, SETTINGS, COMMAND, JOB_AVAILABLE, PRINTERS), reads what the agent sent, talks to the runtime over its management pipe, and can restart the runtime on the same store. The peer can acknowledge a message, refuse it with an ERROR frame, or drop the connection without acknowledging it; the fake printer can hold a submission open, count entries into it and script a spooler cancellation. The scripted server refuses, and counts with the forbidden polls, a claim made before it acknowledged the agent's applied state.
|
||||
- `SessionFixture` supplies a fake session with no network and a manual clock, for decisions about sign-in, pairing and the protected files. `EvaluateAsync` lets the renewal interval pass and evaluates authorization once, the check the reconnect loop and the heartbeat make; `RunOnePassAsync` runs the service loop for one pass, after which `AssertSentNothing` shows that nothing was sent.
|
||||
- `SingleServerHarness` is the runtime as tests hold it, and the only type in the test project that names the runtime class. Both fixtures build it, and a test that needs a runtime of its own constructs the harness, never the runtime. It makes the management pipe on a private name and sends a command over it with `PipeAsync`. The constants and pure functions tests read from the runtime (the update limits, the reconnect delay, the job list merge) are read from the harness too.
|
||||
- `ScriptedPrintServer` is the server `RealtimeRuntimeFixture` runs against: the realtime peer, the HTTP handler, the job queue and the accepted connections. It knows nothing about the runtime and shares nothing between instances, so several can stand beside one service; the fixture repeats the members of its one server under their old names.
|
||||
|
||||
Wait for the event (a recorded message, a log line, a signal from a fake) with `fixture.WaitAsync`, never for a length of time; its 30-second limit applies to that one wait, and the runtime lives until the fixture is disposed. The runtime reads these from its time provider: the heartbeat interval, session renewal, the reconnect backoff, the update pause and its preparation limit, the catalog refresh interval, command expiry and download pacing. Pass a `ManualTimeProvider` and let a heartbeat interval pass with `fixture.HeartbeatAsync`, or a backoff with `fixture.BackoffPassAsync`. The WebSocket handshake timeout, the acknowledgement timeout and pipe command timeouts still use real time. To show that something did not happen, find an event that can only follow it: commands are handled in arrival order, claims are made one at a time, and frames on one socket arrive in the order they were sent. Where no such event exists (a claim wave or a synchronization pass that decides to do nothing leaves no trace), say so in the test and give the wrong action heartbeat rounds in which to show.
|
||||
|
||||
A test opens a store with `TestState.OpenStore`, the only place the suite constructs one, declares it as `JournalStore` (the test project's alias for the store's type, declared in `TestState.cs`), and deletes its folder with `JournalFiles.Delete`, which empties the SQLite connection pool of that journal only. `TestState.OpenStore` migrates the journal before it opens the store, as the service does when it starts, and `SingleServerHarness` migrates when it is built, because many tests call the runtime without running its loop; a test whose subject is the unmigrated journal uses `TestState.OpenStoreAsFound`. Do not call `SqliteConnection.ClearAllPools()` in this suite: the classes run in parallel, and clearing every pool disposes a connection that a store in another test has just taken from its pool, which that test sees as `ObjectDisposedException: SQLitePCL.sqlite3`.
|
||||
|
||||
A store takes its SQLite connection (`JournalDatabase.Open`) inside the journal's lock, and the open must stay there. Microsoft.Data.Sqlite 10.0.0 takes a pooled connection under the pool's lock but records which `SqliteConnection` owns it only afterwards (`SqliteConnectionFactory.GetConnection`, then `SqliteConnectionInternal.Activate`). A second open of the same journal that finds the pool empty in that gap takes the connection for abandoned (`SqliteConnectionPool.ReclaimLeakedConnections`) and is handed it too, so two stores share one native handle and one store's transaction can swallow or roll back the other's writes. Every pooled open of a journal goes through `JournalDatabase.Open`, so the lock keeps any opener out of that gap while another asks the pool. The migration and the version read use connections that are not pooled, and so does a test that needs SQL of its own against a journal: it takes its connection from `TestState.OpenJournal`. A pooled connection opened anywhere else shares the journal's pool without that lock and can fall into the same gap. `AgentStoreOpenRaceTests` fails when the open is moved out of the lock. Read those three library methods again before changing the package version or the pooling of the journal's connection.
|
||||
|
||||
## The journal's schema
|
||||
|
||||
The journal carries a schema version in SQLite's `PRAGMA user_version`: 0 for a journal no versioned build has migrated (a new one, or one a 1.x release left), and from then on the number of migration steps it has passed. `JournalSchema.Version` is the version this build works with, which is the number of steps in `JournalMigration.Steps`.
|
||||
|
||||
- **Migration happens once, when the service starts.** `AgentRuntime.RunAsync` calls `JournalSchema.Migrate` before anything opens a store. It reads the version, and when steps are pending it runs all of them in one transaction on a connection of its own and stamps the new version in that same transaction. A step that fails rolls back every step of that run and the version with them; the service fails to start, as it did when the first store open failed, and the next start tries again. Before the failure is passed on, the runtime logs one line for the operator (`JournalSchema.StartFailureText`): the journal's folder, the kind of failure, that the journal was not changed and what to check. That line never repeats the failure's own message, so it is safe to show whatever the journal holds. Two callers in one process are serialized by the journal's lock, and the second finds nothing to do. Across processes only the service's `worker.lock` keeps a second migration away, so the version is read again inside the transaction, under SQLite's write lock, and that reading decides which steps run: a journal another process migrated in between has no step run on it twice.
|
||||
- **Opening a store never changes the schema.** The store's constructor opens the journal read-write without create and checks the version (`JournalSchema.OpenCurrent`). A missing journal or one behind the version fails with "The print journal has not been brought up to date"; nothing is created. There is no `CREATE TABLE` or `ALTER TABLE` anywhere outside the migration steps. `JournalOpenTests` calls every method of the store and compares `sqlite_master` before and after; its list of method names fails the test when a method is added without being called there. That comparison cannot see a statement that creates a table which already exists, so a second test removes tables from a migrated journal and shows that the store calls which need them fail with "no such table" and put nothing back.
|
||||
- **A step is never edited once it has shipped.** A journal that has passed a step does not run it again, so a changed step would leave old and new installations with different schemas. A schema change is a new file `JournalMigration.V<n>.cs` and a new entry at the end of `Steps`. A step works only through the `JournalMigrationContext` it is given, which belongs to the migration's transaction. Its statements are written out in the step, not built from code that queries the tables: the five `jobs_display_*` indexes repeat the sort expressions of `JobSorting`, and a test compares the two, so changing a sort expression requires a step that replaces its index.
|
||||
- **Step 1 is every schema change of the 1.x releases.** Those releases had no version: each store open created what was missing, and three tables were created on first use. Step 1 creates each table, column and index only when it is missing, with the statements those releases used, so a journal in any 1.x shape, and an empty one, ends in the same schema (`Migrate_EveryShape_EndsInOneSchema`). Rows from before events carried a connection scope get the scope of the pairing saved at that moment, as every 1.x open did.
|
||||
- **A journal from a newer build is not touched.** When the version is higher than this build knows, `Migrate` returns `StateStatus.NewerBuild` with both versions and opens nothing for writing. The service then starts no connection and serves only its pipe: `status` and `snapshot` carry the one sentence in `StateStatus.NewerBuildText`, `transport`, the three log operations and the two update operations work, and every other operation is refused with that sentence. The runtime's `OpenStore` refuses as well, so no path can open a store on that state. The reason is logged once, as an ERROR line when the service starts; each refused operation is logged at DEBUG, to the file and only while debug logging is on, so a client that keeps asking does not fill the 100 entries the snapshot shows. This is the case of an older release candidate installed over a newer one.
|
||||
- **The version is read without writing.** `JournalDatabase.ReadVersion` uses a read-only connection. A service process that was killed leaves its write-ahead log on disk with its latest changes, and a version stamped by a newer build may be only there, so a log with content is read through an ordinary read-only connection. (A service that is stopped in the ordinary way is a different case: Microsoft.Data.Sqlite closes its pooled connections when the process exits, and SQLite folds the log into the journal when the last connection closes.) For a log with content SQLite then creates or rebuilds `journal.db-shm`, its index of the log, which holds no data. The journal and the log are not written. With no log, or an empty one, the journal is opened as immutable, which creates no file at all; an ordinary read-only connection would leave an empty log and an index behind.
|
||||
|
||||
Tests build the state an earlier release left with `LegacyStateBuilder`: the journal from a script of that release's own statements (`tests/Qqq.PrintAgent.Cli.Tests/Fixtures/journal-1.2.1.sql`, transcribed from tag `v1.2.1`, and `journal-oldest.sql`, the oldest shape the history shows), and the pairing file in the serialization and protection 1.x used. `JournalProbe` answers what the tests ask from outside the product: the version, the schema as SQLite records it, a digest of each table's rows and a hash of every file. A new release that changes what is stored gets a fixture of the journal the release before it leaves. `JournalUpgradeTests` runs the upgrade on what an installation can leave besides a cleanly closed journal: a 1.2.1 journal whose rows are only in the log of a killed service (`LegacyStateBuilder.WithJournalLeftByAKill`), each shape between the oldest and 1.2.1, including 1.0.x without the certificate exception table, and a 1.2.1 state folder under the service loop with one job left in SUBMITTING and one in RECEIVED, which the start records as OUTCOME_UNKNOWN and FAILED.
|
||||
|
||||
## Testing code that reaches Windows
|
||||
|
||||
Code in the Windows library that asks Windows for something a test must not touch (the print service, Windows Installer, the machine state folder, Program Files, the task scheduler, the all-users Start menu) is in three parts. A public static entry point binds the real Windows and holds no rule. A class of rules takes everything it needs from Windows through an interface, and the folders it works in as values, so a test runs the rules against a hand-written fake in a temporary folder. A real adapter behind the interface only asks Windows and passes on what it said, failures included.
|
||||
|
||||
| Entry point | Rules | What the rules get from Windows | Fake in `tests/Qqq.PrintAgent.Cli.Tests` |
|
||||
| --- | --- | --- | --- |
|
||||
| `MsiUpdateInstaller` (`ApplyAsync`, `ReadLastOutcomeMessage`) | `MsiUpdateSteps`, in the places of `MsiUpdatePlaces` | `IPathSecurity` (owners, access lists, who is asking), `IProcessHost` (the current process, a process by id, a program started, a file's version) | `FakePathSecurity`, `FakeProcessHost`; `MsiUpdateSetup` builds the folders |
|
||||
| `MsiUpdateService` (public constructor) | `MsiUpdateService` (internal constructor) | `IMsiReleaseSource`, the pipe as a function, `MsiUpdateSteps` | `FakeMsiReleaseSource` |
|
||||
| `AgentServiceControl` | `AgentServiceActions` | `IServiceManager`, `IProcessHost`, a `TimeProvider` | `FakeServiceManager`, `ManualTimeProvider` |
|
||||
| `AgentPipePeer.Installed.Authenticate(pipe)`, the bound check itself, which `AgentPipeClient` calls | `AgentPipePeer.Authenticate(pipe, facts, installedService)` | `IPipePeerFacts` (the pipe's server process, a service's state and process, a process's account and program) | `FakePipePeerFacts` |
|
||||
|
||||
Sign-in startup (`AgentStartup`, `AgentStartupTasks`, `ITaskScheduler`) and the branded shortcut (`AgentBrandingInstaller`, `AgentBrandingShortcuts`, `IShortcutShell`, `IShortcutRecord`) are built the same way.
|
||||
|
||||
Rules for this kind of code:
|
||||
|
||||
- A fake behaves as Windows does wherever a rule depends on it, and says so in its summary. A service handle remembers the state it read until it is refreshed; an id no running process has cannot be opened; a program file or a folder that does not exist cannot be started or read; the program of a process that has ended cannot be read. What .NET 10 reports for that last read depends on whether anything else still holds the ended process: Windows error 299 while another handle keeps it, which is what `FakeProcess` does unless a test says otherwise, and an invalid-operation failure ("has exited") when nothing holds it, which a test sets on the fake. The second is the update helper's own situation, and the only one it takes for a companion that has closed; each case has a test in `MsiUpdateApplyTests`. When a fake and Windows disagree, fix the fake first and then see which tests still pass.
|
||||
- A real adapter is tested for real whenever that changes nothing on the computer: a program starter against the worker fixture's stand-in mode (`Qqq.PrintAgent.WorkerFixture.exe --stand-in`), file and access-list code in temporary folders, links as junctions made with `mklink /J`, token facts against the test process, a pipe the test serves itself under a name of its own (with the stand-in as its client where the two ends must be different processes), and service reads against the built-in remote procedure call service (`RpcSs`), which is only read. Controls are sent only to a service name no service has, which Windows refuses.
|
||||
- One older suite names the print service itself. `AgentPipeSecurityTests` runs the real peer check against a pipe the test serves, and the peer check asks Windows for the service `QqqPrintAgent`. On a computer without the product there is no such service. On a computer with the product installed, Windows refuses to open the service for a test run without administrator rights; in an elevated run the service is opened and its status is read. Nothing else is sent to it: it is not started, stopped or changed, and its pipe is not contacted. The test's outcome is the same refusal in every case.
|
||||
- `[ExcludeFromCodeCoverage(Justification = "...")]` is for a member that only passes a call on to rules bound to a real place of this computer. The justification names what the member binds and where the rules and the binding are tested. A class that decides something is never excluded.
|
||||
- What an entry point is bound to is a value a test can read, and a test compares it with the real place: `MsiUpdateInstaller.Installed.Places` with `MsiUpdatePlaces.Installed()`, the companion's `MsiUpdateService` with the installed service's pipe and `MsiUpdateInstaller.Installed`, `AgentServiceControl.Installed.ServiceName`, and `AgentPipePeer.Installed` with the Windows facts and the service program under Program Files. The peer check has no entry point besides that value: `AgentPipeClient` calls `AgentPipePeer.Installed.Authenticate`, so the Windows facts and the service program are named in one place, the value the test reads, and there is no pass-through in between that could hand the rules others. The entry points of `MsiUpdateInstaller` and `AgentServiceControl` are one line each, excluded from coverage, that passes the call on to the bound value and names no place of its own. No test can tell whether such a line, or the client's call, uses the bound value or builds another one, because no test may reach the installed product; that is checked by reading: none of them names facts, a place or a program.
|
||||
- A test of these rules never starts Windows Installer, asks the release feed, connects to the installed service's pipe, starts or stops a service, or writes under Program Files or the machine state folder. `MsiUpdateInstaller.ApplyAsync`, `AgentServiceControl.StartAsync` and `RestartAsync` must not be called from a test for that reason.
|
||||
- An `await` on code under test that would wait forever if the code were wrong gets a limit (`MsiUpdateSetup.Patience`), so the test fails instead of hanging the run.
|
||||
|
||||
## Local server and CLI
|
||||
|
||||
@@ -40,9 +94,9 @@ $agent = '.\src\Qqq.PrintAgent.Cli\bin\x64\Debug\net10.0-windows10.0.19041.0\qqq
|
||||
& $agent run
|
||||
```
|
||||
|
||||
`run` requests a connection refresh through the authenticated service; it does not start a second foreground worker. Device setup and explicit printer exposure use that same service. Submit a physical test only when authorized, after approving the selected exposed queue in QQQ.
|
||||
`run` requests a connection refresh through the authenticated service; the command line never claims or prints a job itself. Device setup and explicit printer exposure use that same service. Submit a physical test only when authorized, after approving the selected exposed queue in QQQ.
|
||||
|
||||
Direct bearer diagnostics require explicit `--legacy-compat` and a separate private `--data-dir`; they cannot use `--service` and are never an automatic fallback. The exclusive `worker.lock` prevents simultaneous workers on one journal. Do not point a development worker at installed state.
|
||||
The command line has no state and no server transport of its own: every command is one request to the service. The `--legacy-compat` bearer diagnostics and their `--data-dir`, `--once` and `--service` options were removed in 2.0. The service holds an exclusive `worker.lock` in its data directory, so a second service host cannot run against the same journal; do not point a development service at installed state. `CommandLine` takes the console, the environment, the pipe and the helper entry points as injected functions, so its tests need none of them.
|
||||
|
||||
## Virtual file printer (macOS/Linux)
|
||||
|
||||
@@ -55,15 +109,23 @@ with the printing QBit without Windows or hardware; it does not qualify this ser
|
||||
|
||||
The worker persists receipt and SUBMITTING before invoking Windows. It records SPOOLED and the spooler job ID afterward and replays pending cloud events. A crash between spool acceptance and recording cannot be made atomic. The worker uses OUTCOME_UNKNOWN rather than automatic reprint. Preserve that distinction when extending retry/cancellation logic.
|
||||
|
||||
Core has one composition, the one the service builds, and no optional paths beside it:
|
||||
|
||||
- `PrintWorker` is one lane. Its log, its two predicates and every member of `PrintWorkerOptions` except the clock are required. It does not report unfinished jobs from a previous run, send inventory or send heartbeats; the runtime does.
|
||||
- `IPrinterBackend` is three reads and `PrintFileAsync(job, path, submissionTimeout, token)`. A document is always a verified staged file. The reads differ in what they ask Windows: `Discover` reads the settings of every queue and is for the inventory, the companion's list and sharing changes; `ListWithoutMedia` reads no queue's settings; `FindWithMedia` reads the settings of the one queue asked for. A print job uses the last two and never `Discover`, so a queue whose driver hangs stops only its own jobs. A new backend, a wrapper included, implements all four members and applies its rule to each read alike.
|
||||
- `PrintApiClient` is built only by `PrintApiClient.ForSession`. Inventory, job events and command receipts go over the realtime channel attached with `WithRealtime`, and fail as a closed connection without one.
|
||||
|
||||
Core tests of the worker build it through `WorkerHarness`, which composes it the same way over hand-written fakes: a fake server that answers claims and documents and acknowledges or refuses each event frame, a journal, a printer that takes staged files, and a connection that can be dropped and reopened. A test that needs an API client over its own fake server gets one from `FakeSessionClient.ApiOver`.
|
||||
|
||||
## Packaging
|
||||
|
||||
```powershell
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
./build/Build-Installer.ps1
|
||||
```
|
||||
|
||||
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.
|
||||
`./build/Package-Release.ps1` (the version defaults to `VersionPrefix` in `Directory.Build.props`) 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.
|
||||
|
||||
@@ -71,6 +133,6 @@ MSI ProductVersion is numeric; assembly/package informational version may contai
|
||||
|
||||
`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.
|
||||
|
||||
`GitHubMsiReleaseClient` reads the fixed QRun-IO release feed, applies strict version/channel/minimum policy and validates bounded release assets and SHA-256. `MsiUpdateInstaller` validates protected staging, ACLs and reparse boundaries, copies the installed self-contained CLI for handoff, rechecks the MSI and uses Windows Installer for all components. The runtime's `prepare-update`/`resume-after-update` operations coordinate a bounded claim pause while preserving heartbeats. No server-supplied executable, per-user replacement package or persistent privileged updater is involved. See the [threat model](security/threat-model.md) for trust and failure boundaries.
|
||||
`GitHubMsiReleaseClient` reads the fixed QRun-IO release feed, applies strict version/channel/minimum policy and validates bounded release assets and SHA-256. `MsiUpdateSteps`, which the installed product reaches through `MsiUpdateInstaller`, validates protected staging, ACLs and reparse boundaries, copies the installed self-contained CLI for handoff, rechecks the MSI and uses Windows Installer for all components. The runtime's `prepare-update`/`resume-after-update` operations coordinate a bounded claim pause while preserving heartbeats: `prepare-update` stops new claims, answers "Ready for update." once every job already claimed has finished, answers that it is not ready when that takes more than 25 seconds, and answers that preparation was interrupted when printing was resumed or a sign-in, sign-out or enrollment began while it waited; the pause lifts by itself after 10 minutes. No server-supplied executable, per-user replacement package or persistent privileged updater is involved. See the [threat model](security/threat-model.md) for trust and failure boundaries.
|
||||
|
||||
Historical 0.1.3-rc.1 verification: locked restore, zero-warning Release build, **36 Core + 127 Windows/CLI tests (163 total)**. Synthetic release/download/hash/request/ACL/version/maintenance tests do not install an MSI. Installed shell and actual upgrade acceptance remain separate gates; use [session state](SESSION-STATE.md) for current verification. Completed protected update stages are retained for manual administrator review.
|
||||
@@ -43,6 +43,8 @@ The audit originally found backend liveness defects and missing day-to-day contr
|
||||
| Poll interval was ignored and CLI timeouts exited successfully. | Runtime honors a 1–20 second bounded interval; foreground Ctrl+C is the only successful cancellation path. Other CLI cancellations/timeouts exit 1. | Timeout regression passes; reconnect wakes the interval early. |
|
||||
| Arbitrary branding accents could make white AppKit text unreadable. | JSON load and direct application enforce unrounded white-text contrast of at least 4.5:1; lighter accents fall back to default teal. Main heading follows the active theme. | Tests include the #767676 pass / #777777 fail boundary. See [branding policy](BRANDING.md). Theme, scaling and assistive-technology acceptance remain separate. |
|
||||
|
||||
Note added 2026-11-10, for version 2.0. Three rows above record the command line as it was on the audit date and no longer describe the product: "CLI enrollment could overwrite service credentials under the wrong Windows identity", "Native driver calls could block the service indefinitely" (where it says the CLI uses a disposable child) and "Poll interval was ignored and CLI timeouts exited successfully" (where it speaks of foreground Ctrl+C). Version 2.0 removed the command line's foreground mode (`--legacy-compat`, its per-user data directory and worker lock, and `run --once` with its Ctrl+C path) and its printer-worker role. The default-directory, shared-directory rejection and lock-contention tests the first row cites were deleted with that mode. What still holds from those rows: service commands use the pipe, which is now all the command line does; the service starts the disposable printer child, and only the service; and a command-line timeout or cancellation exits 1, now held by `CliServiceCommandTests`. The rows themselves are left as written.
|
||||
|
||||
## Boundaries retained
|
||||
|
||||
- Device connections are outbound HTTPS; HTTP is only a loopback development exception. No inbound firewall rule or local HTTP management endpoint is added.
|
||||
|
||||
@@ -18,7 +18,7 @@ Run the MSI as an administrator. Windows Installer deploys one coherent applicat
|
||||
|
||||
- The `QqqPrintAgent` automatic Windows service, running as LocalSystem and depending on the Windows Spooler.
|
||||
- The native **QQQ Print Agent** setup/status app and Start-menu entry.
|
||||
- The `qqq-print-agent.exe` diagnostic CLI.
|
||||
- The `qqq-print-agent.exe` command-line tool, which manages the service.
|
||||
- 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.
|
||||
@@ -49,7 +49,7 @@ Full uninstall removes only tasks whose name, current-user SID, interactive prin
|
||||
|
||||
## Isolated native printer operations
|
||||
|
||||
Discovery, submission and cancellation run in a short-lived child of the service or explicit diagnostic CLI. Control requests use anonymous standard-input/output pipes. Realtime documents stream to protected owned staging files; their content is not placed in command arguments or logs. Discovery and cancellation have bounded helper deadlines. Realtime printing uses the server's `nativeSubmissionTimeoutMillis`, default 120 seconds, rather than a fixed submission deadline. A timed-out child is terminated, with bounded cleanup, and also watches its parent. The child still runs with service privileges; isolation limits a blocked driver, not its authority.
|
||||
Discovery, submission and cancellation run in a short-lived child of the service. Control requests use anonymous standard-input/output pipes. Realtime documents stream to protected owned staging files; their content is not placed in command arguments or logs. Discovery and cancellation have bounded helper deadlines. Realtime printing uses the server's `nativeSubmissionTimeoutMillis`, default 120 seconds, rather than a fixed submission deadline. A timed-out child is terminated, with bounded cleanup, and also watches its parent. The child still runs with service privileges; isolation limits a blocked driver, not its authority.
|
||||
|
||||
This bounds a blocked printer driver without stopping cloud connectivity permanently. It cannot retract bytes already accepted by a printer: timeout, crash or cancellation after submission begins is an uncertain outcome and requires inspection before reprinting. Existing spooler receipt cancellation verifies the exact QQQ document identity before requesting cancellation; it does not prove that no paper or label has already emerged. Synthetic subprocess tests use a fake backend and never access a printer.
|
||||
|
||||
@@ -65,7 +65,7 @@ Use qbit-printing 1.0.0 on QQQ 4.1.0-RC.21 or a compatible newer server; the rel
|
||||
|
||||
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.
|
||||
|
||||
Normal CLI setup, printer exposure and connection commands use the authenticated installed service. Sign in through the companion first; `run` requests a connection refresh. Explicit `--legacy-compat` diagnostics require a separate `--data-dir` and cannot use `--service`. Do not copy a user-protected DPAPI credential into ProgramData: credentials are bound to the account that created them. The service owns unattended discovery and printing.
|
||||
CLI setup, printer exposure and connection commands are requests to the authenticated installed service; see [Administrator operations](ADMINISTRATION.md#command-line). Sign in through the companion first; `run` requests a connection refresh. The command line keeps no credentials or data of its own. Do not copy a user-protected DPAPI credential into ProgramData: credentials are bound to the account that created them. The service owns unattended discovery and printing.
|
||||
|
||||
## Updates
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ connection like every other negotiated state. The contract is in the protocol re
|
||||
|
||||
## Printing extension 2.3: the client reads the operator catalog
|
||||
|
||||
- The inventory snapshot (`INVENTORY` over the realtime channel, `PUT /device/printers` otherwise) is
|
||||
- The inventory snapshot (`INVENTORY` over the realtime channel) 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
|
||||
|
||||
+3
-1
@@ -12,7 +12,7 @@ The installed browser callback is exactly `io.qrun.qqq-print-agent:/oauth/callba
|
||||
|
||||
**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 CLI `printers`, `enroll`, `expose`, `run` and `sign-out` commands use the installed authenticated service. `run` requests a connection refresh; the service owns unattended printing. The command line has no sign-in or transport of its own: the bearer-based `--legacy-compat` diagnostics were removed in 2.0.
|
||||
|
||||
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.
|
||||
|
||||
@@ -20,6 +20,8 @@ The server and its identity provider govern session lifetime, renewal and curren
|
||||
|
||||
Explicit sign-in can replace unreadable saved QQQ session data only after fresh server authentication and, when device proof survives, authoritative device binding. Rejected binding leaves the unreadable file untouched. Successful recovery preserves its ciphertext in a uniquely named `qqq-session.recovery-*.dpapi` file in the protected data directory.
|
||||
|
||||
Enrollment replaces the pairing and its session as one staged step. The session for the new pairing is first written as `qqq-session.staged.dpapi`; once the pairing is replaced, the next read of the session (enrollment itself, a sign-in or a service start) moves it into place, preserving an unreadable earlier session as `qqq-session.recovery-*.dpapi`. A staged session is discarded when it does not belong to the stored pairing, or when the stored session already does or was saved after it; one that cannot be read is preserved under a recovery name. Until a staged session can be moved into place it is still the one that is used. The activity log records each of these once it has happened; a leftover that cannot be removed yet is dealt with, and logged, by a later read.
|
||||
|
||||
If device proof itself is unreadable, sign-in does not enable printing or reconstruct that proof. Explicit enrollment is required; it preserves the old ciphertext as `connection.recovery-*.dpapi`, printer choices and the old connection's journal without attaching those receipts to the new device. Read-only status remains available. Explicit sign-out can quarantine unreadable session data locally, but cannot revoke an unavailable server session; revoke that previous session in QQQ. Automatic connection recovery does not perform this explicit credential replacement.
|
||||
|
||||
## Replace a revoked or expired pairing
|
||||
|
||||
+215
-2
@@ -23,14 +23,126 @@ apply. No credentials are placed in URLs or messages. WSS and HTTPS share the
|
||||
configured origin; redirects cannot select another document host. Plain WS/HTTP
|
||||
is restricted to explicit loopback development.
|
||||
|
||||
### A server that runs as several nodes
|
||||
|
||||
A QQQ server may run as several nodes behind a load balancer with cookie
|
||||
stickiness. Sessions are rows in its database, so any node accepts the session;
|
||||
what differs per node is which one holds this device's socket. The client keeps
|
||||
the load balancer's own cookies (Application Load Balancer names, beginning
|
||||
`AWSALB`) from every answer of the configured origin, including the answer to
|
||||
the WebSocket upgrade, accepted or refused, and sends them on every later
|
||||
request to that origin and on the next upgrade. Discovery runs immediately
|
||||
before each upgrade, so the socket opens on the node the HTTP requests already
|
||||
reach, and claims and downloads then go to the node that holds the socket. The
|
||||
cookies are held in memory only, for the life of one session client, are never
|
||||
saved with the session, are kept across sign-out, are never sent to the sign-in
|
||||
provider, and are bounded (eight values of at most 4096 characters, names of at
|
||||
most 64, scoped to the origin's host and root path). The log counts them and
|
||||
never shows a name or a value.
|
||||
|
||||
When that node stops:
|
||||
|
||||
| What the client sees | What it does | Needs a person |
|
||||
|---|---|---|
|
||||
| The socket closes or stops answering heartbeats | Marks realtime not ready, cancels the claim wave, and reconnects after the configured backoff (1 to 30 seconds by default). The load balancer routes the reconnect to a healthy node and rewrites its cookie; the client follows it. Job events and receipts travel on the socket only, so one that was not acknowledged stays in the journal and is sent again, under the same identifier, on the new socket. After its welcome and applied state the server announces outstanding work again and claims resume; a claimed job replays, a job already recorded locally is not submitted twice | No |
|
||||
| 502, 503 or 504 (or any 5xx, or 409) on a claim | The request fails; the session is kept. The claim wave ends with one `Print lane stopped` line; the job stays on the server and is claimed by the next wave | No |
|
||||
| The same on a document download, after the claim | The session is kept. Windows was never asked, so the job is recorded FAILED locally with nothing submitted, and the lane goes on | No (the FAILED job needs an operator retry) |
|
||||
| A dropped connection with no answer | The same as a 5xx: a transport failure, session kept | No |
|
||||
| 5xx on the WebSocket upgrade | A failed connection attempt; retried after backoff, with the load balancer's cookie from that refusal | No |
|
||||
| 401 on any request, or 401/403 on the WebSocket upgrade | The server withdrew authorization. A 401 to a request clears the session; a refused upgrade makes the service block printing. Either way reconnecting stops until someone signs in | Yes |
|
||||
|
||||
A server at QQQ Printing 1.1.x refuses a claim (409) unless the socket is on
|
||||
the node that received it. The cookie keeps the two together until the load
|
||||
balancer chooses another node for this client's requests while its socket is
|
||||
still open on the first. That happens while a node is being drained, and
|
||||
equally after a failed health check or whenever else the load balancer
|
||||
chooses again. From then on every claim is refused, and it stays so until the
|
||||
socket closes for any reason and the reconnect follows the new cookie: the
|
||||
client does not yet close the socket on this account. A server with the
|
||||
several-node support in QQQ Printing after 1.1.2 accepts the claim on any
|
||||
node.
|
||||
|
||||
The service owns separate connection, receive, send, synchronization, command
|
||||
and job workers. Settings, desired state, commands, inventory, job events and
|
||||
receipts use small bounded WebSocket messages. A job notification wakes a finite
|
||||
HTTPS claim drain. It stops when the queue is empty or capacity is occupied and
|
||||
resumes on a committed notification or released capacity. There is no periodic
|
||||
HTTPS claim drain, called a wave. A wave claims jobs one at a time while a
|
||||
download slot is free, waits for a slot when all are occupied, and stops
|
||||
claiming at the first claim that finds the queue empty or fails. It stays open
|
||||
until the jobs it claimed have finished. A notification that arrives while a
|
||||
wave is open is remembered but not acted on at once: the job it announces is
|
||||
claimed by the next wave, which starts only after the open wave's in-flight
|
||||
jobs have finished, even when that job is for an idle printer. There is no periodic
|
||||
HTTP job/control/receipt fetch and no polling fallback. WebSocket liveness and
|
||||
normal QQQ session renewal do not query the job queue.
|
||||
|
||||
Inventory, job events and command receipts travel only on the WebSocket, each
|
||||
acknowledged by the server. Version 2.0 deleted the HTTP requests the client
|
||||
once made in their place when no channel was attached (inventory, heartbeat,
|
||||
job events, the control poll and the HTTP command receipt): with no channel a
|
||||
send fails as a closed connection. Events and receipts stay in the journal and
|
||||
are replayed after the reconnect, and inventory is sent again on every new
|
||||
connection. The heartbeat is a frame the runtime sends on the channel. The
|
||||
printing requests that remain over HTTPS are the claim, the document, the
|
||||
queued-job preview, operator actions, the printer catalog and enrollment. The
|
||||
API client that makes them can be built only from the signed-in session, and
|
||||
every one of them, the document included, goes through that session.
|
||||
|
||||
Each lane runs a worker that does four things in a pass: deliver the events
|
||||
the journal still holds, claim one job, stage its document and submit it once.
|
||||
Everything else belongs to the runtime and is handed to the worker, none of it
|
||||
optional: whether it may dispatch, claim or submit, the limits in force, the
|
||||
gates the lanes share and the leases a job holds. The runtime, not the worker,
|
||||
reports at start the jobs a previous run left unfinished, and sends inventory
|
||||
and heartbeats.
|
||||
|
||||
A printer backend has three reads, and a job uses the two that do not read
|
||||
every printer's settings. The full discovery reads the settings of every queue
|
||||
through its driver; it is for the inventory report, the companion's list and
|
||||
sharing changes. The list without media names every queue from the spooler's
|
||||
enumeration alone: the service opens no queue for it and asks no driver. The
|
||||
lookup is the same enumeration and then the settings of one queue. A lane lists
|
||||
before it claims (the list names the job's printer in the journal), looks up
|
||||
the claimed job's own printer once after the download and before SUBMITTING is
|
||||
recorded, and the print helper looks the same printer up again before it
|
||||
prints. So for one job the service reads the settings of that job's queue and
|
||||
of no other; a queue whose driver hangs or fails stops only its own jobs,
|
||||
which are refused before submission. Each read through the service is a helper
|
||||
of its own with a 20 second limit.
|
||||
|
||||
What still asks every queue is the inventory report. A report that cannot be
|
||||
read (one queue's driver is enough) sends nothing and is logged once per
|
||||
reason; the connection stays up and the inventory it already delivered stays
|
||||
in force, so this failure by itself holds no job. Jobs continue when the cause
|
||||
is one queue's settings. A cause the lanes share, the spooler's list or the
|
||||
service's stored data, stops them too, with "Print lane stopped" in the log.
|
||||
While it lasts the server is told nothing new about any printer, so
|
||||
a change in the status or the media of a healthy printer is not reported
|
||||
either. A connection that has delivered no inventory yet claims nothing until
|
||||
one full discovery succeeds and the server accepted it, so after a restart or
|
||||
a reconnect a driver that fails every time still stops printing.
|
||||
|
||||
Known limit: a print server that is switched off or cannot be reached. The
|
||||
enumeration is Windows' own work, and all three reads begin with it. It asks
|
||||
the spooler for the queues installed on the station and for the connections
|
||||
the service's identity has to printers that another computer, a print server,
|
||||
shares. Microsoft documents that this enumeration opens every such connection
|
||||
and waits for a timeout when the connection's server is down or gone, or the
|
||||
printer on it no longer exists. So one connection to a print server that does
|
||||
not answer can still stall the list, the lookup and the print, for every
|
||||
printer on the station. The list runs before every claim with its 20 second
|
||||
limit. If Windows waits longer than that, the pass ends before it claims (the
|
||||
log has "Print lane stopped: The printer operation timed out") and no job is
|
||||
taken for as long as it lasts; a lookup that runs out of time fails its job
|
||||
before SUBMITTING, and a print that runs out of time ends as OUTCOME_UNKNOWN.
|
||||
The three reads therefore cover a broken driver on the station and do not
|
||||
cover a dead print server, which stalls printing as it did before them. The
|
||||
remedy is to bring the server back or to remove that printer connection from
|
||||
Windows. This comes from Microsoft's documentation and from the code; it has
|
||||
not been measured on Windows. Listing in a way that opens no connection would
|
||||
change what reaches Windows, so it waits for the check on real printers (W70
|
||||
in [the audit](AUDIT-2026-11-10.md)). A spooler that cannot enumerate at all
|
||||
also stops every pass before it claims.
|
||||
|
||||
Documents stream over separate authenticated HTTPS requests into owned files in
|
||||
the protected service staging directory. Length and SHA-256 are checked while
|
||||
streaming; only a complete verified file can reach the native printer worker.
|
||||
@@ -39,6 +151,107 @@ Download/print concurrency and aggregate staging/bandwidth are bounded; jobs
|
||||
for one printer retain their submission order. Native printing runs in an
|
||||
isolated child with a configurable deadline, retaining uncertain outcomes.
|
||||
|
||||
## Claimed jobs the worker does not print
|
||||
|
||||
A claimed job is checked against the contract before anything else handles it.
|
||||
Fields the client does not read are ignored wherever they appear, to the nesting
|
||||
depth the JSON reader accepts (64 levels, counted from the claim response),
|
||||
because the contract tells consumers to ignore unknown fields and the server
|
||||
relays a submitter's options unchanged. The response itself is parsed to 1,024
|
||||
levels, so that a job nested too deep to read can still be identified.
|
||||
|
||||
- **Malformed job.** A job that breaks the contract (a missing or unusable
|
||||
member, a member the client reads given twice in any letter case, nesting
|
||||
deeper than 64 levels, or text that cannot be read) but still names itself
|
||||
with one usable job identifier is refused, not skipped. The worker records
|
||||
RECEIVED and then FAILED with the reason and "No bytes were submitted",
|
||||
delivers both events, never downloads or submits the document, and the lane
|
||||
goes on to the next job. A malformed replay of a job the journal already
|
||||
knows, in any state, changes nothing and is logged. Text that cannot be read
|
||||
is a surrogate escape without its partner or bytes that are not UTF-8, both
|
||||
of which the JSON parser accepts. It is refused in a text member the client
|
||||
reads and in a member name beside the job, in the job, in its options or in
|
||||
its media selection; inside a field the client does not read it is ignored.
|
||||
- **Claim that cannot be identified.** A claim response that names no single
|
||||
job (not JSON, no usable identifier, the identifier not readable text or
|
||||
given twice) cannot be answered for: the lane stops with an error, the next wake-up claims again,
|
||||
and the server offers the same job until it is cancelled there. The one such
|
||||
response a submitter could in principle cause is a job nested deeper than the
|
||||
1,024 levels the response is parsed to. That depth is derived from the
|
||||
server's cap of 2,000 characters on a job's options: the cheapest nesting,
|
||||
an array in an array, costs two characters a level, so about 1,000 levels fit,
|
||||
plus the response, the job and the options. No options the server accepts
|
||||
today can nest deeper, so this case cannot be produced through it as it
|
||||
stands; that rests on the server's cap, and the depth must rise if the cap
|
||||
does.
|
||||
- **Claimed job that is already submitting.** A job the journal holds as
|
||||
SUBMITTING, or in a final state, is never processed a second time when the
|
||||
server offers it again. For SUBMITTING the worker logs the job and the reason.
|
||||
The lane then ends its pass and the wave is marked drained, because the server
|
||||
would only offer the same job again; the next committed notification or
|
||||
released capacity starts a new wave. The outcome is reported by the lane that
|
||||
is submitting the job or, after a restart, by the runtime's recovery at start
|
||||
as OUTCOME_UNKNOWN.
|
||||
- **Claimed job that is already in hand.** A job a lane of this computer
|
||||
holds is left to that lane when the server offers it again. A lane holds a
|
||||
job from its claim until its pass for that job ends. Normally that is after
|
||||
the final state was recorded and delivered; the delivery is skipped when
|
||||
the pass is cancelled, as when the connection is replaced or the service
|
||||
stops, and a delivery can fail, and the lane lets go all the same. So a job
|
||||
in hand is being worked on or has just finished. The worker asks the
|
||||
runtime whether the job is in hand before it does anything else with the
|
||||
claim: nothing is recorded or reported, the
|
||||
job's title and printer in history are not rewritten, the log says once
|
||||
that the job was offered again while a lane of this computer still holds
|
||||
it, and the lane ends its pass as it does for a job that is submitting.
|
||||
- **Printer changed before the download finished.** The assigned printer is
|
||||
looked up once, after the download and before the job asks for the
|
||||
printer's turn; no other queue's settings are read. A printer the operator stopped sharing, a queue that is
|
||||
gone, or a selected media default that changed by then is FAILED with its
|
||||
reason before SUBMITTING is recorded, and Windows is never asked. The
|
||||
timing line reports this one check as `revalidate`.
|
||||
- **Printer changed while the job waits for its turn.** A job can wait a
|
||||
long time behind a print. The worker does not look the printer up again
|
||||
when the turn is held: that would start a print helper once more for
|
||||
every job, while the job holds the printer's turn and a print slot, and
|
||||
would add a second wait on that queue's driver. What
|
||||
changed during the wait is settled after SUBMITTING is recorded, by
|
||||
whoever can tell whether anything was sent:
|
||||
- *No longer shared.* The exposure filter in front of the backend checks
|
||||
sharing at the moment of submission. Its refusal is made before the
|
||||
backend behind it is called and has a type only the filter can raise, so
|
||||
the worker knows nothing was sent: the job is FAILED, not
|
||||
OUTCOME_UNKNOWN, and its reason ends "no bytes were submitted".
|
||||
- *Media default changed.* The print helper reads the queue's default
|
||||
again before it starts the document and refuses a mismatch with its own
|
||||
error code. The job is FAILED.
|
||||
- *Queue removed from Windows.* The print helper finds no queue before it
|
||||
sends anything, but reports that as an ordinary failure, which the worker
|
||||
cannot tell from one that happened part-way. The job is OUTCOME_UNKNOWN
|
||||
and needs an operator's review, although nothing was printed. An error
|
||||
code for a missing queue would let the worker record FAILED; it is not
|
||||
implemented.
|
||||
- **Between SUBMITTING and Windows.** If delivery of the SUBMITTING receipt
|
||||
cannot be confirmed, or the worker is stopped after it was acknowledged and
|
||||
before the native call, Windows is never asked. The job is FAILED and its
|
||||
reason says that no bytes were submitted.
|
||||
- **After spool acceptance.** Once a job is recorded as SPOOLED it stays
|
||||
SPOOLED. A failure while releasing its staging reservation or printer turn
|
||||
afterwards is logged and is not written over the record.
|
||||
- **One way to print.** A printer backend reads printers and prints one
|
||||
verified staged file under a submission limit; it has no other print
|
||||
operation. Version 2.0 deleted the submission of a document from memory, and
|
||||
with it the separate interface for backends that take files, so every
|
||||
backend takes one, the exposure filter and the profile wrapper included. A
|
||||
worker can no longer be built with a backend that cannot print its staged
|
||||
file, and a wrapper can no longer refuse a file after SUBMITTING was
|
||||
recorded because the backend inside it takes none. The print helper accepts
|
||||
five operations (the three reads: discover, list without media, find one
|
||||
queue with its media; print a staged file by its path; cancel one spool
|
||||
job); it no longer accepts a document inside a request. The queue to find
|
||||
is named by its identifier, and a request whose identifier is not 24
|
||||
hexadecimal digits in lower case is refused before any queue is asked.
|
||||
|
||||
## Administrator settings
|
||||
|
||||
In QQQ, authorized administrators use **Printing connection and limits** for
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
# Session state — 2026-11-11 (`release/2.0` only)
|
||||
|
||||
## 2.0 in progress on `release/2.0`; `main` is unaffected
|
||||
|
||||
Version 2.0 (several servers at once, a connection wizard, enforced quality gates) is decided in [ADR-0003](adr/0003-version-2.md) and built from pull requests against `release/2.0`. Nothing here is released, installed or printed. One development snapshot has been published (below); it is not a release.
|
||||
|
||||
- `release/2.0` is at `02f34c3`. Twenty-nine pull requests are merged: 20, 21, 25, 24, 27, 28, 29, 30, 31, 33, 34, 35 and 37 to 53. The service still holds one server; version 2 of the management pipe exists as types that nothing serves yet; the connection wizard and the server tree are not built. The fourteen merged after the snapshot below, oldest first:
|
||||
- 40 (`b5dfb2c`): a stop that overtakes the realtime channel's loops does not fail the run.
|
||||
- 41 (`077148f`): sign-in startup, the branded shortcut and the browser hand-off behind tested seams.
|
||||
- 42 (`e2ca086`): every Windows printing call behind a tested seam, changing nothing that reaches Windows.
|
||||
- 43 (`db6ee01`): documents only: the design-record gaps settled, the review gate and the hardware check written down, the audit brought up to date.
|
||||
- 44 (`6ad3001`): the printer is checked once per job, a job in hand is left to its lane, and a lane always lets go of a job.
|
||||
- 45 (`f481c43`): a claim whose text cannot be read is refused instead of stopping the lane.
|
||||
- 46 (`fe95a9f`): a piped enrollment code is read as UTF-8 bytes on every console code page.
|
||||
- 47 (`7b99a29`): a certificate exception names its origins, and an address with no nameable origin is refused.
|
||||
- 48 (`65a99d3`): the load balancer's affinity cookies are kept, so requests and the realtime socket reach one server node (until the load balancer chooses again: audit W90).
|
||||
- 49 (`ebea861`): the journal's schema has a version and is migrated once, when the service starts.
|
||||
- 50 (`db45420`): a print job asks Windows about its own printer only.
|
||||
- 51 (`4e01519`): the review findings in the pipe contract and in the log's redaction closed.
|
||||
- 52 (`2e08344`): the in-app update, the service controls and the pipe's identity check behind tested seams.
|
||||
- 53 (`02f34c3`): the companion's own sign-in for several servers; a failed browser start no longer logs the sign-in address.
|
||||
- Verified on that content, locally: `build/Verify.ps1` passed on the reviewed head of pull request 53 (`0b9102e`), whose tree is the tree of `02f34c3`, as that pull request's description records: 1,174 Core tests, 2,321 Windows runtime and command-line tests and 537 presentation tests, none failed; line coverage Core 100% of 2,311 lines, presentation 100% of 1,373, command line 100% of 120 (the count when it was last listed, with pull request 46; its source has not changed since), Windows 96.08% (4,540 of 4,725). Gitea runs no checks for this repository. Nothing was built or run for the documentation change that wrote this entry on 2026-11-11.
|
||||
- Not done on anything from `release/2.0`, as far as any record shows: nothing has been run elevated, nothing has been installed and nothing has been printed. The descriptions of pull requests 41 and 52 say for their own changes that no elevated run was made, the description of pull request 53 that nothing ran in an installed companion, and neither hardware check has been run. What the tests show about printing is shown against hand-written fakes of Windows: at most that the product records a job as accepted into the spooler. Nothing here says that Windows accepted a job, and nothing says that paper came out.
|
||||
- Still to build, from the two plans this work follows:
|
||||
- The multi-server build ([ADR-0004](adr/0004-multi-server-runtime.md)): plan step 10, the server registry and a store opened for one server, is on a branch that has not merged (in review on 2026-11-11, by the integrator's record, which was not checked for this entry); plan steps 11 to 25 are not merged (per-server sharing, files and the upgrade moves; scopes and retention; the host, supervision, claim demand and the printer queues; several connections; the version 2 pipe server and its operations; removing a server; the command line on version 2; retiring version 1).
|
||||
- The wizard and tree build ([ADR-0005](adr/0005-connection-wizard-and-server-tree.md)): of its plan's sixteen steps, 1 and 3 are merged (in pull requests 51 and 53), step 2, the companion's client on the version 2 envelope, is on a branch that has not merged (in review, by the same record), and steps 4 to 16 are not merged (the wizard, the shell and tree, the pages, the views, the interactive suites on a synthetic service, and the documents).
|
||||
- The qualification of the upgrade on an installed 1.2.1, the two checks on real printers, and the re-audit that the release rule of [the audit](AUDIT-2026-11-10.md) asks for. None has been run.
|
||||
- Decisions written down on 2026-11-11, all taken under the standing instruction and open to the owner's reversal: the twenty-one gaps of the wizard plan in [ADR-0005](adr/0005-connection-wizard-and-server-tree.md), "Resolved while planning the build", and which service step builds `transport.get` and `printers.discover` in [ADR-0004](adr/0004-multi-server-runtime.md), "Resolved while planning the wizard and tree build". G7 and G11 stand on conditions stated with them, and neither condition is met yet. One half of G19, adding the layout tests to the solution, was what the plan called "the owner's call"; it was taken like the rest, nothing is built on it, and it is listed for the owner. What needs the owner is listed first in [TODO](TODO.md).
|
||||
- Snapshot, 2026-11-10: the Gitea prerelease `snapshot-2.0.0-rc.1` at commit `6f0237d`, unsigned. `build/Verify.ps1` passed on that commit: 766 Core tests, 1,117 Windows and command-line tests, 352 presentation tests, none failed; line coverage Core 100% (2,130 lines), command line 100% (117), presentation 100% (922), Windows 74.84% (3,172 of 4,238). It has not been installed anywhere, the interactive desktop suite has not been run on it, nothing has been printed with it, and the upgrade from 1.2.x is not qualified. It has both cases of audit finding U22 (a browser that cannot be started puts the sign-in address, or the provider's sign-out address, into the companion's log), which builds from `02f34c3` on do not have; the description of pull request 53 says that the snapshot's release notes list this as a known issue. Which released versions have the sign-in case was stated wrongly when that was written: audit U22, the changelog and that description gave every release from 0.2.0-rc.1 to 1.2.1 (eleven version tags), and by the code at the tags it is the four from `v1.1.0` to `v1.2.1`. U22, the changelog and [TODO](TODO.md) were corrected on 2026-11-11. By the integrator's record, the snapshot's release notes on Gitea were corrected too and a comment on pull request 53 records the correction; neither the notes nor the comment was read on Gitea for this entry (not verified).
|
||||
- Coverage minimums in `build/minimum-coverage.json`: Core 100, presentation 100, command line 100, Windows 95. The Windows minimum was 86 at `e2ca086` and 87 after pull request 49; it is 95 since pull request 52, the change that put the update installer, the update service, the service controls and the pipe-peer check behind tested seams (plan step 8 of the build). `build/Verify.ps1` on that change measured Core 100% of 2,311 lines, the command line 100% of 120, the presentation assembly 100% of 936 and Windows between 96.06% and 96.14% over four runs (4,539 to 4,543 of 4,725), with 1,174 Core, 2,321 Windows and command-line and 360 presentation tests; pull request 53 then added presentation code and tests (1,373 lines and 537 tests, above). The Windows layer is therefore above its release bar of 90, and the minimum keeps it there. The bar's exception record is EX-0016 in the standards repository (pull request 58, open), whose status is "Proposed, awaiting approval".
|
||||
- Review gate (the owner asked on 2026-11-10 that the process be changed; the terms were taken under the standing instruction): no head merges that the reviewer has not seen; see "About the process" in [ADR-0004](adr/0004-multi-server-runtime.md). Pull requests 28, 29, 35, 37 and 38 merged with an unseen final fix round and 30 and 31 with no review; two retrospective reviews followed, and their open findings are in [the audit](AUDIT-2026-11-10.md).
|
||||
- Hardware check (release condition, [ADR-0003](adr/0003-version-2.md)): an early check after the printing seams step and a final check on the release candidate, on a Zebra ZT411 and a Canon office printer, each print approved by the owner. The printing seams step merged as pull request 42, so the early check is due, and it needs the owner present. Pull request 50 then changed what reaches Windows for queues other than the job's own, and is inside both checks ([ADR-0003](adr/0003-version-2.md), "Hardware check: what it must look at for the one-queue change"). Neither check has been run.
|
||||
|
||||
The entries below are the state when the branch was started. They are kept as written except two that were corrected on 2026-11-10 because they had become untrue (the coverage gate's first minimums, and which exception records are filed); where they differ from the entries above, the entries above are current.
|
||||
|
||||
- Baseline, measured locally on `a695996` with the full `build/Verify.ps1`: 197 Core and 377 Windows tests pass; merged line coverage is Core 87.19% (1,674 of 1,920), Windows 69.04% (2,623 of 3,799), CLI 38.26% (44 of 115). The WinUI companion is not measured.
|
||||
- A second run of the same commit measured Windows at 68.91% (2,618 of 3,799): five lines in timing-dependent waits are not reached on every run.
|
||||
- The coverage gate runs in `build/Verify.ps1`; its first minimums were 86 / 68 / 37 (Core / Windows / CLI) in `build/minimum-coverage.json`: the measured value rounded down, less one point for that variance. The release bar is 100 / 90 / 100, so the gate currently proves "within a point of the baseline", not "covered".
|
||||
- Not yet done: the standards exception record for the Gitea equivalents is unfiled (the one for 90% Windows is filed and not approved, above), changed-line coverage is unmeasured, and Gitea runs no checks for this repository until its CI workflow lands (owned by the Gitea CI work, not this branch).
|
||||
- Since 2026-11-10 the owner allows `Core.Tests` to be built on the maintainer's PC; see the host note in `CLAUDE.md`. Earlier entries below that call Core.Tests hosted-only describe the state before that date.
|
||||
- Stages: (1) foundations and gates, (2) runtime decomposition while still single-server, (3) multi-server service and versioned pipe, (4) view models, server tree and wizard, (5) release hardening and an independent re-audit.
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
# TODO
|
||||
|
||||
## Needs the owner
|
||||
|
||||
Written on 2026-11-11. Five things wait for the owner; the detail of the second, third and fourth is in the items under "Open for 2.0".
|
||||
|
||||
- [ ] **Whether a 1.2.x release gets the fix for the sign-in address in the log.** Versions 1.1.0, 1.1.1, 1.2.0 and 1.2.1 (the four version tags `v1.1.0` to `v1.2.1`) write the identity provider's sign-in address to the companion's log, and show it in the dialog, when Windows cannot start the browser: the whole address, with that attempt's `state`, `nonce` and `code_challenge`. The seven tags from `v0.2.0-rc.1` to `v1.0.1` start the browser the same way but catch the failure and show a fixed text; they neither log nor show the address (read at each tag; the logging came with `aaa5051`). It takes a failed browser start to happen: no default browser, or a broken handler for web addresses. The unreleased `release/1.2.2` branch (`d6c3013`, in no tag) has that case and would add a second one: the provider's sign-out address, with its `id_token_hint`, in the log. 2.0 has the fix since pull request 53 ([audit](AUDIT-2026-11-10.md) U22, and the first entry of the changelog); `main` and `release/1.2.2` were not changed by it. Whether a 1.2.x release gets the fix is the owner's decision. A 1.2.2 must not be released without it. [ADR-0003](adr/0003-version-2.md) says that no 1.2.x patch follows once 2.0.0 is released, so the decision is about the time until then.
|
||||
- [ ] **The check on real printers after the printing changes is due** (pull requests 42 and 50), and it needs the owner present, to approve each print at the time it is sent. "Hardware check, early" and the item about the one-queue change below say what it has to look at. Not run.
|
||||
- [ ] **The upgrade qualification needs an administrator prompt answered.** Installing 1.2.1, updating it to a 2.0 build and the elevated runs listed under "Qualify the upgrade from 1.2.x to 2.0" below raise Windows' consent prompt, which the owner answers each time ([ADR-0004](adr/0004-multi-server-runtime.md), "About the process"). Not run.
|
||||
- [ ] **The corrected exception record EX-0016 awaits the owner's approval** (the Windows coverage bar of 90%; the item "Standards exception EX-0016" below). Read on 2026-11-11 in the local clone of the standards repository: the record is on the branch of standards pull request 58 at `7a58fb4` with the status "Proposed, awaiting approval", and it is not on that clone's `main`. Whether the pull request is still open on the server was not checked.
|
||||
- [ ] **A decision taken on the owner's behalf that the owner may want to reverse: the layout tests join the solution**, so that every pull request builds them ([ADR-0005](adr/0005-connection-wizard-and-server-tree.md), G19). The plan for the wizard and tree build had called this "the owner's call", because the layout test project is outside both solution files on purpose. It was taken under the standing instruction like the plan's other recommendations: the project is added in the step that builds the interactive suites (plan step 15). Nothing is built, and neither solution file changes until that step, so a reversal before then has nothing to undo; until then each step that touches the layout tests builds them by hand and says so in its pull request.
|
||||
|
||||
## Open for 2.0
|
||||
|
||||
- [ ] A printer whose settings cannot be read still stops printing after a restart or a reconnect, because dispatch waits for one full discovery (decided 2026-11-10, not built; the open part of W65; see "About printing" in `docs/adr/0004-multi-server-runtime.md`): report such a queue as UNKNOWN with no formats and no media, read each queue in its own helper when the helper for all of them fails, and do not ask an unreadable queue again until a retry interval has passed. Its own pull request, with its own review and the check on real printers.
|
||||
- [ ] The print helper reads the job's own queue's settings once before the document and discards the result (the selected-media check and the PDF path read what they need themselves; W66 in [the audit](AUDIT-2026-11-10.md)). Removing that read changes what reaches Windows for the printer being printed to, so it goes with a check on real printers, not before.
|
||||
- [ ] The one-queue change is inside both hardware checks below. What each check has to look at is the proposed list in [ADR-0003](adr/0003-version-2.md), "Hardware check: what it must look at for the one-queue change". Its first rule: reproduce the stall on the old behaviour first, and record "defect not reproduced" if no queue on the test PC holds a full discovery; a paused queue or a switched-off printer does not count. Not agreed by the owner, not run.
|
||||
- [ ] A printer connection to a print server that is switched off or unreachable still stalls the list, the lookup and the print for every printer (W70 in [the audit](AUDIT-2026-11-10.md); known from Microsoft's documentation, not measured). Listing in a way that opens no connection changes what reaches Windows, so it goes with the hardware check, whose list has the connection case.
|
||||
|
||||
- [ ] CI definitions still name a literal version for branch builds: `.github/workflows/ci.yml` falls back to `1.2.1`, and so does the Gitea build file proposed in pull request 17. On `release/2.0` the source version is 2.0.0, so `Build-Installer.ps1` refuses those builds ("ProductVersion must match the reviewed source VersionPrefix"). A branch build must name no version (`./build/Build-CiRelease.ps1` then packages the source version); a tag build passes the tag's version. Removing the fallback is not enough: an empty `-ProductVersion` is refused, so the parameter must be left out, and the step that locates the MSI by the same variable needs the value from `./build/Get-SourceVersion.ps1`. Owned by the CI work; the workflow files are deliberately not changed by the 2.0 branches.
|
||||
- [ ] Hardware check, early (release condition, [ADR-0003](adr/0003-version-2.md)): due now, because the printing seams step it waited for merged as pull request 42 (`e2ca086`). On the development PC, by driving the built print helper without installing anything. A Zebra ZT411 at 300 dpi for ZPL and a Canon office printer for PDF; the owner approves each print at the time. Not run.
|
||||
- [ ] Hardware check, final (release condition): on the release candidate, through the installed service, same two printers, each print approved by the owner. "Ready to merge to `main`" is held until it is recorded in [ACCEPTANCE.md](ACCEPTANCE.md), with "the spooler accepted the job" and "paper came out" as separate statements. Not run.
|
||||
- [ ] Standards exception EX-0016 (the Windows coverage bar of 90%): in standards pull request 58, which is open; its status is "Proposed, awaiting approval".
|
||||
- [ ] Open findings from the two retrospective reviews of 2026-11-10 and from the review of the printing seams step: listed in [the audit](AUDIT-2026-11-10.md) under "Found by the retrospective reviews". The should-fix findings about the pipe contract close before anything serves the contract.
|
||||
- [ ] Nothing reconnects the realtime socket when a load balancer moves this client's HTTP requests to another node while the socket stays open on the first (W90 in [the audit](AUDIT-2026-11-10.md), Should-fix; [REALTIME.md](REALTIME.md), "A server that runs as several nodes"). Against a server at QQQ Printing 1.1.x every claim is then refused until the socket closes for another reason; no job is lost or printed twice. The fix belongs in the runtime: reconnect when a claim is refused for this reason, or when the balancer rewrites its cookie. Not built. The review of pull request 48 would schedule it inside 2.0, and neither plan of the 2.0 build gives it to a step (both searched on 2026-11-11).
|
||||
- [ ] Qualify the upgrade from 1.2.x to 2.0. Not done: the 2026-11-10 snapshot (`snapshot-2.0.0-rc.1`) was not installed anywhere. The qualification also has to cover what pull request 41 left unverified: no elevated MSI run has exercised the new branding and startup code, so the real machine registry key, the installer's custom actions, and shell and folder creation as SYSTEM are unexercised, and its real-shell tests ran only on the development PC's Windows build. It also has to cover what the review of the update, service-control and pipe-peer seams (plan step 8, 2026-11-10) listed as shown only by an elevated run on the installed product; none of it has been run:
|
||||
- The upgrade is finished by 1.2.1's own updater, not by 2.0's ([ADR-0004](adr/0004-multi-server-runtime.md), "Recorded while reviewing the update and pipe-peer seams"). Run a real 1.2.1 installation's in-app update to a 2.0 build and check what that helper needs from 2.0 after the installer exits:
|
||||
- the companion, the service and the command line tool at their three paths under `%ProgramFiles%\QQQ Print Agent`, each carrying the release as its product version, with identical build metadata, so that the old helper records success;
|
||||
- the service named `QqqPrintAgent`, running as SYSTEM from its service path and serving the pipe `QqqPrintAgent`, so that the old helper's peer check passes;
|
||||
- an answer of one line to the old helper's version 1 request `resume-after-update`, the only request it sends after the installer exits, and printing not left paused afterwards;
|
||||
- `last-result.json` as the old helper wrote it, and its line in the machine activity log, shown by the 2.0 companion it reopens;
|
||||
- the companion reopened by the old helper from `App\Qqq.PrintAgent.exe`.
|
||||
- Not read in that review and to be checked before this run: what 1.2.1's release client needs from the published 2.0 release before the installer starts (the feed entry, the asset names and the checksum).
|
||||
- The first real authentication through the new adapters, from an elevated command line tool or companion against the installed service. No unit test has any of these:
|
||||
- `GetNamedPipeServerProcessId` asked by a client whose server is another process (a test now shows the reverse, the server's end with a child as client);
|
||||
- `ServiceController.ServiceHandle` opening with full access, and `QueryServiceStatusEx` returning state 4 and the pipe server's id;
|
||||
- `OpenProcessToken` on a process of another account reporting SYSTEM (audit W77);
|
||||
- the main module equal to `%ProgramFiles%\QQQ Print Agent\Service\Qqq.PrintAgent.Service.exe`.
|
||||
- The two refusals for real: the same command without elevation gives "not the installed Windows print service", and the service stopped during a command gives "stopped or restarted".
|
||||
- The 2.0 updater itself, which needs a 2.0 build that updates to a later 2.0 build:
|
||||
- the real access lists under `C:\ProgramData\QqqPrintAgent\updates\<session>` and on the installed `Cli` tree passing the trust check;
|
||||
- the helper's own program path equal to the staged path;
|
||||
- the companion's id and start time agreeing between the companion and the helper;
|
||||
- Windows Installer able to open the MSI while the helper holds it with read sharing;
|
||||
- exit codes 0, 1602 and 3010;
|
||||
- the three version resources read;
|
||||
- `last-result.json` read back by the reopened companion;
|
||||
- `resume-after-update` accepted through the peer check.
|
||||
- A shortcut that another program holds (the fix of 2026-11-11; audit W120 to W122). No real install, upgrade or uninstall has run this code. Each run needs an administrator and the owner, and after each the activity log (`%ProgramData%\QqqPrintAgent\logs`) is read for the `[Installer]` lines:
|
||||
- an uninstall of a 2.0 build while another program holds the shortcut under **Start → Print Connectors** open (once with nothing shared, once shared for reading): the uninstall finishes, the shortcut is left, the values `LastShortcutName` and `PendingShortcutNames` are gone, one warning names the file; then an install of the same build under the same display name takes the shortcut over;
|
||||
- an upgrade from a 2.0 build to a later 2.0.x build with the shortcut held the same two ways: the upgrade finishes and is not rolled back, the shortcut still starts the companion, `LastShortcutName` is unchanged, one warning names the file, and a repair afterwards replaces the shortcut;
|
||||
- the same uninstall and upgrade with Explorer's Start menu open on the Print Connectors folder, and with the shortcut selected in Explorer;
|
||||
- the same uninstall and upgrade with a real virus scanner active (Microsoft Defender's real-time protection, and a full scan of the Start menu folder running);
|
||||
- a rename (a changed `BRANDNAME`) while the previous display name's shortcut is held: the new shortcut appears, the old one stays, and the next run removes it;
|
||||
- an install over a file of the shortcut's name that is not recorded and is held: the install fails and rolls back, and the error line in the activity log says to close the program that holds the file; what Windows Installer itself shows is to be written down, because the documents state it from Windows Installer's rules and not from a run;
|
||||
- whether the two seconds of tries are enough in each of these, and how long the installer's step takes when they are not.
|
||||
- The service controls on the real service: a refresh that really reads again (audit W78), stop, wait and start through real pending states, "Unavailable" without elevation, and the Services console with its elevation prompt.
|
||||
- The Windows-layer suite run once elevated and once as SYSTEM or on the hosted runner: the token assertions, the administrator branch of `InstalledProductsStepsAreBoundToThisComputer`, the shell start without a desktop, and `mklink /J`.
|
||||
- [ ] Constraint on the service step that builds the version 2 pipe server: it needs a test that no version 2 request is ever answered outside the envelope, on any path: an exception in a handler, the operation's time limit, a refused or unreadable request, a full pipe. The companion's client reads any answer that is not JSON as an older service ([ADR-0005](adr/0005-connection-wizard-and-server-tree.md), "Resolved while reviewing the management client"), so text such as the version 1 loop's `Error: ...` after an operation acted would tell the operator to update the service. The same step supplies the version 2 send function, which has to keep the contract stated on `ManagementClient`'s constructor: which exceptions mean "not sent", which mean "outcome unknown", nothing else for what a pipe can do, and no request or reply text in any exception message.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
| 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. |
|
||||
| Companion cannot connect to service | Confirm the MSI is installed, `QqqPrintAgent` is running, and the companion is elevated. The command-line tool is only a client of that pipe; it does not host it. |
|
||||
| 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). |
|
||||
| Sign-out reports a failure on a development server with **Allow untrusted server certificate** on | The exception covers the server's own origin. During sign-in the companion also applies it to the sign-in provider the server names, but the service does not yet, so when that provider is on another origin with an untrusted certificate the service ends the QQQ session and signs out locally and then cannot read the provider's sign-out page; the log shows `TLS certificate for <provider host> rejected` and which origins the exception covers. The provider's browser session stays open: close the browser or sign out at the provider. Installing the provider's root in **Local Machine → Trusted Root Certification Authorities** removes the need for the exception. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Log shows "The printer inventory could not be read" | The line ends with the reason, and what to expect depends on the cause. **One printer's settings cannot be read:** its driver hangs (the read is given up after 20 seconds, and the reason is a timeout) or fails. Jobs for the other printers keep printing, unless dispatch is paused, the log has no "Print lane stopped", and only the printer list is not reported. Find the printer (open each queue's **Printer properties**; the one that hangs or errors is it), repair its driver or connection, or remove the queue from Windows. **The Spooler cannot list its printers, or the list stalls:** the reason is the Spooler's own error or again a timeout. The print lanes need the same list, so expect "Print lane stopped" in the log as well, and no printing for any printer until it is repaired (see the row on "Print lane stopped" below). **The service cannot read its own stored data:** the reason names the journal (a `SqliteException`, for example). The print lanes need the same data, so expect "Print lane stopped" as well, and no printing until it is repaired; check the disk and the service's data folder. In every case the server is told nothing new about any printer until it is over, a healthy one included. The line also says one of two things about the connection. "The inventory this connection already delivered stays in force" means that this failure by itself holds no job; whether jobs go on is decided by the cause, as above. "No job is claimed on this connection" means that the service restarted or reconnected while the list could not be read, and nothing prints until it can, whatever the cause. The log says "The printer inventory can be read again" when it is over. |
|
||||
| Companion shows an error in place of the service's status, or `printers` / `snapshot` is slow and then fails with a timeout | The same cause: the snapshot the companion asks for carries the printer list, which reads every printer's settings. Jobs for other printers are not affected by it; see the row above. |
|
||||
| No job is taken for any printer, and the log shows "Print lane stopped: The printer operation timed out" | The service could not get the list of printers from Windows within 20 seconds, which it asks for before it takes each job. A Spooler that is stopped or hung does this. So can one printer that is a connection to a printer shared by another computer (a print server) while that computer is switched off or cannot be reached: Windows opens every such connection each time the printers are listed and waits for it to time out. Jobs for every printer are held up by it, not only jobs for that printer. Bring the print server back, or remove that printer connection from Windows. The print-server case is known from Microsoft's documentation of the Windows call and has not been reproduced on a station. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -17,7 +21,9 @@
|
||||
| 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). |
|
||||
| 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. |
|
||||
| Another worker owns the directory | A second copy of the service host (for example a development build started by hand) holds `worker.lock`. Stop it. Only one service may own a given journal/data directory. |
|
||||
| An install, upgrade or uninstall stops with "A program run as part of the setup did not finish as expected" and rolls back | One of the installer's own steps failed; Windows Installer does not say which or why. For the step that creates or removes the Start menu shortcut, the reason is in the service's activity log, `%ProgramData%\QqqPrintAgent\logs\qqq-print-agent-<date>.log` (open it as an administrator; the folder is still there after an uninstall): look for the error line `[Installer] Branding shortcut operation failed.` and the text after it. A shortcut file that another program holds is not a cause: it is tried for two seconds and then left where it is, with a `[Installer]` warning line that names the file. **An uninstall** fails at this step for three reasons only: it does not run as an elevated administrator, the registry key `HKLM\SOFTWARE\QRunIO\PrintAgent` cannot be written, or the **Print Connectors** folder of the all-users Start menu cannot be listed. **An install, upgrade or repair** also fails when the install folder, the Start menu or the Print Connectors folder has an untrusted owner or lets an ordinary user write; when the companion is missing from the install folder; when a file that is not this product's shortcut already has the shortcut's name (rename or remove that file, or choose another display name); when a file under that name cannot be opened and the registry does not record it as this product's (close the program that holds it and run the installer again); or when Windows' shortcut automation (the `WScript.Shell` object of Windows Script Host) is not available. A verbose installer log (`msiexec /i <msi> /l*v install.log`) names the step as `ApplyBranding` or `RemoveBrandingShortcut`. None of this has been observed in a real installer run yet; it is what the code and Windows Installer's rules give. |
|
||||
| A shortcut under **Start → Print Connectors** is still there after an uninstall | The uninstall could not open or delete it, usually because another program held the file, and left it where it is rather than fail. The activity log has a `[Installer]` warning with the file name and the kind of failure. The shortcut points at a program that is no longer installed: delete it by hand (as an administrator), or install again under the same display name, which takes the shortcut over. |
|
||||
| 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. |
|
||||
|
||||
## ZPL prints but PDF does not
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-0002: Initial prerelease quality and packaging gates
|
||||
|
||||
Status: accepted for the owner-authorized happy-path prerelease. Date: 2026-10-29.
|
||||
Status: accepted for the owner-authorized happy-path prerelease. Date: 2026-10-29. The coverage waiver below is superseded by [ADR-0003](0003-version-2.md) (2026-11-10), which gates coverage per assembly; the MSI validation exceptions remain in force.
|
||||
|
||||
The original template mandates 100% core/CLI coverage and automated desktop acceptance. The new connector includes native printing and a privileged service; those requirements are not yet established. The owner requested a working happy path, public setup and CI that actually verifies this release rather than claiming an unattained gate.
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# ADR-0003: Version 2.0 — several servers, a connection wizard and enforced quality gates
|
||||
|
||||
Status: accepted by the owner on 2026-11-10; in progress on `release/2.0`. Supersedes the coverage waiver in [ADR-0002](0002-prerelease-quality-and-packaging.md); its MSI validation exceptions stand. Updated on 2026-11-10: the status of the coverage exception, and the hardware check as a release condition.
|
||||
|
||||
## Context
|
||||
|
||||
Through 1.2.x the service holds exactly one server connection: one saved pairing, one session, one realtime socket, and a management pipe whose requests carry no server identity. Testing against a second server means signing out and pairing again. The connection dialog puts the address, a certificate exception, three pairing choices, discovery, two sign-in methods and an enrollment code on one scrolling form.
|
||||
|
||||
A baseline audit on 2026-11-10 measured merged line coverage of 87% for Core, 69% for the Windows layer and 38% for the CLI, with the WinUI companion unmeasured and no gate. It found the runtime concentrated in one class holding every piece of connection state, about 1,300 lines of decision and formatting logic inside view classes, and a polling path in the runtime that only tests reach. ADR-0002 waived the coverage gate for the first prerelease; the product has been stable since 1.0.1.
|
||||
|
||||
## Decisions
|
||||
|
||||
**Several servers at once.** The service keeps up to eight server connections. Each has its own sign-in, pairing, printer sharing, printer profiles, media choices, certificate exception, journal scope and staging directory. A printer may be shared with more than one server; one machine-wide queue per printer keeps two servers from interleaving on a device. One connection failing must not stop another. Every edition gets this; none is limited to one server.
|
||||
|
||||
**In-place upgrade, no way back.** The 1.x pairing, history and printer choices become the first server without re-pairing. The installer already refuses downgrades. 1.x ends when 2.0.0 is released: the updater offers 2.0 to 1.x installations through the normal prompt and no 1.2.x patch follows.
|
||||
|
||||
**A wizard replaces the connection dialog.** Its steps are address, sign-in, pairing, printers to share, done. The pairing step detects what the server needs rather than asking the operator to pick from three options first.
|
||||
|
||||
**Presentation logic leaves the views.** Decisions and formatting move to a UI-free view-model assembly; views bind to it. The companion shows servers as a tree, each with its own printers and jobs; logs, settings, the Windows service and updates stay machine-level.
|
||||
|
||||
**The management pipe gets a versioned contract.** Requests name the server they address, replies are typed instead of matched by text prefix, and a client or service that does not understand the other's version says so.
|
||||
|
||||
**Coverage is gated.** `build/Verify.ps1` fails when an assembly falls below its minimum in `build/minimum-coverage.json`. Each minimum is the measured percentage rounded down to a whole number, less one point: a few lines in timing-dependent waits are reached on some runs and not others (five lines of the Windows layer moved between two runs of the same commit), and a minimum set at the measured value would fail at random. Minimums only move up, by the same rule, in the change that adds the tests. An assembly that has reached its release bar is recorded at the bar, not a point under it: the command line is at 100, where every line is reached by tests that do not depend on timing, and a point of slack would only hide a regression. Above a release bar that is below 100, which is the Windows layer's case, the minimum keeps following the rule (the measured percentage rounded down, less one point), and once the bar has been reached it never drops below the bar; this sentence was taken on 2026-11-10 under the owner's standing instruction for this work (take the recommended option, record it, keep going), and the owner can overrule it. The release bar is 100% for Core, the view-model assembly and the CLI, and 90% for the Windows layer, where the remainder is native printing, installer and service-control code behind thin adapters marked `[ExcludeFromCodeCoverage]` with a written reason. The 90% figure deviates from K22-TEST-10. The exception record for it is EX-0016 in the KofTwentyTwo standards repository, in pull request 58, which is open (head `7a58fb4` on 2026-11-10). The record's status is "Proposed, awaiting approval": an earlier wording was approved, a review then corrected the wording, and the corrected wording has not been approved. It lists K22-TEST-10 and, in part, K22-CODE-CS-11; it states a release bar of 90 and the minimum "73 at commit `6f0237d`"; and it makes a check on real printers a condition of releasing 2.0. Until it is approved, the deviation is not covered by an approved exception. The 90% is a release bar, and since 2026-11-10 the gate holds the Windows layer above it. The recorded minimum for the Windows layer was 73 at `6f0237d` (74.84% measured). It rose to 80 with pull request 41, to 86 with pull request 42 (87.47% measured, 3,835 of 4,384 lines, on its reviewed head) and to 87 with pull request 49. It is 95 with the change that put the update installer, the update service, the service controls and the pipe-peer check behind tested seams (plan step 8 of the build), which measured between 96.06% and 96.14% over four runs (4,539 to 4,543 of 4,725 lines). K22-TEST-10 also asks for 80% coverage of changed lines; nothing measures that yet, and it is open work for this release.
|
||||
|
||||
**Test doubles are hand-written fakes at boundaries.** No mocking library is added. The polling path that only tests reach is removed once its tests run against the realtime path.
|
||||
|
||||
**Delivery.** Work lands as small pull requests on `release/2.0`; `main` stays 1.2.x until release. Gitea is the primary host, so the repository standard is to be met with Gitea's equivalents; an exception record mapping the GitHub-only requirements is to be proposed and is not yet filed.
|
||||
|
||||
## Consequences
|
||||
|
||||
The native printing API, wire payload and WebSocket subprotocol do not change: each server still sees one device. The pipe contract, the on-disk state layout and the CLI's option names do change, and 2.0 is the release that breaks them.
|
||||
|
||||
One LocalSystem service holding credentials for several servers has a larger blast radius than one. Isolation between connections is a security boundary and belongs in the [threat model](../security/threat-model.md) before the multi-server service merges.
|
||||
|
||||
Until an assembly's release bar is reached, a green gate means "no worse than the recorded minimum", not "fully covered". Every gated assembly has now reached its bar, the Windows layer last, on 2026-11-10. For the Windows layer a green gate still does not mean "fully covered": its bar is 90, and what is not covered is the excluded adapters and the lines the coverage report lists.
|
||||
|
||||
## Release condition: the hardware check
|
||||
|
||||
Added on 2026-11-10. This is a condition for releasing 2.0, beside the two already on record: the release rule of the [baseline audit](../AUDIT-2026-11-10.md) and the coverage bars above. The exception record EX-0016 (above) makes a check on real printers a condition of releasing 2.0 as well.
|
||||
|
||||
The owner agreed on 2026-11-10 to two checks on real printers on the development PC: a Zebra ZT411 at 300 dpi for ZPL and a Canon office printer for PDF. Each print is approved by the owner at the time it is sent; nothing is printed on the strength of this record alone. Neither check has been run.
|
||||
|
||||
- **Early check.** After the printing seams step, the step that puts the native printing calls behind adapters. That step merged on 2026-11-10 as pull request 42 (`e2ca086`), so the early check is due. It has not been run. It runs without installing anything, by driving the built print helper executable. Its purpose is what the unit tests of that step cannot judge, because they run against fakes: the marshaling of the real calls, what real drivers answer, the real spooler's errors and the geometry of the output.
|
||||
- **Final check.** On the release candidate, through the installed service (session 0, LocalSystem).
|
||||
- **Inside both checks: a job reads the settings of its own queue only.** Added on 2026-11-10 with the change recorded in [ADR-0004](0004-multi-server-runtime.md), "About printing". That change deliberately reduces what reaches Windows: for a job, queues other than the job's own get no per-queue call, where every queue's settings were read three times. Its tests run against fakes, so they cannot judge what only a real spooler and real drivers show, and each check takes that on, on a PC that has more queues installed than the one printed to. This item was rewritten in the fix round after the review of that change, which found that the check as first written could pass without showing that the fix works (its finding S2). The only broken queue it named was one that is paused or whose device is switched off, and it had no case of a printer connection to a print server (ADR-0004, "Known limit"). What each check must look at is now the list under "Hardware check: what it must look at for the one-queue change" below, and its first rule is that the stall is reproduced on the old behaviour before anything is concluded from the new one. The early check can drive the two new helper operations (`list-without-media`, `find-with-media`) as it drives the others. Not run.
|
||||
- "Ready to merge to `main`" is held until the final check is recorded in [ACCEPTANCE.md](../ACCEPTANCE.md), with the Windows version, driver and model, format, and paper or label size.
|
||||
- The steps headed "No paper" that submit a job (2 and 3 below) put real jobs in the Windows spooler. They count as prints for the owner's approval, and the owner confirms that the queue is paused before each is sent. If the queue is not paused, paper comes out.
|
||||
- The 1.2.1 values that steps 1 and 3 compare with are captured before the candidate is installed. Installing the candidate is the upgrade-qualification run, and it uses up the only 1.2.1 baseline on that PC, because the installer does not go back to a lower version.
|
||||
- Each record keeps two statements apart: that the spooler accepted the job (the job is recorded SPOOLED with its spooler job number), and that paper came out, with what the owner saw on it. The first never stands in for the second.
|
||||
|
||||
A proposed order, from the reviewer of the printing seams step. It is a proposal: the owner has not agreed to it, and the reviewer wrote it without the author's own list. It was written for a run through the installed service, so it describes the final check; the early check drives the helper and can take only the steps that need no service, and which those are is decided when the early check is prepared.
|
||||
|
||||
1. No paper: discovery compared with 1.2.1 on the same machine, against values captured from 1.2.1 before the candidate was installed. Identifiers, formats, and media profile identifiers and revision hashes must be equal. Leave it refreshing for a while and look for helper crashes.
|
||||
2. No paper, with the ZT411 queue paused: submit one ZPL job and inspect its name (`QQQ <jobId>`), its data type (RAW) and its size; cancel it from the product. Then delete a second job by hand and cancel it; the expected answer is ALREADY_ABSENT.
|
||||
3. No paper, with the Canon queue paused: submit the same PDF from 1.2.1 (before the candidate is installed, keeping its pages and size) and from the build under test, compare pages and size, and cancel both.
|
||||
4. One ZPL label.
|
||||
5. A PDF ruler page on the Canon at actual size.
|
||||
6. A PDF with selected media on the ZT411; scan a barcode.
|
||||
7. Three copies; a ZPL file over 64 KiB; two pages in two copies (the order must be 1, 2, 1, 2).
|
||||
8. FIT, with a page larger and a page smaller than the paper.
|
||||
9. Job paper: 4 × 1 on the 4 × 6 default must print short or be refused before the document is started.
|
||||
10. Change the printing defaults between selection and print; the expected result is a media mismatch and an empty queue.
|
||||
11. Kill the service mid-job; no helper process may be left behind.
|
||||
12. Fifty mixed jobs.
|
||||
|
||||
The reviewer also noted that the size of the rendered bitmap in session 0 needs one log line, or a harness with no printer, to be measured.
|
||||
|
||||
### Hardware check: what it must look at for the one-queue change
|
||||
|
||||
A proposal, written on 2026-11-10 by the reviewer of the one-queue change (ADR-0004, "About printing") and taken over here as the plan for that change. The owner has not agreed to it, and none of it has been run. None of it can be judged on fakes. It adds to the proposed order above and replaces nothing in it.
|
||||
|
||||
The rule that comes first: **reproduce the stall on the old behaviour before anything else.** The change exists because one queue could hold a full discovery, so the check must first find a queue on the test PC that does. That is a queue that holds the helper's `discover` operation, the read every job made before the change, until its 20 second limit, or that ends the helper. If no queue on the test PC holds a full discovery, the record says "defect not reproduced". The check has then shown that the new reads agree with the old one, and has not shown that the fix works. A paused queue, or a queue whose printer is switched off, is not such a case: it normally still answers the settings read, the paper lists and the device context from the driver's own data, so the code before the change would have printed with it too (the reviewer's inference, not a measurement).
|
||||
|
||||
Two kinds of queue hold a full discovery, and only one of them shows the fix. A printer connection to a print server that is dead holds the listing as well as the full discovery, because both begin with the same enumeration; it shows the known limit (W70) and not the fix. The fix is shown only by a queue that holds the full discovery and does not hold the list without media. Of the candidates in step 3, the connection to a print server that is switched off is of the first kind. The TCP/IP port to an address that drops packets and the damaged driver are meant to be of the second kind, reached by the settings read and not by the enumeration; timing `list-without-media` with each candidate present (steps 4 and 5) is what settles which kind it is.
|
||||
|
||||
What this section says above holds for every step here. A step that submits to a real queue (the prints in 5 and 6, the job in 8, the cancellation and the test print in 9) counts as a print, and the owner approves each at the time it is sent; nothing is sent on the strength of this record. The 1.2.1 values that a step compares with are captured before the candidate is installed. Setting up a queue that stalls (3 and 4) adds a printer or a connection to the test PC and needs an administrator, so it is agreed with the owner when the check is prepared. Steps 7 to 9 need the installed service and belong to the final check; steps 1 to 6 can drive the helper. A printer connection belongs to one Windows identity, so what step 4 shows for the signed-in user does not settle it for the service, which runs as LocalSystem; that answer comes from the final check. For steps 7 to 9 the queue must break after the service has delivered its inventory on that connection: a queue that is already broken when the service starts or reconnects stops every claim, and the run then shows only the restart limit. In step 8 the FAILED job is therefore observed before the restart.
|
||||
|
||||
1. **Agreement.** For every installed queue, `list-without-media` equals `discover` without the media, and `find-with-media` equals `discover`'s entry for that queue, including the media profile identifier and revision. Take both under the service's identity.
|
||||
2. **No driver loaded by the list.** Trace the modules the helper loads during `list-without-media`: none from the spooler's driver folder, and no `splwow64`. During `find-with-media`: only the driver of the queue asked for.
|
||||
3. **Reproduce the defect first.** Find a queue that actually holds `discover` for 20 seconds or ends its helper. Candidates: a connection to a print server that is switched off; a TCP/IP port to an address that drops packets, with bidirectional support on; a damaged driver. If none does, record "defect not reproduced".
|
||||
4. **The connection case.** Time `list-without-media` with a printer connection whose print server is switched off. Microsoft documents that the enumeration the service uses opens every such connection and waits for a timeout (ADR-0004, "Known limit"; W70 in [the audit](../AUDIT-2026-11-10.md)), so the expected result is a stall. If it stalls, the fix does not cover that kind of printer; record how long Windows waits and whether it waits again on every call.
|
||||
5. **With the broken queue present.** Time `list-without-media` and `find-with-media` for a healthy queue. Print ZPL and PDF on healthy queues, and compare the spooled name, data type and size with those from a station without the broken queue.
|
||||
6. **Shared spooler parts.** Run `find-with-media` and a print on a healthy queue while a `discover` helper is blocked in the broken one, preferably with two queues on the same driver or the same port monitor. The fakes assume that queues are independent of one another.
|
||||
7. **Long run.** Leave the service running for an hour with the broken queue present. A discovery helper is then ended at its 20 second limit on every status round. Watch the spooler's handle and thread counts, helper or `splwow64` processes left behind, and that prints on healthy queues keep their times.
|
||||
8. **The stated limits.** First, with the service running on the inventory it delivered: a job for the broken printer must be FAILED after about 20 seconds with nothing in the spooler. Then restart the service and confirm that nothing is claimed and that the log says so.
|
||||
9. **Cancel and test print** while the queue is broken.
|
||||
@@ -0,0 +1,451 @@
|
||||
# ADR-0004: Multi-server runtime, stored state and the management pipe contract
|
||||
|
||||
Status: accepted for 2.0 under the owner's direction of 2026-11-10 ("take the recommended option, record it, keep going"); not yet implemented. Refines [ADR-0003](0003-version-2.md), which decided that the service keeps up to eight server connections. Revised on 2026-11-10 after an independent review of the first draft; the revision is what is to be built. Revised again on 2026-11-10 after two retrospective reviews of merged work: what changed, and why, is in "Resolved after the retrospective reviews" below. One sentence about journal rows without a scope was corrected on 2026-11-10 when the journal's schema version was built: see "Resolved while building the journal's schema version".
|
||||
|
||||
## Context
|
||||
|
||||
In 1.2.x one `AgentRuntime` object is the connection: 51 of its 65 fields are per-connection state, one loop set serves it, and any of four tasks ending stops the service. The store derives an implicit "connection scope" from the one saved pairing; job events, job summaries, control state and realtime settings are keyed by it, while printer sharing, printer profiles and the certificate exception are machine-wide. The management pipe has one instance, serves one client at a time, takes string operations with no server identity and answers with text that callers match by prefix.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Servers have a local identity
|
||||
|
||||
Each server the operator adds gets a `serverId`: a GUID created on this computer, never sent to any server and never reused. Everything per-server is keyed by it. The registry is a `servers` table (`server_id`, normalized origin, display name, position, added time); the origin is unique, so one server cannot be added twice.
|
||||
|
||||
A server's name can be changed. Its address cannot: the pairing, the session, the certificate exception and the journal scope are all bound to the origin, so a different address is a different server, added with the wizard, and the old one is removed. No operation edits an address. What a name may be is under "Server names" in "Resolved after the retrospective reviews".
|
||||
|
||||
The limit is eight, and adding a ninth is refused with a message that says so. Eight is what one person testing against several environments needs, and it is a number at which the worst case (eight connections, each with its own claim lanes, sockets and staged documents, and a tree with eight nodes) is still tested as a whole. It is one constant; raising it is a later decision, not a redesign.
|
||||
|
||||
### What is per-server and what is per-computer
|
||||
|
||||
Per server: the pairing, the session, the certificate exception, which printers are shared, each shared printer's display name, description and location, the job journal, control state, realtime settings and the staging directory. ADR-0003 also lists media choices; nothing of that kind is stored today (the server picks media per job from what the printer advertises), so there is nothing to make per-server.
|
||||
|
||||
Per computer: the Windows printers that exist, the computer's own name, description and location, log settings, the Windows service, updates and appearance. The computer details describe this machine, so every server receives the same ones.
|
||||
|
||||
A printer may be shared with several servers.
|
||||
|
||||
### Stored state
|
||||
|
||||
Secrets move from the state directory's root to `servers/<serverId>/`: `connection.dpapi`, `qqq-session.dpapi`, the staged session of a pairing change in progress (`qqq-session.staged.dpapi`), pairings and sessions set aside as unreadable (`connection.recovery-*.dpapi`, `qqq-session.recovery-*.dpapi`) and `staging/`. All are still protected with DPAPI under the service identity. Staging a pairing and resolving a staged one work inside that folder exactly as they do in the root today, including the rule that the order of the stored and staged session is their last-write time.
|
||||
|
||||
The journal stays one SQLite database. One file gives one transaction across the registry and the per-server tables, one retention pass and one thing to back up, and the volume (eight writers, a few rows per job) is far inside what SQLite's write-ahead log handles. Isolation between servers is by key, and a connection's store is opened for one explicit server, so nothing a connection does can be written without one. Only the host works across servers: migration, the registry and retention.
|
||||
|
||||
The connection scope (a hash of origin and device) stays the key for events, jobs, control state and realtime settings, so a new pairing on the same server still starts a clean scope and nothing recorded under an old pairing can be sent with a new credential. What is new is the relation between a server and its scopes:
|
||||
|
||||
- A `server_scopes` table (`connection_id`, `server_id`, first used, last used, current flag, removed time) has one row for every scope in the journal. The row is written before the pairing file it belongs to, so a pairing on disk always has its row and no reader ever has to derive a scope from a pairing file that may be unreadable. The 1.x fallback that invents a new "unreadable" scope on every open is removed.
|
||||
- "Last used" is written by the connection that owns the scope, when it starts and when it stops. Removing a server keeps its rows and stamps them removed. The migration creates a row with no server for every scope it finds in the 1.x journal that is not the first server's; its last use is the newest time recorded in that scope, or the time of the migration when its rows carry no time, as the oldest 1.x rows do not.
|
||||
- A server's current scope is the live one: events are delivered from it, its unreviewed failures count toward the server's attention count, and its jobs can be acknowledged, retried and cancelled.
|
||||
- A server's earlier scopes are history and nothing else. The Jobs page and `jobs.query` show them, marked as from an earlier pairing, so history survives a new pairing; this is a change from 1.x, where a new pairing hides it. Their failures do not count toward attention, and acknowledge, retry and cancel are refused (`job_not_current`). Their undelivered events can never be delivered, because they belong to a credential that has been replaced.
|
||||
- A scope with no server, or with a removed one, is shown nowhere.
|
||||
|
||||
Retention is one pass run by the host, not by each store:
|
||||
|
||||
- Current scope, as today: details go after the retention period or beyond the detail limit, never while an event is undelivered; summaries stay.
|
||||
- Earlier scope of a server that still exists: the same, except that an undelivered event does not hold anything back.
|
||||
- Scope with no server or a removed one: everything (events, summaries, control state, realtime settings and the `server_scopes` row) is deleted once the retention period has passed since its last use.
|
||||
- Control-state cleanup and its limits become per server. Today a store deletes other scopes' control state after 30 days, refuses to save when 128 other scopes exist and refuses a command when 4,096 receipts exist in the whole table, so with several servers one could erase or block another's. In 2.0 a store touches only its own server's scopes, and both limits count that server's rows.
|
||||
|
||||
Printer sharing and printer profiles get new tables keyed by `server_id` (`server_printers`, `server_printer_profiles`). The old machine-wide tables are not reused under a new meaning: an older build that somehow ran against this directory would otherwise read every server's sharing as its own.
|
||||
|
||||
The schema carries a version (`PRAGMA user_version`) and is migrated once, when the service starts, in a transaction. Opening a store no longer creates or alters tables. A service that finds a version newer than it knows does not start its connections and does not touch the state. It keeps serving the pipe: the snapshot says that a newer build wrote this state and that the newer build must be reinstalled; the snapshot, the transport read (with an empty list), the log operations and the update operations work, so that it can be; and every other operation, `servers.add` included, is answered `state_newer`. It never migrates down. This matters between release candidates, because the installer allows any build with the same numeric version to replace another (`AllowSameVersionUpgrades`), so an older candidate can be installed over a newer one.
|
||||
|
||||
### Upgrade from 1.x
|
||||
|
||||
The first start of 2.0 turns the existing connection into the first server. In order:
|
||||
|
||||
1. Any staged pairing is resolved in place by the existing rules, so a pairing change that 1.x left half-finished is completed or discarded before anything moves.
|
||||
2. In one transaction: the schema is migrated; the `servers` row is created; the pairing's scope is recorded in `server_scopes`; sharing and printer profiles are copied to the server's tables; the certificate exception is copied if it is for the server's origin; and one row per file to move is written to a `pending_moves` table (source, destination).
|
||||
3. Each move is carried out and its row deleted. The protected files, a staged session that could not be resolved in step 1 and any set-aside recovery files are moved; a move within the state directory keeps the last-write time that the staged-session rules depend on. Temporary files (`*.tmp`) are deleted, because nothing ever reads one. The staging directory is not moved: its leftovers are deleted at every start anyway.
|
||||
4. No connection starts while a `pending_moves` row exists. An interrupted upgrade continues at step 3 on the next start.
|
||||
|
||||
Every move ends, so step 4 cannot hold the service forever:
|
||||
|
||||
| Source | Destination | What happens |
|
||||
| --- | --- | --- |
|
||||
| Present | Absent | Moved; row deleted. |
|
||||
| Absent | Present | Already done; row deleted. |
|
||||
| Present | Present | Something other than the upgrade wrote the destination. The destination is kept, the source is moved beside it under a recovery name, the row is deleted and the event is logged. |
|
||||
| Absent | Absent | The file is gone. The row is deleted and logged; the server then simply lacks that file, and shows as needing a sign-in or a new pairing. |
|
||||
| A move fails (in use, access denied) | | Tried three times in that start. Then the service starts without connections and the snapshot reports the upgrade as incomplete and names the file. As with a newer schema, the snapshot, the transport read (with an empty list), the log operations and the update operations work, and every other operation is answered `migration_incomplete`. The next start tries again. |
|
||||
|
||||
The service is first started by the installer before the installation commits. If the installer then rolls back, 1.x is left on state that 2.0 has already moved, and shows no pairing. This is accepted: nothing has been deleted, reinstalling 2.0 picks up where it stopped, and keeping a second copy of the secrets in the root to cover it would leave credentials behind that nothing maintains. It is read from the installer's authoring and is checked during upgrade qualification.
|
||||
|
||||
The cases:
|
||||
|
||||
| What 1.x left | Result |
|
||||
| --- | --- |
|
||||
| Pairing and session readable | One server, paired and signed in. |
|
||||
| Session only (signed in, never enrolled) | One server named by the session's origin, signed in, not paired. |
|
||||
| A pairing for one server and a session for another (1.x allows it: signed in to the second server, with printing blocked) | One server, from the pairing, needing a sign-in. The other server's session is moved into that server's folder under a recovery name and never used. |
|
||||
| Pairing unreadable, session readable | One server named by the session's origin; the unreadable pairing is moved with it and reported; the journal rows of the unknown scope belong to no server. |
|
||||
| Pairing readable, session missing or unreadable | One server, paired, needing a sign-in. |
|
||||
| Neither readable | No servers; the files stay where they are, untouched, and the computer says why. |
|
||||
| A certificate exception for the server's origin | Carried over for that origin; see "Certificate exception" below. |
|
||||
| A certificate exception for another origin than the server's | Not copied, and logged: it cannot apply to this server. |
|
||||
| Journal scopes from earlier pairings or other servers | Belong to no server; removed by retention. They were already invisible in 1.x. |
|
||||
|
||||
Nothing but temporary files is deleted by the upgrade. Going back to 1.x means uninstalling: the installer refuses a lower numeric version, and 1.x does not know the new layout.
|
||||
|
||||
### Runtime structure
|
||||
|
||||
- **Host.** Owns the pipe server, the server registry, the per-printer queues, retention and one connection per server. It supervises them: a connection that faults is restarted with backoff, and neither a faulting connection nor a faulting pipe client stops anything else.
|
||||
- **Server connection.** One per server: the session client, the realtime channel, authorization state, control state, settings and its claim pipeline. This is what the per-connection fields of `AgentRuntime` become.
|
||||
- **Printer queue.** One per Windows printer, shared by all servers. A job is identified by server and job together. The queue hands the device to one submission at a time. Within one server nothing changes: a job takes its place in that server's line for the printer when it is claimed, so a server's jobs for one printer are submitted in claim order. Across servers, the queue takes the job at the head of each server's line in turn, skipping a server whose head job is not yet staged, so one server's backlog cannot keep another's job waiting behind all of it. Waiting for the printer has no deadline of its own, as today: the limits that exist are the download timeout before the wait and the native submission timeout after SUBMITTING. Each server's own policy (concurrency, bandwidth) still applies inside its connection.
|
||||
|
||||
The spooler document name stays `QQQ <jobId>`. A server tag in it was considered and rejected: verified cancellation matches that name exactly, so changing it would make jobs spooled by 1.x impossible to cancel after the upgrade. The Jobs page already says which server a job came from.
|
||||
|
||||
`PrintWorker`'s reliability boundary does not change: SUBMITTING is recorded before the spooler call, and a submission interrupted after that is OUTCOME_UNKNOWN, never reprinted automatically.
|
||||
|
||||
### Management pipe, version 2
|
||||
|
||||
Requests and replies are JSON objects on the existing framing.
|
||||
|
||||
```json
|
||||
{ "v": 2, "op": "printers.share", "server": "<serverId>", "args": { "printerId": "...", "shared": true } }
|
||||
{ "v": 2, "ok": true, "result": { } }
|
||||
{ "v": 2, "ok": false, "error": { "code": "server_not_found", "message": "...", "details": { } } }
|
||||
```
|
||||
|
||||
`server` names the server an operation acts on, and is absent when the operation acts on the computer or on the list of servers. `message` is for people; no client decides anything by matching text. `details` is present only for the codes that say so below, and is typed per code. Clients switch on `code`, which is one of:
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `unsupported_version` | The service does not speak this `v`. |
|
||||
| `unknown_operation`, `invalid_request` | The operation does not exist, or its arguments are missing or malformed. A test print in a format the printer does not offer is `invalid_request`, and so are arguments given to an operation that takes none (an empty `args` object says the same as leaving it out and is accepted). |
|
||||
| `server_not_found`, `server_ambiguous` | No server has this identifier; or (command line only) a name matches more than one. |
|
||||
| `server_limit`, `server_exists` | Eight servers already exist; or this origin is already a server. |
|
||||
| `server_removing` | The operation names a server that is being removed. Reads still answer for it, and a repeated `servers.remove` succeeds with `removing` again, so a client that retried after a timeout is told what is true. Every other change to that server is refused with this code. Once removal has finished the server no longer exists, so a `servers.remove` repeated after that gets `server_not_found`, which a client that retried reads as removed. |
|
||||
| `busy` | Another operation that changes this server's session, pairing or trust is running. |
|
||||
| `not_signed_in`, `not_paired` | The operation needs a session, or a pairing, that this server does not have. |
|
||||
| `not_connected` | This server's connection is not ready for the action: its administrator disconnected this device, or the connection is still waiting for authorization or for the control state to be agreed. |
|
||||
| `server_incompatible`, `certificate_untrusted` | The compatibility check failed; `details` carries its stages and, for the second, a description of the certificate that was presented. |
|
||||
| `provider_changed` | `servers.add` and `trust.set` (when it switches the exception on): the sign-in provider the service reads from the server now is not the one `servers.check` reported, as the request states it. That is so when the service reads another provider, when it reads one where the check named none, and when it reads none where the check named one. Nothing was stored or changed. The client runs `servers.check` again and shows what it now reports; there are no details, because the check's result names the provider. |
|
||||
| `server_unreachable` | The QQQ server could not be reached or did not answer in time (`session.signIn`, `servers.add`, `pairing.enroll` before the code is sent, `printers.testPrint`, `jobs.retry`, `jobs.cancel`). |
|
||||
| `server_refused` | The QQQ server refused the action; `message` carries its reason. The wire enforces the form of that text, here and for every other code: before a message is sent, each control, format, line-separator, paragraph-separator, private-use and unassigned character in it becomes a space, and it is cut at 512 UTF-16 units, never inside a surrogate pair. Which characters those are is the one definition Core uses for text a server supplied. The cut adds no mark, so no message is longer than 512 units, and a failure with no message at all cannot be built ("Text on the wire" under "Resolved after the retrospective reviews"). |
|
||||
| `sign_in_refused`, `bind_refused`, `identity_changed` | The server refused the session; refused to bind the pairing; or bound it for a different account than the approved one, in which case `details` names the approved principal and tenant. The only way to build those details puts each value through the same definition, cut at 200; a value the pairing does not record is left out, never replaced by words. |
|
||||
| `enrollment_refused`, `enrollment_unconfirmed` | The server refused the code; or the outcome is not known and `details` says what was left behind. |
|
||||
| `printer_not_found`, `job_not_found`, `job_not_current` | No such printer or job for this server (a test print on a printer that is not shared with that server is `printer_not_found`); or the job belongs to an earlier pairing and is history. |
|
||||
| `duplicate_risk_unconfirmed` | A retry could print twice and the request did not confirm that. |
|
||||
| `cancel_refused` | Windows refused to cancel a document in the spooler; `details` carries the `reason`, `documentGone` or `ownershipMismatch`. Nothing in the Windows queue was changed. |
|
||||
| `state_unreadable` | Stored state for this server cannot be read. The message names the file; there are no details. |
|
||||
| `state_newer`, `migration_incomplete` | The whole service is not running connections: a newer build wrote the state, or the upgrade from 1.x could not finish. The snapshot says which and why. |
|
||||
| `update_in_progress` | The service is prepared for an update and is not accepting this. |
|
||||
| `internal_error` | Anything else; the detail is in the service log, never in the reply. |
|
||||
|
||||
Concurrency:
|
||||
|
||||
- The pipe serves up to sixteen clients at once, so a sign-in that takes two minutes does not make status unavailable. A seventeenth waits for a free instance.
|
||||
- A read never waits for an operation that changes something. One read takes time of its own: `printers.discover` runs a discovery, and a request that arrives while a discovery is running waits for that discovery and shares its result (single-flight; see "Resolved after the retrospective reviews"). Operations that change one server's session, pairing or trust (`session.signIn`, `session.signOut`, `pairing.enroll`, `trust.set`, `servers.remove`) run one at a time per server; a second one is answered `busy` at once. Operations on different servers do not wait for each other.
|
||||
- `servers.add` holds the registry for as long as it takes to validate and store, not for the sign-in, which has already happened in the companion.
|
||||
|
||||
Access, as the code has it:
|
||||
|
||||
- The service authenticates a client by the pipe's access list only: LocalSystem and Administrators. It checks nothing else about a client.
|
||||
- The client verifies, before it sends anything, that the process serving the pipe is the installed service.
|
||||
- Replies never carry a session credential, a device token or document content. One reply is an exception and is named here so it stays one: `session.signOut` returns the identity provider's sign-out request, which includes the provider's ID-token hint, for the companion to open in the browser. It is never logged. The same reply says whether the session was also ended on the server (`serverSessionEnded`): sign-out always finishes on this computer, and when the server could not be told, the operator is asked to revoke the session there.
|
||||
|
||||
The snapshot is one aggregated document, so the companion asks once for everything instead of once per server and per view:
|
||||
|
||||
- Once: the computer's printers and when they were discovered (`printersDiscoveredAt`, absent before the first discovery, so an empty list before then is told from a computer with no printers), service state, update state, log settings, and the state of the stored data (ready, upgrade incomplete with the file it stopped at, or written by a newer build).
|
||||
- Per server: identifier, name, address, position; health and its status text; the attention count that drives health; authentication (signed in, as whom, authorization blocked and why); paired flag and device identifier; transport state and the realtime meter; when the server last answered this computer (`lastContact`; the control state has its own, which is the control channel's alone); control state (desired state, last command); shared printers with each one's server-side approval; whether the certificate exception is on and the two origins it covers; and whether stored state is unreadable.
|
||||
- Jobs stay paged and are queried per server.
|
||||
- The snapshot never starts printer discovery, and never contacts a server. It reports the printers the host last discovered. The host refreshes that list at least every 60 seconds and whenever a server connection synchronizes its printers, so a snapshot's list is at most that old. A snapshot is therefore always cheap, whatever a printer driver is doing. A client that needs a fresh list asks `printers.discover`, which runs a discovery now and returns the computer's printers with the time; the wizard's printer step and the command line's `printers` use it.
|
||||
- While the stored data is not ready, the list of servers is empty and the computer's part says why. `transport.get` still answers in that state, with an empty list: the companion repeats it every second, and an error every second would be noise beside the snapshot that already says why.
|
||||
|
||||
The companion keeps polling the snapshot, every five seconds as today. Between snapshots it draws the traffic meter from `transport.get`, a light read it repeats every second as it does in 1.x: for each server its identifier, its transport state with the meter, and `lastContact`, and nothing else. A change notification pushed by the service is not part of 2.0: one aggregated request already removes the cost that made polling a problem, and a push channel is a second protocol to secure and test.
|
||||
|
||||
Clients older than the service:
|
||||
|
||||
- A request without `v` gets the one legacy-format reply that 1.x clients already display as an error, telling the operator to restart the companion.
|
||||
- The one 1.x client that is certain to reach a 2.0 service is the update helper, which sends `resume-after-update` after the installer finishes. It is answered with that same error, and that is harmless: the update pause lives in memory, and the service the installer just started is not paused.
|
||||
- From 2.0 on, the helper is always one version behind the service it resumes. `update.prepare` and `update.resume` therefore keep working in every pipe version a later service accepts; a later version that changes them keeps accepting the old form.
|
||||
|
||||
Operations:
|
||||
|
||||
- On the computer or the list of servers (no `server`): `snapshot`, `transport.get`, `servers.list`, `servers.check`, `servers.add`, `servers.reorder` (the identifiers in their new order, in `args`), `computer.getProfile`, `computer.setProfile`, `printers.discover`, `logs.getSettings`, `logs.setSettings`, `logs.clear`, `update.prepare`, `update.resume`. `printers.discover` is a read with the ordinary 30-second limit; it does not answer while the stored data is not ready. It is single-flight: the computer runs one discovery at a time, and a request that arrives while one is running joins it and gets its result.
|
||||
- On one server (`server` required): `servers.rename`, `servers.remove`, `session.signIn`, `session.signOut`, `pairing.enroll`, `connection.reconnect`, `trust.get`, `trust.set`, `printers.share`, `printers.setProfile`, `printers.testPrint`, `jobs.query`, `jobs.details`, `jobs.retry`, `jobs.cancel`, `jobs.acknowledge`.
|
||||
|
||||
When a server comes to exist:
|
||||
|
||||
- `servers.check` takes an address and, optionally, the operator's acceptance of the certificate exception for it. It also works for an address that is already a server, using that server's stored exception; that is how a repair re-checks. The service runs the compatibility check itself, under its own identity and certificate store, and returns the typed stages. This is the check that matters, because the service is what connects; in 1.x the companion checked under the user's certificate store while the service validated as LocalSystem, so the two could disagree.
|
||||
- `servers.add` takes the address, the name, the certificate exception if the operator accepted one, the sign-in provider's origin that `servers.check` reported (for comparison only; see "Who names the sign-in provider" under "Resolved after the retrospective reviews"), and the session credential from a completed sign-in. It is atomic: the server, its certificate exception and its session are stored together, or nothing is. `servers.add` never creates a server without a session, so there is no provisional server to clean up. A server can lose its session later (sign-out, expiry, an upgrade that found only a pairing); that is what `not_signed_in` is for.
|
||||
- `trust.set` and `session.signIn` are for servers that already exist.
|
||||
|
||||
Certificate exception:
|
||||
|
||||
- It stays what it is in 1.x, a development-only switch that accepts a certificate Windows does not trust, and it gets a boundary it lacks today. In 1.x, once switched on, it applies to every host the client contacts. In 2.0 it applies to two origins and no others: the server's own, and the sign-in provider that server names. Both are recorded with the server and shown to the operator in the warning, in two stages when the certificate Windows refuses is the server's own ("Who names the sign-in provider" under "Resolved after the retrospective reviews").
|
||||
- Pinning the accepted certificate's fingerprint was considered and rejected. The exception exists for self-signed certificates and private authorities such as a local Caddy root, and a private authority reissues its certificates on a short cycle; a pin would stop matching at each renewal and unattended printing would stop with it. The durable way to trust a private authority is to install its root in the Local Machine trusted root store, which needs no exception at all. The wizard and the Connection page say so wherever the exception is offered.
|
||||
- When the trust stage fails, `servers.check` returns a description of the presented certificate (subject, issuer, expiry, fingerprint, and why Windows refused it), so the operator sees what the exception would accept. `trust.get` returns, for an existing server, the two origins the exception covers and the certificate from the last refusal the service remembers, with the address that presented it and when. It never probes: it contacts no server, and returns no certificate when the service has seen no refusal since it started.
|
||||
- A server with the exception switched on says so in the snapshot, and the companion shows it on the server's node and its Connection page for as long as it is on, so a development setting is not forgotten.
|
||||
- If a server later names a different sign-in provider, the exception does not follow it: sign-in fails as untrusted and the operator accepts the new origin or does not. How the operator accepts it is under "Who names the sign-in provider" in "Resolved after the retrospective reviews": the Connection page checks the server, shows both origins, and sends `trust.set` with the provider's origin it showed.
|
||||
- An exception carried over from 1.x covers the server's origin. The provider's origin is recorded from the first sign-in description the service reads after the upgrade, and logged. The companion's sign-in uses the same exception for the same two origins, from the snapshot, so the two cannot disagree about what is accepted.
|
||||
- Core needs the per-origin check in place of the one switch. That closes the first half of audit finding C9; its second half, binding the discovery address to the issuer's origin, is separate Core work.
|
||||
|
||||
Removing a server:
|
||||
|
||||
1. Its connection stops claiming. A submission already handed to Windows finishes and is recorded, bounded by the print deadline; jobs claimed but not yet submitted are reported back as not printed.
|
||||
2. The service ends the session with the server, as sign-out does, without waiting on a server that does not answer.
|
||||
3. Its protected files and staged documents are deleted, and its rows in the registry, sharing and profile tables. Its scopes stay in the journal, stamped removed, until retention deletes them. Events that were not delivered stay undelivered.
|
||||
4. Its device record on the server is not touched.
|
||||
|
||||
### Command line
|
||||
|
||||
`servers` lists them. `servers rename` and `servers remove` change them. The command line does not add a server: adding needs an interactive sign-in, which only the companion can do, so `servers add` does not exist and `enroll --server URL` from 1.x is gone. `enroll` pairs a server that the companion has already added and signed in.
|
||||
|
||||
Every per-server command takes `--server`, which always selects and never names an address. It accepts a server's identifier, the first eight or more characters of one, or its name. A name that matches more than one server is refused with their identifiers. It may be omitted while exactly one server exists.
|
||||
|
||||
### Companion
|
||||
|
||||
The left pane is a tree: one node per server showing its name and health, with Printers, Jobs and Connection beneath it; then Add server, which opens the wizard; then the per-computer items (Computer, Logs, Settings). The footer and the tray icon show the worst health across servers, and the tooltip names each server. *Made exact on 2026-11-11: the tooltip names as many servers as fit in what Windows shows of it and then says how many are left out; the tray menu lists every server ([ADR-0005](0005-connection-wizard-and-server-tree.md), G15).* Busy state is per server, so a sign-in on one does not freeze the others. [ADR-0005](0005-connection-wizard-and-server-tree.md) has the detail.
|
||||
|
||||
Browser sign-in has one callback inbox and one redirect address per Windows user, so it cannot be per server. One dispatcher in the companion owns the inbox and hands each callback to the sign-in whose OAuth state it carries; a callback for a sign-in that has ended is dropped. Sign-ins to different servers can then run at once.
|
||||
|
||||
## Security consequences
|
||||
|
||||
One LocalSystem service now holds credentials for several servers, so the boundary between connections is a security boundary:
|
||||
|
||||
- A server can address only the printers shared with it, and sees only its own jobs.
|
||||
- Each connection has its own protected files, staging directory, journal scopes and certificate exception; none is derived from another's, and cleanup in one never deletes or counts another's rows.
|
||||
- A protocol violation, an authorization loss or a hostile payload ends or blocks that one connection.
|
||||
- What is shared is bounded so that one server cannot use it up. A printer takes waiting servers in turn. Staged bytes have a machine-wide ceiling of 16 GiB, of which one eighth is reserved for each server that exists; only what is not reserved goes to whoever asks first, and no document an accepted policy allows is larger than a server's eighth (the arithmetic is under "The staging ceiling" in "Resolved after the retrospective reviews", which replaced an earlier wording of this sentence on 2026-11-10). The pipe has sixteen instances.
|
||||
- Text a server supplies (titles, names, messages) stays bounded and is never used to build a path, a key or a command.
|
||||
|
||||
`docs/security/threat-model.md` gets these as boundary rows before the multi-server service merges.
|
||||
|
||||
## Resolved while planning the build
|
||||
|
||||
A planning pass over the code on 2026-11-10 found places where this record was silent or disagreed with the code. Each was settled as follows, under the owner's direction to take the recommended option and record it.
|
||||
|
||||
- **Where the pipe contract lives.** The envelope, error codes, operation names, arguments and results are one set of types in a `Management` folder of the Windows assembly, which the service, the command line and the presentation assembly already reference. A separate assembly would add a project and installer payload for no new boundary.
|
||||
- **The companion between now and the tree.** The wizard and tree are a later stage, and until then the companion in this branch speaks version 1 of the pipe and knows one server. While the service can hold only one server, that server follows the stored files after each version 1 sign-in, enrollment and sign-out. Once it can hold several, an interim adapter serves version 1 requests against the first server in the list: a version 1 sign-in to a new address adds that server and moves it first, and signs the others out, because the old window cannot show a second live connection. The adapter is deleted when the companion speaks version 2. Only then does a request without `v` get the legacy error described above. *Changed on 2026-11-10: see "The interim companion" under "Resolved after the retrospective reviews".*
|
||||
- **An unpaired server has no scope.** In 1.x an unpaired store uses one fixed scope name, which two signed-in, unpaired servers would share. An unpaired server reads defaults and cannot write to the journal. Rows the migration finds under that name, under an "unreadable" name or under no scope belong to no server. *Changed on 2026-11-10 for rows under no scope: see "Rows found under no scope" under "Resolved while building the journal's schema version".*
|
||||
- **When a scope becomes current.** The scope's row is inserted, not current, before any file is written. It becomes current in one transaction after the pairing file is in place. On open, a readable pairing whose row is not current is made current, which covers a stop between the two; an unreadable pairing never changes it. *Changed on 2026-11-10: see "When a scope becomes current" under "Resolved after the retrospective reviews".*
|
||||
- **A server's own print limit and the shared printer queue.** The queue skips a server whose head job is not yet staged, and also one that cannot take another print at that moment under its own policy. Both are decided together under the queue's lock, so a server busy on one printer neither holds another printer against everyone nor has its job for an idle printer held by someone else's long print. *Changed on 2026-11-10: see "Print capacity and the shared queue" under "Resolved after the retrospective reviews".*
|
||||
- **The staging ceiling.** 8 GiB for the computer, so each server's guaranteed eighth is 1 GiB, above the default policy of 512 MiB. A server's own limit still applies to its own total. A document larger than a server could ever be given is refused before download and recorded FAILED, instead of waiting forever. *Changed on 2026-11-10: see "The staging ceiling" under "Resolved after the retrospective reviews".*
|
||||
- **Removal is restartable.** The print deadline can be far longer than a pipe request may take, and removal spans files and rows with no transaction. `servers.remove` marks the server as being removed, stops its claims and answers that removal has started or has finished. The host finishes when a submission in progress ends, and at the next start if it was stopped first. The snapshot shows a server that is being removed.
|
||||
- **Jobs waiting behind another printer's job** (audit finding W23) are fixed in the claim path, not by the printer queues: lanes claim while there is demand and a free slot, instead of in waves that must all finish.
|
||||
- **Who names the sign-in provider.** `servers.check` returns the provider's origin whenever it could read the sign-in description, so the re-check after the operator accepts the exception can name both addresses. `servers.add` carries only that the operator accepted. The service derives and stores the provider's origin from its own read and never takes it from a client. *Changed on 2026-11-10: see "Who names the sign-in provider" under "Resolved after the retrospective reviews".*
|
||||
- **The snapshot carries no recent jobs and no log lines.** Jobs come from `jobs.query`; the service's in-memory log ring is removed. The command line's `snapshot` prints the aggregated document.
|
||||
- **Ambiguous names are the command line's business.** The service takes a full server identifier only. The command line resolves a prefix or a name after listing the servers, and reports an ambiguous one itself.
|
||||
|
||||
## Resolved while reviewing the pipe contract
|
||||
|
||||
The contract was built as types and reviewed on 2026-11-10 before anything served it. The review found outcomes and readings that version 1 has and this record had no place for. They were settled as follows and are written into the sections above.
|
||||
|
||||
- **Outcomes version 1 explains in text.** Five codes were added so that none of them becomes `internal_error`: `not_connected`, `server_unreachable`, `server_refused`, `cancel_refused` (with the reason in `details`) and `server_removing`. `identity_changed` carries the approved account in `details`. A sign-out reply says whether the server session was ended.
|
||||
- **Test prints.** A printer that is not shared with the server is `printer_not_found`; a format the printer does not offer is `invalid_request`.
|
||||
- **Unreadable state.** `state_unreadable` names the file in its message and has no details.
|
||||
- **The remembered certificate.** `trust.get` returns the certificate from the last refusal the service remembers, with the address that presented it and when, and never probes.
|
||||
- **Last contact and the traffic meter.** Each server's snapshot has a `lastContact`, and `transport.get` is the light read for the meter, so the companion keeps its one-second meter without asking for the whole snapshot each second.
|
||||
- **A snapshot is always cheap.** It reports the printers the host last discovered, with the time, and never starts a discovery. The host keeps that list at most 60 seconds old, and `printers.discover` is how a client asks for a fresh one.
|
||||
- **Repeated members.** A request or a reply in which any object has the same member twice is refused whole, in the envelope and at any depth beneath it, because two readers can disagree about which value counts.
|
||||
- **Arguments that are left out.** A request without `args` is read as one with empty arguments, so it is valid exactly when every argument of the operation is optional. `update.prepare` and `update.resume` take none today; if either ever gains arguments, a request without them stays accepted, because the update helper is always one version behind. Arguments given to an operation that takes none are refused; an empty object is accepted.
|
||||
- **What a reply must contain.** A reply that lacks a member its type requires, or has a null inside a list its type knows, is an unreadable reply and not a record with a hole in it; a member the reader does not know is still skipped, whatever it holds. A vocabulary value the reader does not know (a health, a stage, an outcome) is treated as unknown, never as a failure to read.
|
||||
- **Dates.** A date on the wire is ISO 8601 with a trailing `Z` or an offset, and nothing else is read. The times inside the reused job, printer-catalog and control records are carried as the text the service stored; they are not converted.
|
||||
- **The version of a reply.** A reply carries the version of the request it answers. The one exception is `unsupported_version`, which is answered in version 2 whatever version was asked for.
|
||||
- **Text a server supplied.** An error's message and the approved account in the details of `identity_changed` can repeat what a server said. Both are made printable and cut by one definition, which Core owns and the contract calls, when the reply is built: 512 UTF-16 units for any message, 200 for each identity value. It is enforced where the reply is made, not asked of whoever writes a handler. *Made exact on 2026-11-10: see "Text on the wire" under "Resolved after the retrospective reviews". Core owns which characters are shown; the cut, and what stands for a missing value, are the contract's own rule; and "where the reply is made" is the error record and the two records that name an account, not the reply factories.*
|
||||
- **A server that is being removed.** Reads answer; a repeated `servers.remove` succeeds with `removing` again; every other change is refused with `server_removing`.
|
||||
- **The traffic meter while the stored data is not ready.** `transport.get` answers with an empty list instead of an error.
|
||||
- **One way to read an envelope.** A request or a reply is read only by the contract's two parsing functions, which make the envelope checks. The envelope types refuse to be read by the serializer directly, so those checks cannot be walked around. *Since 2026-11-10 the refusal is declared on the types, so it holds for the default options and for any options object that does not bring a converter of its own for an envelope type (the serializer asks the options before the type, so a converter written on purpose for the type would be used; nothing in the product has one), and it covers writing too: an envelope is written only by the contract's own function, as one line.*
|
||||
|
||||
## Resolved after the retrospective reviews, 2026-11-10
|
||||
|
||||
Two retrospective reviews were run on 2026-11-10 over work that had merged without its reviewer seeing the final state ("About the process" below says how that happened). The first covered pull requests 37, 38, 31 and 30; the second covered the last fix rounds of pull requests 29, 35 and 28. Neither found a blocker. The first found that five decisions under "Resolved while planning the build" needed settling before the steps that build on them, and that this record lacked two rules.
|
||||
|
||||
They are settled here. Each settlement was taken on 2026-11-10 under the owner's standing instruction for this work: when a decision comes up, take the recommended option, record it and keep going. They follow the reviewer's recommended resolutions, made concrete where the build needs a number or a rule, except the staging ceiling, where the reviewer's recommendation was not taken, for the reason given there. They are not the owner's own words, and the owner can overrule any of them. None of it is built yet. The earlier wording of the five planning decisions stays where it was written, marked as changed; the sentences in the sections above that restate them were edited in place and are listed under "Smaller corrections made on 2026-11-10". The reviews' findings about code are open findings in [the audit](../AUDIT-2026-11-10.md).
|
||||
|
||||
### Decisions that changed
|
||||
|
||||
**The staging ceiling.** This replaces "The staging ceiling" under "Resolved while planning the build" and the sentence about staged bytes under "Security consequences". The machine ceiling described here is a design decision. It is not in the code yet.
|
||||
|
||||
- What the code does, read at `077148f`: the client advertises `maxStagedBytes` 2^53 − 1 and `maxPdfBytes` and `maxZplBytes` 2,147,483,647, accepts a policy up to those, requires the staged limit to be at least the larger document limit, and refuses any other policy as `CONFIGURATION_REQUIRED` (`src/Qqq.PrintAgent.Core/Printing/RealtimeSettings.cs:52-63` and `:82-100`; the file is unchanged since `v1.2.1`). It never lowers a policy it accepted. That is the rule in [REALTIME.md](../REALTIME.md) ("advertises its actual ceilings ... refuses incompatible policy rather than silently reducing it"), so REALTIME.md is right and this record was wrong. The code has no machine-wide ceiling: staging is bounded only by the one server's own limit.
|
||||
- What was wrong: the earlier text added a ceiling of 8 GiB with a guaranteed eighth of 1 GiB and left the advertised limits as they were. With eight servers no server can then hold more than 1 GiB, so a policy the client had accepted (documents of up to 2,147,483,647 bytes) could no longer be met, and a job inside that policy would be recorded FAILED once an eighth server was added. That is a silent reduction.
|
||||
- The decision, taken under the standing instruction on 2026-11-10: what 1.2.x accepts stays accepted. The per-document ceiling stays 2,147,483,647 bytes, as the code advertises today. The machine ceiling for staged documents is 16 GiB (17,179,869,184 bytes), so that each server's eighth, 2 GiB (2,147,483,648 bytes), holds the largest document the client accepts. The reason: an installation whose server policy is above 1 GiB works on 1.2.x, and the upgrade to 2.0 must not start refusing it as `CONFIGURATION_REQUIRED`. That refusal is raised in one place (`RealtimeSettings.cs:99`), which is reached when a policy arrives (`AgentRuntime.Realtime.cs:283-284`) and also when the stored settings are loaded (`AgentStore.Realtime.cs:25`). Narrowing the bounds would therefore fail a stored 1.2.x policy at the first start after the upgrade, before the server had sent anything.
|
||||
- The arithmetic. One eighth of the ceiling, 2 GiB, is reserved for each server that exists. It is a reservation, not a minimum when asked: what a server leaves unused of its eighth is never lent to another. As an admission rule: a transfer is admitted only when, counting it, the sum over the servers of the larger of what each holds and 2 GiB does not exceed 16 GiB. It is a rule for what is admitted, not a state that always holds: adding a server adds its 2 GiB to the sum at once, and the sum can stand above 16 GiB until transfers admitted earlier finish. What a server holds above its eighth comes from what is not reserved (16 GiB less 2 GiB for each server that exists), first come. With eight servers nothing is unreserved. The ceiling is a bound, not an allocation: nothing is set aside on disk.
|
||||
- "Could ever be given" means the eighth: 2 GiB, the same for every server, whatever the number of servers. Every document an accepted policy allows fits it, so adding a server never makes an accepted policy unattainable. The earlier sentence about refusing "a document larger than a server could ever be given" is withdrawn, because no accepted policy can produce one.
|
||||
- What the client advertises does not change. `maxPdfBytes` and `maxZplBytes` stay 2,147,483,647, and the eighth is what makes that true for every server, whatever the number of servers. `maxStagedBytes` stays as it is: a server's staged limit is a cap on that server's own total, the client honors a cap by staying under it, and a cap above what the computer has room for is never reached. The reviewer's recommendation was to advertise the real ceiling so that a policy above it is refused instead of silently unattainable. With the eighth at least as large as the largest document, the advertised document ceilings are real; advertising 16 GiB as the staged ceiling would refuse a policy 1.2.x accepts, and no job fails for want of it.
|
||||
- What that leaves untrue, stated plainly. For this one field the advertised value is not a ceiling the machine holds, and in 1.2.x it was not one either. The effective total is the smaller of the server's staged limit and the room the computer has for that server. A policy that allows several large transfers at once is, with eight servers, served within the 2 GiB eighth, which for documents near the document ceiling means one at a time. The staged limit is only ever used as an upper bound (`AgentRuntime.Pipeline.cs:274`, `:278`; `PrintApiClient.Download.cs:28`), so less room means waiting, never a failed job. The wire contract is stricter than this repository's REALTIME.md: the protocol repository's `docs/REALTIME.md` says the limits in HELLO are the client's "actual supported maxima" and that a client must "never silently clamp or use a lower intersection". Both REALTIME texts get a sentence saying this in the change that builds the ceiling. Neither is edited here.
|
||||
- A server's own staged limit still applies to its own total. Above its eighth a server gets what is unreserved and free, and a transfer waits for room as transfers wait under that limit today (`src/Qqq.PrintAgent.Windows/AgentRuntime.Pipeline.cs:278`). No job is failed for it.
|
||||
- A server added while others hold more than their eighths gets its eighth as the transfers already admitted finish. Nothing admitted is taken back.
|
||||
- Consequence for upgrades: no policy 1.2.x accepts is refused by 2.0 on this account, and the numbers the client sends do not change. REALTIME.md gets the machine ceiling in the change that builds it, because it describes the code.
|
||||
- Considered and not taken: keeping 8 GiB and lowering the advertised document ceiling to its eighth, 1 GiB. It would make 2.0 refuse a policy that 1.2.x accepts.
|
||||
|
||||
**The interim companion.** This corrects "The companion between now and the tree".
|
||||
|
||||
- While the service can hold only one server, "follows the stored files" means the server is derived again from the stored files, by the function the upgrade uses to derive the first server. When the origin is unchanged the server keeps its identifier. When the stored files name another origin, that is a different server: it gets a new identifier, and the old server's registry row goes and its scopes are stamped removed, as they are when a server is removed. The certificate exception is carried only when it is for the new server's origin, as in the upgrade; otherwise it is not carried, and that is logged. Nothing keyed by an identifier therefore moves to another origin, and "No operation edits an address" still holds. The earlier wording did not say whether the identifier was kept; had it been, an exception accepted for one server would have covered another, and the first server's scopes would have shown as the second's earlier pairings.
|
||||
- In that phase the printers shared and their profiles are carried to the server derived again, as the upgrade copies them: 1.x holds them for the computer, and the version 1 window has no step that shares them again.
|
||||
- Once the service can hold several, a version 1 sign-in to a new address adds a server with no shared printers, as any new server has none. The operator shares them again on the Printers page. This differs from 1.x and from the single-server phase, and is recorded as interim behavior.
|
||||
- Signing the others out stops unattended printing on every other server at each version 1 sign-in, until someone signs in to that server again.
|
||||
- A version 1 sign-in to a ninth address is refused (`server_limit`, shown as the adapter's error text). The version 1 window cannot remove a server, and the command line's `servers remove` arrives later in the build, so until it does an interim build that holds eight servers cannot take a ninth. That is accepted for builds from this stage only: the adapter is deleted when the companion speaks version 2, which is before 2.0 is released.
|
||||
|
||||
**Who names the sign-in provider.** This adds to the decision of that name and changes step 1 of the wizard in [ADR-0005](0005-connection-wizard-and-server-tree.md), which is updated with it.
|
||||
|
||||
- When the certificate Windows refuses is the server's own, the provider is not known until the exception is accepted, because the sign-in description cannot be read before then. So the first warning names the server's address and says that the sign-in provider's address will be shown after the check. The re-check that follows returns the provider's origin, and the step then shows both addresses the exception covers, before the operator goes on.
|
||||
- The service reads the sign-in description once in `servers.check` and again in `servers.add`, and a server whose certificate is unverified could name a different provider the second time. `servers.add` therefore carries the provider's origin that `servers.check` reported, for comparison only. The service still derives the origin from its own read and stores only that. When the origin it derives differs from the one the request carries, `servers.add` is refused and nothing is stored, so a stored exception never covers an origin the operator did not see. Comparing is not taking the value from the client.
|
||||
- That refusal carries a code of its own, `provider_changed`, with no details. It was settled on 2026-11-10, under the standing instruction, when the plan for the wizard stage was written and the argument was added to the contract. The alternatives were weighed and not taken: `certificate_untrusted` would say that Windows refused a certificate when none was refused, and `invalid_request` could not be told from a bad name. (`server_incompatible` says a stage of the check failed, and here every stage passed; `server_refused` says the QQQ server refused, and here the service did.) The wizard needs no details: it returns to its first step, and the check it runs again shows the provider the server names now.
|
||||
- The argument is `ServersAddArgs.ProviderOrigin`, and it states what the operator was shown by the check. An absent origin means "the check named no provider", and it is compared like any other value: if the service then reads a provider, the request is refused with `provider_changed`. (Decided on 2026-11-10 under the standing instruction, in place of an earlier wording of the same day under which an origin was compared only when it was given.) The reason: the comparison exists so that what the operator accepted is what the service uses, and a server that names no provider at the check and one at the add is exactly the case the operator has not seen. A caller that has not run a check cannot add a server that uses a provider; it must check first, which is what the wizard and the command line do. Nothing uses the contract yet, so no caller breaks. The comparison is one rule, which both operations call (`SignInProviderOrigin.Agrees`): none shown and none read agree; none shown and one read, one shown and none read, and one shown and another read are refused; the same one agrees. Two origins are compared in the one form an origin is kept in (scheme, the host's ASCII name and port), so one place written two ways is one place. That form has no place for the zone of an IPv6 literal (`[fe80::1%25eth0]`), so two zones of one address, or a zone and none, would compare as one place: an IPv6 literal with a zone therefore names no provider for this comparison and agrees with nothing, and the address rule refuses one as a server's address (`ServerAddress.TryRead`, as a host that cannot be named). On the wire, "the check named no provider" is the member left out or given as null; both are read as that statement. Text that names no origin (empty text, a word such as "none", a host with no scheme, a host that cannot be named) states nothing the check could have shown and could never agree, so it is not read and then answered `provider_changed` for ever: it is refused as `invalid_request` when the arguments are read, as a name that breaks the name rule is, for `servers.add` and for `trust.set` whichever way it switches.
|
||||
- `trust.set` has the same race, and until now nothing said how an operator accepts a provider that a server names later ("Certificate exception" says only that the operator "accepts the new origin or does not"). Switching the exception on records the server's origin and the provider the server names, from the service's own read, which the operator has not seen. So the Connection page runs `servers.check` for the server first and shows both origins, and `trust.set` carries the provider's origin the operator was shown: `TrustSetArgs.ProviderOrigin`, compared by the same rule, an absent origin included, and refused with the same code, `provider_changed`, with nothing changed. A request that switches the exception off accepts nothing, so nothing is compared for it.
|
||||
|
||||
**When a scope becomes current.** In the decision of that name, "On open" becomes "when the server's connection starts", once, by the host. Finding a readable pairing whose row is not current means decrypting the pairing file and deriving its scope. Done at every open of a store, that is the derivation the design removed ("no reader ever has to derive a scope from a pairing file"), and it makes opening a store a writer again; stores are opened for every pass of a lane and for every command. A stop between the file write and the flag can only be seen at a start, so that is where it is repaired. The rest of the decision stands: an unreadable pairing never changes the current row.
|
||||
|
||||
**Print capacity and the shared queue.** This adds to "A server's own print limit and the shared printer queue". Deciding both under the queue's lock is not enough, because whether a server can take another print changes outside that lock: a print slot is freed on another printer, traffic is allowed again, a transition ends, a setting changes. A queue that skipped that server's head job must be woken by that change, or the job for an idle printer stays skipped until something unrelated wakes the queue. So: every change to a server's print capacity raises the printer queues' signal, and a waiter takes the signal's task before it looks at the state, which is the order pull request 38 gave the existing waiters in the claim pipeline.
|
||||
|
||||
### Decisions that were missing
|
||||
|
||||
**Server names.** Neither record said what a server's name may be, and the name was to go in front of every log line of that server.
|
||||
|
||||
- The rule, enforced where a name enters, at `servers.add` and `servers.rename`, and refused there as `invalid_request`: 1 to 64 UTF-16 units after trimming; every code point one that Core's definition of printable text shows (the definition the pipe contract uses for text a server supplied); no square brackets, which delimit the prefixes of a log line (the ASCII pair, and since the fix round of 2026-11-10 the fullwidth pair U+FF3B and U+FF3D, which a person reads as the same brackets); at least one code point that draws something, a letter, a digit, a punctuation mark or a symbol other than the few that draw nothing (added in the fix round of 2026-11-10: Core's definition goes by category and so shows the Hangul fillers, the blank Braille pattern and a combining mark on its own, and a name made only of those and of spaces would be a server the operator cannot see; the definition itself is unchanged, audit C27); and not shaped like a server identifier or like the first eight or more characters of one, which is what `--server` takes as an identifier or a prefix.
|
||||
- Log lines carry the server's short identifier (the first eight characters of its identifier, which `--server` accepts), not its name. The reasoning is in finding 10 of the first retrospective review: the activity log file redacts each joined line a second time, so a name containing a credential keyword (`Cookie:`, `token:`, `password=`) hides that server's lines; square brackets in a name forge structure inside a line; and the service log's own check of a name is a second, weaker copy of Core's rule. A short identifier has none of these and survives a rename.
|
||||
- The rule must be in force before any per-server log prefix is used. Nothing calls the service log's per-server view today (read at `077148f`).
|
||||
- The upgrade and the wizard propose the host as the name. A host can break the rule (the brackets of an IPv6 literal, a host of eight or more hexadecimal digits). The code that proposes a name must produce one that passes. How was settled on 2026-11-10, with the plan for the wizard stage, and is in the contract beside the rule (`ServerName.Propose`): the proposal is the host by its ASCII name, without the brackets of an IPv6 literal and cut at 64 units; a host shaped like an identifier or its beginning gets the word "Server" in front; and an address with no host to name is proposed as "Server".
|
||||
- The rule is one function in the contract, which the service and the companion both call (`ServerName.Check`), and it says which part of the rule a name breaks, so the companion can tell the operator before anything is sent. The service still refuses with `invalid_request` and no more.
|
||||
- The address an operator types has one rule as well, beside the name's (`ServerAddress.TryRead`): HTTPS, or plain HTTP for this computer itself; no user information, query, fragment or path; and a host with an ASCII name, which is the form an origin is stored, compared and shown in. It is the rule the session client applies to the origin it is built for, plus the last condition. The session client's own check and the three copies in the sign-in flow of the version 1 companion are not removed by the change that added the rule; they go when their callers move to it.
|
||||
|
||||
**`printers.discover` is single-flight.** `printers.discover` is a read, and it runs a driver discovery, which starts a helper process. Without a rule, a hung driver and a client that retries could hold several of the sixteen pipe instances for 30 seconds each, with a helper process for each request, beside the host's own refresh. So the computer runs one discovery at a time: a `printers.discover` that arrives while a discovery is running, whoever started it, joins that discovery and gets its result. Each request keeps its own 30-second limit. (Finding 6 of the first retrospective review.)
|
||||
|
||||
### Consequences of removal and of scopes, recorded
|
||||
|
||||
These follow from "Removing a server", "Removal is restartable" and the scope decisions, and were not written down.
|
||||
|
||||
- After `removing` is answered, the server's credentials and its live session stay on this computer until the submission in progress ends, which is bounded only by the print deadline (24 hours at most), or until the next start.
|
||||
- `servers.add` for an address that is being removed is answered `server_exists`, because the origin is unique until removal has finished. `server_removing` is for an operation that names a server, and `servers.add` names none.
|
||||
- A server that is being removed counts toward the limit of eight until its removal has finished. (Taken under the standing instruction: its row, its files and its connection still exist.)
|
||||
- If the service stops during a removal while a submission is in progress, the host finishes the removal at the next start, before connections start. The interrupted job is recorded OUTCOME_UNKNOWN in a scope that is then shown nowhere, so that outcome is never reported to the server or shown to the operator. The job is not printed again. This is recorded as a consequence; nobody has decided that it is acceptable.
|
||||
- Not settled: what "unpaired" means for a server whose pairing file is missing while one of its scope rows is still current. The record says that an unreadable pairing never changes the current row and says nothing about a missing one. It is to be settled in the step that builds scopes.
|
||||
|
||||
### Smaller corrections made on 2026-11-10
|
||||
|
||||
- The `busy` row said "session or pairing"; the concurrency rules and the code include trust. The row now says so.
|
||||
- "A reply carries the version of the request it answers" cannot hold for `unsupported_version`, which is answered in version 2. The exception is now named.
|
||||
- A `servers.remove` repeated after removal has finished gets `server_not_found`. The `server_removing` row now says so.
|
||||
|
||||
Sentences in the sections above that were edited in place on 2026-11-10 to agree with this section, with no marker beside them:
|
||||
|
||||
- Under "Servers have a local identity": a pointer to the name rule was added.
|
||||
- Under Concurrency: "Reads never wait." became the two sentences about reads and `printers.discover`.
|
||||
- Under Operations: the sentence that `printers.discover` is single-flight was added.
|
||||
- Under "When a server comes to exist": the provider's origin was added to what `servers.add` takes.
|
||||
- Under "Certificate exception": the pointer to the two-stage warning was added.
|
||||
- Under "Security consequences": "Staged bytes have a machine-wide ceiling, of which each server is guaranteed one eighth; the rest goes to whoever asks first." was replaced by the sentence that is there now.
|
||||
- In the code table (later on 2026-11-10, with "Text on the wire" and the provider's code): the rows for `server_refused` and `identity_changed` gained their last sentences, about the cut without a mark, the failure with no message and the value that is left out; and the row for `provider_changed` was added.
|
||||
- In ADR-0005: steps 1 and 2 of the wizard (the two-stage warning, the provider comparison, a pointer to the name rule) and the sentence about the name of the first server after an upgrade.
|
||||
|
||||
### Text on the wire
|
||||
|
||||
Settled on 2026-11-10, when the contract's gaps below were closed, under the standing instruction. The first retrospective review found that the function Core wrote for pieces of sentences was being used as the form of typed wire members (its finding 4), and this record had left one question open: whether a limit includes the mark of a cut.
|
||||
|
||||
- There is no mark. A value on the wire is cut at its limit exactly, never inside a surrogate pair, and nothing is added to say that it was cut: a message is at most 512 UTF-16 units and an identity value at most 200, as the code table says. A mark after the limit made the numbers in this record untrue (515 and 203); a mark inside the limit would put a decoration into an identity value, which is a typed member that a client may compare.
|
||||
- Applying the rule to its own result changes nothing. The reader of a reply applies it again, so a client holds bounded, printable text even from a service that did not send it so, and holds the same text as a service that did.
|
||||
- That is a statement about text. JSON's grammar also lets a string hold the escape of a surrogate without its partner (`\ud800` on its own). That is not text, and the rule never sees it, because it cannot be read into a string at all. It is refused where it stands; it is never made into U+FFFD and it never makes the parsing functions throw (settled on 2026-11-10 in the fix round after the review of these fixes, which found that they threw). In a request's `op` or `server` the request is refused as for any other malformed operation name or server. In a reply's `code` or `message` the reply is incomplete, as it is when either is not text; the alternative, "not JSON", is what a client takes for a version 1 service, which such a reply is not. In a member's name, at any depth, the text is refused as not JSON, in a request and in a reply, because the parse cannot establish that no member is repeated. In a value inside the arguments, a result or the details, the envelope is read and the record is not (`invalid_request` for arguments). `ManagementJson.Write` refuses a payload that holds one with the error it gave before these fixes. A surrogate without its partner inside a string that code already holds does become U+FFFD, in a message and in an identity value.
|
||||
- A missing value is absent. The two members of the details of `identity_changed` may be left out, and so may the principal and the tenant wherever a reply names an account. No words such as "(missing)" are sent in their place: a client could not tell them from an account's name, and would have to match text to avoid showing them.
|
||||
- A failure must say something. An error whose message is missing, empty or blank once it is printable cannot be built by the service. When a server refuses "with no reason" (ADR-0005), the handler says so in its own words. A client still reads a reply whose message is empty, so that it learns the code; the parsing functions refuse nothing for this.
|
||||
- An account is carried one way in every reply. The snapshot, the result of `servers.add`, the result of `session.signIn` and the details of `identity_changed` give a principal and a tenant the same form and the same bound of 200. The session client itself admits an identity value of up to 1,024 units and excludes only control characters; what Core admits is not changed here (the first review's finding 5 names that as the other place the rule could live), so a value between the two bounds is carried cut. The message that stands beside an account (`ServerAuthentication.Message`) has the form of an error's message, printable and at most 512 units, when the record is built and when it is read (added in the fix round of 2026-11-10; it is the service's own wording today, and was held as given). The other text in results that a server can influence (a server's status, the printer sync detail, the last command and its outcome, job titles and messages) is decided when the handlers that produce it are written.
|
||||
- Which characters are shown stays one definition, which Core owns (`ServerText.IsShown`) and the contract calls, as "Text a server supplied" says. The cut and the treatment of a missing value are the contract's own (`ManagementText`), because Core's function for sentences rightly marks a cut and names a missing value. The rule for a server's name uses the same definition of a shown character.
|
||||
|
||||
### Where the contract as built did less than this record says
|
||||
|
||||
The rows for `server_refused` and `identity_changed`, and "Text a server supplied" and "One way to read an envelope" under "Resolved while reviewing the pipe contract", say what the contract enforces. The types merged in pull request 37 did less in four places, which the first retrospective review showed with a probe. All four are closed in the contract (2026-11-10), and nothing serves the contract yet:
|
||||
|
||||
- The form and the bound of a message were applied by the two reply factories only. They are now applied in the error record itself, which has no member that can be set afterwards, for an error the service builds by any route and for one a client reads (audit W31).
|
||||
- A details record was not tied to its code, and any text was accepted as a code. The service can now build an error only with a code from the table, and with exactly the details record the table names for it; a client still reads a code it does not know (audit W32).
|
||||
- The envelope types refused the serializer only when it was given the contract's own options. The refusal is now declared on the types and holds for the default options and for every options object that brings no converter of its own for an envelope type, for reading and for writing (audit W33).
|
||||
- A cut value carried a three-character mark after the cut, and a missing value was sent as the text `(missing)`. "Text on the wire" above settles both (audit W34).
|
||||
- Found by the review of those fixes and closed in the round after it: a request and a reply could still be built member by member, with another version, with an error beside a result, or with any text as an operation or a server. Both are now built only by functions that check them, which the parser uses as well, so every request that exists is one the parser would read back and every reply holds exactly one of a result and an error (audit W75). The parsing functions also no longer throw for the escape of a surrogate without its partner (audit W74; "Text on the wire").
|
||||
|
||||
Three decisions of this section were also missing from the contract and are in it now. What each still needs is built with the pipe server and the registry, not with the contract:
|
||||
|
||||
- The server-name rule is a type of the contract (`ServerName`), and the arguments of `servers.add` and `servers.rename` cannot be built with a name that breaks it. The service log's per-server view takes a server's identifier and prefixes its first eight characters. The contract also proposes a name that passes (`ServerName.Propose`) and holds the rule for an address (`ServerAddress`); the upgrade and the wizard, which will call them, are not built (audit W39).
|
||||
- The operation table marks `printers.discover` as single-flight. The discovery itself is made single-flight where the pipe server is built (audit W36).
|
||||
- The provider's origin in `servers.add`, below.
|
||||
|
||||
A fifth gap came from this section's own decision, not from the review's probe, and is closed in the contract:
|
||||
|
||||
- `ServersAddArgs` had no argument for the sign-in provider's origin, and its comment said the provider's address "is not an argument". It now carries the origin `servers.check` reported, for comparison only, and so does `TrustSetArgs`; the refusal has the code `provider_changed`, which is in the code table above and in the contract's table (audit W56). No handler exists yet, so the comparison itself is made when `servers.add` and `trust.set` are built; the contract gives them one rule to make it with.
|
||||
|
||||
## Resolved while building the journal's schema version, 2026-11-10
|
||||
|
||||
The journal's schema version and its one-time migration (plan step 5 of the build) were reviewed independently before they merged. The review found one place where the code and this record disagreed. It is settled here, on 2026-11-10, under the owner's standing instruction for this work: when a decision comes up, take the recommended option, record it and keep going. It follows the reviewer's recommended resolution. It is not the owner's own words, and the owner can overrule it, but only before version 1 of the schema ships: a migration step that has shipped is never edited. The earlier sentence stays where it was written, marked as changed.
|
||||
|
||||
**Rows found under no scope.** This corrects the last sentence of "An unpaired server has no scope" under "Resolved while planning the build", for rows under no scope. What that sentence says about rows under the unpaired name and under an "unreadable" name stands.
|
||||
|
||||
- What the code does: version 1 of the journal's schema gives job events that were written before events recorded their pairing the scope of the pairing that is saved when the migration runs. With no pairing saved that is the unpaired name, and with a pairing that cannot be read it is a new "unreadable" name (`JournalMigration.V1` and `AgentStore.ScopeForUnscopedRows`). Every 1.x start did exactly this: the store ran `UPDATE events SET connection_id=$scope WHERE connection_id IS NULL` each time it was opened, at every tag from `v0.1.6-rc.1` to `v1.2.1`.
|
||||
- What was wrong: the earlier sentence said that rows found under no scope belong to no server. Built as written, version 1 would have had to leave those rows without a scope, or set them apart. A journal with such rows would then show fewer jobs after the upgrade to 2.0 than any 1.x release shows on the same journal.
|
||||
- The decision: the code stays as it is, and this record now says what it does. Rows found under no scope are given the saved pairing's scope by version 1, as every 1.x start gave it to them.
|
||||
- Why. Only a journal last written by the first release candidate (`v0.1.0-rc.1`), or by a build from before it, has such rows: events carry a scope from the next tagged build (`v0.1.6-rc.1`) on, and every start of that build and of every later one adopted the unscoped rows it found. So no 1.x installation reaches version 1 with any, and for the journals that do have them, adopting keeps what 1.x showed the operator: the same jobs, under the same pairing.
|
||||
- The consequence, stated plainly: after version 1 no journal has rows without a scope. The later step that introduces several servers will find none, and needs no rule for them. Rows adopted under a readable pairing become that pairing's rows, which are the first server's after the upgrade, and cannot be told apart afterwards from rows that pairing wrote itself. Rows adopted under the unpaired name or an "unreadable" name belong to no server, as the earlier sentence says of those names.
|
||||
- Considered and not taken: taking the adoption out of version 1, so that the rows stay under no scope until the step that introduces several servers assigns them to no server. It would hide jobs that every build since `v0.1.6-rc.1` shows on the same journal, and the only journals it would treat differently are ones no 1.x release leaves behind.
|
||||
|
||||
## Resolved while planning the wizard and tree build, 2026-11-10
|
||||
|
||||
The plan for the connection wizard and the server tree found one gap in how this record is to be built (its gap G4; the others are in [ADR-0005](0005-connection-wizard-and-server-tree.md), "Resolved while planning the build"). It was settled on 2026-11-10 as the plan recommended, under the owner's standing instruction for this work (when a decision comes up, take the recommended option, record it and keep going), and written down here on 2026-11-11. It is not the owner's own words, and the owner can reverse it.
|
||||
|
||||
**Who builds `transport.get` and `printers.discover`.** Both operations came into this record when the pipe contract was reviewed ("Last contact and the traffic meter" and "A snapshot is always cheap" under "Resolved while reviewing the pipe contract"). The plan for the multi-server build had been written before that, and it gives neither of them to any step: its step for the pipe server lists the snapshot, the list of servers and the computer, log and update operations, its later steps list the rest by name, and neither of the two is among them. The window cannot move to version 2 without the two: the companion draws its traffic meter from `transport.get` every second, and the wizard's printer step asks `printers.discover`.
|
||||
|
||||
- The decision: the step that builds the version 2 pipe server (sixteen clients at once, the envelope, the operations on the computer and the aggregated snapshot; plan step 20 of the build) also builds `transport.get` and `printers.discover`.
|
||||
- `transport.get` is built with what "Management pipe, version 2" says of it: for each server its identifier, its transport state with the meter and `lastContact`, and an empty list, not an error, while the stored data is not ready.
|
||||
- `printers.discover` is built with its single-flight ("`printers.discover` is single-flight" under "Resolved after the retrospective reviews"): the computer runs one discovery at a time, and a request that arrives while one is running joins it and gets its result. The contract's operation table already marks the operation (`ManagementOperationInfo.SingleFlight`); making the discovery itself single-flight is the open part of audit finding W36, and it closes in that step.
|
||||
- Not built: nothing serves version 2 of the pipe yet (read at `02f34c3`).
|
||||
|
||||
## Recorded while reviewing the update and pipe-peer seams, 2026-11-10
|
||||
|
||||
The update installer, the update service, the service controls and the pipe-peer check were put behind tested seams (plan step 8 of the build) and reviewed independently before they merged. The review changed no decision of this record. It found a constraint on a later step that this record did not state. It is recorded here on 2026-11-10, from that review and from reading the 1.2.1 source. It is a finding about released code and not a decision, and nothing in it was run.
|
||||
|
||||
**An upgrade from 1.2.x is finished by the updater of 1.2.x.** When an installed 1.2.x updates itself to 2.0, the code that runs is its own: its companion downloads the 2.0 MSI, and a copy of its command line tool, the helper, starts Windows Installer and finishes the update after the 2.0 files are in place. 2.0 therefore has to satisfy what that helper does after the installer exits. An MSI installed by hand runs no helper. Read at tag `v1.2.1` with `git show`: `MsiUpdateInstaller.Apply.cs`, `MsiUpdateInstaller.Paths.cs`, `AgentPipeClient.cs`, `AgentPipePeer.cs`, `AgentPipeFraming.cs` and `PipeRequest.cs` under `src/Qqq.PrintAgent.Windows`, each identical at `v1.2.0`.
|
||||
|
||||
After Windows Installer exits, the 1.2.1 helper does four things in this order. The last three are each attempted whatever came before, and a failure in any of them is swallowed.
|
||||
|
||||
1. Only after exit code 0, it reads the product version of `App\Qqq.PrintAgent.exe`, `Service\Qqq.PrintAgent.Service.exe` and `Cli\qqq-print-agent.exe` under `%ProgramFiles%\QQQ Print Agent`. Each must be readable and carry the requested release in front of any `+`, and the three version strings must be identical, build metadata included. If not, the helper reports the update as failed, with exit code 1, although 2.0 is installed.
|
||||
2. It writes the outcome to `%ProgramData%\QqqPrintAgent\updates\last-result.json` (the time, the release, the exit code and a message), after its own check that the machine state folder is protected, and writes one line to the machine activity log. The 2.0 companion shows that outcome when it reopens.
|
||||
3. It sends one request to the service, `resume-after-update`, and waits for one line in answer. This is the only request it sends after the installer exits. It connects to the pipe named `QqqPrintAgent`, waiting three seconds at most. Before it sends anything it runs the 1.2.1 peer check: the service named `QqqPrintAgent` must be running and hosted by the process that serves the pipe, and that process must run as SYSTEM from `%ProgramFiles%\QQQ Print Agent\Service\Qqq.PrintAgent.Service.exe`. The request is one line in the version 1 form, UTF-8, ended by a line feed. As 1.2.1's `PipeRequest` and its JSON options write it, the line is `{"operation":"resume-after-update","confirmDuplicateRisk":false,"claimLegacyDevice":false,"startNewPairing":false}`. The answer is read up to its line feed, within 35 seconds for the whole exchange, and its content is ignored.
|
||||
4. It starts `%ProgramFiles%\QQQ Print Agent\App\Qqq.PrintAgent.exe` with no arguments, in that program's own folder, after checking that the path holds no link and that the file and every folder up to Program Files have a trusted owner and access list.
|
||||
|
||||
What 2.0 must keep for that helper:
|
||||
|
||||
- The three programs at those three paths, each carrying the release as its product version, with identical build metadata. Otherwise a completed upgrade is reported to the operator as failed.
|
||||
- A service named `QqqPrintAgent` that runs as SYSTEM from that service path and serves a pipe named `QqqPrintAgent`. Otherwise the helper's peer check refuses and the request is never sent.
|
||||
- An answer to the version 1 request `resume-after-update`: a pause that is in force is lifted, and one line comes back. The step that retires version 1 of the pipe must keep answering this one request in its version 1 form for as long as an upgrade from 1.2.x is supported. The helper sends nothing else after the installer exits.
|
||||
- The form of `last-result.json`, which the 2.0 companion reads.
|
||||
|
||||
What an unanswered request costs, as read at `v1.2.1` and at the head of that step:
|
||||
|
||||
- The review expected printing to stay paused after the upgrade until the pause expires, which is ten minutes. The code does not bear that out for a service the installer has replaced. The pause is a field of the running service process (`_updatePauseUntil`, in 1.2.1 and here) and is not stored, and the installer stops the old process and starts the new one (`ServiceControl` with `Stop="both"` in `installer/Package.wxs`). A 2.0 service that the installer started begins without a pause, whether or not it answers.
|
||||
- The expectation becomes true as soon as a later step stores the pause or keeps the service process across an update. The request is then the only thing that lifts the pause before it expires.
|
||||
- Where the paused 1.2.x process survives the installer, as after an install that failed or was cancelled before the service was stopped, it is the 1.2.x service that answers.
|
||||
- What is certain today is smaller. A 2.0 service that accepts the connection and neither answers with a line nor closes it holds the helper for up to 35 seconds before the companion is reopened.
|
||||
|
||||
The qualification of the upgrade from 1.2.x, which is open in `docs/TODO.md`, has to run a real 1.2.1 installation's own update to a 2.0 build and look at each of the four steps.
|
||||
|
||||
## Decisions taken while working, per the owner's direction
|
||||
|
||||
About the runtime:
|
||||
|
||||
- The computer details stay per-computer rather than per-server.
|
||||
- The command line's `--legacy-compat` bearer diagnostics are removed in 2.0. They talk to servers older than the realtime printing API, which the service itself cannot use, and they are the last code that sends a device token outside a session.
|
||||
- Preparing for an update drains the work in hand. Jobs claimed before the pause pass the submission gate and finish; no new job is claimed; "Ready for update" is answered when no job is in hand or the deadline is reached, and the reply says which. In 1.2.x only prints already submitted were waited for, so a job that was still downloading was lost to the update.
|
||||
- 2.0 does not honor control state written by the 0.2 polling prerelease. The "authorization required" flag and the LEGACY connection mode are read from an old file without failing and otherwise ignored; whether a sign-in is required comes from the live authorization state.
|
||||
- A failed claim ends the wave, not one lane: one claim and one log line per wake-up, rather than sixteen.
|
||||
|
||||
About printing (2026-11-10):
|
||||
|
||||
- **A print job reads the settings of its own printer only.** The owner asked for this on 2026-11-10, on being told that every job read the settings of every queue on the station (review of the printing seams, which merged as pull request 42, `e2ca086`; finding 3 of that review, W44 in [the audit](../AUDIT-2026-11-10.md)), so that one unrelated printer whose driver hung or failed stopped label printing. The same effect was then found in two more places, the discovery before the claim (C25) and the inventory report (W65); this change closes W44 and C25 and part of W65. What the owner asked for was the fix of the first finding, W44. The author of that fix found the other two places while making it. Taking them into the same change, and with them the design of three reads in place of one, was decided on 2026-11-10 by the integrator (whoever brings the changes into `release/2.0`), under the owner's standing instruction and without a separate word from the owner. A printer backend now has three reads, named for what they cost: `Discover` reads the settings of every queue; `ListWithoutMedia` is the spooler's enumeration and nothing else; `FindWithMedia` is the enumeration and the settings read of the one queue asked for. All three go through one mapping in the Windows adapter, so they cannot disagree about a queue, and every wrapper (the exposure filter, the profile wrapper, the isolated backend with one helper operation for each) applies its own rule to all three.
|
||||
- **Where each read is used.** A lane lists before it claims, looks up the claimed job's printer after the download and before SUBMITTING, and the print helper looks the same printer up before it prints. The step before the claim stays where it was and became the list, instead of moving behind the claim as a lookup: the printer is not known before the claim, a failure between the claim and RECEIVED would have needed a new rule, and the order that is pinned (a pass that cannot list the queues stops before it claims; loss of synchronization during the list; the timing line) is unchanged. Operator and server cancellations of a spooled job, and the test print, need a queue's name or formats and nothing else, so they take them from the list; their refusals and messages are unchanged.
|
||||
- **This deliberately changes what reaches Windows, and only for other queues.** After this change, queues other than the job's own receive no per-queue calls on the print path, and no queue receives any before the claim (the job's own queue was read there too, as one of all of them; it is now read after the download and in the print helper, and not a third time before the claim). For the printer being printed to, the settings read after the download is the read the discovery made for it there, and inside the print helper every call, its arguments and their order are what they were, including the settings read the helper makes before the document and then discards. The agreement with the owner for the printing code is that what reaches Windows for the printer being printed to is not changed without proof on real printers; that read is redundant but proven, and removing it buys nothing an operator can see. It is recorded as open (W66) and would be removed together with a check on real printers. Because fewer calls reach Windows, this change is part of the check on real printers before release ([ADR-0003](0003-version-2.md), "Release condition: the hardware check").
|
||||
- **The inventory report no longer ends the connection.** The report still reads every queue. A read that fails used to fault the synchronizer and with it the whole realtime connection, on every heartbeat round, which stopped the lanes whatever a job itself asked. Now a failed read is caught (cancellation excepted), logged as an error when its reason changes, nothing is sent (a station that cannot be read is never reported as a station with no printers), the round goes on to the receipts, and the inventory the connection already delivered stays in force, so the failure by itself holds no lane. When the cause is one queue's settings the lanes continue. When it is a cause the lanes share, the spooler's list or the journal, they stop on it themselves ("Print lane stopped"); the error line therefore gives the reason and does not say that jobs go on (corrected after the confirmation review of the fix round, which offered a job while the journal could not be read and saw it claimed only afterwards). The cost is that the server is told nothing new about any printer while the read fails, so a change in the status or the media of a healthy printer is not reported either, where before the connection dropped on every round. A stale inventory cannot print on a withdrawn printer: each job's own printer is looked up before SUBMITTING, the exposure filter refuses at submission, and the selected media must match; it can cost a job that is refused.
|
||||
- **What this does not fix.** Dispatch waits until the connection has delivered an inventory, and that needs one full discovery to succeed. So after a restart or a reconnect, a queue whose driver hangs or fails every time still stops printing, until it is repaired or removed. The pipe requests that carry the printer list also still read every queue: `snapshot` and `status` (the companion builds its status line, Printers view and recent jobs from the snapshot), `printers` and a sharing change. With such a queue each is slow and then fails with the reason, and the pipe goes on serving; the companion shows the rejected snapshot as a service problem while other printers print (W67), and the pipe serves one request at a time, so a request behind one of them waits for it (W68). The server's `REFRESH_INVENTORY` command also reads every queue, under the gate that commands share (W69).
|
||||
- **Residual risk.** The enumeration itself asks the spooler about every local queue and every connection of the service's identity (one call for the size, one for the list). A spooler that cannot enumerate, or that hangs while it does, still stops every pass before it claims and every lookup, which is the behaviour we want for a broken spooler but means the fix covers a broken driver, not a broken spooler. Nothing here was run against a real spooler or printer; the tests use hand-written fakes.
|
||||
- **Known limit: a print server that is switched off or cannot be reached.** Added in the fix round after the review of this change (its finding S1). The first wording of this record, of the code comments and of the changelog said that the list asks no queue anything, so that no queue can slow it or fail it. That is true of the service's own process and not of Windows. Both enumeration calls are `EnumPrinters` for local queues and connections at level 2, and Microsoft documents that an enumeration at that level performs an `OpenPrinter` on each remote printer connection and, when the connection is down, its server no longer exists or its printer no longer exists, waits for the remote call to time out (the remarks on `PRINTER_INFO_4`, read on 2026-11-10). In the words of a station: a printer that is not installed on the station but is a connection to a printer shared by another computer, a print server, can still stall the list, the lookup and the print for every printer while that server is switched off or cannot be reached. The list runs before every claim with a 20 second limit, so a wait longer than that ends every pass before it claims and no job is taken; a lookup that runs out of time fails its job before SUBMITTING, and a print that runs out of time ends as OUTCOME_UNKNOWN. So the fix covers a broken driver on the station and does not cover a dead print server, which behaves as it did before this change. The enumeration is deliberately left as it is. Listing at another level (level 4 is answered from the registry and opens nothing) and reading level 2 for the one queue would change what reaches Windows, for the printer being printed to as well, and that is not changed without proof on real printers. It is open as W70, it is stated as a known limit in the changelog, `docs/REALTIME.md` and `docs/TROUBLESHOOTING.md`, and it is a case of its own in the plan for the hardware check ([ADR-0003](0003-version-2.md)). Nothing of it was measured: not whether the service's identity has such connections on a given station, not how long Windows waits, and not whether the wait recurs on every call.
|
||||
- **Decided on 2026-11-10 under the owner's standing instruction, and not built in this change** (it is to be its own pull request, with its own review and the check on real printers):
|
||||
- What the server is told: a queue whose settings cannot be read (the read fails, the helper dies, or there is no answer within the queue's limit) is reported as present with status UNKNOWN, no formats and no media profile. That is the shape a queue that needs a person at the keyboard already has, so it cannot be shared or printed to, and a job already assigned to it is refused at the lookup before SUBMITTING ("Assigned printer or document format is unavailable."), with Windows never asked. The rest of the inventory is delivered, so dispatch can start.
|
||||
- How: the full discovery first tries one helper for all queues, as today. If that helper fails or times out, each queue is read in its own helper with its own limit, so one hung or crashing driver costs only its own queue. A queue found unreadable is not asked again until a retry interval has passed; until then it is reported unreadable without a native call.
|
||||
- Open, not settled: the retry interval; whether the unreadable state is shown to the operator in the companion with its reason; whether the per-queue limit is shorter than the 20 seconds of the whole discovery.
|
||||
|
||||
About the process:
|
||||
|
||||
- The command-line assembly's release bar is 100% line coverage, like Core, because it is UI-free logic (K22-TEST-10).
|
||||
- Coverage minimums are the measured value rounded down, less one point, because a few timing-dependent lines differ between runs.
|
||||
- Pull requests merge into `release/2.0` by squash (K22-REPO-22), so the branch gets one commit per reviewed change.
|
||||
- Until Gitea runs checks for this repository, a pull request merges into `release/2.0` on full local verification plus an independent review with no blocker or unresolved should-fix item (owner's rule, 2026-11-10).
|
||||
- The review gate is on a commit id. The owner asked on 2026-11-10 that the process be changed so that what the next item describes does not happen again. The terms that follow were taken under the standing instruction; they are not the owner's words. A pull request merges only when the reviewer's last report covers the exact head being merged, or the delta from the head it last passed to that head, and leaves nothing at should-fix or above. Every fix round goes back to the reviewer for confirmation, however small. Commits added by whoever integrates the change count like any others. A replay onto a newer base is the one relaxation: it needs no new review only when `git range-diff` shows the patches identical. Verification (`build/Verify.ps1`) runs on the same head that is merged, and that head sits on the current tip of `release/2.0`, so that the tree the squash merge produces is the tree that was reviewed and verified. Pull requests that change only documentation or decision records are reviewed too. The reviewed head and the verdict go in the pull request description.
|
||||
- How the earlier merges went wrong. Pull requests 28, 29, 35, 37 and 38 were each merged with a final fix round their reviewer had not seen: a fix made "as the reviewer proposed" was treated as reviewed. The documentation pull requests 30 and 31 were merged with no review at all. The rule in force then (two items up) asked for an independent review with no blocker or unresolved should-fix item and did not say which head the review had to cover, so this is recorded as a gap in the process and not as a breach of the rule above, which did not exist yet. Two retrospective reviews were then run on 2026-11-10: one of pull requests 37, 38, 31 and 30 (no blocker, 14 should-fix, 7 nits), and one of the last fix rounds of pull requests 29, 35 and 28 (no blocker, 2 should-fix, 8 nits). Their findings about code are listed in [the audit](../AUDIT-2026-11-10.md) under "Found by the retrospective reviews"; their findings about this record are settled in "Resolved after the retrospective reviews" above.
|
||||
- The owner approved, on 2026-11-10, running the 1.2.x upgrade install and the elevated desktop tests from the working session; Windows' own consent prompt is still answered by the owner each time.
|
||||
- The workflow files under `.github` and `.gitea` are not changed by the 2.0 branches; what they need (no literal version for branch builds) is recorded in `docs/TODO.md` by the change that sets the version, for the CI work.
|
||||
@@ -0,0 +1,424 @@
|
||||
# ADR-0005: The connection wizard and the server tree
|
||||
|
||||
Status: accepted for 2.0 under the owner's direction of 2026-11-10; the wizard and the tree are not built, and the window does not use any part of this record yet; revised the same day after an independent review; steps 1 and 2 of the wizard were changed on 2026-11-10 after a retrospective review (ADR-0004, "Resolved after the retrospective reviews"); the gaps found while the build was planned are settled in "Resolved while planning the build, 2026-11-10/11" below, which also says what is merged and lists the sentences corrected in place on 2026-11-11. Builds on [ADR-0003](0003-version-2.md) (a wizard replaces the connection dialog; servers are shown as a tree) and [ADR-0004](0004-multi-server-runtime.md) (server identity, the pipe contract).
|
||||
|
||||
## Context
|
||||
|
||||
The 1.2.x connection dialog is one scrolling form: address, a development certificate exception, a three-way pairing choice the operator must make before signing in, a discover button, two sign-in methods and an enrollment code. Every message the window shows is mirrored into the dialog's own feedback bar, which is never closed, so the dialog opens showing whatever was said last. The left pane lists Printers, Jobs and Logs for the one connection.
|
||||
|
||||
## Decisions
|
||||
|
||||
### The wizard is a page, not a dialog
|
||||
|
||||
Add server opens the wizard in the content area. A dialog cannot be resized by the operator, cannot host another dialog (the certificate warning, the removal confirmation), and was the source of the clipped layout the current form works around. When the computer has no servers and the service reports that its stored data is ready, the wizard is what the window shows; with no servers in any other state the window shows the Computer page, which says why (G5 below).
|
||||
|
||||
It has five steps, a step indicator, and Back, Next and Cancel. Enter moves forward and Escape cancels. Each step shows only what that step needs, and its own result; nothing from another step or another page appears in it.
|
||||
|
||||
1. **Server.** The address, and a name that defaults to the host (what a name may be is in ADR-0004, "Server names"; a host that breaks that rule is not proposed as it stands). Checking the server asks the service to run the compatibility check (`servers.check`), because the service is what will connect and it trusts certificates as LocalSystem, not as the signed-in user. The step shows the five stages in order (reaching the address, a trusted secure connection, a QQQ server, a printing extension it can print through, a sign-in method it implements), each passed, failed with the reason, or not reached. Next is enabled only when all five pass. A certificate failure shows the certificate that was presented (subject, issuer, expiry, fingerprint, and why Windows refused it) and says how to go on. When Windows refused the certificate because it does not trust the issuing authority, installing that authority's root certificate in this computer's trusted root store is the lasting fix and needs no exception, and switching on the development-only exception for this one server, with its warning, is the second way (G12 below). For any other refusal the step shows the certificate and offers neither. The warning comes in two stages, because the sign-in provider is known only from the server's sign-in description, which cannot be read while the server's own certificate is refused: the first warning names the server's address and says that the sign-in provider's address will be shown after the check; the re-check that follows returns the provider's address, and the step then shows both addresses the exception covers before the operator goes on. (An earlier wording had one warning name both addresses before the exception was accepted, which cannot be done in that case.) Nothing is stored yet: the choice travels with the wizard and is saved when the server is. An address that is already a server on this computer says so and offers to open it.
|
||||
2. **Sign in.** The methods the server offers and no others. Browser sign-in shows that it is waiting, can be cancelled, and gives up after three minutes; password sign-in clears the password after every attempt. The sign-in runs in the companion, as the user. If the user's Windows account refuses a certificate that the service accepted, the step says that it is this account that refused it. When sign-in succeeds the wizard adds the server (`servers.add`), which stores the server, the certificate exception if one was accepted and the session together or not at all. The request carries the provider's address that the check reported, for comparison only: when the service's own read names another provider, names one where the check named none (the request then carries no address, which says so), or names none where the check named one, the add is refused with `provider_changed` and nothing is stored, and the wizard goes back to step 1 and checks the server again, so the operator sees the provider the server names now. When it is the sign-in provider's certificate that is refused, this step shows it and offers the same two ways forward as step 1.
|
||||
3. **Pair this computer.** For a new server this is the enrollment code and where to create it. A refused code says so and can be tried again. When the outcome is not known (the reply was lost, or the service stopped answering), the step says exactly that, shows what the service reports was left behind, and offers to check again rather than to enroll again. The check reads a snapshot that was sent after it was asked for, never one already in flight ("Resolved while reviewing the management client, 2026-11-11").
|
||||
4. **Printers.** The Windows printers that can print unattended, none selected. Sharing can be skipped and done later.
|
||||
5. **Done.** The server's name, who is signed in, how many printers are shared and that the server must approve them, and a button that opens the server.
|
||||
|
||||
Cancelling before sign-in leaves nothing behind. Cancelling after it asks whether to keep the server (signed in, not paired) or remove it.
|
||||
|
||||
### The ordinary pairing is tried first; a choice is asked for only on refusal
|
||||
|
||||
The old form made the operator pick one of three pairing options before signing in. The wizard tries the ordinary thing and asks only when that fails. What the service can tell apart, and what the wizard does:
|
||||
|
||||
| Outcome | What the wizard does |
|
||||
| --- | --- |
|
||||
| No pairing on this computer for this server | Asks for an enrollment code. |
|
||||
| The saved pairing binds | Nothing to decide; this is the common case. |
|
||||
| The pairing binds, but for a different account than the one approved for it | Says which account is approved and asks the operator to sign in as that account. |
|
||||
| The server refuses the bind | Says that the server refused this pairing for this account, and offers the two repairs with what each does. |
|
||||
|
||||
The two repairs are: associate the existing pairing with this account (for a pairing created before accounts were linked), or set up a new pairing (for a revoked or replaced device), which needs a new sign-in and a new enrollment code and keeps history and printer choices. The new sign-in falls away, for either repair, once the condition of G11 below is met; it is not met yet.
|
||||
|
||||
The wizard cannot say why a bind was refused. The server answers 401, 403 or a failure with no reason, and 2.0 does not change the wire ([ADR-0003](0003-version-2.md)). So the choice between the repairs is deferred to the one case that needs it, not removed. A reason code on a refused bind is a request to the printing protocol, outside this release.
|
||||
|
||||
Repairing a server starts from that server's Connection page and enters the wizard at the sign-in step. The compatibility check runs again first, without showing step 1 unless it fails, because the sign-in methods on offer come from it.
|
||||
|
||||
### The tree
|
||||
|
||||
- One node per server, in the operator's order, showing its name and health as both a glyph and text, and a marker for as long as its certificate exception is switched on. Beneath it: Printers, Jobs and Connection. Selecting the server shows its overview.
|
||||
- Add server, disabled with an explanation at eight.
|
||||
- Computer (this computer's details, the Windows service, companion startup), Logs and Settings (appearance, updates, about).
|
||||
|
||||
Printers and Jobs are the pages 1.2.x has, scoped to the selected server. Connection gathers, per server, what 1.2.x spreads over the Settings page, the footer and the connection dialog: status, who is signed in, the realtime meter, sign in again, sign out, reconnect, the certificate exception, rename and remove.
|
||||
|
||||
A server's context menu offers reconnect, sign out, rename, move up, move down and remove (the two moves are G16 below). Removing asks first and says what it does: printing from that server stops, its pairing and session are deleted from this computer, its job history is no longer shown and is deleted after the retention period, and its device record on the server is not touched.
|
||||
|
||||
After an upgrade from 1.x the existing connection is the first server, named after its host (or, where the host breaks the name rule of ADR-0004, "Server names", after a name made from it that passes), and the wizard is not shown.
|
||||
|
||||
### One status at a time, per place
|
||||
|
||||
- The footer and the tray icon show the worst health across servers; the footer names the count in each state and opens the first server that needs attention; the tray tooltip gives the worst health, the count in each state and as many servers with their state as fit in what Windows shows of a tooltip, then how many are left out; the tray menu lists every server (G15 below).
|
||||
- Each server has its own busy state. A sign-in in progress on one server does not disable or silently drop commands on another, and refresh never stops.
|
||||
- Browser sign-in is the one thing that cannot be per server, because Windows gives each user one callback inbox and one redirect address. One dispatcher owns the inbox and routes each callback by its OAuth state to the sign-in that started it, so two servers can be signing in at once and a late callback for one cannot fail the other.
|
||||
- A message belongs to the page that caused it and is cleared when the operator leaves that page. The wizard never shows a message from elsewhere.
|
||||
|
||||
### Accessibility
|
||||
|
||||
Every interactive control has an AutomationId that stays stable across releases. Only one server's pages are on screen at a time, so identifiers inside them are the same for every server and carry nothing machine-generated. A server's tree node is `Server.<n>`, its position in the tree counted from one, with the server's name as its accessible name; its children are found through it. Tree items, rows and steps have accessible names. Progress and results are announced once, after the text is final. Everything is reachable and operable from the keyboard, and state is never conveyed by color alone. What each of these requirements means as a test is defined under "What the re-audit scores accessibility against" below (G18).
|
||||
|
||||
Existing identifiers the UI tests use (`NavigatePrinters`, `NavigateJobs`, `NavigateLogs`, `ServerAddress`, `EnrollmentCode`, `EnrollDevice` and the rest) keep their meaning where the control still exists; where a control is replaced, the test moves with it in the same change.
|
||||
|
||||
### Where the logic lives
|
||||
|
||||
Every decision above (which step is next, whether Next is enabled, what a failure means, which repairs to offer, what each node shows) is in the UI-free presentation assembly and covered by unit tests. The views bind to it. The interactive UI tests cover the shell: that the tree and wizard render, navigate and announce.
|
||||
|
||||
## Consequences
|
||||
|
||||
The pairing choice no longer exists as a control up front, so documentation and support text that name its three options change. The wizard needs the typed compatibility result from Core, the service-side check and typed replies from the pipe; it cannot be built on message matching. Sign-in messages that name controls of the old dialog are rewritten.
|
||||
|
||||
## Resolved while planning the build, 2026-11-10/11
|
||||
|
||||
A planning pass on 2026-11-10 read this record against the code of `release/2.0` (at `65a99d3`; nothing was built or run for it) and listed twenty-one places where the record was silent, disagreed with the code or left a choice open. They keep the plan's numbers, G1 to G21. Each was settled on 2026-11-10 as the plan recommended, under the owner's standing instruction for this work (when a decision comes up, take the recommended option, record it in the decision record, and keep going), with one exception: for G1 and G2 the rule that was taken that day, and then built, is tighter than the plan's wording, and what is recorded here is the rule as built. They were written into this record on 2026-11-11. None of this is the owner's own words, and the owner can reverse any of it. The plan listed seven of them (G1, G2, G4, G5, G7, G11 and G19) as needing a decision before a step could start, which its summary puts as needing the owner or the service steps; it asked that four (G1, G2, G4 and G11) be raised with the service steps, and it called one part of G19, adding the layout tests to the solution, the owner's call. All seven were taken under the standing instruction like the rest, that part of G19 included; it is recorded with G19 that the owner may reverse it before the step that builds it. Two of them keep a condition that is stated with them (G7 and G11).
|
||||
|
||||
"Built" below means merged on `release/2.0` and read there at `02f34c3` on 2026-11-11. "Not built yet" means that no merged code does it. Neither the wizard nor the tree exists, the window calls none of the merged parts, and nothing serves version 2 of the pipe, so no decision here has reached an operator. Since then the wizard's frame and its Server step were added to the presentation assembly; the entries that touches say so (G3, G5, G8, G12 and G20). The window does not use them, so the last part of that sentence still holds.
|
||||
|
||||
Sentences of this record that a decision contradicted, or that said less than the rule as built, were corrected in place on 2026-11-11, each with a pointer to its decision, except the third case in step 2:
|
||||
|
||||
- Under "The wizard is a page, not a dialog": when the wizard is what the window shows (G5).
|
||||
- In step 1 of the wizard: when the certificate exception is offered (G12).
|
||||
- In step 2 of the wizard: the third case of the provider comparison, a provider named by the check and none read by the service (G1).
|
||||
- Under "The ordinary pairing is tried first": a new pairing still needs a new sign-in, as it did; a sentence was added that the new sign-in falls away once the condition of G11 is met, which it is not yet (G11).
|
||||
- Under "The tree": the server's menu gained the two moves (G16).
|
||||
- Under "One status at a time, per place": what the tray tooltip holds (G15). [ADR-0004](0004-multi-server-runtime.md), "Companion", says "the tooltip names each server"; it carries a pointer to G15 since 2026-11-11.
|
||||
- The status line, and a pointer from "Accessibility" to the definition below (G18).
|
||||
|
||||
### The decisions
|
||||
|
||||
**G1. What `servers.add` says about the sign-in provider, and the code of its refusal.** `servers.add` always states the sign-in provider that the check showed the operator: the origin `servers.check` reported, or nothing when the check named none. Nothing is itself a statement, "the check named none", and is compared like any other value. The service compares what the request states with what it reads from the server itself, in both directions, and refuses with the code `provider_changed`, with no details and nothing stored, when the two differ: when it reads another provider, when it reads one where none was shown, and when it reads none where one was shown. The wizard then goes back to step 1 and checks again. The plan's wording was weaker (the origin "compared whenever it is given"); it was not taken ([ADR-0004](0004-multi-server-runtime.md), "Who names the sign-in provider").
|
||||
|
||||
- Rules out: an add that states no provider and is accepted whatever the service reads; `certificate_untrusted` or `invalid_request` as the code of this refusal; details on it.
|
||||
- Built, in the contract, by pull request 51 (`4e01519`): the argument (`ServersAddArgs.ProviderOrigin`), the one rule both operations call (`SignInProviderOrigin.Agrees`), the code in the contract's table of codes with no details record, and the golden request `servers.add.request.json`, which carries `providerOrigin`. Text that names no origin is refused when the arguments are read, and a member left out and a null both state "none".
|
||||
- Built by pull request 53 (`02f34c3`): the companion's own read of the sign-in description stops the sign-in when the provider it names is not the one the check reported, with the same three cases (`CompanionSignInState.ProviderChanged`).
|
||||
- Built in the companion's client for version 2 of the pipe (`ManagementClient.AddServerAsync`): it takes the result of the check the operator saw, not an origin as text, states that check's provider, and refuses a check that failed; what that argument does not guarantee is under "Resolved while reviewing the management client, 2026-11-11".
|
||||
- Not built yet: the comparison in the service, because nothing serves `servers.add`. The step that builds it has `ServersAddArgs.ProviderAgrees` to call.
|
||||
|
||||
**G2. `trust.set` has the same race, and how an operator accepts a provider that a server names later.** Before the exception is switched on for a server that exists, the Connection page runs `servers.check` for that server and shows both origins the exception would cover. `trust.set` then states the provider the operator was shown, as `servers.add` does: compared by the same rule, nothing included, and refused with the same code with nothing changed. A request that switches the exception off accepts nothing, so nothing is compared for it.
|
||||
|
||||
- Rules out: an exception that comes to cover an origin the operator was not shown; an origin that is compared only when it is given.
|
||||
- Built, in the contract, by pull request 51: `TrustSetArgs.ProviderOrigin`, `TrustSetArgs.ProviderAgrees` and the golden request `trust.set.request.json`.
|
||||
- Built in the companion's client (`ManagementClient.SetTrustAsync`): it takes the check's result too, and refuses to switch the exception on without one; unlike the add it accepts a check that failed, for the reason given under "Resolved while reviewing the management client, 2026-11-11".
|
||||
- Not built yet: the Connection page, and the comparison in the service.
|
||||
|
||||
**G3. One rule for a name and one for an address, in the contract.** What a server's name may be and what an address may be are each one function of the pipe contract, which the service and the companion both call: `ServerName.Check`, which says which part of the rule a name breaks; `ServerName.Propose`, which makes a name from an address; and `ServerAddress.TryRead`.
|
||||
|
||||
- Rules out: a copy of either rule in the presentation assembly, which is how the three address rules of audit finding U16 came about.
|
||||
- Built by pull request 51 (audit W39 says what the rules are and what is held by test).
|
||||
- Not built yet: their callers in the window. The dialog's flow and the session client still apply their own checks to an address, as W39 records, so U16 stays open. The companion's client for version 2 takes the origin `ServerAddress.TryRead` returned and sends nothing for an address that rule refuses ("Resolved while reviewing the management client, 2026-11-11").
|
||||
- Built in the presentation assembly by the wizard's Server step, which the window does not use yet: the address is read by `ServerAddress.TryRead`, the name is `ServerName.Propose` of the origin until one is typed, and a typed name is judged by `ServerName.Check`. The step has no rule of its own for either. The sign-in provider a check reports is read by the contract's rule as well (`SignInProviderOrigin.TryRead`; "Resolved while building the wizard, 2026-11-11").
|
||||
|
||||
**G4. Who builds `transport.get` and `printers.discover` in the service.** The step of the multi-server build that builds the version 2 pipe server builds both, and makes `printers.discover` single-flight there. It is recorded in [ADR-0004](0004-multi-server-runtime.md), "Resolved while planning the wizard and tree build".
|
||||
|
||||
- Rules out: leaving either to the step that moves the window to version 2, which needs both from its first day.
|
||||
- Not built yet.
|
||||
|
||||
**G5. When the wizard is what the window shows.** Only when the snapshot was answered, it reports the stored data as ready, and there are no servers. With no servers in any other state the window shows the Computer page, which says why: the upgrade from 1.x stopped at a file it names, a newer build wrote the data, the service was not reached, or a service of another pipe version answered. Add server is disabled with that reason.
|
||||
|
||||
- Rules out: a wizard on a service that would refuse every operation it sends (`state_newer`, `migration_incomplete`), and a wizard on a service that did not answer.
|
||||
- Built in part, in the presentation assembly: a run that adds a server is not started without an answered snapshot, while the stored data is not ready, or at eight servers, and `ConnectionWizard.WhyAddServerCannotStart` is the sentence that says which, for the Add server item too.
|
||||
- Not built yet: the window, which decides what it shows when there are no servers, and the Computer page that says why.
|
||||
|
||||
**G6. A computer whose 1.x files could not be read.** [ADR-0004](0004-multi-server-runtime.md), "The cases", says that with neither the pairing nor the session readable there are no servers, the files stay where they are and "the computer says why". The contract has no member that could say it: the stored state carries a status, the file an unfinished upgrade stopped at and two version numbers (`StoredState`). The decision is that the stored state gains a list of fixed values that name what was left in place unread; the plan's examples are `pairingUnreadable` and `sessionUnreadable`. It is a change to the contract for the service steps to make.
|
||||
|
||||
- Rules out: free text for the reason, and a companion that guesses it from the absence of servers.
|
||||
- Not built yet. Until it is, such a computer shows the first-run wizard with no explanation.
|
||||
|
||||
**G7. Which control is the tree.** A `TreeView` in the left pane, in place of the `NavigationView` the window has today (`MainWindow.xaml:40`). The reason given is its automation: a tree with tree items that carry a level and a position, and the platform's own keys.
|
||||
|
||||
- The condition: this stands subject to a comparison of the two controls with Narrator and Accessibility Insights, made at the start of the step that builds the views and recorded here. How a `NavigationView` exposes nested items was not verified when the decision was taken, and has not been since. The view models are the same for either control.
|
||||
- Rules out, once the comparison confirms it: a `NavigationView` with nested items.
|
||||
- Not built yet.
|
||||
|
||||
**G8. Where Back works.** From Sign in to Server, where the step before stored nothing, and from Done to Printers. Not from Pair this computer and not from Printers, because the step before each of them stored something.
|
||||
|
||||
- Rules out: a Back that would have to undo a stored server or a stored pairing.
|
||||
- Built in the presentation assembly by the wizard's frame, which the window does not use yet: Back is offered from Sign in and from Done and from no other step, and not while the wizard waits for the service. Back to the Server step says that step's result again ("Resolved while building the wizard, 2026-11-11").
|
||||
- Not built yet: the Sign in and Done steps themselves, and with them what going back from each discards.
|
||||
|
||||
**G9. Leaving the wizard's page.** The wizard stays alive while the operator looks at another page. Add server shows that one is in progress, and starting another asks first whether to cancel it. There is one wizard at a time.
|
||||
|
||||
- Rules out: a wizard that navigation cancels, and two wizards at once.
|
||||
- Not settled by the plan: what a step shows of its last result when the operator comes back. "One status at a time, per place" clears a page's message when the page is left.
|
||||
- Not built yet.
|
||||
|
||||
**G10. A server that is signed in and not paired.** Its overview and its Connection page offer "Pair this computer", which enters the wizard at step 3. If the service then answers `not_signed_in`, the wizard goes to step 2. A repair still enters at the sign-in step.
|
||||
|
||||
- Rules out: sending a server that needs only a pairing through a sign-in first.
|
||||
- Not built yet.
|
||||
|
||||
**G11. After `bind_refused`, whether the same credential is sent again with a repair.** The wizard keeps its session until the outcome is final and sends the same credential again with the repair the operator chose (`SessionSignInArgs.AssociatePairing` or `NewPairing`). When that is answered `sign_in_refused`, it falls back to a new sign-in.
|
||||
|
||||
- The condition: this holds only if the service, when it answers `bind_refused`, keeps nothing of the credential and leaves the session on the server alone. The service step that builds `session.signIn` must state which it does. That step is not built: in `src`, `session.signIn` is named only in the contract's own files (by search at `02f34c3`), so the condition is not met. Until it is, the decision is not in force and every repair starts with a new sign-in: this record said so of a new pairing, and the plan's other option says it of both. What the service does with such a session was not read when the decision was taken, and was not read for this section either.
|
||||
- Rules out, once the condition is met: a second sign-in for a repair of a session the server has just accepted.
|
||||
- Not built yet.
|
||||
|
||||
**G12. When the certificate exception is offered.** Only when the reasons Windows refused the certificate include that it does not trust the issuing authority (`untrustedAuthority` among the refusals of the certificate the check describes). A name mismatch alone, or no certificate at all, gets the certificate shown and no exception offered. If a check still fails after the exception was accepted, the step says that the exception does not cover it.
|
||||
|
||||
- Rules out: offering a development exception for a certificate issued to another name.
|
||||
- Built in the presentation assembly by the wizard's Server step, which the window does not use yet: the exception is offered only when the refusals include `untrustedAuthority`; a certificate that came with no reason is offered none; and when the certificate is refused again under the exception the step says that the exception does not cover it and does not offer it again for that result. What the step says for each reason is under "Resolved while building the wizard, 2026-11-11".
|
||||
- The contract carries the reasons (`PresentedCertificate.Refusals`). This decides what the wizard offers; it does not change Core, whose exception, once on, accepts a certificate from a covered origin whatever Windows held against it (`QqqSessionClient.EvaluateCertificate`).
|
||||
|
||||
**G13. This Windows account refuses a certificate that the service accepted.** Step 2 says that it is this account that refused it, shows the certificate, and offers no exception: an exception would be stored for a service that does not need one.
|
||||
|
||||
- Rules out: an exception stored with the server because of what this Windows account does not trust.
|
||||
- Built in part by pull request 53: `CompanionSignIn` ends in `AccountRefusedCertificate` and keeps the refused certificate and who presented it (`CompanionSignIn.Rejection`).
|
||||
- Not built yet: the step that shows it.
|
||||
|
||||
**G14. Health when there are no servers.** Red, with the text "Add a server to start printing". The plan's reason is that 1.x is red while it is not enrolled; the version 1 snapshot is (`AgentRuntime.GetSnapshot`).
|
||||
|
||||
- Rules out: a neutral state for a computer that cannot print for any server.
|
||||
- Not built yet.
|
||||
|
||||
**G15. The tray tooltip.** The worst health, the count in each state, as many servers with their state as fit, and "+N more" for the rest; the tray menu lists every server. The plan counts 127 characters as what Windows shows of a tooltip, against eight names of up to 64 units; the number was not checked against Windows here.
|
||||
|
||||
- Rules out: a tooltip that names every server whatever their number and the length of their names.
|
||||
- Not built yet.
|
||||
|
||||
**G16. How the operator orders the servers.** Move up and Move down, in the server's menu and with Alt+Up and Alt+Down. Each sends the whole new order (`servers.reorder`). There is no drag and drop.
|
||||
|
||||
- Rules out: drag and drop as the only way to reorder, which a keyboard cannot do.
|
||||
- Not built yet.
|
||||
|
||||
**G17. Identifiers of rows.** A printer row is `Printer.<n>` and a job row `Job.<n>`, by position, each with an accessible name. The controls inside a row are found through the row.
|
||||
|
||||
- Rules out: an identifier made from a printer's or a job's own identifier, and inner identifiers that repeat with no row to find them through (audit U10).
|
||||
- Not built yet.
|
||||
|
||||
**G18. What "accessible" is measured against.** The KofTwentyTwo standards have no rule that says what it is, so the table under "What the re-audit scores accessibility against" below is the definition.
|
||||
|
||||
- Rules out: scoring the criterion "Accessibility and UX consistency" against a reviewer's own list.
|
||||
- Not built yet: none of the tests the table names exists.
|
||||
|
||||
**G19. How the interface is driven against a service that is not the real one.** The UI tests start the layout host, which compiles the product's own views into a test program (`tests/Qqq.PrintAgent.LayoutTests`), against a scripted version 2 service. The product gets no setting that points it at another pipe.
|
||||
|
||||
- Also taken under the standing instruction, and not built: the layout test project is added to the solution in the step that builds the interactive suites (plan step 15 of the wizard and tree build), so that every pull request builds it from then on (the plan names `Qqq.PrintAgent.slnx`, which is the solution `build/Verify.ps1` builds). The plan had left this half to the owner: it recommends it and calls it "the owner's call", because the project is outside the solution on purpose (neither solution file lists it, read at `02f34c3`). It was taken on the owner's behalf all the same, and the owner may reverse it before that step; it is listed for the owner in [TODO](../TODO.md). Nothing changes in either solution file until then, and until then each step that touches the layout tests builds them by hand and says so in its pull request.
|
||||
- Rules out: a test route into the production transport.
|
||||
- Not built yet.
|
||||
|
||||
**G20. Text a server influenced, in rows and nodes.** Audit finding W35 is to be closed in the contract. Until it is, the presentation assembly puts a status text, a job title and a job message through the contract's own printable form before it reaches a row or a node.
|
||||
|
||||
- Rules out: showing such text as it was received.
|
||||
- Built in part by pull request 51: wherever a reply names an account, the principal, the tenant and the message beside them are printable and bounded (`ServerAuthentication`; W35 has what is still open).
|
||||
- Built with the wizard's Server step: the contract's rule can be called from the presentation assembly (`ManagementText.Bounded` is public; when this entry was first written, on 2026-11-11, `ManagementText` was internal to the Windows assembly and could not be). The Server step puts a check stage's reasons and a refused certificate's subject, issuer and fingerprint through it before they are shown.
|
||||
- Not built yet: the rows and the nodes (a status text, a job title, a job message), and the same treatment for the other texts W35 lists.
|
||||
|
||||
**G21. Logging by template in the presentation assembly.** The presentation log takes a message template and its values, and new presentation code logs only that way (K22-CODE-CS-10). The older types, which join strings into a finished message, are not rewritten; they go when the window stops using them.
|
||||
|
||||
- Rules out: new code that builds a log message by joining strings.
|
||||
- Built by pull request 53: `IPresentationLog` has the template form, and `CompanionSignIn`, `BrowserCallbackDispatcher` and `ProviderSignOut` log through it.
|
||||
- Not built yet: the older types. The audit records them as U27.
|
||||
|
||||
Smaller points the plan settled with these, none of them built except where it says so:
|
||||
|
||||
- The step indicator of a repair counts only the steps of that run.
|
||||
- The sign-in methods a step shows are those that the service's check and the companion's own read both found. Built by pull request 53 (`CompanionSignIn`), which logs a difference.
|
||||
- The first page selected after an upgrade is the first server's Printers page, because that is where 1.x opened.
|
||||
|
||||
The plan has a fourth, what a server's overview shows, for which it points at the first test of its own step 9. It is not recorded here and is left to the step that builds the overview.
|
||||
|
||||
### Accepted limits
|
||||
|
||||
Recorded as limits of the design, not as work to do:
|
||||
|
||||
- The companion cannot show that another client is changing a server until one of its own requests is refused with `busy`. The snapshot does not say it.
|
||||
- `servers.check` returns all five stages together, so step 1 shows no progress from stage to stage while the check runs.
|
||||
- A check that the operator cancelled runs on in the service for up to 120 seconds, the limit of a long operation, and holds one of the pipe's sixteen instances while it does.
|
||||
- Two companions in two Windows sessions can together ask for more than sixteen instances. Their reads are then skipped until their next turn.
|
||||
|
||||
### What the re-audit scores accessibility against
|
||||
|
||||
This is the definition G18 refers to. The [baseline audit](../AUDIT-2026-11-10.md) scores the companion on "Accessibility and UX consistency", and that criterion cannot be 9 or better with a Medium finding open against it. The plan names U9, U10 and U11 as the findings that therefore have to close; which criterion a finding counts against is for the re-audit's reviewer to say. The standards give it no content of their own: a search of `standards/`, `architecture/` and `policies/` in the standards repository at revision `707bbd33f0b9`, repeated on 2026-11-11, finds accessibility in one place, K22-AGENT-20, which tells an agent to preserve it when changing code and does not say what it is. What binds is "Accessibility" above and this section. The reference architecture for a Windows desktop application adds where the proof has to live: logic that can be tested without a window is outside the UI project and under the coverage gate (its section 2, with K22-TEST-10), and the window itself is tested end to end through UI Automation (its section 8).
|
||||
|
||||
What is to be built:
|
||||
|
||||
- **Tree.** The arrow keys move, expand and collapse; Home and End go to the first and the last node; Enter selects; the Menu key or Shift+F10 opens a server's menu; F2 renames; Delete removes, after the prompt; Alt+Up and Alt+Down reorder; F6 moves between the tree, the page and the footer. A server node's accessible name is the server's name, its health in words, and "certificate exception on" or "being removed" when that is so. Health is a glyph and a text, never color alone.
|
||||
- **Wizard.** The step's title says "Step n of 5" and the step's name. Enter is the step's primary action and Escape is Cancel; inside the certificate warning, Escape closes the warning only. When the step changes, focus goes to the step's first input; after a result, to the place the view model names. A result is announced once.
|
||||
- **Rows.** Every printer row and every job row has an accessible name, and its identifier is its position (G17).
|
||||
|
||||
How each requirement is shown. A requirement counts as shown only when both columns are: its gated test exists and passes on the commit the re-audit reads, and its interactive run is recorded in [ACCEPTANCE.md](../ACCEPTANCE.md) for a build of that commit. A row with no recorded run is not shown, and the finding it answers stays open. The middle column is in the presentation tests, which `build/Verify.ps1` runs and gates at 100% line coverage. The right column is the interactive suite, which the gate does not run; its runs are made on the interactive desktop and are to be recorded in ACCEPTANCE.md. The middle column names one test class, `XamlContractTests`, in two rows, and describes the tests of the other four rows without naming them. `XamlContractTests` exists since the identifier baseline (plan step 11), in `tests/Qqq.PrintAgent.Presentation.Tests/Views/`, and the three tests the plan names for it are in it and pass: `EveryInteractiveControl_HasAnIdentifier`, `Identifiers_AreTheRecordedSet` and `StatusTexts_DeclareALiveRegion`. They read the views as they stand before the tree and the wizard, so they hold the row "Stable identifiers" for today's window, and the row "Live regions declared" for it with one exception: that row asks message bars to declare a live setting, and the tests hold the connection dialog's message bar to declaring none, which is how it stands today (audit U28, to be decided where the views are rewritten). Both rows are shown for the new views only when the same tests pass on them. Of the types the other four rows are to test, `ServerTree` and `HealthBadge` do not exist yet (a whole-word search of `src` and `tests` finds neither), so the rows about the tree have no test. `Announcement`, `PrimaryAction`, `CancelAction` and `FocusTarget` exist since the wizard's frame and its Server step (pull request 56), and that step's tests cover them for the Server step only: whether they are enough to show the rows about announcements and the keyboard for the wizard is for the re-audit to judge, and no row has them for the tree or for the wizard's other steps. A name may change when its test is written, and what the row requires does not. The right column names each run and not what the run must observe to pass or to fail; that is to be defined, row by row, when the step that makes the runs (plan step 15) is written, and it is not defined here.
|
||||
|
||||
| Requirement | In the presentation tests (gated) | Interactive (recorded) |
|
||||
| --- | --- | --- |
|
||||
| Stable identifiers | Every interactive control in the companion's XAML, and every identifier set from code, has an identifier, and the set equals a recorded list, so a rename shows as a difference (`XamlContractTests`) | UI Automation finds each control by its identifier |
|
||||
| `Server.<n>` and names | Server identifiers count from one and are renumbered after a reorder and after a removal; every node, row and step has a name that is not empty | The tree is a UI Automation tree with tree items |
|
||||
| Not color alone | Every health has a glyph name and a text, different for each level, the unknown level included | A run in a high-contrast theme |
|
||||
| Announced once, after the text is final | The number of announcements, and the text at the moment of each, for the footer and for every result of the wizard | Live-region events counted through UI Automation |
|
||||
| Keyboard | The primary action and the cancel action of every state; every command has an access key that is unique in its menu, or a gesture; the focus target of every state | A walk of add, repair and remove with the keyboard only |
|
||||
| Live regions declared | Status texts and message bars declare a live setting in the XAML (`XamlContractTests`) | A pass with Narrator |
|
||||
|
||||
## Resolved while reviewing the management client, 2026-11-11
|
||||
|
||||
The companion's client for version 2 of the pipe (`ManagementClient`, the presentation assembly) was reviewed independently before anything was built on it. Two findings needed a decision. Both were decided on 2026-11-11 by the coordinator of the 2.0 work, following the reviewer's recommended direction; they are not the owner's own words, and the owner can overrule them. The first reverses what the client did until then.
|
||||
|
||||
### A version mismatch is not proof that nothing was carried out
|
||||
|
||||
- What the client did: a reply that was not JSON, or was JSON without a version, was reported as "an older service answered", and a reply in another version as "a newer service answered". In both cases the result said that nothing was carried out and that there was nothing to check before repeating.
|
||||
- What holds. A version 1 service does nothing with a version 2 request. The review ran it: all 30 golden version 2 requests, sent to this branch's version 1 runtime on a private pipe, were each answered `Error: Unknown command.`, and the printer backend was never called. For the released 1.x handlers (`v1.0.0`, `v1.2.1`) the same was read, not run. A newer service that keeps the contract answers in another version only to refuse the version.
|
||||
- What does not hold. The client sees only the form of the reply. Text that is not JSON is also what any answer outside the envelope looks like: the pipe loop the 2.0 service grows from answers every exception, its own time limit included, as `Error: ...` text, and if one path of the version 2 dispatcher reaches that after an operation acted, the reply reads as "older service". A reply in another version need not be the refusal of the version: `{"v":"2","ok":true,"result":{}}` says the operation succeeded. And JSON without a version is not what a version 1 service answers a version 2 request with (30 of 30 answers were not JSON; it answers requests of its own version in JSON, a snapshot for one, and the client sends none of those), so its sender is not known.
|
||||
- The decision.
|
||||
- The two failure values stay (`OlderService`, `NewerService`), and each still names the side to update.
|
||||
- For an operation the contract's operation table calls a read, the result is not unconfirmed, as before: a read changes nothing whoever answered it.
|
||||
- For every other operation the result is unconfirmed, and its message says so: which side to update, and that the operation may have been carried out and must be checked before it is repeated. No message says or implies that nothing was carried out.
|
||||
- JSON without a version is an unreadable reply (unconfirmed for every operation), not an older service.
|
||||
- Text that is not JSON stays "older service", with the rule above deciding whether there is something to check.
|
||||
- Why this direction. With a real version mismatch every operation fails anyway, so reporting "check before repeating" costs the operator one look. The other direction told the operator to update one side and repeat a test print, a retry, an add, an enrollment or a sign-in that may have happened.
|
||||
- What it means for the wizard and the pages. For an operation that is not a read, `OlderService` and `NewerService` are no longer among the failures after which nothing happened. They are treated as any result that is unconfirmed is treated, with the side to update named as well: after `servers.add` that is the state for an add whose outcome is not known ("Check" takes a snapshot), and after `pairing.enroll` the state for an enrollment whose outcome is not known. Whether there is something to check is read from the result's "unconfirmed" value, never from the failure value alone.
|
||||
- What it asks of the service. No version 2 request may ever be answered outside the envelope, because the client reads any answer that is not JSON as an older service. The step that builds the version 2 pipe server needs a test of exactly that (recorded in `docs/TODO.md`).
|
||||
|
||||
### A snapshot that is known to be newer than the caller's last change
|
||||
|
||||
- What the client did: the snapshot had only the behaviour the refresh timer needs. A call made while a snapshot was in flight shared that one's answer, and with twelve requests in flight the call was skipped.
|
||||
- What was wrong with that. A caller that reads the state to learn the outcome of a change could be given a snapshot the service had already answered from before the change, or none. The wizard's "Check" after an add whose outcome is not known ("nothing was stored") and after `enrollment_unconfirmed` ("a new code is offered only after a check found no pairing") are such callers, and a wrong answer there offers a repeat of the change. A page refreshed straight after a change could show the state from before it until the next tick. The version 1 client shares no answers, so it does not have this.
|
||||
- The decision. The timer's behaviour stays the default: single-flight, and skipped at the ceiling. A caller can ask instead for a snapshot that is sent after its call (`SnapshotAsync(sentAfterTheCall: true)`). That call is never answered from a snapshot sent earlier: it waits out one that is in flight and then sends another. Callers that wait together may share the one that is sent after they all asked. At the ceiling it waits its turn and is not skipped. A caller that leaves while it waits has sent nothing. One snapshot is at the pipe at a time in either case.
|
||||
- Who uses it. Every read that decides something about a change the same caller just made or could not confirm: both "Check" actions of the wizard, and a page's refresh after its own change. The timers keep the default.
|
||||
- What a result says about when it was sent. Added on 2026-11-11 with the shell and tree, on the conditions the client's second review named. The client numbers its snapshots as it hands them to the pipe. A snapshot's result carries the number of the request that made it (`Flight`). The request puts the number on the result it makes, so everyone who shared that request reads the same number, and a result that no request made (a skipped read, nothing sent, a wait that ended) has none. The result of every other request that was handed to the pipe carries how many snapshots had been handed to the pipe when that result was made (`SnapshotsSent`): the count is read after the reply arrived, or after the failure or the end of the caller's wait was known, and never before the request was sent. A snapshot with a higher number was sent after that. The number says "sent after", not "shows the change". It is one client's count, starts again with the companion and means nothing between two clients; the transport reads are numbered apart. No operation is sent, shared, skipped or answered differently for it. How the shell uses the two numbers is under "Polling and local changes" below.
|
||||
|
||||
### Smaller points settled in the same review
|
||||
|
||||
- **A check that failed is not the one the operator went on from.** Next is enabled only when all five stages pass, so `servers.add` is never rightly sent with a failed check. The client refuses one before anything is sent (it would otherwise state a provider, or none, that the operator did not accept). `trust.set` is not held to that: a server whose own certificate is refused is the one an exception is switched on for, and whether the check of an existing server can pass before the exception is on is for the service step to say. What the check argument does not guarantee, for either operation: the result is a public record, so one changed after the service returned it is sent as changed, and it does not name the address it was for, so a check of another server that names the same provider is not detected. The wizard and the Connection page have to keep each check with the address it was made for (G1, G2).
|
||||
- **An address is passed on as the origin that was judged.** The client's two methods that take a server's address take the origin `ServerAddress.TryRead` returned, not the text the operator typed, and the request carries it as scheme, host and port (G3).
|
||||
- **What the contract refuses is not sent, and the client adds one rule of its own.** A request is put to the contract's own check and its arguments are read back by the contract's own reader. Values those two accept are sent, whatever the service will say of them. The one rule the client adds is about the check the operator saw (the first point of this list): `servers.add`, and a `trust.set` that switches the exception on, are refused without it, and `servers.add` is refused with one that failed. The contract's reader would accept each of those requests.
|
||||
- **A reply is read only for what was asked.** A rename answered with another server's summary, an add answered with another address, a reorder answered with other servers and job details for another job are unreadable replies, so unconfirmed. The order a reorder's reply lists the servers in is not compared.
|
||||
- **Text in a result reaches the view models as it arrived.** Only a refusal's message is already printable. The printable form of G20 is applied by the rows and the nodes.
|
||||
|
||||
## Resolved while building the wizard, 2026-11-11
|
||||
|
||||
Decided while building the wizard's frame and its Server step in the presentation assembly, where the text above left a choice open, and corrected after the independent review of that work. Each takes the option that is safest for the operator.
|
||||
|
||||
- **An acceptance is for one check.** The certificate exception stays accepted only while a check that passed under it is on screen. A check under it that does not pass (a failed stage, a refusal, no answer), an edit of the address and the operator's own choice all forget it, and the warning is shown again before it can be used again. An exception therefore never applies to a check the operator did not just confirm.
|
||||
- **No state of the exception outlives the result it belongs to.** What an accepted warning said of the sign-in provider goes with the one check that runs under it and is kept only with that check's pass. A warning that is open says what the check on screen reported, and nothing remembered from an earlier warning, whether that one was accepted, declined or left open. Only accepting a warning makes a check run under the exception.
|
||||
- **A result on screen is about the address in the box.** Editing the address removes the result and stops the wait for a check that is running. A check is sent for the address that is in the box when it is sent: one whose address was edited while it was being started is given up before anything is sent, and it is not sent for the new address either, because the operator asked for a check of the old one.
|
||||
- **One warning that names both addresses skips the review only for the provider it named.** When the failed check already reported the sign-in provider, one warning names both addresses and the review is skipped, as step 1 says. When the re-check under the exception then names another provider, or none, the step shows what the exception now covers and asks the operator to look before going on.
|
||||
- **Who says what a sign-in provider's address is.** The contract, and nobody else. The wizard reads the provider a check reported with the rule the arguments of `servers.add` and `trust.set` are held to (`SignInProviderOrigin.TryRead`, made public for this), and shows it in the form that rule compares. Text the contract refuses is "not known yet" in the warning, and makes a check that says "passed" an answer that cannot be used. An IPv6 literal with a zone is such text: the form an origin is shown in has no place for the zone, so showing it would show another address than the one reported. The address that presented a refused certificate is treated the same way and is left out. The Sign in step takes the provider for the companion's own session from the same function.
|
||||
- **A refusal that comes back under the exception.** The step says that the exception does not cover what Windows refused, drops the acceptance, and does not offer the exception again for that result, even when the reasons still include an untrusted authority.
|
||||
- **The root certificate is named only where it can help.** Step 1 above calls installing the authority's root certificate the lasting fix for a certificate failure. That holds for one reason: an authority Windows does not trust. For a certificate issued for another name the step says to enter the address the certificate was issued for or to ask the server's administrator for one issued for this address; for no certificate, and for a reason this version does not know or the service did not give, it says to ask the server's administrator. None of these mentions the computer's trust store. With more than one reason each gets its own sentence, and the root is said to be the fix for the untrusted authority only.
|
||||
- **What counts as passed.** Next is enabled only when the service answered the check as passed, the contract's own rule finds no failure in the result, all five stages this version knows are reported as passed and none is reported twice, the sign-in provider it names is one the contract takes for an origin or is absent, and at least one sign-in method on offer is one this version knows. Anything else is shown as not passed.
|
||||
- **What a row shows.** A stage this version knows whose outcome is missing, or is one this version does not know, is shown as "not known", never as passed. A stage this version does not know is not shown, as the contract asks of a client. A stage reported more than once is shown with the worst of its outcomes (failed, then not known, then not reached, then passed) and the reasons of every entry. A row shows at most five reasons and then says how many more there are.
|
||||
- **The result that is kept.** The Server step keeps the result of the check whose rows are on screen while that check counts as passed, exactly as the client returned it, and no other; it is what adding the server is given. It is kept also while the typed name breaks the name rule, which only keeps Next from being used. What is sent, shown and logged as the address is the origin the contract's address rule returned, never the text as typed.
|
||||
- **A pass while the name breaks the name rule.** The step does not say "Choose Next", which cannot be used. The sentence ends with what is wrong with the name, the focus goes to the name, and the result is shown as a warning.
|
||||
- **An address that is already a server.** The step says so whatever the stages say, and offers to open that server when the service named it by a full identifier. Otherwise it says so and offers nothing to open.
|
||||
- **Enter is never silent on the Server step.** Enter is Check, then Next. After a check that did not pass, Enter checks again. Where Next cannot be used (the name breaks the name rule, the address is already a server) and while a check is running, Enter says why. Escape cancels the wizard, and inside the certificate warning it closes the warning only.
|
||||
- **What is announced.** A check says two things, each once: that it is checking, when it starts, and its result, when the state, the stages and the buttons are already in place. Editing the address and changing step clear what was said. Back to the Server step says its result again, the exception and the addresses it covers among it, with the focus on Next.
|
||||
- **No answer.** Whether a check may have run is taken from the client's own word for the result. Only a request that never left is said to have checked nothing. A service of another version is told as that, with which side to update, and as "the check could not be made": that the service is of another version is read from the shape of its reply, so what it did with the request is not claimed.
|
||||
- **Update and busy.** `update_in_progress` is told as what the service says: it is prepared for an update, which may still be running or may have stopped. `busy` is told as the service making another change, without saying who asked for it.
|
||||
- **Stored data not ready.** The wizard does not start for adding a server without a snapshot, while the stored data is not ready, or at eight servers, counting one that is being removed; the tree's Add server item shows the same sentence. When the service answers a check with `state_newer` or `migration_incomplete`, the wizard says so once and ends on the Computer page.
|
||||
- **Once the run has ended.** Nothing the wizard is asked for does anything after the run has ended or the wizard was disposed, handing over to another step included, and what the Server step offers does nothing on another step. Ending the run and disposing the wizard free the wizard's key of the window's gate there and then; the request that was running ends by itself and its answer is not shown.
|
||||
- **Waiting is the frame's.** Holding the gate, the wait, the rule for an answer that comes too late and the busy state belong to the frame, so the later steps wait the way the Server step does.
|
||||
- **Modes.** The frame knows the three modes. So far a run can only be started for adding a server; the entry points for a repair and for pairing come with the steps that build them, so no mode exists that shows a step with nothing in it by another route than going on from step 1.
|
||||
- **The contract's printable form.** `ManagementText.Bounded` is public, so the companion gives the members that are plain text on the wire (a stage's reasons, a refused certificate's subject, issuer and fingerprint) the form the contract defines, where it shows them. A refusal's message already has that form and is not changed again.
|
||||
- **What is said once carries its sentence.** `Announcement.Raised` hands the sentence to its listeners, so a listener that announces something more from inside the event does not change what the listeners after it hear. `OperationGate.Changed` is raised on the thread that entered or released the key and can be out of date when it is heard; a listener takes it as "this key changed" and asks the gate.
|
||||
|
||||
## Resolved while building the shell and tree, 2026-11-11
|
||||
|
||||
Taken under the standing instruction while the models of the tree, the footer and the tray were built in the presentation assembly (`Shell/`). Where this record or the plan left a choice open, the option that is safest for the operator was taken. Nothing here is on screen yet: the window is rewritten on these models in a later step.
|
||||
|
||||
**Health**
|
||||
|
||||
- The worst health across servers is red, then unknown, then orange, then green. Unknown outranks orange and green because it is not known to be fine, and it is below red because red is known to need the operator.
|
||||
- A server's health is unknown when the service reports a word this build does not know, and for every server while the service is not answering: what the last snapshot said may no longer be true, so the node says "Not known while the local print service is not answering" and does not go on showing green. Before the first read has ended the footer and the tray are unknown too, where 1.x started red.
|
||||
- With no server the footer and the tray are red and say "Add a server to start printing".
|
||||
- The tray needs a fourth icon for unknown (`tray-unknown.png` beside the three there are); the model names it "unknown". It is added with the window.
|
||||
|
||||
**What a server says**
|
||||
|
||||
- The sentence beside a server's name follows the footer rule of 1.x for a paired server: the health's own sentence, replaced by "Dispatch disconnected by administrator" or "Dispatch paused by administrator", and by the missing authorization before either. Three states 1.x did not have come first: "Being removed", "Stored data for this server cannot be read", "Not paired".
|
||||
- No sentence says more than the snapshot. The contract's orange is wider than 1.x's ("working toward a connection, or held by the administrator"), so the 1.x sentence "Connected · printers need attention" is used only when the snapshot shows both: the transport is connected, and nothing is shared or a shared printer is not ready or is gone from Windows. Otherwise an orange server says "Waiting for the server connection" or "Not ready · see Connection".
|
||||
- With one server the footer is that server's sentence. For a paired server whose service answers, in the states 1.x had (each health, the administrator's pause and disconnection, missing authorization), that is the sentence the 1.x footer showed; a test gives the same inputs to both. It is not the same everywhere: what an upgraded operator reads differently is listed under "What an operator upgrading from 1.x reads differently" below.
|
||||
- Beside the name, a server that is not authorized to print says what the snapshot shows to be missing. The snapshot carries `signedIn` and `authorized` separately. When nobody is signed in, a sign-in is exactly what is missing, and the node says "Sign in required". When somebody is signed in and the session is not authorized, signing in again may not help, and the node says "Authorization required". Alone in the footer both read "<server name> authorization required": the 1.x words, with the server's name where 1.x had the edition's.
|
||||
- A server that is being removed counts in the footer with the health the service reports for it, as it counts toward the eight.
|
||||
- The footer's link opens the first server in the worst state; while every server is green it opens nothing; with no server it opens the wizard; while the service is not available it opens the Computer page.
|
||||
|
||||
**The tree**
|
||||
|
||||
- A new server's node is expanded. Selecting one of a server's pages expands the server. Expansion otherwise stays as the operator left it.
|
||||
- When the server whose page is on screen goes, the neighbour's overview is shown (the server after it, or the one before it when it was last), not the neighbour's page of the same kind, and a message says which server went: an operator must not find themselves on another server's Jobs or Connection page without being told. When no server is left the wizard is shown if the stored data is ready, and the Computer page if not.
|
||||
- That a server went is said only when the snapshots show it. A snapshot whose stored data is ready lists every server, so a server it no longer lists has been removed. While the stored data is not ready the service lists no server and has removed none (ADR-0004): the Computer page is then shown with a message that says the server is not listed and why, and neither the message nor the log says that it was removed. For the same reason the refusal of a wizard for a server the tree does not have says that the server is not in the window's list and may have been removed: where that is said, it is not known whether the server was ever listed.
|
||||
- A snapshot entry whose identifier is not a server identifier, or repeats another's, is left out and logged once; the contract promises neither can happen.
|
||||
- The identifiers are the plan's, and they agree with the ones the window has today: `NavigatePrinters`, `NavigateJobs`, `NavigateLogs`, `NavigateSettings` exist; `NavigateComputer`, `NavigateConnection`, `AddServer` and `Server.<n>` are new.
|
||||
- A command of a server's menu is not disabled while another command runs on that server. Choosing it is refused out loud with the sentence of the command that is running.
|
||||
|
||||
**Which page is shown**
|
||||
|
||||
- Before the service has answered, the Computer page. When it first answers with its stored data ready, the window lands once: on the first server's Printers page (where 1.x opened), or on the wizard when there is no server. It does not land after the operator has chosen a page.
|
||||
- The Add server node stays enabled while a wizard is open, whatever else is true, and says "in progress": the wizard can itself bring the computer to eight servers, and the operator must be able to get back to it.
|
||||
- The shell asks for the wizard through an interface of three calls (add, repair, pair) and is told when the wizard has ended. If the wizard ends naming a server no snapshot lists yet, the Computer page stands in and the server is shown when a snapshot lists it.
|
||||
- Going back to an open wizard through the Add server node only shows its page: the launcher is not asked again, so a wizard in progress is never restarted by a click on "Add server, in progress". The launcher is asked when the operator asks for the wizard that adds a server in so many words, which can be while another wizard is open (its owner decides what becomes of that one), and when the computer has no server and no wizard is open.
|
||||
- A repair and a pairing are not held to the limit of eight servers. The limit is on adding one, and an operator at the limit must still be able to sign in again.
|
||||
- A wizard is recorded as open before its page is selected, so a view that passes every selection of its tree on to the shell cannot cause a second request.
|
||||
- The shell never calls the launcher from inside `WizardEnded`. When a wizard ends with nothing to show on a computer with no server, the wizard's page stays on screen with no wizard open, and what happens next is decided when the next snapshot has been read: a new wizard, the first server's Printers page when the snapshot lists a server, or the Computer page when the service has stopped answering. The operator who chooses Add server before that gets a wizard at once. A wizard that ended with a server on this computer, one it added or one it left in place, passes that server's page, also when no snapshot lists the server yet.
|
||||
|
||||
**Polling and local changes**
|
||||
|
||||
- Each of the two reads never overlaps itself: a tick while its read is in flight starts nothing. The two are independent of each other, as in 1.x, so a slow snapshot does not stop the meter.
|
||||
- "A local change" is a command starting and a command ending under the server's key in the gate, whoever entered it, and a change that was made without the gate and is noted with its own result. What a snapshot says about a server that was changed is shown only when the snapshot's request was sent after the change was answered. A snapshot sent before a command began is not shown for that server. A snapshot sent while a command runs is shown while it runs, so a server that is busy for minutes (a browser sign-in) keeps refreshing; once the command has ended, only a snapshot sent after its end is shown.
|
||||
- A change under the computer's key (its details, a reorder) holds back the snapshot's computer part and the order of the list in the same way.
|
||||
- When a snapshot was sent is the client's to say, not the shell's. The shell is not the only caller of the client's snapshot: the wizard has its own in flight, and the client hands a tick the snapshot that is in the air, whoever sent it. A mark the shell took when it asked would be wrong for such a snapshot, and the changed server's old entry would be shown for one more tick. So the shell takes no mark. A snapshot's result carries the number the client gave its request when it was handed to the pipe, and a key's stamp is the client's count of the snapshots handed to the pipe so far. An entry is shown only when the snapshot's number is strictly greater than the key's stamp: the snapshot with the stamp's own number was already on its way when the stamp was taken.
|
||||
- A stamp is taken after the change was answered, never before it was sent. A count read before the change was sent would let through a snapshot that was sent before the change was carried out. For a command the stamp is read when its hold is given back, so a command gives its hold back after its answer has arrived and not before. For a change made without the gate the stamp is the count the change's own result carries, which the client read when the reply was read; `NoteChange` takes that result and nothing else, so a caller cannot pass a moment from before its change was answered. The stamp a command gets when it starts is a second one, read then: it holds back what was sent before the command began, and it is never the only one, because the stamp of the end follows. A stamp never goes back.
|
||||
- One limit is left, for the pages to observe. Between a command's answer and the moment it gives its hold back, the key still has the stamp of the command's start, so a snapshot that was sent while the command ran is shown if it arrives in that gap. A command that gives its hold back straight after its answer has no such gap. A command that goes on waiting first (a page that asks for a snapshot after its change while it still holds its key) notes its result (`NoteChange`) as soon as it has it, which closes the gap.
|
||||
- A change whose outcome is not known has no moment at which it is known to have been carried out. Its result carries the count read when its outcome stopped being known (the pipe failed, the reply could not be read, the caller stopped waiting), and the key is held back until a snapshot that was sent after that. What that snapshot says is shown, whatever it says: the service may have made the change, not made it, or make it later, and the window shows what the service says and assumes nothing. Nothing the operator saw is put back by that, because a change whose outcome is not known shows no result of its own. If the change is made after all, a later snapshot shows it.
|
||||
- A changed server that such a snapshot no longer lists stays until a newer one arrives. So a snapshot that is not newer than a server's change never makes that server go, and "Server removed" is never said by one. A changed server the window does not show yet (the wizard has just added it) is left out until a snapshot that was sent after its change lists it.
|
||||
- The numbers are one client's. They start again with the companion and mean nothing between two clients, which is why the stamps are read from the client the shell reads its snapshots through. The transport reads are numbered apart and take no part in this. A result that no request made (a skipped read) has no number and changes nothing.
|
||||
- A page whose command has ended asks the shell for a snapshot that is newer than its change. The shell waits for the read it has in flight, whose entry for the changed server is not shown, and then asks the client for a snapshot that is sent after the call (`SnapshotAsync(sentAfterTheCall: true)`), through the one private method that names it. That snapshot is never one that was already in flight, whoever sent it, and it is never skipped: at the client's ceiling it waits its turn. Several callers that wait behind the same read share the one read that follows it, which is sent after each of their calls.
|
||||
- With each snapshot it shows, the shell says which servers' entries were held back (`SnapshotApplied`). For those servers the snapshot the window shows still holds the entry from before the change, the same object as before, so a page leaves what it shows of such a server as its own command left it. A server that was left out because the window had no entry for it yet is named too.
|
||||
- The gate can change on another thread than the window's, when a command's hold is released there. The shell does not marshal the change: it has no dispatcher, and a change that is posted would lag behind the gate, which the next command reads at once. On any thread but the one it was built on it leaves the tree alone and says so once in the log, without the holder's sentence. Every read then sets busy on every node from the gate, the Computer node and the Add server node included.
|
||||
- While the service is not answering, a server's commands stay enabled as the last snapshot left them. A command given then fails out loud with the client's reason, and a wizard is refused before it starts. This is recorded and left as it is: nothing is lost or done without a word.
|
||||
|
||||
**Messages**
|
||||
|
||||
- One rule holds for all messages: a message that has not been on screen is never replaced. A message has been on screen once its page was the one on screen and it was the message the bar showed. Each page keeps a short list, in the order of arrival. The bar shows the oldest, says how many more wait, and shows the next when the operator dismisses the one on screen. A message caused by a page that is not on screen (its command ended after the operator left) waits under that page and is shown when the operator opens it.
|
||||
- Leaving a page keeps the messages that were not on screen, whoever made the move. The message that was on screen is dropped when the operator made the move. When the shell made the move (it lands, follows a server the wizard added, shows a neighbour, is told that the wizard ended), the operator did not choose to leave and may not have read the bar: an error that was on screen then stays under its page, as a failure that has waited, and is shown when the operator next opens that page. A warning or less that was on screen is dropped by the same move, because a refusal ("Cannot start now") would otherwise be shown again when it is no longer true. The review round first dropped the message on screen for every move; the coordinator of the 2.0 work ruled on 2026-11-11 that an error must not be lost that way.
|
||||
- What arrives for the page on screen takes the place of the message the bar shows, so a result replaces the progress before it. There is one exception: a failure that had been waiting stays until the operator dismisses it or leaves, and what arrives meanwhile waits behind it. So the notice that a server went can never cover a failure that came on screen in the same moment, in whatever order the two are posted. The shell also keeps that notice under the page it is about to show before it moves the bar there, so it stands behind whatever was already waiting.
|
||||
- A page keeps at most eight messages. When one more arrives, a failure takes the place of the oldest waiting message that is not a failure; a message that is not a failure is not kept, and neither is a failure when all eight are failures. The page then carries a note of its own, where the first result was lost, that counts the results that were not kept and how many of them were failures, so no failure goes without a word. A failure is a message whose severity is a warning or an error.
|
||||
- A message caused by a page is never shown on another page, with one exception. A server that has gone has no page any more, so a failure kept under one of its pages could never be seen. Such failures are shown on the page the shell shows instead (the neighbour's overview, the page on screen when another page was on screen, or the wizard's page when no server is left), each saying which server it was about, and a failure reported for that server later is shown on the page on screen in the same way. What was waiting there and was not a failure is dropped, and so is a message on screen that was not an error. An error that was on screen on a page of the server that went is shown on the page the shell shows instead, as the failures that waited are: the shell took the operator away from it, and its page cannot be opened again. The window remembers the last 32 servers that went, with their names, for this; a failure reported for a server that went before those waits under its own page, where no page shows it.
|
||||
- While the stored data is not ready, messages that wait under a server's pages stay where they are. The server has not gone, and they are shown on its page when it is listed again.
|
||||
- Everything posted for a page that is not on screen waits, in order. A page therefore posts what is worth reading later: results, not progress.
|
||||
- The wizard's page shows no notice from elsewhere, including the one that says a server went. The failures of a server that has gone are the one exception, as above: when the last server goes, the page the shell shows instead is the wizard's, and losing a failure would be worse than showing it there.
|
||||
|
||||
**The tray's tooltip**
|
||||
|
||||
- With several servers it is the count in each state, worst first, then as many servers as fit whole, each with its health in words, then "+N more". The servers are named worst first, and servers in the same state in the tree's order, so the ones that need the operator are the ones named; the menu lists all of them in the tree's order. How many fit depends on the counts before them. With eight names of 64 characters none fits: the tooltip is then the counts alone, which say how many servers there are, and "+8 more" is not written, because after a count of eight servers it would read as eight further ones. The product's name ends the tooltip when there is room for it after everything else. With one server or none it is the footer's sentence and the product's name, which is the form of the 1.x tooltip; when both do not fit the product's name is left out before anything is cut.
|
||||
- Two of the tray's texts are equal when they say the same, so a view is told of a new tooltip or menu only when one of them changed, not on every tick.
|
||||
|
||||
**An update's pause**
|
||||
|
||||
- While printing is paused for an update, a computer that has servers says "Printing paused while an update is installed" in the footer and the tray, whatever its servers say. Its health is the worst of the servers' health and orange, so it is never green, and the footer's link opens the Computer page, which says until when the pause lasts. Each server's node keeps its own sentence. In 1.x the footer went on saying "Connected and ready" while the status line of the Settings page named the pause; the shell knows of the pause from the snapshot, so it says so where the operator looks.
|
||||
|
||||
**Commands and the Add server node**
|
||||
|
||||
- A server that is neither paired nor signed in is told to sign in when it is asked to reconnect, not to pair: pairing needs a session first.
|
||||
- While the Add server node is disabled, the name a screen reader reads says that it is unavailable and why ("Add server, unavailable: …"), from before the first answer on. The window has to keep the disabled node reachable by keyboard for that name to be read.
|
||||
|
||||
**What an operator upgrading from 1.x reads differently**
|
||||
|
||||
Checked against the 1.x window (`MainWindow.xaml`, `MainWindow.xaml.cs`), `ShellStatus` and the health rule of the 1.x service (`AgentRuntime.cs`). "The red sentence" is "Disconnected or needs attention".
|
||||
|
||||
| State | 1.x footer | This footer |
|
||||
| --- | --- | --- |
|
||||
| Before the first answer | Red, the red sentence. The window is built with "Checking local service…" in its footer and replaces it before it is shown | Unknown, "Checking local service…" |
|
||||
| Service not reached | The red sentence, and a contact line that says the data may be stale. "Local print service unavailable" was the status line of the Settings page | "Local print service unavailable" |
|
||||
| Service refused to report | The red sentence. "Local service needs attention" was the status line of the Settings page | "Local service needs attention" |
|
||||
| Reply unreadable | The red sentence | "Cannot read the local service's answer" |
|
||||
| Not authorized | "<edition> authorization required" | "<server name> authorization required" |
|
||||
| Not paired | "<edition> authorization required" when the service reported a sign-in status that was not authorized, and otherwise the red sentence | "Not paired" |
|
||||
| Stored state of one server unreadable | Not a state the 1.x snapshot reported; the footer followed the health word | "Stored data for this server cannot be read · see Connection" |
|
||||
| Being removed | Did not exist | "Being removed", with the health the service reports |
|
||||
| Orange, not connected | Could not occur: a 1.x service that is not connected is red | "Waiting for the server connection" |
|
||||
| Orange, connected, shared printers ready | Could not occur: orange meant connected with a printer issue | "Not ready · see Connection" |
|
||||
| Health word this build does not know | Red, the red sentence | Unknown, "State not known to this version · update the companion" |
|
||||
| Update's pause | The health's own sentence | "Printing paused while an update is installed", never green |
|
||||
|
||||
The tray differs too. The 1.x tooltip was the health's own sentence and the product's name, and the first line of its menu was the health's own sentence; neither was the footer's sentence. A computer whose administrator had paused dispatch went on saying what its health said there, for example "Connected and ready", while its footer said "Dispatch paused by administrator". The tooltip and the menu's first line now say what the footer says. That is more exact, and it is not what 1.x showed.
|
||||
|
||||
**For the step that joins the shell and the wizard (binding)**
|
||||
|
||||
- Until that step the rule "may a wizard start" exists twice: in the shell, which refuses a start out loud with a reason, and in the wizard's own branch, with different sentences and one different condition. The shell also refuses while the service is not answering, which the wizard, handed the last snapshot, cannot know. The shell's rule is the one. The step that joins the two drops the wizard's rule or has it delegate to the shell's, so that the operator reads one sentence for one reason, and corrects any comment that says the rule exists once.
|
||||
- The wizard's three endings map onto the one call the shell takes. Cancelled is `WizardEnded(null)`. Ending on the Computer page is `WizardEnded(PageKey.Computer)`. Ending on a server (added, repaired, paired or left in place) is `WizardEnded(PageKey.Server(id))`.
|
||||
- The launcher hands the wizard the shell's snapshot, which is the list both count against the limit, a server that is being removed included.
|
||||
- The wizard tells the shell of every change it makes. It changes servers without entering the gate under their keys, and it has snapshots of its own in flight, which the shell can be handed. As soon as the result of a change is there, the wizard's owner calls `NoteChange` on the shell's `Busy` with the key that changed and the result of the change itself, exactly as the client returned it, also when that result says the outcome is not known. The key is the server's identifier (for an add, the identifier the result names) and `OperationGate.Computer` for the computer's details and the order of the servers. Nothing else is passed: no count the wizard read itself and no result of another request, because only the change's own result says when the change was answered.
|
||||
- The wizard's own snapshots need no agreement with the shell. Whichever of them the shell is handed, the result says when it was sent.
|
||||
- `Announcement.Raised` carries its sentence. The footer's listener in the window reads the sentence from the event, as the wizard's listener does.
|
||||
@@ -8,14 +8,44 @@ The signed 1.0.1 release uses the realtime and native-authentication boundaries
|
||||
|
||||
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 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. The command line has no server transport: the `--legacy-compat` bearer diagnostics were removed in 2.0, and the code they used was deleted after them. No shipped code can send a device token outside a session. The printing API client takes no token and sets no Authorization header; it can be built only from a signed-in session, and every request it makes, a document download included, is sent through that session, which adds what authenticates it and refuses any address outside the printing extension's path. Inventory, job events and command receipts travel only over the authenticated realtime channel: with no channel they fail and are replayed after the reconnect, and nothing is sent over HTTP in their place. The command line keeps no credentials or journal of its own.
|
||||
|
||||
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.
|
||||
Printing has one path. A document reaches Windows only as a staged file whose length and SHA-256 were verified while it streamed: the worker hands the file's path to the print helper, which checks the file is a regular, absolute path and verifies its length and hash again before the spooler. The helper is the service's own executable started with `--printer-worker`, and it accepts five operations: three reads (discover every printer with its settings, list the printers without reading any settings, and find one printer with its settings), print a staged file by path, and cancel one spool job. The printer to find is named by its identifier, 24 hexadecimal digits in lower case; the service starts no helper for anything else, and the helper refuses anything else before it asks Windows. A print job causes the list, the lookup of its own printer and the print, so for a job the helper opens that job's queue and no other. A request is one line of at most 1 MiB characters (it holds a job's description and a path, a few kilobytes); the helper counts while it reads and refuses a longer one before it is held in memory. The service bounds the helper's answer at the same size, after reading it. The operation that carried a document inside the request, and the in-memory submission behind it, were deleted in 2.0, so no document travels in a message between the two processes.
|
||||
|
||||
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. The only other cookies the session client ever sends are a load balancer's affinity cookies (Application Load Balancer names beginning `AWSALB`), which select a server node and carry no identity: they are taken only from answers of the configured origin, the answer to the realtime upgrade included, accepted only when scoped to that host and the root path with a plain value of at most 4096 characters and a name of at most 64 (eight at most, a `Secure` one only over HTTPS), sent only to that origin, never to the identity provider, held in memory and never written to disk or to a log, which only counts them. They go with every request to that origin, a request that carries no credential included, so the compatibility check's two public requests may carry them once an earlier answer set them; they are still not a credential. A hostile server could set them, but it already controls every answer the client trusts; the bounds keep it from growing the request without limit. Cookies under any other name are neither stored nor sent, and no longer decide whether a session answer is valid. Passwords stay in the companion; provider refresh/access tokens remain server-side. The companion holds a session only while a sign-in attempt runs: it discards its copy as soon as the service confirms that it took the session over, and also when a hand-over that was sent could not be confirmed, so the service is the only process that keeps one. CLI operations use the existing authenticated service pipe. The enrollment code reaches the CLI only through an environment variable or standard input, never as an argument, and the CLI prints it nowhere. Typed at a Windows console it is read without echo; redirected input is read as a line, and that includes a terminal that hands native programs a pipe instead of a console (the default Git Bash window, for example), where the terminal itself may echo what is typed and the variable should be used instead. An unattended caller with a console nobody types at must set the variable or redirect input, or the command waits. A command line the CLI refuses is never echoed, because it can carry a misplaced enrollment code or a sign-in callback address. 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.
|
||||
|
||||
Sign-out also ends the identity provider's browser session. The companion sends only the fixed registered callback with `sign-out`; the service refuses any other value, asks QQQ to log out with `nativeLogout.postLogoutRedirectUri`, and returns the provider end-session request to the companion over the administrator-only pipe. That request carries the provider's ID token hint (sensitive, bounded to 32 KiB, no whitespace or control characters), so it is never logged, the record prints redacted, and only the provider host is logged. Core refuses the data unless the issuer equals the one QQQ advertises, the end-session endpoint is HTTPS on the issuer's origin and identical to the provider's own discovery, and the callback is the one requested. The browser return is accepted only on the exact callback with the state this sign-out generated. Browser sign-in also sends `max_age=0` beside `prompt=login`.
|
||||
|
||||
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.
|
||||
|
||||
Successful cancellation means Windows accepted the request, not that printed paper was recalled. Absent/completed jobs, ownership mismatch, failure, expiry and uncertain outcome remain distinct. Only a specific missing command receipt after expiry plus seven days retires its transport retry, preserving the exact local receipt and distinguishing retirement from server acknowledgement. Discovery/socket failures never use this exception. Acknowledged or transport-retired command history can be pruned after 30 days; unresolved current-connection evidence remains and causes explicit capacity backpressure. No global Windows queue purge is implemented.
|
||||
|
||||
## Claim boundary
|
||||
|
||||
A claimed job is untrusted input, and part of it is not even the server's. The options of a job are written by whoever submitted it and relayed by the server unchanged; the rest of the claim (identifiers, title, format, hash, length) is written by the server. The client therefore treats the whole claim as hostile and checks it before anything else handles it.
|
||||
|
||||
What is validated and bounded at the claim: the response is at most 2 MiB and is parsed to 1,024 levels of nesting; the job itself may nest to 64 levels counted from the response; job and printer identifiers are 1 to 200 ASCII letters, digits or `-`, `_`, `.`, `:`, so they are safe in a route and a log line (they are never used as file names, for which `:` would not be safe); the title is at most 1,024 code points; format and scale are tokens of at most 32 ASCII letters, digits or underscores; the content hash is 64 hexadecimal digits; the content length is 1 to 2,147,483,647; copies are 1 to 32,767; paper dimensions are complete, positive, at most 2,000 mm and never combined with a media profile. Creation time and status are only displayed and are blanked when missing or over-long. Fields the client does not read are ignored, as the contract requires of unknown fields. Text the client cannot read at all also fails these checks. The JSON parser accepts, as valid JSON, a surrogate escape without its partner (`\ud800` alone, `\udc00` alone, or the two the wrong way round) and bytes that are not UTF-8, and neither can be turned into text. A claim is refused when such text is a text member the client reads, or a member name in any object the client reads members from: the response beside the job, the job, its options or its media selection. Inside a field the client does not read it is ignored with the rest of that field. Whether a well-formed job may print (effective copies and size policy, the format the queue offers, the selected media still being current) is decided later by the worker, and the document is verified against the claimed length and SHA-256 while it streams.
|
||||
|
||||
A job that fails these checks but still names itself with one usable identifier is refused, not skipped: the worker records RECEIVED and then FAILED, delivers both, never downloads or submits the document, and goes on to the next job. The reason is fixed text chosen by the client ("The claimed job has no print options. No bytes were submitted."); nothing the server or the submitter supplied is repeated in it, so the reason of a refusal cannot carry text into the journal, the server's history or the companion. The refused job itself is recorded like any other: its title, up to 1,024 code points and otherwise unchecked, is kept in the stand-in, stored in the journal and shown in the companion's history, and its identifiers, format and creation time are kept when they pass the checks above. A title, printer identifier, format or creation time that cannot be read is left empty; nothing is substituted for it. A malformed replay of a job the journal already knows changes nothing.
|
||||
|
||||
The client matches member names without regard to letter case. A job that gives a member the client reads twice (in the job, its options or its media selection, for example `copies` and `Copies`) could therefore make the client act on a value the server did not mean, so such a job is refused as above. Names the client does not read may repeat freely, because nothing is decided from them.
|
||||
|
||||
Residual risk: a claim that cannot be identified cannot be refused. A response that is not JSON, is nested beyond 1,024 levels, names no usable job identifier (missing, malformed, or text that cannot be read), gives the identifier twice in the contract's spelling, or holds two job members names no single job; the lane stops with a logged error, the server offers the same job again on the next claim, and that device prints nothing further until the job is cancelled on the server. Of these only the nesting depends on a submitter. The parse depth of 1,024 is derived from the server's cap of 2,000 characters on a job's options: the server's JSON library sets no depth limit, the cheapest nesting (an array in an array) costs two characters a level, so about 1,000 levels fit in the cap, and the response, the job and the options object add three. A response nested past 1,024 therefore cannot be produced through the server's options cap as it stands. This closure rests on that cap: if the server raises it, or relays another submitter-controlled member, the depth must be revisited. The cloud queue preview is display only: it requires each entry to name its job and printer once with usable identifiers, tolerates members that are unknown or repeated, and prints nothing.
|
||||
|
||||
## Compatibility check
|
||||
|
||||
The connection wizard's check of a server runs before anyone has signed in, against an address an operator typed. It makes two public requests (discovery, then the sign-in description) and sends no credential with either: no session cookie, no device proof and no Authorization header, even when the client holds a session and a bound device. It follows no redirect. It never ends a held session, whatever the server answers; a 401 to one of its requests is reported as "asks for authorization" and the session is left alone, so a check cannot sign the service out. The caller's cancellation always wins over a failure that surfaces at the same moment.
|
||||
|
||||
Everything the check shows was sent by that unauthenticated server, so it is shown only in printable, bounded form: control characters, line and paragraph separators, bidirectional and zero-width format characters, private-use and unassigned code points become spaces, a value is cut at 80 UTF-16 units and never inside a surrogate pair, a problem's whole detail at 512, and the subject and issuer of a refused certificate at 512. The same applies to every line the session client logs that carries text from a response, a certificate or a browser callback: a value is cut at 80 units, longer text that an operator has to read or compare (an address, a listed callback, a principal, tenant or device identifier, an error description, a failure's message) at 512, and a server-supplied list at 32 values, so a server can neither forge a log line nor flood the log.
|
||||
|
||||
## Certificate exception
|
||||
|
||||
The development-only exception was one switch on the session client. Once on, that client accepted a certificate Windows does not trust from every host it contacted, which included the address the server gave for its sign-in provider's discovery document, wherever that was. It is now a set of at most two origins, fixed when the session client is built: the server's own, and the sign-in provider that server names. An untrusted certificate is accepted only for an HTTP request or a socket upgrade that is going to one of those origins. From any other host it is refused whatever the exception holds, the refusal is logged with the certificate and the origins the exception covers, and the compatibility check reports it. The set can be built only from well-formed HTTPS origins and cannot be widened afterwards. This closes the first half of audit finding C9. The second half stays open: the provider's discovery address is not yet required to be on the issuer's origin, so a discovery address on a third origin is now fetched under normal validation.
|
||||
|
||||
Where the two origins come from today. The service builds the exception from its saved setting and names the server's origin only. The companion's sign-in names the server's origin, reads the server's sign-in description under that, and, when the description names a provider on another origin, continues on a second session whose exception also names the provider, with a warning in the log naming both. The provider's origin is therefore taken from a server whose certificate was not verified, which is what the exception means for that server; it is not yet shown to the operator before it is used, recorded with the server, or held fixed when the server later names a different provider. Those are the multi-server steps that follow ([ADR-0004](../adr/0004-multi-server-runtime.md)): the service derives the provider's origin from its own read, stores it with the server, shows both origins in the warning, and never takes it from a client. Until then the service cannot prepare the provider's sign-out page when the provider has an untrusted certificate on another origin: the QQQ session is ended, the local sign-out completes, and the request fails at the provider's certificate.
|
||||
|
||||
The form of an origin. The provider's address is text the server wrote, and its origin is what an operator is shown before deciding to trust it. A host name can hold letters that look like others, characters that show as nothing and characters that end a line, so an origin is kept, compared and shown in one form only: scheme, the host's ASCII name and port. The ASCII name is the IDNA name the connection is really made to, so a look-alike letter appears as the `xn--` name it is, a zero-width character is not in the name, and a name written in Unicode and its `xn--` form are one origin, as they are for the session client's own same-origin rule. A host the platform cannot name in ASCII (one that holds a line or paragraph separator, or a name it does not treat as a domain name) is not an origin: it cannot be named in an exception, nothing covers it, the description's provider origin is reported as absent, and a certificate presented for it is refused with the host logged in printable form. The log line for every certificate decision names the host this way, because the address may be the provider's discovery address, which the server also wrote. So does every other line that names the provider's address: the grant that was accepted, the discovery document that is fetched and the request for it, what the provider answered, the authorization and end-session pages, and the companion's two lines for the page it opens in the browser. The host is formed for display in one place, and a described address is its scheme, that host, the port and the path, never a query, fragment or user information. Where such a line describes an address the server or its provider supplied as text, it says by kind what it left out ("(query omitted)") and never its content, which can be a secret; and a provider's authorization endpoint that is refused is refused by naming the rule it broke (it carries a query, it is on another origin), so the log alone says why an address that reads as usable was not used. The lines that reject a value quote it as it was written, made printable, because there the text itself is the point: an issuer that does not match another exactly (the provider's own against the one the server advertises, and the one a browser callback names against the one expected), and an issuer or a discovery address that fails the grant's checks, both in the log line that rejects the grant and in the compatibility problem an operator is shown. A host with a look-alike letter can therefore still appear as written in a rejection; it never does in a line that says what was accepted or used. A refused certificate is kept with the origin that presented it, in the same form, and the time on the session client's clock, so a caller can tell a refusal from the server from one from its provider; both are public information; the management contract's description of a refused certificate takes the address and the time from that record and accepts neither from a caller. The session client's same-origin rule is made on the same form: it refuses a request to another origin and holds a sign-in provider's authorization and end-session endpoints to the issuer's origin, and an address with no nameable origin is not the same origin as anything. Before, the platform's own exception for a host it cannot name escaped from those checks when a server put such a host in an issuer or an endpoint; it is now the ordinary refusal. The address of the provider's discovery document is the one such address the client connects to, and the platform throws when asked to connect to a host it cannot name. The grant is therefore usable only when its issuer and its discovery address each have an origin: otherwise sign-in and sign-out are refused as a configuration problem before any request for the provider is made, and a server check offers no browser sign-in. The server's own address is typed by the operator and is logged as typed where a line names the server; a line that describes a whole address (a request's route) names the host in the same ASCII form, and a line that says two addresses differ shows both in the form they were compared in (a request refused for another origin by the ASCII names, a saved session refused for another server as each was written).
|
||||
|
||||
## 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).
|
||||
@@ -36,12 +66,12 @@ The QQQ server supplies assigned document bytes over HTTPS. The Windows LocalSys
|
||||
| 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 |
|
||||
| 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 origin, and during the companion's sign-in from the origin of the sign-in provider that server names, until turned off; it accepts none from any other host, is persisted in the protected store, never applies to another server, and every session created under it is logged with a warning naming the origins it covers. See "Certificate exception" above |
|
||||
| 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.
|
||||
The service owns `connection.dpapi`, `journal.db` and its WAL. The journal carries a schema version, and only the service changes its schema: once, at start, in one transaction, before anything else opens it. A build that finds a version newer than it knows (an older release candidate installed over a newer one) does not open the journal for writing, contacts no server and reads no protected file; it serves its pipe so that an administrator can see why and install the newer build again. 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.
|
||||
|
||||
@@ -91,24 +121,28 @@ Unit/integration fixtures must cover fixed-origin/asset validation, channel/vers
|
||||
|
||||
The bounded request captures MSI hash/version and the exact parent companion PID/start time. The helper must be running from that staged copy, requires an administrator, and waits at most 30 seconds for the expected installed companion to exit; it never kills it. A verified MSI read handle disallows writes/deletion while Windows Installer runs. Installer arguments use structured `/i`, `/passive`, `/norestart` and a protected `/L*v` log path; the helper waits for Windows Installer without terminating it.
|
||||
|
||||
Exit 0 is accepted only when the actual installed companion ProductVersion matches the requested release, ignoring build metadata. Exit 3010 explicitly requires Windows restart and is not reported as fully active; cancellation and other failures are distinct. The helper atomically records a bounded, dated `updates/last-result.json`, writes the shared AppKit log, attempts to clear maintenance pause, and attempts to relaunch the fixed installed companion. The UI can read the last outcome after handoff, when the original coordinator no longer exists. If relaunch fails, protected result/install logs remain for diagnosis.
|
||||
Exit 0 is accepted only when the actual installed companion, service and command line tool each carry a ProductVersion that matches the requested release, ignoring build metadata, and the three carry the same version including its build metadata. Exit 3010 explicitly requires Windows restart and is not reported as fully active; cancellation and other failures are distinct. The helper atomically records a bounded, dated `updates/last-result.json`, writes the shared AppKit log, attempts to clear maintenance pause, and attempts to relaunch the fixed installed companion. The UI can read the last outcome after handoff, when the original coordinator no longer exists. If relaunch fails, protected result/install logs remain for diagnosis.
|
||||
|
||||
The runtime's update pause is bounded and keeps heartbeats alive; abandoned handoff does not permanently stop claims. Failed pre-handoff stages are cleaned by the adapter after path checks. Completed stages/helper copies/install logs are retained for administrator review; automatic update-stage retention is not claimed. Pure tests cover request/path/ACL/hash/exit/version policy without invoking an installer; the gate owner reported all 163 tests passing (36 Core + 127 Windows/CLI), locked restore and zero build warnings/errors. This includes synthetic helper tests, not real MSI application or installed UI acceptance.
|
||||
|
||||
From 2.0 the steps themselves run in tests, not only their pure rules. The staging checks, the preparation, the hand-off and the apply step (`MsiUpdateSteps`) run in temporary folders against fakes of the folder security and of the processes, and the companion's update flow (`MsiUpdateService`) runs over them with a fake release source and pipe. Each check that guards which file is run (who asks, location, links, owner and access list, size, extension, release, request fields, hash, the helper's own location, the companion's identity) has a test that shows the refusal and that no helper or installer was started; the installer's command line and the held MSI handle are observed at the moment of the start; exit 0 is accepted only when the installed companion, service and command line tool all carry the requested release; and resuming printing and reopening the companion are shown to be attempted whatever happened, with the outcome recorded whenever the helper has the rights to record it. The installer in those tests is a process of the fake. Nothing here starts Windows Installer, so real MSI application, the helper's file locks and installed UI acceptance remain separate qualification, as above.
|
||||
|
||||
### Metadata, history and deployment branding (0.1.5)
|
||||
|
||||
Local administrators can edit bounded device/printer presentation metadata through the existing protected management pipe. Physical queue identities and Windows destinations remain unchanged. Metadata is machine-local, synchronized under the existing scoped device credential, and displayed as text by the native QQQ UI. The management request limit is 64 Ki characters to accommodate JSON-escaped Unicode descriptions.
|
||||
|
||||
Job pages deserialize at most100 summaries, event delivery/recovery uses200-record batches and detail maintenance deletes at most250 eligible records per pass. Unacknowledged/uncertain evidence and deduplication summaries remain; this is bounded work per operation, not a bounded lifetime database. Diagnostic log clearing removes owned dated files only, with a shared write lock and reparse rejection; it cannot clear the print journal or credentials.
|
||||
|
||||
The optional branded-install wrapper requires an administrator, validates bounded local configuration/artwork and stages it under the protected service directory. The MSI uses public display-label properties and remembers them in HKLM; customer labels never select the executable, service identity, updater origin or privileged command. The shortcut helper validates fixed target/empty arguments/ownership before replacing or deleting a tracked shortcut. Customer artwork and strings do not change code-signing identity or source/license attribution. WhatIf validation and pure shortcut tests do not establish a completed branded MSI upgrade/rollback qualification.
|
||||
The optional branded-install wrapper requires an administrator, validates bounded local configuration/artwork and stages it under the protected service directory. The MSI uses public display-label properties and remembers them in HKLM; customer labels never select the executable, service identity, updater origin or privileged command. The shortcut helper validates fixed target/empty arguments/ownership before replacing or deleting a tracked shortcut. A file it cannot open is not judged and is therefore never replaced or deleted: it is left in place with a logged warning that carries the file name and the kind of the failure only, and no deletion is deferred to a restart. Customer artwork and strings do not change code-signing identity or source/license attribution. WhatIf validation and pure shortcut tests do not establish a completed branded MSI upgrade/rollback qualification.
|
||||
|
||||
Shared Start menu directories use a separate shortcut policy: deletion-only rights are allowed because they cannot create replacement content; untrusted writes, permission changes and ownership changes remain rejected. New shortcut folders have protected SYSTEM/Administrators full access and Users read/execute. Protected updater/data directories retain their stricter policy. MSI major-upgrade removal is now inside the install transaction; rollback behavior still requires installed qualification.
|
||||
Shared Start menu directories use a separate shortcut policy: deletion-only rights are allowed because they cannot create replacement content; untrusted writes, permission changes and ownership changes remain rejected. An install refuses to run against a shortcut folder that fails this policy. An uninstall does not refuse (decided 2026-11-11): it opens, changes and deletes nothing in such a folder, logs a warning and continues, because refusing protected nothing more and kept the product installed; shortcuts of the product may then remain there. New shortcut folders have protected SYSTEM/Administrators full access and Users read/execute. Protected updater/data directories retain their stricter policy. MSI major-upgrade removal is now inside the install transaction; rollback behavior still requires installed qualification.
|
||||
|
||||
### Local peer and pre-existing machine state (0.1.8 audit)
|
||||
|
||||
Every management client authenticates the connected pipe's process against the installed SCM service PID, LocalSystem identity and fixed installed executable before transmitting request bytes. Retained service/process/token handles and repeated identity checks limit PID-reuse races. Clients request only explicit read/write rights with Anonymous impersonation; the runtime retains one protected first-instance pipe across requests. A substitute endpoint must receive no enrollment code or other command data.
|
||||
|
||||
From 2.0 that decision (`AgentPipePeer`) is tested against a fake of the facts it rests on, once for each way the other end is not the service: a service that is not running, one hosted by another process than the one serving the pipe, a pipe served by no process, a process that is not SYSTEM or runs another program, a pipe or a service that names another process when asked a second time, and each fact that cannot be read. A check that could not be carried out is a refusal, and so is a process that cannot be looked at; only a pipe that can no longer say who serves it, or a process that is certainly gone, turns the answer into "the service stopped, try again". The readers of the real facts are tested against a pipe the test serves itself, the test process, a harmless child program and a read-only status query of a built-in Windows service. Two of the properties above are shown on the real readers: the process named for a pipe is the one that serves it and not the one connected to it (the test serves, a child program connects), and the process whose account was read stays held, so that its id still names it after it has ended, until the check lets it go. What the installed product's check is bound to (the Windows facts and the service program under Program Files) is a value the tests read. One thing no test without administrator rights can show, because every process such a test can open runs under the test's own account: that the account read is the peer's and not the caller's (audit W77). The client opens the print service with full access, which Windows grants only to an administrator, so a successful authentication against the installed service needs an elevated client and remains a native qualification requirement, and that requirement includes this.
|
||||
|
||||
Canonical machine state accepts only SYSTEM, Administrators and TrustedInstaller ownership and access grants. The initial validation covers existing descendants and rejects links, null DACLs and untrusted inherit-only grants. Existing hostile state is rejected rather than normalized and adopted. Store, log, branding, startup and updater entry points share the invariant; explicitly separate diagnostic directories remain outside machine-state adoption. Administrator changes after successful validation remain within the trusted boundary.
|
||||
|
||||
Direct MSI and branded installation enforce this invariant before using persisted files. The deferred preflight runs before folder creation; the installation script executes before old-product removal, which remains inside the rollback transaction. Startup definitions are created within validated state, then held against write/deletion while Task Scheduler reads them; the resulting definition is verified. Actual MSI formatting, component reuse, successful installed-peer authentication and hostile-tree rollback are separate native qualification requirements, not conclusions from unit tests.
|
||||
|
||||
+14
-3
@@ -3,15 +3,15 @@
|
||||
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
|
||||
```
|
||||
|
||||
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**.
|
||||
The version is `VersionPrefix` in `Directory.Build.props`; add `-ReleaseVersion X.Y.Z-rc.N` for a test build, whose numeric part must equal it. 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**.
|
||||
|
||||
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 -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.
|
||||
@@ -28,6 +28,17 @@ Remove `-WhatIf` in an elevated administrator PowerShell to install. Optional `-
|
||||
|
||||
`BRANDNAME` and `BRANDCOMPANY` public MSI properties set the service display name and installed-product labels. Their persisted HKLM values are reused by subsequent ordinary MSI upgrades, including updates installed from the companion. The installer creates a branded shortcut in the all-users **Print Connectors** Start menu folder; rename/full-uninstall cleanup verifies the exact owned target before deleting it. Optional ICO branding affects the AppKit application icon and this shortcut; embedded release binaries/icons remain shared. Internal service/AppId/MSI upgrade identity and paths stay fixed, and source/license attribution remains visible. See [Customer branding](../docs/BRANDING.md) for configuration precedence, rename/reset behavior and asset limits.
|
||||
|
||||
**A shortcut that another program holds (decided 2026-11-11).** The shortcut is created, renamed and removed by two required steps of the MSI (`--apply-branding` on install, upgrade and repair; `--remove-branding-shortcut` on a full uninstall), and Windows Installer undoes the whole run when a required step fails. A virus scanner, a backup or the Start menu can have a `.lnk` open for a moment, so these rules apply:
|
||||
|
||||
- A shortcut file that Windows reports as in use (a sharing violation or a lock violation) is tried nine times over two seconds. Access denied and other failures are not tried again.
|
||||
- **An uninstall never fails over a shortcut file.** A recorded shortcut that is still held, or cannot be opened or deleted for another reason, is left where it is, and so is a held leftover temporary file (`.qqq-` and 32 hexadecimal digits, `.lnk`; a read-only one is removed). The registry record is removed all the same, because the product is leaving. A file that could not be opened was not judged to be ours and is therefore never deleted, and no deletion is scheduled for the next restart: an install before that restart would lose its new shortcut. What stays behind is a shortcut to a program that is gone; delete it by hand, or install again under the same display name, which takes it over. An install under another display name does not find it.
|
||||
- **An untrusted Print Connectors folder no longer fails an uninstall either.** When the folder has an untrusted owner, lets an ordinary user write, or is reached through a link, nothing in it is opened, changed or deleted, and the uninstall goes on. The check exists so that an elevated process does not act on files an ordinary user could have put there; failing the uninstall on top of that protected nothing and kept the product installed. 1.2.1 and earlier 2.0 builds failed here.
|
||||
- **An install, upgrade or repair is not failed by the product's own shortcut being held.** When the recorded shortcut under the display name being applied still cannot be opened or replaced after the two seconds, it is left exactly as it is and the run finishes; the registry record is kept, and the next install, upgrade or repair replaces it. The shortcut that stays starts the same companion: target, arguments and description are the same in 1.2.1 and in 2.0 (read in both), and the install path is fixed. Only a changed icon is missed until then. A previous display name's shortcut that is held stays for the moment and is removed by a later run.
|
||||
- **What still fails.** An uninstall: the step does not run as an elevated administrator, the registry record under `HKLM\SOFTWARE\QRunIO\PrintAgent` cannot be changed, or the folder cannot be listed. An install, upgrade or repair, before anything is changed: the same first two; an untrusted install, Start menu or Print Connectors folder; a missing companion; a file under the wanted name that is not this product's shortcut, or that the registry does not record and that cannot be opened (it cannot be judged, so it is not overwritten: close the program that holds it and run the installer again); Windows' shortcut automation (the `WScript.Shell` object) not being available. Later: the new shortcut or the record cannot be written.
|
||||
- **Where the reason is.** When one of these steps fails, Windows Installer shows only its general message that a program run as part of the setup did not finish as expected, and rolls back. The reason is in the service's activity log, `%ProgramData%\QqqPrintAgent\logs\qqq-print-agent-<date>.log` (readable by administrators; the folder survives an uninstall), in an error line that starts with `[Installer] Branding shortcut operation failed.` Each file that was left where it is has a warning line there that starts with `[Installer]` and gives the file name and the kind of the failure.
|
||||
|
||||
No real install, upgrade or uninstall has exercised these rules. They are shown by tests that hold real files open in temporary folders; the runs with an administrator are listed under "Qualify the upgrade from 1.2.x to 2.0" in [TODO](../docs/TODO.md).
|
||||
|
||||
The installer protects `%ProgramData%\QqqPrintAgent` for SYSTEM and Administrators and preserves the journal/enrollment through upgrades and uninstall. It replaces app/service/CLI together and stops the old service before removing files. Preserved credentials/data can be deliberately removed by an administrator after decommissioning; do not delete the journal while a device still has unresolved print jobs.
|
||||
|
||||
No inbound firewall rule or persistent privileged update daemon is installed. The companion uses AppKit update prompts with a machine MSI adapter: manual or optional startup checks use the fixed GitHub release feed, and installation requires confirmation and administrator rights. Protected staging and a copied CLI helper update all components together; completed stages/logs are retained for administrator review. Same-feed SHA-256 checks do not replace publisher signing. See [Updates](../docs/INSTALLATION.md#updates). Production release qualification must include signed installation, service-account printing while logged out, upgrade/reboot, and uninstall/reinstall recovery. Building an MSI alone does not establish those checks.
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text;
|
||||
using Qqq.PrintAgent.Windows;
|
||||
|
||||
namespace Qqq.PrintAgent.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Everything qqq-print-agent.exe decides: which role an argument list selects, which request an operator command becomes,
|
||||
/// what is printed where, and the exit code a script sees. The command line holds no state of its own and never talks to a
|
||||
/// QQQ server: an operator command is one request to the installed service over its administrator pipe, because the service
|
||||
/// owns the sign-in, the pairing and all printing. The outside world is reached only through the host, so tests run this
|
||||
/// class against fakes.
|
||||
/// </summary>
|
||||
internal sealed class CommandLine(CommandLineHost host)
|
||||
{
|
||||
/// <summary>The environment variable that supplies the one-time enrollment code to a script.</summary>
|
||||
internal const string EnrollmentCodeVariable = "QQQ_PRINT_ENROLLMENT_CODE";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The help text: printed on standard output when asked for, and on standard error after a refused command line. Its
|
||||
/// line ends are made the platform's here, because the literal carries whatever this source file was checked out
|
||||
/// with and a console or a captured file should not get a mixture.
|
||||
/// </summary>
|
||||
internal static readonly string Usage = """
|
||||
Usage: qqq-print-agent <command>
|
||||
|
||||
Commands:
|
||||
printers List the printers the service can see, as JSON.
|
||||
enroll --server URL Pair this computer with a QQQ server using a one-time enrollment code.
|
||||
expose --printer ID --exposed true|false Share a printer with QQQ, or stop sharing it.
|
||||
run Ask the service to refresh its connection to QQQ.
|
||||
sign-out End the service's QQQ session.
|
||||
service-status Show the service's connection status.
|
||||
snapshot Show the service's state as JSON: connection, printers, recent jobs and logs.
|
||||
help Show this text (also --help or -h, alone or after a command).
|
||||
|
||||
Every command is carried out by the installed print service, which owns the sign-in, the pairing and all printing.
|
||||
Run from an administrator prompt, and sign in through the companion app first.
|
||||
The enrollment code is read from the QQQ_PRINT_ENROLLMENT_CODE environment variable, or else from standard input:
|
||||
at a Windows console it is prompted for and not shown; redirected input is read as one line, without a prompt.
|
||||
An unattended caller must set the variable or redirect input, or the prompt waits for a key.
|
||||
The code is never accepted on the command line.
|
||||
Exit code: 0 on success; 1 when the arguments are wrong, the service cannot be reached or the service reports an error.
|
||||
""".ReplaceLineEndings();
|
||||
|
||||
|
||||
|
||||
/// <summary>Each operator command, with the pipe operation it becomes and the options it requires. Every listed option is mandatory, and nothing else is accepted.</summary>
|
||||
private static readonly Dictionary<string, (string Operation, string[] Options)> s_commands = new(StringComparer.Ordinal)
|
||||
{
|
||||
["printers"] = ("printers", []),
|
||||
["enroll"] = ("enroll", ["--server"]),
|
||||
["expose"] = ("set-exposure", ["--printer", "--exposed"]),
|
||||
["run"] = ("reconnect", []),
|
||||
["sign-out"] = ("sign-out", []),
|
||||
["service-status"] = ("status", []),
|
||||
["snapshot"] = ("snapshot", []),
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>What a command line is told when it uses an option of the legacy mode that 2.0 removed, so a script written for 1.x learns why it fails.</summary>
|
||||
internal const string RemovedOptionsReason = "The options --service, --legacy-compat, --data-dir and --once were removed in 2.0: the command line only manages the installed service.";
|
||||
|
||||
|
||||
|
||||
/// <summary>The options of the removed legacy mode. They are recognized only to explain the refusal.</summary>
|
||||
private static readonly HashSet<string> s_removedOptions = new(StringComparer.Ordinal) { "--service", "--legacy-compat", "--data-dir", "--once" };
|
||||
|
||||
|
||||
|
||||
/// <summary>The names of the operator commands, exposed so a test can hold the help text to the commands that really exist.</summary>
|
||||
internal static IReadOnlyCollection<string> CommandNames => s_commands.Keys;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Runs one invocation and returns the process exit code. Two argument shapes are internal and fixed: the MSI update
|
||||
/// helper and the browser sign-in callback forwarder, each started by another part of the product with exactly these
|
||||
/// arguments. Everything else is an operator command. This executable is not a printer worker: the service starts its
|
||||
/// own executable for that. A failure becomes one line on standard error and exit code 1; a timeout or cancellation
|
||||
/// does too, so a script never mistakes an operation that did not finish for one that succeeded.
|
||||
/// </summary>
|
||||
internal async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return args switch
|
||||
{
|
||||
["--apply-update", string request] => await host.ApplyUpdateAsync(request),
|
||||
["--auth-callback", string callback] => await ForwardSignInCallbackAsync(callback),
|
||||
_ => await ManageServiceAsync(args),
|
||||
};
|
||||
}
|
||||
catch(OperationCanceledException)
|
||||
{
|
||||
host.Error.WriteLine("The operation timed out or was canceled before completion.");
|
||||
return 1;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
host.Error.WriteLine(exception.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Hands the browser's callback address to the companion that is waiting for it. Success is exit code 0; a refusal is thrown and reported by the caller.</summary>
|
||||
private async Task<int> ForwardSignInCallbackAsync(string callback)
|
||||
{
|
||||
await host.ForwardSignInCallbackAsync(callback, CancellationToken.None);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Carries out an operator command: help is printed here, and any other command is checked against the table, turned into
|
||||
/// one pipe request and sent to the service. The reply is printed on standard output as the service wrote it, also when
|
||||
/// it is a refusal. A reply that starts with "Error:" is the service refusing or failing the request, so it exits 1
|
||||
/// for every command.
|
||||
/// </summary>
|
||||
private async Task<int> ManageServiceAsync(string[] args)
|
||||
{
|
||||
if(AsksForHelp(args))
|
||||
{
|
||||
host.Output.WriteLine(Usage);
|
||||
return 0;
|
||||
}
|
||||
Dictionary<string, string> options = new(StringComparer.Ordinal);
|
||||
string? problem = FindProblem(args, options);
|
||||
if(problem is not null)
|
||||
{
|
||||
return RefuseArguments(problem);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// the enrollment code is asked for last, once the command line is known //
|
||||
// to be valid, so a mistyped command never prompts for a single-use code //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
PipeRequest request = new(s_commands[args[0]].Operation, options.GetValueOrDefault("--server"), args[0] is "enroll" ? ReadEnrollmentCode() : null,
|
||||
options.GetValueOrDefault("--printer"), options.TryGetValue("--exposed", out string? choice) ? IsWord(choice, "true") : null);
|
||||
string response = await host.SendAsync(request);
|
||||
host.Output.WriteLine(response);
|
||||
bool failed = response.StartsWith("Error:", StringComparison.Ordinal);
|
||||
if(args[0] is "sign-out" && !failed)
|
||||
{
|
||||
host.Output.WriteLine("The identity provider's browser session is not ended from the command line; use Sign out in the QQQ Print Agent app to end it too.");
|
||||
}
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Says whether the command line is a request for help: no arguments, a help word in first place whatever follows
|
||||
/// ("help enroll" is a person asking how to enroll), or a help flag in last place after a real command
|
||||
/// ("printers --help"). Answering costs nothing and sends nothing.
|
||||
/// </summary>
|
||||
private static bool AsksForHelp(string[] args)
|
||||
=> args is [] or ["help" or "--help" or "-h", ..] || (s_commands.ContainsKey(args[0]) && args[^1] is "--help" or "-h");
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Refuses a command line: the reason and the help text go to standard error and the exit code is 1. The reason never
|
||||
/// repeats what was typed. A refused command line can hold a secret in the wrong place (an enrollment code given as an
|
||||
/// argument, a sign-in callback address), and standard error is routinely captured into logs.
|
||||
/// </summary>
|
||||
private int RefuseArguments(string reason)
|
||||
{
|
||||
host.Error.WriteLine(reason);
|
||||
host.Error.WriteLine(Usage);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks the command line and reads the "--name value" pairs that follow the command, in any order, into the given
|
||||
/// dictionary. It returns null when the command line is acceptable, and otherwise the reason it is not, which the
|
||||
/// caller refuses with rather than guess what was meant. Every option of a command is required exactly once, with a
|
||||
/// value that is not blank and is not itself an option name, so a forgotten value cannot swallow the next option.
|
||||
/// Each reason is assembled only from this class's own command and option names, never from an argument that did not
|
||||
/// match one of them, so it cannot repeat a secret that was typed in the wrong place.
|
||||
/// </summary>
|
||||
private static string? FindProblem(string[] args, Dictionary<string, string> options)
|
||||
{
|
||||
if(args.Any(s_removedOptions.Contains))
|
||||
{
|
||||
return RemovedOptionsReason;
|
||||
}
|
||||
if(!s_commands.TryGetValue(args[0], out (string Operation, string[] Options) command))
|
||||
{
|
||||
return "Unknown command.";
|
||||
}
|
||||
for(int index = 1; index < args.Length; index += 2)
|
||||
{
|
||||
string name = args[index];
|
||||
if(!command.Options.Contains(name, StringComparer.Ordinal))
|
||||
{
|
||||
return command.Options.Length == 0 ? $"'{args[0]}' takes no arguments." : $"'{args[0]}' accepts only {string.Join(" and ", command.Options)}.";
|
||||
}
|
||||
if(options.ContainsKey(name))
|
||||
{
|
||||
return $"{name} may be given only once.";
|
||||
}
|
||||
if(index + 1 == args.Length || string.IsNullOrWhiteSpace(args[index + 1]) || args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return $"{name} needs a value.";
|
||||
}
|
||||
options[name] = args[index + 1];
|
||||
}
|
||||
string? missing = command.Options.FirstOrDefault(option => !options.ContainsKey(option));
|
||||
if(missing is not null)
|
||||
{
|
||||
return $"{missing} is required.";
|
||||
}
|
||||
return options.TryGetValue("--exposed", out string? choice) && !IsWord(choice, "true") && !IsWord(choice, "false") ? "--exposed must be true or false." : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Compares an option value with a fixed word, ignoring letter case and nothing else: "TRUE" is true, " true" is not.</summary>
|
||||
private static bool IsWord(string text, string word) => string.Equals(text, word, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the one-time enrollment code from the environment, or else from standard input, so the code never appears in
|
||||
/// the command line or shell history. It is passed to the service and nowhere else: this class never prints it. With
|
||||
/// the variable or redirected input nothing is written, so standard error stays empty unless something fails.
|
||||
/// What a shell adds around the code is dropped whichever way it arrives, and a source that holds nothing else counts
|
||||
/// as holding no code.
|
||||
/// </summary>
|
||||
private string ReadEnrollmentCode()
|
||||
{
|
||||
string code = WithoutShellAdditions(host.ReadEnvironmentVariable(EnrollmentCodeVariable));
|
||||
if(code.Length == 0)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// redirected input is read silently. a prompt would be the only thing on //
|
||||
// standard error in a successful scripted enrollment. when a script //
|
||||
// redirects or captures standard error (2>&1, 2>file, the ISE, remoting), //
|
||||
// Windows PowerShell turns each line a native program writes there into an //
|
||||
// error record, which with ErrorActionPreference Stop ends the script //
|
||||
// after the single-use code has already been spent //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
code = WithoutShellAdditions(host.IsInputRedirected() ? ReadPipedLine() : PromptWithoutEcho());
|
||||
}
|
||||
return code.Length > 0 ? code : throw new InvalidOperationException("Enrollment code is required.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the first line of redirected standard input from its bytes; null when there are none. The console's own
|
||||
/// text reader would decode them in the console input code page and look for no byte-order mark, so the three bytes
|
||||
/// of a UTF-8 mark arrived as three other characters in front of the code on any code page but UTF-8, and the server
|
||||
/// rejected the code as unknown. Windows PowerShell 5.1 sends that mark whenever its output encoding is UTF-8,
|
||||
/// whatever the console code page. A code is ASCII, so the bytes are read as UTF-8, which is the same for it under
|
||||
/// every code page; a mark at the very start is taken for what it says and is not part of the text, so a file
|
||||
/// saved as UTF-16, which is what redirection in Windows PowerShell 5.1 writes, is read as UTF-16. Nothing else is
|
||||
/// guessed: UTF-16 with no mark is read as UTF-8, and a byte that is not UTF-8 becomes the replacement character,
|
||||
/// so what is sent is then not the code. The reader is given the stream to close.
|
||||
/// </summary>
|
||||
private string? ReadPipedLine()
|
||||
{
|
||||
using StreamReader reader = new(host.OpenInput(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
return reader.ReadLine();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Drops what a shell can put around a code that never has any of it: surrounding white space (cmd's "echo CODE | ..."
|
||||
/// pipes a trailing space) and a leading byte-order mark. Piped input has lost one mark already, to the reader that
|
||||
/// decoded it, but Windows PowerShell 5.1 sends two when both the console code page and its output encoding are
|
||||
/// UTF-8, and the variable is never decoded here at all. The mark is not white space, so it is removed between two
|
||||
/// trims; left in, the code would pass the blank check and the server would reject it as unknown. Nothing at all
|
||||
/// returns an empty string.
|
||||
/// </summary>
|
||||
private static string WithoutShellAdditions(string? text) => (text ?? "").Trim().TrimStart('\uFEFF').Trim();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Prompts at the console and reads one line key by key without showing it, so a typed code is not left on the screen
|
||||
/// or in a terminal recording. The prompt goes to standard error, so it appears even when standard output is captured.
|
||||
/// Enter ends the line, and so does a key that carries a carriage return or line feed without being the Enter key
|
||||
/// (Ctrl+M, or a line end pasted by a terminal). Backspace removes the last character typed, and keys that produce no
|
||||
/// printable character (arrows, function keys) are ignored. The line end that ended the read was not shown either, so
|
||||
/// the prompt line is ended here, also when reading fails, before anything else is written.
|
||||
/// </summary>
|
||||
private string PromptWithoutEcho()
|
||||
{
|
||||
host.Error.Write("Enrollment code: ");
|
||||
try
|
||||
{
|
||||
StringBuilder typed = new();
|
||||
for(ConsoleKeyInfo key = host.ReadKey(true); key.Key != ConsoleKey.Enter && key.KeyChar is not ('\r' or '\n'); key = host.ReadKey(true))
|
||||
{
|
||||
if(key.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
typed.Length = Math.Max(0, typed.Length - 1);
|
||||
}
|
||||
else if(!char.IsControl(key.KeyChar))
|
||||
{
|
||||
typed.Append(key.KeyChar);
|
||||
}
|
||||
}
|
||||
return typed.ToString();
|
||||
}
|
||||
finally
|
||||
{
|
||||
host.Error.WriteLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Qqq.PrintAgent.Windows;
|
||||
|
||||
namespace Qqq.PrintAgent.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Everything outside the process that the command line touches, each as a replaceable function, so <see cref="CommandLine"/>
|
||||
/// runs in a test without the real console, the installed service or its pipe. <see cref="Program"/> supplies the real ones.
|
||||
/// </summary>
|
||||
/// <param name="Output">Standard output: service replies, and usage that was asked for.</param>
|
||||
/// <param name="Error">Standard error: failures, usage shown because the arguments were wrong, and the enrollment prompt when the code is typed at a console. A command that succeeds without that prompt writes nothing here.</param>
|
||||
/// <param name="ReadEnvironmentVariable">Reads one environment variable; the enrollment code arrives this way so it stays out of the command line and shell history.</param>
|
||||
/// <param name="IsInputRedirected">Says whether standard input is a file or a pipe instead of a console, which decides how the enrollment code is read and whether it is prompted for.</param>
|
||||
/// <param name="OpenInput">Opens redirected standard input as the bytes that were piped in, for an enrollment code that arrives that way. The bytes, not the console's text reader, because that reader decodes in the console input code page and looks for no byte-order mark.</param>
|
||||
/// <param name="ReadKey">Reads one key from the console, for an enrollment code that is typed; the argument says whether to keep the key off the screen.</param>
|
||||
/// <param name="SendAsync">Sends one request to the installed service over its administrator pipe and returns the reply text.</param>
|
||||
/// <param name="ApplyUpdateAsync">Applies the prepared MSI update named by a request file and returns the exit code.</param>
|
||||
/// <param name="ForwardSignInCallbackAsync">Forwards a browser sign-in callback address to the running companion.</param>
|
||||
internal sealed record CommandLineHost(
|
||||
TextWriter Output,
|
||||
TextWriter Error,
|
||||
Func<string, string?> ReadEnvironmentVariable,
|
||||
Func<bool> IsInputRedirected,
|
||||
Func<Stream> OpenInput,
|
||||
Func<bool, ConsoleKeyInfo> ReadKey,
|
||||
Func<PipeRequest, Task<string>> SendAsync,
|
||||
Func<string, Task<int>> ApplyUpdateAsync,
|
||||
Func<string, CancellationToken, Task> ForwardSignInCallbackAsync);
|
||||
@@ -3,272 +3,26 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
using Qqq.PrintAgent.Windows;
|
||||
|
||||
namespace Qqq.PrintAgent.Cli;
|
||||
|
||||
/// <summary>Scriptable setup, printer selection and foreground diagnostics.</summary>
|
||||
/// <summary>
|
||||
/// Process entry point of qqq-print-agent.exe. It decides nothing: it hands the real console, environment, service pipe and
|
||||
/// helper entry points to <see cref="CommandLine"/>, which holds every decision and is tested against fakes.
|
||||
/// </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.
|
||||
/// Binds the outside world and runs the arguments. Each boundary is passed as a method reference, not a wrapper, so there
|
||||
/// is no code here that a test cannot reach: asking for help exercises this whole composition without touching the service.
|
||||
/// </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);
|
||||
return 0;
|
||||
}),
|
||||
_ => ExecuteAsync(() => DispatchAsync(args)),
|
||||
};
|
||||
public static Task<int> Main(string[] args)
|
||||
=> new CommandLine(new CommandLineHost(Console.Out, Console.Error, Environment.GetEnvironmentVariable, IsInputRedirected, Console.OpenStandardInput, Console.ReadKey,
|
||||
AgentPipeClient.SendAsync, MsiUpdateInstaller.ApplyAsync, QqqBrowserCallback.ForwardFromHandlerAsync)).RunAsync(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
|
||||
{
|
||||
return await command();
|
||||
}
|
||||
catch(OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine("The operation timed out or was canceled before completion.");
|
||||
return 1;
|
||||
}
|
||||
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";
|
||||
bool legacy = UseLegacyCompatibility(args);
|
||||
if(legacy)
|
||||
{
|
||||
Console.Error.WriteLine("Explicit legacy compatibility diagnostics: private device bearer transport, separate local state. This is not QQQ user sign-in.");
|
||||
}
|
||||
if(!legacy && command is "printers" or "enroll" or "expose" or "run" or "sign-out")
|
||||
{
|
||||
return await DispatchServiceAsync(args, serviceCommand ?? AgentPipeClient.SendAsync);
|
||||
}
|
||||
string directory = ResolveDataDirectory(args);
|
||||
switch(command)
|
||||
{
|
||||
case "printers":
|
||||
Console.WriteLine(JsonSerializer.Serialize(CreatePrinterBackend().Discover(), PrintApiClient.JsonOptions));
|
||||
return 0;
|
||||
case "service-status":
|
||||
case "snapshot":
|
||||
Console.WriteLine(await AgentPipeClient.SendAsync(new PipeRequest(command is "snapshot" ? "snapshot" : "status")));
|
||||
return 0;
|
||||
case "enroll":
|
||||
return await EnrollAsync(args, directory);
|
||||
case "expose":
|
||||
return await SetExposureAsync(args, directory);
|
||||
case "run":
|
||||
return await RunAsync(args, directory);
|
||||
default:
|
||||
Console.WriteLine("printers | enroll --server URL | expose --printer ID --exposed true|false | run | sign-out | service-status | snapshot\nNormal commands use the authenticated installed service. Sign in through the companion first. run requests a connection refresh; the Windows service owns unattended printing.\nExplicit isolated legacy diagnostics only: add --legacy-compat and a separate --data-dir; legacy run accepts --once. Enrollment code is read from QQQ_PRINT_ENROLLMENT_CODE or an interactive prompt.");
|
||||
return command is "help" ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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);
|
||||
if(legacy && args.Contains("--service", StringComparer.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Legacy compatibility cannot access the installed service's state.", nameof(args));
|
||||
}
|
||||
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))
|
||||
{
|
||||
throw new ArgumentException("Foreground diagnostic options require explicit --legacy-compat. Normal printing is owned by the signed-in Windows service.", nameof(args));
|
||||
}
|
||||
PipeRequest request = args[0] switch
|
||||
{
|
||||
"run" => new("reconnect"),
|
||||
"enroll" => new("enroll", Option(args, "--server") ?? throw new ArgumentException("--server is required.", nameof(args)), ReadEnrollmentCode()),
|
||||
"expose" => new("set-exposure", PrinterId: Option(args, "--printer") ?? throw new ArgumentException("--printer is required.", nameof(args)),
|
||||
Exposed: bool.TryParse(Option(args, "--exposed"), out bool exposed) ? exposed : throw new ArgumentException("--exposed must be true or false.", nameof(args))),
|
||||
_ => new(args[0]),
|
||||
};
|
||||
string response = await send(request);
|
||||
Console.WriteLine(response);
|
||||
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");
|
||||
if(string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
Console.Write("Enrollment code: ");
|
||||
code = Console.ReadLine();
|
||||
}
|
||||
return !string.IsNullOrWhiteSpace(code) ? code : throw new InvalidOperationException("Enrollment code is required.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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))
|
||||
{
|
||||
return AgentStore.DefaultDirectory;
|
||||
}
|
||||
string directory = Path.GetFullPath(Option(args, "--data-dir") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "QqqPrintAgent"));
|
||||
if(string.Equals(Path.TrimEndingDirectorySeparator(directory), Path.TrimEndingDirectorySeparator(AgentStore.DefaultDirectory), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Use --service to manage the installed service. Foreground commands require a separate per-user data directory.");
|
||||
}
|
||||
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))
|
||||
{
|
||||
throw new InvalidOperationException("Continuous legacy polling is no longer supported. Sign in to the installed native-session service for realtime printing. --once is a legacy diagnostic only.");
|
||||
}
|
||||
using FileStream instance = AcquireWorkerLock(directory);
|
||||
using AgentStore journal = new(directory);
|
||||
AgentConfiguration config = journal.LoadConfiguration() ?? throw new InvalidOperationException("Enroll this account first.");
|
||||
using PrintApiClient client = new(config.ServerUrl, config.DeviceToken);
|
||||
ExposedPrinterBackend printers = new(CreatePrinterBackend(), journal.GetExposedPrinterIds);
|
||||
PrintWorker worker = new(client, printers, journal, Console.WriteLine);
|
||||
using CancellationTokenSource stopping = new();
|
||||
ConsoleCancelEventHandler cancelHandler = (_, e) => { e.Cancel = true; stopping.Cancel(); };
|
||||
Console.CancelKeyPress += cancelHandler;
|
||||
try
|
||||
{ await worker.RunOnceAsync(stopping.Token); }
|
||||
catch(OperationCanceledException) when(stopping.IsCancellationRequested) { return 0; }
|
||||
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));
|
||||
if(!bool.TryParse(Option(args, "--exposed"), out bool exposed))
|
||||
{
|
||||
throw new ArgumentException("--exposed must be true or false.", nameof(args));
|
||||
}
|
||||
if(args.Contains("--service", StringComparer.Ordinal))
|
||||
{
|
||||
string response = await AgentPipeClient.SendAsync(new PipeRequest("set-exposure", PrinterId: id, Exposed: exposed));
|
||||
Console.WriteLine(response);
|
||||
return response.StartsWith("Error:", StringComparison.Ordinal) ? 1 : 0;
|
||||
}
|
||||
using FileStream instance = AcquireWorkerLock(directory);
|
||||
using AgentStore store = new(directory);
|
||||
store.SetExposure(id, exposed, CreatePrinterBackend().Discover());
|
||||
Console.WriteLine(exposed ? "Printer exposed to QQQ." : "Printer hidden from QQQ.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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));
|
||||
string? code = Environment.GetEnvironmentVariable("QQQ_PRINT_ENROLLMENT_CODE");
|
||||
if(string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
Console.Write("Enrollment code: ");
|
||||
code = Console.ReadLine();
|
||||
}
|
||||
if(string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new InvalidOperationException("Enrollment code is required.");
|
||||
}
|
||||
if(args.Contains("--service", StringComparer.Ordinal))
|
||||
{
|
||||
string response = await AgentPipeClient.SendAsync(new PipeRequest("enroll", server, code));
|
||||
Console.WriteLine(response);
|
||||
return response.StartsWith("Error:", StringComparison.Ordinal) ? 1 : 0;
|
||||
}
|
||||
using FileStream instance = AcquireWorkerLock(directory);
|
||||
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
|
||||
using PrintApiClient api = new(server);
|
||||
EnrollmentResponse result = await api.EnrollAsync(new EnrollmentRequest(code, Environment.MachineName, "0.1.0"));
|
||||
store.SaveConfiguration(new AgentConfiguration(server, result.DeviceId, result.DeviceToken, result.PollIntervalSeconds));
|
||||
Console.WriteLine($"Enrolled device {result.DeviceId}.");
|
||||
return 0;
|
||||
}
|
||||
/// <summary>Says whether standard input is a file or a pipe instead of a console. It exists only because a property cannot be passed as a method reference.</summary>
|
||||
internal static bool IsInputRedirected() => Console.IsInputRedirected;
|
||||
}
|
||||
@@ -5,9 +5,11 @@
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- The scriptable head over the same Core as the window. Ships as a self-contained
|
||||
single-file exe named after the app id (qqq-print-agent.exe), so the coverage package
|
||||
name is the assembly name, not the project name. -->
|
||||
<!-- The command-line client of the installed service. It reaches the service through the
|
||||
Windows layer's pipe client and holds no protocol or printing logic of its own, so it
|
||||
references only that layer. The executable is named after the app id
|
||||
(qqq-print-agent.exe), so the coverage package name is the assembly name, not the
|
||||
project name. The installer publishes it self-contained for win-x64. -->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
@@ -18,20 +20,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Qqq.PrintAgent.Core\Qqq.PrintAgent.Core.csproj" />
|
||||
<ProjectReference Include="..\Qqq.PrintAgent.Windows\Qqq.PrintAgent.Windows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Qqq.PrintAgent.Cli.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The app's own namespaces are global usings: their sort position against other
|
||||
usings depends on the app's name, which the template replaces. -->
|
||||
<Using Include="Qqq.PrintAgent.Core" />
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup><ProjectReference Include="../Qqq.PrintAgent.Windows/Qqq.PrintAgent.Windows.csproj" /></ItemGroup></Project>
|
||||
|
||||
|
||||
</Project>
|
||||
@@ -56,7 +56,7 @@ public static class AppIdentity
|
||||
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;
|
||||
public static AppInfo DefaultInfo { get; } = Compose(Edition, new RuntimeBranding());
|
||||
|
||||
/// <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;
|
||||
@@ -65,21 +65,32 @@ public static class AppIdentity
|
||||
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 string? EditionLogoPath => EditionLogoIn(Edition, AppContext.BaseDirectory);
|
||||
|
||||
public static RuntimeBranding Branding { get; } = RuntimeBranding.LoadMachine(includeInstallerLabels: Edition is null);
|
||||
public static AppInfo Info { get; } = Branding.Apply(DefaultInfo);
|
||||
public static AppInfo Info { get; } = Compose(Edition, Branding);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Builds the identity an edition and a machine's branding produce together: the edition's presentation over the
|
||||
/// generic identity, then the machine's branding over that. The static members above apply it to what this build and
|
||||
/// this machine hold; a test applies it to an edition and branding of its own, with no machine state involved.
|
||||
/// </summary>
|
||||
internal static AppInfo Compose(Edition? edition, RuntimeBranding branding) => branding.Apply(edition?.Apply(BaseInfo) ?? BaseInfo);
|
||||
|
||||
|
||||
|
||||
/// <summary>The logo an edition ships under the given application folder, or null for the generic build or an edition without one.</summary>
|
||||
internal static string? EditionLogoIn(Edition? edition, string baseDirectory)
|
||||
{
|
||||
if(edition is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string path = Path.Combine(baseDirectory, "Assets", "Brand", "logo.png");
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
/// <summary>Where the app keeps its per-user data; honors the data-folder override variable.</summary>
|
||||
public static AppPaths Paths { get; } = new(Info);
|
||||
|
||||
@@ -56,6 +56,19 @@ public interface IQqqSessionClient : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Ends the session like <see cref="SignOutAsync"/> and also asks the server for the identity provider's sign-out, returned for the
|
||||
/// host to open in the browser so the provider's own session ends too. Null means there is nothing to open (no session, a
|
||||
/// password server, or a server without provider sign-out); the local session is forgotten either way. The default only signs out.
|
||||
/// </summary>
|
||||
async Task<QqqProviderLogout?> SignOutWithProviderAsync(Uri postLogoutRedirectUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await SignOutAsync(cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the credential that lets a later start restore this session; it is the only secret the caller persists.</summary>
|
||||
QqqSessionCredential ExportCredential();
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// One attempt to open the realtime socket. The session client supplies the request, reads the outcome and decides what
|
||||
/// it means; the connector only performs the upgrade. Disposing it abandons the attempt, and
|
||||
/// <see cref="Release"/> hands the connected socket to the caller instead.
|
||||
/// </summary>
|
||||
internal interface IQqqSocketConnector : IDisposable
|
||||
{
|
||||
/// <summary>The HTTP status the server answered the upgrade with, or zero when there was no HTTP answer; it tells a refused authorization from a broken connection.</summary>
|
||||
HttpStatusCode UpgradeStatus { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The subprotocol the server selected, or null when it selected none.</summary>
|
||||
string? SubProtocol { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Hands over the headers of the server's answer to the upgrade, whether it accepted or refused, and lets go of them;
|
||||
/// null when there was no HTTP answer, or when they were already taken. The session client reads the load balancer's
|
||||
/// cookies from them. The connector passes on what the platform collected and selects nothing.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, IEnumerable<string>>? TakeUpgradeHeaders();
|
||||
|
||||
|
||||
|
||||
/// <summary>Performs the upgrade described by the request; it throws when the connection or the upgrade fails.</summary>
|
||||
Task ConnectAsync(QqqSocketRequest request, CancellationToken cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the connected socket, which from then on owns the transport underneath it. The connector must not be disposed afterwards.</summary>
|
||||
WebSocket Release();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// The one place the session client obtains the platform's HTTP and WebSocket stacks. The client decides everything
|
||||
/// (redirects, cookies, certificate policy, the upgrade request) and passes those decisions in; an implementation only
|
||||
/// constructs platform objects with them. That keeps every decision in code a test can run, with the production
|
||||
/// implementation reduced to construction.
|
||||
/// </summary>
|
||||
internal interface IQqqTransport
|
||||
{
|
||||
/// <summary>Creates the handler behind the client's HTTP requests, applying the given policy and asking the given callback about every server certificate.</summary>
|
||||
HttpMessageHandler CreateHttpHandler(QqqTransportPolicy policy, Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool> certificate);
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates a connector for one realtime socket attempt, with the same policy and its own certificate callback.</summary>
|
||||
IQqqSocketConnector CreateSocketConnector(QqqTransportPolicy policy, RemoteCertificateValidationCallback certificate);
|
||||
}
|
||||
@@ -46,6 +46,16 @@ public sealed class QqqApiDiscovery
|
||||
|
||||
|
||||
|
||||
/// <summary>The UI API capability that says logout can return the identity provider's end-session data.</summary>
|
||||
public const string NativeSessionLogoutFeature = "nativeSessionLogout";
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the server is a plain 1.10+ release that advertises provider sign-out. The capability is additive and never inferred from native sign-in support.</summary>
|
||||
public bool SupportsNativeSessionLogout => Features?.Contains(NativeSessionLogoutFeature, StringComparer.Ordinal) == true && SupportedVersion(ApiVersion, 10);
|
||||
|
||||
|
||||
|
||||
/// <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;
|
||||
|
||||
@@ -56,12 +66,17 @@ public sealed class QqqApiDiscovery
|
||||
|
||||
|
||||
|
||||
/// <summary>A one-line, credential-free summary for the log: API version and every advertised extension with its version and path.</summary>
|
||||
/// <summary>
|
||||
/// A one-line, credential-free summary for the log: API version and the advertised extensions with their versions and
|
||||
/// paths. Everything in it is what the server sent, so each value is shown in printable, bounded form and a long list
|
||||
/// is counted instead of spelled out; a hostile document cannot break the log line or make it long.
|
||||
/// </summary>
|
||||
public string Describe()
|
||||
{
|
||||
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}.";
|
||||
: QqqCompatibilityProblem.BoundedList(Extensions.Where(extension => extension is not null)
|
||||
.Select(extension => $"{QqqCompatibilityProblem.Printable(extension.Name)} {QqqCompatibilityProblem.Printable(extension.Version)} at {QqqCompatibilityProblem.Printable(extension.PathPrefix)}"));
|
||||
return $"QQQ API {QqqCompatibilityProblem.Printable(ApiVersion)}, {Features?.Count ?? 0} feature(s), client channels [{QqqCompatibilityProblem.PrintableList(ClientChannels)}], {extensions}.";
|
||||
}
|
||||
|
||||
|
||||
@@ -74,10 +89,10 @@ public sealed class QqqApiDiscovery
|
||||
/// </summary>
|
||||
internal void Validate(bool nativeSession = false, Action<string>? report = null)
|
||||
{
|
||||
List<string> problems = Problems(nativeSession);
|
||||
List<QqqCompatibilityProblem> problems = Problems(nativeSession);
|
||||
if(problems.Count != 0)
|
||||
{
|
||||
report?.Invoke("Discovery rejected: " + string.Join("; ", problems) + ".");
|
||||
report?.Invoke("Discovery rejected: " + string.Join("; ", problems.Select(problem => problem.Detail)) + ".");
|
||||
throw Configuration();
|
||||
}
|
||||
}
|
||||
@@ -85,44 +100,95 @@ public sealed class QqqApiDiscovery
|
||||
|
||||
|
||||
/// <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.
|
||||
/// Lists every way the discovery document falls short of what this agent needs, each with its kind and a plain
|
||||
/// explanation, so the log and the connection wizard can both say which server-side requirement is missing. Sign-in
|
||||
/// accepts a 1.x printing extension as before; asking for realtime also reports one as too old, because only 2.x can
|
||||
/// deliver a job.
|
||||
/// </summary>
|
||||
private List<string> Problems(bool nativeSession)
|
||||
internal List<QqqCompatibilityProblem> Problems(bool nativeSession, bool realtime = false)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
string[] required = ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
|
||||
var problems = new List<QqqCompatibilityProblem>();
|
||||
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"); }
|
||||
problems.Add(new(QqqCompatibilityIssue.ApiVersionUnsupported,
|
||||
$"API version '{QqqCompatibilityProblem.Printable(ApiVersion)}' is not a plain 1.{minimumMinor}+ release{(nativeSession ? " (1.9 is needed for native browser sign-in)" : "")}"));
|
||||
}
|
||||
AddFeatureProblems(problems, nativeSession);
|
||||
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"); }
|
||||
}
|
||||
{ problems.Add(new(QqqCompatibilityIssue.ClientChannelMissing, "the 'api' client channel is not offered")); }
|
||||
AddPrintingProblems(problems, realtime);
|
||||
return problems;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Adds a problem for a missing features list, for each core feature the agent calls that is absent, and, when browser sign-in is wanted, for the missing native sign-in feature.</summary>
|
||||
private void AddFeatureProblems(List<QqqCompatibilityProblem> problems, bool nativeSession)
|
||||
{
|
||||
if(Features is null)
|
||||
{
|
||||
problems.Add(new(QqqCompatibilityIssue.ApiFeatureMissing, "no features list"));
|
||||
return;
|
||||
}
|
||||
string[] required = ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
|
||||
string[] missing = [.. required.Where(feature => !Features.Contains(feature, StringComparer.Ordinal))];
|
||||
if(missing.Length != 0)
|
||||
{ problems.Add(new(QqqCompatibilityIssue.ApiFeatureMissing, "missing feature(s) " + string.Join(", ", missing))); }
|
||||
if(nativeSession && !Features.Contains("nativeSessionLogin", StringComparer.Ordinal))
|
||||
{ problems.Add(new(QqqCompatibilityIssue.SignInUnavailable, "the nativeSessionLogin feature is not advertised, so browser sign-in cannot be used")); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Adds the printing extension problems: none advertised, more than one, a version that is not a plain 1.x or 2.x
|
||||
/// release, a non-standard path, and, when realtime is required, a 1.x extension that can never deliver a job.
|
||||
/// </summary>
|
||||
private void AddPrintingProblems(List<QqqCompatibilityProblem> problems, bool realtime)
|
||||
{
|
||||
Extension[] printing = [.. (Extensions ?? []).Where(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal))];
|
||||
if(printing.Length == 0)
|
||||
{
|
||||
problems.Add(new(QqqCompatibilityIssue.PrintingExtensionMissing, "no printing extension is advertised (is the printing QBit installed and enabled?)"));
|
||||
return;
|
||||
}
|
||||
if(printing.Length != 1)
|
||||
{
|
||||
problems.Add(new(QqqCompatibilityIssue.PrintingExtensionUnsupported, $"{printing.Length} printing extensions are advertised, expected exactly one"));
|
||||
return;
|
||||
}
|
||||
string version = QqqCompatibilityProblem.Printable(printing[0].Version);
|
||||
if(!(SupportedVersion(printing[0].Version, 0) || SupportedVersion(printing[0].Version, 0, "2")))
|
||||
{ problems.Add(new(QqqCompatibilityIssue.PrintingExtensionUnsupported, $"printing extension version '{version}' is not a plain 1.x or 2.x release")); }
|
||||
else if(realtime && SupportedVersion(printing[0].Version, 0))
|
||||
{ problems.Add(new(QqqCompatibilityIssue.PrintingExtensionTooOld, $"printing extension {version} cannot deliver jobs to this agent; upgrade the server to printing extension 2.x")); }
|
||||
if(!string.Equals(printing[0].PathPrefix, "/qbit/printing", StringComparison.Ordinal))
|
||||
{ problems.Add(new(QqqCompatibilityIssue.PrintingExtensionUnsupported, $"printing extension path '{QqqCompatibilityProblem.Printable(printing[0].PathPrefix)}' is not /qbit/printing")); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The version of the single printing extension the server advertises, for display beside the compatibility
|
||||
/// result, or null when there is nothing to show: no printing extension, more than one, or one that states no
|
||||
/// version. Null always means absent; whoever composes text from it words that. Because a present value is shown,
|
||||
/// it is the printable form of what the server sent (see <see cref="QqqCompatibilityProblem.Printable"/>): a
|
||||
/// version that passed validation is unchanged by that, and anything else can neither break a line nor run long.
|
||||
/// It is derived, so it is never written to JSON.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public string? PrintingExtensionVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
Extension[] printing = [.. (Extensions ?? []).Where(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal))];
|
||||
return printing.Length == 1 && !string.IsNullOrEmpty(printing[0].Version) ? QqqCompatibilityProblem.Printable(printing[0].Version) : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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")
|
||||
{
|
||||
|
||||
@@ -15,6 +15,22 @@ public sealed class QqqAuthenticationMetadata
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The origin of the sign-in provider this description names: scheme, host and port of the issuer in the native
|
||||
/// browser grant, with the host as its ASCII name (see <see cref="QqqOrigin.Of"/>). The server wrote the issuer,
|
||||
/// and an operator is shown this origin before deciding to trust it, so a look-alike letter appears as the
|
||||
/// "xn--" name it really is and no invisible or line-ending character can appear at all. Null when the server
|
||||
/// signs in with a password, publishes no such grant, or names an issuer that is not a plain HTTPS address or
|
||||
/// whose host has no ASCII name. It is derived from what the server sent and is never part of the document.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public Uri? ProviderOrigin => Uri.TryCreate(Values?.NativeSessionLogin?.Issuer, UriKind.Absolute, out Uri? issuer)
|
||||
&& string.Equals(issuer.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal) && issuer.UserInfo.Length == 0
|
||||
? QqqOrigin.Of(issuer)
|
||||
: null;
|
||||
|
||||
|
||||
|
||||
/// <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
|
||||
{
|
||||
@@ -22,6 +38,19 @@ public sealed class QqqAuthenticationMetadata
|
||||
public string BaseUrl { get; init; } = "";
|
||||
public string Scopes { get; init; } = "";
|
||||
public NativeGrant? NativeSessionLogin { get; init; }
|
||||
|
||||
/// <summary>The server's opt-in to provider sign-out (OIDC RP-initiated logout) with the exact callbacks it will accept; absent on servers that do not offer it.</summary>
|
||||
public NativeLogout? NativeSessionLogout { get; init; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The server's declaration that it can return the provider's end-session data at logout: flow name and the exact post-logout callbacks.</summary>
|
||||
public sealed class NativeLogout
|
||||
{
|
||||
public const string RpInitiatedFlow = "OIDC_RP_INITIATED_LOGOUT";
|
||||
public string Flow { get; init; } = "";
|
||||
public IReadOnlyList<string> PostLogoutRedirectUris { get; init; } = [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// The development-only certificate exception of one server: the origins from which a certificate Windows does not
|
||||
/// trust is accepted. There are at most two, the server's own origin and the origin of the sign-in provider that server
|
||||
/// names, and a certificate from any other host is judged normally. The value is immutable and can only be built from
|
||||
/// well-formed HTTPS origins, so an exception can never be widened after it was accepted and can never name
|
||||
/// something that is not a place a certificate comes from. An empty value means no exception.
|
||||
/// </summary>
|
||||
public sealed class QqqCertificateExceptionOrigins
|
||||
{
|
||||
/// <summary>The most origins an exception can cover: the server's own and its sign-in provider's.</summary>
|
||||
public const int MaximumOrigins = 2;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the origins, which the factories have already normalized and counted, behind a read-only wrapper. The
|
||||
/// array is one a factory made for this value and hands to nothing else, and a caller that reads
|
||||
/// <see cref="Origins"/> gets a list it can neither write through nor cast back to the array, so the value is
|
||||
/// immutable in fact and not only by convention.
|
||||
/// </summary>
|
||||
private QqqCertificateExceptionOrigins(Uri[] origins)
|
||||
{
|
||||
Origins = Array.AsReadOnly(origins);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>No exception: every certificate is judged as Windows judges it.</summary>
|
||||
public static QqqCertificateExceptionOrigins None { get; } = new([]);
|
||||
|
||||
|
||||
|
||||
/// <summary>The origins the exception covers, each as an HTTPS address of scheme, the host's ASCII name and port with the path "/" (see <see cref="QqqOrigin.Of"/>); none for no exception. The list is read-only: an attempt to change it throws.</summary>
|
||||
public IReadOnlyList<Uri> Origins { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the exception covers nothing.</summary>
|
||||
public bool IsEmpty => Origins.Count == 0;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Builds the exception for the given origins. Each must be an absolute HTTPS address with no user information,
|
||||
/// query or fragment and no path beyond "/", and its host must have an ASCII name; the same origin given twice
|
||||
/// counts once, and a name in Unicode and the same name in its "xn--" form are the same origin. More than two
|
||||
/// distinct origins, or one that is not such an address, is refused with an <see cref="ArgumentException"/>, so a
|
||||
/// wider or malformed exception cannot exist. No origins gives <see cref="None"/>.
|
||||
/// </summary>
|
||||
public static QqqCertificateExceptionOrigins For(params Uri[] origins)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(origins);
|
||||
List<Uri> distinct = [];
|
||||
foreach(Uri origin in origins)
|
||||
{
|
||||
Uri normalized = Normalize(origin) ?? throw new ArgumentException("A certificate exception names HTTPS origins only: an absolute https address with no user information, path, query or fragment.", nameof(origins));
|
||||
if(!distinct.Contains(normalized))
|
||||
{ distinct.Add(normalized); }
|
||||
}
|
||||
if(distinct.Count > MaximumOrigins)
|
||||
{ throw new ArgumentException("A certificate exception covers at most two origins: the server's own and the sign-in provider it names.", nameof(origins)); }
|
||||
return distinct.Count == 0 ? None : new QqqCertificateExceptionOrigins([.. distinct]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Builds the exception an operator accepted for the server at an address: the origin of that address and, when it
|
||||
/// is given, the origin of the sign-in provider that server names. Only the scheme, host and port of the server's
|
||||
/// address are used; whether the address is one a session may use at all is the session client's to decide. A
|
||||
/// server reached over plain HTTP presents no certificate, so its own origin is left out; the provider's is still
|
||||
/// covered. The provider's origin must be a well-formed HTTPS origin, as <see cref="For"/> requires.
|
||||
/// </summary>
|
||||
public static QqqCertificateExceptionOrigins ForServer(Uri server, Uri? provider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
Uri? own = server.IsAbsoluteUri && string.Equals(server.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal) ? QqqOrigin.Of(server) : null;
|
||||
return (own, provider) switch
|
||||
{
|
||||
(not null, null) => For(own),
|
||||
(not null, not null) => For(own, provider),
|
||||
(null, not null) => For(provider),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when the address a request or a socket upgrade is going to is on one of the covered origins: the same host
|
||||
/// and port, over HTTPS or its WebSocket form. The host is compared by its ASCII name, as the session client's own
|
||||
/// same-origin rule compares it, so a name written in Unicode and the same name in its "xn--" form are one host.
|
||||
/// Anything else, a relative address or a host with no ASCII name included, is not covered.
|
||||
/// </summary>
|
||||
public bool Covers(Uri address)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(address);
|
||||
return address.IsAbsoluteUri
|
||||
&& (string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal) || string.Equals(address.Scheme, Uri.UriSchemeWss, StringComparison.Ordinal))
|
||||
&& QqqOrigin.AsciiHost(address) is { } host
|
||||
&& Origins.Any(origin => string.Equals(origin.IdnHost, host, StringComparison.OrdinalIgnoreCase) && origin.Port == address.Port);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The covered origins for a log line or a warning: "none", or the origins separated by commas. Each is shown with its host's ASCII name, the form it is kept in, so nothing a server wrote in a host name can look like another name or lay the line out differently.</summary>
|
||||
public override string ToString() => IsEmpty ? "none" : string.Join(", ", Origins.Select(origin => origin.GetLeftPart(UriPartial.Authority)));
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the origin in its stored form (see <see cref="QqqOrigin.Of"/>), or null when the address is not a well-formed HTTPS origin or its host has no ASCII name.</summary>
|
||||
private static Uri? Normalize(Uri? origin)
|
||||
{
|
||||
return origin is not null && origin.IsAbsoluteUri && string.Equals(origin.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
|
||||
&& origin.UserInfo.Length == 0 && origin.Query.Length == 0 && origin.Fragment.Length == 0 && string.Equals(origin.AbsolutePath, "/", StringComparison.Ordinal)
|
||||
? QqqOrigin.Of(origin)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net.Security;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Why a server certificate was refused, which certificate it was, which address presented it and when, so the wizard
|
||||
/// can show the operator the issuer and thumbprint they would be trusting before they allow an exception. One session
|
||||
/// client talks to its server and to the sign-in provider that server names, so the certificate alone does not say
|
||||
/// which of the two presented it: <paramref name="Origin"/> does. It is the origin of the address the request or the
|
||||
/// socket upgrade was going to, with the host as its ASCII name (see <see cref="QqqOrigin.Of"/>), and null only for
|
||||
/// an address whose host has no such name. <paramref name="ObservedAt"/> is the session client's clock at the
|
||||
/// refusal. The rest is public certificate data; a certificate the server did not present leaves the identity fields
|
||||
/// null.
|
||||
/// </summary>
|
||||
public sealed record QqqCertificateRejection(SslPolicyErrors Errors, string? Subject, string? Issuer, DateTime? NotBeforeUtc, DateTime? NotAfterUtc, string? Thumbprint,
|
||||
Uri? Origin, DateTimeOffset ObservedAt)
|
||||
{
|
||||
/// <summary>True when Windows does not trust the issuing authority, the one case an administrator may choose to allow for a development server.</summary>
|
||||
public bool IsUntrustedAuthority => Errors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors);
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the certificate was issued for a different host name or address than the one entered.</summary>
|
||||
public bool IsNameMismatch => Errors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// One reason a server cannot be used by this agent, as a value the connection wizard can branch on. The log has always
|
||||
/// carried these reasons as text; this is the same knowledge in a form a screen can act on without parsing a message.
|
||||
/// </summary>
|
||||
public enum QqqCompatibilityIssue
|
||||
{
|
||||
/// <summary>No answer at all: the name did not resolve, the connection was refused or reset, or the request timed out.</summary>
|
||||
ServerUnreachable,
|
||||
|
||||
/// <summary>Something answered, but the secure connection could not be established: an untrusted, mismatched or missing certificate, or a failed TLS handshake.</summary>
|
||||
SecureConnectionFailed,
|
||||
|
||||
/// <summary>The address answered over HTTP, but not with a usable QQQ discovery document: an error status, a redirect, a sign-in page, or malformed JSON.</summary>
|
||||
NotQqqServer,
|
||||
|
||||
/// <summary>A QQQ server whose UI API version is not a plain release this agent supports.</summary>
|
||||
ApiVersionUnsupported,
|
||||
|
||||
/// <summary>A QQQ server that lacks a core feature this agent calls.</summary>
|
||||
ApiFeatureMissing,
|
||||
|
||||
/// <summary>A QQQ server that does not offer the <c>api</c> client channel this agent identifies itself on.</summary>
|
||||
ClientChannelMissing,
|
||||
|
||||
/// <summary>A QQQ server without the printing extension.</summary>
|
||||
PrintingExtensionMissing,
|
||||
|
||||
/// <summary>A QQQ server whose printing extension predates realtime printing, so it could accept a sign-in but never deliver a job.</summary>
|
||||
PrintingExtensionTooOld,
|
||||
|
||||
/// <summary>A printing extension this agent cannot negotiate with: a newer major version, a version that is not a plain release, a non-standard path, or more than one extension.</summary>
|
||||
PrintingExtensionUnsupported,
|
||||
|
||||
/// <summary>The server is otherwise usable but offers no sign-in method this agent implements, or its sign-in description could not be read.</summary>
|
||||
SignInUnavailable,
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// One shortfall found while checking a server: what kind it is and a plain sentence fragment saying exactly what was
|
||||
/// wrong. A wizard shows the detail to an operator before anyone has signed in, so it is bounded twice: every piece of
|
||||
/// text taken from the server goes through <see cref="Printable"/> first, and the whole detail is cut at
|
||||
/// <see cref="DetailLimit"/> however many pieces were joined into it. A hostile value can therefore neither inject
|
||||
/// lines into a log, reorder what a screen shows, nor flood either, and no detail ever contains a credential.
|
||||
/// </summary>
|
||||
public sealed record QqqCompatibilityProblem(QqqCompatibilityIssue Issue, string Detail)
|
||||
{
|
||||
/// <summary>The most UTF-16 units of one server-supplied value that are shown; a version, a path or an address worth reading is shorter.</summary>
|
||||
internal const int ValueLimit = 80;
|
||||
|
||||
/// <summary>The most UTF-16 units a detail holds before it is cut.</summary>
|
||||
internal const int DetailLimit = 512;
|
||||
|
||||
/// <summary>
|
||||
/// The most UTF-16 units shown of longer text the server can influence: an address, a certificate name, an error
|
||||
/// description, a failure's message, and the identity values and listed addresses an operator has to compare. The
|
||||
/// short limit would cut two identifiers that share their first 80 characters to the same text.
|
||||
/// </summary>
|
||||
internal const int TextLimit = 512;
|
||||
|
||||
/// <summary>The most values of a server-supplied list that are shown; the rest are counted.</summary>
|
||||
internal const int ListLimit = 32;
|
||||
|
||||
private const string Cut = "...";
|
||||
|
||||
|
||||
|
||||
/// <summary>What was wrong, in plain words: at most <see cref="DetailLimit"/> UTF-16 units, followed by an ellipsis when it was cut.</summary>
|
||||
public string Detail { get; } = Bounded(Detail);
|
||||
|
||||
|
||||
|
||||
/// <summary>The check this problem belongs to, so a wizard can mark the earlier checks as passed.</summary>
|
||||
public QqqCompatibilityStage Stage => Issue switch
|
||||
{
|
||||
QqqCompatibilityIssue.ServerUnreachable => QqqCompatibilityStage.Connection,
|
||||
QqqCompatibilityIssue.SecureConnectionFailed => QqqCompatibilityStage.Security,
|
||||
QqqCompatibilityIssue.PrintingExtensionMissing or QqqCompatibilityIssue.PrintingExtensionTooOld or QqqCompatibilityIssue.PrintingExtensionUnsupported => QqqCompatibilityStage.Printing,
|
||||
QqqCompatibilityIssue.SignInUnavailable => QqqCompatibilityStage.SignIn,
|
||||
_ => QqqCompatibilityStage.Server,
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Makes server-supplied text safe to show as part of a sentence: a problem's detail, or a line of the session
|
||||
/// client's log, which an operator reads too. Every code point that <see cref="ServerText.IsShown"/> does not show
|
||||
/// becomes a space: control characters, the line and paragraph separators, the format characters that reorder or
|
||||
/// hide text, and private-use and unassigned code points. Other space separators (U+00A0, U+3000) are kept as they
|
||||
/// are, and a surrogate without its partner is shown as U+FFFD. The text is read as whole code points and cut
|
||||
/// after the limit (<see cref="ValueLimit"/> UTF-16 units unless the caller names another), never in the middle
|
||||
/// of a surrogate pair, and reading stops there, so a value of any length costs the same. Because the result is a
|
||||
/// piece of a sentence, a cut is marked with an ellipsis after the limit and a missing value is shown as the words
|
||||
/// "(missing)". Neither suits a typed member of the management pipe, which has its own rule for that reason and
|
||||
/// shares only <see cref="ServerText.IsShown"/> with this one.
|
||||
/// </summary>
|
||||
internal static string Printable(string? value, int limit = ValueLimit)
|
||||
{
|
||||
if(string.IsNullOrEmpty(value))
|
||||
{ return "(missing)"; }
|
||||
var shown = new StringBuilder();
|
||||
foreach(Rune rune in value.EnumerateRunes())
|
||||
{
|
||||
if(shown.Length + rune.Utf16SequenceLength > limit)
|
||||
{ return shown.Append(Cut).ToString(); }
|
||||
shown.Append(ServerText.IsShown(rune) ? rune.ToString() : " ");
|
||||
}
|
||||
return shown.ToString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Joins a server-supplied list for display: each value printable and cut at the given limit, at most <see cref="ListLimit"/> of them, and a count of the rest, so neither one value nor the number of values can make a line unbounded.</summary>
|
||||
internal static string PrintableList(IEnumerable<string?>? values, int limit = ValueLimit) => BoundedList((values ?? []).Select(value => Printable(value, limit)));
|
||||
|
||||
|
||||
|
||||
/// <summary>Joins items that are already printable, showing at most <see cref="ListLimit"/> of them and counting the rest.</summary>
|
||||
internal static string BoundedList(IEnumerable<string> items)
|
||||
{
|
||||
string[] all = [.. items];
|
||||
string shown = string.Join(", ", all.Take(ListLimit));
|
||||
return all.Length > ListLimit ? shown + $", and {all.Length - ListLimit} more" : shown;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Cuts a detail at the limit, one unit earlier when the limit falls inside a surrogate pair, and marks the cut.</summary>
|
||||
private static string Bounded(string detail)
|
||||
{
|
||||
if(detail.Length <= DetailLimit)
|
||||
{ return detail; }
|
||||
int end = char.IsHighSurrogate(detail[DetailLimit - 1]) ? DetailLimit - 1 : DetailLimit;
|
||||
return string.Concat(detail.AsSpan(0, end), Cut);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// The checks a server passes through, in the order they are made, so a wizard can show which steps succeeded before the
|
||||
/// one that failed. Each later stage is only meaningful when every earlier one passed.
|
||||
/// </summary>
|
||||
public enum QqqCompatibilityStage
|
||||
{
|
||||
/// <summary>Reaching the address at all.</summary>
|
||||
Connection,
|
||||
|
||||
/// <summary>Establishing a trusted secure connection.</summary>
|
||||
Security,
|
||||
|
||||
/// <summary>Reading a QQQ discovery document with the API version, features and channel this agent needs.</summary>
|
||||
Server,
|
||||
|
||||
/// <summary>Finding a printing extension this agent can print through.</summary>
|
||||
Printing,
|
||||
|
||||
/// <summary>Finding a sign-in method this agent implements.</summary>
|
||||
SignIn,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// The one form in which an origin is kept, compared and shown: the scheme, the host's name in plain ASCII, and the
|
||||
/// port. A host name sent by a server can hold letters that look like other letters (a Cyrillic letter in place of
|
||||
/// the "a" in "apple.com"), characters that show as nothing (a zero-width space) and characters that end a line
|
||||
/// (U+2028). The ASCII name is the one the connection is really made to, "xn--..." for a name outside ASCII, so it
|
||||
/// is what an operator has to see before a trust decision, and what two addresses must share to be the same place.
|
||||
/// The session client's own same-origin rule compares that name as well, so a Unicode name and its "xn--" form are
|
||||
/// one origin everywhere.
|
||||
/// </summary>
|
||||
public static class QqqOrigin
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the origin of an absolute HTTP or WebSocket address (https, http, wss, ws) as scheme, ASCII host and
|
||||
/// port with the path "/", or null when the address is relative, has another scheme, or has a host with no plain
|
||||
/// ASCII name (one the platform could not convert, for instance because it holds a line separator). An IPv6
|
||||
/// literal keeps its brackets, an IPv4 address written short ("127.1") is already in its full form, and a default
|
||||
/// port is not shown by the result's <see cref="Uri.Authority"/>. Nothing but ASCII letters, digits and the
|
||||
/// punctuation of a host name can appear in the result, so it is safe in a log line and on a screen.
|
||||
/// </summary>
|
||||
public static Uri? Of(Uri? address)
|
||||
{
|
||||
if(address is null || !address.IsAbsoluteUri || address.Scheme is not ("https" or "http" or "wss" or "ws") || AsciiHost(address) is not { } host)
|
||||
{ return null; }
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// the text is rebuilt from a host a server may have written, so it is //
|
||||
// parsed again without throwing: a name the conversion left unusable //
|
||||
// (an empty label, say) is no origin, and never an exception //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
string text = address.Scheme + Uri.SchemeDelimiter + (address.HostNameType == UriHostNameType.IPv6 ? "[" + host + "]" : host) + ":" + address.Port.ToString(CultureInfo.InvariantCulture) + "/";
|
||||
return Uri.TryCreate(text, UriKind.Absolute, out Uri? origin) ? origin : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The host and port of an address in the form they are shown in, in a log line or on a screen: the authority of
|
||||
/// the address's origin (see <see cref="Of"/>), so a name outside ASCII appears as the "xn--" name the connection
|
||||
/// is really made to and a default port is left out. An address that has no origin is shown as it was written,
|
||||
/// with every character that could end a line, reorder the text or show as nothing replaced by a space, and an
|
||||
/// address that names no host (a relative one, or the browser callback's custom scheme) is shown as nothing. This
|
||||
/// is the one place a host is formed for display, so a provider's host reads the same in every line that names
|
||||
/// it. The result never holds user information, a path, a query or a fragment.
|
||||
/// </summary>
|
||||
public static string ShownAuthority(Uri address)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(address);
|
||||
if(Of(address) is { } origin)
|
||||
{ return origin.Authority; }
|
||||
string written = address.IsAbsoluteUri ? address.Authority : "";
|
||||
return written.Length == 0 ? "" : QqqCompatibilityProblem.Printable(written, QqqCompatibilityProblem.TextLimit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ASCII name of an absolute address's host, the name a connection to it is made to, or null when the
|
||||
/// host has none. The platform refuses to name some hosts (it throws for one that holds a line separator) and
|
||||
/// leaves others in Unicode (a name it does not treat as a domain name); neither is a name that may be compared
|
||||
/// or shown, so both are null here and no caller has to guard the conversion. The colon and percent sign of an
|
||||
/// IPv6 literal are accepted for a literal only.
|
||||
/// </summary>
|
||||
internal static string? AsciiHost(Uri address)
|
||||
{
|
||||
string host;
|
||||
try
|
||||
{ host = address.IdnHost; }
|
||||
catch(UriFormatException)
|
||||
{ return null; }
|
||||
bool literal = address.HostNameType == UriHostNameType.IPv6;
|
||||
return host.Length != 0 && host.All(character => char.IsAsciiLetterOrDigit(character) || character is '.' or '-' or '_' || (literal && character is ':' or '%')) ? host : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// The production socket connector: a <see cref="ClientWebSocket"/> and the HTTP invoker that carries its upgrade. It
|
||||
/// copies the session client's request onto the socket options and connects; what the outcome means is decided by the
|
||||
/// client.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage(Justification = "A thin adapter over the platform network stack: it constructs ClientWebSocket and SocketsHttpHandler and copies a prepared request onto them, and decides nothing. The request, the policy, the certificate decision and the handling of every outcome live in QqqSessionClient and are tested through a fake IQqqSocketConnector. One loopback test does run this class end to end, against a peer inside the test process and over plain WebSocket, which shows the wiring works; it is still excluded because that is one accepted upgrade, and what else the class can meet (a TLS handshake, a refused upgrade, an abandoned attempt) is the platform's behavior against a real endpoint, not logic here to cover.")]
|
||||
internal sealed class QqqPlatformSocketConnector : IQqqSocketConnector
|
||||
{
|
||||
private readonly ClientWebSocket _socket = new();
|
||||
private readonly HttpMessageInvoker _invoker;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the socket and the invoker for its upgrade with the client's redirect and cookie policy and its certificate callback.</summary>
|
||||
internal QqqPlatformSocketConnector(QqqTransportPolicy policy, RemoteCertificateValidationCallback certificate)
|
||||
{
|
||||
// MA0039: see QqqPlatformTransport.CreateHttpHandler; the callback is the session client's logged certificate policy.
|
||||
#pragma warning disable MA0039
|
||||
_invoker = new HttpMessageInvoker(new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = policy.FollowRedirects,
|
||||
UseCookies = policy.UseCookieJar,
|
||||
SslOptions = new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = certificate },
|
||||
});
|
||||
#pragma warning restore MA0039
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The HTTP status of the upgrade response, collected by the socket.</summary>
|
||||
public HttpStatusCode UpgradeStatus => _socket.HttpStatusCode;
|
||||
|
||||
|
||||
|
||||
/// <summary>The subprotocol the server selected.</summary>
|
||||
public string? SubProtocol => _socket.SubProtocol;
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the upgrade response's headers as the socket collected them and releases the socket's copy, which it would otherwise hold for as long as it lives.</summary>
|
||||
public IReadOnlyDictionary<string, IEnumerable<string>>? TakeUpgradeHeaders()
|
||||
{
|
||||
IReadOnlyDictionary<string, IEnumerable<string>>? headers = _socket.HttpResponseHeaders;
|
||||
_socket.HttpResponseHeaders = null;
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Copies the request onto the socket options and connects through the invoker.</summary>
|
||||
public Task ConnectAsync(QqqSocketRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_socket.Options.AddSubProtocol(request.SubProtocol);
|
||||
_socket.Options.CollectHttpResponseDetails = true;
|
||||
foreach(KeyValuePair<string, string> header in request.Headers)
|
||||
{ _socket.Options.SetRequestHeader(header.Key, header.Value); }
|
||||
_socket.Options.KeepAliveInterval = request.KeepAliveInterval;
|
||||
_socket.Options.KeepAliveTimeout = request.KeepAliveTimeout;
|
||||
return _socket.ConnectAsync(request.Uri, _invoker, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Wraps the connected socket so that disposing it also disposes the invoker that carried its upgrade.</summary>
|
||||
public WebSocket Release() => new OwnedPrintingSocket(_socket, _invoker);
|
||||
|
||||
|
||||
|
||||
/// <summary>Abandons the attempt: disposes the socket and its invoker.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_socket.Dispose();
|
||||
_invoker.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// The production transport: it constructs the platform's HTTP handler and WebSocket connector from the policy and
|
||||
/// certificate callbacks the session client hands it, and decides nothing itself.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage(Justification = "Constructs HttpClientHandler and the platform socket connector, which only do anything against a live TLS endpoint. Every setting applied here is chosen by QqqSessionClient and asserted through a fake IQqqTransport.")]
|
||||
internal sealed class QqqPlatformTransport : IQqqTransport
|
||||
{
|
||||
/// <summary>The single instance; the transport holds no state.</summary>
|
||||
internal static QqqPlatformTransport Instance { get; } = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds the platform HTTP handler with the client's redirect and cookie policy and its certificate callback.</summary>
|
||||
public HttpMessageHandler CreateHttpHandler(QqqTransportPolicy policy, Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool> certificate)
|
||||
{
|
||||
// MA0039: the callback is QqqSessionClient's, which 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.
|
||||
#pragma warning disable MA0039
|
||||
return new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = policy.FollowRedirects,
|
||||
UseCookies = policy.UseCookieJar,
|
||||
ServerCertificateCustomValidationCallback = certificate,
|
||||
};
|
||||
#pragma warning restore MA0039
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds a connector for one realtime socket attempt.</summary>
|
||||
public IQqqSocketConnector CreateSocketConnector(QqqTransportPolicy policy, RemoteCertificateValidationCallback certificate) => new QqqPlatformSocketConnector(policy, certificate);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// The identity provider's own sign-out, ready for the host to open in the browser. The address carries the provider's ID token
|
||||
/// hint, so it is held only in memory, handed straight to the browser, never logged, and its label is fixed text.
|
||||
/// </summary>
|
||||
public sealed class QqqProviderLogout
|
||||
{
|
||||
/// <summary>Records the address to open, the registered address the provider returns the browser to, and the random state that binds the return to this sign-out.</summary>
|
||||
public QqqProviderLogout(Uri endSessionUri, Uri redirectUri, string state)
|
||||
{
|
||||
EndSessionUri = endSessionUri ?? throw new ArgumentNullException(nameof(endSessionUri));
|
||||
RedirectUri = redirectUri ?? throw new ArgumentNullException(nameof(redirectUri));
|
||||
State = state ?? throw new ArgumentNullException(nameof(state));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The provider's end-session address including the sensitive ID token hint. Only open it in the browser.</summary>
|
||||
public Uri EndSessionUri { get; }
|
||||
|
||||
/// <summary>The registered callback the provider sends the browser to when the sign-out is finished.</summary>
|
||||
public Uri RedirectUri { get; }
|
||||
|
||||
/// <summary>The random state this sign-out sent and expects back.</summary>
|
||||
public string State { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>True only for a callback on exactly the registered address, with no fragment or user information and this sign-out's own state, so a forged or stale return cannot be taken as the provider finishing.</summary>
|
||||
public bool Matches(Uri callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if(!callback.IsAbsoluteUri || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0
|
||||
|| !string.Equals(callback.GetLeftPart(UriPartial.Path), RedirectUri.AbsoluteUri, StringComparison.Ordinal) || callback.Query.Length < 2)
|
||||
{ return false; }
|
||||
string? state = null;
|
||||
foreach(string entry in callback.Query[1..].Split('&'))
|
||||
{
|
||||
string[] pair = entry.Split('=', 2);
|
||||
if(pair.Length != 2)
|
||||
{ return false; }
|
||||
if(string.Equals(Uri.UnescapeDataString(pair[0].Replace('+', ' ')), "state", StringComparison.Ordinal))
|
||||
{
|
||||
if(state is not null)
|
||||
{ return false; }
|
||||
state = Uri.UnescapeDataString(pair[1].Replace('+', ' '));
|
||||
}
|
||||
}
|
||||
return string.Equals(state, State, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying a sign-out can never reveal the ID token hint or state.</summary>
|
||||
public override string ToString() => "QQQ provider sign-out (redacted)";
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Security.Authentication;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// What a compatibility check found out about a server, for the connection wizard: every problem with its kind, the
|
||||
/// discovery document when one was read, the sign-in methods on offer, the certificate that was refused when that is
|
||||
/// why the connection failed, and the origin of the sign-in provider the server names. The provider's origin is there
|
||||
/// whenever the sign-in description could be read and names one, whether or not sign-in is usable, so a caller can
|
||||
/// show the operator both addresses a certificate exception would cover before the exception is saved. It is a
|
||||
/// finding, not a failure: an unusable server produces a result, never an exception, so the wizard can show the
|
||||
/// operator what to fix. No field carries a credential.
|
||||
/// </summary>
|
||||
public sealed record QqqServerCompatibility(IReadOnlyList<QqqCompatibilityProblem> Problems, QqqApiDiscovery? Discovery, QqqSignInMethods SignInMethods,
|
||||
QqqCertificateRejection? Certificate = null, Uri? ProviderOrigin = null)
|
||||
{
|
||||
/// <summary>True when nothing stands between the operator and signing in: the server answered, is a QQQ server with a usable printing extension, and offers a sign-in method.</summary>
|
||||
public bool IsCompatible => Problems.Count == 0;
|
||||
|
||||
|
||||
|
||||
/// <summary>The earliest check that failed, or null when all passed; every stage before it can be shown as passed.</summary>
|
||||
public QqqCompatibilityStage? FailedStage => Problems.Count == 0 ? null : Problems.Min(problem => problem.Stage);
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the result contains a problem of the given kind.</summary>
|
||||
public bool Has(QqqCompatibilityIssue issue) => Problems.Any(problem => problem.Issue == issue);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Turns a failed request into the problem it stands for, or null for a failure that says nothing about the server (a
|
||||
/// programming error, for instance) and must keep propagating. A timeout, a name that does not resolve and a refused
|
||||
/// connection are "unreachable"; a failed TLS handshake is a secure connection failure, explained by the refused
|
||||
/// certificate when there was one; an answer that is not the expected document is reported as the given issue.
|
||||
/// The caller decides beforehand whether a cancellation was its own.
|
||||
/// </summary>
|
||||
internal static QqqCompatibilityProblem? FromFailure(Exception exception, QqqCompatibilityIssue unreadable, string document, QqqCertificateRejection? certificate)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
QqqSessionException session => new(unreadable, session.Failure switch
|
||||
{
|
||||
QqqSessionException.FailureKind.TransportRefused => $"the server refused or redirected the {document} request",
|
||||
QqqSessionException.FailureKind.AuthenticationRequired or QqqSessionException.FailureKind.PermissionDenied => $"the server asks for authorization before answering the {document} request",
|
||||
_ => $"the {document} answer was not a valid QQQ document (a sign-in page or proxy error page in place of the API is a common cause)",
|
||||
}),
|
||||
OperationCanceledException => new(QqqCompatibilityIssue.ServerUnreachable, "the server did not answer in time"),
|
||||
HttpRequestException or IOException => IsSecureConnectionFailure(exception)
|
||||
? new(QqqCompatibilityIssue.SecureConnectionFailed, SecureConnectionDetail(certificate))
|
||||
: new(QqqCompatibilityIssue.ServerUnreachable, UnreachableDetail(exception)),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the failure, or any cause behind it, is a TLS failure: the platform reports those either as a secure connection error or as an authentication exception underneath a generic request failure.</summary>
|
||||
private static bool IsSecureConnectionFailure(Exception exception)
|
||||
{
|
||||
for(Exception? current = exception; current is not null; current = current.InnerException)
|
||||
{
|
||||
if(current is AuthenticationException or HttpRequestException { HttpRequestError: HttpRequestError.SecureConnectionError })
|
||||
{ return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Explains a secure connection failure from the certificate that was refused, or as a plain handshake failure when no certificate was the cause.</summary>
|
||||
private static string SecureConnectionDetail(QqqCertificateRejection? certificate)
|
||||
{
|
||||
if(certificate is null)
|
||||
{ return "the secure connection could not be established (the TLS handshake failed)"; }
|
||||
if(certificate.IsUntrustedAuthority)
|
||||
{ return "Windows does not trust the authority that issued the server's certificate"; }
|
||||
return certificate.IsNameMismatch
|
||||
? "the server's certificate was issued for a different name or address"
|
||||
: "the server presented no usable certificate";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Explains why the server could not be reached, from the platform's own classification of the failed request.</summary>
|
||||
private static string UnreachableDetail(Exception exception) => exception switch
|
||||
{
|
||||
HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError } => "the server name could not be found",
|
||||
HttpRequestException { HttpRequestError: HttpRequestError.ConnectionError } => "the server refused the connection or is not listening on that port",
|
||||
_ => "the connection failed before the server answered",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Load balancer affinity. A QQQ server may run as several nodes behind a load balancer that names the node it chose in a
|
||||
/// cookie and expects that cookie back, so one client's requests keep reaching one node. This client attaches its cookies by
|
||||
/// hand and used to drop everything but the session, so its requests and its realtime socket could land on different nodes.
|
||||
/// It now keeps the load balancer's own cookies, and nothing else: they are held in memory only, for the life of this client
|
||||
/// object, sent only to the session origin, and replaced or removed whenever the load balancer rewrites them (which is how it
|
||||
/// moves a client off a node that stopped). They carry routing, never identity, so they are not saved with the session and
|
||||
/// signing out neither needs nor clears them.
|
||||
/// </summary>
|
||||
public sealed partial class QqqSessionClient
|
||||
{
|
||||
private const int MaximumAffinityCookies = 8;
|
||||
private const int MaximumAffinityValueLength = 4096;
|
||||
private readonly Lock _affinityLock = new();
|
||||
private readonly SortedDictionary<string, string> _affinity = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>How many load balancer cookies are held; for the log and tests, never their values.</summary>
|
||||
internal int AffinityCookieCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock(_affinityLock)
|
||||
{ return _affinity.Count; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Keeps or drops load balancer cookies named in a response's Set-Cookie headers. Only the Application Load Balancer
|
||||
/// family (names beginning AWSALB) is recognized. A cookie is ignored unless it is scoped to this host and the root path,
|
||||
/// has a plain bounded value and, when marked Secure, arrived over HTTPS; one the server expired is removed. A malformed
|
||||
/// header is skipped, because a routing hint must never fail a request.
|
||||
/// </summary>
|
||||
private void AdoptAffinity(IEnumerable<string> setCookieHeaders)
|
||||
{
|
||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||
bool secureOrigin = string.Equals(Origin.Scheme, "https", StringComparison.Ordinal);
|
||||
foreach(string header in setCookieHeaders)
|
||||
{
|
||||
string[] parts = header.Split(';');
|
||||
int separator = parts[0].IndexOf('=', StringComparison.Ordinal);
|
||||
if(header.Length > 8192 || separator <= 0)
|
||||
{ continue; }
|
||||
string name = parts[0][..separator].Trim();
|
||||
if(!IsAffinityCookieName(name))
|
||||
{ continue; }
|
||||
string value = parts[0][(separator + 1)..].Trim();
|
||||
bool expired = value.Length == 0;
|
||||
bool usable = value.Length <= MaximumAffinityValueLength && !value.Any(character => character is <= ' ' or '"' or ',' or '\\' or >= (char)0x7f);
|
||||
foreach(string attribute in parts.Skip(1))
|
||||
{
|
||||
string[] pair = attribute.Split('=', 2);
|
||||
string key = pair[0].Trim();
|
||||
string detail = pair.Length == 2 ? pair[1].Trim() : "";
|
||||
if(key.Equals("Max-Age", StringComparison.OrdinalIgnoreCase))
|
||||
{ expired |= long.TryParse(detail, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long seconds) && seconds <= 0; }
|
||||
else if(key.Equals("Expires", StringComparison.OrdinalIgnoreCase))
|
||||
{ expired |= DateTimeOffset.TryParse(detail, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset at) && at <= now; }
|
||||
else if(key.Equals("Domain", StringComparison.OrdinalIgnoreCase))
|
||||
{ usable &= string.Equals(detail.TrimStart('.'), Origin.Host, StringComparison.OrdinalIgnoreCase); }
|
||||
else if(key.Equals("Path", StringComparison.OrdinalIgnoreCase))
|
||||
{ usable &= string.Equals(detail, "/", StringComparison.Ordinal); }
|
||||
else if(key.Equals("Secure", StringComparison.OrdinalIgnoreCase))
|
||||
{ usable &= secureOrigin; }
|
||||
}
|
||||
lock(_affinityLock)
|
||||
{
|
||||
if(expired)
|
||||
{ _affinity.Remove(name); }
|
||||
else if(usable && (_affinity.ContainsKey(name) || _affinity.Count < MaximumAffinityCookies))
|
||||
{ _affinity[name] = value; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The Cookie header for a request to the session origin: the session cookies the caller passed, when any, followed by the load balancer cookies; null when there is neither.</summary>
|
||||
[return: NotNullIfNotNull(nameof(sessionCookie))]
|
||||
private string? WithAffinity(string? sessionCookie)
|
||||
{
|
||||
string affinity;
|
||||
lock(_affinityLock)
|
||||
{
|
||||
if(_affinity.Count == 0)
|
||||
{ return sessionCookie; }
|
||||
affinity = string.Join("; ", _affinity.Select(pair => pair.Key + "=" + pair.Value));
|
||||
}
|
||||
return string.IsNullOrEmpty(sessionCookie) ? affinity : sessionCookie + "; " + affinity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the load balancer cookies from the answer to a WebSocket upgrade, which the connector hands over and lets go
|
||||
/// of. It runs whether the upgrade was accepted or refused: a node that is stopping answers 502 or 503, and that answer
|
||||
/// carries the load balancer's new choice of node for the retry. The header name is matched without regard to letter
|
||||
/// case, here and not in the connector, so the platform adapter decides nothing. An attempt that got no HTTP answer
|
||||
/// changes nothing.
|
||||
/// </summary>
|
||||
private void AdoptSocketAffinity(IQqqSocketConnector connector)
|
||||
{
|
||||
IReadOnlyDictionary<string, IEnumerable<string>>? headers = connector.TakeUpgradeHeaders();
|
||||
if(headers is null)
|
||||
{ return; }
|
||||
foreach(KeyValuePair<string, IEnumerable<string>> header in headers)
|
||||
{
|
||||
if(header.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase))
|
||||
{ AdoptAffinity(header.Value); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True for the Application Load Balancer's cookie names: AWSALB, AWSALBCORS, AWSALBTG, AWSALBTGCORS and the AWSALBAPP series.</summary>
|
||||
private static bool IsAffinityCookieName(string name) => name.Length <= 64 && name.StartsWith("AWSALB", StringComparison.Ordinal)
|
||||
&& name.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_');
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility part of the session client: the same public discovery and authentication requests every sign-in begins
|
||||
/// with, reported as a typed result instead of a thrown failure, so the connection wizard can tell the operator which
|
||||
/// step failed and why. Nothing here sends a credential, and nothing here ends a session the client holds, whatever
|
||||
/// the server answers.
|
||||
/// </summary>
|
||||
public sealed partial class QqqSessionClient
|
||||
{
|
||||
private QqqCertificateRejection? _certificateRejection;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The certificate this client refused most recently, with the origin that presented it and the time on the
|
||||
/// client's clock, or null when it has refused none since its last compatibility check began. The client talks to
|
||||
/// its server and to the sign-in provider that server names, and the rejection's origin says which of them it was.
|
||||
/// </summary>
|
||||
public QqqCertificateRejection? LastCertificateRejection => Volatile.Read(ref _certificateRejection);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether this origin is a server the agent can use and says how a user may sign in. The server is asked
|
||||
/// afresh each time, so a retry after the operator fixed something sees the fix. An unreachable address, a refused
|
||||
/// certificate, an answer that is not QQQ discovery, a missing or unusable printing extension and an unsupported
|
||||
/// sign-in description are each returned as problems; only the caller's own cancellation and programming errors throw.
|
||||
/// A certificate the client refused is returned with the problem at whichever of the two requests it was refused.
|
||||
/// Passing the browser callback the host will use also checks that the server lists it, so the wizard never offers a
|
||||
/// browser button that cannot work. A fully usable answer is cached exactly as <see cref="DiscoverAsync"/> would.
|
||||
/// </summary>
|
||||
public async Task<QqqServerCompatibility> CheckCompatibilityAsync(Uri? browserRedirect = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_diagnostics.Info($"Checking whether {Origin.Authority} is a server this agent can use.");
|
||||
Volatile.Write(ref _certificateRejection, null);
|
||||
QqqApiDiscovery discovery;
|
||||
try
|
||||
{ discovery = await FetchDiscoveryAsync(keepSession: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch(Exception exception)
|
||||
{
|
||||
if(Unreadable(exception, QqqCompatibilityIssue.NotQqqServer, "discovery", cancellationToken) is not { } unreadable)
|
||||
{ throw; }
|
||||
return Report(new QqqServerCompatibility([unreadable], null, QqqSignInMethods.None, RefusedCertificate(unreadable)));
|
||||
}
|
||||
List<QqqCompatibilityProblem> problems = discovery.Problems(nativeSession: false, realtime: true);
|
||||
if(problems.Count != 0)
|
||||
{ return Report(new QqqServerCompatibility(problems, discovery, QqqSignInMethods.None)); }
|
||||
_discovery = discovery;
|
||||
(QqqSignInMethods methods, QqqCompatibilityProblem? signIn, Uri? provider) = await SignInMethodsAsync(discovery, browserRedirect, cancellationToken).ConfigureAwait(false);
|
||||
return Report(new QqqServerCompatibility(signIn is null ? [] : [signIn], discovery, methods, RefusedCertificate(signIn), provider));
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Turns a failed public request into the problem it stands for, or null for a failure that says nothing about the
|
||||
/// server and must keep propagating. The caller's cancellation is checked first and thrown from here, so it wins
|
||||
/// over whatever failure surfaced at the same moment: a wizard that was closed must see its own cancellation, not
|
||||
/// "unreachable" and not a connection error.
|
||||
/// </summary>
|
||||
private QqqCompatibilityProblem? Unreadable(Exception exception, QqqCompatibilityIssue issue, string document, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return QqqServerCompatibility.FromFailure(exception, issue, document, Volatile.Read(ref _certificateRejection));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The certificate this check refused, when that is what the problem reports; null for every other problem, so a certificate is never shown beside a failure it did not cause.</summary>
|
||||
private QqqCertificateRejection? RefusedCertificate(QqqCompatibilityProblem? problem)
|
||||
=> problem?.Issue == QqqCompatibilityIssue.SecureConnectionFailed ? Volatile.Read(ref _certificateRejection) : null;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the authentication metadata of a server that passed discovery and names the sign-in methods it offers, or the
|
||||
/// problem that leaves none. Table-based servers offer a password; OAuth2 servers offer the browser when their native
|
||||
/// grant passes the same checks sign-in applies and lists the host's callback. Metadata that passed is cached, as
|
||||
/// sign-in would. The origin of the provider the description names is returned whenever the description was read,
|
||||
/// usable or not.
|
||||
/// </summary>
|
||||
private async Task<(QqqSignInMethods Methods, QqqCompatibilityProblem? Problem, Uri? ProviderOrigin)> SignInMethodsAsync(QqqApiDiscovery discovery, Uri? browserRedirect, CancellationToken cancellationToken)
|
||||
{
|
||||
QqqAuthenticationMetadata metadata;
|
||||
try
|
||||
{ metadata = await FetchAuthenticationAsync(keepSession: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch(Exception exception)
|
||||
{
|
||||
if(Unreadable(exception, QqqCompatibilityIssue.SignInUnavailable, "sign-in description", cancellationToken) is not { } unreadable)
|
||||
{ throw; }
|
||||
return (QqqSignInMethods.None, unreadable, null);
|
||||
}
|
||||
if(string.Equals(metadata.Type, "TABLE_BASED", StringComparison.Ordinal))
|
||||
{
|
||||
_authentication = metadata;
|
||||
return (QqqSignInMethods.Password, null, null);
|
||||
}
|
||||
List<string> problems = BrowserSignInProblems(discovery, metadata, browserRedirect);
|
||||
if(problems.Count != 0)
|
||||
{ return (QqqSignInMethods.None, new QqqCompatibilityProblem(QqqCompatibilityIssue.SignInUnavailable, string.Join("; ", problems)), metadata.ProviderOrigin); }
|
||||
_authentication = metadata;
|
||||
return (QqqSignInMethods.Browser, null, metadata.ProviderOrigin);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Lists why browser sign-in cannot be offered: an authentication type the agent does not implement, a server too old
|
||||
/// for the native session grant, a grant that fails the sign-in checks, or, when the grant is otherwise usable, a
|
||||
/// callback the server does not list. An empty list means the browser button will work.
|
||||
/// </summary>
|
||||
private List<string> BrowserSignInProblems(QqqApiDiscovery discovery, QqqAuthenticationMetadata metadata, Uri? browserRedirect)
|
||||
{
|
||||
if(!string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
|
||||
{ return [$"authentication type '{QqqCompatibilityProblem.Printable(metadata.Type)}' is not supported; only TABLE_BASED and OAUTH2 are"]; }
|
||||
List<string> problems = [.. discovery.Problems(nativeSession: true).Select(problem => problem.Detail), .. NativeGrantProblems(metadata)];
|
||||
if(problems.Count == 0 && browserRedirect is not null
|
||||
&& !(ValidRedirect(browserRedirect) && metadata.Values!.NativeSessionLogin!.RedirectUris.Contains(browserRedirect.AbsoluteUri, StringComparer.Ordinal)))
|
||||
{ problems.Add($"the server's native session grant does not list the callback {QqqCompatibilityProblem.Printable(browserRedirect.OriginalString.Split('?', 2)[0])}"); }
|
||||
return problems;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Writes the outcome of a compatibility check to the log, one line naming every problem, and returns the result unchanged.</summary>
|
||||
private QqqServerCompatibility Report(QqqServerCompatibility result)
|
||||
{
|
||||
_diagnostics.Info(result.IsCompatible
|
||||
? $"Compatibility check passed: printing extension {result.Discovery?.PrintingExtensionVersion ?? "(missing)"}; sign-in by {result.SignInMethods}."
|
||||
: $"Compatibility check found {result.Problems.Count} problem(s): {string.Join("; ", result.Problems.Select(problem => problem.Issue + " (" + problem.Detail + ")"))}.");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ public sealed partial class QqqSessionClient
|
||||
["state"] = state,
|
||||
["nonce"] = nonce,
|
||||
["prompt"] = grant.Prompt,
|
||||
// prompt=login alone was seen reusing a retained provider session on real Authentik; max_age=0 makes any session too old.
|
||||
["max_age"] = "0",
|
||||
["code_challenge_method"] = "S256",
|
||||
["code_challenge"] = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))),
|
||||
};
|
||||
@@ -59,11 +61,14 @@ public sealed partial class QqqSessionClient
|
||||
: $"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.");
|
||||
_diagnostics.Info($"Browser sign-in attempt started: the authorization page is {ShownText(QqqSessionDiagnostics.Describe(builder.Uri))} (query not logged); waiting for the callback, which expires after five minutes.");
|
||||
return _browserLogin;
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Browser sign-in start", exception))
|
||||
{ throw; }
|
||||
catch(Exception exception)
|
||||
{
|
||||
ReportFailure("Browser sign-in start", exception);
|
||||
throw;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
@@ -79,7 +84,7 @@ public sealed partial class QqqSessionClient
|
||||
}
|
||||
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.");
|
||||
_diagnostics.Info($"Browser sign-in refused: the server's native grant does not list the callback {redirectUri.AbsoluteUri}; it lists [{ShownAddresses(grant.RedirectUris)}]. Add this callback to the QQQ native session grant.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
}
|
||||
@@ -93,7 +98,7 @@ public sealed partial class QqqSessionClient
|
||||
/// </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))}.");
|
||||
_diagnostics.Info($"Fetching the identity provider's discovery document from {ShownText(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);
|
||||
@@ -103,15 +108,17 @@ public sealed partial class QqqSessionClient
|
||||
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")}.");
|
||||
_diagnostics.Info($"Provider discovery: issuer {ShownAddress(providerIssuer)}, authorization endpoint {ShownAddress(providerAuthorization)}, 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}'.");
|
||||
_diagnostics.Info($"Browser sign-in refused: the provider's issuer '{ShownText(providerIssuer)}' does not exactly match the issuer QQQ advertises '{ShownText(grant.Issuer)}'.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(!TryHttps(providerAuthorization, out Uri? authorizationUri) || !SameOrigin(authorizationUri!, new Uri(grant.Issuer)))
|
||||
string? problem = HttpsProblem(providerAuthorization, out Uri? authorizationUri) ?? (SameOrigin(authorizationUri!, new Uri(grant.Issuer)) ? null : "is on another origin");
|
||||
if(problem is not null)
|
||||
{
|
||||
_diagnostics.Info("Browser sign-in refused: the provider's authorization endpoint is not an HTTPS address on the issuer's origin.");
|
||||
// The line above shows the endpoint without what it left out, so this one names the rule that refused it.
|
||||
_diagnostics.Info($"Browser sign-in refused: the provider's authorization endpoint is not an HTTPS address on the issuer's origin: the endpoint {problem}.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(!s256)
|
||||
@@ -139,46 +146,7 @@ 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
|
||||
|| !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))
|
||||
{
|
||||
_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();
|
||||
}
|
||||
string code = AcceptCallback(pending, attempt, callback);
|
||||
_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)),
|
||||
@@ -187,13 +155,69 @@ public sealed partial class QqqSessionClient
|
||||
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; }
|
||||
catch(Exception exception)
|
||||
{
|
||||
ReportFailure("Browser sign-in completion", exception);
|
||||
throw;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks a browser callback against the attempt it claims to finish and returns its authorization code. The callback must
|
||||
/// belong to the pending attempt, arrive within five minutes, be exactly the registered redirect with no fragment or user
|
||||
/// information, carry no provider error, the attempt's own state, a usable code and, when it names one, the expected issuer.
|
||||
/// Each refusal is logged with its reason and reported as the same generic invalid response.
|
||||
/// </summary>
|
||||
private string AcceptCallback(QqqBrowserLogin? pending, QqqBrowserLogin attempt, Uri callback)
|
||||
{
|
||||
TimeSpan age = _timeProvider.GetElapsedTime(attempt.StartedAt);
|
||||
_diagnostics.Info($"Browser callback received at {ShownText(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
|
||||
|| !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: [{ShownList(values.Keys)}] (values not logged).");
|
||||
if(values.TryGetValue("error", out string? providerError))
|
||||
{
|
||||
_diagnostics.Info($"Browser sign-in refused by the identity provider: error '{Shown(providerError)}'{(values.TryGetValue("error_description", out string? description) ? " (" + ShownText(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 '{ShownText(issuer)}' differs from the expected '{ShownText(attempt.Issuer)}'.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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
|
||||
@@ -229,57 +253,110 @@ public sealed partial class QqqSessionClient
|
||||
private QqqAuthenticationMetadata.NativeGrant ValidateNativeGrant(QqqAuthenticationMetadata metadata)
|
||||
{
|
||||
_discovery!.Validate(nativeSession: true, report: _diagnostics.Info);
|
||||
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(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"); }
|
||||
List<string> problems = NativeGrantProblems(metadata);
|
||||
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).");
|
||||
QqqAuthenticationMetadata.NativeGrant grant = metadata.Values!.NativeSessionLogin!;
|
||||
_diagnostics.Info($"Native browser sign-in grant accepted: issuer {ShownAddress(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>
|
||||
/// Lists, in plain words, every reason the published native-session grant cannot be used; an empty list means it can.
|
||||
/// The sign-in path throws on a non-empty list and the compatibility check reports it, so both judge the grant by the
|
||||
/// same rules. The reasons are shown to an operator before anyone has signed in, so every value the server supplied
|
||||
/// is quoted through <see cref="QqqCompatibilityProblem.Printable"/>, never raw.
|
||||
/// </summary>
|
||||
private List<string> NativeGrantProblems(QqqAuthenticationMetadata metadata)
|
||||
{
|
||||
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 '{QqqCompatibilityProblem.Printable(metadata.Type)}', not OAUTH2"); }
|
||||
if(grant is null)
|
||||
{ problems.Add("no nativeSessionLogin grant is published"); }
|
||||
else
|
||||
{ AddGrantProblems(grant, problems); }
|
||||
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 '{QqqCompatibilityProblem.Printable(metadata.Values?.Scopes)}' do not include openid"); }
|
||||
return problems;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Adds the shortfalls of the grant itself: flow, challenge method, prompt, nonce policy, issuer and discovery addresses, and the redirect list.</summary>
|
||||
private static void AddGrantProblems(QqqAuthenticationMetadata.NativeGrant grant, List<string> problems)
|
||||
{
|
||||
if(!string.Equals(grant.Flow, QqqAuthenticationMetadata.NativeSessionFlow, StringComparison.Ordinal))
|
||||
{ problems.Add($"grant flow is '{QqqCompatibilityProblem.Printable(grant.Flow)}', expected {QqqAuthenticationMetadata.NativeSessionFlow}"); }
|
||||
if(!string.Equals(grant.CodeChallengeMethod, "S256", StringComparison.Ordinal))
|
||||
{ problems.Add($"code challenge method is '{QqqCompatibilityProblem.Printable(grant.CodeChallengeMethod)}', expected S256"); }
|
||||
if(!string.Equals(grant.Prompt, "login", StringComparison.Ordinal))
|
||||
{ problems.Add($"prompt is '{QqqCompatibilityProblem.Printable(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 '{QqqCompatibilityProblem.Printable(grant.NonceValidation)}' is not one this client supports (expected REQUIRED, OPTIONAL or absent)"); }
|
||||
if(!TryHttps(grant.Issuer, out _))
|
||||
{ problems.Add($"issuer '{QqqCompatibilityProblem.Printable(grant.Issuer)}' is not a plain HTTPS address"); }
|
||||
if(!TryHttps(grant.DiscoveryUrl, out _))
|
||||
{ problems.Add($"discovery address '{QqqCompatibilityProblem.Printable(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");
|
||||
return;
|
||||
}
|
||||
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: " + QqqCompatibilityProblem.PrintableList(invalid)); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Parses an address and accepts it only when it is an absolute HTTPS address that has an origin (see
|
||||
/// <see cref="QqqOrigin.Of"/>) and also passes the configuration-address safety checks. Every address that comes
|
||||
/// through here is text a server supplied, and each is later compared with another, shown, or connected to. A
|
||||
/// host the platform cannot name in ASCII (one that holds a line separator) has no origin, and the platform
|
||||
/// throws when asked to connect to it, so such an address is refused here, where the caller reports a refusal,
|
||||
/// and never reaches a request. The rules are those of <see cref="HttpsProblem"/>, which also says which one failed.
|
||||
/// </summary>
|
||||
private static bool TryHttps(string? value, out Uri? uri) => HttpsProblem(value, out uri) is null;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Says which rule a server-supplied HTTPS address breaks, as the end of a sentence about it for a log line, or
|
||||
/// null when it breaks none and may be used. The rules are tried in a fixed order and the first one broken is
|
||||
/// named. Nothing of the address is in the answer: a line that describes the address leaves out its query, its
|
||||
/// fragment and its user information, which may carry a secret, so the reason is given by naming the rule and
|
||||
/// never by showing what broke it.
|
||||
/// </summary>
|
||||
private static string? HttpsProblem(string? value, out Uri? uri)
|
||||
{
|
||||
if(!Uri.TryCreate(value, UriKind.Absolute, out uri))
|
||||
{ return "is missing or is not an absolute address"; }
|
||||
Uri address = uri;
|
||||
(bool Broken, string Rule)[] rules =
|
||||
[
|
||||
(!string.Equals(address.Scheme, "https", StringComparison.Ordinal), "is not an HTTPS address"),
|
||||
(QqqOrigin.Of(address) is null, "has a host with no ASCII name"),
|
||||
(address.UserInfo.Length != 0, "carries user information"),
|
||||
(address.Query.Length != 0, "carries a query"),
|
||||
(address.Fragment.Length != 0, "carries a fragment"),
|
||||
(!SafeConfigurationUri(address), "names port 0, is longer than 2,048 characters, or contains a wildcard or a backslash"),
|
||||
];
|
||||
return rules.Where(rule => rule.Broken).Select(rule => rule.Rule).FirstOrDefault();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Provider sign-out part of the session client. Signing out of the agent must also end the identity provider's browser session,
|
||||
/// because the provider is meant to be invisible: leaving it signed in would let the next sign-in skip the credential prompt.
|
||||
/// The server describes the provider's end-session request at logout, this client checks it against the provider's own discovery,
|
||||
/// and the host opens it in the browser.
|
||||
/// </summary>
|
||||
public sealed partial class QqqSessionClient
|
||||
{
|
||||
private const int IdTokenHintLimit = 32_768;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Ends the QQQ session and, when the server offers provider sign-out for this exact callback, returns the provider's end-session
|
||||
/// request for the host to open in the browser. The request must name the issuer QQQ advertises, an end-session endpoint that is
|
||||
/// HTTPS on the issuer's origin and identical to the provider's own discovery, and the callback that was asked for; anything else
|
||||
/// is refused. A server without the capability gets the ordinary logout and null, and says so in the log. The local session is
|
||||
/// forgotten in every case, including failure.
|
||||
/// </summary>
|
||||
public async Task<QqqProviderLogout?> SignOutWithProviderAsync(Uri postLogoutRedirectUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(postLogoutRedirectUri);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if(_sessionToken is null)
|
||||
{
|
||||
_diagnostics.Info("Sign-out requested with no held session; clearing local state only.");
|
||||
return null;
|
||||
}
|
||||
_diagnostics.Info($"Signing out of {Origin.Authority}: ending the server session and asking for the identity provider sign-out.");
|
||||
QqqAuthenticationMetadata metadata = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
|
||||
string? unavailable = ProviderLogoutUnavailable(metadata, grant, postLogoutRedirectUri);
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/logout"), HttpMethod.Post,
|
||||
unavailable is null ? JsonBody(new { nativeLogout = new { postLogoutRedirectUri = postLogoutRedirectUri.AbsoluteUri } }) : JsonBody(new { }),
|
||||
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
_diagnostics.Info("Server session ended.");
|
||||
if(unavailable is not null)
|
||||
{
|
||||
_diagnostics.Info("The identity provider session was NOT ended: " + unavailable + ".");
|
||||
return null;
|
||||
}
|
||||
return await ProviderLogoutFromAsync(response, grant!, postLogoutRedirectUri, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
ReportFailure("Provider sign-out", exception);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearSession();
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Says why provider sign-out cannot be requested (no capability, no listed callback, a password server), or null when it can.</summary>
|
||||
private string? ProviderLogoutUnavailable(QqqAuthenticationMetadata metadata, QqqAuthenticationMetadata.NativeGrant? grant, Uri redirect)
|
||||
{
|
||||
if(!string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal) || grant is null)
|
||||
{ return "this server signs in with a password, so there is no identity provider session"; }
|
||||
if(_discovery?.SupportsNativeSessionLogout != true)
|
||||
{ return "the server does not advertise the nativeSessionLogout capability (UI API 1.10 or later)"; }
|
||||
QqqAuthenticationMetadata.NativeLogout? logout = metadata.Values?.NativeSessionLogout;
|
||||
if(logout is null || !string.Equals(logout.Flow, QqqAuthenticationMetadata.NativeLogout.RpInitiatedFlow, StringComparison.Ordinal))
|
||||
{ return "the server publishes no OIDC_RP_INITIATED_LOGOUT descriptor"; }
|
||||
if(logout.PostLogoutRedirectUris is null || !logout.PostLogoutRedirectUris.Contains(redirect.AbsoluteUri, StringComparer.Ordinal))
|
||||
{ return $"the server does not list {redirect.AbsoluteUri} as a post-logout callback"; }
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads and checks the provider data the server returned at logout, then builds the end-session request with a fresh state. Anything that does not match is refused without logging the hint.</summary>
|
||||
private async Task<QqqProviderLogout?> ProviderLogoutFromAsync(HttpResponseMessage response, QqqAuthenticationMetadata.NativeGrant grant, Uri redirect, CancellationToken cancellationToken)
|
||||
{
|
||||
using JsonDocument document = ParseDocument(response);
|
||||
if(!document.RootElement.TryGetProperty("nativeLogoutData", out JsonElement data) || data.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
_diagnostics.Info("The identity provider session was NOT ended: the server returned no provider sign-out data (an older session, or none was stored).");
|
||||
return null;
|
||||
}
|
||||
string? issuer = StringProperty(data, "issuer");
|
||||
string? endpoint = StringProperty(data, "endSessionEndpoint");
|
||||
string? callback = StringProperty(data, "postLogoutRedirectUri");
|
||||
string? hint = StringProperty(data, "idTokenHint");
|
||||
if(!string.Equals(issuer, grant.Issuer, StringComparison.Ordinal) || !string.Equals(callback, redirect.AbsoluteUri, StringComparison.Ordinal)
|
||||
|| string.IsNullOrEmpty(hint) || hint.Length > IdTokenHintLimit || hint.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))
|
||||
|| !TryHttps(endpoint, out Uri? endSession) || !SameOrigin(endSession!, new Uri(grant.Issuer)))
|
||||
{
|
||||
_diagnostics.Info("Provider sign-out refused: the data the server returned does not match the issuer, callback or HTTPS end-session endpoint it advertised.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
Uri discovered = await ProviderEndSessionEndpointAsync(grant, cancellationToken).ConfigureAwait(false);
|
||||
if(!string.Equals(discovered.AbsoluteUri, endSession!.AbsoluteUri, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info("Provider sign-out refused: the end-session endpoint from the server differs from the one in the identity provider's own discovery.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
string state = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var query = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["id_token_hint"] = hint,
|
||||
["post_logout_redirect_uri"] = redirect.AbsoluteUri,
|
||||
["state"] = state,
|
||||
};
|
||||
var builder = new UriBuilder(endSession)
|
||||
{
|
||||
Query = string.Join("&", query.Select(pair => Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))),
|
||||
};
|
||||
_diagnostics.Info($"Provider sign-out ready: the end-session page is {ShownText(QqqSessionDiagnostics.Describe(builder.Uri))} (query not logged).");
|
||||
return new QqqProviderLogout(builder.Uri, redirect, state);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads a string property of a JSON object, or null when it is missing or not a string.</summary>
|
||||
private static string? StringProperty(JsonElement element, string name) => element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the identity provider's discovery document and returns its end-session endpoint, after the same issuer, HTTPS and
|
||||
/// same-origin checks the authorization endpoint gets. A provider without one cannot be signed out of from here.
|
||||
/// </summary>
|
||||
private async Task<Uri> ProviderEndSessionEndpointAsync(QqqAuthenticationMetadata.NativeGrant grant, CancellationToken cancellationToken)
|
||||
{
|
||||
_diagnostics.Info($"Fetching the identity provider's discovery document from {ShownText(QqqSessionDiagnostics.Describe(new Uri(grant.DiscoveryUrl)))} for its end-session endpoint.");
|
||||
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);
|
||||
string? providerIssuer = StringProperty(document.RootElement, "issuer");
|
||||
string? endpoint = StringProperty(document.RootElement, "end_session_endpoint");
|
||||
if(!string.Equals(providerIssuer, grant.Issuer, StringComparison.Ordinal) || !TryHttps(endpoint, out Uri? endSession) || !SameOrigin(endSession!, new Uri(grant.Issuer)))
|
||||
{
|
||||
_diagnostics.Info("Provider sign-out refused: the provider's discovery names a different issuer or no HTTPS end-session endpoint on the issuer's origin.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
return endSession!;
|
||||
}
|
||||
}
|
||||
@@ -3,73 +3,56 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
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.
|
||||
/// proof, and they are sent to the discovered origin and nowhere else. The platform socket itself comes from the transport; what
|
||||
/// is sent on the upgrade and what each outcome means is decided here.
|
||||
/// </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>The subprotocol this agent speaks; a server that selects anything else is refused.</summary>
|
||||
private const string PrintingSubProtocol = "qqq-printing.v1";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes standard discovery once per socket establishment, then connects only to that origin with the current
|
||||
/// session and approved device proof. The answer to the upgrade is read for the load balancer's cookies whatever the
|
||||
/// outcome, an accepted upgrade, a refusal with 401 or 403 and any other failure alike, because a refusal from a
|
||||
/// node that is stopping carries the load balancer's choice of node for the retry.
|
||||
/// </summary>
|
||||
public async Task<WebSocket> ConnectPrintingAsync(TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout, CancellationToken cancellationToken = default)
|
||||
{
|
||||
HttpMessageInvoker invoker = CreateSocketInvoker();
|
||||
var socket = new ClientWebSocket();
|
||||
long generation;
|
||||
IQqqSocketConnector connector = _transport.CreateSocketConnector(TransportPolicy, ValidateSocketCertificate);
|
||||
try
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
(QqqSocketRequest request, long generation) = await PrepareSocketAsync(heartbeatInterval, heartbeatTimeout, cancellationToken).ConfigureAwait(false);
|
||||
_diagnostics.Info($"Opening the realtime socket to {QqqSessionDiagnostics.Describe(request.Uri)} with subprotocol {PrintingSubProtocol} (session cookie and device proof attached; heartbeat every {heartbeatInterval.TotalSeconds:0} s, timeout {heartbeatTimeout.TotalSeconds:0} s).");
|
||||
long started = _timeProvider.GetTimestamp();
|
||||
try
|
||||
{
|
||||
RequireAuthenticated();
|
||||
_diagnostics.Info("Preparing the realtime printing socket: refreshing discovery first.");
|
||||
await RefreshPrintingDiscoveryAsync(cancellationToken).ConfigureAwait(false);
|
||||
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);
|
||||
}
|
||||
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();
|
||||
}
|
||||
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);
|
||||
{ await connector.ConnectAsync(request, cancellationToken).ConfigureAwait(false); }
|
||||
finally { AdoptSocketAffinity(connector); }
|
||||
_diagnostics.Info($"Realtime socket upgraded in {Elapsed(started)}; server selected subprotocol {ShownOr(connector.SubProtocol, "(none)")}.");
|
||||
await ConfirmSocketAsync(connector.SubProtocol, generation, cancellationToken).ConfigureAwait(false);
|
||||
return connector.Release();
|
||||
}
|
||||
catch(WebSocketException exception) when(socket.HttpStatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden)
|
||||
catch(WebSocketException exception) when(connector.UpgradeStatus is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
_diagnostics.Info($"Realtime socket upgrade refused with HTTP {(int)socket.HttpStatusCode} ({exception.WebSocketErrorCode}); authorization is required.");
|
||||
socket.Dispose();
|
||||
invoker.Dispose();
|
||||
_diagnostics.Info($"Realtime socket upgrade refused with HTTP {(int)connector.UpgradeStatus} ({exception.WebSocketErrorCode}); authorization is required.");
|
||||
connector.Dispose();
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.AuthenticationRequired, "QQQ authorization is required to restore the realtime connection.");
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
ReportSocketFailure(exception, socket);
|
||||
socket.Dispose();
|
||||
invoker.Dispose();
|
||||
ReportSocketFailure(exception, connector.UpgradeStatus);
|
||||
connector.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -77,52 +60,93 @@ public sealed partial class QqqSessionClient
|
||||
|
||||
|
||||
/// <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.
|
||||
/// Under the client gate: requires a session, refreshes discovery, requires an approved device binding, and builds the
|
||||
/// upgrade request (address, subprotocol, cookies, origin, client channel, device proof and keep-alive). It
|
||||
/// returns the request with the session generation it was built under, so a session change during the connect is noticed.
|
||||
/// The upgrade carries the load balancer's cookies beside the session, so the socket opens on the node this client's
|
||||
/// HTTP requests already reach, and that node then serves its claims and downloads too. The discovery made just
|
||||
/// before may itself have rewritten those cookies, and the request is built after it, so it carries the newest.
|
||||
/// </summary>
|
||||
private HttpMessageInvoker CreateSocketInvoker()
|
||||
private async Task<(QqqSocketRequest Request, long Generation)> PrepareSocketAsync(TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout, CancellationToken cancellationToken)
|
||||
{
|
||||
#pragma warning disable MA0039
|
||||
return new HttpMessageInvoker(new SocketsHttpHandler
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
|
||||
RequireAuthenticated();
|
||||
_diagnostics.Info("Preparing the realtime printing socket: refreshing discovery first.");
|
||||
await RefreshPrintingDiscoveryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if(_device is null || _binding?.HasApprovedDevice != true)
|
||||
{
|
||||
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
|
||||
_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.");
|
||||
}
|
||||
var uri = new UriBuilder(new Uri(Origin, PrintingPath + "device/realtime")) { Scheme = string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) ? "wss" : "ws" };
|
||||
KeyValuePair<string, string>[] headers =
|
||||
[
|
||||
new("Cookie", WithAffinity(CookieHeader())),
|
||||
new("Origin", Origin.GetLeftPart(UriPartial.Authority)),
|
||||
new("X-QQQ-Client", ClientChannel),
|
||||
new("X-QQQ-Device-Id", _device.DeviceId),
|
||||
new("X-QQQ-Device-Token", _device.DeviceToken),
|
||||
];
|
||||
return (new QqqSocketRequest(uri.Uri, PrintingSubProtocol, headers, heartbeatInterval, heartbeatTimeout), _generation);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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)
|
||||
/// <summary>Under the client gate: refuses the upgraded socket when the session changed while it was connecting, or when the server did not select this agent's subprotocol.</summary>
|
||||
private async Task ConfirmSocketAsync(string? subProtocol, long generation, CancellationToken cancellationToken)
|
||||
{
|
||||
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;
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if(generation != _generation)
|
||||
{
|
||||
_diagnostics.Info("Realtime socket discarded: the session changed while it was connecting.");
|
||||
throw AuthenticationRequired();
|
||||
}
|
||||
if(!string.Equals(subProtocol, PrintingSubProtocol, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info("Realtime socket refused: the server did not select the qqq-printing.v1 subprotocol.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The certificate decision for the HTTP client: the same logged policy as everywhere else, judged for the address the
|
||||
/// request is going to, which is what decides whether the certificate exception applies, or for the origin when a
|
||||
/// request carries no address.
|
||||
/// </summary>
|
||||
private bool ValidateHttpCertificate(HttpRequestMessage request, X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors errors)
|
||||
=> EvaluateCertificate(request.RequestUri ?? Origin, certificate, errors);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The certificate decision for the WebSocket upgrade: the same logged policy, so a rejected or explicitly allowed
|
||||
/// certificate is reported the same way for the socket as for HTTP. The upgrade only ever goes to this client's own
|
||||
/// origin and follows no redirect, so that origin is the address the certificate exception is asked about. The
|
||||
/// platform may hand over the older certificate type, which is converted so its details can be logged.
|
||||
/// </summary>
|
||||
private bool ValidateSocketCertificate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
|
||||
=> EvaluateCertificate(Origin, certificate is null ? null : certificate as X509Certificate2 ?? new X509Certificate2(certificate), errors);
|
||||
|
||||
|
||||
|
||||
/// <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)
|
||||
private void ReportSocketFailure(Exception exception, HttpStatusCode upgradeStatus)
|
||||
{
|
||||
int status = (int)socket.HttpStatusCode;
|
||||
int status = (int)upgradeStatus;
|
||||
_diagnostics.Info(exception is WebSocketException webSocket
|
||||
? $"Realtime socket upgrade failed: {webSocket.WebSocketErrorCode}{(status == 0 ? "" : ", HTTP " + status.ToString(System.Globalization.CultureInfo.InvariantCulture))}: {webSocket.Message}"
|
||||
? $"Realtime socket upgrade failed: {webSocket.WebSocketErrorCode}{(status == 0 ? "" : ", HTTP " + status.ToString(System.Globalization.CultureInfo.InvariantCulture))}: {ShownText(webSocket.Message)}"
|
||||
: exception is OperationCanceledException ? "Realtime socket connection was cancelled or timed out."
|
||||
: "Realtime socket connection failed: " + QqqSessionDiagnostics.Describe(exception));
|
||||
: "Realtime socket connection failed: " + ShownText(QqqSessionDiagnostics.Describe(exception)));
|
||||
}
|
||||
|
||||
|
||||
@@ -132,12 +156,13 @@ public sealed partial class QqqSessionClient
|
||||
{
|
||||
_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)
|
||||
// Discovery already required exactly one printing extension with a plain 1.x or 2.x version, so only the major is left to check.
|
||||
string version = discovery.PrintingExtensionVersion ?? "";
|
||||
if(!version.StartsWith("2.", StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info($"Realtime printing needs printing extension 2.x; the server advertises {extension.Version}.");
|
||||
_diagnostics.Info($"Realtime printing needs printing extension 2.x; the server advertises {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+)")}.");
|
||||
_diagnostics.Info($"Printing extension {version} supports realtime; media negotiation {(discovery.SupportsPrinterMedia() ? "available" : "not available (needs 2.3+)")}.");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
public const string PrintingPath = "/qqq/v1/qbit/printing/";
|
||||
private const int MetadataLimit = 65_536;
|
||||
private const int PrintingLimit = 22 * 1024 * 1024;
|
||||
private static readonly TimeSpan RequestLimit = TimeSpan.FromSeconds(20);
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = false };
|
||||
|
||||
/// <summary>Redirects are never followed and the platform keeps no cookie jar: a redirect could carry credentials elsewhere, and the session cookie is attached by hand.</summary>
|
||||
private static readonly QqqTransportPolicy TransportPolicy = new(FollowRedirects: false, UseCookieJar: false);
|
||||
private readonly IQqqTransport _transport;
|
||||
private readonly HttpClient _http;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ApplicationTrafficMeter? _traffic;
|
||||
@@ -40,15 +45,31 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
/// <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.
|
||||
/// manages cookies, because redirects are refused and the cookies this client sends (the session, and a load
|
||||
/// balancer's affinity cookies) are attached by hand to each request, and the HTTP client has no overall timeout
|
||||
/// because every request sets its own bound. A certificate Windows does not trust
|
||||
/// is accepted only from the origins the certificate exception names; without one, from nowhere.
|
||||
/// </summary>
|
||||
public QqqSessionClient(Uri origin, HttpMessageHandler? handler = null, TimeProvider? timeProvider = null, bool allowLoopbackHttp = false, ApplicationTrafficMeter? traffic = null,
|
||||
QqqSessionDiagnostics? diagnostics = null, bool allowUntrustedCertificate = false)
|
||||
QqqSessionDiagnostics? diagnostics = null, QqqCertificateExceptionOrigins? certificateException = null)
|
||||
: this(origin, handler, timeProvider, allowLoopbackHttp, traffic, diagnostics, certificateException ?? QqqCertificateExceptionOrigins.None, QqqPlatformTransport.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The same client over a given transport. Production uses the platform transport; a test supplies a fake one to see
|
||||
/// the policy, the certificate decisions and the socket upgrade request this client produces, and to play each outcome
|
||||
/// of an upgrade back to it.
|
||||
/// </summary>
|
||||
internal QqqSessionClient(Uri origin, HttpMessageHandler? handler, TimeProvider? timeProvider, bool allowLoopbackHttp, ApplicationTrafficMeter? traffic,
|
||||
QqqSessionDiagnostics? diagnostics, QqqCertificateExceptionOrigins certificateException, IQqqTransport transport)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(origin);
|
||||
_transport = transport;
|
||||
_diagnostics = diagnostics ?? QqqSessionDiagnostics.None;
|
||||
AllowsUntrustedCertificate = allowUntrustedCertificate;
|
||||
CertificateException = certificateException;
|
||||
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,23 +80,13 @@ 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 ?? transport.CreateHttpHandler(TransportPolicy, ValidateHttpCertificate))
|
||||
{
|
||||
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."); }
|
||||
_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 {(certificateException.IsEmpty ? "refused" : "ALLOWED from " + certificateException)}).");
|
||||
if(!certificateException.IsEmpty)
|
||||
{ _diagnostics.Info($"WARNING: certificate validation is relaxed for {certificateException} and for no other host. Any certificate presented from there, including a forged one, will be accepted. Use this only for development servers."); }
|
||||
}
|
||||
|
||||
|
||||
@@ -83,37 +94,46 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
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>The origins from which this client accepts a certificate that Windows does not trust (self-signed or private authority), for development servers only; empty for none. It is fixed when the client is built.</summary>
|
||||
public QqqCertificateExceptionOrigins CertificateException { 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.
|
||||
/// is accepted silently (details at debug level). One it does not trust is accepted only when the address the request
|
||||
/// or the socket upgrade is going to is on an origin the certificate exception names, and then with a warning naming
|
||||
/// the certificate so the exception is never silent. From any other host it is rejected, whatever exception the
|
||||
/// client holds, with the exact policy errors, the subject, issuer and validity, and the remedy; the rejection is
|
||||
/// kept, with the origin that presented the certificate and the time, for the compatibility check to report. The
|
||||
/// address may be one a server supplied (its sign-in provider's discovery document), so the log names its host
|
||||
/// by the ASCII name the connection is made to, never as the server wrote it.
|
||||
/// </summary>
|
||||
internal bool EvaluateCertificate(string host, X509Certificate2? certificate, SslPolicyErrors errors)
|
||||
internal bool EvaluateCertificate(Uri address, X509Certificate2? certificate, SslPolicyErrors errors)
|
||||
{
|
||||
Uri? origin = QqqOrigin.Of(address);
|
||||
string host = QqqOrigin.ShownAuthority(address);
|
||||
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}";
|
||||
: $"subject '{ShownText(certificate.Subject)}', issuer '{ShownText(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)
|
||||
if(CertificateException.Covers(address))
|
||||
{
|
||||
_diagnostics.Info($"TLS certificate for {host} is NOT trusted by Windows ({errors}) but was accepted because untrusted certificates are allowed for this server: {summary}.");
|
||||
_diagnostics.Info($"TLS certificate for {host} is NOT trusted by Windows ({errors}) but was accepted because the certificate exception covers this origin: {summary}.");
|
||||
return true;
|
||||
}
|
||||
Volatile.Write(ref _certificateRejection, new QqqCertificateRejection(errors, certificate is null ? null : ShownText(certificate.Subject), certificate is null ? null : ShownText(certificate.Issuer),
|
||||
certificate?.NotBefore.ToUniversalTime(), certificate?.NotAfter.ToUniversalTime(), certificate?.Thumbprint, origin, _timeProvider.GetUtcNow()));
|
||||
_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."));
|
||||
: "The server presented no usable certificate.")
|
||||
+ (CertificateException.IsEmpty ? "" : $" The certificate exception in force covers {CertificateException} and not this host."));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -166,7 +186,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
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.");
|
||||
_diagnostics.Info($"Password sign-in refused locally: the server authenticates with {Shown(authentication.Type)}, not TABLE_BASED.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
ClearSession();
|
||||
@@ -178,8 +198,11 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
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; }
|
||||
catch(Exception exception)
|
||||
{
|
||||
ReportFailure("Password sign-in", exception);
|
||||
throw;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
@@ -194,7 +217,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
ArgumentNullException.ThrowIfNull(credential);
|
||||
if(credential.Origin != Origin)
|
||||
{
|
||||
_diagnostics.Info($"Saved session refused: it belongs to {QqqSessionDiagnostics.Describe(credential.Origin)}, not {QqqSessionDiagnostics.Describe(Origin)}.");
|
||||
_diagnostics.Info($"Saved session refused: it belongs to {Written(credential.Origin)}, not {Written(Origin)}.");
|
||||
throw new ArgumentException("The session belongs to another server.", nameof(credential));
|
||||
}
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -228,8 +251,11 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
_diagnostics.Info("Renewing the session with the server.");
|
||||
await ResumeCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Session renewal", exception))
|
||||
{ throw; }
|
||||
catch(Exception exception)
|
||||
{
|
||||
ReportFailure("Session renewal", exception);
|
||||
throw;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
@@ -252,8 +278,11 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
else
|
||||
{ _diagnostics.Info("Sign-out requested with no held session; clearing local state only."); }
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Sign-out", exception))
|
||||
{ throw; }
|
||||
catch(Exception exception)
|
||||
{
|
||||
ReportFailure("Sign-out", exception);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearSession();
|
||||
@@ -299,20 +328,19 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
RequireSuccess(response);
|
||||
_diagnostics.Info($"Server accepted the legacy claim for device {credential.DeviceId}.");
|
||||
}
|
||||
// The identity check refuses, and clears the session, when the server names any device but this one, so an identity that comes back is bound to it.
|
||||
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();
|
||||
}
|
||||
_device = credential;
|
||||
_binding = identity;
|
||||
_generation++;
|
||||
_diagnostics.Info($"Device {credential.DeviceId} bound to principal {identity.PrincipalId} in tenant {identity.TenantId}.");
|
||||
_diagnostics.Info($"Device {credential.DeviceId} bound to principal {ShownText(identity.PrincipalId)} in tenant {ShownText(identity.TenantId)}.");
|
||||
return identity;
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Device binding", exception))
|
||||
{ throw; }
|
||||
catch(Exception exception)
|
||||
{
|
||||
ReportFailure("Device binding", exception);
|
||||
throw;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
@@ -379,15 +407,29 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
{
|
||||
if(_discovery is not null)
|
||||
{ return _discovery; }
|
||||
QqqApiDiscovery discovery = await FetchDiscoveryAsync(keepSession: false, cancellationToken).ConfigureAwait(false);
|
||||
discovery.Validate(report: _diagnostics.Info);
|
||||
_discovery = discovery;
|
||||
return discovery;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Requests the discovery document and parses it without judging or caching it, so the throwing path
|
||||
/// (<see cref="DiscoverCoreAsync"/>) and the compatibility check, which reports the same shortfalls as values, read the
|
||||
/// server in exactly the same way. The request carries no credential. The compatibility check asks for a held
|
||||
/// session to be kept whatever the server answers; see <see cref="CheckAuthorization"/>. Callers hold the client gate.
|
||||
/// </summary>
|
||||
private async Task<QqqApiDiscovery> FetchDiscoveryAsync(bool keepSession, CancellationToken cancellationToken)
|
||||
{
|
||||
_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);
|
||||
RequireSuccess(response, keepSession);
|
||||
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 = discovery;
|
||||
_diagnostics.Debug(() => $"Discovery features: [{ShownList(discovery.Features)}]; client channels: [{ShownList(discovery.ClientChannels)}].");
|
||||
return discovery;
|
||||
}
|
||||
|
||||
@@ -402,19 +444,10 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
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}'.");
|
||||
QqqAuthenticationMetadata metadata = await FetchAuthenticationAsync(keepSession: false, cancellationToken).ConfigureAwait(false);
|
||||
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.");
|
||||
_diagnostics.Info($"Authentication type '{Shown(metadata.Type)}' is not supported; only TABLE_BASED and OAUTH2 are.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
if(string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
|
||||
@@ -425,6 +458,29 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Requests the authentication metadata and parses it without judging or caching it, for the same reason discovery is
|
||||
/// fetched separately: the sign-in path throws on an unsupported server, and the compatibility check reports it.
|
||||
/// The request carries no credential, and the compatibility check asks for a held session to be kept whatever the
|
||||
/// server answers. Callers hold the client gate and have already discovered the server.
|
||||
/// </summary>
|
||||
private async Task<QqqAuthenticationMetadata> FetchAuthenticationAsync(bool keepSession, CancellationToken cancellationToken)
|
||||
{
|
||||
_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, keepSession);
|
||||
QqqAuthenticationMetadata metadata = Parse<QqqAuthenticationMetadata>(response);
|
||||
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
|
||||
_diagnostics.Info($"Authentication metadata: type {Shown(metadata.Type)}, provider '{Shown(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 '{Shown(metadata.Values?.Scopes)}'."
|
||||
: $"Native grant: flow {Shown(grant.Flow)}, challenge {Shown(grant.CodeChallengeMethod)}, prompt {Shown(grant.Prompt)}, issuer {ShownAddress(grant.Issuer)}, discovery {ShownAddress(grant.DiscoveryUrl)}, {grant.RedirectUris?.Count ?? 0} redirect address(es) [{ShownAddresses(grant.RedirectUris)}], scopes '{Shown(metadata.Values?.Scopes)}'.");
|
||||
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)
|
||||
{
|
||||
@@ -462,14 +518,14 @@ 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)"}.");
|
||||
_diagnostics.Info($"Session identity: principal {ShownText(principal)}, tenant {ShownText(tenant)}, device {(device is null ? "(none)" : ShownText(device))}.");
|
||||
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.");
|
||||
? $"Identity changed: the session was bound to principal {ShownText(_binding.PrincipalId)} in tenant {ShownText(_binding.TenantId)}; the server now reports principal {ShownText(principal)} in tenant {ShownText(tenant)}. Session cleared."
|
||||
: $"Device mismatch: proof was sent for device {credential!.DeviceId} but the server reports device {(device is null ? "(none)" : ShownText(device))}. Session cleared.");
|
||||
ClearSession();
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The server returned a different principal or device binding.");
|
||||
}
|
||||
@@ -486,20 +542,19 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
/// 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,
|
||||
internal 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}.");
|
||||
// The comparison is made on the ASCII names, so both sides are shown that way.
|
||||
_diagnostics.Info($"Refused a request to {QqqSessionDiagnostics.Describe(uri)}: it is not on the session origin {QqqOrigin.ShownAuthority(Origin)}.");
|
||||
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.");
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
if(!boundedByCaller)
|
||||
{ timeout.CancelAfter(TimeSpan.FromSeconds(20)); }
|
||||
long started = _timeProvider.GetTimestamp();
|
||||
_diagnostics.Debug(() => $"HTTP {method} {route}: session cookie {(cookie is not null ? "yes" : "no")}, load balancer cookies {(providerDiscovery ? 0 : AffinityCookieCount)}, 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.");
|
||||
using var timeout = new Deadline(_timeProvider, boundedByCaller ? Timeout.InfiniteTimeSpan : RequestLimit, cancellationToken);
|
||||
using HttpRequestMessage request = BuildRequest(uri, method, content, cookie, device, basic, providerDiscovery);
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
@@ -511,9 +566,17 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
}
|
||||
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")}.");
|
||||
_diagnostics.Debug(() => $"HTTP {method} {route} -> {(int)response.StatusCode} {Shown(response.ReasonPhrase)} in {Elapsed(started)}; content type {ShownOr(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); }
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// every answer from the session origin may rewrite the load //
|
||||
// balancer's cookies, an error from a node that is stopping included; //
|
||||
// an answer from the sign-in provider is never read for them //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(!providerDiscovery && response.Headers.TryGetValues("Set-Cookie", out IEnumerable<string>? setCookies))
|
||||
{ AdoptAffinity(setCookies); }
|
||||
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.");
|
||||
@@ -537,7 +600,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
|
||||
|
||||
|
||||
/// <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>
|
||||
/// <summary>
|
||||
/// Builds the outgoing request: no-store caching, this agent's channel header, and only the session cookie, origin, Basic
|
||||
/// credential and device proof the caller passed. A request to the session origin also carries the load balancer's
|
||||
/// cookies, with or without a session, so it reaches the node the load balancer last chose; the provider never sees them.
|
||||
/// The Origin header still goes only with a session cookie on an unsafe method, as before.
|
||||
/// </summary>
|
||||
private HttpRequestMessage BuildRequest(Uri uri, HttpMethod method, HttpContent? content, string? cookie, QqqDeviceCredential? device, string? basic, bool providerDiscovery)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, uri)
|
||||
@@ -547,13 +615,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
request.Headers.CacheControl = new CacheControlHeaderValue { NoStore = true };
|
||||
if(!providerDiscovery)
|
||||
{ request.Headers.Add("X-QQQ-Client", ClientChannel); }
|
||||
if(cookie is not null)
|
||||
string? cookies = providerDiscovery ? cookie : WithAffinity(cookie);
|
||||
if(cookies is not null)
|
||||
{ request.Headers.Add("Cookie", cookies); }
|
||||
if(cookie is not null && method != HttpMethod.Get && method != HttpMethod.Head && method != HttpMethod.Options)
|
||||
{
|
||||
request.Headers.Add("Cookie", cookie);
|
||||
if(method != HttpMethod.Get && method != HttpMethod.Head && method != HttpMethod.Options)
|
||||
{
|
||||
request.Headers.Add("Origin", Origin.GetLeftPart(UriPartial.Authority));
|
||||
}
|
||||
request.Headers.Add("Origin", Origin.GetLeftPart(UriPartial.Authority));
|
||||
}
|
||||
if(basic is not null)
|
||||
{ request.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); }
|
||||
@@ -574,7 +641,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
? $"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) + ")")}.");
|
||||
: $"HTTP {method} {route} could not be sent after {Elapsed(started)}: {ShownText(QqqSessionDiagnostics.Describe(exception))}{(exception.InnerException is null ? "" : " (" + ShownText(QqqSessionDiagnostics.Describe(exception.InnerException)) + ")")}.");
|
||||
}
|
||||
|
||||
|
||||
@@ -647,7 +714,9 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
/// <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.
|
||||
/// server ended the session, so the local session is cleared and sign-in is required. Cookies under any other name are
|
||||
/// not the session and are not examined: a load balancer in front of the server adds its own to every answer, and one
|
||||
/// this parser dislikes must not turn a good answer into an invalid one.
|
||||
/// </summary>
|
||||
private string? ReadSessionCookie(HttpResponseMessage response)
|
||||
{
|
||||
@@ -658,12 +727,14 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
{
|
||||
if(header.Length > 8192)
|
||||
{ throw InvalidResponse(); }
|
||||
string name = header.Split('=', 2)[0].Trim();
|
||||
if(name is not "sessionUUID" and not "sessionId")
|
||||
{ continue; }
|
||||
var parsed = new CookieContainer();
|
||||
try
|
||||
{ parsed.SetCookies(Origin, header); }
|
||||
catch(CookieException) { throw InvalidResponse(); }
|
||||
string name = header.Split('=', 2)[0].Trim();
|
||||
if((name is "sessionUUID" or "sessionId") && parsed.GetAllCookies().Count == 0)
|
||||
if(parsed.GetAllCookies().Count == 0)
|
||||
{
|
||||
_diagnostics.Info($"The server deleted the {name} cookie; the session has ended and sign-in is required.");
|
||||
ClearSession();
|
||||
@@ -708,7 +779,11 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
|
||||
|
||||
|
||||
/// <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>
|
||||
/// <summary>
|
||||
/// Forgets the token, session cookies, device and bound identity and advances the generation, so every request already in
|
||||
/// flight is refused when it returns. The load balancer's cookies are kept: they say which node to reach, not who is
|
||||
/// asking, and the sign-in that follows should reach the same node.
|
||||
/// </summary>
|
||||
private void ClearSession()
|
||||
{
|
||||
_sessionToken = null;
|
||||
@@ -743,14 +818,21 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
|
||||
/// <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.
|
||||
/// withdrawn session can be repaired by signing in, whereas a denied permission cannot. The compatibility check asks
|
||||
/// for the session to be kept: its requests carry no credential, so a 401 to one of them says nothing about a session
|
||||
/// the client holds, and a check the operator runs to look at a server must not sign the service out.
|
||||
/// </summary>
|
||||
private void CheckAuthorization(HttpResponseMessage response)
|
||||
private void CheckAuthorization(HttpResponseMessage response, bool keepSession = false)
|
||||
{
|
||||
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();
|
||||
if(keepSession)
|
||||
{ _diagnostics.Info("The server answered 401 Unauthorized to a request that carried no credential; a held session is not affected."); }
|
||||
else
|
||||
{
|
||||
_diagnostics.Info("The server answered 401 Unauthorized: the session is no longer accepted. Local session cleared; sign-in is required.");
|
||||
ClearSession();
|
||||
}
|
||||
throw AuthenticationRequired();
|
||||
}
|
||||
if(response.StatusCode == HttpStatusCode.Forbidden)
|
||||
@@ -763,12 +845,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
|
||||
|
||||
/// <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)
|
||||
private void RequireSuccess(HttpResponseMessage response, bool keepSession = false)
|
||||
{
|
||||
CheckAuthorization(response);
|
||||
CheckAuthorization(response, keepSession);
|
||||
if(!response.IsSuccessStatusCode)
|
||||
{
|
||||
_diagnostics.Info($"The server answered {(int)response.StatusCode} {response.ReasonPhrase}; the request did not succeed.");
|
||||
_diagnostics.Info($"The server answered {(int)response.StatusCode} {Shown(response.ReasonPhrase)}; the request did not succeed.");
|
||||
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "The QQQ request did not succeed.");
|
||||
}
|
||||
}
|
||||
@@ -778,27 +860,33 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
/// <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.
|
||||
/// The address that results is checked as well, in the same refusal: it must still be on this origin and under the
|
||||
/// printing route, whatever the text looked like.
|
||||
/// </summary>
|
||||
private Uri PrintingUri(string path)
|
||||
{
|
||||
if(string.IsNullOrEmpty(path) || path.StartsWith('/') || path.Contains('\\', StringComparison.Ordinal)
|
||||
|| path.Contains('#', StringComparison.Ordinal) || path.Split('?', 2)[0].Contains('%', StringComparison.Ordinal)
|
||||
|| path.Split('?', 2)[0].Split('/').Any(part => part is "." or ".." or ""))
|
||||
{
|
||||
throw new ArgumentException("Use a relative native printing route.", nameof(path));
|
||||
}
|
||||
var uri = new Uri(Origin, PrintingPath + path);
|
||||
if(!SameOrigin(uri, Origin) || !uri.AbsolutePath.StartsWith(PrintingPath, StringComparison.Ordinal))
|
||||
{ throw new ArgumentException("Invalid printing route.", nameof(path)); }
|
||||
return uri;
|
||||
bool relative = !string.IsNullOrEmpty(path) && !path.StartsWith('/') && !path.Contains('\\', StringComparison.Ordinal)
|
||||
&& !path.Contains('#', StringComparison.Ordinal) && !path.Split('?', 2)[0].Contains('%', StringComparison.Ordinal)
|
||||
&& !path.Split('?', 2)[0].Split('/').Any(part => part is "." or ".." or "");
|
||||
Uri? uri = relative ? new Uri(Origin, PrintingPath + path) : null;
|
||||
return uri is not null && SameOrigin(uri, Origin) && uri.AbsolutePath.StartsWith(PrintingPath, StringComparison.Ordinal)
|
||||
? uri
|
||||
: throw new ArgumentException("Use a relative native printing route.", nameof(path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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>
|
||||
/// Compares scheme, host and port only, ignoring case; used to refuse cross-origin requests and to hold a sign-in
|
||||
/// provider's endpoints to its issuer's origin. Both addresses are reduced to their origin by
|
||||
/// <see cref="QqqOrigin.Of"/> first, so the host is compared by its ASCII name and an address that has no nameable
|
||||
/// origin is not the same origin as anything, itself included. The addresses compared here can be text a server
|
||||
/// supplied, and the platform throws when asked to name some hosts (one that holds a line separator), so the
|
||||
/// comparison must not be made on the raw address: such an address is a refusal, never an exception.
|
||||
/// </summary>
|
||||
private static bool SameOrigin(Uri left, Uri right) => QqqOrigin.Of(left) is { } first && QqqOrigin.Of(right) is { } second
|
||||
&& string.Equals(first.Scheme, second.Scheme, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(first.IdnHost, second.IdnHost, StringComparison.OrdinalIgnoreCase) && first.Port == second.Port;
|
||||
|
||||
|
||||
|
||||
@@ -815,7 +903,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
{ 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}).");
|
||||
_diagnostics.Info($"Response rejected: the JSON did not match the expected {typeof(T).Name} shape ({ShownText(exception.Message)}).");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
}
|
||||
@@ -836,7 +924,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
{
|
||||
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.");
|
||||
_diagnostics.Info($"Response rejected: expected a JSON object but received {document.RootElement.ValueKind} (content type {ShownOr(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();
|
||||
}
|
||||
RejectDuplicateProperties(document.RootElement);
|
||||
@@ -846,7 +934,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
}
|
||||
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.");
|
||||
_diagnostics.Info($"Response rejected: the body is not valid JSON ({ShownText(exception.Message)}; content type {ShownOr(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();
|
||||
}
|
||||
}
|
||||
@@ -890,23 +978,85 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
|
||||
|
||||
|
||||
/// <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.
|
||||
/// Records why an operation failed (type and message only). The caller rethrows the exception unchanged, so the log gains
|
||||
/// a line and nothing else about the failure differs. A cancellation is noted, not reported as a failure.
|
||||
/// </summary>
|
||||
private bool ReportFailure(string operation, Exception exception)
|
||||
private void 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;
|
||||
: $"{operation} failed: {ShownText(QqqSessionDiagnostics.Describe(exception))}");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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>
|
||||
/// A value the server supplied, as it may appear in a log line: printable and short. Every log line of this client
|
||||
/// that carries text read from a response, a certificate or a callback passes it through here or through one of the
|
||||
/// two helpers below, so nothing a server sends can start a new log line, reorder one or make one long.
|
||||
/// </summary>
|
||||
private static string Shown(string? value) => QqqCompatibilityProblem.Printable(value);
|
||||
|
||||
|
||||
|
||||
/// <summary>The same for a value that may legitimately be absent, with the words to show in its place.</summary>
|
||||
private static string ShownOr(string? value, string absent) => value is null ? absent : Shown(value);
|
||||
|
||||
|
||||
|
||||
/// <summary>The same for longer text the server can influence: an address, a certificate name, an error description, a failure's message, or an identity value, which the contract lets run to 200 characters and which an operator compares with another.</summary>
|
||||
private static string ShownText(string? value) => QqqCompatibilityProblem.Printable(value, QqqCompatibilityProblem.TextLimit);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The same for an address a server supplied as text, where the line says what this client accepted or will use:
|
||||
/// the issuer and discovery address of a sign-in provider, and the provider's own issuer and authorization
|
||||
/// endpoint. An HTTP or WebSocket address is shown as <see cref="QqqSessionDiagnostics.Describe(Uri)"/> shows any
|
||||
/// address: its host as the ASCII name the connection is made to (a host that has none, as written and made
|
||||
/// printable), and no user information, query or fragment. A look-alike letter in the host therefore reads as
|
||||
/// the "xn--" name it is in every line. What was left out is said, by kind and never by content ("(query
|
||||
/// omitted)"): a query or user information can carry a secret, and its presence can be the very reason the
|
||||
/// address is refused in the next line, so without the note the log would show a usable address and then refuse
|
||||
/// it. Text that is not such an address (a relative one, another scheme, or none at all) is shown as it was
|
||||
/// written. The lines that reject a value quote it through <see cref="ShownText"/> instead: an issuer that does
|
||||
/// not match another exactly, and an issuer or discovery address the grant's checks refuse.
|
||||
/// </summary>
|
||||
private static string ShownAddress(string? value)
|
||||
{
|
||||
if(!Uri.TryCreate(value, UriKind.Absolute, out Uri? address) || address.Scheme is not ("https" or "http" or "wss" or "ws"))
|
||||
{ return ShownText(value); }
|
||||
(bool Present, string Part)[] parts = [(address.UserInfo.Length != 0, "user information"), (address.Query.Length != 0, "query"), (address.Fragment.Length != 0, "fragment")];
|
||||
string[] omitted = [.. parts.Where(part => part.Present).Select(part => part.Part)];
|
||||
return ShownText(QqqSessionDiagnostics.Describe(address)) + (omitted.Length == 0 ? "" : " (" + string.Join(", ", omitted) + " omitted)");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An address as it was written, without its query, fragment or user information and made printable, for a line
|
||||
/// that says two addresses differ when they were compared as written. The form every other line uses names a
|
||||
/// host by its ASCII name, and two addresses that differ only in how that name was written would then read as
|
||||
/// the same text on both sides of "not".
|
||||
/// </summary>
|
||||
private static string Written(Uri address) => ShownText(address.IsAbsoluteUri ? address.Scheme + "://" + address.Authority + address.AbsolutePath : address.ToString());
|
||||
|
||||
|
||||
|
||||
/// <summary>The same for a list the server supplied: each value printable, the list cut at a fixed number of values and the rest counted.</summary>
|
||||
private static string ShownList(IEnumerable<string?>? values) => QqqCompatibilityProblem.PrintableList(values);
|
||||
|
||||
|
||||
|
||||
/// <summary>The same for a list of addresses the operator has to compare with one of their own, such as the callbacks a server lists: each is shown up to the longer limit, so two that share a long beginning still differ in the log.</summary>
|
||||
private static string ShownAddresses(IEnumerable<string?>? values) => QqqCompatibilityProblem.PrintableList(values, QqqCompatibilityProblem.TextLimit);
|
||||
|
||||
|
||||
|
||||
/// <summary>Milliseconds since a timestamp taken from the client's clock, for request timing in the log.</summary>
|
||||
private string Elapsed(long started) => _timeProvider.GetElapsedTime(started).TotalMilliseconds.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + " ms";
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,8 +37,13 @@ public sealed class QqqSessionDiagnostics(Action<string>? info = null, Action<st
|
||||
|
||||
|
||||
|
||||
/// <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 an address without its query, fragment or user information, so a logged route can never carry a code,
|
||||
/// state or credential. The host is in the form every host is shown in (see <see cref="QqqOrigin.ShownAuthority"/>):
|
||||
/// the ASCII name the connection is made to, because the address may be one a server wrote (its sign-in provider's
|
||||
/// discovery document or endpoints), and a look-alike letter must read as the "xn--" name it is.
|
||||
/// </summary>
|
||||
public static string Describe(Uri uri) => uri.IsAbsoluteUri ? uri.Scheme + "://" + QqqOrigin.ShownAuthority(uri) + uri.AbsolutePath : uri.ToString();
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// The ways a server lets this agent sign a user in. A server offers one of them today, but the value is a set so the
|
||||
/// wizard shows whatever is offered instead of assuming.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum QqqSignInMethods
|
||||
{
|
||||
/// <summary>No method this agent implements.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>A user name and password, sent once to the server's own session endpoint (table-based authentication).</summary>
|
||||
Password = 1,
|
||||
|
||||
/// <summary>The system browser and the server's identity provider, through the native session grant with PKCE.</summary>
|
||||
Browser = 2,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Everything about one realtime socket upgrade, decided by the session client before any platform object is touched:
|
||||
/// the address, the subprotocol to offer, the request headers (session cookie with the load balancer's cookies after it,
|
||||
/// origin, client channel and device proof) and the keep-alive timing. The headers carry credentials, so the label is
|
||||
/// fixed text.
|
||||
/// </summary>
|
||||
internal sealed record QqqSocketRequest(Uri Uri, string SubProtocol, IReadOnlyList<KeyValuePair<string, string>> Headers, TimeSpan KeepAliveInterval, TimeSpan KeepAliveTimeout)
|
||||
{
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying a request can never reveal the cookie or device proof in its headers.</summary>
|
||||
public override string ToString() => "QQQ realtime socket request (redacted)";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// How the platform transport must behave, chosen by the session client: whether redirects are followed and whether the
|
||||
/// platform keeps its own cookie jar. The client sets both to false, because a redirect could carry credentials to
|
||||
/// another host and the session cookie is attached by hand to exactly the requests that may carry it.
|
||||
/// </summary>
|
||||
internal sealed record QqqTransportPolicy(bool FollowRedirects, bool UseCookieJar);
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Security.AccessControl;
|
||||
|
||||
namespace Qqq.PrintAgent.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The Windows facts the machine state rules are applied to: who the process runs as, what a path's attributes and
|
||||
/// security descriptor are, and how a protected folder is created. An implementation only reads or creates; whether what
|
||||
/// it returns is acceptable is decided by <see cref="MachineStateGuard"/>.
|
||||
/// </summary>
|
||||
internal interface IMachineStatePlatform
|
||||
{
|
||||
/// <summary>True when the process runs with administrator rights, the only case in which the machine folder may be created.</summary>
|
||||
bool IsAdministrator { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The attributes of an existing file or folder; a link shows as a reparse point.</summary>
|
||||
FileAttributes GetAttributes(string path);
|
||||
|
||||
|
||||
|
||||
/// <summary>The security descriptor of a folder: owner and access rules.</summary>
|
||||
FileSystemSecurity GetDirectorySecurity(string path);
|
||||
|
||||
|
||||
|
||||
/// <summary>The security descriptor of a file: owner and access rules.</summary>
|
||||
FileSystemSecurity GetFileSecurity(string path);
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates a folder that has the given security descriptor from the moment it exists.</summary>
|
||||
void CreateProtectedDirectory(string path, string descriptor);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace Qqq.PrintAgent.Core;
|
||||
|
||||
/// <summary>Reads the labels the installer persisted for this machine, so an unattended upgrade keeps a deployment's identity.</summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
[ExcludeFromCodeCoverage(Justification = "A single read of the machine registry hive, which a test must not depend on or write to. Whether a label is used is decided in RuntimeBranding.LoadMachine, tested with a supplied reader.")]
|
||||
internal static class MachineInstallerLabels
|
||||
{
|
||||
/// <summary>Returns the named label from the installer's machine key, or null when the key or the value is absent or is not text.</summary>
|
||||
internal static string? Read(string name)
|
||||
{
|
||||
using RegistryKey? key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\QRunIO\PrintAgent");
|
||||
return key?.GetValue(name) as string;
|
||||
}
|
||||
}
|
||||
@@ -5,149 +5,46 @@
|
||||
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace Qqq.PrintAgent.Core;
|
||||
|
||||
/// <summary>Rejects hostile pre-existing machine state before privileged consumers open it.</summary>
|
||||
/// <summary>
|
||||
/// Rejects hostile pre-existing machine state before privileged consumers open it. This is the fixed entry point for the
|
||||
/// real machine folder under ProgramData; the rules themselves live in <see cref="MachineStateGuard"/>, which a test can
|
||||
/// point at a temporary folder.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public static class MachineStateDirectory
|
||||
{
|
||||
private static readonly Lock Gate = new();
|
||||
private static bool _validated;
|
||||
private static readonly MachineStateGuard Guard = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "QqqPrintAgent"), new MachineStatePlatform());
|
||||
|
||||
|
||||
|
||||
/// <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.";
|
||||
public static string Root => Guard.Root;
|
||||
|
||||
|
||||
|
||||
/// <summary>Private diagnostic paths remain independent; canonical machine state is always protected.</summary>
|
||||
public static void EnsureForPath(string path)
|
||||
{
|
||||
if(!Contains(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock(Gate)
|
||||
{
|
||||
if(_validated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
RejectLinks(Root);
|
||||
if(!Directory.Exists(Root))
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
if(!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
|
||||
{
|
||||
throw new UnauthorizedAccessException(Error);
|
||||
}
|
||||
DirectorySecurity security = new();
|
||||
security.SetSecurityDescriptorSddlForm("O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)");
|
||||
new DirectoryInfo(Root).Create(security);
|
||||
}
|
||||
ValidateTree(Root);
|
||||
_validated = true;
|
||||
}
|
||||
}
|
||||
public static void EnsureForPath(string path) => Guard.EnsureForPath(path);
|
||||
|
||||
|
||||
|
||||
/// <summary>Validate existing branding without creating state merely to read optional settings.</summary>
|
||||
public static void ValidateExistingForPath(string path)
|
||||
{
|
||||
if(Contains(path) && (Directory.Exists(Root) || File.Exists(Root)))
|
||||
{
|
||||
EnsureForPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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);
|
||||
}
|
||||
public static void ValidateExistingForPath(string path) => Guard.ValidateExistingForPath(path);
|
||||
|
||||
|
||||
|
||||
/// <summary>Inspect every adopted descendant; never follow junctions or normalize hostile ownership.</summary>
|
||||
public static void ValidateTree(string directory)
|
||||
{
|
||||
RejectLinks(directory);
|
||||
ValidateAcl(new DirectoryInfo(directory).GetAccessControl());
|
||||
foreach(string entry in Directory.EnumerateFileSystemEntries(directory))
|
||||
{
|
||||
FileAttributes attributes = File.GetAttributes(entry);
|
||||
if(attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
throw new UnauthorizedAccessException(Error);
|
||||
}
|
||||
if(attributes.HasFlag(FileAttributes.Directory))
|
||||
{
|
||||
ValidateTree(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateAcl(new FileInfo(entry).GetAccessControl());
|
||||
}
|
||||
}
|
||||
}
|
||||
public static void ValidateTree(string directory) => Guard.ValidateTree(directory);
|
||||
|
||||
|
||||
|
||||
/// <summary>Verify a newly created staging file before its privileged consumer opens it.</summary>
|
||||
public static void ValidateFile(string path)
|
||||
{
|
||||
RejectLinks(path);
|
||||
ValidateAcl(new DirectoryInfo(Path.GetDirectoryName(Path.GetFullPath(path))!).GetAccessControl());
|
||||
ValidateAcl(new FileInfo(path).GetAccessControl());
|
||||
}
|
||||
public static void ValidateFile(string path) => Guard.ValidateFile(path);
|
||||
|
||||
|
||||
|
||||
/// <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))
|
||||
{
|
||||
if((File.Exists(current) || Directory.Exists(current)) && File.GetAttributes(current).HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
throw new UnauthorizedAccessException(Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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);
|
||||
if(descriptor.DiscretionaryAcl is null || !Trusted(security.GetOwner(typeof(SecurityIdentifier))?.Value))
|
||||
{
|
||||
throw new UnauthorizedAccessException(Error);
|
||||
}
|
||||
foreach(FileSystemAccessRule rule in security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)))
|
||||
{
|
||||
if(rule.AccessControlType == AccessControlType.Allow && rule.FileSystemRights != default && !Trusted(rule.IdentityReference.Value))
|
||||
{
|
||||
// Inherit-only access is also unsafe: a newly created credential/log could inherit it.
|
||||
throw new UnauthorizedAccessException(Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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";
|
||||
/// <summary>Requires an explicit access list, a trusted owner, and no allow rule for any account other than SYSTEM, Administrators or the Windows installer service.</summary>
|
||||
internal static void ValidateAcl(FileSystemSecurity security) => MachineStateGuard.ValidateAcl(security);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace Qqq.PrintAgent.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The rules that decide whether a machine state folder may be trusted: no links anywhere on the way to it, a trusted
|
||||
/// owner, and no access for anyone but SYSTEM, Administrators and the Windows installer. The folder it guards and the
|
||||
/// Windows calls it needs (security descriptors, the process token, file attributes, creating a protected folder) are
|
||||
/// given to it, so the rules can be exercised against a temporary folder with chosen descriptors.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class MachineStateGuard(string root, IMachineStatePlatform platform)
|
||||
{
|
||||
/// <summary>The access a newly created machine folder gets: owned by Administrators, full control for SYSTEM and Administrators only, inherited by everything inside, and protected from inheriting anything else.</summary>
|
||||
internal const string ProtectedDescriptor = "O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)";
|
||||
|
||||
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.";
|
||||
private readonly Lock _gate = new();
|
||||
private bool _validated;
|
||||
|
||||
|
||||
|
||||
/// <summary>The folder this guard protects.</summary>
|
||||
internal string Root => root;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Makes sure the guarded folder exists and is trusted before anything under it is used; a path outside it is none of
|
||||
/// this guard's business. The folder is created, protected, only by an administrator; an existing one is validated
|
||||
/// with everything in it. A folder that passed is not validated again in this process.
|
||||
/// </summary>
|
||||
internal void EnsureForPath(string path)
|
||||
{
|
||||
if(!Contains(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock(_gate)
|
||||
{
|
||||
if(_validated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
RejectLinks(root);
|
||||
if(!Directory.Exists(root))
|
||||
{
|
||||
if(!platform.IsAdministrator)
|
||||
{
|
||||
throw new UnauthorizedAccessException(Error);
|
||||
}
|
||||
platform.CreateProtectedDirectory(root, ProtectedDescriptor);
|
||||
}
|
||||
ValidateTree(root);
|
||||
_validated = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Validates the guarded folder when it already exists, without creating it merely because something optional was read from under it.</summary>
|
||||
internal void ValidateExistingForPath(string path)
|
||||
{
|
||||
if(Contains(path) && (Directory.Exists(root) || File.Exists(root)))
|
||||
{
|
||||
EnsureForPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when a path is the guarded 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 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>Inspects a folder and every descendant: none may be reached through or be a link, and each must pass the access check. Hostile ownership is reported, never repaired.</summary>
|
||||
internal void ValidateTree(string directory)
|
||||
{
|
||||
RejectLinks(directory);
|
||||
ValidateAcl(platform.GetDirectorySecurity(directory));
|
||||
foreach(string entry in Directory.EnumerateFileSystemEntries(directory))
|
||||
{
|
||||
FileAttributes attributes = platform.GetAttributes(entry);
|
||||
if(attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
throw new UnauthorizedAccessException(Error);
|
||||
}
|
||||
if(attributes.HasFlag(FileAttributes.Directory))
|
||||
{
|
||||
ValidateTree(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateAcl(platform.GetFileSecurity(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Verifies one newly created file, and the folder holding it, before a privileged consumer opens it.</summary>
|
||||
internal void ValidateFile(string path)
|
||||
{
|
||||
RejectLinks(path);
|
||||
ValidateAcl(platform.GetDirectorySecurity(Path.GetDirectoryName(Path.GetFullPath(path))!));
|
||||
ValidateAcl(platform.GetFileSecurity(path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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 void RejectLinks(string path)
|
||||
{
|
||||
for(string? current = Path.GetFullPath(path); current is not null; current = Path.GetDirectoryName(current))
|
||||
{
|
||||
if((File.Exists(current) || Directory.Exists(current)) && platform.GetAttributes(current).HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
throw new UnauthorizedAccessException(Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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);
|
||||
if(descriptor.DiscretionaryAcl is null || !Trusted(security.GetOwner(typeof(SecurityIdentifier))?.Value))
|
||||
{
|
||||
throw new UnauthorizedAccessException(Error);
|
||||
}
|
||||
foreach(FileSystemAccessRule rule in security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)))
|
||||
{
|
||||
if(rule.AccessControlType == AccessControlType.Allow && rule.FileSystemRights != default && !Trusted(rule.IdentityReference.Value))
|
||||
{
|
||||
// Inherit-only access is also unsafe: a newly created credential/log could inherit it.
|
||||
throw new UnauthorizedAccessException(Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace Qqq.PrintAgent.Core;
|
||||
|
||||
/// <summary>The production source of Windows facts for the machine state rules: each member is one Windows call and decides nothing.</summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
[ExcludeFromCodeCoverage(Justification = "Each member is a single Windows call (process token, file attributes, security descriptor, protected folder creation) whose result depends on the machine and on elevation: a test cannot own a folder as Administrators or create a junction without privileges. Every decision about the results is in MachineStateGuard, tested through a fake IMachineStatePlatform.")]
|
||||
internal sealed class MachineStatePlatform : IMachineStatePlatform
|
||||
{
|
||||
/// <summary>Asks the process token whether it is in the Administrators role.</summary>
|
||||
public bool IsAdministrator
|
||||
{
|
||||
get
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the path's attributes from the file system.</summary>
|
||||
public FileAttributes GetAttributes(string path) => File.GetAttributes(path);
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the folder's security descriptor.</summary>
|
||||
public FileSystemSecurity GetDirectorySecurity(string path) => new DirectoryInfo(path).GetAccessControl();
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the file's security descriptor.</summary>
|
||||
public FileSystemSecurity GetFileSecurity(string path) => new FileInfo(path).GetAccessControl();
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the folder with the descriptor applied atomically, so it never exists with inherited access.</summary>
|
||||
public void CreateProtectedDirectory(string path, string descriptor)
|
||||
{
|
||||
DirectorySecurity security = new();
|
||||
security.SetSecurityDescriptorSddlForm(descriptor);
|
||||
new DirectoryInfo(path).Create(security);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
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);
|
||||
public sealed record AgentConfiguration(string ServerUrl, string DeviceId, string DeviceToken, int PollIntervalSeconds = 5)
|
||||
{
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying the configuration can never reveal the device token.</summary>
|
||||
public override string ToString() => "Agent configuration (redacted)";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
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);
|
||||
@@ -6,6 +6,10 @@
|
||||
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);
|
||||
public sealed record EnrollmentRequest(string EnrollmentCode, string DeviceName, string AgentVersion)
|
||||
{
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying the request can never reveal the enrollment code.</summary>
|
||||
public override string ToString() => "Enrollment request (redacted)";
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
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);
|
||||
public sealed record EnrollmentResponse(int ProtocolVersion, string DeviceId, string DeviceToken, int PollIntervalSeconds)
|
||||
{
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying the response can never reveal the device token.</summary>
|
||||
public override string ToString() => "Enrollment response (redacted)";
|
||||
}
|
||||
|
||||
|
||||
@@ -5,45 +5,65 @@
|
||||
|
||||
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>
|
||||
/// Filters cloud inventory and rejects jobs for queues no longer exposed locally. One rule decides what each of the three reads
|
||||
/// lets through: a printer is offered only when the operator shared it and it has at least one usable format.
|
||||
/// </summary>
|
||||
public sealed class ExposedPrinterBackend(IPrinterBackend inner, Func<IReadOnlySet<string>> exposedIds) : IPrinterBackend
|
||||
{
|
||||
/// <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>
|
||||
/// <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. The inner backend reads the settings of every printer for it.</summary>
|
||||
public IReadOnlyList<PrinterInfo> Discover()
|
||||
{
|
||||
IReadOnlySet<string> exposed = exposedIds();
|
||||
return inner.Discover().Where(printer => exposed.Contains(printer.PrinterId) && printer.Formats.Length > 0).ToArray();
|
||||
return inner.Discover().Where(printer => IsOffered(exposed, printer)).ToArray();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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)
|
||||
/// <summary>Returns the same printers as <see cref="Discover"/>, by the same rule, without their media profiles; the inner backend reads the settings of no printer for it.</summary>
|
||||
public IReadOnlyList<PrinterInfo> ListWithoutMedia()
|
||||
{
|
||||
if(!exposedIds().Contains(job.PrinterId))
|
||||
{
|
||||
throw new InvalidOperationException("The printer is not exposed to QQQ on this computer.");
|
||||
}
|
||||
return inner.PrintAsync(job, content, cancellationToken);
|
||||
IReadOnlySet<string> exposed = exposedIds();
|
||||
return inner.ListWithoutMedia().Where(printer => IsOffered(exposed, printer)).ToArray();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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>
|
||||
/// Returns the printer only when <see cref="Discover"/> would list it: one that is not shared, or that has no usable format,
|
||||
/// reads as absent. A printer that is not shared is never asked about, so the inner backend reads the settings of the one
|
||||
/// shared printer and of nothing else.
|
||||
/// </summary>
|
||||
public PrinterInfo? FindWithMedia(string printerId)
|
||||
{
|
||||
IReadOnlySet<string> exposed = exposedIds();
|
||||
if(!exposed.Contains(printerId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
PrinterInfo? printer = inner.FindWithMedia(printerId);
|
||||
return printer is not null && IsOffered(exposed, printer) ? printer : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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>
|
||||
/// <summary>
|
||||
/// Prints a staged file 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. The refusal is made before the backend
|
||||
/// behind the filter is called and has its own type, <see cref="PrinterNotExposedException"/>, so the caller knows
|
||||
/// that nothing was submitted.
|
||||
/// </summary>
|
||||
public Task<string> PrintFileAsync(PrintJob job, string path, TimeSpan submissionTimeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if(!exposedIds().Contains(job.PrinterId))
|
||||
{
|
||||
throw new InvalidOperationException("The printer is not exposed to QQQ on this computer.");
|
||||
throw new PrinterNotExposedException("The printer is not exposed to QQQ on this computer; no bytes were submitted.");
|
||||
}
|
||||
return inner is IFilePrinterBackend files ? files.PrintFileAsync(job, path, submissionTimeout, cancellationToken)
|
||||
: throw new NotSupportedException("The printer backend does not support file submissions.");
|
||||
return inner.PrintFileAsync(job, path, submissionTimeout, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The one rule behind all three reads: the operator shared the printer, and it has at least one format it can print unattended.</summary>
|
||||
private static bool IsOffered(IReadOnlySet<string> exposed, PrinterInfo printer) => exposed.Contains(printer.PrinterId) && printer.Formats.Length > 0;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -8,7 +8,8 @@ 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.
|
||||
/// or printing a job twice. The service implements it with SQLite, and reads from that store itself, at start, the jobs a
|
||||
/// previous run left unfinished; tests supply an in-memory version.
|
||||
/// </summary>
|
||||
public interface IJobJournal
|
||||
{
|
||||
@@ -34,11 +35,6 @@ public interface IJobJournal
|
||||
|
||||
/// <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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,20 +6,55 @@
|
||||
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.
|
||||
/// What the worker needs from a printing platform: read the printers it can use and submit one staged document. Windows, the isolated
|
||||
/// helper, the exposure filter, the profile wrapper and test fakes all implement it, so the worker's safety rules are written once. A
|
||||
/// document reaches a backend only as a verified file, so no document travels in memory or in a control message, and every backend,
|
||||
/// a wrapper around another included, can take one.
|
||||
/// <para>
|
||||
/// There are three reads, and they differ in what they cost the platform. <see cref="Discover"/> reads the settings of every
|
||||
/// printer; <see cref="ListWithoutMedia"/> reads the settings of none; <see cref="FindWithMedia"/> reads the settings of one. A
|
||||
/// printer whose settings cannot be read (a driver that hangs or fails) therefore gets in the way only of a read that asks for
|
||||
/// its settings. A print job uses the second before it is claimed and the third for its own printer, and never the first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That is a statement about settings and no more. All three reads begin with the platform's own list of printers, and what
|
||||
/// the platform does to produce that list is outside this contract. On Windows a connection to a printer on a print server
|
||||
/// that does not answer can hold the list, and so all three reads; see the Windows adapter.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IPrinterBackend
|
||||
{
|
||||
/// <summary>Lists the printers this backend can print to, with their formats, status and media profiles.</summary>
|
||||
/// <summary>
|
||||
/// Lists every printer this backend can print to, with its formats, status and media profiles. It reads the settings of every
|
||||
/// printer, so it is as slow as the slowest one and fails when one of them cannot be read at all. It is for reporting the
|
||||
/// inventory, never for a print job.
|
||||
/// </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);
|
||||
/// <summary>
|
||||
/// Lists every printer this backend can print to by what identifies it: identifier, names, port, status and formats, exactly
|
||||
/// as <see cref="Discover"/> reports them, with no media profiles (<see cref="PrinterInfo.MediaProfiles"/> is null, meaning
|
||||
/// "not read"). It reads the settings of no printer, so a printer whose settings cannot be read does not slow it or fail
|
||||
/// it on that account. It is still as slow as the platform's own list of printers, and fails when that list fails.
|
||||
/// </summary>
|
||||
IReadOnlyList<PrinterInfo> ListWithoutMedia();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The one printer with this identifier as it is now, exactly as <see cref="Discover"/> reports it, media profiles included,
|
||||
/// or null when this backend has no such printer. It reads the settings of that printer and of no other.
|
||||
/// </summary>
|
||||
PrinterInfo? FindWithMedia(string printerId);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Prints the verified file at the path on the job's printer and returns the spooler receipt. The caller keeps its read-only
|
||||
/// lease on the file until this returns, so the content cannot change underneath. The limit bounds the native submission where
|
||||
/// the backend can enforce one. A failure after submission began, a timeout included, means the outcome is uncertain.
|
||||
/// </summary>
|
||||
Task<string> PrintFileAsync(PrintJob job, string path, TimeSpan submissionTimeout, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -24,16 +24,12 @@ public sealed partial class PrintApiClient
|
||||
|
||||
|
||||
/// <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.
|
||||
/// Reads the whole operator printer catalog (every page until the server returns no cursor, at most ten pages). The catalog is
|
||||
/// user-scoped, and it is the signed-in session's user who reads 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++)
|
||||
|
||||
@@ -4,117 +4,55 @@
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
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.
|
||||
/// Device control part of the API client: the strict validation applied to every command the realtime channel delivers,
|
||||
/// and delivery of command receipts over that channel. A 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>
|
||||
/// <summary>
|
||||
/// Checks a command that arrived over the realtime channel before anything acts on it. A command that fails the rule
|
||||
/// is a <see cref="PrintingProtocolException"/>, like any other malformed frame.
|
||||
/// </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)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync("device/control/poll", new
|
||||
{
|
||||
controlVersion = 1,
|
||||
agentVersion = AgentVersion,
|
||||
capabilities = ControlCapabilities,
|
||||
lastAppliedRevision = revision,
|
||||
}, JsonOptions, timeout.Token).ConfigureAwait(false);
|
||||
if(response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
if(_nativeSession)
|
||||
{
|
||||
throw new InvalidDataException("The advertised native printing control endpoint is unavailable.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
byte[] bytes = await response.Content.ReadAsByteArrayAsync(timeout.Token).ConfigureAwait(false);
|
||||
if(bytes.Length > 65_536)
|
||||
{
|
||||
throw new InvalidDataException("Device control response exceeded its bound.");
|
||||
}
|
||||
DeviceControlPoll poll = JsonSerializer.Deserialize<DeviceControlPoll>(bytes, JsonOptions) ?? throw new InvalidDataException("Missing device control response.");
|
||||
ValidateControlPoll(poll);
|
||||
return poll;
|
||||
if(!ValidControlCommand(command))
|
||||
{ throw new PrintingProtocolException("The realtime command frame carries an invalid device control command."); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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.
|
||||
/// True for a command that is safe to act on: present, with safe command and request identifiers, a bounded action, a
|
||||
/// creation and expiry time that parse, a lifetime of at most 15 minutes, and a job identifier when the action cancels
|
||||
/// a spool job.
|
||||
/// </summary>
|
||||
private static void ValidateControlPoll(DeviceControlPoll poll)
|
||||
{
|
||||
if(poll.ControlVersion != 1 || poll.PollIntervalSeconds is < 1 or > 300 || poll.DesiredState is null
|
||||
|| poll.DesiredState.Revision < 0 || poll.DesiredState.ConnectionState is not ("CONNECTED" or "DISCONNECTED")
|
||||
|| poll.Commands is null || poll.Commands.Count > 20 || !DateTimeOffset.TryParse(poll.ServerTime, CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
|
||||
{
|
||||
throw new InvalidDataException("Invalid device control response.");
|
||||
}
|
||||
var identifiers = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach(DeviceControlCommand command in poll.Commands)
|
||||
{
|
||||
if(command is null || !ValidControlId(command.CommandId) || !ValidControlId(command.RequestId) || !identifiers.Add(command.CommandId)
|
||||
|| string.IsNullOrEmpty(command.Action) || command.Action.Length > 64
|
||||
|| !DateTimeOffset.TryParse(command.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset created)
|
||||
|| !DateTimeOffset.TryParse(command.ExpiresAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset expires)
|
||||
|| expires <= created || expires - created > TimeSpan.FromMinutes(15)
|
||||
|| (string.Equals(command.Action, "CANCEL_SPOOL", StringComparison.Ordinal) && !ValidControlId(command.JobId)))
|
||||
{
|
||||
throw new InvalidDataException("Invalid device control command.");
|
||||
}
|
||||
}
|
||||
}
|
||||
private static bool ValidControlCommand(DeviceControlCommand? command)
|
||||
=> command is not null && ValidControlId(command.CommandId) && ValidControlId(command.RequestId)
|
||||
&& !string.IsNullOrEmpty(command.Action) && command.Action.Length <= 64
|
||||
&& DateTimeOffset.TryParse(command.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset created)
|
||||
&& DateTimeOffset.TryParse(command.ExpiresAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset expires)
|
||||
&& expires > created && expires - created <= TimeSpan.FromMinutes(15)
|
||||
&& (!string.Equals(command.Action, "CANCEL_SPOOL", StringComparison.Ordinal) || ValidControlId(command.JobId));
|
||||
|
||||
|
||||
|
||||
/// <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 ':');
|
||||
internal static bool ValidControlId([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] 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.
|
||||
/// Delivers one command receipt over the realtime channel, with a message identity derived from the receipt and the
|
||||
/// server's acknowledgement. A receipt that is not acknowledged, or a channel that is gone, throws, so the caller
|
||||
/// keeps the receipt 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)
|
||||
{
|
||||
await RealtimeChannel.SendAsync("COMMAND_RECEIPT", new { commandId, receipt }, "receipt-" + receipt.ReceiptId, acknowledged: true, token: token).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync($"device/control/commands/{Uri.EscapeDataString(commandId)}/receipt", receipt, JsonOptions, timeout.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(timeout.Token).ConfigureAwait(false));
|
||||
if(!body.RootElement.TryGetProperty("accepted", out JsonElement accepted) || accepted.ValueKind != JsonValueKind.True)
|
||||
{
|
||||
throw new InvalidDataException("Control receipt was not accepted.");
|
||||
}
|
||||
await RealtimeChannel.SendAsync("COMMAND_RECEIPT", new { commandId, receipt }, "receipt-" + receipt.ReceiptId, acknowledged: true, token: token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,26 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using Qqq.PrintAgent.Core.Transport;
|
||||
|
||||
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.
|
||||
/// checked, with total, connect and idle time limits, paced by the runtime's 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.
|
||||
/// download deletes its partial file, so only a verified file is ever returned. The throttle is told how many bytes each read
|
||||
/// brought and returns when the next read may start; the runtime shares one across its lanes so the bandwidth limit holds in
|
||||
/// aggregate.
|
||||
/// </summary>
|
||||
public async Task<DownloadedDocument> DownloadFileAsync(PrintJob job, string directory, RealtimeSettings settings, CancellationToken token, Func<int, CancellationToken, Task>? throttle = null)
|
||||
public async Task<DownloadedDocument> DownloadFileAsync(PrintJob job, string directory, RealtimeSettings settings, Func<int, CancellationToken, Task> throttle, CancellationToken token)
|
||||
{
|
||||
settings.Validate();
|
||||
if(job.ContentLength < 1 || job.ContentLength > settings.MaximumDocumentBytes(job.Format) || job.ContentLength > settings.MaxStagedBytes)
|
||||
@@ -44,20 +46,16 @@ 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,
|
||||
/// Requests the document through the signed-in session, 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)
|
||||
private async Task DownloadIntoFileAsync(PrintJob job, string path, RealtimeSettings settings, Func<int, CancellationToken, Task> throttle, CancellationToken token)
|
||||
{
|
||||
using var total = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
total.CancelAfter(settings.DownloadTimeoutMillis);
|
||||
using var connect = CancellationTokenSource.CreateLinkedTokenSource(total.Token);
|
||||
connect.CancelAfter(settings.DownloadConnectTimeoutMillis);
|
||||
using var total = new Deadline(_time, TimeSpan.FromMilliseconds(settings.DownloadTimeoutMillis), token);
|
||||
using var connect = new Deadline(_time, TimeSpan.FromMilliseconds(settings.DownloadConnectTimeoutMillis), total.Token);
|
||||
string route = $"device/jobs/{Uri.EscapeDataString(job.JobId)}/content";
|
||||
using HttpResponseMessage response = _nativeSessionClient is null
|
||||
? await _http.GetAsync(route, HttpCompletionOption.ResponseHeadersRead, connect.Token).ConfigureAwait(false)
|
||||
: await _nativeSessionClient.SendPrintingAsync(HttpMethod.Get, route, cancellationToken: connect.Token).ConfigureAwait(false);
|
||||
using HttpResponseMessage response = await _session.SendPrintingAsync(HttpMethod.Get, route, cancellationToken: connect.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
if(response.Content.Headers.ContentLength is long declared && declared != job.ContentLength)
|
||||
{
|
||||
@@ -73,19 +71,17 @@ public sealed partial class PrintApiClient
|
||||
|
||||
/// <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.
|
||||
/// data arrives within the idle limit, or when the final length or SHA-256 does not match. The throttle paces the copy after each
|
||||
/// read.
|
||||
/// </summary>
|
||||
private static async Task CopyVerifiedAsync(Stream source, Stream output, PrintJob job, RealtimeSettings settings, Func<int, CancellationToken, Task>? throttle, CancellationToken token)
|
||||
private 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);
|
||||
byte[] buffer = new byte[81920];
|
||||
long received = 0;
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
while(true)
|
||||
{
|
||||
using var idle = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
idle.CancelAfter(settings.DownloadIdleTimeoutMillis);
|
||||
using var idle = new Deadline(_time, TimeSpan.FromMilliseconds(settings.DownloadIdleTimeoutMillis), token);
|
||||
int count = await source.ReadAsync(buffer, idle.Token).ConfigureAwait(false);
|
||||
if(count == 0)
|
||||
{ break; }
|
||||
@@ -94,14 +90,7 @@ public sealed partial class PrintApiClient
|
||||
{ throw new InvalidDataException("Document exceeds its declared length."); }
|
||||
digest.AppendData(buffer, 0, count);
|
||||
await output.WriteAsync(buffer.AsMemory(0, count), token).ConfigureAwait(false);
|
||||
if(throttle is not null)
|
||||
{ await throttle(count, token).ConfigureAwait(false); }
|
||||
else if(settings.MaxDownloadBytesPerSecond > 0)
|
||||
{
|
||||
TimeSpan remaining = TimeSpan.FromSeconds((double)received / settings.MaxDownloadBytesPerSecond) - Stopwatch.GetElapsedTime(started);
|
||||
if(remaining > TimeSpan.Zero)
|
||||
{ await Task.Delay(remaining, token).ConfigureAwait(false); }
|
||||
}
|
||||
await throttle(count, token).ConfigureAwait(false);
|
||||
}
|
||||
if(received != job.ContentLength || !string.Equals(Convert.ToHexString(digest.GetHashAndReset()), job.ContentSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Realtime part of the API client: attaches a WebSocket channel so inventory, heartbeats and events travel over it with the server's
|
||||
/// acknowledgement instead of over separate HTTP calls.
|
||||
/// Realtime part of the API client: attaches the WebSocket channel that inventory, job events and command receipts travel over, each with
|
||||
/// the server's acknowledgement. Nothing falls back to HTTP: a send made with no channel attached fails as a closed connection.
|
||||
/// </summary>
|
||||
public sealed partial class PrintApiClient
|
||||
{
|
||||
@@ -16,8 +16,8 @@ public sealed partial class PrintApiClient
|
||||
|
||||
|
||||
|
||||
/// <summary>The channel to send on: the provider's current one when a provider was given (so a reconnect is picked up), otherwise the attached one; with neither it throws, never silently falling back to HTTP.</summary>
|
||||
private PrintingRealtimeChannel RealtimeChannel => _realtimeProvider?.Invoke() ?? _realtime ?? throw new IOException("Realtime connection unavailable.");
|
||||
/// <summary>The channel to send on: the provider's current one when a provider was given (so a reconnect is picked up), otherwise the attached one; with neither it throws that the channel is closed, the failure every other "the connection is gone" raises.</summary>
|
||||
private PrintingRealtimeChannel RealtimeChannel => _realtimeProvider?.Invoke() ?? _realtime ?? throw new RealtimeChannelClosedException("Realtime connection unavailable.", null);
|
||||
|
||||
|
||||
|
||||
@@ -29,9 +29,4 @@ public sealed partial class PrintApiClient
|
||||
_http.Timeout = TimeSpan.FromMilliseconds(settings.RequestTimeoutMillis);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when a realtime channel is attached, which tells the worker that heartbeats and events already flow over the socket.</summary>
|
||||
public bool UsesRealtime => _realtime is not null;
|
||||
}
|
||||
@@ -8,41 +8,15 @@ using Qqq.PrintAgent.Core.Authentication;
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Native session part of the API client: routes every request through the signed-in QQQ session instead of a device bearer token, and
|
||||
/// confines those requests to the printing extension's path.
|
||||
/// Session part of the API client: the signed-in QQQ session every request goes through, which is the only thing that authenticates
|
||||
/// one and which confines the requests to the printing extension's path.
|
||||
/// </summary>
|
||||
public sealed partial class PrintApiClient
|
||||
{
|
||||
private bool _nativeSession;
|
||||
private IQqqSessionClient? _nativeSessionClient;
|
||||
private readonly IQqqSessionClient _session;
|
||||
|
||||
|
||||
|
||||
/// <summary>The session remains owned by the service; disposing an API request adapter does not sign it out.</summary>
|
||||
public static PrintApiClient ForSession(IQqqSessionClient session)
|
||||
{
|
||||
PrintApiClient client = new(session.Origin.AbsoluteUri, handler: new SessionHandler(session));
|
||||
client._nativeSession = true;
|
||||
client._nativeSessionClient = session;
|
||||
client._http.BaseAddress = new Uri(session.Origin, "/qqq/v1/qbit/printing/");
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends this client's requests through the session, which attaches the cookie and device proof and refuses anything outside the native printing path, so the client itself holds no credential.</summary>
|
||||
private sealed class SessionHandler(IQqqSessionClient session) : HttpMessageHandler
|
||||
{
|
||||
/// <summary>Accepts only requests under the printing extension path, strips that prefix and hands the rest to the session; any other address throws before a request is made.</summary>
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
const string prefix = "/qqq/v1/qbit/printing/";
|
||||
string path = request.RequestUri!.AbsolutePath;
|
||||
if(!path.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Request is outside the native printing extension.");
|
||||
}
|
||||
return session.SendPrintingAsync(request.Method, path[prefix.Length..] + request.RequestUri.Query, request.Content, cancellationToken);
|
||||
}
|
||||
}
|
||||
/// <summary>Builds the client for a signed-in session, the only way to build one. The session remains owned by the service; disposing an API request adapter does not sign it out. The clock is the one the client counts its limits on, the system clock by default.</summary>
|
||||
public static PrintApiClient ForSession(IQqqSessionClient session, TimeProvider? timeProvider = null) => new(session, timeProvider);
|
||||
}
|
||||
@@ -3,57 +3,58 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// The agent's client for the QQQ printing API. The base part holds the HTTP calls that remain (enrollment, claim, content,
|
||||
/// queued jobs and operator actions) and routes inventory, heartbeat and events over the realtime channel whenever one is
|
||||
/// attached. Other parts add the native session, downloads, realtime and control calls.
|
||||
/// The agent's client for the QQQ printing API. It is built from a signed-in session and sends every request through
|
||||
/// it. The base part holds the HTTP calls (enrollment, claim, queued jobs and operator actions) and sends inventory and
|
||||
/// job events over the realtime channel, which is the only way they travel. Other parts add the session, downloads,
|
||||
/// the channel itself and control calls.
|
||||
/// </summary>
|
||||
public sealed partial class PrintApiClient : IDisposable
|
||||
{
|
||||
public static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull };
|
||||
private readonly HttpClient _http;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
|
||||
|
||||
/// <summary>The agent's assembly version, sent with heartbeats so the server can show which build each device runs.</summary>
|
||||
/// <summary>The agent's assembly version, sent when enrolling, in the realtime hello and with heartbeats, so the server can show which build each device runs.</summary>
|
||||
public static string AgentVersion => typeof(PrintApiClient).Assembly.GetName().Version?.ToString() ?? "unknown";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates a client for one server. The address must be HTTPS, or HTTP only on loopback for local development. Responses
|
||||
/// are limited to 2 MiB and requests to 60 seconds, and a bearer token is attached only when one is supplied (the legacy
|
||||
/// diagnostic path); native sessions authenticate through the session client instead.
|
||||
/// Creates a client whose every request goes through the given signed-in session, to the printing extension's path on
|
||||
/// the session's server. There is no other way to build one, so the client cannot send anything outside a session,
|
||||
/// and it holds no credential of its own: the session adds what authenticates a request. The server address is the
|
||||
/// session's, which the session client has already required to be HTTPS or explicitly allowed loopback HTTP. Responses
|
||||
/// are limited to 2 MiB and requests to 60 seconds. The download limits are counted on the supplied clock, the system
|
||||
/// clock by default. The 60 second request limit is not: it is <c>HttpClient.Timeout</c>, which the platform enforces
|
||||
/// on the system clock whatever clock is supplied.
|
||||
/// </summary>
|
||||
public PrintApiClient(string serverUrl, string? token = null, HttpMessageHandler? handler = null)
|
||||
private PrintApiClient(IQqqSessionClient session, TimeProvider? timeProvider)
|
||||
{
|
||||
Uri uri = new(serverUrl.TrimEnd('/') + "/printing/v1/");
|
||||
if(!string.Equals(uri.Scheme, "https", StringComparison.Ordinal) && !(string.Equals(uri.Scheme, "http", StringComparison.Ordinal) && uri.IsLoopback))
|
||||
_session = session;
|
||||
_time = timeProvider ?? TimeProvider.System;
|
||||
_http = new HttpClient(new PrintingSessionHandler(session))
|
||||
{
|
||||
throw new ArgumentException("Use HTTPS, or HTTP on loopback for local development.", nameof(serverUrl));
|
||||
}
|
||||
_http = handler is null ? new HttpClient() : new HttpClient(handler);
|
||||
_http.BaseAddress = uri;
|
||||
_http.Timeout = TimeSpan.FromSeconds(60);
|
||||
_http.MaxResponseContentBufferSize = 2 * 1024 * 1024;
|
||||
if(token is not null)
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
BaseAddress = new Uri(session.Origin, PrintingSessionHandler.Prefix),
|
||||
Timeout = TimeSpan.FromSeconds(60),
|
||||
MaxResponseContentBufferSize = 2 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Enrolls this computer with a short-lived code and returns the device identity and its secondary proof. A protocol version other than 1 is rejected rather than guessed at.</summary>
|
||||
/// <summary>Enrolls this computer with a short-lived code, on the enrollment route of the signed-in session, and returns the device identity and its secondary proof. A protocol version other than 1 is rejected rather than guessed at.</summary>
|
||||
public async Task<EnrollmentResponse> EnrollAsync(EnrollmentRequest request, CancellationToken token = default)
|
||||
{
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync(_nativeSession ? "devices/enroll" : "enroll", request, JsonOptions, token).ConfigureAwait(false);
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync("devices/enroll", request, JsonOptions, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
EnrollmentResponse result = await response.Content.ReadFromJsonAsync<EnrollmentResponse>(token).ConfigureAwait(false)
|
||||
?? throw new InvalidDataException("Missing enrollment response.");
|
||||
@@ -67,103 +68,37 @@ public sealed partial class PrintApiClient : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>Reports the printers this computer shares, over the realtime channel when attached (waiting for the server's acknowledgement) and otherwise over HTTP.</summary>
|
||||
/// <summary>Reports the printers this computer shares, over the realtime channel, and waits for the server's acknowledgement.</summary>
|
||||
public async Task InventoryAsync(IReadOnlyList<PrinterInfo> printers, CancellationToken token)
|
||||
{
|
||||
if(_realtime is not null)
|
||||
{
|
||||
await RealtimeChannel.SendAsync("INVENTORY", new { printers }, acknowledged: true, token: token).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
using HttpResponseMessage response = await _http.PutAsJsonAsync("device/printers", new { printers }, JsonOptions, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await RealtimeChannel.SendAsync("INVENTORY", new { printers }, acknowledged: true, token: token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends a liveness heartbeat without a device profile.</summary>
|
||||
public Task HeartbeatAsync(CancellationToken token) => HeartbeatAsync(null, token);
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends a liveness heartbeat carrying the agent version and, when given, the computer profile, over the realtime channel when attached and otherwise over HTTP.</summary>
|
||||
public async Task HeartbeatAsync(DeviceProfile? profile, CancellationToken token)
|
||||
{
|
||||
if(_realtime is not null)
|
||||
{
|
||||
await RealtimeChannel.SendAsync("HEARTBEAT", new { agentVersion = AgentVersion, profile }, token: token).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync("device/heartbeat", new { agentVersion = AgentVersion, profile }, JsonOptions, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Asks the server for the next job for this device, or null when none is waiting. The server replays a claimed job until its RECEIVED event commits, so the caller must record it.</summary>
|
||||
/// <summary>
|
||||
/// Asks the server for the next job for this device, or null when none is waiting. The server replays a claimed job
|
||||
/// until its RECEIVED event commits, so the caller must record it. The job is checked against the contract before it
|
||||
/// is returned: one that breaks it throws <see cref="PrintingProtocolException"/>, carrying a stand-in when the job
|
||||
/// named itself, so nothing later ever handles a job with a missing member or an unsafe identifier. Fields the agent
|
||||
/// does not read are ignored, and a byte-order mark in front of the body is accepted.
|
||||
/// </summary>
|
||||
public async Task<PrintJob?> ClaimAsync(CancellationToken token)
|
||||
{
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync("device/jobs/claim", new { }, JsonOptions, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<JobClaim>(token).ConfigureAwait(false))?.Job;
|
||||
return PrintJobContract.ReadClaim(await response.Content.ReadAsByteArrayAsync(token).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a whole document into memory, rejecting an empty or over-20 MiB declaration up front. Reading stops the moment
|
||||
/// the data exceeds the declared length, and the result must match the declared length and SHA-256 or it is refused, so a
|
||||
/// truncated or altered document can never be printed.
|
||||
/// </summary>
|
||||
public async Task<byte[]> DownloadAsync(PrintJob job, CancellationToken token)
|
||||
{
|
||||
if(job.ContentLength is < 1 or > 20_971_520)
|
||||
{
|
||||
throw new InvalidDataException("Document exceeds the 20 MiB limit or is empty.");
|
||||
}
|
||||
|
||||
using var downloadTimeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
downloadTimeout.CancelAfter(_http.Timeout);
|
||||
token = downloadTimeout.Token;
|
||||
using HttpResponseMessage response = await _http.GetAsync($"device/jobs/{Uri.EscapeDataString(job.JobId)}/content", HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using Stream source = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
|
||||
using MemoryStream output = new();
|
||||
byte[] buffer = new byte[81920];
|
||||
int read;
|
||||
while((read = await source.ReadAsync(buffer, token).ConfigureAwait(false)) != 0)
|
||||
{
|
||||
if(output.Length + read > job.ContentLength)
|
||||
{
|
||||
throw new InvalidDataException("Document length mismatch.");
|
||||
}
|
||||
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), token);
|
||||
}
|
||||
byte[] content = output.ToArray();
|
||||
if(content.LongLength != job.ContentLength || !string.Equals(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(content)), job.ContentSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException("Document integrity check failed.");
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Delivers one job event. Over the realtime channel the message id is derived from the event id and the server must
|
||||
/// Delivers one job event over the realtime channel. The message id is derived from the event id and the server must
|
||||
/// acknowledge it, so a replay after a lost acknowledgement carries the same identity and is not applied twice.
|
||||
/// </summary>
|
||||
public async Task EventAsync(string jobId, PrintEvent printEvent, CancellationToken token)
|
||||
{
|
||||
if(_realtime is not null)
|
||||
{
|
||||
await RealtimeChannel.SendAsync("JOB_EVENT", new { jobId, @event = printEvent }, "event-" + printEvent.EventId, acknowledged: true, token: token).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
using HttpResponseMessage response = await _http.PostAsJsonAsync($"device/jobs/{Uri.EscapeDataString(jobId)}/events", printEvent, JsonOptions, token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await RealtimeChannel.SendAsync("JOB_EVENT", new { jobId, @event = printEvent }, "event-" + printEvent.EventId, acknowledged: true, token: token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -173,15 +108,20 @@ public sealed partial class PrintApiClient : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>Lists jobs the server is holding for this device but this computer has not received. A server without the endpoint (404) is treated as having none, so an older server only loses the preview.</summary>
|
||||
/// <summary>
|
||||
/// Lists jobs the server is holding for this device but this computer has not received. A server without the endpoint
|
||||
/// (404) is treated as having none, so an older server only loses the preview. Each entry must name its job and
|
||||
/// printer once, with usable identifiers; a list that breaks the contract throws
|
||||
/// <see cref="PrintingProtocolException"/>. Fields the agent does not read are ignored, and a byte-order mark in
|
||||
/// front of the body is accepted.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<PrintJob>> QueuedJobsAsync(CancellationToken token)
|
||||
{
|
||||
using HttpResponseMessage response = await _http.GetAsync("device/jobs/queued", token).ConfigureAwait(false);
|
||||
if(response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{ return []; }
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync(token).ConfigureAwait(false));
|
||||
return result.RootElement.TryGetProperty("jobs", out JsonElement jobs) ? jobs.Deserialize<PrintJob[]>(JsonOptions) ?? [] : [];
|
||||
return PrintJobContract.ReadQueued(await response.Content.ReadAsByteArrayAsync(token).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Reads jobs the server sends and refuses any that break the contract, so nothing past the API client ever sees a job
|
||||
/// with a missing member or an identifier that is unsafe to put in a route, a file name or a log line. Whether a
|
||||
/// well-formed job may print (format supported, size and copies within policy, media still current) stays with the
|
||||
/// worker, which reports those as FAILED; this is only about shape. Fields the agent does not read are ignored
|
||||
/// wherever they appear, as the contract requires, because the server relays a submitter's options unchanged. For
|
||||
/// the same reason a response is parsed much deeper than a job may nest: whoever submits a job chooses how deep its
|
||||
/// options go, and a job that could not even be identified could not be refused.
|
||||
/// </summary>
|
||||
internal static class PrintJobContract
|
||||
{
|
||||
/// <summary>The most queued jobs one response may list, as the contract states.</summary>
|
||||
internal const int QueuedLimit = 200;
|
||||
|
||||
private const int TitleLimit = 1024;
|
||||
private const int TokenLimit = 32;
|
||||
private const int TimestampLimit = 64;
|
||||
private const int CopiesLimit = 32767;
|
||||
|
||||
/// <summary>The members of a job the agent reads, by their contract names. A test holds this list to the <see cref="PrintJob"/> record.</summary>
|
||||
internal static readonly string[] JobMembers = ["jobId", "printerId", "title", "format", "contentSha256", "contentLength", "options", "createdAt", "status"];
|
||||
|
||||
/// <summary>The members of a job's options the agent reads. A test holds this list to the <see cref="PrintOptions"/> record.</summary>
|
||||
internal static readonly string[] OptionMembers = ["copies", "scale", "paperWidthMm", "paperHeightMm", "mediaProfile"];
|
||||
|
||||
/// <summary>The members of a media selection the agent reads. A test holds this list to the <see cref="PrinterMediaSelection"/> record.</summary>
|
||||
internal static readonly string[] MediaMembers = ["id", "revision"];
|
||||
|
||||
private static readonly string[] Identifiers = ["jobId", "printerId"];
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads a claim response: null when the server has no job for this device, otherwise the validated job. A response
|
||||
/// that is not a JSON object, or a job that breaks the contract, throws. Whenever the job still names itself with one
|
||||
/// usable identifier the failure carries a stand-in for it, so the worker can answer for the job instead of being
|
||||
/// offered it again forever; only a response that names no job the agent can answer for throws without one. A job
|
||||
/// nested deeper than <see cref="ProtocolJson.ReaderDepth"/> is such an identified failure; a response nested
|
||||
/// deeper than <see cref="ProtocolJson.ParseDepth"/> is not JSON to the parser and names nothing. Text the JSON
|
||||
/// library cannot read (an unpaired surrogate escape, bytes that are not UTF-8) breaks the contract like any other
|
||||
/// fault, and never stops the job from being identified: in a member name beside the job or where the agent reads
|
||||
/// the job's members, and in a text member the agent reads, where the stand-in then leaves that member empty. A
|
||||
/// response that offers no job is not looked through for such names; there is nothing in it to refuse.
|
||||
/// </summary>
|
||||
internal static PrintJob? ReadClaim(ReadOnlyMemory<byte> body)
|
||||
{
|
||||
using JsonDocument document = ProtocolJson.ParseObject(ProtocolJson.WithoutByteOrderMark(body), "claim response", ProtocolJson.ParseDepth);
|
||||
JsonElement[] offered = ProtocolJson.Members(document.RootElement, "job");
|
||||
if(offered.Length > 1)
|
||||
{ throw new PrintingProtocolException("The claim response names more than one job."); }
|
||||
if(offered.Length == 0 || offered[0].ValueKind == JsonValueKind.Null)
|
||||
{ return null; }
|
||||
JsonElement element = offered[0];
|
||||
if(element.ValueKind != JsonValueKind.Object)
|
||||
{ throw new PrintingProtocolException("The claimed job is not a JSON object."); }
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// the stand-in is built first, from the members as the server spelled //
|
||||
// them, so every refusal below can name the job it is about //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
PrintJob? named = StandIn(Text(element, "jobId"), Text(element, "printerId"), Text(element, "title"), Text(element, "format"), Text(element, "createdAt"));
|
||||
if(ProtocolJson.HasUnreadableName(document.RootElement) || HasUnreadableName(element))
|
||||
{ throw Violation("The claim has a member name that is not readable text.", named); }
|
||||
if(ProtocolJson.NestsDeeperThan(element, ProtocolJson.ReaderDepth - 1))
|
||||
{ throw Violation("The claimed job is nested deeper than the agent reads.", named); }
|
||||
if(RepeatsReadMember(element))
|
||||
{ throw Violation("The claimed job repeats a member the agent reads.", named); }
|
||||
PrintJob job;
|
||||
try
|
||||
{ job = ProtocolJson.Read<PrintJob>(element, "claimed job"); }
|
||||
catch(PrintingProtocolException violation)
|
||||
{ throw Violation(violation.Message, named); }
|
||||
return ClaimProblem(job) is { } problem
|
||||
? throw Violation(problem, StandIn(job.JobId, job.PrinterId, job.Title, job.Format, job.CreatedAt))
|
||||
: Normalize(job);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the list of jobs the server is holding for this device. They are shown, never printed from here, so only
|
||||
/// what the display relies on is required of each: a usable job and printer identifier, each given once. Everything
|
||||
/// else that is missing becomes empty text, and a repeated display member is tolerated because nothing is decided
|
||||
/// from it; the job is judged in full when it is claimed. For the same reason an entry may nest as deep as the
|
||||
/// response is parsed: a job that will be refused for its depth when it is claimed is still shown until then. A
|
||||
/// response without a list means none; more than the contract's limit is refused.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<PrintJob> ReadQueued(ReadOnlyMemory<byte> body)
|
||||
{
|
||||
using JsonDocument document = ProtocolJson.ParseObject(ProtocolJson.WithoutByteOrderMark(body), "queued jobs response", ProtocolJson.ParseDepth);
|
||||
JsonElement[] lists = ProtocolJson.Members(document.RootElement, "jobs");
|
||||
if(lists.Length > 1)
|
||||
{ throw new PrintingProtocolException("The queued jobs response holds more than one list."); }
|
||||
if(lists.Length == 0 || lists[0].ValueKind == JsonValueKind.Null)
|
||||
{ return []; }
|
||||
if(lists[0].ValueKind != JsonValueKind.Array || lists[0].GetArrayLength() > QueuedLimit)
|
||||
{ throw new PrintingProtocolException($"The queued jobs response does not hold a list of at most {QueuedLimit} jobs."); }
|
||||
List<PrintJob> jobs = [];
|
||||
foreach(JsonElement element in lists[0].EnumerateArray())
|
||||
{
|
||||
if(element.ValueKind != JsonValueKind.Object)
|
||||
{ throw new PrintingProtocolException("A queued job is not a JSON object."); }
|
||||
PrintJob job = ProtocolJson.Read<PrintJob>(element, "queued job", toParseDepth: true);
|
||||
if(!PrintApiClient.ValidControlId(job.JobId) || !PrintApiClient.ValidControlId(job.PrinterId) || ProtocolJson.RepeatsMember(element, Identifiers))
|
||||
{ throw new PrintingProtocolException("A queued job has no usable job or printer identifier."); }
|
||||
jobs.Add(Normalize(job) with { Title = job.Title ?? "", Format = job.Format ?? "", ContentSha256 = job.ContentSha256 ?? "", Options = job.Options ?? new PrintOptions() });
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when a claimed job gives a member the agent reads more than once: in the job itself, in its options or in
|
||||
/// its media selection. The agent would act on one of the values and the server may have meant the other, so such a
|
||||
/// job is refused rather than printed on a guess. Fields the agent does not read are not looked at.
|
||||
/// </summary>
|
||||
private static bool RepeatsReadMember(JsonElement job)
|
||||
{
|
||||
if(ProtocolJson.RepeatsMember(job, JobMembers))
|
||||
{ return true; }
|
||||
if(ProtocolJson.Members(job, "options") is not [{ ValueKind: JsonValueKind.Object } options])
|
||||
{ return false; }
|
||||
return ProtocolJson.RepeatsMember(options, OptionMembers)
|
||||
|| (ProtocolJson.Members(options, "mediaProfile") is [{ ValueKind: JsonValueKind.Object } media] && ProtocolJson.RepeatsMember(media, MediaMembers));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when a claimed job has a member whose name is not readable text where the agent reads members: in the job
|
||||
/// itself, in its options or in its media selection. The JSON library skips some such names and fails on others,
|
||||
/// so without this check whether the job printed would depend on how the name was spelled. Names deeper inside a
|
||||
/// field the agent does not read are not looked at, like everything else in such a field.
|
||||
/// </summary>
|
||||
private static bool HasUnreadableName(JsonElement job)
|
||||
{
|
||||
if(ProtocolJson.HasUnreadableName(job))
|
||||
{ return true; }
|
||||
if(ProtocolJson.Members(job, "options") is not [{ ValueKind: JsonValueKind.Object } options])
|
||||
{ return false; }
|
||||
return ProtocolJson.HasUnreadableName(options)
|
||||
|| (ProtocolJson.Members(options, "mediaProfile") is [{ ValueKind: JsonValueKind.Object } media] && ProtocolJson.HasUnreadableName(media));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Says what is wrong with a claimed job, or null when its shape is acceptable: safe identifiers, a bounded title, a
|
||||
/// format token, a SHA-256 hash, a positive length and usable options.
|
||||
/// </summary>
|
||||
private static string? ClaimProblem(PrintJob job)
|
||||
{
|
||||
if(!PrintApiClient.ValidControlId(job.JobId))
|
||||
{ return "The claimed job has no usable job identifier."; }
|
||||
if(!PrintApiClient.ValidControlId(job.PrinterId))
|
||||
{ return "The claimed job has no usable printer identifier."; }
|
||||
if(job.Title is null || !WithinTitleLimit(job.Title))
|
||||
{ return "The claimed job has no title, or one that is too long."; }
|
||||
if(!IsToken(job.Format))
|
||||
{ return "The claimed job has no usable document format."; }
|
||||
if(job.ContentSha256 is not { Length: 64 } || !job.ContentSha256.All(char.IsAsciiHexDigit))
|
||||
{ return "The claimed job has no SHA-256 content hash."; }
|
||||
if(job.ContentLength is < 1 or > int.MaxValue)
|
||||
{ return "The claimed job declares an empty or impossibly large document."; }
|
||||
return OptionsProblem(job.Options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Says what is wrong with a job's options, or null when they are usable: present, a possible copy count, a scale
|
||||
/// token, and paper dimensions that are complete, in range and not combined with a media profile. Whether a selected
|
||||
/// media profile is well formed and still current is judged once, by <see cref="PrinterMediaProfile.RequireMatch"/>,
|
||||
/// just before submission.
|
||||
/// </summary>
|
||||
private static string? OptionsProblem(PrintOptions? options)
|
||||
{
|
||||
if(options is null)
|
||||
{ return "The claimed job has no print options."; }
|
||||
if(options.Copies is < 1 or > CopiesLimit)
|
||||
{ return "The claimed job asks for an impossible number of copies."; }
|
||||
if(!IsToken(options.Scale))
|
||||
{ return "The claimed job has no usable scale option."; }
|
||||
try
|
||||
{ _ = options.CustomPaperMicrons(); }
|
||||
catch(InvalidDataException)
|
||||
{ return "The claimed job has incomplete or out-of-range paper dimensions, or combines them with a media profile."; }
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True for a title within the contract's limit. The contract counts Unicode code points, as JSON Schema does, so a
|
||||
/// title is measured that way and not in UTF-16 units: 1,024 characters from outside the basic plane, which take two
|
||||
/// units each, are as acceptable as 1,024 letters.
|
||||
/// </summary>
|
||||
private static bool WithinTitleLimit(string title) => title.EnumerateRunes().Count() <= TitleLimit;
|
||||
|
||||
|
||||
|
||||
/// <summary>True for a short token of ASCII letters, digits and underscores, the form a format or scale name takes.</summary>
|
||||
private static bool IsToken([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? value) => value is { Length: >= 1 and <= TokenLimit } && value.All(character => char.IsAsciiLetterOrDigit(character) || character == '_');
|
||||
|
||||
|
||||
|
||||
/// <summary>Replaces a missing or over-long creation time or status, which are only ever displayed, with empty text.</summary>
|
||||
private static PrintJob Normalize(PrintJob job) => job with
|
||||
{
|
||||
CreatedAt = job.CreatedAt is { Length: <= TimestampLimit } created ? created : "",
|
||||
Status = job.Status is { Length: <= TokenLimit } status ? status : "",
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Builds the stand-in for a malformed job: its identifier, when that is usable, and whichever display facts are safe
|
||||
/// to keep, with the rest left empty. Without a usable identifier there is nothing to report against, so there is no
|
||||
/// stand-in.
|
||||
/// </summary>
|
||||
private static PrintJob? StandIn(string? jobId, string? printerId, string? title, string? format, string? createdAt)
|
||||
{
|
||||
if(!PrintApiClient.ValidControlId(jobId))
|
||||
{ return null; }
|
||||
return new PrintJob(jobId, PrintApiClient.ValidControlId(printerId) ? printerId : "", title is not null && WithinTitleLimit(title) ? title : "", IsToken(format) ? format : "",
|
||||
"", 0, new PrintOptions(), createdAt is { Length: <= TimestampLimit } ? createdAt : "", PrintJobState.Claimed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the text member the contract names, or null when it is missing, not text, not readable text, or given more than once so that it is not clear which value is meant.</summary>
|
||||
private static string? Text(JsonElement element, string name)
|
||||
=> ProtocolJson.Members(element, name) is [{ ValueKind: JsonValueKind.String } member] ? ProtocolJson.Text(member) : null;
|
||||
|
||||
|
||||
|
||||
/// <summary>The failure for a malformed claimed job, carrying the stand-in when the job could be identified.</summary>
|
||||
private static PrintingProtocolException Violation(string message, PrintJob? standIn)
|
||||
=> standIn is null ? new PrintingProtocolException(message) : new PrintingProtocolException(message, standIn);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// The states a job passes through, by the exact names the wire contract and the journal use. They are named once here
|
||||
/// so a state is never spelled by hand where a typing mistake would silently fail to match, and so the rule for which
|
||||
/// states are final has one home.
|
||||
/// </summary>
|
||||
public static class PrintJobState
|
||||
{
|
||||
/// <summary>The server handed the job to this device; nothing is recorded locally yet.</summary>
|
||||
public const string Claimed = "CLAIMED";
|
||||
|
||||
/// <summary>The agent recorded the job and told the server it has it. No bytes have gone to a printer.</summary>
|
||||
public const string Received = "RECEIVED";
|
||||
|
||||
/// <summary>The agent is about to hand the document to the printer. From here on, an interruption leaves the outcome unknown.</summary>
|
||||
public const string Submitting = "SUBMITTING";
|
||||
|
||||
/// <summary>Windows accepted the document into the spooler. That is not the same as paper coming out.</summary>
|
||||
public const string Spooled = "SPOOLED";
|
||||
|
||||
/// <summary>The spooler reported the job complete.</summary>
|
||||
public const string SpoolerCompleted = "SPOOLER_COMPLETED";
|
||||
|
||||
/// <summary>The job was refused or failed before any bytes could have reached a printer; submitting it again is safe.</summary>
|
||||
public const string Failed = "FAILED";
|
||||
|
||||
/// <summary>Submission began and was interrupted, so the job may or may not have printed. It is never printed again automatically; an operator must look.</summary>
|
||||
public const string OutcomeUnknown = "OUTCOME_UNKNOWN";
|
||||
|
||||
|
||||
|
||||
/// <summary>True for a state after which the agent never works on the job again: spooled, completed, failed or of unknown outcome.</summary>
|
||||
public static bool IsFinal(string? state) => state is Spooled or SpoolerCompleted or Failed or OutcomeUnknown;
|
||||
}
|
||||
@@ -5,61 +5,58 @@
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>Serial happy-path worker. A locally ambiguous submission is never automatically printed again.</summary>
|
||||
public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJobJournal journal, Action<string>? log = null,
|
||||
Func<bool>? canDispatch = null, Func<bool>? canSynchronize = null, PrintWorkerOptions? options = null)
|
||||
/// <summary>
|
||||
/// One lane of the print pipeline: each pass claims at most one job, stages its document and submits it once. A
|
||||
/// locally ambiguous submission is never automatically printed again. The worker keeps those rules and nothing else:
|
||||
/// whether it may dispatch, claim or submit, the limits in force, the gates shared with the other lanes and the
|
||||
/// leases a job holds all come from the runtime that composes it, and every one of them is required. Jobs a previous
|
||||
/// run left unfinished are reported by the runtime once, at start, and inventory and heartbeats are the runtime's to
|
||||
/// send, so a pass does none of that.
|
||||
/// </summary>
|
||||
public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJobJournal journal, Action<string> log,
|
||||
Func<bool> canDispatch, Func<bool> canSynchronize, PrintWorkerOptions options)
|
||||
{
|
||||
/// <summary>
|
||||
/// One pass of the claim loop. It first reports work a previous run left half-done and drains the durable event
|
||||
/// outbox, because the server never re-claims those jobs and an unreported outcome would stay invisible. Only when
|
||||
/// nothing is pending, dispatch is allowed and the connection is synchronized does it discover printers and claim at
|
||||
/// most one job. Returns true when a job was spooled, or, when recovery is switched off (realtime lanes, where the
|
||||
/// runtime recovers once and each lane only drains), whenever a claimed job was processed whatever its outcome so
|
||||
/// the lane keeps draining; false when idle, paused, blocked, or the claimed job was already final.
|
||||
/// One pass of a lane. It first drains the durable event outbox, because an outcome the server never heard of would
|
||||
/// stay invisible. Only when nothing is pending, the connection is synchronized and dispatch is allowed does it
|
||||
/// list the printers and claim at most one job. The list is the one that reads no printer's settings: it is used
|
||||
/// only to name the claimed job's printer in the journal, so a printer whose driver hangs or fails cannot stop a
|
||||
/// pass before it claims. A platform that cannot list its printers at all still stops the pass here, with nothing
|
||||
/// claimed. Returns true whenever a claimed job was processed, whatever its outcome, and when a malformed job was
|
||||
/// refused and reported, so the lane keeps draining; false when idle, paused, blocked, or the claimed job was one
|
||||
/// a lane of this computer already holds, or one the journal already holds as final or as submitting, which ends
|
||||
/// the lane's pass because the server would only offer that job again.
|
||||
/// </summary>
|
||||
public async Task<bool> RunOnceAsync(CancellationToken token = default)
|
||||
public async Task<bool> RunOnceAsync(CancellationToken token)
|
||||
{
|
||||
// A recovered job must be reported even when the server never claims it again.
|
||||
foreach((string jobId, string previousState) in options?.RecoverIncomplete == false ? [] : journal.Incomplete())
|
||||
{
|
||||
Record(jobId, previousState is "SUBMITTING" ? "OUTCOME_UNKNOWN" : "FAILED",
|
||||
previousState is "SUBMITTING" ? "Agent restarted during submission; inspect the printer before reprinting." : "Agent stopped before submitting this job; submit a new job if printing is still needed.");
|
||||
}
|
||||
await FlushAsync(token).ConfigureAwait(false);
|
||||
if(canSynchronize?.Invoke() == false)
|
||||
if(!canSynchronize())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if(!api.UsesRealtime)
|
||||
{ await api.HeartbeatAsync(token).ConfigureAwait(false); }
|
||||
if(journal.Pending().Count > 0)
|
||||
{
|
||||
// Drain a bounded outbox batch before claiming more work after an outage.
|
||||
return false;
|
||||
}
|
||||
long inventoryStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
IReadOnlyList<PrinterInfo> inventory = printers.Discover();
|
||||
TimeSpan inventoryElapsed = System.Diagnostics.Stopwatch.GetElapsedTime(inventoryStarted);
|
||||
if(canSynchronize?.Invoke() == false)
|
||||
long inventoryStarted = Time.GetTimestamp();
|
||||
IReadOnlyList<PrinterInfo> inventory = printers.ListWithoutMedia();
|
||||
TimeSpan inventoryElapsed = Time.GetElapsedTime(inventoryStarted);
|
||||
if(!canSynchronize() || !canDispatch())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if(options?.SynchronizeInventory != false)
|
||||
{ await api.InventoryAsync(inventory, token).ConfigureAwait(false); }
|
||||
if(canDispatch?.Invoke() == false)
|
||||
long claimStarted = Time.GetTimestamp();
|
||||
Claim claim = await ClaimAndRegisterAsync(inventory, token).ConfigureAwait(false);
|
||||
TimeSpan claimElapsed = Time.GetElapsedTime(claimStarted);
|
||||
if(claim.Job is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
long claimStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
PrintJob? job = await ClaimAndRegisterAsync(inventory, token).ConfigureAwait(false);
|
||||
TimeSpan claimElapsed = System.Diagnostics.Stopwatch.GetElapsedTime(claimStarted);
|
||||
if(job is null)
|
||||
{
|
||||
return false;
|
||||
// A refused job was reported as FAILED, so the lane keeps draining exactly as it does after any other failed job.
|
||||
return claim.Refused;
|
||||
}
|
||||
try
|
||||
{ return await ProcessJobAsync(job, inventory, new JobAttempt(inventoryElapsed, claimElapsed), token).ConfigureAwait(false); }
|
||||
finally { options?.ActiveJobChanged?.Invoke(null); }
|
||||
{ return await ProcessJobAsync(claim.Job, inventory, new JobAttempt(Time, inventoryElapsed, claimElapsed), token).ConfigureAwait(false); }
|
||||
finally { options.ActiveJobChanged(null); }
|
||||
}
|
||||
|
||||
|
||||
@@ -67,59 +64,138 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
|
||||
/// <summary>
|
||||
/// Claims one job under the shared claim gate and registers it locally before anything else can happen to it. The
|
||||
/// server keeps replaying CLAIMED until RECEIVED commits, so admission is serialized through that acknowledgement; if
|
||||
/// it is interrupted the job is recorded FAILED with no bytes submitted. A job the journal already holds in a final
|
||||
/// state is dropped here so a replayed claim can never print twice.
|
||||
/// it is interrupted the job is recorded FAILED with no bytes submitted. A job this computer is already working on,
|
||||
/// or that the journal holds in a final state or as submitting, is dropped here so a replayed claim can never print
|
||||
/// twice; the pass then ends, because the server would only offer that job again. A claimed job that breaks the
|
||||
/// contract but names itself is refused and reported instead of returned.
|
||||
/// </summary>
|
||||
private async Task<PrintJob?> ClaimAndRegisterAsync(IReadOnlyList<PrinterInfo> inventory, CancellationToken token)
|
||||
private async Task<Claim> ClaimAndRegisterAsync(IReadOnlyList<PrinterInfo> inventory, CancellationToken token)
|
||||
{
|
||||
if(options?.ClaimGate is not null)
|
||||
{ await options.ClaimGate.WaitAsync(token).ConfigureAwait(false); }
|
||||
await options.ClaimGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if(canDispatch?.Invoke() == false || options?.CanClaim?.Invoke() == false)
|
||||
{ return null; }
|
||||
PrintJob? job = await api.ClaimAsync(token).ConfigureAwait(false);
|
||||
if(job is not null)
|
||||
if(!canDispatch() || !options.CanClaim())
|
||||
{ return default; }
|
||||
PrintJob? job;
|
||||
try
|
||||
{ job = await api.ClaimAsync(token).ConfigureAwait(false); }
|
||||
catch(PrintingProtocolException violation) when(violation.RejectedJob is not null)
|
||||
{ return new Claim(null, await RefuseAsync(violation.RejectedJob, violation.Message, inventory, token).ConfigureAwait(false)); }
|
||||
if(job is null || IsReplay(job, inventory))
|
||||
{ return default; }
|
||||
options.ActiveJobChanged(job);
|
||||
try
|
||||
{
|
||||
PrinterInfo? printer = inventory.FirstOrDefault(item => string.Equals(item.PrinterId, job.PrinterId, StringComparison.Ordinal));
|
||||
journal.TrackJob(job, string.IsNullOrWhiteSpace(printer?.DisplayName) ? printer?.Name : printer.DisplayName);
|
||||
if(journal.GetState(job.JobId) is "SPOOLED" or "SPOOLER_COMPLETED" or "FAILED" or "OUTCOME_UNKNOWN")
|
||||
{ return null; }
|
||||
options?.ActiveJobChanged?.Invoke(job);
|
||||
try
|
||||
{
|
||||
Record(job, "RECEIVED");
|
||||
// The server replays CLAIMED until RECEIVED commits. Serialize admission through its ACK.
|
||||
await FlushAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Record(job, "FAILED", "Receipt acknowledgement was interrupted before document submission; no bytes were submitted.");
|
||||
options?.ActiveJobChanged?.Invoke(null);
|
||||
throw;
|
||||
}
|
||||
Record(job, PrintJobState.Received);
|
||||
// The server replays CLAIMED until RECEIVED commits. Serialize admission through its ACK.
|
||||
await FlushAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
return job;
|
||||
catch
|
||||
{
|
||||
// The lane lets go of the job whatever the recording does: a ticket left behind would make every later offer of the job read as one a lane still holds.
|
||||
try
|
||||
{ Record(job, PrintJobState.Failed, "Receipt acknowledgement was interrupted before document submission; no bytes were submitted."); }
|
||||
finally { options.ActiveJobChanged(null); }
|
||||
throw;
|
||||
}
|
||||
return new Claim(job, false);
|
||||
}
|
||||
finally { options?.ClaimGate?.Release(); }
|
||||
finally { options.ClaimGate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Says whether a claimed job is one this computer must not start on, because it already has, and says so in the
|
||||
/// log when the server should not have offered it. A job that is in hand, held by a lane of this computer right
|
||||
/// now, is left entirely to that lane: nothing is recorded for it here, not even its display facts. The lane lets
|
||||
/// go of a job when its pass for that job ends. Normally that is after the job's final state was recorded and
|
||||
/// delivered; delivery is skipped when the pass is cancelled, and it can fail, and the lane lets go all the same.
|
||||
/// So a job in hand may be one that has just finished, and the log line says "still holds" for that reason and
|
||||
/// does not claim work is under way. A job the journal holds as submitting was handed to a printer once already
|
||||
/// and has not concluded, and one it holds in a final state has; neither is ever submitted a second time.
|
||||
/// </summary>
|
||||
private bool IsReplay(PrintJob job, IReadOnlyList<PrinterInfo> inventory)
|
||||
{
|
||||
if(options.IsInHand(job.JobId))
|
||||
{
|
||||
log($"{job.JobId}: offered again while a lane of this computer still holds it, working on it or just finished with it; it is left to that lane, nothing is recorded for it and this pass claims nothing more.");
|
||||
return true;
|
||||
}
|
||||
Track(job, inventory);
|
||||
if(journal.GetState(job.JobId) is not { } state || !(PrintJobState.IsFinal(state) || string.Equals(state, PrintJobState.Submitting, StringComparison.Ordinal)))
|
||||
{ return false; }
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// a job recorded as submitting was handed to a printer once already //
|
||||
// and has not concluded: another lane is submitting it now, or a run //
|
||||
// stopped part-way and the runtime's recovery reports it as unknown. //
|
||||
// claiming it again must never print it a second time, and it must //
|
||||
// not pass silently, because the server should not be offering it //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
if(!PrintJobState.IsFinal(state))
|
||||
{ log($"{job.JobId}: offered again while recorded as {state}, so its submission has started and not concluded; it is not submitted a second time and this pass claims nothing more."); }
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reports a claimed job the API client refused because it broke the contract. The server offers a claimed job again
|
||||
/// until the agent answers for it, so leaving it unanswered would block every job behind it; the job is therefore
|
||||
/// recorded as received and then FAILED, with the reason and the fact that nothing was submitted, and both events are
|
||||
/// delivered. A job the journal already knows, in any state, is left exactly as it is: it was answered for once, its
|
||||
/// outcome is recorded or another lane is working on it right now, and a FAILED written over RECEIVED or SUBMITTING
|
||||
/// would claim that nothing was submitted while a submission may be under way. The replay is only logged. Returns
|
||||
/// true when the refusal was reported.
|
||||
/// </summary>
|
||||
private async Task<bool> RefuseAsync(PrintJob rejected, string reason, IReadOnlyList<PrinterInfo> inventory, CancellationToken token)
|
||||
{
|
||||
if(journal.GetState(rejected.JobId) is { } known)
|
||||
{
|
||||
log($"{rejected.JobId}: offered again in a form that breaks the contract while already recorded as {known}; nothing was recorded or reported for it. {reason}");
|
||||
return false;
|
||||
}
|
||||
Track(rejected, inventory);
|
||||
Record(rejected, PrintJobState.Received);
|
||||
Record(rejected, PrintJobState.Failed, reason + " No bytes were submitted.");
|
||||
await FlushAsync(token).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stores the job's display facts in the journal, naming its printer by the operator's friendly name when one is set and by the queue name otherwise.</summary>
|
||||
private void Track(PrintJob job, IReadOnlyList<PrinterInfo> inventory)
|
||||
{
|
||||
PrinterInfo? printer = inventory.FirstOrDefault(item => string.Equals(item.PrinterId, job.PrinterId, StringComparison.Ordinal));
|
||||
journal.TrackJob(job, string.IsNullOrWhiteSpace(printer?.DisplayName) ? printer?.Name : printer.DisplayName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The result of one claim: the job to process, or none, and whether a malformed job was refused and reported instead.</summary>
|
||||
private readonly record struct Claim(PrintJob? Job, bool Refused);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Runs one claimed job and reports its outcome. The submission steps live in <see cref="SubmitJobAsync"/> so their
|
||||
/// disposal scope stays inside this method's try block, which keeps every failure, including one raised while
|
||||
/// releasing a reservation, recorded through the same FAILED or OUTCOME_UNKNOWN decision. One timing line per job is
|
||||
/// logged afterwards so a slow print can be attributed to a phase from the service's own log without any extra
|
||||
/// request, payload or printer access.
|
||||
/// releasing a reservation, inside the same decision: FAILED when Windows was never asked, OUTCOME_UNKNOWN when
|
||||
/// submission had begun, and nothing at all when the job is already recorded as SPOOLED, because a release that
|
||||
/// fails after Windows accepted the document says nothing about the print and must not be written over it. Two
|
||||
/// refusals that arrive after submission began are still FAILED, because each is known to have been made before
|
||||
/// any bytes were sent: the native layer's media mismatch, and the exposure filter's refusal of a printer that is
|
||||
/// no longer shared, which only the filter can raise and raises before it calls the backend behind it. One
|
||||
/// timing line per job is logged afterwards so a slow print can be attributed to a phase from the service's own log
|
||||
/// without any extra request, payload or printer access. Returns true when the job was processed, whatever its
|
||||
/// outcome; false only when something else had already recorded a final state for it, so nothing was done.
|
||||
/// </summary>
|
||||
private async Task<bool> ProcessJobAsync(PrintJob job, IReadOnlyList<PrinterInfo> inventory, JobAttempt attempt, CancellationToken token)
|
||||
{
|
||||
PrinterInfo? assignedPrinter = inventory.FirstOrDefault(printer => string.Equals(printer.PrinterId, job.PrinterId, StringComparison.Ordinal));
|
||||
journal.TrackJob(job, string.IsNullOrWhiteSpace(assignedPrinter?.DisplayName) ? assignedPrinter?.Name : assignedPrinter.DisplayName);
|
||||
|
||||
string? state = journal.GetState(job.JobId);
|
||||
if(state is "SPOOLED" or "SPOOLER_COMPLETED" or "FAILED" or "OUTCOME_UNKNOWN")
|
||||
Track(job, inventory);
|
||||
if(PrintJobState.IsFinal(journal.GetState(job.JobId)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -129,20 +205,30 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
|
||||
{ await SubmitJobAsync(job, attempt, token).ConfigureAwait(false); }
|
||||
catch(Exception exception)
|
||||
{
|
||||
Record(job, attempt.Submitting && exception is not PrinterMediaMismatchException ? "OUTCOME_UNKNOWN" : "FAILED", exception.Message);
|
||||
if(attempt.Spooled)
|
||||
{ log($"{job.JobId}: spooled, and stays recorded as spooled; releasing what the job held failed afterwards: {exception.Message}"); }
|
||||
else
|
||||
{ Record(job, attempt.Submitting && exception is not (PrinterMediaMismatchException or PrinterNotExposedException) ? PrintJobState.OutcomeUnknown : PrintJobState.Failed, exception.Message); }
|
||||
}
|
||||
log?.Invoke(attempt.TimingMessage(job.JobId));
|
||||
log(attempt.TimingMessage(job.JobId));
|
||||
if(!token.IsCancellationRequested)
|
||||
{ await FlushAsync(token).ConfigureAwait(false); }
|
||||
return attempt.Spooled || options?.RecoverIncomplete == false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Downloads, revalidates and submits one job. The reservation, staged file and submission lease are declared here and
|
||||
/// released when this method returns, after SPOOLED is recorded, exactly as when the same steps ran inline. The
|
||||
/// attempt records how far the job got so the caller can tell a refusal before any bytes from an uncertain submission.
|
||||
/// Stages, revalidates and submits one job. The reservation, staged file and submission lease are declared here and
|
||||
/// released when this method returns, after SPOOLED is recorded. The attempt records how far the job got so the
|
||||
/// caller can tell a refusal before any bytes from an uncertain submission. Between recording SUBMITTING and calling
|
||||
/// Windows nothing has been sent, so the two things that can stop the job there (delivery of the receipt not being
|
||||
/// confirmed, and cancellation) say in their own words that no bytes were submitted. Handing the verified staged
|
||||
/// file to the backend is the only step after which an outcome is uncertain. The printer is looked up once,
|
||||
/// after the download, and not a second time when its turn is held: that would start the print helper once more
|
||||
/// for every job, while the job holds the printer's turn and a print slot. What changes during the wait for the
|
||||
/// turn is refused later by whoever can tell that nothing was sent: the exposure filter for a printer that is no
|
||||
/// longer shared, and the native layer for media that changed.
|
||||
/// </summary>
|
||||
private async Task SubmitJobAsync(PrintJob job, JobAttempt attempt, CancellationToken token)
|
||||
{
|
||||
@@ -150,42 +236,58 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
|
||||
{
|
||||
throw new InvalidDataException("Copies exceed the effective printing policy.");
|
||||
}
|
||||
await using IAsyncDisposable? reservation = options?.ReserveDocumentAsync is null ? null : await options.ReserveDocumentAsync(job, token).ConfigureAwait(false);
|
||||
using DownloadedDocument? file = options?.StagingDirectory is null ? null : await api.DownloadFileAsync(job, options.StagingDirectory, options.Settings, token, options.ThrottleDownloadAsync).ConfigureAwait(false);
|
||||
byte[]? content = file is null ? await api.DownloadAsync(job, token).ConfigureAwait(false) : null;
|
||||
await using IAsyncDisposable reservation = await options.ReserveDocumentAsync(job, token).ConfigureAwait(false);
|
||||
using DownloadedDocument file = await api.DownloadFileAsync(job, options.StagingDirectory, options.Settings, options.ThrottleDownloadAsync, token).ConfigureAwait(false);
|
||||
attempt.Download = attempt.Lap();
|
||||
RequireCurrentPrinter(job);
|
||||
attempt.Revalidate = attempt.Lap();
|
||||
|
||||
await using IAsyncDisposable? submission = options?.AcquireSubmissionAsync is null ? null : await options.AcquireSubmissionAsync(job, token).ConfigureAwait(false);
|
||||
await using IAsyncDisposable submission = await options.AcquireSubmissionAsync(job, token).ConfigureAwait(false);
|
||||
RequireSubmitAllowed("Dispatch is paused before native submission.");
|
||||
Record(job, "SUBMITTING");
|
||||
await FlushAsync(token).ConfigureAwait(false);
|
||||
Record(job, PrintJobState.Submitting);
|
||||
try
|
||||
{ await FlushAsync(token).ConfigureAwait(false); }
|
||||
catch(Exception exception)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// SUBMITTING is on record but Windows has not been asked, so this is a //
|
||||
// plain refusal. the flush sends every pending event, so it may have //
|
||||
// failed on another job's event, on a lost acknowledgement or on the //
|
||||
// journal; all that is known is that delivery of this receipt was not //
|
||||
// confirmed. the failure's own message ("connection reset") would leave //
|
||||
// an operator who reads SUBMITTING then FAILED unsure whether anything //
|
||||
// printed, so the refusal states that nothing was //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
throw new IOException("Delivery of the submitting receipt could not be confirmed, so the document was not sent to the printer; no bytes were submitted.", exception);
|
||||
}
|
||||
attempt.Gate = attempt.Lap();
|
||||
RequireSubmitAllowed("Dispatch stopped before native submission; no bytes were submitted.");
|
||||
RealtimeSettings submissionSettings = EffectiveSettings;
|
||||
if(job.Options.Copies > submissionSettings.MaxCopies || job.ContentLength > submissionSettings.MaximumDocumentBytes(job.Format))
|
||||
{ throw new InvalidDataException("Document or copies exceed the current submission policy."); }
|
||||
token.ThrowIfCancellationRequested();
|
||||
if(token.IsCancellationRequested)
|
||||
{ throw new OperationCanceledException("Stopped before native submission; no bytes were submitted.", token); }
|
||||
attempt.Submitting = true;
|
||||
attempt.Restart();
|
||||
string spoolId = await SubmitNativeAsync(job, file, content, submissionSettings, token).ConfigureAwait(false);
|
||||
string spoolId = await printers.PrintFileAsync(job, file.Path, TimeSpan.FromMilliseconds(submissionSettings.NativeSubmissionTimeoutMillis), token).ConfigureAwait(false);
|
||||
attempt.Submit = attempt.Lap();
|
||||
Record(job, "SPOOLED", spoolerJobId: spoolId);
|
||||
Record(job, PrintJobState.Spooled, spoolerJobId: spoolId);
|
||||
attempt.Spooled = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Discovers the printers again after the download and requires the assigned queue to still offer the job's format and
|
||||
/// the selected media profile, so a queue or driver default that changed while the document streamed is refused before
|
||||
/// any submission instead of printing on the wrong stock.
|
||||
/// Reads the job's own printer again after the download and requires it to still be shared, to still offer the
|
||||
/// job's format and to still have the selected media profile, so a printer withdrawn, or a queue or driver default
|
||||
/// changed, while the document streamed is refused before SUBMITTING is recorded, with Windows never asked, instead
|
||||
/// of printing on the wrong stock. Only that one printer is asked about: what another printer on the computer
|
||||
/// answers, or fails to answer, has no part in whether this job may print.
|
||||
/// </summary>
|
||||
private void RequireCurrentPrinter(PrintJob job)
|
||||
{
|
||||
PrinterInfo? currentPrinter = printers.Discover().SingleOrDefault(p => string.Equals(p.PrinterId, job.PrinterId, StringComparison.Ordinal) && p.Formats.Contains(job.Format, StringComparer.Ordinal));
|
||||
if(currentPrinter is null)
|
||||
PrinterInfo? currentPrinter = printers.FindWithMedia(job.PrinterId);
|
||||
if(currentPrinter is null || !currentPrinter.Formats.Contains(job.Format, StringComparer.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Assigned printer or document format is unavailable.");
|
||||
}
|
||||
@@ -200,30 +302,19 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
|
||||
/// </summary>
|
||||
private void RequireSubmitAllowed(string refusal)
|
||||
{
|
||||
if((options?.CanSubmit ?? canDispatch)?.Invoke() == false)
|
||||
if(!options.CanSubmit())
|
||||
{ throw new InvalidOperationException(refusal); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Hands the verified staged file, or the in-memory content for a backend without file support, to the native printer
|
||||
/// and returns its spooler receipt. This is the only step after which an outcome is uncertain.
|
||||
/// </summary>
|
||||
private Task<string> SubmitNativeAsync(PrintJob job, DownloadedDocument? file, byte[]? content, RealtimeSettings submissionSettings, CancellationToken token)
|
||||
{
|
||||
return file is not null && printers is IFilePrinterBackend files
|
||||
? files.PrintFileAsync(job, file.Path, TimeSpan.FromMilliseconds(submissionSettings.NativeSubmissionTimeoutMillis), token)
|
||||
: printers.PrintAsync(job, content ?? throw new InvalidOperationException("File printing is required."), token);
|
||||
}
|
||||
/// <summary>The limits in force right now, read from the runtime each time, so an administrator's policy change reaches a job that is already past its first check.</summary>
|
||||
private RealtimeSettings EffectiveSettings => options.CurrentSettings();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The limits in force right now. A live callback wins over the snapshot taken at construction so an administrator's
|
||||
/// policy change reaches a job that is already past its first check; defaults apply when no settings were supplied.
|
||||
/// </summary>
|
||||
private RealtimeSettings EffectiveSettings => options?.CurrentSettings?.Invoke() ?? options?.Settings ?? new();
|
||||
/// <summary>The clock the phase timings are measured on: the one supplied in the options, the system clock unless a test supplies its own.</summary>
|
||||
private TimeProvider Time => options.TimeProvider;
|
||||
|
||||
|
||||
|
||||
@@ -242,7 +333,7 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
|
||||
private void Record(string jobId, string state, string? message = null, string? spoolerJobId = null)
|
||||
{
|
||||
journal.Record(jobId, new PrintEvent(Guid.NewGuid().ToString("N"), state, message, spoolerJobId));
|
||||
log?.Invoke($"{jobId}: {state}");
|
||||
log($"{jobId}: {state}");
|
||||
}
|
||||
|
||||
|
||||
@@ -254,8 +345,7 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
|
||||
/// </summary>
|
||||
private async Task FlushAsync(CancellationToken token)
|
||||
{
|
||||
if(options?.EventGate is not null)
|
||||
{ await options.EventGate.WaitAsync(token).ConfigureAwait(false); }
|
||||
await options.EventGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
foreach((string jobId, PrintEvent printEvent) in journal.Pending())
|
||||
@@ -264,19 +354,19 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
|
||||
journal.Acknowledge(printEvent.EventId);
|
||||
}
|
||||
}
|
||||
finally { options?.EventGate?.Release(); }
|
||||
finally { options.EventGate.Release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Progress and phase timing of one claimed job. Progress tells the caller whether native submission began, which
|
||||
/// decides between a known refusal and an uncertain outcome; the durations come from the monotonic clock and feed the
|
||||
/// single timing line, so they carry no document content.
|
||||
/// decides between a known refusal and an uncertain outcome; the durations come from the worker's monotonic clock and
|
||||
/// feed the single timing line, so they carry no document content.
|
||||
/// </summary>
|
||||
private sealed class JobAttempt(TimeSpan inventory, TimeSpan claim)
|
||||
private sealed class JobAttempt(TimeProvider time, TimeSpan inventory, TimeSpan claim)
|
||||
{
|
||||
private long _mark = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
private long _mark = time.GetTimestamp();
|
||||
|
||||
public bool Submitting { get; set; }
|
||||
|
||||
@@ -295,7 +385,7 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
|
||||
/// <summary>Starts the next phase now, discarding the time since the previous mark.</summary>
|
||||
public void Restart()
|
||||
{
|
||||
_mark = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
_mark = time.GetTimestamp();
|
||||
}
|
||||
|
||||
|
||||
@@ -303,8 +393,8 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
|
||||
/// <summary>Returns the time since the previous mark and starts the next phase at the same instant, so phases never overlap or leave a gap.</summary>
|
||||
public TimeSpan Lap()
|
||||
{
|
||||
long now = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
TimeSpan elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(_mark, now);
|
||||
long now = time.GetTimestamp();
|
||||
TimeSpan elapsed = time.GetElapsedTime(_mark, now);
|
||||
_mark = now;
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
@@ -6,23 +6,73 @@
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Optional hooks and limits that change how a <see cref="PrintWorker"/> runs. The defaults give the simple serial worker; the
|
||||
/// realtime service supplies a staging folder, the live server limits, claim and event gates, bandwidth throttling, and
|
||||
/// reservation callbacks, so concurrency and ordering are decided by the runtime while the worker keeps the safety rules.
|
||||
/// The hooks and limits the runtime hands a <see cref="PrintWorker"/>: the staging folder, the live server limits,
|
||||
/// the claim and event gates, bandwidth throttling and the reservation callbacks, so concurrency and ordering are
|
||||
/// decided by the runtime while the worker keeps the safety rules. Every one is required, because a worker without
|
||||
/// any of them is not one the service runs; only the clock has a default.
|
||||
/// </summary>
|
||||
public sealed record PrintWorkerOptions
|
||||
{
|
||||
public string? StagingDirectory { get; init; }
|
||||
public RealtimeSettings Settings { get; init; } = new();
|
||||
public Func<RealtimeSettings>? CurrentSettings { get; init; }
|
||||
public Func<bool>? CanSubmit { get; init; }
|
||||
public bool RecoverIncomplete { get; init; } = true;
|
||||
public bool SynchronizeInventory { get; init; } = true;
|
||||
public SemaphoreSlim? EventGate { get; init; }
|
||||
public SemaphoreSlim? ClaimGate { get; init; }
|
||||
public Func<int, CancellationToken, Task>? ThrottleDownloadAsync { get; init; }
|
||||
public Func<bool>? CanClaim { get; init; }
|
||||
public Func<PrintJob, CancellationToken, Task<IAsyncDisposable>>? ReserveDocumentAsync { get; init; }
|
||||
public Func<PrintJob, CancellationToken, Task<IAsyncDisposable>>? AcquireSubmissionAsync { get; init; }
|
||||
public Action<PrintJob?>? ActiveJobChanged { get; init; }
|
||||
/// <summary>Where documents are streamed to disk and verified before printing.</summary>
|
||||
public required string StagingDirectory { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The limits in force when the worker was created; a download is held to these.</summary>
|
||||
public required RealtimeSettings Settings { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the limits in force right now, so a policy change reaches a job that is already past its first check.</summary>
|
||||
public required Func<RealtimeSettings> CurrentSettings { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Says whether a native submission may start; asked before and again after the SUBMITTING receipt is acknowledged, so a pause that arrives in between still stops it.</summary>
|
||||
public required Func<bool> CanSubmit { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Shared by every lane so pending events are delivered by one lane at a time and never twice.</summary>
|
||||
public required SemaphoreSlim EventGate { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Shared by every lane so only one claim is in flight until its RECEIVED event is acknowledged.</summary>
|
||||
public required SemaphoreSlim ClaimGate { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Paces every download against one shared bandwidth limit; given the bytes just received.</summary>
|
||||
public required Func<int, CancellationToken, Task> ThrottleDownloadAsync { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Says whether a lane of this computer holds the job with the given identifier: from the moment it was claimed until the lane lets go of it when its pass for that job ends, normally after the job's final state was recorded and delivered. A claim of such a job is a replay, and the worker leaves the job to the lane that holds it.</summary>
|
||||
public required Func<string, bool> IsInHand { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Says whether this lane may still claim; false once another lane found the queue empty.</summary>
|
||||
public required Func<bool> CanClaim { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Reserves staging space for a job before its download; the returned lease frees it when the job ends.</summary>
|
||||
public required Func<PrintJob, CancellationToken, Task<IAsyncDisposable>> ReserveDocumentAsync { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Takes the job's turn on its printer before native submission; the returned lease releases it when the job ends.</summary>
|
||||
public required Func<PrintJob, CancellationToken, Task<IAsyncDisposable>> AcquireSubmissionAsync { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Told which job the worker is processing, and null when it stops, so the runtime can track and cancel an active transfer.</summary>
|
||||
public required Action<PrintJob?> ActiveJobChanged { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The clock the per-job phase timings are measured on; the system clock unless a test supplies its own.</summary>
|
||||
public TimeProvider TimeProvider { get; init; } = TimeProvider.System;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// The exposure filter's refusal: the job's printer is not shared with QQQ on this computer. Only
|
||||
/// <see cref="ExposedPrinterBackend"/> can create it, because the constructor is internal, and the filter creates it
|
||||
/// only before it calls the backend behind it. Whoever catches it therefore knows that nothing was handed to a
|
||||
/// printing platform and no document bytes were submitted, which is why the worker records such a job as FAILED and
|
||||
/// not as an uncertain outcome.
|
||||
/// </summary>
|
||||
public sealed class PrinterNotExposedException : InvalidOperationException
|
||||
{
|
||||
/// <summary>Creates the refusal. Internal so that no backend outside this assembly can raise it after it has started to print.</summary>
|
||||
internal PrinterNotExposedException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// The failure raised by the three readers that check what the server sends against the printing contract before any
|
||||
/// field is trusted. The realtime frame reader raises it for a frame that is not valid JSON, repeats a field, lacks its
|
||||
/// type, message identifier or payload object, for an ACK or ERROR without its required member, for a payload member
|
||||
/// of the wrong JSON type, and for a realtime command that fails the command rule. The claim reader and the queued-job
|
||||
/// list reader raise it for a response that is not valid JSON, lacks a required member, carries an unusable value, or
|
||||
/// repeats a member the agent reads.
|
||||
/// <para>
|
||||
/// It does not cover the rest of the API client. The HTTP control poll and its receipt, enrollment, the operator
|
||||
/// printer catalog, operator actions, realtime settings and document downloads report a bad response as
|
||||
/// <see cref="InvalidDataException"/> or <see cref="System.Text.Json.JsonException"/>, as they did before this type
|
||||
/// existed, and their callers in the Windows layer and the companion handle those types. In particular the command
|
||||
/// rule is one rule, but it raises this type for a command that arrives over the realtime channel and
|
||||
/// <see cref="InvalidDataException"/> for the same command in a polled batch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is an <see cref="IOException"/>, so every caller that already treats a broken connection as "reconnect and
|
||||
/// replay" handles it without change, and a caller that wants to tell a misbehaving server from a lost one can catch
|
||||
/// this type first. The message is fixed text naming what was wrong; it never repeats what the server sent.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PrintingProtocolException : IOException
|
||||
{
|
||||
/// <summary>Reports a violation that cannot be tied to one job.</summary>
|
||||
public PrintingProtocolException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reports a claimed job that broke the contract but named itself well enough to be reported back to the server.</summary>
|
||||
public PrintingProtocolException(string message, PrintJob rejectedJob) : base(message)
|
||||
{
|
||||
RejectedJob = rejectedJob;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A stand-in for the claimed job that was refused, holding only its identifier and the display facts that were safe
|
||||
/// to keep, or null when the violation is not about an identifiable job. The worker uses it to record the job as
|
||||
/// FAILED, because a claimed job the agent never answers would be offered again forever and block the queue behind it.
|
||||
/// </summary>
|
||||
public PrintJob? RejectedJob { get; }
|
||||
}
|
||||
@@ -5,21 +5,28 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Qqq.PrintAgent.Core.Transport;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>One continuously active reader and one bounded writer. Callbacks must only dispatch work, never await it.</summary>
|
||||
public sealed class PrintingRealtimeChannel(WebSocket socket, RealtimeSettings limits, ApplicationTrafficMeter? traffic = null) : IDisposable
|
||||
/// <summary>
|
||||
/// One continuously active reader and one bounded writer. Callbacks must only dispatch work, never await it. The
|
||||
/// acknowledgement wait is counted on the supplied clock (the system clock by default), so a test reaches the timeout
|
||||
/// by moving a clock.
|
||||
/// </summary>
|
||||
public sealed class PrintingRealtimeChannel(WebSocket socket, RealtimeSettings limits, ApplicationTrafficMeter? traffic = null, TimeProvider? timeProvider = null) : IDisposable
|
||||
{
|
||||
private readonly TimeProvider _time = timeProvider ?? TimeProvider.System;
|
||||
private readonly Channel<byte[]> _outgoing = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(4096) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
|
||||
private RealtimeSettings _limits = limits;
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource> _pending = new(StringComparer.Ordinal);
|
||||
private long _queuedBytes;
|
||||
private int _queuedFrames;
|
||||
private int _closed;
|
||||
private int _disposed;
|
||||
|
||||
|
||||
|
||||
@@ -40,29 +47,90 @@ public sealed class PrintingRealtimeChannel(WebSocket socket, RealtimeSettings l
|
||||
/// <summary>
|
||||
/// Runs the reader and writer until either ends, then shuts the channel down in a fixed order: mark it closed, stop the
|
||||
/// outgoing queue, cancel both loops, abort the socket, and fail every send still waiting for an acknowledgement so its
|
||||
/// caller replays the durable record instead of waiting forever.
|
||||
/// caller replays the durable record instead of waiting forever. When the reader or writer ended with a failure, each of
|
||||
/// those failed sends carries it as its cause, so whoever reports the send also reports why the channel closed.
|
||||
/// <para>
|
||||
/// How the method ends says why the channel ended. It returns normally when the channel was ended on purpose: the
|
||||
/// caller cancelled the token it passed in, or the channel was disposed while it ran. It throws when the channel ended
|
||||
/// by itself, with what the reader failed with (a frame it rejected, a socket the server closed, a callback that
|
||||
/// threw) or, when the reader did not fail, what the writer failed with. A callback that gives up by throwing a
|
||||
/// cancellation while the caller's token is not cancelled ends the method as that cancellation, because it was not
|
||||
/// the caller who asked to stop.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A socket that is being ended refuses the next call on it, and a loop that is about to use the socket at that
|
||||
/// moment fails on it instead of seeing the cancellation. The socket is ended twice over on a deliberate stop: the
|
||||
/// platform socket aborts itself as soon as the token of a call in flight is cancelled, and this shutdown aborts it
|
||||
/// again. A failure of that kind (the socket is disposed, or in an invalid state) is the stop itself and is not
|
||||
/// reported when the caller had cancelled, or when the loop had not failed before this shutdown began. Any other
|
||||
/// failure is reported whenever it arrives, even beside a cancellation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task RunAsync(Action<RealtimeEnvelope> received, CancellationToken token)
|
||||
{
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
Task reader = ReadAsync(received, lifetime.Token);
|
||||
Task writer = WriteAsync(lifetime.Token);
|
||||
try
|
||||
Task ended = await Task.WhenAny(reader, writer).ConfigureAwait(false);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// read before the teardown below: whether the channel was disposed, //
|
||||
// so one that failed first and was disposed a moment later still //
|
||||
// reports its failure; and which loop had already failed while the //
|
||||
// caller had not cancelled, because only such a failure can be told //
|
||||
// from the socket refusing a call as it is being ended //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
bool disposed = Volatile.Read(ref _disposed) != 0;
|
||||
bool callerCancelled = token.IsCancellationRequested;
|
||||
bool readerFailedFirst = reader.IsFaulted && !callerCancelled;
|
||||
bool writerFailedFirst = writer.IsFaulted && !callerCancelled;
|
||||
Interlocked.Exchange(ref _closed, 1);
|
||||
_outgoing.Writer.TryComplete();
|
||||
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||
socket.Abort();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// a send still waiting for its acknowledgement is told why the //
|
||||
// channel closed. a refusal the run takes to be the caller's stop //
|
||||
// is not a cause, so the waiting send and the run say the same //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
Exception? cause = FailureOf(ended, !callerCancelled);
|
||||
foreach(TaskCompletionSource pending in _pending.Values)
|
||||
{
|
||||
await Task.WhenAny(reader, writer).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _closed, 1);
|
||||
_outgoing.Writer.TryComplete();
|
||||
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||
socket.Abort();
|
||||
foreach(TaskCompletionSource pending in _pending.Values)
|
||||
{
|
||||
pending.TrySetException(new IOException("Realtime connection closed before acknowledgement; durable receipts will be replayed."));
|
||||
}
|
||||
await Task.WhenAll(reader, writer).ConfigureAwait(false);
|
||||
pending.TrySetException(new RealtimeChannelClosedException("Realtime connection closed before acknowledgement; durable receipts will be replayed.", cause));
|
||||
}
|
||||
var both = Task.WhenAll(reader, writer);
|
||||
await both.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// ended on purpose: disposed (whatever the loops met on a socket that //
|
||||
// was disposed under them is the dispose itself), or cancelled by the //
|
||||
// caller with no failure but the socket refusing a call as it ended. //
|
||||
// anything else is rethrown: a failure, the reader's first, or a //
|
||||
// cancellation nobody asked for //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(disposed)
|
||||
{ return; }
|
||||
Exception? failure = FailureOf(reader, readerFailedFirst) ?? FailureOf(writer, writerFailedFirst);
|
||||
if(failure is not null)
|
||||
{ ExceptionDispatchInfo.Throw(failure); }
|
||||
if(!token.IsCancellationRequested)
|
||||
{ await (reader.IsCanceled ? reader : writer.IsCanceled ? writer : both).ConfigureAwait(false); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What a loop failed with, or null when it did not fail or its failure is only the stop. A socket that has been
|
||||
/// aborted refuses a call in one of two ways: it is disposed, or it is in an invalid state. A loop that met that,
|
||||
/// and is not known to have failed before the stop, was stopped by the abort and not by anything the server or the
|
||||
/// network did. A connection the server dropped fails differently (closed prematurely) and is always reported.
|
||||
/// </summary>
|
||||
private static Exception? FailureOf(Task loop, bool failedBeforeStop)
|
||||
{
|
||||
Exception? failure = loop.Exception?.InnerException;
|
||||
bool refusedByAnEndedSocket = failure is ObjectDisposedException || (failure is WebSocketException { WebSocketErrorCode: WebSocketError.InvalidState });
|
||||
return failure is null || (!failedBeforeStop && refusedByAnEndedSocket) ? null : failure;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +144,7 @@ public sealed class PrintingRealtimeChannel(WebSocket socket, RealtimeSettings l
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if(Volatile.Read(ref _closed) != 0)
|
||||
{ throw new IOException("Realtime connection is closed."); }
|
||||
{ throw new RealtimeChannelClosedException("Realtime connection is closed.", null); }
|
||||
string id = messageId ?? Guid.NewGuid().ToString("N");
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(new { type, messageId = id, payload }, PrintApiClient.JsonOptions);
|
||||
if(bytes.Length > _limits.MaxFrameBytes)
|
||||
@@ -91,7 +159,7 @@ public sealed class PrintingRealtimeChannel(WebSocket socket, RealtimeSettings l
|
||||
Queue(bytes);
|
||||
if(completion is not null)
|
||||
{
|
||||
await completion.Task.WaitAsync(TimeSpan.FromMilliseconds(_limits.AckTimeoutMillis), token).ConfigureAwait(false);
|
||||
await completion.Task.WaitAsync(TimeSpan.FromMilliseconds(_limits.AckTimeoutMillis), _time, token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -106,6 +174,9 @@ public sealed class PrintingRealtimeChannel(WebSocket socket, RealtimeSettings l
|
||||
/// <summary>
|
||||
/// Adds a frame to the bounded outgoing queue, counting bytes and frames against the negotiated limits. Exceeding a limit
|
||||
/// aborts the socket and throws, which forces a reconnect, because dropping a frame silently would lose a durable event.
|
||||
/// A frame is also refused when the channel closed after the send was let in, while the frame was being serialized:
|
||||
/// the queue no longer takes anything then, and the send is told the channel is closed, which is what happened,
|
||||
/// instead of that the queue is full, which would be reported as the reason the connection dropped.
|
||||
/// </summary>
|
||||
private void Queue(byte[] bytes)
|
||||
{
|
||||
@@ -115,6 +186,8 @@ public sealed class PrintingRealtimeChannel(WebSocket socket, RealtimeSettings l
|
||||
{
|
||||
Interlocked.Add(ref _queuedBytes, -bytes.Length);
|
||||
Interlocked.Decrement(ref _queuedFrames);
|
||||
if(Volatile.Read(ref _closed) != 0)
|
||||
{ throw new RealtimeChannelClosedException("Realtime connection is closed.", null); }
|
||||
socket.Abort();
|
||||
throw new IOException("Realtime output queue is full; reconnect is required.");
|
||||
}
|
||||
@@ -140,103 +213,89 @@ public sealed class PrintingRealtimeChannel(WebSocket socket, RealtimeSettings l
|
||||
|
||||
/// <summary>
|
||||
/// The single reader. It assembles text messages from fragments within the negotiated size, rejects any other message
|
||||
/// type, parses each message with a depth limit and no duplicate fields, checks the envelope, and hands it to the
|
||||
/// callback. A close frame or any protocol violation ends the loop, which closes the connection and triggers a reconnect.
|
||||
/// type, has each complete message parsed and checked as an envelope, and routes it. A close frame ends the loop as a
|
||||
/// lost connection; anything the server gets wrong ends it as a <see cref="PrintingProtocolException"/>. Either way the
|
||||
/// connection closes and the runtime reconnects.
|
||||
/// </summary>
|
||||
private async Task ReadAsync(Action<RealtimeEnvelope> received, CancellationToken token)
|
||||
{
|
||||
byte[] buffer = new byte[8192];
|
||||
using var message = new MemoryStream();
|
||||
while(!token.IsCancellationRequested)
|
||||
while(true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
ValueWebSocketReceiveResult result = await socket.ReceiveAsync(buffer.AsMemory(), token).ConfigureAwait(false);
|
||||
traffic?.RecordDownload(result.Count);
|
||||
if(result.MessageType == WebSocketMessageType.Close)
|
||||
{ throw new IOException("Server closed the realtime connection."); }
|
||||
if(result.MessageType != WebSocketMessageType.Text || message.Length + result.Count > _limits.MaxFrameBytes)
|
||||
{
|
||||
throw new InvalidDataException("Realtime message type or size is invalid.");
|
||||
throw new PrintingProtocolException("The realtime message is not text or exceeds the negotiated frame size.");
|
||||
}
|
||||
message.Write(buffer, 0, result.Count);
|
||||
if(!result.EndOfMessage)
|
||||
{ continue; }
|
||||
using var document = JsonDocument.Parse(message.GetBuffer().AsMemory(0, checked((int)message.Length)), new JsonDocumentOptions { MaxDepth = 24 });
|
||||
RejectDuplicateFields(document.RootElement);
|
||||
RealtimeEnvelope envelope = document.RootElement.Deserialize<RealtimeEnvelope>(PrintApiClient.JsonOptions) ?? throw new InvalidDataException("Missing realtime envelope.");
|
||||
if(string.IsNullOrEmpty(envelope.Type) || string.IsNullOrEmpty(envelope.MessageId) || envelope.MessageId.Length > 200)
|
||||
{
|
||||
throw new InvalidDataException("Invalid realtime envelope.");
|
||||
}
|
||||
DispatchFrame(envelope with { WireLength = checked((int)message.Length) }, received);
|
||||
DispatchFrame(RealtimeEnvelope.Parse(message.GetBuffer().AsMemory(0, checked((int)message.Length))), received);
|
||||
message.SetLength(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Rejects any object that repeats a field name, ignoring case, anywhere in the message; two parsers could otherwise read different values from the same text.</summary>
|
||||
private static void RejectDuplicateFields(JsonElement value)
|
||||
{
|
||||
if(value.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach(JsonProperty property in value.EnumerateObject())
|
||||
{
|
||||
if(!names.Add(property.Name))
|
||||
{ throw new InvalidDataException("Duplicate realtime JSON field."); }
|
||||
RejectDuplicateFields(property.Value);
|
||||
}
|
||||
}
|
||||
else if(value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach(JsonElement child in value.EnumerateArray())
|
||||
{ RejectDuplicateFields(child); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Routes one validated frame. An ACK completes the send waiting on that message. An ERROR for a message still awaiting
|
||||
/// acknowledgement fails that send with an HTTP-like status the callers already handle; errors that mean authorization
|
||||
/// is gone are also passed on so the runtime can stop. Everything else goes to the callback with its payload cloned,
|
||||
/// because the parsed document is freed as soon as the reader moves on.
|
||||
/// Routes one validated frame. An ACK must name the message it acknowledges and completes the send waiting on it; an
|
||||
/// ACK for a message no longer waiting is ignored. An ERROR must carry a code; for a message still awaiting
|
||||
/// acknowledgement it fails that send with an HTTP-like status the callers already handle, and errors that mean
|
||||
/// authorization is gone are also passed on so the runtime can stop. Everything else goes to the callback.
|
||||
/// </summary>
|
||||
private void DispatchFrame(RealtimeEnvelope envelope, Action<RealtimeEnvelope> received)
|
||||
{
|
||||
if(string.Equals(envelope.Type, "ACK", StringComparison.Ordinal))
|
||||
{
|
||||
string? id = envelope.Payload.GetProperty("acknowledgedMessageId").GetString();
|
||||
if(id is not null && _pending.TryGetValue(id, out TaskCompletionSource? completion))
|
||||
string acknowledged = ProtocolJson.RequiredText(envelope.Payload, "acknowledgedMessageId", 200, "realtime ACK frame");
|
||||
if(_pending.TryGetValue(acknowledged, out TaskCompletionSource? completion))
|
||||
{ completion.TrySetResult(); }
|
||||
return;
|
||||
}
|
||||
if(string.Equals(envelope.Type, "ERROR", StringComparison.Ordinal) && _pending.TryGetValue(envelope.MessageId, out TaskCompletionSource? failed))
|
||||
if(string.Equals(envelope.Type, "ERROR", StringComparison.Ordinal))
|
||||
{
|
||||
string? code = envelope.Payload.GetProperty("code").GetString();
|
||||
System.Net.HttpStatusCode status = code switch
|
||||
string code = ProtocolJson.RequiredText(envelope.Payload, "code", 128, "realtime ERROR frame");
|
||||
if(_pending.TryGetValue(envelope.MessageId, out TaskCompletionSource? failed))
|
||||
{
|
||||
"NOT_FOUND" => System.Net.HttpStatusCode.NotFound,
|
||||
"EXPIRED" => System.Net.HttpStatusCode.Gone,
|
||||
"CONFLICT" => System.Net.HttpStatusCode.Conflict,
|
||||
_ => System.Net.HttpStatusCode.BadRequest,
|
||||
};
|
||||
failed.TrySetException(new HttpRequestException("Realtime operation was rejected: " + code, null, status));
|
||||
if(code is not ("AUTHORIZATION_REQUIRED" or "AUTHENTICATION_REQUIRED"))
|
||||
{ return; }
|
||||
failed.TrySetException(new HttpRequestException("Realtime operation was rejected: " + code, null, RejectionStatus(code)));
|
||||
if(code is not ("AUTHORIZATION_REQUIRED" or "AUTHENTICATION_REQUIRED"))
|
||||
{ return; }
|
||||
}
|
||||
}
|
||||
received(envelope with { Payload = envelope.Payload.Clone() });
|
||||
received(envelope);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Marks the channel closed, stops the queue, fails any send still waiting so its caller replays the durable record, and disposes the socket.</summary>
|
||||
/// <summary>Maps the server's rejection code to the HTTP status the same refusal would have had over HTTP, which is what the callers of an acknowledged send already handle.</summary>
|
||||
private static System.Net.HttpStatusCode RejectionStatus(string code) => code switch
|
||||
{
|
||||
"NOT_FOUND" => System.Net.HttpStatusCode.NotFound,
|
||||
"EXPIRED" => System.Net.HttpStatusCode.Gone,
|
||||
"CONFLICT" => System.Net.HttpStatusCode.Conflict,
|
||||
_ => System.Net.HttpStatusCode.BadRequest,
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Marks the channel disposed and closed, stops the queue, fails any send still waiting so its caller replays the
|
||||
/// durable record, and disposes the socket. A run that is still going ends normally: disposing is its owner ending
|
||||
/// it on purpose. The waiting sends are told the channel is closed, the same failure a send gets when the channel
|
||||
/// closes for any other reason.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposed, 1);
|
||||
Interlocked.Exchange(ref _closed, 1);
|
||||
_outgoing.Writer.TryComplete();
|
||||
foreach(TaskCompletionSource pending in _pending.Values)
|
||||
{ pending.TrySetException(new IOException("Realtime connection was disposed.")); }
|
||||
{ pending.TrySetException(new RealtimeChannelClosedException("Realtime connection was disposed.", null)); }
|
||||
socket.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Sends an API client's requests through the signed-in session, which attaches the cookie and device proof and refuses
|
||||
/// anything outside the native printing routes, so the API client itself holds no credential. The session is not owned
|
||||
/// here and is never disposed by this handler.
|
||||
/// </summary>
|
||||
internal sealed class PrintingSessionHandler(IQqqSessionClient session) : HttpMessageHandler
|
||||
{
|
||||
/// <summary>The path every native printing route lives under.</summary>
|
||||
internal const string Prefix = "/qqq/v1/qbit/printing/";
|
||||
|
||||
|
||||
|
||||
/// <summary>Accepts only requests under the printing extension path, strips that prefix and hands the rest, with its query, to the session; any other address throws before a request is made.</summary>
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string path = request.RequestUri!.AbsolutePath;
|
||||
if(!path.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Request is outside the native printing extension.");
|
||||
}
|
||||
return session.SendPrintingAsync(request.Method, path[Prefix.Length..] + request.RequestUri.Query, request.Content, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// The JSON checks a message from the server goes through before any field is trusted. Every message must be a JSON
|
||||
/// object within a depth limit. What is done about a repeated field name depends on who wrote the message: a realtime
|
||||
/// frame is written by the server alone, so no name may repeat anywhere in it; a job carries options the server relays
|
||||
/// from whoever submitted it, so there only the members the agent reads may not repeat and everything else is ignored,
|
||||
/// as the contract requires of unknown fields. The checks live in one place so each reader states its rule in one call
|
||||
/// and all fail with <see cref="PrintingProtocolException"/>.
|
||||
/// </summary>
|
||||
internal static class ProtocolJson
|
||||
{
|
||||
/// <summary>
|
||||
/// The nesting a claimed job may have, counted from the response that carries it: the depth the JSON reader accepts
|
||||
/// by default, and nothing tighter, because a submitter's options may nest as deep as a reader allows.
|
||||
/// </summary>
|
||||
internal const int ReaderDepth = 64;
|
||||
|
||||
/// <summary>
|
||||
/// The nesting a claim or queued-job response is parsed to. It is far past <see cref="ReaderDepth"/> on purpose: a
|
||||
/// job nested too deep to read must still be parsed far enough to learn which job it is, or it could not be
|
||||
/// answered for and the server would offer it forever.
|
||||
/// <para>
|
||||
/// The number comes from the server, not from this client. The server accepts at most 2,000 characters of options
|
||||
/// from a submitter and its JSON library sets no depth limit of its own. The cheapest nesting is an array inside an
|
||||
/// array, two characters a level, so about 1,000 levels fit in that cap; the response, the job and the options
|
||||
/// object add three. 1,024 covers that, so nothing a submitter can get past the server's cap is too deep to
|
||||
/// identify. If the server raises its cap, this must rise with it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The work stays bounded at this depth. The document parser and the serializer's skipping of fields a type does
|
||||
/// not have are loops, not recursion; the one recursive walk, <see cref="NestsDeeperThan"/>, stops after the levels
|
||||
/// it is asked about, 63 for a claimed job; and the response is already limited to 2 MiB.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal const int ParseDepth = 1024;
|
||||
|
||||
private static readonly byte[] ByteOrderMark = [0xEF, 0xBB, 0xBF];
|
||||
private static readonly JsonSerializerOptions DeepOptions = new(PrintApiClient.JsonOptions) { MaxDepth = ParseDepth };
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Parses a message as a JSON object within the depth limit. The caller owns the returned document. The subject names
|
||||
/// the message in the failure text ("realtime frame", "claim response").
|
||||
/// </summary>
|
||||
internal static JsonDocument ParseObject(ReadOnlyMemory<byte> message, string subject, int maximumDepth)
|
||||
{
|
||||
JsonDocument document;
|
||||
try
|
||||
{ document = JsonDocument.Parse(message, new JsonDocumentOptions { MaxDepth = maximumDepth }); }
|
||||
catch(JsonException)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// the parser's own message can quote the offending text, so it is //
|
||||
// deliberately not passed on //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
throw new PrintingProtocolException($"The {subject} is not valid JSON.");
|
||||
}
|
||||
if(document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
document.Dispose();
|
||||
throw new PrintingProtocolException($"The {subject} is not a JSON object.");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Drops a UTF-8 byte-order mark from the start of an HTTP response body. The JSON parser refuses one, but a server
|
||||
/// or a proxy in front of it may send it, and the readers the contract checks replaced accepted it.
|
||||
/// </summary>
|
||||
internal static ReadOnlyMemory<byte> WithoutByteOrderMark(ReadOnlyMemory<byte> body)
|
||||
=> body.Span.StartsWith(ByteOrderMark) ? body[ByteOrderMark.Length..] : body;
|
||||
|
||||
|
||||
|
||||
/// <summary>Rejects any object that repeats a field name, ignoring case, anywhere in the message; two parsers could otherwise read different values from the same text.</summary>
|
||||
internal static void RejectRepeatedFields(JsonElement value, string subject)
|
||||
{
|
||||
if(value.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach(JsonProperty property in value.EnumerateObject())
|
||||
{
|
||||
if(!names.Add(property.Name))
|
||||
{ throw new PrintingProtocolException($"The {subject} repeats a field."); }
|
||||
RejectRepeatedFields(property.Value, subject);
|
||||
}
|
||||
}
|
||||
else if(value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach(JsonElement child in value.EnumerateArray())
|
||||
{ RejectRepeatedFields(child, subject); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when the object spells one of the given member names more than once. Names are compared ignoring case because
|
||||
/// that is how the deserializer matches them: "copies" and "Copies" are the same member to it, so a job carrying
|
||||
/// both could print a different number of copies than the server meant. A name outside the list may repeat freely,
|
||||
/// and so may a name that is not readable text, which is none of the listed ones.
|
||||
/// </summary>
|
||||
internal static bool RepeatsMember(JsonElement value, IReadOnlyList<string> names)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach(JsonProperty property in value.EnumerateObject())
|
||||
{
|
||||
if(Name(property) is { } name && names.Contains(name, StringComparer.OrdinalIgnoreCase) && !seen.Add(name))
|
||||
{ return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Finds the candidates for the member the contract names: every member spelled exactly as the contract spells it,
|
||||
/// because that is the one the server itself reads, or, when there is none, every member that matches ignoring
|
||||
/// case, as the deserializer would take it. One candidate is the member, none means it is absent, and more than one
|
||||
/// means the message does not say which value it means. A member whose name is not readable text is never a
|
||||
/// candidate, and never stops the others from being found.
|
||||
/// </summary>
|
||||
internal static JsonElement[] Members(JsonElement value, string name)
|
||||
{
|
||||
JsonElement[] exact = [.. value.EnumerateObject().Where(property => IsNamed(property, name)).Select(property => property.Value)];
|
||||
return exact.Length != 0
|
||||
? exact
|
||||
: [.. value.EnumerateObject().Where(property => string.Equals(Name(property), name, StringComparison.OrdinalIgnoreCase)).Select(property => property.Value)];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the object has a member whose name is not readable text, as <see cref="Name"/> judges it.</summary>
|
||||
internal static bool HasUnreadableName(JsonElement value) => value.EnumerateObject().Any(property => Name(property) is null);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The member's name, or null when it is not readable text. The parser accepts as valid JSON a name it cannot turn
|
||||
/// into text: one with a surrogate escape that has no partner (a lone <c>\ud800</c> or <c>\udc00</c>, or the two
|
||||
/// the wrong way round) or with bytes that are not UTF-8. Asking for such a name throws, and not as a JSON failure.
|
||||
/// The readers of a job ask here instead, because a job must be read far enough to learn which job it is before
|
||||
/// it can be refused. The judgement is the JSON library's own; nothing here decides what readable means. A thrown
|
||||
/// exception is the only way the library gives it, so each such name costs one each time it is asked for; the
|
||||
/// 2 MiB limit on a response bounds how many there can be.
|
||||
/// </summary>
|
||||
internal static string? Name(JsonProperty property)
|
||||
{
|
||||
try
|
||||
{ return property.Name; }
|
||||
catch(InvalidOperationException)
|
||||
{ return null; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The text of a JSON string, or null when it is not readable text, as <see cref="Name"/> describes. The caller has checked that the value is a string.</summary>
|
||||
internal static string? Text(JsonElement value)
|
||||
{
|
||||
try
|
||||
{ return value.GetString(); }
|
||||
catch(InvalidOperationException)
|
||||
{ return null; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the member is spelled exactly as given. A name that is not readable text is spelled as nothing.</summary>
|
||||
private static bool IsNamed(JsonProperty property, string name)
|
||||
{
|
||||
try
|
||||
{ return property.NameEquals(name); }
|
||||
catch(InvalidOperationException)
|
||||
{ return false; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when the value nests containers more than the given number of levels deep, itself included: an empty object
|
||||
/// is one level. The walk stops at the first branch that is too deep, so it never recurses further than the number
|
||||
/// of levels asked about, however deep the document goes.
|
||||
/// </summary>
|
||||
internal static bool NestsDeeperThan(JsonElement value, int levels)
|
||||
{
|
||||
if(value.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array))
|
||||
{ return false; }
|
||||
if(levels == 0)
|
||||
{ return true; }
|
||||
return value.ValueKind == JsonValueKind.Object
|
||||
? value.EnumerateObject().Any(property => NestsDeeperThan(property.Value, levels - 1))
|
||||
: value.EnumerateArray().Any(child => NestsDeeperThan(child, levels - 1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Converts a JSON object into a contract type, reporting a member of the wrong JSON type as a protocol violation.
|
||||
/// The serializer reads to its default depth unless the caller asks for the whole parsed depth, which only a value
|
||||
/// that is displayed and never acted on should: fields the type does not have are skipped, however deep they go.
|
||||
/// </summary>
|
||||
internal static T Read<T>(JsonElement value, string subject, bool toParseDepth = false)
|
||||
{
|
||||
try
|
||||
{ return value.Deserialize<T>(toParseDepth ? DeepOptions : PrintApiClient.JsonOptions)!; }
|
||||
catch(JsonException)
|
||||
{ throw new PrintingProtocolException($"The {subject} does not have the expected fields."); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads a required text member of at most the given length, or reports its absence, wrong type, emptiness or excess length as a protocol violation.</summary>
|
||||
internal static string RequiredText(JsonElement value, string name, int maximumLength, string subject)
|
||||
{
|
||||
return value.TryGetProperty(name, out JsonElement member) && member.ValueKind == JsonValueKind.String && member.GetString() is { Length: >= 1 } text && text.Length <= maximumLength
|
||||
? text
|
||||
: throw new PrintingProtocolException($"The {subject} has no usable {name}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// The failure a send gets because the realtime connection was already gone: a send that was still waiting for its
|
||||
/// acknowledgement when the channel closed, or a send attempted afterwards. It reports a consequence, not a cause. What
|
||||
/// closed the channel is the inner exception, when the reader or writer ended with a failure and the send was waiting at
|
||||
/// that moment. It has a type of its own so that whoever chooses the reason a connection ended can tell it from an I/O
|
||||
/// failure that is a reason in itself, such as a full output queue. It derives from <see cref="IOException"/>, so code that
|
||||
/// handles a lost connection by catching that keeps working.
|
||||
/// </summary>
|
||||
public sealed class RealtimeChannelClosedException(string message, Exception? cause) : IOException(message, cause);
|
||||
@@ -10,7 +10,45 @@ namespace Qqq.PrintAgent.Core.Printing;
|
||||
/// <summary>One realtime message: a type, a message id for deduplication and an undecoded payload. The payload stays raw until the type is checked, so an unknown message cannot be misread as a known one.</summary>
|
||||
public sealed record RealtimeEnvelope(string Type, string MessageId, JsonElement Payload)
|
||||
{
|
||||
private const int TypeLimit = 64;
|
||||
private const int MessageIdLimit = 200;
|
||||
private const int DepthLimit = 24;
|
||||
|
||||
|
||||
|
||||
/// <summary>The size of the frame on the wire, filled in by the receiver for traffic accounting and never serialized, so it cannot be spoofed by a peer.</summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public int WireLength { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads one complete frame from the server. It must be a JSON object within the depth limit, with no repeated field,
|
||||
/// a short type, a message identifier of at most 200 characters and an object payload; anything else is a protocol
|
||||
/// violation. The payload is copied out of the parse buffer, so the envelope stays valid after the reader moves on.
|
||||
/// </summary>
|
||||
internal static RealtimeEnvelope Parse(ReadOnlyMemory<byte> frame)
|
||||
{
|
||||
using JsonDocument document = ProtocolJson.ParseObject(frame, "realtime frame", DepthLimit);
|
||||
ProtocolJson.RejectRepeatedFields(document.RootElement, "realtime frame");
|
||||
RealtimeEnvelope envelope = ProtocolJson.Read<RealtimeEnvelope>(document.RootElement, "realtime frame");
|
||||
if(string.IsNullOrEmpty(envelope.Type) || envelope.Type.Length > TypeLimit || string.IsNullOrEmpty(envelope.MessageId) || envelope.MessageId.Length > MessageIdLimit
|
||||
|| envelope.Payload.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new PrintingProtocolException("The realtime frame has no usable type, message identifier or payload object.");
|
||||
}
|
||||
return envelope with { Payload = envelope.Payload.Clone(), WireLength = frame.Length };
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Converts the payload into the contract type a message of this kind carries. A member of the wrong JSON type is
|
||||
/// reported as a protocol violation, the same failure a malformed frame raises. What the converted value means is
|
||||
/// judged by its consumer, which may report a bad value in its own way: realtime settings, for one, are refused
|
||||
/// with <see cref="InvalidDataException"/>.
|
||||
/// </summary>
|
||||
public T ReadPayload<T>() => Payload.ValueKind == JsonValueKind.Object
|
||||
? ProtocolJson.Read<T>(Payload, "realtime payload")
|
||||
: throw new PrintingProtocolException("The realtime payload is not a JSON object.");
|
||||
}
|
||||
@@ -23,19 +23,29 @@ public sealed record RuntimeBranding(string? DisplayName = null, string? Company
|
||||
/// labels anyway, and labels left by an earlier generic install must not relabel the edition. The branding file still wins.
|
||||
/// </summary>
|
||||
public static RuntimeBranding LoadMachine(bool includeInstallerLabels = true)
|
||||
=> LoadMachine(DefaultPath, includeInstallerLabels && OperatingSystem.IsWindows() ? MachineInstallerLabels.Read : null);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads a branding file and, when a reader for the installer's persisted labels is given, fills in the display and
|
||||
/// company names the file leaves out. The file always wins, a label that is blank, over-long or carries control
|
||||
/// characters is ignored, and a reader that cannot reach its store leaves the file's branding as it is. Taking the
|
||||
/// path and the reader as arguments keeps the machine's ProgramData folder and registry out of the rules.
|
||||
/// </summary>
|
||||
internal static RuntimeBranding LoadMachine(string path, Func<string, string?>? installerLabel)
|
||||
{
|
||||
RuntimeBranding branding = Load(DefaultPath);
|
||||
if(!OperatingSystem.IsWindows() || !includeInstallerLabels)
|
||||
RuntimeBranding branding = Load(path);
|
||||
if(installerLabel is null)
|
||||
{
|
||||
return branding;
|
||||
}
|
||||
try
|
||||
{
|
||||
using Microsoft.Win32.RegistryKey? key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\QRunIO\PrintAgent");
|
||||
return branding with
|
||||
{
|
||||
DisplayName = branding.DisplayName ?? ValidMachineLabel(key?.GetValue("DisplayName") as string),
|
||||
CompanyName = branding.CompanyName ?? ValidMachineLabel(key?.GetValue("CompanyName") as string),
|
||||
DisplayName = branding.DisplayName ?? ValidMachineLabel(installerLabel("DisplayName")),
|
||||
CompanyName = branding.CompanyName ?? ValidMachineLabel(installerLabel("CompanyName")),
|
||||
};
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Qqq.PrintAgent.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The one definition of which characters are shown as they are when text that a server supplied, or that an operator
|
||||
/// typed, is put on a screen, in a log or on the management pipe. It is a type of its own because several things
|
||||
/// depend on it and none of them owns it: the session client's problem details and log lines
|
||||
/// (<see cref="Authentication.QqqCompatibilityProblem"/>), the form of text on the management pipe, and the rule for
|
||||
/// what a server's name may be. Each of those decides for itself how long the text may be and what stands in for a
|
||||
/// missing value; none of them decides again which characters are safe.
|
||||
/// </summary>
|
||||
public static class ServerText
|
||||
{
|
||||
/// <summary>
|
||||
/// True for a code point that shows as itself and cannot change how the text around it is laid out. False for
|
||||
/// control characters, the line and paragraph separators (U+2028, U+2029) that end a line as surely as a newline,
|
||||
/// the format characters that reorder or hide text (the bidirectional embeddings, overrides and isolates, the
|
||||
/// zero-width characters, the invisible tag characters), and private-use and unassigned code points. Space
|
||||
/// separators other than the plain space (the no-break space U+00A0, the ideographic space U+3000) are shown: they
|
||||
/// are visible gaps and move nothing. A surrogate without its partner is not a code point and never arrives here
|
||||
/// as one: reading text by code point replaces it with U+FFFD, which is shown.
|
||||
/// </summary>
|
||||
public static bool IsShown(Rune rune) => Rune.GetUnicodeCategory(rune) is not (UnicodeCategory.Control or UnicodeCategory.Format
|
||||
or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator or UnicodeCategory.PrivateUse or UnicodeCategory.OtherNotAssigned);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// A cancellation token that fires when the caller cancels or when a time limit measured on the supplied clock runs
|
||||
/// out, whichever is first. Every request and transfer limit Core enforces itself goes through it, so the limit is
|
||||
/// counted on the injected <see cref="TimeProvider"/> and a test can reach it by moving a clock instead of waiting.
|
||||
/// The exception is <c>HttpClient.Timeout</c>, which the API client also sets: the platform enforces that one on the
|
||||
/// system clock and no clock can be injected into it. An infinite limit leaves only the caller's cancellation.
|
||||
/// </summary>
|
||||
internal sealed class Deadline : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _limit;
|
||||
private readonly CancellationTokenSource _either;
|
||||
|
||||
|
||||
|
||||
/// <summary>Starts the limit now on the given clock and ties it to the caller's token.</summary>
|
||||
internal Deadline(TimeProvider time, TimeSpan limit, CancellationToken token)
|
||||
{
|
||||
_limit = new CancellationTokenSource(limit, time);
|
||||
_either = CancellationTokenSource.CreateLinkedTokenSource(token, _limit.Token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Cancelled by the caller or by the limit, whichever comes first.</summary>
|
||||
internal CancellationToken Token => _either.Token;
|
||||
|
||||
|
||||
|
||||
/// <summary>Stops the clock and releases both sources; the token must not be used afterwards.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_either.Dispose();
|
||||
_limit.Dispose();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 819 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user