feat: add standard QQQ sign-in and remote device controls

Use discovered QQQ session authentication with protected service persistence,
renewal, principal/device binding and explicit legacy compatibility diagnostics.
Add durable control polling/receipts, owned spool cancellation, sign-in UI and
installer-managed browser callback forwarding with branded display text.

Validation: 356 tests and WinUI/service/CLI build pass with zero warnings,
errors or skips. Independent review fixes are covered by regressions. The
unsigned 0.2.0-rc.1 candidate MSI builds and passes payload/table inspection;
installed callback, provider login and real-server interop remain separate gates.
Pin the reviewed shared protocol at cc3c4e50e8f6f4fbc93d351fb51edb4f5438bbd3.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandCodex committed 2026-10-31 08:51:17 -05:00
1 parent 33ff6c2e9f
commit 2c1c5f1054
76 files changed
+3862 -64

No files matched your search

+3 -3
View File
@@ -12,7 +12,7 @@ on:
default: '0.1.9'
release-version:
type: string
default: '0.1.9-rc.1'
default: '0.2.0-rc.1'
permissions:
contents: read
@@ -39,7 +39,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: QRun-IO/qqq-print-protocol
ref: f4f977ca4aa6b749e6a52760eed5e53c99a7704d
ref: cc3c4e50e8f6f4fbc93d351fb51edb4f5438bbd3
path: qqq-print-protocol
persist-credentials: false
- name: Set up .NET
@@ -55,7 +55,7 @@ jobs:
- name: Build unsigned prerelease MSI
env:
PRODUCT_VERSION: ${{ inputs.product-version || '0.1.9' }}
RELEASE_VERSION: ${{ inputs.release-version || '0.1.9-rc.1' }}
RELEASE_VERSION: ${{ inputs.release-version || '0.2.0-rc.1' }}
run: |
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## Unreleased — 0.2.0-rc.1 candidate
Upgrade the server first: the new client requires the semantic printing extension
and standard QQQ sign-in; older bearer-only servers need an upgrade. Existing
pairings and history are preserved, but unbound devices need explicit account association.
- Discover QQQ's semantic printing/authentication capabilities and sign in through
standard table-based or explicitly advertised native browser authentication.
Protect the service session with DPAPI; resume through QQQ and preserve pairing
and journals when authorization is lost.
- Add independent device-control polling, durable desired state/command receipts
and exact owned spool cancellation; disconnect keeps the control path available.
- Route normal CLI operations through the authenticated service; direct legacy
diagnostics require an explicit compatibility flag.
- Add installer-owned browser callback forwarding, cancellable sign-in and
customer-branded callback text. Installed callback/provider acceptance remains
unqualified; no signing or stable1.0 readiness is claimed.
- Pass356 tests and build WinUI/service/CLI with zero warnings/errors/skips.
## 0.1.9-rc.1 — 2026-10-31
- Keep management responses available until the client closes its one-command connection or the existing deadline expires. This prevents Windows from discarding unread success and error responses during server disconnect.
+1 -1
View File
@@ -55,7 +55,7 @@
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
CLI's version output show as a short hash. -->
<PropertyGroup>
<VersionPrefix>0.1.9</VersionPrefix>
<VersionPrefix>0.2.0</VersionPrefix>
<VersionSuffix>rc.1</VersionSuffix>
</PropertyGroup>
+3 -1
View File
@@ -13,6 +13,8 @@ The native setup and status application uses the same [KofTwentyTwo AppKit](http
> **Prerelease software.** [v0.1.9-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.1.9-rc.1) is published; all four release assets and their build provenance attestations are verified. The 255-test source gate and all 16 installed UI checks passed. Installed acceptance used a separately built CI MSI; [acceptance notes](docs/ACCEPTANCE.md) distinguish it from the published MSI. The MSI remains unsigned, and remote deployment and broader hardware qualification remain open.
The unreleased `0.2.0-rc.1` candidate adds standard QQQ sign-in and server device controls. Upgrade the QQQ printing server to the matching native extension before installing this client: it requires discovered printing extension `1.0.0` and a user session, and existing unbound pairings require explicit account association. An older bearer-only server will not work through a silent fallback. See [sign-in and migration](docs/QQQ-SIGN-IN.md). The candidate passed356 tests with zero warnings/errors/skips; installed callback and provider acceptance remain separate.
## What works
- Discover Windows print queues and explicitly choose which ones to expose to QQQ.
@@ -94,7 +96,7 @@ git clone https://github.com/QRun-IO/qqq-print-agent.git
git clone https://github.com/QRun-IO/qqq-print-protocol.git
Set-Location qqq-print-agent
./build/Verify.ps1
./build/Build-Installer.ps1 -ProductVersion 0.1.9 -ReleaseVersion 0.1.9-rc.1
./build/Build-Installer.ps1 -ProductVersion 0.2.0 -ReleaseVersion 0.2.0-rc.1
```
[History and log limits](docs/SCALABILITY.md) · [Customer branding](docs/BRANDING.md) · [Developer guide](docs/DEVELOPMENT.md) · [Administrator guide](docs/ADMINISTRATION.md) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [Release process](docs/RELEASING.md) · [Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
+2 -2
View File
@@ -2,9 +2,9 @@
[CmdletBinding()]
param(
[ValidatePattern('^\d+\.\d+\.\d+$')]
[string] $ProductVersion = '0.1.9',
[string] $ProductVersion = '0.2.0',
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
[string] $ReleaseVersion = '0.1.9-rc.1',
[string] $ReleaseVersion = '0.2.0-rc.1',
[string] $PayloadDirectory,
[switch] $SkipPublish,
[string] $CertificateThumbprint,
+4 -4
View File
@@ -1,12 +1,12 @@
#Requires -Version 7.4
[CmdletBinding()]
param()
param([string] $ProtocolFixtures)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$projectRoot = Split-Path -Parent $PSScriptRoot
$resultsDirectory = Join-Path $projectRoot ('TestResults/verify-' + [Guid]::NewGuid().ToString('N'))
$protocolFixtures = Join-Path (Split-Path -Parent $projectRoot) 'qqq-print-protocol\fixtures'
if(-not $ProtocolFixtures) { $ProtocolFixtures = Join-Path (Split-Path -Parent $projectRoot) 'qqq-print-protocol\fixtures' }
if(-not (Test-Path -LiteralPath $protocolFixtures))
{
throw 'Check out the pinned qqq-print-protocol repository beside qqq-print-agent before running verification.'
@@ -16,13 +16,13 @@ try
{
& dotnet restore Qqq.PrintAgent.slnx '-p:Platform=x64' --locked-mode
if($LASTEXITCODE -ne 0) { throw 'Locked dependency restore failed.' }
& dotnet build Qqq.PrintAgent.slnx --configuration Release '-p:Platform=x64' --no-restore -warnaserror
& 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'))
{
# 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' --no-restore --settings coverage.runsettings --logger trx --collect 'XPlat Code Coverage' --results-directory $resultsDirectory
& 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" }
}
Write-Output "Current verification evidence: $resultsDirectory"
+15
View File
@@ -0,0 +1,15 @@
# QQQ sign-in candidate
This describes the unreleased native-authentication implementation. It is not installed/provider acceptance evidence.
Open **Settings → Sign in / Connection settings**, enter the QQQ server origin and choose **Discover sign-in options**. Discovery must advertise a compatible QQQ UI API and the printing extension before credentials are sent. Table-based authentication presents username/password fields; configured external authentication presents a browser button only for an explicitly advertised native session grant. Passwords remain in the companion and are sent only to the selected QQQ server, never stored or sent to the service.
The service receives the opaque QQQ session through its existing authenticated administrator pipe. It validates and resumes that session, verifies the authoritative user/tenant/device binding, and protects the session with service-identity DPAPI. Provider access and refresh tokens remain on the QQQ server. Normal printing uses `/qqq/v1/qbit/printing` with the current user session; the device ID/token are secondary possession proof, not independent user authority. Session resume runs periodically while printing continues. Expired or denied authorization stops new traffic until sign-in is restored, preserving receipts and pairing.
Existing installations require sign-in after upgrading. To associate an existing unbound pairing, explicitly select **Associate this computer's existing pairing with my QQQ account**. A new pairing still requires an enrollment code after sign-in. Sign-out preserves the device credential, printer choices and print journal, while removing the locally stored QQQ session. It does not recall pages already sent to a printer.
The installed browser callback is exactly `io.qrun.qqq-print-agent:/oauth/callback`. The host must advertise and approve this redirect for its configured QQQ OAuth2 session client; the separate public bearer client is not substituted for the server-managed session exchange. MSI registers the stable scheme independently of display branding and removes its owned registration on uninstall. The browser launches the quoted installed CLI callback helper, which forwards only to an active companion attempt in the same Windows user/session, after checking the companion executable. The companion validates the single-use state and PKCE exchange using the standard QQQ grant. No callback starts a new sign-in attempt. No provider registration, registry changes or installed browser sign-in are performed by source tests.
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.
Pending qualification includes the configured provider's grant, callback registration in the packaged installation, current-user permission changes and long-lived provider renewal. No successful synthetic test substitutes for those deployment checks.
+10
View File
@@ -1,5 +1,15 @@
# Session state — 2026-10-31
## Isolated remote-control implementation — not released
The device-controls worktree adds independent control polling, persisted desired dispatch/connection state, connection-scoped command receipts and verified single-job spool cancellation. Pause stops new claims; administrator disconnect keeps control polling and existing print-event delivery alive while suppressing ordinary synchronization. Reconnect does not restart Windows, the service or an in-flight print. Authorization failure requires explicit local recovery and never erases the print journal. The companion displays desired state, control contact and the last remote action/outcome.
The reviewed isolated solution/UI build passed without warnings or errors, and all **279 tests (72 Core + 207 Windows/CLI)** passed without skips. Verification evidence is in `TestResults/verify-211a2eed18b04373858451e6a1f59070`; the command used `build/Verify.ps1 -ProtocolFixtures` with the coordinated protocol worktree. Tests cover blocked printing, disconnect/reconnect, lost receipt and native cancellation responses, duplicate delivery, interrupted execution, expiry, ownership, revision rollback, authorization denial, legacy negotiation and receipt retirement. Independent review identified two corrected contract issues: generated receipts now use literal `Z` UTC timestamps, and omitted desired-state fields cannot silently resume dispatch. No installed service, live server, printer or enrollment has been changed by this work.
Native QQQ authentication integration now passes the combined solution/UI gate: **356 tests (127 Core + 229 Windows/CLI), zero warnings/errors/skips**. Evidence is in `TestResults/verify-01fc65d1e3d742ceac9b69463a75b555` and `native-auth-verify-final.log`. Independent source review findings were corrected and rechecked: malformed input/timeout handling, session-generation races, constructor-failure gate release, installed callback location and visible browser cancellation. Runtime tests also cover temporary resume failure recovery and pre-enrollment session restoration.
The implementation includes discovery-first sign-in, protected service session storage, periodic standard resume, authoritative principal/tenant/device checks, explicit legacy-device association, and native printing transport. Native control 404 cannot fall back to private bearer routes. Settings exposes sign-in/sign-out and current identity; the installed private-scheme callback forwards only to the active companion attempt, where Core validates PKCE/state. Normal CLI commands use the service; direct bearer diagnostics require explicit `--legacy-compat`. See [QQQ sign-in](QQQ-SIGN-IN.md) for setup and remaining installed/provider qualifications. The preceding 279-test gate is historical controls-only evidence. Barcode diagnostics are explicitly outside this printing change.
## Published 0.1.9-rc.1 — 2026-10-31
[v0.1.9-rc.1](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v0.1.9-rc.1) is published from protected main revision `cf0e362`, following the audit merge. [Release workflow 41190815995](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41190815995) passed; all four assets, their checksums/digests and their build provenance attestations were verified. The MSI remains unsigned.
+13
View File
@@ -1,5 +1,18 @@
# TODO
## Remote controls candidate — not released
- [x] Implement independent bounded control polling and persisted pause/disconnect state without interrupting active print submission.
- [x] Persist command execution intent and immutable delivery receipts; recover interrupted commands as unknown without replaying physical cancellation.
- [x] Reuse verified single-job spool cancellation, preserving current-connection ownership and reporting absence, refusal and uncertainty explicitly.
- [x] Add companion desired-state, control-contact and last-action status with accessible text.
- [x] Complete the isolated Verify.ps1 gate: 279 tests (72 Core + 207 Windows/CLI), zero warnings/errors/skips. Independent control-boundary review issues are fixed with runtime regressions.
- [x] Wire native QQQ printing transport and refuse control-404 legacy fallback on that advertised extension; keep direct bearer diagnostics behind explicit `--legacy-compat`.
- [x] Implement standard QQQ auth discovery/configured sign-in, protected service session storage and periodic resume, preserving device/journal identity.
- [x] Complete the combined native-authentication, callback forwarding, CLI, UI and control gate: 356 tests (127 Core + 229 Windows/CLI), zero warnings/errors/skips, and independent source review fixes rechecked.
- [ ] Qualify the configured provider and installed private callback end to end; source tests do not establish actual provider renewal or permissions deployment.
- [ ] Qualify the combined candidate with a real server and installed companion only after review. No new physical printing is implied.
## Published 0.1.9-rc.1 — 2026-10-31
- [x] Reproduce and correct management-response loss during immediate named-pipe disconnect; current/legacy delayed readers and bounded shutdown/deadline regressions pass.
+14
View File
@@ -1,5 +1,19 @@
# Threat model: QQQ Print Agent
## Remote device controls candidate
The remote-control adapter accepts only inventory refresh and cancellation of one locally journaled spooler job. Desired pause/disconnect state is revisioned and persists by canonical server URL and device identity; older or conflicting revisions cannot resume a device. The independent poll loop does not wait behind rendering. A paused device still synchronizes but claims no new work. A disconnected device keeps only control traffic and existing event delivery active; already-submitted printing is not recalled. Reconnect is a transport refresh, never an OS or service restart.
The native QQQ session adapter requires discovery before credentials and never treats a native control 404 as legacy permission. After support is negotiated, 404 does not remove restrictions. Authorization denial becomes authorization-required, without inferring that every 401/403 proves revocation. New claims wait for an authenticated, bound QQQ user and valid control state. Existing installations without a protected QQQ session wait for explicit sign-in; device proof alone cannot authorize normal service printing. Explicit isolated CLI diagnostics may use the compatibility bearer adapter with `--legacy-compat`; it is not a fallback from native authentication.
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. Passwords stay in the companion; provider refresh/access tokens remain server-side. Normal CLI operations use the existing authenticated service pipe. The private browser callback is fixed and MSI-owned, forwarding bounded data only into an active same-user/session companion inbox after executable verification. It carries a short-lived authorization code, never a session cookie or PKCE verifier. Core validates exact advertised redirect, random state, PKCE and single-use expiry; no callback creates an authentication attempt. Provider registration, installed callback delivery and long-duration renewal remain deployment qualifications.
Control responses are capped at 64 KiB after the HTTP client's bounded receive, with at most 20 commands. 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 receipt-endpoint 404 after expiry plus seven days retires its transport retry, preserving the exact local receipt and distinguishing retirement from server acknowledgement. A poll 404 never uses 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 queue purge is implemented.
## Historical audit baseline
Installed baseline: 0.1.7-rc.1; audit source changes pending release. Reviewed: 2026-10-30. This is an implementation-level inventory. The separate independent deep scan has not yet returned its findings.
## Boundaries and assets
+3
View File
@@ -56,6 +56,9 @@
<Directory Id="APPDIR" Name="App">
<Component Id="AgentCompanion" Guid="2FD52718-B33B-42AA-9E28-21B3B70FA4BB" Bitness="always64">
<File Id="CompanionExe" Source="$(PayloadRoot)\app\Qqq.PrintAgent.exe" KeyPath="yes" />
<RegistryValue Root="HKLM" Key="SOFTWARE\Classes\io.qrun.qqq-print-agent" Type="string" Value="URL:[BRANDNAME] sign-in" />
<RegistryValue Root="HKLM" Key="SOFTWARE\Classes\io.qrun.qqq-print-agent" Name="URL Protocol" Type="string" Value="" />
<RegistryValue Root="HKLM" Key="SOFTWARE\Classes\io.qrun.qqq-print-agent\shell\open\command" Type="string" Value="&quot;[CLIDIR]qqq-print-agent.exe&quot; --auth-callback &quot;%1&quot;" />
</Component>
<Files Include="$(PayloadRoot)\app\**">
<Exclude Files="$(PayloadRoot)\app\Qqq.PrintAgent.exe" />
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Package</OutputType>
<InstallerPlatform>x64</InstallerPlatform>
<ProductVersion Condition="'$(ProductVersion)' == ''">0.1.8</ProductVersion>
<ProductVersion Condition="'$(ProductVersion)' == ''">0.2.0</ProductVersion>
<PayloadRoot Condition="'$(PayloadRoot)' == ''">$(MSBuildThisFileDirectory)..\artifacts\package</PayloadRoot>
<OutputName>QqqPrintAgent-$(ProductVersion)-x64</OutputName>
<DefineConstants>ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot)</DefineConstants>
+56 -2
View File
@@ -16,6 +16,11 @@ public static class Program
{
["--printer-worker"] => PrinterWorkerHost.RunAsync(),
["--apply-update", string request] => ExecuteAsync(() => MsiUpdateInstaller.ApplyAsync(request)),
["--auth-callback", string callback] => ExecuteAsync(async () =>
{
await QqqBrowserCallback.ForwardAsync(callback);
return 0;
}),
_ => ExecuteAsync(() => DispatchAsync(args)),
};
@@ -33,9 +38,18 @@ public static class Program
catch(Exception exception) { Console.Error.WriteLine(exception.Message); return 1; }
}
private static async Task<int> DispatchAsync(string[] args)
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)
{
@@ -53,11 +67,51 @@ public static class Program
case "run":
return await RunAsync(args, directory);
default:
Console.WriteLine("printers | enroll --server URL [--data-dir PATH] [--service] | expose --printer ID --exposed true|false [--data-dir PATH] [--service] | run [--data-dir PATH] [--once] | service-status | snapshot\nEnrollment code is read from QQQ_PRINT_ENROLLMENT_CODE or an interactive prompt.");
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;
}
}
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;
}
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;
}
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.");
}
private static string? Option(string[] args, string key)
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>UI and service seam for standard QQQ sessions. Implementations do not persist passwords or provider tokens.</summary>
public interface IQqqSessionClient : IDisposable
{
Uri Origin { get; }
bool IsAuthenticated { get; }
Task<QqqApiDiscovery> DiscoverAsync(CancellationToken cancellationToken = default);
Task<QqqAuthenticationMetadata> GetAuthenticationMetadataAsync(CancellationToken cancellationToken = default);
Task SignInWithPasswordAsync(string username, string password, CancellationToken cancellationToken = default);
Task<QqqBrowserLogin> BeginBrowserLoginAsync(Uri redirectUri, CancellationToken cancellationToken = default);
Task CompleteBrowserLoginAsync(QqqBrowserLogin attempt, Uri callback, CancellationToken cancellationToken = default);
Task RestoreSessionAsync(QqqSessionCredential credential, CancellationToken cancellationToken = default);
Task ResumeSessionAsync(CancellationToken cancellationToken = default);
Task SignOutAsync(CancellationToken cancellationToken = default);
QqqSessionCredential ExportCredential();
Task<QqqSessionBinding> GetSessionIdentityAsync(CancellationToken cancellationToken = default);
Task<QqqSessionBinding> BindDeviceAsync(QqqDeviceCredential credential, bool claimLegacyDevice = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> SendPrintingAsync(HttpMethod method, string relativePath, HttpContent? content = null, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>The existing semantic UI API discovery document, including the printing extension.</summary>
public sealed class QqqApiDiscovery
{
public string ApiVersion { get; init; } = "";
public IReadOnlyList<string> Features { get; init; } = [];
public IReadOnlyList<string> ClientChannels { get; init; } = [];
public IReadOnlyList<Extension> Extensions { get; init; } = [];
public sealed record Extension(string Name, string Version, string PathPrefix);
internal void Validate(bool nativeSession = false)
{
string[] required = ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
if(!SupportedVersion(ApiVersion, nativeSession ? 9u : 8u) || Features is null
|| required.Any(feature => !Features.Contains(feature, StringComparer.Ordinal))
|| ClientChannels is null || !ClientChannels.Contains("api", StringComparer.Ordinal)
|| Extensions is null || Extensions.Count(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)) != 1)
{
throw Configuration();
}
Extension printing = Extensions.Single(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal));
if(!SupportedVersion(printing.Version, 0) || !string.Equals(printing.PathPrefix, "/qbit/printing", StringComparison.Ordinal)
|| (nativeSession && !Features.Contains("nativeSessionLogin", StringComparer.Ordinal)))
{
throw Configuration();
}
}
private static bool SupportedVersion(string? value, uint minimumMinor)
{
string[] parts = value?.Split('.') ?? [];
return parts.Length == 3 && parts.All(part => part.Length != 0 && (part.Length == 1 || part[0] != '0')
&& uint.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out _))
&& string.Equals(parts[0], "1", StringComparison.Ordinal)
&& uint.Parse(parts[1], CultureInfo.InvariantCulture) >= minimumMinor;
}
internal static QqqSessionException Configuration() => new(QqqSessionException.FailureKind.ConfigurationRequired,
"This server does not advertise the required QQQ printing and sign-in capabilities.");
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>Provider metadata from QQQ. A public native bearer client is not a stored-session grant.</summary>
public sealed class QqqAuthenticationMetadata
{
public const string NativeSessionFlow = "AUTHORIZATION_CODE_PKCE_SESSION";
public string Name { get; init; } = "";
public string Type { get; init; } = "";
public ProviderValues? Values { get; init; }
public sealed class ProviderValues
{
public string ClientId { get; init; } = "";
public string BaseUrl { get; init; } = "";
public string Scopes { get; init; } = "";
public NativeGrant? NativeSessionLogin { get; init; }
}
public sealed class NativeGrant
{
public string Flow { get; init; } = "";
public string CodeChallengeMethod { get; init; } = "";
public string Prompt { get; init; } = "";
public string Issuer { get; init; } = "";
public string DiscoveryUrl { get; init; } = "";
public IReadOnlyList<string> RedirectUris { get; init; } = [];
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>An in-memory, single-use browser attempt. The host owns callback delivery, never provider tokens.</summary>
public sealed class QqqBrowserLogin
{
internal QqqBrowserLogin(Uri authorizationUri, Uri redirectUri, string state, string verifier, string issuer, long startedAt)
{
AuthorizationUri = authorizationUri;
RedirectUri = redirectUri;
State = state;
Verifier = verifier;
Issuer = issuer;
StartedAt = startedAt;
}
public Uri AuthorizationUri { get; }
public Uri RedirectUri { get; }
internal string State { get; }
internal string Verifier { get; }
internal string Issuer { get; }
internal long StartedAt { get; }
public override string ToString() => "QQQ browser sign-in attempt (redacted)";
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>Secondary device possession proof. It never replaces the current QQQ user session.</summary>
public sealed class QqqDeviceCredential
{
public QqqDeviceCredential(string deviceId, string deviceToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
ArgumentException.ThrowIfNullOrWhiteSpace(deviceToken);
if(deviceId.Length > 128 || deviceId.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not '.' and not '_' and not '-'))
{
throw new ArgumentException("Invalid device credential.", nameof(deviceId));
}
if(deviceToken.Length > 4096 || deviceToken.Any(character => character < 33 || character > 126))
{
throw new ArgumentException("Invalid device credential.", nameof(deviceToken));
}
DeviceId = deviceId;
DeviceToken = deviceToken;
}
public string DeviceId { get; }
public string DeviceToken { get; }
public override string ToString() => "QQQ device credential (redacted)";
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.Cryptography;
using System.Text.Json;
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>Stable server-derived identity, separate from rotating credentials. It is not an authorization grant.</summary>
public sealed record QqqSessionBinding(Uri Origin, string PrincipalId, string TenantId, string? DeviceId)
{
public bool HasApprovedDevice => !string.IsNullOrEmpty(DeviceId);
/// <summary>Unambiguous identity for a host's journal migration; session and device secrets are excluded.</summary>
public string IdentityKey => Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(
new[] { Origin.AbsoluteUri, PrincipalId, TenantId, DeviceId })));
}
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Qqq.PrintAgent.Core.Authentication;
public sealed partial class QqqSessionClient
{
public async Task<QqqBrowserLogin> BeginBrowserLoginAsync(Uri redirectUri, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(redirectUri);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
QqqAuthenticationMetadata metadata = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
QqqAuthenticationMetadata.NativeGrant grant = ValidateNativeGrant(metadata);
if(!ValidRedirect(redirectUri) || !grant.RedirectUris.Contains(redirectUri.AbsoluteUri, StringComparer.Ordinal))
{ throw QqqApiDiscovery.Configuration(); }
using HttpResponseMessage response = await SendCoreAsync(new Uri(grant.DiscoveryUrl), HttpMethod.Get, null, MetadataLimit,
null, null, null, cancellationToken, providerDiscovery: true).ConfigureAwait(false);
RequireSuccess(response);
using JsonDocument document = ParseDocument(response);
JsonElement provider = document.RootElement;
if(!provider.TryGetProperty("issuer", out JsonElement issuer) || issuer.ValueKind != JsonValueKind.String
|| !string.Equals(issuer.GetString(), grant.Issuer, StringComparison.Ordinal)
|| !provider.TryGetProperty("authorization_endpoint", out JsonElement authorization) || authorization.ValueKind != JsonValueKind.String
|| !TryHttps(authorization.GetString(), out Uri? authorizationUri)
|| !SameOrigin(authorizationUri!, new Uri(grant.Issuer))
|| !provider.TryGetProperty("code_challenge_methods_supported", out JsonElement methods) || methods.ValueKind != JsonValueKind.Array
|| !methods.EnumerateArray().Any(value => value.ValueKind == JsonValueKind.String && string.Equals(value.GetString(), "S256", StringComparison.Ordinal)))
{
throw QqqApiDiscovery.Configuration();
}
string state = Base64Url(RandomNumberGenerator.GetBytes(32));
string verifier = Base64Url(RandomNumberGenerator.GetBytes(32));
var query = new Dictionary<string, string>(StringComparer.Ordinal)
{
["client_id"] = metadata.Values!.ClientId,
["response_type"] = "code",
["redirect_uri"] = redirectUri.AbsoluteUri,
["scope"] = metadata.Values.Scopes,
["state"] = state,
["nonce"] = Base64Url(RandomNumberGenerator.GetBytes(32)),
["prompt"] = grant.Prompt,
["code_challenge_method"] = "S256",
["code_challenge"] = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))),
};
var builder = new UriBuilder(authorizationUri!)
{
Query = string.Join("&", query.Select(pair => Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))),
};
ClearSession();
_browserLogin = new QqqBrowserLogin(builder.Uri, redirectUri, state, verifier, grant.Issuer, _timeProvider.GetTimestamp());
return _browserLogin;
}
finally { _gate.Release(); }
}
public async Task CompleteBrowserLoginAsync(QqqBrowserLogin attempt, Uri callback, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(attempt);
ArgumentNullException.ThrowIfNull(callback);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
QqqBrowserLogin? pending = _browserLogin;
_browserLogin = null;
TimeSpan age = _timeProvider.GetElapsedTime(attempt.StartedAt);
if(!ReferenceEquals(pending, attempt)
|| age < TimeSpan.Zero || age > TimeSpan.FromMinutes(5)
|| !callback.IsAbsoluteUri || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0
|| !string.Equals(callback.GetLeftPart(UriPartial.Path), attempt.RedirectUri.AbsoluteUri, StringComparison.Ordinal))
{
throw InvalidResponse();
}
Dictionary<string, string> values = CallbackValues(callback);
if(values.ContainsKey("error") || !values.TryGetValue("state", out string? state) || !string.Equals(state, attempt.State, StringComparison.Ordinal)
|| !values.TryGetValue("code", out string? code) || string.IsNullOrWhiteSpace(code) || code.Length > 4096
|| (values.TryGetValue("iss", out string? issuer) && !string.Equals(issuer, attempt.Issuer, StringComparison.Ordinal)))
{
throw InvalidResponse();
}
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post,
JsonBody(new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow }),
MetadataLimit, null, null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
AdoptManagedSession(response);
}
finally { _gate.Release(); }
}
private QqqAuthenticationMetadata.NativeGrant ValidateNativeGrant(QqqAuthenticationMetadata metadata)
{
_discovery!.Validate(nativeSession: true);
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
if(!string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) || !string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal)
|| grant is null || !string.Equals(grant.Flow, QqqAuthenticationMetadata.NativeSessionFlow, StringComparison.Ordinal)
|| !string.Equals(grant.CodeChallengeMethod, "S256", StringComparison.Ordinal) || !string.Equals(grant.Prompt, "login", StringComparison.Ordinal)
|| !TryHttps(grant.Issuer, out _) || !TryHttps(grant.DiscoveryUrl, out _)
|| string.IsNullOrWhiteSpace(metadata.Values!.ClientId) || metadata.Values.ClientId.Length > 1024
|| string.IsNullOrWhiteSpace(metadata.Values.Scopes) || !metadata.Values.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains("openid", StringComparer.Ordinal)
|| grant.RedirectUris is null || grant.RedirectUris.Count is < 1 or > 32
|| grant.RedirectUris.Any(value => !Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) || !ValidRedirect(uri)))
{
throw QqqApiDiscovery.Configuration();
}
return grant;
}
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);
private static bool SafeConfigurationUri(Uri uri) => uri.IsAbsoluteUri && uri.UserInfo.Length == 0 && uri.Query.Length == 0 && uri.Fragment.Length == 0
&& uri.Port != 0 && uri.AbsoluteUri.Length <= 2048 && !uri.OriginalString.Contains('*', StringComparison.Ordinal) && !uri.OriginalString.Contains('\\', StringComparison.Ordinal);
// Mirror the central native-session grant: HTTPS app links or explicit dotted private schemes; no inferred loopback exception.
private static bool ValidRedirect(Uri uri) => SafeConfigurationUri(uri) && uri.AbsolutePath.Length != 0
&& (string.Equals(uri.Scheme, "https", StringComparison.Ordinal)
|| (!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) && uri.Scheme.Contains('.', StringComparison.Ordinal)));
private static Dictionary<string, string> CallbackValues(Uri callback)
{
if(callback.Query.Length is < 2 or > 16_384)
{ throw InvalidResponse(); }
var values = new Dictionary<string, string>(StringComparer.Ordinal);
foreach(string entry in callback.Query[1..].Split('&'))
{
string[] pair = entry.Split('=', 2);
if(pair.Length != 2)
{ throw InvalidResponse(); }
string name = Uri.UnescapeDataString(pair[0].Replace('+', ' '));
string value = Uri.UnescapeDataString(pair[1].Replace('+', ' '));
if(name.Any(char.IsControl) || value.Any(char.IsControl) || !values.TryAdd(name, value))
{ throw InvalidResponse(); }
}
return values;
}
private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -0,0 +1,530 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>Origin-bound QQQ session transport. Provider token refresh remains entirely on the QQQ server.</summary>
public sealed partial class QqqSessionClient : IQqqSessionClient
{
public const string ClientChannel = "api/qqq-print-agent";
public const string PrintingPath = "/qqq/v1/qbit/printing/";
private const int MetadataLimit = 65_536;
private const int PrintingLimit = 22 * 1024 * 1024;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = false };
private readonly HttpClient _http;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _gate = new(1, 1);
private CookieContainer _cookies = new();
private QqqApiDiscovery? _discovery;
private QqqAuthenticationMetadata? _authentication;
private string? _sessionToken;
private QqqDeviceCredential? _device;
private QqqSessionBinding? _binding;
private QqqBrowserLogin? _browserLogin;
private long _generation;
public QqqSessionClient(Uri origin, HttpMessageHandler? handler = null, TimeProvider? timeProvider = null, bool allowLoopbackHttp = false)
{
ArgumentNullException.ThrowIfNull(origin);
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)
&& !(allowLoopbackHttp && string.Equals(origin.Scheme, "http", StringComparison.Ordinal) && origin.IsLoopback)))
{
throw new ArgumentException("Use an HTTPS server origin, or explicitly enabled loopback HTTP.", nameof(origin));
}
Origin = new Uri(origin.GetLeftPart(UriPartial.Authority) + "/", UriKind.Absolute);
_timeProvider = timeProvider ?? TimeProvider.System;
_http = new HttpClient(handler ?? new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false })
{
Timeout = Timeout.InfiniteTimeSpan,
};
}
public Uri Origin { get; }
public bool IsAuthenticated => _sessionToken is not null;
public async Task<QqqApiDiscovery> DiscoverAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{ return await DiscoverCoreAsync(cancellationToken).ConfigureAwait(false); }
finally { _gate.Release(); }
}
public async Task<QqqAuthenticationMetadata> GetAuthenticationMetadataAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{ return await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false); }
finally { _gate.Release(); }
}
public async Task SignInWithPasswordAsync(string username, string password, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(username);
ArgumentException.ThrowIfNullOrWhiteSpace(password);
if(username.Length > 1024 || username.Contains(':', StringComparison.Ordinal) || username.Any(char.IsControl))
{
throw new ArgumentException("Invalid sign-in input.", nameof(username));
}
if(password.Length > 4096 || password.Any(character => character is '\r' or '\n'))
{
throw new ArgumentException("Invalid sign-in input.", nameof(password));
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
QqqAuthenticationMetadata authentication = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
if(!string.Equals(authentication.Type, "TABLE_BASED", StringComparison.Ordinal))
{ throw QqqApiDiscovery.Configuration(); }
ClearSession();
string basic = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + password));
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post, JsonBody(new { }),
MetadataLimit, null, null, basic, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
AdoptManagedSession(response);
}
finally { _gate.Release(); }
}
public async Task RestoreSessionAsync(QqqSessionCredential credential, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(credential);
if(credential.Origin != Origin)
{ throw new ArgumentException("The session belongs to another server.", nameof(credential)); }
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
ClearSession();
SetSession(credential.SessionToken);
await ResumeCoreAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
ClearSession();
throw;
}
finally { _gate.Release(); }
}
public async Task ResumeSessionAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{ await ResumeCoreAsync(cancellationToken).ConfigureAwait(false); }
finally { _gate.Release(); }
}
public async Task SignOutAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if(_sessionToken is not null)
{
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/logout"), HttpMethod.Post, JsonBody(new { }),
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
}
}
finally
{
ClearSession();
_gate.Release();
}
}
public QqqSessionCredential ExportCredential() => new(Origin, _sessionToken ?? throw AuthenticationRequired());
public async Task<QqqSessionBinding> GetSessionIdentityAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{ return await IdentityCoreAsync(_device, cancellationToken).ConfigureAwait(false); }
finally { _gate.Release(); }
}
public async Task<QqqSessionBinding> BindDeviceAsync(QqqDeviceCredential credential, bool claimLegacyDevice = false, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(credential);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
RequireAuthenticated();
if(claimLegacyDevice)
{
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, PrintingPath + "device/bind"), HttpMethod.Post, JsonBody(new { }),
MetadataLimit, CookieHeader(), credential, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
}
QqqSessionBinding identity = await IdentityCoreAsync(credential, cancellationToken).ConfigureAwait(false);
if(!string.Equals(identity.DeviceId, credential.DeviceId, StringComparison.Ordinal))
{ throw InvalidResponse(); }
_device = credential;
_binding = identity;
_generation++;
return identity;
}
finally { _gate.Release(); }
}
/// <summary>Only native printing routes are accepted. Network I/O does not hold the state lock or delay control polling behind printing.</summary>
public async Task<HttpResponseMessage> SendPrintingAsync(HttpMethod method, string relativePath, HttpContent? content = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(method);
Uri uri = PrintingUri(relativePath);
string cookie;
QqqDeviceCredential? device;
long generation;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await DiscoverCoreAsync(cancellationToken).ConfigureAwait(false);
RequireAuthenticated();
device = relativePath.StartsWith("device/", StringComparison.Ordinal) ? _device : null;
if(relativePath.StartsWith("device/", StringComparison.Ordinal) && (device is null || _binding?.HasApprovedDevice != true))
{
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "An approved device binding is required.");
}
if(!relativePath.StartsWith("device/", StringComparison.Ordinal) && !relativePath.StartsWith("admin/", StringComparison.Ordinal)
&& !string.Equals(relativePath, "devices/enroll", StringComparison.Ordinal))
{
throw new ArgumentException("Only native printing operations are accepted.", nameof(relativePath));
}
cookie = CookieHeader();
generation = _generation;
}
finally { _gate.Release(); }
HttpResponseMessage response = await SendCoreAsync(uri, method, content, PrintingLimit, cookie, device, null, cancellationToken).ConfigureAwait(false);
bool acquired = false;
try
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
acquired = true;
if(generation != _generation)
{ throw AuthenticationRequired(); }
CheckAuthorization(response);
string? returned = ReadSessionCookie(response);
if(returned is not null && !string.Equals(returned, _sessionToken, StringComparison.Ordinal))
{ throw InvalidResponse(); }
return response;
}
catch
{
response.Dispose();
throw;
}
finally { if(acquired) { _gate.Release(); } }
}
private async Task<QqqApiDiscovery> DiscoverCoreAsync(CancellationToken cancellationToken)
{
if(_discovery is not null)
{ return _discovery; }
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/version"), HttpMethod.Get, null, MetadataLimit,
null, null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
QqqApiDiscovery discovery = Parse<QqqApiDiscovery>(response);
discovery.Validate();
_discovery = discovery;
return discovery;
}
private async Task<QqqAuthenticationMetadata> AuthenticationCoreAsync(CancellationToken cancellationToken)
{
await DiscoverCoreAsync(cancellationToken).ConfigureAwait(false);
if(_authentication is not null)
{ return _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);
if(!string.Equals(metadata.Type, "TABLE_BASED", StringComparison.Ordinal) && !string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
{
throw QqqApiDiscovery.Configuration();
}
if(string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
{ ValidateNativeGrant(metadata); }
_authentication = metadata;
return metadata;
}
private async Task ResumeCoreAsync(CancellationToken cancellationToken)
{
RequireAuthenticated();
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post, JsonBody(new { }),
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
AdoptManagedSession(response);
if(_binding is not null)
{ await IdentityCoreAsync(_device, cancellationToken).ConfigureAwait(false); }
}
private async Task<QqqSessionBinding> IdentityCoreAsync(QqqDeviceCredential? credential, CancellationToken cancellationToken)
{
RequireAuthenticated();
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, PrintingPath + "session"), HttpMethod.Get, null,
MetadataLimit, CookieHeader(), credential, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
using JsonDocument document = ParseDocument(response);
JsonElement root = document.RootElement;
string principal = IdentityValue(root, "principalId");
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);
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)))
{
ClearSession();
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The server returned a different principal or device binding.");
}
_binding ??= identity;
return identity;
}
private async Task<HttpResponseMessage> SendCoreAsync(Uri uri, HttpMethod method, HttpContent? content, int maximumBytes, string? cookie,
QqqDeviceCredential? device, string? basic, CancellationToken cancellationToken, bool providerDiscovery = false)
{
if(!providerDiscovery && !SameOrigin(uri, Origin))
{ throw new ArgumentException("Cross-origin QQQ requests are forbidden.", nameof(uri)); }
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
using var request = new HttpRequestMessage(method, uri) { Content = content };
request.Headers.CacheControl = new CacheControlHeaderValue { NoStore = true };
if(!providerDiscovery)
{ request.Headers.Add("X-QQQ-Client", ClientChannel); }
if(cookie is not null)
{
request.Headers.Add("Cookie", cookie);
if(method != HttpMethod.Get && method != HttpMethod.Head && method != HttpMethod.Options)
{
request.Headers.Add("Origin", Origin.GetLeftPart(UriPartial.Authority));
}
}
if(basic is not null)
{ request.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); }
if(device is not null)
{
request.Headers.Add("X-QQQ-Device-Id", device.DeviceId);
request.Headers.Add("X-QQQ-Device-Token", device.DeviceToken);
}
HttpResponseMessage response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false);
try
{
if((int)response.StatusCode is >= 300 and < 400)
{
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "Redirected authentication or API requests are refused.");
}
if(response.Content.Headers.ContentLength > maximumBytes)
{ throw InvalidResponse(); }
await using Stream input = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false);
using var output = new MemoryStream();
byte[] buffer = new byte[8192];
int count;
while((count = await input.ReadAsync(buffer, timeout.Token).ConfigureAwait(false)) != 0)
{
if(output.Length + count > maximumBytes)
{ throw InvalidResponse(); }
output.Write(buffer, 0, count);
}
var buffered = new ByteArrayContent(output.ToArray());
foreach(KeyValuePair<string, IEnumerable<string>> header in response.Content.Headers)
{ buffered.Headers.TryAddWithoutValidation(header.Key, header.Value); }
response.Content.Dispose();
response.Content = buffered;
response.RequestMessage = null;
return response;
}
catch
{
response.Dispose();
throw;
}
}
private void AdoptManagedSession(HttpResponseMessage response)
{
using JsonDocument document = ParseDocument(response);
string? body = document.RootElement.TryGetProperty("uuid", out JsonElement value) && value.ValueKind != JsonValueKind.Null
? value.ValueKind == JsonValueKind.String ? value.GetString() : throw InvalidResponse() : null;
string? cookie = ReadSessionCookie(response);
if(body is not null && cookie is not null && !string.Equals(body, cookie, StringComparison.Ordinal))
{ ClearSession(); throw InvalidResponse(); }
string token = cookie ?? body ?? _sessionToken ?? throw InvalidResponse();
try
{ SetSession(new QqqSessionCredential(Origin, token).SessionToken); }
catch(ArgumentException) { ClearSession(); throw InvalidResponse(); }
}
private string? ReadSessionCookie(HttpResponseMessage response)
{
if(!response.Headers.TryGetValues("Set-Cookie", out IEnumerable<string>? headers))
{ return null; }
string? token = null;
foreach(string header in headers)
{
if(header.Length > 8192)
{ throw InvalidResponse(); }
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)
{
ClearSession();
throw AuthenticationRequired();
}
foreach(Cookie cookie in parsed.GetAllCookies())
{
if(cookie.Name is not "sessionUUID" and not "sessionId")
{ continue; }
if(!string.Equals(cookie.Domain.TrimStart('.'), Origin.Host, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(cookie.Path, "/", StringComparison.Ordinal) || (token is not null && !string.Equals(token, cookie.Value, StringComparison.Ordinal)))
{
throw InvalidResponse();
}
if(cookie.Expired || string.IsNullOrEmpty(cookie.Value))
{ ClearSession(); throw AuthenticationRequired(); }
token = cookie.Value;
}
}
return token;
}
private void SetSession(string token)
{
if(!string.Equals(_sessionToken, token, StringComparison.Ordinal))
{ _generation++; }
_sessionToken = token;
_cookies = new CookieContainer();
_cookies.Add(Origin, new Cookie("sessionUUID", token, "/") { HttpOnly = true, Secure = string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) });
_cookies.Add(Origin, new Cookie("sessionId", token, "/") { HttpOnly = true, Secure = string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) });
}
private void ClearSession()
{
_sessionToken = null;
_cookies = new CookieContainer();
_device = null;
_binding = null;
_browserLogin = null;
_generation++;
}
private string CookieHeader() => _cookies.GetCookieHeader(Origin);
private void RequireAuthenticated() { if(_sessionToken is null) { throw AuthenticationRequired(); } }
private static QqqSessionException AuthenticationRequired() => new(QqqSessionException.FailureKind.AuthenticationRequired, "Sign in to QQQ to continue.");
private static QqqSessionException InvalidResponse() => new(QqqSessionException.FailureKind.InvalidResponse, "The server returned an invalid authentication or API response.");
private void CheckAuthorization(HttpResponseMessage response)
{
if(response.StatusCode == HttpStatusCode.Unauthorized)
{ ClearSession(); throw AuthenticationRequired(); }
if(response.StatusCode == HttpStatusCode.Forbidden)
{
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The current QQQ user is not permitted to perform this operation.");
}
}
private void RequireSuccess(HttpResponseMessage response)
{
CheckAuthorization(response);
if(!response.IsSuccessStatusCode)
{
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "The QQQ request did not succeed.");
}
}
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;
}
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;
private static StringContent JsonBody<T>(T body) => new(JsonSerializer.Serialize(body, JsonOptions), Encoding.UTF8, "application/json");
private static T Parse<T>(HttpResponseMessage response)
{
using JsonDocument document = ParseDocument(response);
try
{ return document.RootElement.Deserialize<T>(JsonOptions) ?? throw InvalidResponse(); }
catch(JsonException) { throw InvalidResponse(); }
}
private static JsonDocument ParseDocument(HttpResponseMessage response)
{
try
{
using Stream stream = response.Content.ReadAsStream();
var document = JsonDocument.Parse(stream, new JsonDocumentOptions { MaxDepth = 16 });
try
{
if(document.RootElement.ValueKind != JsonValueKind.Object)
{ throw InvalidResponse(); }
RejectDuplicateProperties(document.RootElement);
return document;
}
catch { document.Dispose(); throw; }
}
catch(JsonException) { throw InvalidResponse(); }
}
private static void RejectDuplicateProperties(JsonElement element)
{
if(element.ValueKind == JsonValueKind.Object)
{
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach(JsonProperty property in element.EnumerateObject())
{
if(!names.Add(property.Name))
{ throw InvalidResponse(); }
RejectDuplicateProperties(property.Value);
}
}
else if(element.ValueKind == JsonValueKind.Array)
{
foreach(JsonElement child in element.EnumerateArray())
{ RejectDuplicateProperties(child); }
}
}
private static string IdentityValue(JsonElement root, string name)
{
if(!root.TryGetProperty(name, out JsonElement value) || value.ValueKind != JsonValueKind.String
|| string.IsNullOrWhiteSpace(value.GetString()) || value.GetString()!.Length > 1024 || value.GetString()!.Any(char.IsControl))
{
throw InvalidResponse();
}
return value.GetString()!;
}
public void Dispose()
{
ClearSession();
_http.Dispose();
_gate.Dispose();
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>An opaque QQQ session for protected storage by its host. It contains no provider refresh token.</summary>
public sealed class QqqSessionCredential
{
public QqqSessionCredential(Uri origin, string sessionToken)
{
ArgumentNullException.ThrowIfNull(origin);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionToken);
if(sessionToken.Length > 4096 || sessionToken.Any(character => character < 33 || character > 126 || ";,\"\\\\".Contains(character, StringComparison.Ordinal)))
{
throw new ArgumentException("Invalid QQQ session credential.", nameof(sessionToken));
}
Origin = origin;
SessionToken = sessionToken;
}
public Uri Origin { get; }
public string SessionToken { get; }
public override string ToString() => "QQQ session credential (redacted)";
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>A sanitized failure classification; response bodies and credentials are never included.</summary>
public sealed class QqqSessionException(QqqSessionException.FailureKind failure, string message) : IOException(message)
{
public enum FailureKind
{
ConfigurationRequired,
InvalidResponse,
AuthenticationRequired,
PermissionDenied,
TransportRefused,
}
public FailureKind Failure { get; } = failure;
}
@@ -0,0 +1,3 @@
namespace Qqq.PrintAgent.Core.Printing;
public sealed record DeviceControlCommand(string CommandId, string RequestId, string Action, string CreatedAt, string ExpiresAt, string? JobId = null);
@@ -0,0 +1,3 @@
namespace Qqq.PrintAgent.Core.Printing;
public sealed record DeviceControlPoll(int ControlVersion, string ServerTime, int PollIntervalSeconds, DeviceDesiredState DesiredState, IReadOnlyList<DeviceControlCommand> Commands);
@@ -0,0 +1,3 @@
namespace Qqq.PrintAgent.Core.Printing;
public sealed record DeviceControlReceipt(string ReceiptId, string Outcome, string CompletedAt, string? Message = null);
@@ -0,0 +1,5 @@
using System.Text.Json.Serialization;
namespace Qqq.PrintAgent.Core.Printing;
public sealed record DeviceDesiredState([property: JsonRequired] long Revision, [property: JsonRequired] bool DispatchPaused, [property: JsonRequired] string ConnectionState);
@@ -0,0 +1,79 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace Qqq.PrintAgent.Core.Printing;
public sealed partial class PrintApiClient
{
private static readonly string[] ControlCapabilities = ["REFRESH_INVENTORY", "CANCEL_SPOOL"];
/// <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;
}
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 ValidControlId(string? value) => !string.IsNullOrEmpty(value) && value.Length <= 200 && value.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_' or '.' or ':');
public async Task ControlReceiptAsync(string commandId, DeviceControlReceipt receipt, CancellationToken token)
{
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.");
}
}
}
@@ -0,0 +1,31 @@
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Core.Printing;
public sealed partial class PrintApiClient
{
private bool _nativeSession;
/// <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._http.BaseAddress = new Uri(session.Origin, "/qqq/v1/qbit/printing/");
return client;
}
private sealed class SessionHandler(IQqqSessionClient session) : HttpMessageHandler
{
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);
}
}
}
@@ -9,7 +9,7 @@ using System.Text.Json;
namespace Qqq.PrintAgent.Core.Printing;
public sealed class PrintApiClient : IDisposable
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;
@@ -34,7 +34,7 @@ public sealed class PrintApiClient : IDisposable
public async Task<EnrollmentResponse> EnrollAsync(EnrollmentRequest request, CancellationToken token = default)
{
using HttpResponseMessage response = await _http.PostAsJsonAsync("enroll", request, JsonOptions, token).ConfigureAwait(false);
using HttpResponseMessage response = await _http.PostAsJsonAsync(_nativeSession ? "devices/enroll" : "enroll", request, JsonOptions, token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
EnrollmentResponse result = await response.Content.ReadFromJsonAsync<EnrollmentResponse>(token).ConfigureAwait(false)
?? throw new InvalidDataException("Missing enrollment response.");
@@ -6,7 +6,8 @@
namespace Qqq.PrintAgent.Core.Printing;
/// <summary>Serial happy-path worker. A locally ambiguous submission is never automatically printed again.</summary>
public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJobJournal journal, Action<string>? log = null)
public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJobJournal journal, Action<string>? log = null,
Func<bool>? canDispatch = null, Func<bool>? canSynchronize = null)
{
public async Task<bool> RunOnceAsync(CancellationToken token = default)
{
@@ -17,6 +18,10 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
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)
{
return false;
}
await api.HeartbeatAsync(token).ConfigureAwait(false);
if(journal.Pending().Count > 0)
{
@@ -24,13 +29,25 @@ public sealed class PrintWorker(PrintApiClient api, IPrinterBackend printers, IJ
return false;
}
IReadOnlyList<PrinterInfo> inventory = printers.Discover();
if(canSynchronize?.Invoke() == false)
{
return false;
}
await api.InventoryAsync(inventory, token).ConfigureAwait(false);
if(canDispatch?.Invoke() == false)
{
return false;
}
PrintJob? job = await api.ClaimAsync(token).ConfigureAwait(false);
if(job is null)
{
return false;
}
return await ProcessJobAsync(job, inventory, token).ConfigureAwait(false);
}
private async Task<bool> ProcessJobAsync(PrintJob job, IReadOnlyList<PrinterInfo> inventory, 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);
@@ -0,0 +1,12 @@
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Windows;
/// <summary>Protected service state. ToString never includes the session secret.</summary>
public sealed class AgentAuthentication(QqqSessionBinding binding, QqqSessionCredential? credential)
{
public QqqSessionBinding Binding { get; } = binding;
public QqqSessionCredential? Credential { get; } = credential;
public override string ToString() => "Protected QQQ authorization state";
}
@@ -0,0 +1,4 @@
namespace Qqq.PrintAgent.Windows;
/// <summary>Safe companion status, with no cookie, password or device proof.</summary>
public sealed record AgentAuthenticationStatus(bool SignedIn, string? PrincipalId, string? TenantId, string Message, bool Authorized = false);
@@ -0,0 +1,31 @@
namespace Qqq.PrintAgent.Windows;
public static class AgentControlLabels
{
public static string Support(string value) => value switch
{
"SUPPORTED" => "available",
"LEGACY" => "unavailable on this server",
_ => "checking support",
};
public static string Action(string value) => value switch
{
"REFRESH_INVENTORY" => "Refresh printers",
"CANCEL_SPOOL" => "Cancel one spooler job",
_ => "Unknown action",
};
public static string Outcome(string? value) => value switch
{
"INVENTORY_REFRESHED" => "Printers refreshed",
"CANCEL_REQUESTED" => "Cancellation requested; printed pages cannot be recalled",
"ALREADY_ABSENT" => "Already absent or completed",
"OWNERSHIP_MISMATCH" => "Refused: spooler ownership was not verified",
"UNSUPPORTED" => "Unsupported action",
"FAILED" => "Failed; inspect status",
"EXPIRED" => "Expired without execution",
"OUTCOME_UNKNOWN" => "Outcome unknown; inspect output before retrying",
_ => "No result recorded",
};
}
@@ -0,0 +1,9 @@
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
public sealed record AgentControlState(string Support, DeviceDesiredState DesiredState, bool AuthorizationRequired = false,
string? LastContact = null, string? LastCommand = null, string? LastOutcome = null, string? LastCommandAt = null)
{
public static AgentControlState Initial => new("UNKNOWN", new DeviceDesiredState(0, false, "CONNECTED"));
}
@@ -17,7 +17,7 @@ public static class AgentPipeClient
internal static async Task<string> SendAsync(PipeRequest request, string pipeName)
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(string.Equals(request.Operation, "enroll", StringComparison.Ordinal) ? 150 : 35));
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(request.Operation is "enroll" or "sign-in" or "sign-out" ? 150 : 35));
await using NamedPipeClientStream pipe = CreateClient(pipeName);
using var connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token);
connectTimeout.CancelAfter(TimeSpan.FromSeconds(3));
@@ -0,0 +1,115 @@
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentRuntime
{
private readonly SemaphoreSlim _authenticationGate = new(1, 1);
private IQqqSessionClient? _sessionClient;
private QqqSessionBinding? _sessionBinding;
private DateTimeOffset _nextSessionResume;
private bool _authorizationReady;
private bool _authorizationBlocked;
private string _authenticationMessage = "Sign in to QQQ to enable printing. Existing pairing and history are preserved.";
internal TimeSpan SessionResumeInterval { get; init; } = TimeSpan.FromMinutes(5);
private IQqqSessionClient NewSession(Uri origin) => sessionFactory?.Invoke(origin)
?? new QqqSessionClient(origin, allowLoopbackHttp: origin.IsLoopback);
private async Task<bool> EnsureAuthorizationAsync(CancellationToken token)
{
if(apiFactory is not null)
{
return true;
}
if(_authorizationBlocked)
{
return false;
}
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
try
{
using AgentStore store = new(directory);
AgentAuthentication? saved = store.LoadAuthentication();
AgentConfiguration? configuration = store.LoadConfiguration();
if(saved?.Credential is null || (configuration is not null && !SameOrigin(saved.Binding.Origin, configuration.ServerUrl)))
{
return false;
}
if(_sessionClient is null)
{
IQqqSessionClient restored = NewSession(saved.Credential.Origin);
try
{
await restored.RestoreSessionAsync(saved.Credential, token).ConfigureAwait(false);
QqqSessionBinding binding = configuration is null ? await restored.GetSessionIdentityAsync(token).ConfigureAwait(false)
: await restored.BindDeviceAsync(new QqqDeviceCredential(configuration.DeviceId, configuration.DeviceToken), cancellationToken: token).ConfigureAwait(false);
RequireSameIdentity(saved.Binding, binding);
ActivateSession(restored, binding, store);
}
catch { restored.Dispose(); throw; }
}
else if(!_sessionClient.IsAuthenticated)
{
BlockAuthorization();
return false;
}
else if(DateTimeOffset.UtcNow >= _nextSessionResume)
{
await _sessionClient.ResumeSessionAsync(token).ConfigureAwait(false);
QqqSessionBinding binding = await _sessionClient.GetSessionIdentityAsync(token).ConfigureAwait(false);
RequireSameIdentity(saved.Binding, binding);
ActivateSession(_sessionClient, binding, store);
}
return _authorizationReady;
}
catch(QqqSessionException exception) when(exception.Failure is QqqSessionException.FailureKind.AuthenticationRequired or QqqSessionException.FailureKind.PermissionDenied)
{
BlockAuthorization();
return false;
}
finally { _authenticationGate.Release(); }
}
private void ActivateSession(IQqqSessionClient client, QqqSessionBinding binding, AgentStore store)
{
store.SaveAuthentication(new AgentAuthentication(binding, client.ExportCredential()));
AgentConfiguration? configuration = store.LoadConfiguration();
lock(_stateLock)
{
_sessionClient = client;
_sessionBinding = binding;
_authorizationReady = configuration is not null && SameOrigin(binding.Origin, configuration.ServerUrl)
&& string.Equals(binding.DeviceId, configuration.DeviceId, StringComparison.Ordinal);
_authorizationBlocked = false;
_nextSessionResume = DateTimeOffset.UtcNow + SessionResumeInterval;
_authenticationMessage = _authorizationReady ? "Signed in to QQQ." : "Signed in. Enroll this computer to enable printing.";
}
}
private void BlockAuthorization()
{
lock(_stateLock)
{
_authorizationReady = false;
_authorizationBlocked = true;
_controlReady = false;
_authenticationMessage = "QQQ authorization is required. Sign in again; pairing and history are preserved.";
}
}
private static bool SameOrigin(Uri origin, string address) => Uri.TryCreate(address, UriKind.Absolute, out Uri? configured)
&& string.Equals(origin.GetLeftPart(UriPartial.Authority), configured.GetLeftPart(UriPartial.Authority), StringComparison.OrdinalIgnoreCase);
private static void RequireSameIdentity(QqqSessionBinding expected, QqqSessionBinding actual)
{
if(!string.Equals(expected.IdentityKey, actual.IdentityKey, StringComparison.Ordinal))
{
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The QQQ user, tenant or device changed. Explicit connection setup is required.");
}
}
private AgentAuthenticationStatus AuthenticationStatus() => new(_sessionClient?.IsAuthenticated == true,
_sessionBinding?.PrincipalId, _sessionBinding?.TenantId, _authenticationMessage, _authorizationReady);
}
@@ -0,0 +1,99 @@
using System.Globalization;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentRuntime
{
private async Task ApplyControlCommandAsync(AgentStore store, PrintApiClient api, DeviceControlCommand command, DateTimeOffset serverTime, CancellationToken token)
{
bool expired = DateTimeOffset.Parse(command.ExpiresAt, CultureInfo.InvariantCulture) <= serverTime;
if(!expired && string.Equals(command.Action, "REFRESH_INVENTORY", StringComparison.Ordinal) && !NormalTrafficAllowed)
{
return;
}
bool first = store.BeginControlCommand(command);
DeviceControlReceipt? existing = store.GetControlReceipt(command.CommandId);
if(existing is not null)
{
// Replay the exact receipt, even if its previous acknowledgement was lost.
await DeliverControlReceiptAsync(store, api, command.CommandId, existing, token).ConfigureAwait(false);
return;
}
(string outcome, string message) = !first
? ("OUTCOME_UNKNOWN", "The agent stopped after recording execution intent. No command was automatically repeated.")
: expired ? ("EXPIRED", "The command expired before execution.")
: await ExecuteControlCommandAsync(store, api, command, token).ConfigureAwait(false);
message = AgentActivityLog.Redact(message);
DeviceControlReceipt receipt = new(Guid.NewGuid().ToString("N"), outcome, Now(), message.Length <= 1024 ? message : message[..1024]);
store.SaveControlReceipt(command.CommandId, receipt);
AgentControlState state = store.LoadControlState() with { LastCommand = command.Action, LastOutcome = outcome, LastCommandAt = receipt.CompletedAt };
SetControlState(store, state);
}
private async Task<(string Outcome, string Message)> ExecuteControlCommandAsync(AgentStore store, PrintApiClient api, DeviceControlCommand command, CancellationToken token)
{
try
{
switch(command.Action)
{
case "REFRESH_INVENTORY":
ExposedPrinterBackend exposed = new(new ProfiledPrinterBackend(_printers, store), store.GetExposedPrinterIds);
await api.InventoryAsync(exposed.Discover(), token).ConfigureAwait(false);
return ("INVENTORY_REFRESHED", "The current exposed printer inventory was synchronized.");
case "CANCEL_SPOOL":
return await CancelRemoteSpoolAsync(store, command.JobId!, token).ConfigureAwait(false);
default:
return ("UNSUPPORTED", "This agent does not implement the requested control action.");
}
}
catch(SpoolCancellationException exception)
{
return (exception.Outcome, exception.Message);
}
catch(OperationCanceledException)
{
return ("OUTCOME_UNKNOWN", "Execution was interrupted. No command will be automatically repeated; inspect the printer.");
}
catch(TimeoutException)
{
return ("OUTCOME_UNKNOWN", "The printer did not respond before the deadline. No command will be automatically repeated; inspect its output.");
}
catch(IOException) when(string.Equals(command.Action, "CANCEL_SPOOL", StringComparison.Ordinal))
{
return ("OUTCOME_UNKNOWN", "The isolated spooler result was lost. Cancellation may have occurred; the request will not be repeated.");
}
catch(Exception exception) when(exception is IOException or InvalidOperationException or HttpRequestException or System.ComponentModel.Win32Exception)
{
return ("FAILED", "The requested action could not be confirmed. Inspect local status; pages already printed cannot be recalled.");
}
}
private async Task<(string Outcome, string Message)> CancelRemoteSpoolAsync(AgentStore store, string jobId, CancellationToken token)
{
JobSnapshot? job = store.GetJobDetails(jobId)?.Job;
if(job is null || job.PrinterId is null || job.SpoolerJobId is null)
{
return ("OWNERSHIP_MISMATCH", "No exact committed spooler receipt exists for this job in the current connection. No Windows job was changed.");
}
if(string.Equals(job.Status, "SPOOLER_COMPLETED", StringComparison.Ordinal))
{
return ("ALREADY_ABSENT", "This job was already reported completed; its printed pages cannot be recalled.");
}
if(!string.Equals(job.Status, "SPOOLED", StringComparison.Ordinal))
{
return ("OUTCOME_UNKNOWN", "The job does not have a safely cancelable spooler state. Inspect its output.");
}
if(_printers is not IPrinterJobControl control)
{
return ("UNSUPPORTED", "This printer backend does not support verified spool cancellation.");
}
PrinterInfo? printer = _printers.Discover().SingleOrDefault(value => string.Equals(value.PrinterId, job.PrinterId, StringComparison.Ordinal));
if(printer is null)
{
return ("FAILED", "The recorded printer is unavailable. No other printer queue was changed.");
}
await control.CancelAsync(printer.Name, job.JobId, job.SpoolerJobId, token).ConfigureAwait(false);
return ("CANCEL_REQUESTED", "Windows accepted cancellation of the verified job. Pages already sent or printed cannot be recalled.");
}
}
@@ -0,0 +1,71 @@
namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentRuntime
{
private async Task<string> EnrollWithGatesAsync(PipeRequest request, CancellationToken token)
=> await ConnectionTransitionAsync(() => EnrollAsync(request, token), token).ConfigureAwait(false);
private async Task<string> ConnectionTransitionAsync(Func<Task<string>> action, CancellationToken token)
{
await _connectionGate.WaitAsync(token).ConfigureAwait(false);
try
{
await _controlExecutionGate.WaitAsync(token).ConfigureAwait(false);
try
{ return await action().ConfigureAwait(false); }
finally { _controlExecutionGate.Release(); }
}
finally { _connectionGate.Release(); }
}
private string ReconnectLocal()
{
lock(_stateLock)
{
if(_controlState.AuthorizationRequired)
{
return "Server authorization is required. Open Connection settings to restore authorization; job history is preserved.";
}
if(string.Equals(_controlState.DesiredState.ConnectionState, "DISCONNECTED", StringComparison.Ordinal))
{
return "Disconnected by the administrator. Remote control polling remains active; ask the server administrator to reconnect this device.";
}
}
using AgentStore store = new(directory);
if(store.LoadConfiguration() is null)
{
return "Not enrolled. Open Connection settings to connect this device.";
}
WakePoll();
WriteLog("INFO", "Connection retry requested.");
return "Reconnect requested. Connection status will update after the next server response.";
}
private (string Health, string Status) ControlStatus(string health, string status, bool jobFailure)
{
if(_controlState.AuthorizationRequired)
{
return ("RED", "Server authorization is required. Open Connection settings to restore authorization. Job history is preserved.");
}
if(string.Equals(_controlState.DesiredState.ConnectionState, "DISCONNECTED", StringComparison.Ordinal))
{
return (jobFailure ? "RED" : "ORANGE", "Disconnected by the administrator. Control polling remains active for reconnect; in-flight print receipts are preserved." + ControlSuffix());
}
if(_controlState.DesiredState.DispatchPaused)
{
return (jobFailure ? "RED" : "ORANGE", "Dispatch paused by the administrator. No new jobs will be claimed; in-flight printing continues." + ControlSuffix());
}
if(_controlFailure is not null)
{
return ("ORANGE", status + " " + _controlFailure);
}
return (health, status + ControlSuffix());
}
private string ControlSuffix() => _controlState.Support switch
{
"LEGACY" => " Remote controls are unavailable on this server.",
"UNKNOWN" => " Checking remote control support.",
_ => _controlState.LastCommand is null ? " Remote controls available." : $" Last remote action: {AgentControlLabels.Action(_controlState.LastCommand)} — {AgentControlLabels.Outcome(_controlState.LastOutcome)}.",
};
}
@@ -0,0 +1,208 @@
using System.Diagnostics;
using System.Globalization;
using System.Net;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentRuntime
{
private readonly SemaphoreSlim _controlExecutionGate = new(1, 1);
private AgentControlState _controlState = AgentControlState.Initial;
private bool _controlReady;
private string? _controlFailure;
internal TimeSpan? ControlIntervalOverride { get; init; }
private bool NormalTrafficAllowed
{
get
{
lock(_stateLock)
{
return (apiFactory is not null || _authorizationReady) && _controlReady && !_controlState.AuthorizationRequired && string.Equals(_controlState.DesiredState.ConnectionState, "CONNECTED", StringComparison.Ordinal);
}
}
}
private bool DispatchAllowed
{
get
{
lock(_stateLock)
{
return NormalTrafficAllowed && !_controlState.DesiredState.DispatchPaused;
}
}
}
private async Task ControlLoopAsync(CancellationToken token)
{
while(!token.IsCancellationRequested)
{
int interval = 5;
try
{
interval = await PollControlsOnceAsync(token).ConfigureAwait(false);
}
catch(Exception exception) when(exception is not OperationCanceledException || !token.IsCancellationRequested)
{
lock(_stateLock)
{
_controlFailure = "Remote control could not be synchronized. Existing restrictions remain in effect.";
}
}
await Task.Delay(ControlIntervalOverride ?? TimeSpan.FromSeconds(interval), token).ConfigureAwait(false);
}
}
internal async Task<int> PollControlsOnceAsync(CancellationToken token)
{
if(!await EnsureAuthorizationAsync(token).ConfigureAwait(false))
{
return 5;
}
long generation;
lock(_stateLock)
{
generation = _connectionGeneration;
}
using AgentStore store = new(directory);
AgentConfiguration? configuration = store.LoadConfiguration();
if(configuration is null)
{
return 5;
}
AgentControlState saved = store.LoadControlState();
lock(_stateLock)
{
if(generation != _connectionGeneration)
{
return 5;
}
_controlState = saved;
}
if(saved.AuthorizationRequired)
{
return 30;
}
return await FetchControlPollAsync(store, configuration, saved, generation, token).ConfigureAwait(false);
}
private async Task<int> FetchControlPollAsync(AgentStore store, AgentConfiguration configuration, AgentControlState saved, long generation, CancellationToken token)
{
using PrintApiClient api = CreateApi(configuration);
try
{
DeviceControlPoll? poll = await api.ControlPollAsync(saved.DesiredState.Revision, token).ConfigureAwait(false);
await _controlExecutionGate.WaitAsync(token).ConfigureAwait(false);
try
{
lock(_stateLock)
{
if(generation != _connectionGeneration)
{
return 5;
}
}
return await ApplyControlPollAsync(store, api, saved, poll, token).ConfigureAwait(false);
}
finally { _controlExecutionGate.Release(); }
}
catch(HttpRequestException exception) when(exception.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
lock(_stateLock)
{
if(generation == _connectionGeneration)
{
_controlState = saved with { AuthorizationRequired = true };
store.SaveControlState(_controlState);
_controlReady = false;
}
}
return 30;
}
catch(QqqSessionException exception) when(exception.Failure is QqqSessionException.FailureKind.AuthenticationRequired or QqqSessionException.FailureKind.PermissionDenied)
{
lock(_stateLock)
{
if(generation == _connectionGeneration)
{
BlockAuthorization();
}
}
return 30;
}
}
private async Task<int> ApplyControlPollAsync(AgentStore store, PrintApiClient api, AgentControlState saved, DeviceControlPoll? poll, CancellationToken token)
{
if(poll is null)
{
if(string.Equals(saved.Support, "SUPPORTED", StringComparison.Ordinal))
{
throw new InvalidDataException("Negotiated device control is no longer available; restrictions were retained.");
}
SetControlState(store, saved with { Support = "LEGACY", LastContact = Now() });
return 30;
}
if(poll.DesiredState.Revision < saved.DesiredState.Revision || (poll.DesiredState.Revision == saved.DesiredState.Revision
&& string.Equals(saved.Support, "SUPPORTED", StringComparison.Ordinal) && poll.DesiredState != saved.DesiredState))
{
throw new InvalidDataException("Device control revision regressed or changed without a new revision.");
}
SetControlState(store, saved with { Support = "SUPPORTED", DesiredState = poll.DesiredState, LastContact = Now() });
long received = Stopwatch.GetTimestamp();
store.RecoverInterruptedControlCommands();
await FlushControlReceiptsAsync(store, api, token).ConfigureAwait(false);
foreach(DeviceControlCommand command in poll.Commands)
{
await ApplyControlCommandAsync(store, api, command, DateTimeOffset.Parse(poll.ServerTime, CultureInfo.InvariantCulture) + Stopwatch.GetElapsedTime(received), token).ConfigureAwait(false);
}
await FlushControlReceiptsAsync(store, api, token).ConfigureAwait(false);
return poll.PollIntervalSeconds;
}
private void SetControlState(AgentStore store, AgentControlState value)
{
store.SaveControlState(value);
bool wake;
lock(_stateLock)
{
wake = (_pollStarted && !_controlReady) || value.DesiredState != _controlState.DesiredState;
_controlState = value;
_controlReady = true;
_controlFailure = null;
}
if(wake)
{
WakePoll();
}
}
private static async Task FlushControlReceiptsAsync(AgentStore store, PrintApiClient api, CancellationToken token)
{
foreach((string commandId, DeviceControlReceipt receipt) in store.PendingControlReceipts())
{
await DeliverControlReceiptAsync(store, api, commandId, receipt, token).ConfigureAwait(false);
}
}
private static async Task DeliverControlReceiptAsync(AgentStore store, PrintApiClient api, string commandId, DeviceControlReceipt receipt, CancellationToken token)
{
try
{
await api.ControlReceiptAsync(commandId, receipt, token).ConfigureAwait(false);
store.AcknowledgeControlReceipt(commandId);
}
catch(HttpRequestException exception) when(exception.StatusCode == HttpStatusCode.NotFound)
{
if(!store.RetireExpiredControlReceipt(commandId))
{
throw;
}
}
}
private static string Now() => DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,39 @@
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentRuntime
{
private async Task<string> EnrollNativeAsync(PipeRequest request, CancellationToken token)
{
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
try
{
IQqqSessionClient session = _sessionClient ?? throw new InvalidOperationException("Sign in to QQQ before enrolling this computer.");
if(!session.IsAuthenticated || request.ServerUrl is null || !SameOrigin(session.Origin, request.ServerUrl))
{
throw new InvalidOperationException("Sign in to the selected QQQ server before enrollment.");
}
QqqSessionBinding identity = await session.GetSessionIdentityAsync(token).ConfigureAwait(false);
using var api = PrintApiClient.ForSession(session);
EnrollmentResponse response = await api.EnrollAsync(new EnrollmentRequest(request.EnrollmentCode ?? "", Environment.MachineName, PrintApiClient.AgentVersion), token).ConfigureAwait(false);
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
store.SaveConfiguration(new AgentConfiguration(request.ServerUrl, response.DeviceId, response.DeviceToken, response.PollIntervalSeconds));
QqqSessionBinding expected = identity with { DeviceId = response.DeviceId };
store.SaveAuthentication(new AgentAuthentication(expected, session.ExportCredential()));
ResetConnectionState();
QqqSessionBinding actual = await session.BindDeviceAsync(new QqqDeviceCredential(response.DeviceId, response.DeviceToken), cancellationToken: token).ConfigureAwait(false);
RequireSameIdentity(expected, actual);
ActivateSession(session, actual, store);
lock(_stateLock)
{
_queuedJobs = [];
}
WriteLog("INFO", "Device enrolled with the QQQ user session.");
WakePoll();
return $"Enrolled device {response.DeviceId}";
}
finally { _authenticationGate.Release(); }
}
}
@@ -22,6 +22,9 @@ public sealed partial class AgentRuntime
_lastSuccessfulPoll = null;
_failure = null;
_heartbeatFailure = null;
_controlState = AgentControlState.Initial;
_controlReady = false;
_controlFailure = null;
}
}
@@ -39,7 +42,7 @@ public sealed partial class AgentRuntime
{
using AgentStore store = new(directory);
AgentConfiguration? configuration = store.LoadConfiguration();
if(configuration is not null)
if(configuration is not null && NormalTrafficAllowed)
{
using PrintApiClient api = CreateApi(configuration);
await api.HeartbeatAsync(store.GetDeviceProfile(), token).ConfigureAwait(false);
@@ -42,6 +42,15 @@ public sealed partial class AgentRuntime
return "Cancellation requested from Windows. Pages already sent to the printer may still print; inspect the output.";
}
}
return await SendOperatorActionAsync(store, request, token).ConfigureAwait(false);
}
private async Task<string> SendOperatorActionAsync(AgentStore store, PipeRequest request, CancellationToken token)
{
if(!NormalTrafficAllowed)
{
throw new InvalidOperationException("Server actions are unavailable while disconnected by the administrator or awaiting authorization/control negotiation. Local history is preserved.");
}
AgentConfiguration configuration = store.LoadConfiguration() ?? throw new InvalidOperationException("Connect this device in Settings first.");
if(string.Equals(request.Operation, "test-print", StringComparison.Ordinal))
{
@@ -0,0 +1,85 @@
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentRuntime
{
internal async Task<string> SignInAsync(PipeRequest request, CancellationToken token)
=> await ConnectionTransitionAsync(() => SignInCoreAsync(request, token), token).ConfigureAwait(false);
private async Task<string> SignInCoreAsync(PipeRequest request, CancellationToken token)
{
QqqSessionCredential credential = request.SessionCredential ?? throw new InvalidOperationException("A QQQ sign-in session is required.");
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
IQqqSessionClient? candidate = null;
try
{
candidate = NewSession(credential.Origin);
await candidate.RestoreSessionAsync(credential, token).ConfigureAwait(false);
using AgentStore store = new(directory);
AgentConfiguration? configuration = store.LoadConfiguration();
QqqSessionBinding binding = await candidate.GetSessionIdentityAsync(token).ConfigureAwait(false);
AgentAuthentication? saved = store.LoadAuthentication();
if(configuration is not null && SameOrigin(credential.Origin, configuration.ServerUrl))
{
binding = await candidate.BindDeviceAsync(new QqqDeviceCredential(configuration.DeviceId, configuration.DeviceToken), request.ClaimLegacyDevice, token).ConfigureAwait(false);
if(saved?.Binding.DeviceId is not null && SameOrigin(saved.Binding.Origin, configuration.ServerUrl))
{
RequireSameIdentity(saved.Binding, binding);
}
store.SaveControlState(store.LoadControlState() with { AuthorizationRequired = false });
}
IQqqSessionClient? previous = _sessionClient;
ResetConnectionState();
ActivateSession(candidate, binding, store);
previous?.Dispose();
WakePoll();
return "Signed in to QQQ. " + _authenticationMessage;
}
catch
{
candidate?.Dispose();
throw;
}
finally { _authenticationGate.Release(); }
}
internal async Task<string> SignOutAsync(CancellationToken token)
=> await ConnectionTransitionAsync(() => SignOutCoreAsync(token), token).ConfigureAwait(false);
private async Task<string> SignOutCoreAsync(CancellationToken token)
{
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
try
{
BlockAuthorization();
using AgentStore store = new(directory);
AgentAuthentication? saved = store.LoadAuthentication();
if(saved is not null)
{
store.SaveAuthentication(new AgentAuthentication(saved.Binding, null));
}
try
{
if(_sessionClient is null && saved?.Credential is not null)
{
_sessionClient = NewSession(saved.Credential.Origin);
await _sessionClient.RestoreSessionAsync(saved.Credential, token).ConfigureAwait(false);
}
if(_sessionClient is not null)
{
await _sessionClient.SignOutAsync(token).ConfigureAwait(false);
}
}
finally
{
_sessionClient?.Dispose();
_sessionClient = null;
ResetConnectionState();
}
return "Signed out. Device pairing and print history were preserved.";
}
finally { _authenticationGate.Release(); }
}
}
+42 -34
View File
@@ -8,13 +8,15 @@ using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using KofTwentyTwo.AppKit.Logging;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
/// <summary>Hosts outbound printing and the local administrator management pipe.</summary>
public sealed partial class AgentRuntime(string directory, Action<string> log, IPrinterBackend? printerBackend = null,
Func<AgentConfiguration, PrintApiClient>? apiFactory = null, string pipeName = "QqqPrintAgent", Func<NamedPipeServerStream>? pipeFactory = null) : IDisposable
Func<AgentConfiguration, PrintApiClient>? apiFactory = null, string pipeName = "QqqPrintAgent", Func<NamedPipeServerStream>? pipeFactory = null,
Func<Uri, IQqqSessionClient>? sessionFactory = null) : IDisposable
{
private readonly Lock _stateLock = new();
private readonly IPrinterBackend _printers = printerBackend ?? new WindowsPrinters();
@@ -28,12 +30,16 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
private IReadOnlyList<JobSnapshot> _queuedJobs = [];
private long _queueGeneration;
private DateTimeOffset _nextHistoryMaintenance;
private bool _pollStarted;
/// <summary>No persistent database handles are held by the runtime.</summary>
public void Dispose()
{
_pollRequested.Dispose();
_connectionGate.Dispose();
_controlExecutionGate.Dispose();
_authenticationGate.Dispose();
_sessionClient?.Dispose();
}
@@ -45,18 +51,23 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
using FileStream instance = new(Path.Combine(directory, "worker.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
WriteLog("INFO", "Print service started.");
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(token);
Task control = ControlLoopAsync(lifetime.Token);
Task poll = PollAsync(lifetime.Token);
Task pipe = ServePipeAsync(lifetime.Token);
Task heartbeat = HeartbeatAsync(lifetime.Token);
await Task.WhenAny(poll, pipe, heartbeat).ConfigureAwait(false);
await Task.WhenAny(poll, pipe, heartbeat, control).ConfigureAwait(false);
await lifetime.CancelAsync().ConfigureAwait(false);
await Task.WhenAll(poll, pipe, heartbeat).ConfigureAwait(false);
await Task.WhenAll(poll, pipe, heartbeat, control).ConfigureAwait(false);
}
private async Task PollAsync(CancellationToken token)
{
lock(_stateLock)
{
_pollStarted = true;
}
while(!token.IsCancellationRequested)
{
bool processed = false;
@@ -95,10 +106,17 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
private async Task<bool> PollOnceAsync(CancellationToken token)
{
if(UpdatePaused)
if(UpdatePaused || (apiFactory is null && !_authorizationReady))
{
return false;
}
lock(_stateLock)
{
if(_controlState.AuthorizationRequired)
{
return false;
}
}
using AgentStore store = new(directory);
AgentConfiguration? configuration = store.LoadConfiguration();
if(configuration is null)
@@ -110,7 +128,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
{
queueGeneration = _queueGeneration;
}
IReadOnlyList<PrintJob> queued = await api.QueuedJobsAsync(token).ConfigureAwait(false);
IReadOnlyList<PrintJob> queued = NormalTrafficAllowed ? await api.QueuedJobsAsync(token).ConfigureAwait(false) : [];
lock(_stateLock)
{
if(queueGeneration == _queueGeneration)
@@ -119,7 +137,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
}
}
ExposedPrinterBackend exposed = new(new ProfiledPrinterBackend(_printers, store), store.GetExposedPrinterIds);
bool processed = await new PrintWorker(api, exposed, store, message => WriteLog("INFO", message)).RunOnceAsync(token).ConfigureAwait(false);
bool processed = await new PrintWorker(api, exposed, store, message => WriteLog("INFO", message), () => DispatchAllowed, () => NormalTrafficAllowed).RunOnceAsync(token).ConfigureAwait(false);
if(DateTimeOffset.UtcNow >= _nextHistoryMaintenance)
{
_ = store.PruneHistory();
@@ -136,7 +154,8 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
return processed;
}
private PrintApiClient CreateApi(AgentConfiguration configuration) => apiFactory?.Invoke(configuration) ?? new PrintApiClient(configuration.ServerUrl, configuration.DeviceToken);
private PrintApiClient CreateApi(AgentConfiguration configuration) => apiFactory?.Invoke(configuration)
?? PrintApiClient.ForSession(_sessionClient ?? throw new InvalidOperationException("Sign in to QQQ before printing."));
private void WakePoll()
{
@@ -172,7 +191,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
}).ToArray();
jobs = jobs.Concat(_queuedJobs.Where(queued => !jobs.Any(local => string.Equals(local.JobId, queued.JobId, StringComparison.Ordinal))))
.Select(job => job.PrinterName is null ? job with { PrinterName = printers.FirstOrDefault(printer => string.Equals(printer.PrinterId, job.PrinterId, StringComparison.Ordinal))?.Name } : job).ToArray();
bool connected = configuration is not null && _failure is null && _heartbeatFailure is null && _lastSuccessfulPoll is not null && DateTimeOffset.UtcNow - _lastSuccessfulPoll.Value < TimeSpan.FromSeconds(30);
bool connected = configuration is not null && NormalTrafficAllowed && _failure is null && _heartbeatFailure is null && _lastSuccessfulPoll is not null && DateTimeOffset.UtcNow - _lastSuccessfulPoll.Value < TimeSpan.FromSeconds(30);
bool printerIssue = !printers.Any(printer => printer.Exposed) || printers.Any(printer => printer.Exposed && !string.Equals(printer.Status, "READY", StringComparison.Ordinal));
bool jobFailure = attentionCount > 0;
string health = !connected || jobFailure ? "RED" : printerIssue ? "ORANGE" : "GREEN";
@@ -181,7 +200,13 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
{
status = "Printing paused for application update. " + status;
}
return new AgentSnapshot(configuration is not null, connected, health, status, configuration?.ServerUrl, configuration?.DeviceId, _lastSuccessfulPoll?.ToString("O", System.Globalization.CultureInfo.InvariantCulture), printers, jobs, _logs.ToArray(), store.GetDeviceProfile());
(health, status) = ControlStatus(health, status, jobFailure);
if(apiFactory is null && !_authorizationReady)
{
health = "RED";
status = _authenticationMessage;
}
return new AgentSnapshot(configuration is not null, connected, health, status, configuration?.ServerUrl, configuration?.DeviceId, _lastSuccessfulPoll?.ToString("O", System.Globalization.CultureInfo.InvariantCulture), printers, jobs, _logs.ToArray(), store.GetDeviceProfile(), _controlState, AuthenticationStatus());
}
}
@@ -221,7 +246,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
timeout.CancelAfter(TimeSpan.FromSeconds(30));
string line = await AgentPipeFraming.ReadAsync(pipe, AgentPipeFraming.MaximumCommandCharacters, timeout.Token).ConfigureAwait(false);
PipeRequest request = JsonSerializer.Deserialize<PipeRequest>(line, PrintApiClient.JsonOptions) ?? throw new InvalidDataException("Invalid command.");
timeout.CancelAfter(TimeSpan.FromSeconds(string.Equals(request.Operation, "enroll", StringComparison.Ordinal) ? 120 : 30));
timeout.CancelAfter(TimeSpan.FromSeconds(request.Operation is "enroll" or "sign-in" or "sign-out" ? 120 : 30));
await AgentPipeFraming.WriteResponseAsync(pipe, await HandleCommandAsync(request, timeout.Token).ConfigureAwait(false), timeout.Token).ConfigureAwait(false);
}
catch(Exception exception) when(exception is not OperationCanceledException || !token.IsCancellationRequested)
@@ -291,19 +316,13 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
WriteLog("INFO", request.Exposed.Value ? "A printer was shared with the server." : "A printer was hidden from the server.");
return JsonSerializer.Serialize(GetSnapshot(), PrintApiClient.JsonOptions);
case "enroll":
await _connectionGate.WaitAsync(token).ConfigureAwait(false);
try
{ return await EnrollAsync(request, token).ConfigureAwait(false); }
finally { _connectionGate.Release(); }
return await EnrollWithGatesAsync(request, token).ConfigureAwait(false);
case "sign-in":
return await SignInAsync(request, token).ConfigureAwait(false);
case "sign-out":
return await SignOutAsync(token).ConfigureAwait(false);
case "reconnect":
using(AgentStore store = new(directory))
{
if(store.LoadConfiguration() is null)
{ return "Not enrolled. Open Connection settings to connect this device."; }
}
WakePoll();
WriteLog("INFO", "Connection retry requested.");
return "Reconnect requested. Connection status will update after the next server response.";
return ReconnectLocal();
case "test-print":
case "retry-job":
case "cancel-job":
@@ -320,18 +339,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
private async Task<string> EnrollAsync(PipeRequest request, CancellationToken token)
{
using PrintApiClient api = new(request.ServerUrl ?? throw new InvalidOperationException("Server URL required."));
EnrollmentResponse response = await api.EnrollAsync(new EnrollmentRequest(request.EnrollmentCode ?? "", Environment.MachineName, PrintApiClient.AgentVersion), token).ConfigureAwait(false);
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
store.SaveConfiguration(new AgentConfiguration(request.ServerUrl!, response.DeviceId, response.DeviceToken, response.PollIntervalSeconds));
ResetConnectionState();
lock(_stateLock)
{
_queuedJobs = [];
}
WriteLog("INFO", "Device enrolled with the print server.");
WakePoll();
return $"Enrolled device {response.DeviceId}";
return await EnrollNativeAsync(request, token).ConfigureAwait(false);
}
}
+1 -1
View File
@@ -8,5 +8,5 @@ using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
/// <summary>Credential-free local service observations for the tray companion.</summary>
public sealed record AgentSnapshot(bool Enrolled, bool Connected, string Health, string Status, string? ServerUrl, string? DeviceId, string? LastSuccessfulPoll, IReadOnlyList<PrinterSnapshot> Printers, IReadOnlyList<JobSnapshot> Jobs, IReadOnlyList<AgentLogEntry> Logs, DeviceProfile? DeviceProfile = null);
public sealed record AgentSnapshot(bool Enrolled, bool Connected, string Health, string Status, string? ServerUrl, string? DeviceId, string? LastSuccessfulPoll, IReadOnlyList<PrinterSnapshot> Printers, IReadOnlyList<JobSnapshot> Jobs, IReadOnlyList<AgentLogEntry> Logs, DeviceProfile? DeviceProfile = null, AgentControlState? Control = null, AgentAuthenticationStatus? Authentication = null);
@@ -0,0 +1,41 @@
using System.Security.Cryptography;
using System.Text.Json;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentStore
{
public AgentAuthentication? LoadAuthentication()
{
string path = Path.Combine(_directory, "qqq-session.dpapi");
if(!File.Exists(path))
{
return null;
}
if(new FileInfo(path).Length > 65_536)
{
throw new InvalidDataException("Saved QQQ authorization exceeded its bound.");
}
byte[] bytes = ProtectedData.Unprotect(File.ReadAllBytes(path), null, DataProtectionScope.CurrentUser);
try
{
return JsonSerializer.Deserialize<AgentAuthentication>(bytes, PrintApiClient.JsonOptions)
?? throw new InvalidDataException("Saved QQQ authorization is invalid.");
}
finally { CryptographicOperations.ZeroMemory(bytes); }
}
public void SaveAuthentication(AgentAuthentication authentication)
{
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(authentication, PrintApiClient.JsonOptions);
try
{
byte[] encrypted = ProtectedData.Protect(bytes, null, DataProtectionScope.CurrentUser);
string path = Path.Combine(_directory, "qqq-session.dpapi");
File.WriteAllBytes(path + ".tmp", encrypted);
File.Move(path + ".tmp", path, overwrite: true);
}
finally { CryptographicOperations.ZeroMemory(bytes); }
}
}
@@ -0,0 +1,155 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentStore
{
private void InitializeControls()
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = """
CREATE TABLE IF NOT EXISTS control_state(connection_id TEXT PRIMARY KEY,json TEXT NOT NULL,updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS control_commands(connection_id TEXT NOT NULL,command_id TEXT NOT NULL,command_json TEXT NOT NULL,receipt_json TEXT,acknowledged INTEGER NOT NULL DEFAULT 0,expires_at TEXT NOT NULL,PRIMARY KEY(connection_id,command_id));
CREATE INDEX IF NOT EXISTS control_outbox ON control_commands(connection_id,acknowledged);
CREATE INDEX IF NOT EXISTS control_expiry ON control_commands(expires_at);
""";
command.ExecuteNonQuery();
}
public AgentControlState LoadControlState()
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "SELECT json FROM control_state WHERE connection_id=$scope";
command.Parameters.AddWithValue("$scope", _connectionScope);
return command.ExecuteScalar() is string json ? JsonSerializer.Deserialize<AgentControlState>(json, PrintApiClient.JsonOptions) ?? AgentControlState.Initial : AgentControlState.Initial;
}
public void SaveControlState(AgentControlState state)
{
using SqliteTransaction transaction = _database.BeginTransaction();
using SqliteCommand command = _database.CreateCommand();
command.Transaction = transaction;
command.Parameters.AddWithValue("$scope", _connectionScope);
command.Parameters.AddWithValue("$cutoff", DateTimeOffset.UtcNow.AddDays(-30).ToString("O", CultureInfo.InvariantCulture));
command.CommandText = "DELETE FROM control_state WHERE connection_id<>$scope AND updated_at<$cutoff AND NOT EXISTS(SELECT 1 FROM control_commands c WHERE c.connection_id=control_state.connection_id AND c.acknowledged=0)";
command.ExecuteNonQuery();
command.CommandText = "SELECT COUNT(*) FROM control_state WHERE connection_id<>$scope";
if(Convert.ToInt64(command.ExecuteScalar(), CultureInfo.InvariantCulture) >= 128)
{
throw new InvalidOperationException("Control connection history is full; review retained connection state before proceeding.");
}
command.CommandText = "INSERT INTO control_state(connection_id,json,updated_at) VALUES($scope,$json,$now) ON CONFLICT(connection_id) DO UPDATE SET json=$json,updated_at=$now";
command.Parameters.AddWithValue("$json", JsonSerializer.Serialize(state, PrintApiClient.JsonOptions));
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
transaction.Commit();
}
/// <summary>Commit the execution marker before any effect. An existing marker must never be executed again.</summary>
public bool BeginControlCommand(DeviceControlCommand value)
{
using SqliteTransaction transaction = _database.BeginTransaction();
using SqliteCommand command = _database.CreateCommand();
command.Transaction = transaction;
command.CommandText = "SELECT command_json FROM control_commands WHERE connection_id=$scope AND command_id=$id";
command.Parameters.AddWithValue("$scope", _connectionScope);
command.Parameters.AddWithValue("$id", value.CommandId);
string json = JsonSerializer.Serialize(value, PrintApiClient.JsonOptions);
if(command.ExecuteScalar() is string existing)
{
if(!string.Equals(existing, json, StringComparison.Ordinal))
{
throw new InvalidDataException("A control command identity was reused with different content.");
}
return false;
}
command.CommandText = "DELETE FROM control_commands WHERE expires_at<$cutoff AND (acknowledged<>0 OR connection_id<>$scope)";
command.Parameters.AddWithValue("$cutoff", DateTimeOffset.UtcNow.AddDays(-30).ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
command.CommandText = "SELECT COUNT(*) FROM control_commands";
if(Convert.ToInt64(command.ExecuteScalar(), CultureInfo.InvariantCulture) >= 4096)
{
throw new InvalidOperationException("Control receipt journal is full; resolve delivery before accepting more commands.");
}
command.CommandText = "INSERT INTO control_commands(connection_id,command_id,command_json,expires_at) VALUES($scope,$id,$json,$expires)";
command.Parameters.AddWithValue("$json", json);
command.Parameters.AddWithValue("$expires", DateTimeOffset.Parse(value.ExpiresAt, CultureInfo.InvariantCulture).ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
transaction.Commit();
return true;
}
public DeviceControlReceipt? GetControlReceipt(string commandId)
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "SELECT receipt_json FROM control_commands WHERE connection_id=$scope AND command_id=$id";
command.Parameters.AddWithValue("$scope", _connectionScope);
command.Parameters.AddWithValue("$id", commandId);
return command.ExecuteScalar() is string json ? JsonSerializer.Deserialize<DeviceControlReceipt>(json, PrintApiClient.JsonOptions) : null;
}
public void SaveControlReceipt(string commandId, DeviceControlReceipt receipt)
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "UPDATE control_commands SET receipt_json=$json WHERE connection_id=$scope AND command_id=$id AND receipt_json IS NULL";
command.Parameters.AddWithValue("$scope", _connectionScope);
command.Parameters.AddWithValue("$id", commandId);
command.Parameters.AddWithValue("$json", JsonSerializer.Serialize(receipt, PrintApiClient.JsonOptions));
command.ExecuteNonQuery();
}
public IReadOnlyList<(string CommandId, DeviceControlReceipt Receipt)> PendingControlReceipts()
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "SELECT command_id,receipt_json FROM control_commands WHERE connection_id=$scope AND acknowledged=0 AND receipt_json IS NOT NULL ORDER BY expires_at,command_id LIMIT 20";
command.Parameters.AddWithValue("$scope", _connectionScope);
using SqliteDataReader reader = command.ExecuteReader();
var result = new List<(string, DeviceControlReceipt)>();
while(reader.Read())
{
result.Add((reader.GetString(0), JsonSerializer.Deserialize<DeviceControlReceipt>(reader.GetString(1), PrintApiClient.JsonOptions) ?? throw new InvalidDataException("Invalid saved control receipt.")));
}
return result;
}
public void RecoverInterruptedControlCommands()
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "SELECT command_id FROM control_commands WHERE connection_id=$scope AND receipt_json IS NULL ORDER BY expires_at,command_id LIMIT 20";
command.Parameters.AddWithValue("$scope", _connectionScope);
var identifiers = new List<string>();
using(SqliteDataReader reader = command.ExecuteReader())
{
while(reader.Read())
{ identifiers.Add(reader.GetString(0)); }
}
foreach(string identifier in identifiers)
{
SaveControlReceipt(identifier, new DeviceControlReceipt(Guid.NewGuid().ToString("N"), "OUTCOME_UNKNOWN",
DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture), "Execution was interrupted after its durable intent. The action was not repeated; inspect its outcome."));
}
}
public void AcknowledgeControlReceipt(string commandId)
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "UPDATE control_commands SET acknowledged=1 WHERE connection_id=$scope AND command_id=$id";
command.Parameters.AddWithValue("$scope", _connectionScope);
command.Parameters.AddWithValue("$id", commandId);
command.ExecuteNonQuery();
}
/// <summary>Transport retirement is not server acknowledgement; preserve the exact outcome locally.</summary>
public bool RetireExpiredControlReceipt(string commandId)
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "UPDATE control_commands SET acknowledged=2 WHERE connection_id=$scope AND command_id=$id AND receipt_json IS NOT NULL AND expires_at<$cutoff";
command.Parameters.AddWithValue("$scope", _connectionScope);
command.Parameters.AddWithValue("$id", commandId);
command.Parameters.AddWithValue("$cutoff", DateTimeOffset.UtcNow.AddDays(-7).ToString("O", CultureInfo.InvariantCulture));
return command.ExecuteNonQuery() == 1;
}
}
+1
View File
@@ -87,6 +87,7 @@ public sealed partial class AgentStore : IJobJournal, IDisposable
command.Transaction = null;
CreateJobIndexes();
MigrateJobCreationTime();
InitializeControls();
}
}
@@ -67,6 +67,10 @@ public sealed class IsolatedPrinterBackend(string helperExecutable) : IPrinterBa
PrinterWorkerHost.Response response = JsonSerializer.Deserialize<PrinterWorkerHost.Response>(text, PrintApiClient.JsonOptions) ?? throw new InvalidDataException("Printer worker returned no response.");
if(child.ExitCode != 0 || !response.Success)
{
if(response.ErrorCode is "ALREADY_ABSENT" or "OWNERSHIP_MISMATCH")
{
throw new SpoolCancellationException(response.ErrorCode, response.Error ?? "Verified spool cancellation was refused.");
}
throw new IOException(response.Error ?? "The printer worker stopped unexpectedly. Inspect the printer before retrying.");
}
return response;
+5 -1
View File
@@ -7,7 +7,11 @@ namespace Qqq.PrintAgent.Windows;
public sealed record PipeRequest(string Operation, string? ServerUrl = null, string? EnrollmentCode = null, string? PrinterId = null, bool? Exposed = null,
string? JobId = null, string? Format = null, bool ConfirmDuplicateRisk = false, JobQuery? JobQuery = null, LogSettings? LogSettings = null,
Qqq.PrintAgent.Core.Printing.DeviceProfile? DeviceProfile = null, PrinterProfile? PrinterProfile = null, string? TestProfile = null);
Qqq.PrintAgent.Core.Printing.DeviceProfile? DeviceProfile = null, PrinterProfile? PrinterProfile = null, string? TestProfile = null,
Qqq.PrintAgent.Core.Authentication.QqqSessionCredential? SessionCredential = null, bool ClaimLegacyDevice = false)
{
public override string ToString() => "Print agent management request (credentials redacted)";
}
@@ -16,7 +16,7 @@ public static class PrinterWorkerHost
{
internal const string ParentEnvironmentVariable = "QQQ_PRINT_WORKER_PARENT";
internal sealed record Request(string Operation, PrintJob? Job = null, byte[]? Content = null, string? PrinterName = null, string? JobId = null, string? SpoolerJobId = null);
internal sealed record Response(bool Success, IReadOnlyList<PrinterInfo>? Printers = null, string? SpoolerJobId = null, string? Error = null);
internal sealed record Response(bool Success, IReadOnlyList<PrinterInfo>? Printers = null, string? SpoolerJobId = null, string? Error = null, string? ErrorCode = null);
/// <summary>Run before ordinary service or CLI hosting when invoked with --printer-worker.</summary>
public static Task<int> RunAsync() => RunAsync(new WindowsPrinters());
@@ -63,7 +63,7 @@ public static class PrinterWorkerHost
}
catch(Exception exception)
{
await Console.Out.WriteLineAsync(JsonSerializer.Serialize(new Response(false, Error: AgentActivityLog.Redact(exception.Message)), PrintApiClient.JsonOptions)).ConfigureAwait(false);
await Console.Out.WriteLineAsync(JsonSerializer.Serialize(new Response(false, Error: AgentActivityLog.Redact(exception.Message), ErrorCode: (exception as SpoolCancellationException)?.Outcome), PrintApiClient.JsonOptions)).ConfigureAwait(false);
return 1;
}
finally
@@ -0,0 +1,41 @@
using System.Security.AccessControl;
namespace Qqq.PrintAgent.Windows;
public sealed partial class QqqBrowserCallback
{
internal static string CompanionSibling(string executable)
{
string full = Path.GetFullPath(executable);
DirectoryInfo directory = new(Path.GetDirectoryName(full)!);
if(!string.Equals(Path.GetFileName(full), "qqq-print-agent.exe", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(directory.Name, "Cli", StringComparison.OrdinalIgnoreCase) || directory.Parent is null)
{
throw new UnauthorizedAccessException("Browser callbacks require the installed CLI location.");
}
return Path.Combine(directory.Parent.FullName, "App", "Qqq.PrintAgent.exe");
}
private static string InstalledCompanionPath()
{
string executable = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot locate the installed callback helper.");
string companion = CompanionSibling(executable);
ValidateCallbackInstallation(executable);
ValidateCallbackInstallation(companion);
return companion;
}
private static void ValidateCallbackInstallation(string path)
{
string boundary = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
MsiUpdateInstaller.ValidateContainedPath(path, boundary, allowRoot: false);
MsiUpdateInstaller.ValidateNoReparseComponents(path);
string? current = Path.GetFullPath(path);
while(current is not null && !string.Equals(current, Path.GetDirectoryName(boundary), StringComparison.OrdinalIgnoreCase))
{
FileSystemSecurity security = Directory.Exists(current) ? new DirectoryInfo(current).GetAccessControl() : new FileInfo(current).GetAccessControl();
MsiUpdateInstaller.ValidateTrustedAcl(security, administratorSid: null);
current = Path.GetDirectoryName(current);
}
}
}
@@ -0,0 +1,122 @@
using System.Diagnostics;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace Qqq.PrintAgent.Windows;
/// <summary>A callback inbox exists only during an explicit sign-in attempt. It never contains a PKCE verifier or session cookie.</summary>
public sealed partial class QqqBrowserCallback : IDisposable
{
public const string RedirectUri = "io.qrun.qqq-print-agent:/oauth/callback";
private const int MaximumLength = 8192;
private readonly NamedPipeServerStream _pipe;
public QqqBrowserCallback() : this(PipeName()) { }
internal QqqBrowserCallback(string name) => _pipe = new NamedPipeServerStream(name, PipeDirection.InOut, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly | PipeOptions.FirstPipeInstance);
public async Task<Uri> ReceiveAsync(CancellationToken token)
{
await _pipe.WaitForConnectionAsync(token).ConfigureAwait(false);
using Process sender = ValidateClientSession(_pipe.SafePipeHandle);
Uri callback = Validate(await AgentPipeFraming.ReadAsync(_pipe, MaximumLength, token).ConfigureAwait(false));
await AgentPipeFraming.WriteResponseAsync(_pipe, "received", token).ConfigureAwait(false);
return callback;
}
public static Task ForwardAsync(string callback, CancellationToken token = default) => ForwardAsync(callback, PipeName(),
InstalledCompanionPath(), token);
internal static async Task ForwardAsync(string callback, string pipeName, string expectedExecutable, CancellationToken token)
{
_ = Validate(callback);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
await using NamedPipeClientStream pipe = new(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, TokenImpersonationLevel.Anonymous);
await pipe.ConnectAsync(timeout.Token).ConfigureAwait(false);
using Process peer = AuthenticatePeer(pipe.SafePipeHandle, expectedExecutable);
await AgentPipeFraming.WriteAsync(pipe, callback, MaximumLength, timeout.Token).ConfigureAwait(false);
string acknowledgement = await AgentPipeFraming.ReadAsync(pipe, 32, timeout.Token).ConfigureAwait(false);
if(!string.Equals(acknowledgement, "received", StringComparison.Ordinal))
{
throw new InvalidDataException("The active sign-in attempt did not accept the callback.");
}
}
internal static Uri Validate(string value)
{
if(value.Length > MaximumLength || value.Any(char.IsControl) || !Uri.TryCreate(value, UriKind.Absolute, out Uri? uri)
|| !string.Equals(uri.GetLeftPart(UriPartial.Path), RedirectUri, StringComparison.Ordinal)
|| uri.Fragment.Length != 0 || uri.Query.Length == 0)
{
throw new InvalidDataException("Invalid QQQ browser callback.");
}
return uri;
}
private static string PipeName()
{
using var identity = WindowsIdentity.GetCurrent();
using var process = Process.GetCurrentProcess();
string scope = identity.User?.Value + ":" + process.SessionId.ToString(System.Globalization.CultureInfo.InvariantCulture);
return "QqqPrintAgent.Auth." + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scope)));
}
private static Process AuthenticatePeer(SafePipeHandle pipe, string expectedExecutable)
{
if(!GetNamedPipeServerProcessId(pipe, out uint id))
{
throw new UnauthorizedAccessException("Cannot verify the active sign-in companion.");
}
var process = Process.GetProcessById(checked((int)id));
using var current = Process.GetCurrentProcess();
using SafeProcessHandle handle = OpenProcess(0x1000, false, id);
char[] name = new char[32768];
uint length = 32768;
if(handle.IsInvalid || !QueryFullProcessImageName(handle, 0, name, ref length) || process.SessionId != current.SessionId
|| !string.Equals(new string(name, 0, checked((int)length)), expectedExecutable, StringComparison.OrdinalIgnoreCase) || process.HasExited)
{
process.Dispose();
throw new UnauthorizedAccessException("The callback endpoint is not the installed companion in this user session.");
}
return process;
}
private static Process ValidateClientSession(SafePipeHandle pipe)
{
if(!GetNamedPipeClientProcessId(pipe, out uint id))
{
throw new UnauthorizedAccessException("Cannot verify the callback sender.");
}
var process = Process.GetProcessById(checked((int)id));
using var current = Process.GetCurrentProcess();
if(process.SessionId != current.SessionId || process.HasExited)
{
process.Dispose();
throw new UnauthorizedAccessException("The callback sender is outside this user session.");
}
return process;
}
public void Dispose() => _pipe.Dispose();
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint processId);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetNamedPipeClientProcessId(SafePipeHandle pipe, out uint processId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern SafeProcessHandle OpenProcess(uint access, [MarshalAs(UnmanagedType.Bool)] bool inherit, uint id);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool QueryFullProcessImageName(SafeProcessHandle process, uint flags, [Out] char[] name, ref uint size);
}
@@ -0,0 +1,7 @@
namespace Qqq.PrintAgent.Windows;
/// <summary>A verified refusal that must survive the isolated worker boundary.</summary>
public sealed class SpoolCancellationException(string outcome, string message) : InvalidOperationException(message)
{
public string Outcome { get; } = outcome;
}
@@ -18,8 +18,17 @@ public static class WindowsSpoolerJobs
try
{
_ = GetJob(printer, id, 1, IntPtr.Zero, 0, out uint needed);
if(needed == 0 || needed > 1_048_576)
{ throw new InvalidOperationException("The job is no longer in the Windows queue. Inspect its output."); }
if(needed == 0)
{
int error = Marshal.GetLastWin32Error();
if(error is 87 or 1168)
{
throw new SpoolCancellationException("ALREADY_ABSENT", "The job is no longer in the Windows queue. Pages already printed cannot be recalled.");
}
throw new Win32Exception(error);
}
if(needed > 1_048_576)
{ throw new InvalidOperationException("The Windows spooler response exceeded its bound."); }
IntPtr buffer = Marshal.AllocHGlobal((int)needed);
try
{
@@ -28,7 +37,7 @@ public static class WindowsSpoolerJobs
JobInfo1 info = Marshal.PtrToStructure<JobInfo1>(buffer);
if(!string.Equals(Marshal.PtrToStringUni(info.Document), "QQQ " + jobId, StringComparison.Ordinal))
{
throw new InvalidOperationException("The spooler number now belongs to another document; cancellation was refused.");
throw new SpoolCancellationException("OWNERSHIP_MISMATCH", "The spooler number now belongs to another document; cancellation was refused.");
}
const uint jobControlDelete = 5;
if(!SetJob(printer, id, 0, IntPtr.Zero, jobControlDelete))
@@ -0,0 +1,140 @@
using System.Diagnostics;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent;
public sealed partial class MainWindow
{
private QqqSessionClient? _signInClient;
private QqqAuthenticationMetadata? _signInMetadata;
private CancellationTokenSource? _browserSignInCancellation;
private void CancelBrowserSignIn_Click(object sender, RoutedEventArgs e) => _browserSignInCancellation?.Cancel();
private void ConnectionDialog_Closing(ContentDialog sender, ContentDialogClosingEventArgs args) => _browserSignInCancellation?.Cancel();
private async void DiscoverSignIn_Click(object sender, RoutedEventArgs e)
{
if(_busy)
{ return; }
SetBusy(true);
PasswordSignInPanel.Visibility = Visibility.Collapsed;
BrowserSignInButton.Visibility = Visibility.Collapsed;
try
{
_signInClient?.Dispose();
_signInClient = null;
if(!Uri.TryCreate(ServerBox.Text.Trim(), UriKind.Absolute, out Uri? origin))
{
throw new InvalidOperationException("Enter a valid QQQ server origin.");
}
QqqSessionClient client = new(origin, allowLoopbackHttp: origin.IsLoopback);
_signInClient = client;
_ = await client.DiscoverAsync();
_signInMetadata = await client.GetAuthenticationMetadataAsync();
PasswordSignInPanel.Visibility = string.Equals(_signInMetadata.Type, "TABLE_BASED", StringComparison.Ordinal) ? Visibility.Visible : Visibility.Collapsed;
BrowserSignInButton.Visibility = _signInMetadata.Values?.NativeSessionLogin is not null ? Visibility.Visible : Visibility.Collapsed;
bool supported = PasswordSignInPanel.Visibility == Visibility.Visible || BrowserSignInButton.Visibility == Visibility.Visible;
Feedback(supported ? "Sign-in options ready" : "Server configuration required",
supported ? "Use the sign-in method configured by this QQQ server." : "This server has not enabled a supported native QQQ sign-in grant. Ask its administrator to configure it.",
supported ? InfoBarSeverity.Informational : InfoBarSeverity.Warning);
}
catch(Exception exception) when(exception is IOException or ArgumentException or InvalidOperationException or HttpRequestException or OperationCanceledException)
{
Feedback("Discovery failed", "The server did not provide compatible QQQ authentication and printing discovery. Check its address and configuration.", InfoBarSeverity.Error);
}
finally { SetBusy(false); }
}
private async void PasswordSignIn_Click(object sender, RoutedEventArgs e)
{
if(_busy || _signInClient is null)
{ return; }
SetBusy(true);
try
{
await _signInClient.SignInWithPasswordAsync(UsernameBox.Text.Trim(), QqqPasswordBox.Password);
await TransferSignInAsync();
}
catch(Exception exception) when(exception is IOException or ArgumentException or HttpRequestException or OperationCanceledException or UnauthorizedAccessException)
{
Feedback("Sign-in not completed", "QQQ did not authorize this connection, or the service could not confirm it. Check your sign-in, device ownership and current permissions.", InfoBarSeverity.Error);
}
finally
{
QqqPasswordBox.Password = string.Empty;
SetBusy(false);
}
}
private async Task TransferSignInAsync()
{
string result = await AgentPipeClient.SendAsync(new PipeRequest("sign-in", SessionCredential: _signInClient!.ExportCredential(), ClaimLegacyDevice: ClaimExistingDevice.IsChecked == true));
bool success = result.StartsWith("Signed in to QQQ.", StringComparison.Ordinal);
Feedback(success ? "Signed in" : "Service authorization not completed", result, success ? InfoBarSeverity.Success : InfoBarSeverity.Error);
}
private async void SignOut_Click(object sender, RoutedEventArgs e)
{
if(_busy)
{ return; }
SetBusy(true);
try
{
string result = await AgentPipeClient.SendAsync(new PipeRequest("sign-out"));
_signInClient?.Dispose();
_signInClient = null;
Feedback("QQQ sign-out", result, result.StartsWith("Error:", StringComparison.Ordinal) ? InfoBarSeverity.Warning : InfoBarSeverity.Informational);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException)
{ ServiceUnavailable(); }
finally { SetBusy(false); }
}
private async void BrowserSignIn_Click(object sender, RoutedEventArgs e)
{
if(_busy || _signInClient is null)
{ return; }
SetBusy(true);
CancelBrowserSignInButton.Visibility = Visibility.Visible;
try
{
string redirect = _signInMetadata?.Values?.NativeSessionLogin?.RedirectUris.FirstOrDefault(value =>
string.Equals(value, QqqBrowserCallback.RedirectUri, StringComparison.Ordinal))
?? throw new InvalidOperationException("No approved native browser callback is configured.");
using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(3));
_browserSignInCancellation = timeout;
await CompleteBrowserSignInAsync(new Uri(redirect), timeout.Token);
await TransferSignInAsync();
}
catch(OperationCanceledException)
{
_signInClient?.Dispose();
_signInClient = null;
BrowserSignInButton.Visibility = Visibility.Collapsed;
Feedback("Browser sign-in canceled", "No new sign-in was accepted. Choose Discover sign-in options to start a new attempt.", InfoBarSeverity.Informational);
}
catch(Exception exception) when(exception is IOException or InvalidOperationException or HttpRequestException or UnauthorizedAccessException or System.ComponentModel.Win32Exception)
{
Feedback("Browser sign-in not completed", "No validated QQQ session was received. Check the server's advertised native sign-in grant and installed callback configuration, then try again explicitly.", InfoBarSeverity.Warning);
}
finally
{
_browserSignInCancellation = null;
CancelBrowserSignInButton.Visibility = Visibility.Collapsed;
SetBusy(false);
}
}
private async Task CompleteBrowserSignInAsync(Uri redirect, CancellationToken token)
{
using QqqBrowserCallback inbox = new();
QqqBrowserLogin attempt = await _signInClient!.BeginBrowserLoginAsync(redirect, token);
_ = Process.Start(new ProcessStartInfo(attempt.AuthorizationUri.AbsoluteUri) { UseShellExecute = true });
Uri callbackUri = await inbox.ReceiveAsync(token);
await _signInClient.CompleteBrowserLoginAsync(attempt, callbackUri, token);
}
}
+3 -3
View File
@@ -82,7 +82,7 @@
<StackPanel Orientation="Horizontal" Spacing="8"><Button x:Name="StartServiceButton" AutomationProperties.AutomationId="StartService" Content="Start service" Click="StartService_Click" IsEnabled="False" /><Button x:Name="RestartServiceButton" AutomationProperties.AutomationId="RestartService" Content="Restart service" Click="RestartService_Click" IsEnabled="False" /><Button Content="Windows Services" Click="Services_Click" /></StackPanel>
<HyperlinkButton Content="Get the installer" NavigateUri="https://github.com/QRun-IO/qqq-print-agent/releases" Padding="0" />
</StackPanel>
<StackPanel Spacing="10"><TextBlock Text="Server connection" Style="{ThemeResource SubtitleTextBlockStyle}" /><TextBlock x:Name="StatusText" Text="Waiting for service status" TextWrapping="Wrap" AutomationProperties.AutomationId="ServiceStatus" /><TextBlock x:Name="ConnectionText" Text="Connect this computer to your print server." TextWrapping="Wrap" IsTextSelectionEnabled="True" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /><Button x:Name="ConnectionButton" Content="Connection settings" Click="Connection_Click" AutomationProperties.AutomationId="ConnectionSettings" /></StackPanel>
<StackPanel Spacing="10"><TextBlock Text="Server connection" Style="{ThemeResource SubtitleTextBlockStyle}" /><TextBlock x:Name="StatusText" Text="Waiting for service status" TextWrapping="Wrap" AutomationProperties.AutomationId="ServiceStatus" /><TextBlock x:Name="ConnectionText" Text="Connect this computer to your print server." TextWrapping="Wrap" IsTextSelectionEnabled="True" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /><TextBlock x:Name="AuthenticationText" Text="Sign in to QQQ to enable printing." TextWrapping="Wrap" AutomationProperties.AutomationId="QqqAuthenticationStatus" /><StackPanel Orientation="Horizontal" Spacing="8"><Button x:Name="ConnectionButton" Content="Sign in / Connection settings" Click="Connection_Click" AutomationProperties.AutomationId="ConnectionSettings" /><Button Content="Sign out" Click="SignOut_Click" AutomationProperties.AutomationId="QqqSignOut" /></StackPanel></StackPanel>
<StackPanel Spacing="10"><TextBlock Text="Diagnostic logs" Style="{ThemeResource SubtitleTextBlockStyle}" /><TextBlock Text="Service and companion logs rotate daily. Set retention and the daily size limit, inspect usage, or clear retained diagnostic logs." TextWrapping="Wrap" /><StackPanel Orientation="Horizontal" Spacing="8"><Button Content="Log settings" Click="LogSettings_Click" AutomationProperties.AutomationId="SettingsLogSettings" /><Button Content="Clear local logs" Click="ClearLogs_Click" AutomationProperties.AutomationId="SettingsClearLogs" /></StackPanel></StackPanel>
<StackPanel Spacing="12"><TextBlock Text="Appearance" Style="{ThemeResource SubtitleTextBlockStyle}" /><ComboBox x:Name="ThemeChoice" Header="App theme" MinWidth="220" SelectionChanged="Theme_SelectionChanged"><ComboBoxItem Content="Use Windows setting" Tag="System" /><ComboBoxItem Content="Light" Tag="Light" /><ComboBoxItem Content="Dark" Tag="Dark" AutomationProperties.AutomationId="ThemeDark" /></ComboBox><ToggleSwitch x:Name="SplashToggle" Header="Show splash screen at startup" Toggled="Splash_Toggled" AutomationProperties.AutomationId="SplashAtStartup" /><NumberBox x:Name="SplashDuration" Header="Splash duration (milliseconds)" SpinButtonPlacementMode="Inline" SmallChange="500" LargeChange="1000" ValueChanged="SplashDuration_ValueChanged" AutomationProperties.AutomationId="SplashDuration" MinWidth="220" HorizontalAlignment="Left" /></StackPanel>
<StackPanel Spacing="10">
@@ -105,8 +105,8 @@
<Border Grid.Row="2" Padding="20,10" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" BorderThickness="0,1,0,0">
<Grid ColumnSpacing="12"><Grid.ColumnDefinitions><ColumnDefinition Width="Auto" /><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions><Ellipse x:Name="FooterDot" Width="10" Height="10" Fill="Red" VerticalAlignment="Center" /><StackPanel Grid.Column="1" Spacing="2"><TextBlock x:Name="FooterStatus" Text="Checking local service…" FontWeight="SemiBold" TextWrapping="Wrap" AutomationProperties.AutomationId="FooterStatus" AutomationProperties.LiveSetting="Polite" /><TextBlock x:Name="FooterContact" Text="Status refreshes every 5 seconds" FontSize="12" TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /></StackPanel><StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center"><Button x:Name="ReconnectButton" Content="Reconnect" Click="Reconnect_Click" AutomationProperties.AutomationId="ReconnectDevice" /><Button Content="View logs" Click="ServiceLogs_Click" AutomationProperties.AutomationId="ViewServiceLogs" /></StackPanel></Grid>
</Border>
<ContentDialog x:Name="ConnectionDialog" Title="Connection settings" CloseButtonText="Close">
<StackPanel Spacing="12"><TextBlock Text="In the server, open Printing → Print devices → Actions → Enroll a print device. Enter the server address and the single-use enrollment code below." TextWrapping="Wrap" /><TextBox x:Name="ServerBox" Header="Print server address" PlaceholderText="https://print.example.com" AutomationProperties.AutomationId="ServerAddress" /><PasswordBox x:Name="EnrollmentBox" Header="Enrollment code" PlaceholderText="Paste the code from the server" AutomationProperties.AutomationId="EnrollmentCode" /><Button x:Name="EnrollButton" Content="Connect this device" Click="Enroll_Click" Style="{ThemeResource AccentButtonStyle}" AutomationProperties.AutomationId="EnrollDevice" /><InfoBar x:Name="ConnectionFeedback" IsOpen="False" IsClosable="False" /></StackPanel>
<ContentDialog x:Name="ConnectionDialog" Title="Connection settings" CloseButtonText="Close" Closing="ConnectionDialog_Closing">
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="650"><StackPanel Spacing="12"><TextBlock Text="Sign in using this QQQ server's configured authentication. Printing uses your current permissions. Existing pairing and history are preserved." TextWrapping="Wrap" /><TextBox x:Name="ServerBox" Header="QQQ server address" PlaceholderText="https://print.example.com" AutomationProperties.AutomationId="ServerAddress" /><Button Content="Discover sign-in options" Click="DiscoverSignIn_Click" AutomationProperties.AutomationId="DiscoverQqqSignIn" /><StackPanel x:Name="PasswordSignInPanel" Spacing="8" Visibility="Collapsed"><TextBox x:Name="UsernameBox" Header="QQQ username" AutomationProperties.AutomationId="QqqUsername" /><PasswordBox x:Name="QqqPasswordBox" Header="QQQ password" AutomationProperties.AutomationId="QqqPassword" /><Button Content="Sign in" Click="PasswordSignIn_Click" AutomationProperties.AutomationId="QqqPasswordSignIn" /></StackPanel><Button x:Name="BrowserSignInButton" Content="Sign in with your browser" Visibility="Collapsed" Click="BrowserSignIn_Click" AutomationProperties.AutomationId="QqqBrowserSignIn" /><Button x:Name="CancelBrowserSignInButton" Content="Cancel browser sign-in" Visibility="Collapsed" Click="CancelBrowserSignIn_Click" AutomationProperties.AutomationId="CancelQqqSignIn" /><CheckBox x:Name="ClaimExistingDevice" Content="Associate this computer's existing pairing with my QQQ account" /><TextBlock Text="For a new connection, after signing in, create a single-use enrollment code in QQQ: Printing → Print devices → Actions → Enroll a print device." TextWrapping="Wrap" /><PasswordBox x:Name="EnrollmentBox" Header="Enrollment code (new pairing only)" PlaceholderText="Paste the code from the server" AutomationProperties.AutomationId="EnrollmentCode" /><Button x:Name="EnrollButton" Content="Connect this device" Click="Enroll_Click" Style="{ThemeResource AccentButtonStyle}" AutomationProperties.AutomationId="EnrollDevice" /><InfoBar x:Name="ConnectionFeedback" IsOpen="False" IsClosable="False" /></StackPanel></ScrollViewer>
</ContentDialog>
</Grid>
</Window>
+23
View File
@@ -101,6 +101,8 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Release the tray icon and polling timer when the companion exits.</summary>
public void Dispose()
{
_browserSignInCancellation?.Cancel();
_signInClient?.Dispose();
if(_updatesClosed)
{
return;
@@ -420,9 +422,30 @@ public sealed partial class MainWindow : Window, IDisposable
ConnectionDialog.Title = snapshot.Enrolled ? "Sign in again" : "Connect to the server";
FooterContact.Text = $"Last server contact: {FormatTime(snapshot.LastSuccessfulPoll)} · refreshes every 5 seconds";
StatusText.Text = snapshot.Status;
if(snapshot.Control is { } control)
{
if(control.AuthorizationRequired)
{ FooterStatus.Text = "Server authorization required — sign in again"; }
else if(string.Equals(control.DesiredState.ConnectionState, "DISCONNECTED", StringComparison.Ordinal))
{ FooterStatus.Text = "Disconnected by administrator · control polling active"; }
else if(control.DesiredState.DispatchPaused)
{ FooterStatus.Text = "Dispatch paused by administrator"; }
FooterContact.Text += $" · Remote controls: {AgentControlLabels.Support(control.Support)} · Last control contact: {FormatTime(control.LastContact)}";
if(control.LastCommand is not null)
{
FooterContact.Text += $"\n{AgentControlLabels.Action(control.LastCommand)}: {AgentControlLabels.Outcome(control.LastOutcome)} · {FormatTime(control.LastCommandAt)}";
}
}
ConnectionText.Text = snapshot.Enrolled
? $"Server: {snapshot.ServerUrl}\nDevice: {snapshot.DeviceId}\nLast successful poll: {FormatTime(snapshot.LastSuccessfulPoll)}"
: "This device has not been enrolled. Open Connection settings to connect it to the server.";
AuthenticationText.Text = snapshot.Authentication is { } authentication
? authentication.SignedIn ? $"Signed in as {authentication.PrincipalId} · Tenant {authentication.TenantId}\n{authentication.Message}" : authentication.Message
: "Sign in to QQQ to enable printing.";
if(snapshot.Authentication is { Authorized: false })
{
FooterStatus.Text = "QQQ authorization required";
}
if(string.IsNullOrWhiteSpace(ServerBox.Text) && snapshot.ServerUrl is not null)
{
ServerBox.Text = snapshot.ServerUrl;
@@ -0,0 +1,48 @@
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
public sealed class AgentControlConcurrencyTests
{
[Fact]
public async Task RemoteDisconnectAndReconnectDoNotInterruptBlockedPrintOrControlPolling()
{
using ControlFixture fixture = new();
fixture.OfferJob = true;
fixture.Printer.BlockPrint = true;
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(10));
using AgentRuntime runtime = fixture.Runtime();
Task running = runtime.RunAsync(stop.Token);
try
{
await fixture.Printer.Entered.Task.WaitAsync(stop.Token);
fixture.Desired = new DeviceDesiredState(2, true, "DISCONNECTED");
await UntilAsync(() => runtime.GetSnapshot().Control!.DesiredState.Revision == 2, stop.Token);
int polls = Volatile.Read(ref fixture.Polls);
await UntilAsync(() => Volatile.Read(ref fixture.Polls) >= polls + 3, stop.Token);
int heartbeats = Volatile.Read(ref fixture.Heartbeats);
await UntilAsync(() => Volatile.Read(ref fixture.Polls) >= polls + 6, stop.Token);
Assert.Equal(heartbeats, Volatile.Read(ref fixture.Heartbeats));
Assert.Equal(1, Volatile.Read(ref fixture.Claims));
Assert.Equal("SUBMITTING", Assert.Single(runtime.GetSnapshot().Jobs).Status);
fixture.Desired = new DeviceDesiredState(3, false, "CONNECTED");
await UntilAsync(() => runtime.GetSnapshot().Control!.DesiredState.Revision == 3, stop.Token);
fixture.Printer.Release.TrySetResult();
await UntilAsync(() => runtime.GetSnapshot().Jobs.Any(job => string.Equals(job.Status, "SPOOLED", StringComparison.Ordinal)), stop.Token);
}
finally
{
await stop.CancelAsync();
try
{ await running; }
catch(OperationCanceledException) { }
}
}
private static async Task UntilAsync(Func<bool> condition, CancellationToken token)
{
while(!condition())
{ await Task.Delay(10, token); }
}
}
@@ -0,0 +1,243 @@
using System.Net;
using System.Text.Json;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
public sealed class AgentRemoteControlTests
{
[Theory]
[InlineData("{\"revision\":6,\"connectionState\":\"CONNECTED\"}")]
[InlineData("{\"dispatchPaused\":false,\"connectionState\":\"CONNECTED\"}")]
public async Task MissingRequiredStateCannotResumeDispatch(string state)
{
using ControlFixture fixture = new();
fixture.Desired = new DeviceDesiredState(5, true, "CONNECTED");
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
fixture.PollBody = "{\"controlVersion\":1,\"serverTime\":\"2026-10-31T00:00:00Z\",\"pollIntervalSeconds\":5,\"commands\":[],\"desiredState\":" + state + "}";
await Assert.ThrowsAsync<JsonException>(() => runtime.PollControlsOnceAsync(CancellationToken.None));
Assert.True(runtime.GetSnapshot().Control!.DesiredState.DispatchPaused);
Assert.Equal(5, runtime.GetSnapshot().Control!.DesiredState.Revision);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GeneratedAndRecoveredReceiptsUseRequiredUtcWireTimestamp(bool interrupted)
{
using ControlFixture fixture = new();
fixture.SeedSpooledJob();
fixture.Commands = [ControlFixture.Command()];
if(interrupted)
{
using AgentStore store = new(fixture.DirectoryPath);
Assert.True(store.BeginControlCommand(fixture.Commands[0]));
}
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
Assert.NotEmpty(fixture.Receipts);
foreach(string body in fixture.Receipts)
{
using var json = JsonDocument.Parse(body);
string timestamp = json.RootElement.GetProperty("completedAt").GetString()!;
Assert.EndsWith("Z", timestamp, StringComparison.Ordinal);
Assert.True(timestamp.Length <= 40);
Assert.True(DateTimeOffset.TryParse(timestamp, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out _));
}
}
[Fact]
public async Task LostNativeCancellationResultStaysUnknownAndNeverRepeats()
{
using ControlFixture fixture = new();
fixture.SeedSpooledJob();
fixture.Commands = [ControlFixture.Command()];
fixture.Printer.LoseCancellationResult = true;
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
await runtime.PollControlsOnceAsync(CancellationToken.None);
using AgentStore store = new(fixture.DirectoryPath);
Assert.Equal("OUTCOME_UNKNOWN", store.GetControlReceipt("command")!.Outcome);
Assert.Equal(1, fixture.Printer.Cancellations);
}
[Fact]
public async Task PrunedServerReceiptRetiresTransportOnlyAfterSevenDays()
{
using ControlFixture fixture = new();
DeviceControlCommand old = ControlFixture.Command() with
{
CreatedAt = DateTimeOffset.UtcNow.AddDays(-9).ToString("O", System.Globalization.CultureInfo.InvariantCulture),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-9).AddMinutes(15).ToString("O", System.Globalization.CultureInfo.InvariantCulture),
};
DeviceControlReceipt receipt = new("receipt", "CANCEL_REQUESTED", old.CreatedAt, "Synthetic committed result.");
using(AgentStore store = new(fixture.DirectoryPath))
{
Assert.True(store.BeginControlCommand(old));
store.SaveControlReceipt(old.CommandId, receipt);
}
fixture.ReceiptStatus = HttpStatusCode.NotFound;
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
using AgentStore inspected = new(fixture.DirectoryPath);
Assert.Empty(inspected.PendingControlReceipts());
Assert.Equal(receipt, inspected.GetControlReceipt(old.CommandId));
Assert.Equal(0, fixture.Printer.Cancellations);
fixture.Commands = [ControlFixture.Command("REFRESH_INVENTORY", "new-command")];
fixture.ReceiptStatus = HttpStatusCode.OK;
await runtime.PollControlsOnceAsync(CancellationToken.None);
Assert.Equal(1, fixture.Inventories);
}
[Fact]
public async Task FutureReceipt404IsNotTreatedAsDeliveredOrRetired()
{
using ControlFixture fixture = new();
fixture.SeedSpooledJob();
fixture.Commands = [ControlFixture.Command()];
fixture.ReceiptStatus = HttpStatusCode.NotFound;
using AgentRuntime runtime = fixture.Runtime();
await Assert.ThrowsAsync<HttpRequestException>(() => runtime.PollControlsOnceAsync(CancellationToken.None));
using AgentStore store = new(fixture.DirectoryPath);
Assert.Single(store.PendingControlReceipts());
Assert.Equal(1, fixture.Printer.Cancellations);
}
[Fact]
public async Task OlderOrConflictingRevisionCannotResumePausedDevice()
{
using ControlFixture fixture = new();
fixture.Desired = new DeviceDesiredState(5, true, "CONNECTED");
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
fixture.Desired = new DeviceDesiredState(4, false, "CONNECTED");
await Assert.ThrowsAsync<InvalidDataException>(() => runtime.PollControlsOnceAsync(CancellationToken.None));
fixture.Desired = new DeviceDesiredState(5, false, "CONNECTED");
await Assert.ThrowsAsync<InvalidDataException>(() => runtime.PollControlsOnceAsync(CancellationToken.None));
Assert.True(runtime.GetSnapshot().Control!.DesiredState.DispatchPaused);
}
[Fact]
public async Task LostReceiptAndDuplicateDeliveryNeverRepeatSpoolCancellation()
{
using ControlFixture fixture = new();
fixture.SeedSpooledJob();
fixture.Commands = [ControlFixture.Command()];
fixture.ReceiptFailures = 1;
using(AgentRuntime first = fixture.Runtime())
{
await Assert.ThrowsAsync<HttpRequestException>(() => first.PollControlsOnceAsync(CancellationToken.None));
}
using AgentRuntime restarted = fixture.Runtime();
await restarted.PollControlsOnceAsync(CancellationToken.None);
await restarted.PollControlsOnceAsync(CancellationToken.None);
Assert.Equal(1, fixture.Printer.Cancellations);
Assert.True(fixture.Receipts.Count >= 3);
Assert.All(fixture.Receipts, value => Assert.Equal(fixture.Receipts[0], value));
Assert.Equal("CANCEL_REQUESTED", JsonSerializer.Deserialize<DeviceControlReceipt>(fixture.Receipts[0], PrintApiClient.JsonOptions)!.Outcome);
}
[Fact]
public async Task InterruptedExecutionMarkerBecomesUnknownWithoutNativeCall()
{
using ControlFixture fixture = new();
fixture.SeedSpooledJob();
fixture.Commands = [ControlFixture.Command()];
using(AgentStore store = new(fixture.DirectoryPath))
{ Assert.True(store.BeginControlCommand(fixture.Commands[0])); }
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
Assert.Equal(0, fixture.Printer.Cancellations);
using AgentStore inspected = new(fixture.DirectoryPath);
Assert.Equal("OUTCOME_UNKNOWN", inspected.GetControlReceipt("command")!.Outcome);
Assert.Equal("SPOOLED", inspected.GetState("job"));
}
[Theory]
[InlineData(true, "EXPIRED")]
[InlineData(false, "OWNERSHIP_MISMATCH")]
public async Task ExpiryAndMissingOwnershipNeverTouchWindows(bool expired, string expected)
{
using ControlFixture fixture = new();
fixture.Commands = [ControlFixture.Command(expired: expired)];
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
using AgentStore store = new(fixture.DirectoryPath);
Assert.Equal(expected, store.GetControlReceipt("command")!.Outcome);
Assert.Equal(0, fixture.Printer.Cancellations);
}
[Theory]
[InlineData("ALREADY_ABSENT")]
[InlineData("OWNERSHIP_MISMATCH")]
public async Task VerifiedNativeRefusalIsReportedWithoutInventingCancellation(string outcome)
{
using ControlFixture fixture = new();
fixture.SeedSpooledJob();
fixture.Commands = [ControlFixture.Command()];
fixture.Printer.CancellationOutcome = outcome;
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
using AgentStore store = new(fixture.DirectoryPath);
Assert.Equal(outcome, store.GetControlReceipt("command")!.Outcome);
}
[Fact]
public async Task DisconnectedRefreshWaitsForReconnectAndRestrictionsSurviveRestart()
{
using ControlFixture fixture = new();
fixture.Desired = new DeviceDesiredState(3, true, "DISCONNECTED");
fixture.Commands = [ControlFixture.Command("REFRESH_INVENTORY")];
using(AgentRuntime first = fixture.Runtime())
{ await first.PollControlsOnceAsync(CancellationToken.None); }
Assert.Equal(0, fixture.Inventories);
Assert.Empty(fixture.Receipts);
using AgentRuntime restarted = fixture.Runtime();
fixture.PollStatus = HttpStatusCode.NotFound;
await Assert.ThrowsAsync<InvalidDataException>(() => restarted.PollControlsOnceAsync(CancellationToken.None));
Assert.Equal("DISCONNECTED", restarted.GetSnapshot().Control!.DesiredState.ConnectionState);
fixture.PollStatus = HttpStatusCode.OK;
fixture.Desired = new DeviceDesiredState(4, false, "CONNECTED");
await restarted.PollControlsOnceAsync(CancellationToken.None);
Assert.Equal(1, fixture.Inventories);
Assert.Equal("INVENTORY_REFRESHED", restarted.GetSnapshot().Control!.LastOutcome);
}
[Fact]
public async Task AuthDenialPersistsUntilNewEnrollmentScopeWithoutErasingJournal()
{
using ControlFixture fixture = new();
fixture.SeedSpooledJob();
fixture.PollStatus = HttpStatusCode.Unauthorized;
using(AgentRuntime first = fixture.Runtime())
{ await first.PollControlsOnceAsync(CancellationToken.None); }
using(AgentRuntime restarted = fixture.Runtime())
{
await restarted.PollControlsOnceAsync(CancellationToken.None);
Assert.True(restarted.GetSnapshot().Control!.AuthorizationRequired);
Assert.Equal(1, fixture.Polls);
}
using AgentStore store = new(fixture.DirectoryPath);
Assert.Equal("SPOOLED", store.GetState("job"));
store.SaveConfiguration(new AgentConfiguration("https://synthetic.invalid", "new-device", "new-synthetic-token"));
Assert.False(store.LoadControlState().AuthorizationRequired);
Assert.Null(store.GetState("job"));
fixture.PollStatus = HttpStatusCode.OK;
using AgentRuntime reenrolled = fixture.Runtime();
await reenrolled.PollControlsOnceAsync(CancellationToken.None);
Assert.Equal("SUPPORTED", reenrolled.GetSnapshot().Control!.Support);
}
[Fact]
public async Task Legacy404ExplicitlyAllowsNormalPrinting()
{
using ControlFixture fixture = new();
fixture.PollStatus = HttpStatusCode.NotFound;
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
Assert.Equal("LEGACY", runtime.GetSnapshot().Control!.Support);
}
}
@@ -61,6 +61,10 @@ public sealed class AgentRuntimeBacklogTests
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string path = request.RequestUri!.AbsolutePath;
if(path.EndsWith("/control/poll", StringComparison.Ordinal))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
if(path.EndsWith("/content", StringComparison.Ordinal))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([1]) });
@@ -85,6 +85,10 @@ public sealed class AgentRuntimeHeartbeatTests
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string path = request.RequestUri!.AbsolutePath;
if(path.EndsWith("/control/poll", StringComparison.Ordinal))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
string body = "{}";
if(path.EndsWith("heartbeat", StringComparison.Ordinal))
{
@@ -102,6 +102,10 @@ public sealed class AgentRuntimeProfileTests
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if(request.RequestUri!.AbsolutePath.EndsWith("/control/poll", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
Assert.Equal("synthetic-token", request.Headers.Authorization?.Parameter);
string path = request.RequestUri!.AbsolutePath;
if(request.Content is not null)
@@ -111,6 +111,10 @@ public sealed class AgentRuntimeQueuedJobsTests
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string path = request.RequestUri!.AbsolutePath;
if(path.EndsWith("/control/poll", StringComparison.Ordinal))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
string body = "{}";
if(path.EndsWith("/queued", StringComparison.Ordinal))
{
@@ -101,6 +101,10 @@ public sealed class AgentRuntimeTests
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if(request.RequestUri!.AbsolutePath.EndsWith("/control/poll", StringComparison.Ordinal))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
if(count() == 1)
{ throw new TaskCanceledException("simulated timeout"); }
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
@@ -122,6 +122,10 @@ public sealed class AgentRuntimeUpdateTests
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if(request.RequestUri!.AbsolutePath.EndsWith("/control/poll", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if(request.RequestUri!.AbsolutePath.EndsWith("/claim", StringComparison.Ordinal))
{
state.RecordClaim();
@@ -0,0 +1,151 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
public sealed class AgentSessionIntegrationTests
{
[Fact]
public async Task TransientResumeFailureDoesNotRequireNewSignIn()
{
using SessionFixture fixture = new();
fixture.SaveSession();
using AgentRuntime runtime = fixture.Runtime();
_ = await runtime.PollControlsOnceAsync(CancellationToken.None);
fixture.Session.ResumeFailure = QqqSessionException.FailureKind.TransportRefused;
await Assert.ThrowsAsync<QqqSessionException>(() => runtime.PollControlsOnceAsync(CancellationToken.None));
fixture.Session.ResumeFailure = null;
_ = await runtime.PollControlsOnceAsync(CancellationToken.None);
Assert.Equal(2, fixture.Session.Paths.Count);
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
}
[Fact]
public async Task SavedSignInBeforeEnrollmentResumesWithoutClaimingJobs()
{
using SessionFixture fixture = new();
File.Delete(Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi"));
fixture.Session.Binding = fixture.Session.Binding with { DeviceId = null };
fixture.SaveSession();
using AgentRuntime runtime = fixture.Runtime();
_ = await runtime.PollControlsOnceAsync(CancellationToken.None);
Assert.True(runtime.GetSnapshot().Authentication!.SignedIn);
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
Assert.Empty(fixture.Session.Paths);
}
[Fact]
public async Task RejectedOriginDoesNotKeepAuthenticationGateLocked()
{
using SessionFixture fixture = new();
fixture.Factory = origin => string.Equals(origin.AbsolutePath, "/", StringComparison.Ordinal)
? fixture.Session : new QqqSessionClient(origin);
using AgentRuntime runtime = fixture.Runtime();
QqqSessionCredential invalid = new(new Uri("https://synthetic.invalid/not-an-origin"), "synthetic-cookie");
await Assert.ThrowsAsync<ArgumentException>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: invalid), CancellationToken.None));
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
}
[Fact]
public async Task DelayedOldSessionDenialCannotBlockNewSignIn()
{
using SessionFixture fixture = new();
fixture.SaveSession();
fixture.Session.DelayUnauthorizedPoll = true;
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
using AgentRuntime runtime = fixture.Runtime();
Task<int> oldPoll = runtime.PollControlsOnceAsync(timeout.Token);
await fixture.Session.PollEntered.Task.WaitAsync(timeout.Token);
fixture.NextSession = new SessionFixture.FakeSession();
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.NextSession.ExportCredential()), timeout.Token);
fixture.Session.ReleasePoll.TrySetResult();
_ = await oldPoll;
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
_ = await runtime.PollControlsOnceAsync(timeout.Token);
Assert.Single(fixture.NextSession.Paths);
}
[Fact]
public async Task InstalledLegacyPairingWaitsForSignInWithoutNetworkOrJournalLoss()
{
using SessionFixture fixture = new();
fixture.Local.SeedSpooledJob();
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
Assert.Equal(0, fixture.Created);
Assert.Empty(fixture.Session.Paths);
Assert.False(runtime.GetSnapshot().Connected);
using AgentStore store = new(fixture.Local.DirectoryPath);
Assert.Equal("device", store.LoadConfiguration()!.DeviceId);
Assert.Equal("SPOOLED", store.GetState("job"));
}
[Fact]
public async Task RestoredSessionUsesNativeTransportAndPeriodicallyResumes()
{
using SessionFixture fixture = new();
fixture.SaveSession();
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
await runtime.PollControlsOnceAsync(CancellationToken.None);
Assert.Equal(1, fixture.Created);
Assert.Equal(1, fixture.Session.Resumes);
Assert.Equal(new[] { "device/control/poll", "device/control/poll" }, fixture.Session.Paths, StringComparer.Ordinal);
Assert.True(runtime.GetSnapshot().Authentication!.SignedIn);
string snapshot = JsonSerializer.Serialize(runtime.GetSnapshot(), PrintApiClient.JsonOptions);
Assert.DoesNotContain("synthetic-qqq-cookie", snapshot, StringComparison.Ordinal);
Assert.DoesNotContain("synthetic-token", snapshot, StringComparison.Ordinal);
string protectedData = Encoding.UTF8.GetString(File.ReadAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi")));
Assert.DoesNotContain("synthetic-qqq-cookie", protectedData, StringComparison.Ordinal);
}
[Fact]
public async Task RestoredDifferentPrincipalCannotResumeOrReplaceStoredIdentity()
{
using SessionFixture fixture = new();
fixture.SaveSession();
fixture.Session.Binding = fixture.Session.Binding with { PrincipalId = "different-user" };
using AgentRuntime runtime = fixture.Runtime();
await runtime.PollControlsOnceAsync(CancellationToken.None);
Assert.Empty(fixture.Session.Paths);
using AgentStore store = new(fixture.Local.DirectoryPath);
Assert.Equal("principal", store.LoadAuthentication()!.Binding.PrincipalId);
}
[Fact]
public async Task NativeAdvertisedControlsNeverFallBackOn404()
{
using SessionFixture fixture = new();
fixture.SaveSession();
fixture.Session.Status = HttpStatusCode.NotFound;
using AgentRuntime runtime = fixture.Runtime();
await Assert.ThrowsAsync<InvalidDataException>(() => runtime.PollControlsOnceAsync(CancellationToken.None));
Assert.NotEqual("LEGACY", runtime.GetSnapshot().Control!.Support, StringComparer.Ordinal);
}
[Fact]
public async Task ExplicitSignInBindsLegacyAndSignOutRetainsDeviceAndJournal()
{
using SessionFixture fixture = new();
fixture.Local.SeedSpooledJob();
using AgentRuntime runtime = fixture.Runtime();
PipeRequest request = new("sign-in", SessionCredential: fixture.Session.ExportCredential(), ClaimLegacyDevice: true);
Assert.DoesNotContain("synthetic-qqq-cookie", request.ToString(), StringComparison.Ordinal);
_ = await runtime.SignInAsync(request, CancellationToken.None);
Assert.True(fixture.Session.ClaimedLegacy);
_ = await runtime.SignOutAsync(CancellationToken.None);
await runtime.PollControlsOnceAsync(CancellationToken.None);
Assert.Empty(fixture.Session.Paths);
using AgentStore store = new(fixture.Local.DirectoryPath);
Assert.Null(store.LoadAuthentication()!.Credential);
Assert.Equal("principal", store.LoadAuthentication()!.Binding.PrincipalId);
Assert.Equal("device", store.LoadConfiguration()!.DeviceId);
Assert.Equal("SPOOLED", store.GetState("job"));
}
}
@@ -0,0 +1,58 @@
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
public sealed class BrowserCallbackTests
{
[Fact]
public void CallbackCompanionFollowsTheInstalledCliRatherThanDefaultBrandFolder()
{
string root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Custom connector");
Assert.Equal(Path.Combine(root, "App", "Qqq.PrintAgent.exe"), QqqBrowserCallback.CompanionSibling(Path.Combine(root, "Cli", "qqq-print-agent.exe")));
Assert.Throws<UnauthorizedAccessException>(() => QqqBrowserCallback.CompanionSibling(Path.Combine(root, "qqq-print-agent.exe")));
}
[Theory]
[InlineData("https://example.invalid/oauth/callback?code=x&state=y")]
[InlineData("io.qrun.qqq-print-agent:/other?code=x&state=y")]
[InlineData("io.qrun.qqq-print-agent:/oauth/callback")]
[InlineData("io.qrun.qqq-print-agent:/oauth/callback?code=x#fragment")]
public void InvalidCallbacksNeverReachAnInbox(string callback)
=> Assert.Throws<InvalidDataException>(() => QqqBrowserCallback.Validate(callback));
[Fact]
public void OverlongCallbackIsRejected()
=> Assert.Throws<InvalidDataException>(() => QqqBrowserCallback.Validate(QqqBrowserCallback.RedirectUri + "?code=" + new string('a', 8192)));
[Fact]
public async Task NoActiveAttemptFailsWithoutLaunchingACompanion()
{
using CancellationTokenSource timeout = new(TimeSpan.FromMilliseconds(100));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => QqqBrowserCallback.ForwardAsync(
QqqBrowserCallback.RedirectUri + "?code=synthetic&state=synthetic", "qqq-no-inbox-" + Guid.NewGuid().ToString("N"), Environment.ProcessPath!, timeout.Token));
}
[Fact]
public async Task ActiveSameSessionInboxReceivesOneBoundedCallback()
{
string name = "qqq-callback-test-" + Guid.NewGuid().ToString("N");
using QqqBrowserCallback inbox = new(name);
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
Task<Uri> received = inbox.ReceiveAsync(timeout.Token);
string callback = QqqBrowserCallback.RedirectUri + "?code=synthetic&state=synthetic";
await QqqBrowserCallback.ForwardAsync(callback, name, Environment.ProcessPath!, timeout.Token);
Assert.Equal(callback, (await received).AbsoluteUri);
}
[Fact]
public async Task WrongCompanionExecutableReceivesNoCallbackBytes()
{
string name = "qqq-callback-test-" + Guid.NewGuid().ToString("N");
using QqqBrowserCallback inbox = new(name);
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
Task<Uri> received = inbox.ReceiveAsync(timeout.Token);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => QqqBrowserCallback.ForwardAsync(
QqqBrowserCallback.RedirectUri + "?code=synthetic&state=synthetic", name, "C:\\not-the-installed-companion.exe", timeout.Token));
await Assert.ThrowsAsync<IOException>(() => received);
}
}
@@ -0,0 +1,144 @@
using System.Globalization;
using System.IO.Pipes;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
internal sealed class ControlFixture : IDisposable
{
internal string DirectoryPath { get; } = Path.Combine(Path.GetTempPath(), "qqq-control-" + Guid.NewGuid().ToString("N"));
internal DeviceDesiredState Desired { get; set; } = new(1, false, "CONNECTED");
internal DeviceControlCommand[] Commands { get; set; } = [];
internal HttpStatusCode PollStatus { get; set; } = HttpStatusCode.OK;
internal string? PollBody { get; set; }
internal int ReceiptFailures { get; set; }
internal HttpStatusCode ReceiptStatus { get; set; } = HttpStatusCode.OK;
internal int Polls;
internal int Inventories;
internal int Heartbeats;
internal int Claims;
internal List<string> Receipts { get; } = [];
internal SyntheticPrinter Printer { get; } = new();
internal bool OfferJob { get; set; }
internal static byte[] Content => [1, 2, 3];
internal ControlFixture()
{
using AgentStore store = new(DirectoryPath);
store.SaveConfiguration(new AgentConfiguration("https://synthetic.invalid", "device", "synthetic-token"));
store.SetExposure("printer", true, Printer.Discover());
}
internal AgentRuntime Runtime()
{
string pipe = "qqq-control-test-" + Guid.NewGuid().ToString("N");
return new AgentRuntime(DirectoryPath, _ => { }, Printer, config => new PrintApiClient(config.ServerUrl, config.DeviceToken, new Handler(this)), pipe,
() => new NamedPipeServerStream(pipe, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
{ ControlIntervalOverride = TimeSpan.FromMilliseconds(30), HeartbeatInterval = TimeSpan.FromMilliseconds(30) };
}
internal static DeviceControlCommand Command(string action = "CANCEL_SPOOL", string id = "command", bool expired = false) => new(id, "request-" + id, action,
DateTimeOffset.UtcNow.AddMinutes(expired ? -20 : -1).ToString("O", CultureInfo.InvariantCulture),
DateTimeOffset.UtcNow.AddMinutes(expired ? -10 : 10).ToString("O", CultureInfo.InvariantCulture), string.Equals(action, "CANCEL_SPOOL", StringComparison.Ordinal) ? "job" : null);
internal void SeedSpooledJob()
{
using AgentStore store = new(DirectoryPath);
store.TrackJob(Job(), "Synthetic printer");
PrintEvent receipt = new("spooled", "SPOOLED", SpoolerJobId: "123");
store.Record("job", receipt);
store.Acknowledge(receipt.EventId);
}
private static PrintJob Job() => new("job", "printer", "Synthetic", "PDF", Convert.ToHexString(SHA256.HashData(Content)), Content.Length,
new PrintOptions(), DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), "CLAIMED");
public void Dispose()
{
Printer.Release.TrySetResult();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
Directory.Delete(DirectoryPath, recursive: true);
}
internal sealed class SyntheticPrinter : IPrinterBackend, IPrinterJobControl
{
internal int Cancellations;
internal bool BlockPrint { get; set; }
internal string? CancellationOutcome { get; set; }
internal bool LoseCancellationResult { get; set; }
internal TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IReadOnlyList<PrinterInfo> Discover() => [new("printer", "Synthetic printer", "Fixture", "None", "READY", ["PDF"])];
public async Task<string> PrintAsync(PrintJob job, byte[] content, CancellationToken cancellationToken)
{
Entered.TrySetResult();
if(BlockPrint)
{ await Release.Task.WaitAsync(cancellationToken); }
return "123";
}
public Task CancelAsync(string printerName, string jobId, string spoolerJobId, CancellationToken cancellationToken = default)
{
Assert.Equal("Synthetic printer", printerName);
Assert.Equal("job", jobId);
Assert.Equal("123", spoolerJobId);
Interlocked.Increment(ref Cancellations);
if(LoseCancellationResult)
{
throw new IOException("Synthetic isolated worker reply lost after action.");
}
if(CancellationOutcome is not null)
{ throw new SpoolCancellationException(CancellationOutcome, "Synthetic refusal."); }
return Task.CompletedTask;
}
}
private sealed class Handler(ControlFixture fixture) : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string path = request.RequestUri!.AbsolutePath;
object result = new { accepted = true };
if(path.EndsWith("control/poll", StringComparison.Ordinal))
{
Interlocked.Increment(ref fixture.Polls);
if(fixture.PollStatus != HttpStatusCode.OK)
{ return new HttpResponseMessage(fixture.PollStatus); }
if(fixture.PollBody is not null)
{
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(fixture.PollBody, Encoding.UTF8, "application/json") };
}
result = new DeviceControlPoll(1, DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), 1, fixture.Desired, fixture.Commands);
}
else if(path.EndsWith("/receipt", StringComparison.Ordinal))
{
fixture.Receipts.Add(await request.Content!.ReadAsStringAsync(cancellationToken));
if(fixture.ReceiptStatus != HttpStatusCode.OK)
{
return new HttpResponseMessage(fixture.ReceiptStatus);
}
if(fixture.ReceiptFailures-- > 0)
{ throw new HttpRequestException("Synthetic lost receipt response."); }
}
else if(path.EndsWith("/printers", StringComparison.Ordinal))
{ Interlocked.Increment(ref fixture.Inventories); }
else if(path.EndsWith("/heartbeat", StringComparison.Ordinal))
{ Interlocked.Increment(ref fixture.Heartbeats); }
else if(path.EndsWith("/claim", StringComparison.Ordinal))
{
Interlocked.Increment(ref fixture.Claims);
result = new { job = fixture.OfferJob ? Job() : null };
fixture.OfferJob = false;
}
else if(path.EndsWith("/content", StringComparison.Ordinal))
{ return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(ControlFixture.Content) }; }
else if(path.EndsWith("/queued", StringComparison.Ordinal))
{ result = new { jobs = Array.Empty<PrintJob>() }; }
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(result, PrintApiClient.JsonOptions), Encoding.UTF8, "application/json") };
}
}
}
@@ -0,0 +1,31 @@
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
public sealed class NativeCliRoutingTests
{
[Theory]
[InlineData("printers", "printers")]
[InlineData("run", "reconnect")]
[InlineData("sign-out", "sign-out")]
public async Task NormalCommandsUseServiceRatherThanPrivateBearerTransport(string command, string expected)
{
PipeRequest? sent = null;
int result = await Program.DispatchAsync([command], request =>
{
sent = request;
return Task.FromResult("Synthetic service accepted request.");
});
Assert.Equal(0, result);
Assert.Equal(expected, sent!.Operation);
}
[Fact]
public async Task ForegroundOptionsRequireExplicitCompatibilityBeforeAnyServiceCall()
{
await Assert.ThrowsAsync<ArgumentException>(() => Program.DispatchAsync(["run", "--once"], _ => throw new InvalidOperationException("Must not contact service.")));
Assert.Throws<ArgumentException>(() => Program.UseLegacyCompatibility(["run", "--legacy-compat", "--service"]));
Assert.True(Program.UseLegacyCompatibility(["run", "--legacy-compat"]));
Assert.False(Program.UseLegacyCompatibility(["run"]));
}
}
@@ -0,0 +1,89 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
internal sealed class SessionFixture : IDisposable
{
internal ControlFixture Local { get; } = new();
internal FakeSession Session { get; } = new();
internal FakeSession? NextSession { get; set; }
internal Func<Uri, IQqqSessionClient>? Factory { get; set; }
internal int Created;
internal AgentRuntime Runtime() => new(Local.DirectoryPath, _ => { }, Local.Printer, sessionFactory: origin =>
{
Created++;
return Factory?.Invoke(origin) ?? NextSession ?? Session;
})
{ SessionResumeInterval = TimeSpan.Zero };
internal void SaveSession()
{
using AgentStore store = new(Local.DirectoryPath);
store.SaveAuthentication(new AgentAuthentication(Session.Binding, Session.ExportCredential()));
}
public void Dispose() => Local.Dispose();
internal sealed class FakeSession : IQqqSessionClient
{
public Uri Origin { get; } = new("https://synthetic.invalid/");
public bool IsAuthenticated { get; private set; }
internal QqqSessionBinding Binding { get; set; } = new(new Uri("https://synthetic.invalid/"), "principal", "tenant", "device");
internal List<string> Paths { get; } = [];
internal int Resumes;
internal QqqSessionException.FailureKind? ResumeFailure { get; set; }
internal bool ClaimedLegacy;
internal HttpStatusCode Status { get; set; } = HttpStatusCode.OK;
internal bool DelayUnauthorizedPoll { get; set; }
internal TaskCompletionSource PollEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal TaskCompletionSource ReleasePoll { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public void Dispose() { }
public QqqSessionCredential ExportCredential() => new(Origin, "synthetic-qqq-cookie");
public Task RestoreSessionAsync(QqqSessionCredential credential, CancellationToken cancellationToken = default)
{
Assert.Equal(Origin, credential.Origin);
IsAuthenticated = true;
return Task.CompletedTask;
}
public Task ResumeSessionAsync(CancellationToken cancellationToken = default)
{
Resumes++;
if(ResumeFailure is { } failure)
{
throw new QqqSessionException(failure, "Synthetic session resume failure.");
}
return Task.CompletedTask;
}
public Task SignOutAsync(CancellationToken cancellationToken = default) { IsAuthenticated = false; return Task.CompletedTask; }
public Task<QqqSessionBinding> GetSessionIdentityAsync(CancellationToken cancellationToken = default) => Task.FromResult(Binding);
public Task<QqqSessionBinding> BindDeviceAsync(QqqDeviceCredential credential, bool claimLegacyDevice = false, CancellationToken cancellationToken = default)
{
Assert.Equal("device", credential.DeviceId);
ClaimedLegacy = claimLegacyDevice;
return Task.FromResult(Binding);
}
public async Task<HttpResponseMessage> SendPrintingAsync(HttpMethod method, string relativePath, HttpContent? content = null, CancellationToken cancellationToken = default)
{
Paths.Add(relativePath);
if(DelayUnauthorizedPoll)
{
PollEntered.TrySetResult();
await ReleasePoll.Task.WaitAsync(cancellationToken);
throw new QqqSessionException(QqqSessionException.FailureKind.AuthenticationRequired, "Synthetic old-session denial.");
}
object body = new DeviceControlPoll(1, DateTime.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture), 5, new DeviceDesiredState(1, false, "CONNECTED"), []);
return new HttpResponseMessage(Status) { Content = new StringContent(JsonSerializer.Serialize(body, PrintApiClient.JsonOptions), Encoding.UTF8, "application/json") };
}
public Task<QqqApiDiscovery> DiscoverAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<QqqAuthenticationMetadata> GetAuthenticationMetadataAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task SignInWithPasswordAsync(string username, string password, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<QqqBrowserLogin> BeginBrowserLoginAsync(Uri redirectUri, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task CompleteBrowserLoginAsync(QqqBrowserLogin attempt, Uri callback, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
}
@@ -0,0 +1,43 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Core.Tests;
public sealed class DeviceControlApiTests
{
[Fact]
public async Task SharedControlFixtureIsAcceptedAndCapabilitiesAdvertised()
{
string body = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "fixtures", "control-poll.response.json"));
using Handler handler = new(body);
using PrintApiClient api = new("https://example.invalid", "fixture", handler);
DeviceControlPoll response = (await api.ControlPollAsync(0, CancellationToken.None))!;
Assert.True(response.DesiredState.DispatchPaused);
Assert.Equal(2, response.Commands.Count);
using var request = JsonDocument.Parse(handler.RequestBody!);
Assert.Equal(1, request.RootElement.GetProperty("controlVersion").GetInt32());
Assert.Equal(2, request.RootElement.GetProperty("capabilities").GetArrayLength());
}
[Theory]
[InlineData("{}")]
[InlineData("null")]
[InlineData("{\"controlVersion\":2}")]
public async Task MissingOrInvalidControlStateNeverMeansConnected(string body)
{
using PrintApiClient api = new("https://example.invalid", "fixture", new Handler(body));
await Assert.ThrowsAsync<InvalidDataException>(() => api.ControlPollAsync(0, CancellationToken.None));
}
private sealed class Handler(string body) : HttpMessageHandler
{
internal string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestBody = await request.Content!.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
}
}
}
@@ -7,6 +7,24 @@ namespace Qqq.PrintAgent.Core.Tests;
public sealed class PrintWorkerTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task PausedOrDisconnectedDrainsReceiptsWithoutClaiming(bool synchronize)
{
using FixtureHandler handler = new(null, []);
using PrintApiClient api = new("http://localhost", "fixture", handler);
MemoryJournal journal = new();
journal.Record("finished", new PrintEvent("receipt", "SPOOLED", SpoolerJobId: "1"));
FakePrinter printer = new();
Assert.False(await new PrintWorker(api, printer, journal, canDispatch: () => false, canSynchronize: () => synchronize).RunOnceAsync());
Assert.Equal(0, handler.Claims);
Assert.Equal(synchronize ? 1 : 0, handler.Inventories);
Assert.Equal(synchronize ? 1 : 0, handler.Heartbeats);
Assert.Empty(journal.Pending());
Assert.Equal("SPOOLED", Assert.Single(handler.Events));
Assert.Equal(0, printer.PrintCount);
}
[Theory]
[InlineData("SPOOLED")]
[InlineData("SPOOLER_COMPLETED")]
@@ -134,11 +152,20 @@ public sealed class PrintWorkerTests
private sealed class FixtureHandler(PrintJob? job, byte[] content) : HttpMessageHandler
{
internal int Claims { get; private set; }
internal int Heartbeats { get; private set; }
internal int Inventories { get; private set; }
public List<string> Events { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Assert.Equal("Bearer", request.Headers.Authorization?.Scheme);
string path = request.RequestUri!.AbsolutePath;
if(path.EndsWith("/claim", StringComparison.Ordinal))
{ Claims++; }
if(path.EndsWith("/heartbeat", StringComparison.Ordinal))
{ Heartbeats++; }
if(path.EndsWith("/printers", StringComparison.Ordinal))
{ Inventories++; }
if(path.EndsWith("/content", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(content) };
@@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<PrintingProtocolFixtures Condition="'$(PrintingProtocolFixtures)' == ''">$(MSBuildThisFileDirectory)../../../qqq-print-protocol/fixtures</PrintingProtocolFixtures>
</PropertyGroup>
<ItemGroup>
@@ -25,6 +26,9 @@
</ItemGroup>
<ItemGroup><Content Include="../../../qqq-print-protocol/fixtures/claim.response.json" Link="fixtures/claim.response.json" CopyToOutputDirectory="PreserveNewest" /><Content Include="../../../qqq-print-protocol/fixtures/enroll.response.json" Link="fixtures/enroll.response.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup></Project>
<ItemGroup>
<Content Include="$(PrintingProtocolFixtures)/*.json" Link="fixtures/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,519 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Core.Tests;
public sealed class QqqSessionAuthenticationTests
{
private static readonly Uri Origin = new("https://printing.example.invalid");
private const string Discovery = """{"apiVersion":"1.9.0","features":["version","metaData","processes","processCancel","extensions","widgets","nativeSessionLogin"],"clientChannels":["api"],"extensions":[{"name":"printing","version":"1.0.0","pathPrefix":"/qbit/printing"}]}""";
private const string Table = """{"name":"local","type":"TABLE_BASED"}""";
private const string Identity = """{"principalId":"user-1","tenantId":"tenant-1","deviceId":"device-1"}""";
private const string OAuth = """{"name":"configured-provider","type":"OAUTH2","values":{"clientId":"qqq-client","scopes":"openid profile offline_access","nativeClientId":"not-the-session-client","nativeSessionLogin":{"flow":"AUTHORIZATION_CODE_PKCE_SESSION","codeChallengeMethod":"S256","prompt":"login","issuer":"https://id.example.invalid/application/","discoveryUrl":"https://id.example.invalid/application/.well-known/openid-configuration","redirectUris":["io.qrun.qqq-print-agent:/oauth/callback"]}}}""";
private const string Provider = """{"issuer":"https://id.example.invalid/application/","authorization_endpoint":"https://id.example.invalid/authorize","code_challenge_methods_supported":["S256"]}""";
private static readonly Uri Redirect = new("io.qrun.qqq-print-agent:/oauth/callback");
[Theory]
[InlineData("1.9.0", "1.7.0")]
[InlineData("1.9.0", "2.0.0")]
[InlineData("1.9.0", "1.09.0")]
[InlineData("\"api\"", "\"windows\"")]
[InlineData("\"printing\"", "\"other\"")]
[InlineData("\"1.0.0\"", "\"2.0.0\"")]
[InlineData("/qbit/printing", "/other")]
[InlineData("\"widgets\"", "\"other\"")]
public async Task DiscoveryFailurePrecedesAllCredentials(string oldValue, string newValue)
{
using var handler = new Handler();
handler.Add(Discovery.Replace(oldValue, newValue, StringComparison.Ordinal));
using var client = new QqqSessionClient(Origin, handler);
QqqSessionException exception = await Assert.ThrowsAsync<QqqSessionException>(() => client.SignInWithPasswordAsync("user", "secret"));
Assert.Equal(QqqSessionException.FailureKind.ConfigurationRequired, exception.Failure);
Request request = Assert.Single(handler.Requests);
Assert.Equal("/qqq/v1/version", request.Uri.AbsolutePath);
AssertPublic(request);
}
[Fact]
public async Task PasswordIsUsedOnlyForManageSessionAndCookieOnlyResponseIsAccepted()
{
using Handler handler = TableHandler();
handler.Add("{}", cookie: "sessionUUID=session-secret; Path=/; Secure; HttpOnly");
handler.Add(Identity);
handler.Add("{}");
using var client = new QqqSessionClient(Origin, handler);
await client.SignInWithPasswordAsync("user", "password-not-retained");
AssertPublic(handler.Requests[0]);
AssertPublic(handler.Requests[1]);
Request login = handler.Requests[2];
Assert.Equal("/qqq/v1/manageSession", login.Uri.AbsolutePath);
Assert.Equal("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:password-not-retained")), login.Authorization);
Assert.Null(login.Cookie);
QqqSessionBinding binding = await client.GetSessionIdentityAsync();
Assert.Equal("user-1", binding.PrincipalId);
Assert.Null(handler.Requests[3].Authorization);
Assert.Contains("sessionUUID=session-secret", handler.Requests[3].Cookie, StringComparison.Ordinal);
QqqSessionCredential credential = client.ExportCredential();
Assert.Equal("QQQ session credential (redacted)", credential.ToString());
Assert.Equal("session-secret", credential.SessionToken);
await client.SignOutAsync();
Assert.False(client.IsAuthenticated);
Assert.Equal("/qqq/v1/logout", handler.Requests[4].Uri.AbsolutePath);
Assert.Null(handler.Requests[4].Authorization);
Assert.Equal(Origin.GetLeftPart(UriPartial.Authority), handler.Requests[4].Origin);
}
[Theory]
[InlineData("""{"uuid":"other"}""", "sessionUUID=session-secret; Path=/; HttpOnly")]
[InlineData("""{"uuid":"one","UUID":"two"}""", null)]
[InlineData("{}", null)]
[InlineData("""{"uuid":10}""", null)]
[InlineData("null", "sessionUUID=session-secret; Path=/")]
[InlineData("{}", "sessionUUID=secret; Path=/elsewhere")]
[InlineData("{}", "sessionUUID=; Path=/; Max-Age=0")]
public async Task AmbiguousOrInvalidSessionNeverAuthenticates(string body, string? cookie)
{
using Handler handler = TableHandler();
handler.Add(body, cookie: cookie);
using var client = new QqqSessionClient(Origin, handler);
await Assert.ThrowsAsync<QqqSessionException>(() => client.SignInWithPasswordAsync("user", "password"));
Assert.False(client.IsAuthenticated);
}
[Fact]
public async Task ConflictingSessionCookiesAreRejected()
{
using Handler handler = TableHandler();
handler.AddResponse(() =>
{
HttpResponseMessage response = Response("{}");
response.Headers.Add("Set-Cookie", new[] { "sessionUUID=one; Path=/; HttpOnly", "sessionId=two; Path=/; HttpOnly" });
return response;
});
using var client = new QqqSessionClient(Origin, handler);
await Assert.ThrowsAsync<QqqSessionException>(() => client.SignInWithPasswordAsync("user", "password"));
Assert.False(client.IsAuthenticated);
}
[Theory]
[InlineData("TOKEN")]
[InlineData("unknown")]
[InlineData("")]
public async Task UnknownProviderNeverReceivesPassword(string type)
{
using var handler = new Handler();
handler.Add(Discovery);
handler.Add(JsonSerializer.Serialize(new { type }));
using var client = new QqqSessionClient(Origin, handler);
await Assert.ThrowsAsync<QqqSessionException>(() => client.SignInWithPasswordAsync("user", "password"));
Assert.Equal(2, handler.Requests.Count);
Assert.All(handler.Requests, AssertPublic);
}
[Fact]
public async Task RestoreDiscoversBeforeCookieAndResumesOnlyThroughStandardManageSession()
{
using Handler handler = TableHandler();
handler.Add("{}");
handler.Add("{}");
using var client = new QqqSessionClient(Origin, handler);
await client.RestoreSessionAsync(new QqqSessionCredential(Origin, "saved-session"));
await client.ResumeSessionAsync();
AssertPublic(handler.Requests[0]);
AssertPublic(handler.Requests[1]);
Assert.All(handler.Requests.Skip(2), request =>
{
Assert.Equal("/qqq/v1/manageSession", request.Uri.AbsolutePath);
Assert.Equal("{}", request.Body);
Assert.Null(request.Authorization);
Assert.Contains("sessionUUID=saved-session", request.Cookie, StringComparison.Ordinal);
Assert.Equal(Origin.GetLeftPart(UriPartial.Authority), request.Origin);
});
Assert.Equal("saved-session", client.ExportCredential().SessionToken);
}
[Fact]
public async Task AnotherOriginCredentialIsRejectedWithoutNetwork()
{
using var handler = new Handler();
using var client = new QqqSessionClient(Origin, handler);
await Assert.ThrowsAsync<ArgumentException>(() => client.RestoreSessionAsync(new QqqSessionCredential(new Uri("https://another.invalid"), "secret")));
Assert.Empty(handler.Requests);
}
[Fact]
public async Task DeviceProofIsBoundByServerIdentityAndNeverSentToManagementOrEnrollment()
{
using Handler handler = TableHandler();
handler.Add("""{"uuid":"session"}""");
handler.Add("{}");
handler.Add(Identity);
handler.Add("{}");
handler.Add("{}");
handler.Add("{}");
using var client = new QqqSessionClient(Origin, handler);
await client.SignInWithPasswordAsync("user", "password");
var proof = new QqqDeviceCredential("device-1", "device-secret");
QqqSessionBinding binding = await client.BindDeviceAsync(proof, claimLegacyDevice: true);
Assert.True(binding.HasApprovedDevice);
using HttpResponseMessage device = await client.SendPrintingAsync(HttpMethod.Post, "device/control/poll");
using HttpResponseMessage admin = await client.SendPrintingAsync(HttpMethod.Get, "admin/devices?cursor=a%2Fb");
using HttpResponseMessage enroll = await client.SendPrintingAsync(HttpMethod.Post, "devices/enroll");
Assert.Equal("/qqq/v1/qbit/printing/device/bind", handler.Requests[3].Uri.AbsolutePath);
Assert.Equal("{}", handler.Requests[3].Body);
Assert.All(handler.Requests.Skip(3).Take(3), request =>
{
Assert.Equal("device-1", request.DeviceId);
Assert.Equal("device-secret", request.DeviceToken);
Assert.Null(request.Authorization);
Assert.NotNull(request.Cookie);
});
Assert.Null(handler.Requests[6].DeviceToken);
Assert.Null(handler.Requests[7].DeviceToken);
Assert.Equal("QQQ device credential (redacted)", proof.ToString());
}
[Fact]
public async Task BindingRejectsChangedPrincipalWithoutInferringIdentityFromEmail()
{
using Handler handler = TableHandler();
handler.Add("""{"uuid":"session"}""");
handler.Add("""{"principalId":"user-1","tenantId":"tenant-1","email":"same@example.invalid"}""");
handler.Add("""{"principalId":"user-2","tenantId":"tenant-1","deviceId":"device-1","email":"same@example.invalid"}""");
using var client = new QqqSessionClient(Origin, handler);
await client.SignInWithPasswordAsync("user", "password");
await client.GetSessionIdentityAsync();
await Assert.ThrowsAsync<QqqSessionException>(() => client.BindDeviceAsync(new QqqDeviceCredential("device-1", "proof")));
Assert.False(client.IsAuthenticated);
}
[Fact]
public async Task IncompleteIdentityCannotApproveDevice()
{
using Handler handler = TableHandler();
handler.Add("""{"uuid":"session"}""");
handler.Add("""{"email":"user@example.invalid","deviceId":"device-1"}""");
using var client = new QqqSessionClient(Origin, handler);
await client.SignInWithPasswordAsync("user", "password");
await Assert.ThrowsAsync<QqqSessionException>(() => client.BindDeviceAsync(new QqqDeviceCredential("device-1", "proof")));
await Assert.ThrowsAsync<QqqSessionException>(() => client.SendPrintingAsync(HttpMethod.Get, "device/jobs"));
Assert.Equal(4, handler.Requests.Count);
}
[Theory]
[InlineData("printing-session.response.json", false)]
[InlineData("printing-session-bound.response.json", true)]
public async Task SharedAuthoritativeIdentityFixturesAreAccepted(string fixture, bool bound)
{
string body = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "fixtures", fixture));
using Handler handler = TableHandler();
handler.Add("""{"uuid":"session"}""");
handler.Add(body);
using var client = new QqqSessionClient(Origin, handler);
await client.SignInWithPasswordAsync("user", "password");
QqqSessionBinding identity = bound ? await client.BindDeviceAsync(new QqqDeviceCredential("test-device", "proof"))
: await client.GetSessionIdentityAsync();
Assert.Equal("test-operator", identity.PrincipalId);
Assert.Equal("test-tenant", identity.TenantId);
Assert.Equal(bound, identity.HasApprovedDevice);
}
[Theory]
[InlineData("https://elsewhere.invalid/device/jobs")]
[InlineData("/device/jobs")]
[InlineData("device/../admin/jobs")]
[InlineData("device/%2e%2e/admin/jobs")]
[InlineData("device/jobs#fragment")]
[InlineData("assets/logo.png")]
public async Task CredentialTransportRejectsUnscopedRoutesWithoutNetwork(string path)
{
using Handler handler = TableHandler();
handler.Add("""{"uuid":"session"}""");
using var client = new QqqSessionClient(Origin, handler);
await client.SignInWithPasswordAsync("user", "password");
await Assert.ThrowsAsync<ArgumentException>(() => client.SendPrintingAsync(HttpMethod.Get, path));
Assert.Equal(3, handler.Requests.Count);
}
[Theory]
[InlineData(HttpStatusCode.Unauthorized, QqqSessionException.FailureKind.AuthenticationRequired, false)]
[InlineData(HttpStatusCode.Forbidden, QqqSessionException.FailureKind.PermissionDenied, true)]
public async Task StandardAuthorizationFailuresAreDistinct(HttpStatusCode status, QqqSessionException.FailureKind failure, bool authenticated)
{
using Handler handler = TableHandler();
handler.Add("""{"uuid":"session"}""");
handler.Add("{}", status);
using var client = new QqqSessionClient(Origin, handler);
await client.SignInWithPasswordAsync("user", "password");
QqqSessionException exception = await Assert.ThrowsAsync<QqqSessionException>(() => client.SendPrintingAsync(HttpMethod.Get, "admin/devices"));
Assert.Equal(failure, exception.Failure);
Assert.Equal(authenticated, client.IsAuthenticated);
}
[Fact]
public async Task ExpiredCookieOnApiResponseClearsSession()
{
using Handler handler = TableHandler();
handler.Add("""{"uuid":"session"}""");
handler.Add("{}", cookie: "sessionUUID=; Path=/; Max-Age=0");
using var client = new QqqSessionClient(Origin, handler);
await client.SignInWithPasswordAsync("user", "password");
QqqSessionException exception = await Assert.ThrowsAsync<QqqSessionException>(() => client.SendPrintingAsync(HttpMethod.Get, "admin/devices"));
Assert.Equal(QqqSessionException.FailureKind.AuthenticationRequired, exception.Failure);
Assert.False(client.IsAuthenticated);
}
[Fact]
public async Task RedirectedLoginNeverFollowsOrAcceptsCredentialResponse()
{
using Handler handler = TableHandler();
handler.Add("{}", HttpStatusCode.Redirect, cookie: "sessionUUID=not-adopted; Path=/");
using var client = new QqqSessionClient(Origin, handler);
QqqSessionException exception = await Assert.ThrowsAsync<QqqSessionException>(() => client.SignInWithPasswordAsync("user", "password"));
Assert.Equal(QqqSessionException.FailureKind.TransportRefused, exception.Failure);
Assert.False(client.IsAuthenticated);
Assert.Equal(3, handler.Requests.Count);
}
[Theory]
[InlineData("AUTHORIZATION_CODE_PKCE_SESSION", "AUTHORIZATION_CODE")]
[InlineData("S256", "plain")]
[InlineData("\"login\"", "\"none\"")]
[InlineData("nativeSessionLogin", "nativeBearer")]
[InlineData("https://id.example.invalid/application/", "http://id.example.invalid/application/")]
public async Task UnsupportedNativeGrantFailsBeforeProviderOrCredentialRequest(string oldValue, string newValue)
{
using var handler = new Handler();
handler.Add(Discovery);
handler.Add(OAuth.Replace(oldValue, newValue, StringComparison.Ordinal));
using var client = new QqqSessionClient(Origin, handler);
QqqSessionException exception = await Assert.ThrowsAsync<QqqSessionException>(() => client.BeginBrowserLoginAsync(Redirect));
Assert.Equal(QqqSessionException.FailureKind.ConfigurationRequired, exception.Failure);
Assert.Equal(2, handler.Requests.Count);
Assert.All(handler.Requests, AssertPublic);
}
[Fact]
public async Task NativeSessionRequiresCore19AndExplicitFeature()
{
foreach(string discovery in new[] { Discovery.Replace("1.9.0", "1.8.0", StringComparison.Ordinal), Discovery.Replace("nativeSessionLogin", "other", StringComparison.Ordinal) })
{
using var handler = new Handler();
handler.Add(discovery);
handler.Add(OAuth);
using var client = new QqqSessionClient(Origin, handler);
await Assert.ThrowsAsync<QqqSessionException>(() => client.BeginBrowserLoginAsync(Redirect));
Assert.Equal(2, handler.Requests.Count);
}
}
[Fact]
public async Task OnlyExactAdvertisedAndCentralSupportedRedirectCanBeUsed()
{
foreach(Uri redirect in new[] { new Uri("io.qrun.qqq.print://callback/other"), new Uri("http://127.0.0.1:49152/callback") })
{
using var handler = new Handler();
handler.Add(Discovery);
handler.Add(OAuth);
using var client = new QqqSessionClient(Origin, handler);
await Assert.ThrowsAsync<QqqSessionException>(() => client.BeginBrowserLoginAsync(redirect));
Assert.Equal(2, handler.Requests.Count);
}
}
[Fact]
public async Task BrowserUsesPkceStatePromptAndOnlyStandardSessionExchange()
{
using Handler handler = OAuthHandler();
handler.Add("{}", cookie: "sessionUUID=opaque-session; Path=/; HttpOnly; Secure");
using var client = new QqqSessionClient(Origin, handler);
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(Redirect);
Dictionary<string, string> authorization = Query(attempt.AuthorizationUri);
Assert.Equal("qqq-client", authorization["client_id"]);
Assert.Equal("S256", authorization["code_challenge_method"]);
Assert.Equal("login", authorization["prompt"]);
Assert.Equal(Redirect.AbsoluteUri, authorization["redirect_uri"]);
Assert.Equal(43, authorization["state"].Length);
Assert.Equal(43, authorization["nonce"].Length);
Assert.Equal("QQQ browser sign-in attempt (redacted)", attempt.ToString());
Assert.All(handler.Requests, AssertPublic);
Assert.Null(handler.Requests[2].ClientChannel);
await client.CompleteBrowserLoginAsync(attempt, Callback(attempt, authorization["state"]));
Request exchange = handler.Requests[3];
Assert.Equal("/qqq/v1/manageSession", exchange.Uri.AbsolutePath);
Assert.Null(exchange.Authorization);
Assert.Null(exchange.Cookie);
Assert.Null(exchange.DeviceToken);
using var body = JsonDocument.Parse(exchange.Body!);
Assert.Equal(4, body.RootElement.EnumerateObject().Count());
Assert.Equal("one-time-code", body.RootElement.GetProperty("code").GetString());
Assert.Equal(QqqAuthenticationMetadata.NativeSessionFlow, body.RootElement.GetProperty("authenticationFlow").GetString());
string verifier = body.RootElement.GetProperty("codeVerifier").GetString()!;
Assert.Equal(authorization["code_challenge"], Convert.ToBase64String(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))).TrimEnd('=').Replace('+', '-').Replace('/', '_'));
Assert.Equal("opaque-session", client.ExportCredential().SessionToken);
await Assert.ThrowsAsync<QqqSessionException>(() => client.CompleteBrowserLoginAsync(attempt, Callback(attempt, authorization["state"])));
Assert.Equal(4, handler.Requests.Count);
}
[Theory]
[InlineData("wrong-state")]
[InlineData("duplicate-state")]
[InlineData("different-callback")]
[InlineData("fragment")]
[InlineData("wrong-issuer")]
[InlineData("expired")]
public async Task InvalidCallbackConsumesAttemptWithoutExchange(string fault)
{
using Handler handler = OAuthHandler();
var time = new FakeTime();
using var client = new QqqSessionClient(Origin, handler, time);
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(Redirect);
string state = Query(attempt.AuthorizationUri)["state"];
Uri callback = fault switch
{
"wrong-state" => Callback(attempt, "wrong"),
"duplicate-state" => new Uri(Callback(attempt, state).AbsoluteUri + "&state=" + state),
"different-callback" => new Uri("io.qrun.qqq.print://elsewhere/oauth2redirect?code=one-time-code&state=" + state),
"fragment" => new Uri(Callback(attempt, state).AbsoluteUri + "#fragment"),
"wrong-issuer" => new Uri(Callback(attempt, state).AbsoluteUri + "&iss=https%3A%2F%2Fother.invalid"),
_ => Callback(attempt, state),
};
if(string.Equals(fault, "expired", StringComparison.Ordinal))
{ time.Advance(TimeSpan.FromMinutes(6)); }
await Assert.ThrowsAsync<QqqSessionException>(() => client.CompleteBrowserLoginAsync(attempt, callback));
await Assert.ThrowsAsync<QqqSessionException>(() => client.CompleteBrowserLoginAsync(attempt, Callback(attempt, state)));
Assert.Equal(3, handler.Requests.Count);
Assert.False(client.IsAuthenticated);
}
[Fact]
public async Task DiscoveryIssuerMismatchCannotLaunchBrowser()
{
using var handler = new Handler();
handler.Add(Discovery);
handler.Add(OAuth);
handler.Add(Provider.Replace("https://id.example.invalid/application/", "https://evil.invalid/", StringComparison.Ordinal));
using var client = new QqqSessionClient(Origin, handler);
await Assert.ThrowsAsync<QqqSessionException>(() => client.BeginBrowserLoginAsync(Redirect));
Assert.All(handler.Requests, AssertPublic);
}
[Fact]
public async Task StableBindingExcludesRotatingCredentialsAndHasUnambiguousIdentity()
{
using Handler handler = TableHandler();
handler.Add("""{"uuid":"session-one"}""");
handler.Add(Identity);
handler.Add("""{"uuid":"session-two"}""");
handler.Add(Identity);
using var client = new QqqSessionClient(Origin, handler);
await client.SignInWithPasswordAsync("user", "password");
QqqSessionBinding identity = await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "proof"));
string key = identity.IdentityKey;
await client.ResumeSessionAsync();
Assert.Equal("session-two", client.ExportCredential().SessionToken);
Assert.Equal(key, new QqqSessionBinding(Origin, "user-1", "tenant-1", "device-1").IdentityKey);
Assert.NotEqual(new QqqSessionBinding(Origin, "alpha\nbeta", "gamma", "device").IdentityKey,
new QqqSessionBinding(Origin, "alpha", "beta\ngamma", "device").IdentityKey, StringComparer.Ordinal);
}
[Fact]
public async Task LongPrintingResponseDoesNotBlockControlAndCannotRestoreLoggedOutSession()
{
using Handler handler = TableHandler();
handler.Add("""{"uuid":"session"}""");
handler.Add(Identity);
var pending = new TaskCompletionSource<HttpResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
handler.AddAsync(_ => { entered.SetResult(); return pending.Task; });
handler.Add("{}");
handler.Add("{}");
using var client = new QqqSessionClient(Origin, handler);
await client.SignInWithPasswordAsync("user", "password");
await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "proof"));
Task<HttpResponseMessage> printing = client.SendPrintingAsync(HttpMethod.Get, "device/jobs/job-1/content");
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
using HttpResponseMessage control = await client.SendPrintingAsync(HttpMethod.Post, "device/control/poll").WaitAsync(TimeSpan.FromSeconds(5));
await client.SignOutAsync().WaitAsync(TimeSpan.FromSeconds(5));
pending.SetResult(Response("{}"));
await Assert.ThrowsAsync<QqqSessionException>(async () => { using HttpResponseMessage response = await printing; });
Assert.False(client.IsAuthenticated);
}
[Fact]
public async Task OversizedPublicResponseIsRejectedBeforeCredentials()
{
using var handler = new Handler();
handler.Add(new string('x', 65_537));
using var client = new QqqSessionClient(Origin, handler);
await Assert.ThrowsAsync<QqqSessionException>(() => client.SignInWithPasswordAsync("user", "password"));
AssertPublic(Assert.Single(handler.Requests));
}
private static Uri Callback(QqqBrowserLogin attempt, string state) => new(attempt.RedirectUri.AbsoluteUri + "?code=one-time-code&state=" + Uri.EscapeDataString(state));
private static Dictionary<string, string> Query(Uri uri) => uri.Query[1..].Split('&').Select(part => part.Split('=', 2))
.ToDictionary(part => Uri.UnescapeDataString(part[0]), part => Uri.UnescapeDataString(part[1]), StringComparer.Ordinal);
private static Handler TableHandler()
{
var handler = new Handler();
handler.Add(Discovery);
handler.Add(Table);
return handler;
}
private static Handler OAuthHandler()
{
var handler = new Handler();
handler.Add(Discovery);
handler.Add(OAuth);
handler.Add(Provider);
return handler;
}
private static void AssertPublic(Request request)
{
Assert.Null(request.Authorization);
Assert.Null(request.Cookie);
Assert.Null(request.DeviceId);
Assert.Null(request.DeviceToken);
}
private static HttpResponseMessage Response(string body, HttpStatusCode status = HttpStatusCode.OK, string? cookie = null)
{
var response = new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
if(cookie is not null)
{ response.Headers.Add("Set-Cookie", cookie); }
return response;
}
private sealed record Request(Uri Uri, string? Body, string? Authorization, string? Cookie, string? DeviceId, string? DeviceToken, string? ClientChannel, string? Origin);
private sealed class Handler : HttpMessageHandler
{
private readonly Queue<Func<CancellationToken, Task<HttpResponseMessage>>> _responses = new();
internal List<Request> Requests { get; } = [];
internal void Add(string body, HttpStatusCode status = HttpStatusCode.OK, string? cookie = null) => AddResponse(() => Response(body, status, cookie));
internal void AddResponse(Func<HttpResponseMessage> response) => AddAsync(_ => Task.FromResult(response()));
internal void AddAsync(Func<CancellationToken, Task<HttpResponseMessage>> response) => _responses.Enqueue(response);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string? body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
string? Header(string name) => request.Headers.TryGetValues(name, out IEnumerable<string>? values) ? string.Join("; ", values) : null;
Requests.Add(new Request(request.RequestUri!, body, request.Headers.Authorization?.ToString(), Header("Cookie"),
Header("X-QQQ-Device-Id"), Header("X-QQQ-Device-Token"), Header("X-QQQ-Client"), Header("Origin")));
return await _responses.Dequeue()(cancellationToken);
}
}
private sealed class FakeTime : TimeProvider
{
private long _timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() => _timestamp;
internal void Advance(TimeSpan amount) => _timestamp += amount.Ticks;
}
}