From c9bc78fcc1e015417c80ae1b657b0d148174d7f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 12:31:56 +0000 Subject: [PATCH 1/3] Add the PrintBridge tray app: local print API for web apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrintBridge is the printing sibling of ScanBridge: a Windows tray app with a loopback-only HTTP API that web pages POST a PDF to, which is then rendered and handed to the Windows print spooler with no dialog and no user interaction. Mirrors ScanBridge's structure — settings store, Kestrel host, tray context, code-built settings window, job manager with polling — with the differences the print direction implies: - Named queues. Administrators map a queue name to a Windows printer; callers only ever use the name, so the printer behind a queue can be swapped without touching a web app and no caller learns the machine's printer names. - FIFO drain loop instead of a busy rejection. Submissions are always accepted and printed one at a time, which bounds the memory cost of rendering pages at printer resolution. - PDFtoImage (PDFium + SkiaSharp, MIT/BSD-3) rasterizes each page lazily at the printer's own resolution; System.Drawing.Printing spools it, with StandardPrintController for silent printing, per-page orientation from the PDF and scale-to-fit-never-enlarge centering. The API is documented in docs/api.md: status, queues, submit (raw PDF body plus ?queue=&copies=), poll, cancel. Tests cover queue resolution, the PDF sniffer, job manager behaviour, page fitting math and the settings round-trip. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013Ym9ifwDcWhR56pTn6BZzd --- .editorconfig | 20 + .github/workflows/ci.yml | 25 ++ .github/workflows/release.yml | 57 +++ .gitignore | 4 + Directory.Build.props | 12 + PrintBridge.sln | 27 ++ README.md | 162 +++++++- THIRD-PARTY-NOTICES.md | 44 +++ docs/api.md | 129 +++++++ global.json | 6 + installer/PrintBridge.iss | 50 +++ src/PrintBridge/Api/ApiEndpoints.cs | 138 +++++++ src/PrintBridge/Api/Contracts.cs | 25 ++ src/PrintBridge/App/QueueEditDialog.cs | 143 +++++++ src/PrintBridge/App/SettingsForm.cs | 349 ++++++++++++++++++ src/PrintBridge/App/TrayApplicationContext.cs | 172 +++++++++ src/PrintBridge/Hosting/WebHostRunner.cs | 139 +++++++ src/PrintBridge/PrintBridge.csproj | 33 ++ src/PrintBridge/Printing/PdfSniffer.cs | 25 ++ src/PrintBridge/Printing/PrintJob.cs | 81 ++++ src/PrintBridge/Printing/PrintJobManager.cs | 167 +++++++++ src/PrintBridge/Printing/PrintService.cs | 322 ++++++++++++++++ src/PrintBridge/Program.cs | 92 +++++ src/PrintBridge/Resources/printbridge.ico | Bin 0 -> 15010 bytes src/PrintBridge/Resources/testpage.pdf | Bin 0 -> 1335 bytes src/PrintBridge/Settings/AppSettings.cs | 156 ++++++++ src/PrintBridge/Settings/SettingsStore.cs | 88 +++++ .../Settings/StartupRegistration.cs | 32 ++ .../PrintBridge.Tests/OriginAllowlistTests.cs | 37 ++ tests/PrintBridge.Tests/PageFittingTests.cs | 72 ++++ tests/PrintBridge.Tests/PdfSnifferTests.cs | 52 +++ .../PrintBridge.Tests.csproj | 19 + .../PrintBridge.Tests/PrintJobManagerTests.cs | 217 +++++++++++ .../PrintBridge.Tests/QueueResolutionTests.cs | 119 ++++++ tests/PrintBridge.Tests/SettingsStoreTests.cs | 120 ++++++ 35 files changed, 3133 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 Directory.Build.props create mode 100644 PrintBridge.sln create mode 100644 THIRD-PARTY-NOTICES.md create mode 100644 docs/api.md create mode 100644 global.json create mode 100644 installer/PrintBridge.iss create mode 100644 src/PrintBridge/Api/ApiEndpoints.cs create mode 100644 src/PrintBridge/Api/Contracts.cs create mode 100644 src/PrintBridge/App/QueueEditDialog.cs create mode 100644 src/PrintBridge/App/SettingsForm.cs create mode 100644 src/PrintBridge/App/TrayApplicationContext.cs create mode 100644 src/PrintBridge/Hosting/WebHostRunner.cs create mode 100644 src/PrintBridge/PrintBridge.csproj create mode 100644 src/PrintBridge/Printing/PdfSniffer.cs create mode 100644 src/PrintBridge/Printing/PrintJob.cs create mode 100644 src/PrintBridge/Printing/PrintJobManager.cs create mode 100644 src/PrintBridge/Printing/PrintService.cs create mode 100644 src/PrintBridge/Program.cs create mode 100644 src/PrintBridge/Resources/printbridge.ico create mode 100644 src/PrintBridge/Resources/testpage.pdf create mode 100644 src/PrintBridge/Settings/AppSettings.cs create mode 100644 src/PrintBridge/Settings/SettingsStore.cs create mode 100644 src/PrintBridge/Settings/StartupRegistration.cs create mode 100644 tests/PrintBridge.Tests/OriginAllowlistTests.cs create mode 100644 tests/PrintBridge.Tests/PageFittingTests.cs create mode 100644 tests/PrintBridge.Tests/PdfSnifferTests.cs create mode 100644 tests/PrintBridge.Tests/PrintBridge.Tests.csproj create mode 100644 tests/PrintBridge.Tests/PrintJobManagerTests.cs create mode 100644 tests/PrintBridge.Tests/QueueResolutionTests.cs create mode 100644 tests/PrintBridge.Tests/SettingsStoreTests.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d55fa70 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{csproj,props,targets,sln}] +indent_size = 2 + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.cs] +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +dotnet_sort_system_directives_first = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..81cf5d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore PrintBridge.sln + + - name: Build + run: dotnet build PrintBridge.sln -c Release --no-restore + + - name: Test + run: dotnet test PrintBridge.sln -c Release --no-build --verbosity normal diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ac6c232 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Determine version + id: version + shell: pwsh + run: | + $version = '${{ github.ref_name }}'.TrimStart('v') + "version=$version" >> $env:GITHUB_OUTPUT + + - name: Test + run: dotnet test PrintBridge.sln -c Release + + - name: Publish + run: > + dotnet publish src/PrintBridge -c Release -r win-x64 --self-contained true + -p:Version=${{ steps.version.outputs.version }} + -o publish + + - name: Zip portable build + shell: pwsh + run: | + Copy-Item README.md, THIRD-PARTY-NOTICES.md, LICENSE publish/ + Compress-Archive -Path publish/* -DestinationPath "PrintBridge-${{ steps.version.outputs.version }}-win-x64-portable.zip" + + - name: Build installer + shell: pwsh + run: | + choco install innosetup --no-progress -y + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\PrintBridge.iss ` + /DAppVersion=${{ steps.version.outputs.version }} ` + /DPublishDir=..\publish + Move-Item installer\Output\PrintBridge-${{ steps.version.outputs.version }}-setup.exe . + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: | + PrintBridge-${{ steps.version.outputs.version }}-win-x64-portable.zip + PrintBridge-${{ steps.version.outputs.version }}-setup.exe + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 7282dbf..4b4a2cf 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ bld/ [Ll]og/ [Ll]ogs/ +# Publish output and installer build output +publish/ +installer/Output/ + # .NET Core project.lock.json project.fragment.lock.json diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..d84da0b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + enable + enable + latest + + 0.1.0 + PrintBridge contributors + PrintBridge + false + + diff --git a/PrintBridge.sln b/PrintBridge.sln new file mode 100644 index 0000000..1d7893d --- /dev/null +++ b/PrintBridge.sln @@ -0,0 +1,27 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrintBridge", "src\PrintBridge\PrintBridge.csproj", "{3D5A7C18-92B4-4F0E-8C6D-5A1E7B3F9042}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrintBridge.Tests", "tests\PrintBridge.Tests\PrintBridge.Tests.csproj", "{B6E1F204-7A3C-48D9-A1B5-0C9D8E2F4A13}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3D5A7C18-92B4-4F0E-8C6D-5A1E7B3F9042}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D5A7C18-92B4-4F0E-8C6D-5A1E7B3F9042}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D5A7C18-92B4-4F0E-8C6D-5A1E7B3F9042}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D5A7C18-92B4-4F0E-8C6D-5A1E7B3F9042}.Release|Any CPU.Build.0 = Release|Any CPU + {B6E1F204-7A3C-48D9-A1B5-0C9D8E2F4A13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6E1F204-7A3C-48D9-A1B5-0C9D8E2F4A13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6E1F204-7A3C-48D9-A1B5-0C9D8E2F4A13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6E1F204-7A3C-48D9-A1B5-0C9D8E2F4A13}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 7a07a93..b8039db 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,162 @@ # PrintBridge -A small Windows tray application that lets web applications use desktop printers. A page in the browser sends a document to PrintBridge's local HTTP API; PrintBridge prints from the configured printer. + +A small Windows tray application that lets **web applications print to desktop +printers**. A page in the browser POSTs a PDF to PrintBridge's local HTTP API; +PrintBridge renders it and hands it to the Windows print spooler — silently, with no +print dialog and no user interaction. + +PrintBridge is generic — it knows nothing about any particular web app. Any site whose +origin an administrator adds to the allowlist can print to the queues an administrator +has configured. + +``` +┌──────────────┐ POST http://127.0.0.1:7227 ┌─────────────┐ Windows spooler ┌─────────┐ +│ Your web app │ ────────── PDF ─────────────► │ PrintBridge │ ──────────────────► │ Printer │ +│ (browser) │ ◄──────── job id ──────────── │ (tray app) │ │ │ +└──────────────┘ └─────────────┘ └─────────┘ +``` + +It is the sibling of [ScanBridge](https://github.com/gcgov/scanbridge), which does the +same thing for document scanners. + +## Named queues + +Web apps never name a Windows printer. An administrator configures **queues** — a name +plus the printer it maps to — and web apps ask for the queue: + +| Queue | Printer | +|---|---| +| `labels` | ZDesigner GK420d | +| `front-desk` | HP LaserJet M404 | + +Swapping the printer behind `labels` is a settings change; no web app has to be +touched, and no web app learns anything about the machine's printers. + +## Install + +Grab the latest release from the [Releases](../../releases) page: + +- **`PrintBridge--setup.exe`** — per-user installer (no admin rights needed). + Installs to `%LocalAppData%\Programs\PrintBridge` and starts at login by default. +- **`PrintBridge--win-x64-portable.zip`** — portable build; unzip anywhere and + run `PrintBridge.exe`. + +Both are self-contained: no .NET runtime install is required. + +On first run the settings window opens. Add at least one queue, and — important — add +the website origin(s) that are allowed to print. + +## Configuration + +Right-click the tray icon → **Settings…** + +| Setting | Meaning | +|---|---| +| Print queues | Named queues web apps can print to, each mapped to an installed Windows printer. One is the default, used when a request omits `queue`. | +| Port | The local HTTP port (default **7227**). PrintBridge listens on `127.0.0.1` only — it is never reachable from the network. | +| Allowed website origins | Exact origins (e.g. `https://apps.example.gov`) allowed to call the API from a browser. Empty list = no site may use it. | +| Start when I sign in | Per-user autostart (registry `Run` key). | + +Settings live in `%AppData%\PrintBridge\settings.json`; logs in +`%LocalAppData%\PrintBridge\logs`. + +```json +{ + "port": 7227, + "allowedOrigins": ["https://apps.example.gov"], + "queues": [ + { "name": "labels", "printerName": "ZDesigner GK420d" }, + { "name": "front-desk", "printerName": "HP LaserJet M404" } + ], + "defaultQueue": "front-desk", + "runAtLogin": true +} +``` + +## Using it from a web page + +See [docs/api.md](docs/api.md) for the full HTTP API. The short version: + +```js +// 1. submit the PDF (raw body, not JSON) +const res = await fetch('http://127.0.0.1:7227/api/v1/print-jobs?queue=labels&copies=1', { + method: 'POST', + headers: { 'Content-Type': 'application/pdf' }, + body: pdfBlob, + targetAddressSpace: 'loopback', // Local Network Access hint (Chrome 142+) +}); +const { jobId } = await res.json(); + +// 2. poll until it is done +let job; +do { + await new Promise(r => setTimeout(r, 1000)); + job = await (await fetch(`http://127.0.0.1:7227/api/v1/print-jobs/${jobId}`, + { targetAddressSpace: 'loopback' })).json(); +} while (!['completed', 'failed', 'canceled'].includes(job.status)); + +if (job.status !== 'completed') console.error(job.error.code, job.error.message); +``` + +Only PDF is accepted. `completed` means the Windows spooler took the document; what the +printer does afterwards is not visible to PrintBridge. + +### Browser requirements (Local Network Access) + +Calling `http://127.0.0.1` from an HTTPS page is allowed by Chrome, Edge and Firefox +(loopback is a "potentially trustworthy" origin; Safari currently blocks it). Since +Chrome 142, the **Local Network Access** feature additionally shows a one-time +permission prompt the first time a site talks to the local machine; the user must click +**Allow**. The decision is remembered per site. + +For managed fleets, administrators can skip the prompt entirely by adding the web app's +origin to the `LocalNetworkAccessAllowedForUrls` enterprise policy (Chrome/Edge via +GPO or Intune; Firefox has an equivalent `LocalNetworkAccess` policy). + +### Security model + +- The listener binds to `127.0.0.1` only — nothing on the network can reach it. +- Browsers enforce CORS: only origins on the allowlist get responses. +- Callers can only reach printers an administrator mapped to a queue, and never learn + the printer names. +- Requests carry no credentials and PrintBridge stores no secrets; the worst a + malicious allowed page could do is waste paper. + +## Building from source + +Requires the .NET 10 SDK on Windows. + +``` +dotnet build PrintBridge.sln +dotnet test PrintBridge.sln +dotnet publish src/PrintBridge -c Release -r win-x64 --self-contained true +``` + +The installer is built with [Inno Setup](https://jrsoftware.org/isinfo.php) from +`installer/PrintBridge.iss`. + +### Manual smoke test + +Needs a Windows machine with a real printer. + +1. Run `PrintBridge.exe`. In Settings, add a queue (say `labels`) pointing at a real + printer, make it the default, and add `https://localhost:8097` (or your dev origin) + to the allowed origins. +2. `curl http://127.0.0.1:7227/api/v1/status` → JSON status with `queuesConfigured: 1`. +3. Tray menu → **Print test page** → a page comes out of the printer. +4. Submit a PDF the way a web app would, and poll it to `completed`: + + ``` + curl -i -X POST "http://127.0.0.1:7227/api/v1/print-jobs?queue=labels&copies=1" ^ + -H "Content-Type: application/pdf" --data-binary "@sample.pdf" + curl http://127.0.0.1:7227/api/v1/print-jobs/ + ``` + +5. `?queue=nope` must return `422 unknownQueue`, and posting a non-PDF body must return + `422 invalidDocument`. +6. From a disallowed origin, a browser `fetch` must fail CORS. + +## Licensing + +PrintBridge is MIT-licensed (see [LICENSE](LICENSE)). Its dependencies are MIT and +BSD-3-Clause — see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md). diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..239274d --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,44 @@ +# Third-party notices + +PrintBridge itself is MIT-licensed. It ships with the following third-party components, +which remain under their own licenses. + +## PDFtoImage + +- Package: `PDFtoImage` +- Copyright © David Sungaila +- License: **MIT** +- Source: + +PDFtoImage renders PDF pages to bitmaps, which PrintBridge then hands to the Windows +print spooler. It brings in the two components below. + +## PDFium + +- Packages: `bblanchon.PDFium.Win32` (native `pdfium.dll`), pulled in by PDFtoImage +- Copyright © The PDFium Authors, Google Inc. +- License: **BSD 3-Clause** +- Source: +- Packaging source: + +## SkiaSharp + +- Packages: `SkiaSharp`, `SkiaSharp.NativeAssets.Win32`, pulled in by PDFtoImage +- Copyright © Microsoft Corporation; Skia is copyright © Google Inc. +- License: **MIT** (SkiaSharp), **BSD 3-Clause** (Skia) +- Source: + +## Serilog + +- Packages: `Serilog`, `Serilog.Extensions.Logging`, `Serilog.Sinks.File` +- License: Apache-2.0 +- Source: + +## ASP.NET Core / .NET runtime + +- The self-contained publish includes the .NET, ASP.NET Core and Windows Desktop + runtimes (Windows Forms and `System.Drawing.Printing`) +- License: MIT +- Source: + +No component of PrintBridge or its dependencies is licensed under the GPL or LGPL. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..66e9472 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,129 @@ +# PrintBridge HTTP API + +Base URL: `http://127.0.0.1:` — default port **7227**. The listener binds to +loopback only. All response bodies are JSON (camelCase); the document to print is sent +as a raw PDF request body. + +Browsers must be subject to the CORS allowlist: the calling page's origin has to be +listed under *Allowed website origins* in PrintBridge settings. Same-machine tools +(curl, desktop apps) are not subject to CORS. + +Requests from browsers should pass `targetAddressSpace: 'loopback'` in the `fetch` +options to satisfy Chrome's Local Network Access rules (Chrome 142+). + +## Queues, not printers + +Web apps never name a Windows printer. An administrator configures **named queues** in +PrintBridge settings (for example `labels` → `ZDesigner GK420d`) and the web app asks +for the queue by name, so the printer behind a queue can be swapped without touching +the web app. One queue is marked as the default and is used when a request omits +`queue`. + +## Error shape + +Non-2xx responses (except 404s from unknown URLs) use: + +```json +{ "error": { "code": "unknownQueue", "message": "There is no print queue named 'labels'." } } +``` + +| Code | Meaning | +|---|---| +| `unknownQueue` | The requested queue name is not configured. | +| `noQueueConfigured` | No `queue` was given and no usable default queue is configured. | +| `invalidDocument` | The body is not a PDF, or the PDF cannot be rendered. | +| `invalidCopies` | `copies` is outside 1–99. | +| `documentTooLarge` | The body is larger than 100 MB. | +| `printerUnavailable` | Windows does not have the printer behind the queue. | +| `canceled` | The job was canceled. | +| `printFailed` | Any other print failure; `message` has details. | +| `notFound` | Unknown job id. | + +Messages name the queue, never the Windows printer. + +## Endpoints + +### `GET /api/v1/status` + +Handshake / discovery. Use this to detect whether PrintBridge is installed and running. + +```json +{ "app": "PrintBridge", "version": "1.0.0", "apiVersion": 1, + "queuesConfigured": 2, "defaultQueue": "front-desk" } +``` + +### `GET /api/v1/queues` + +Lists the configured queues so a page can offer a picker. + +```json +[ { "name": "labels", "isDefault": false }, + { "name": "front-desk", "isDefault": true } ] +``` + +### `POST /api/v1/print-jobs?queue=labels&copies=2` + +Submits a document. **The request body is the raw PDF** — not JSON, not multipart. +`Content-Type: application/pdf` is conventional but not enforced; PrintBridge looks for +the `%PDF-` marker in the first 1024 bytes instead. + +| Query parameter | Default | Meaning | +|---|---|---| +| `queue` | the default queue | Name of the queue to print to. | +| `copies` | `1` | 1–99. | + +Responses: `202 Accepted` with `{ "jobId": "…" }` (Location header points at the job), +`422` `unknownQueue` / `noQueueConfigured` / `invalidDocument` / `invalidCopies`, +`413` `documentTooLarge`. + +Submissions are always accepted — there is no "busy" rejection. Jobs are printed one at +a time in the order they arrive, because rendering a PDF at printer resolution is +memory-hungry and serializing it bounds that cost. + +### `GET /api/v1/print-jobs/{jobId}` + +Polls a job. + +```json +{ "jobId": "…", "status": "printing", "queue": "labels", "copies": 2, + "pagesPrinted": 3, "error": null } +``` + +`status` is `queued | printing | completed | failed | canceled`. When `failed`/`canceled`, +`error` carries `{code, message}`. + +`pagesPrinted` counts pages handed to the spooler, including copies (a 2-page document +printed twice reaches 4). `completed` means the Windows print spooler accepted the whole +document — what happens after that (an offline printer, a paper jam) is between Windows +and the printer, and PrintBridge cannot see it. + +### `DELETE /api/v1/print-jobs/{jobId}` + +Cancels a queued or printing job, or discards a finished one. Always `204`. Canceling a +job that is already spooling stops it at the next page boundary; pages Windows has +already accepted may still come out. Finished jobs are discarded automatically after +10 minutes. + +## Typical client flow + +1. `GET /status` — if unreachable, tell the user to install/start PrintBridge. +2. `POST /print-jobs?queue=labels` with the PDF as the body. +3. Poll `GET /print-jobs/{jobId}` every second until the status is terminal. +4. On `failed`, show `error.message`. + +```js +const res = await fetch('http://127.0.0.1:7227/api/v1/print-jobs?queue=labels&copies=1', { + method: 'POST', + headers: { 'Content-Type': 'application/pdf' }, + body: pdfBlob, // a Blob / ArrayBuffer holding the PDF + targetAddressSpace: 'loopback', // Local Network Access hint (Chrome 142+) +}); +const { jobId } = await res.json(); + +let job; +do { + await new Promise(r => setTimeout(r, 1000)); + job = await (await fetch(`http://127.0.0.1:7227/api/v1/print-jobs/${jobId}`, + { targetAddressSpace: 'loopback' })).json(); +} while (!['completed', 'failed', 'canceled'].includes(job.status)); +``` diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/installer/PrintBridge.iss b/installer/PrintBridge.iss new file mode 100644 index 0000000..530279c --- /dev/null +++ b/installer/PrintBridge.iss @@ -0,0 +1,50 @@ +; Inno Setup script for PrintBridge. +; Build (from repo root, after dotnet publish): +; iscc installer\PrintBridge.iss /DAppVersion=1.0.0 /DPublishDir=..\publish + +#ifndef AppVersion + #define AppVersion "0.1.0" +#endif +#ifndef PublishDir + #define PublishDir "..\publish" +#endif + +[Setup] +AppId={{4E1B9D7A-8C36-4F52-B0A9-6D3E5C7F81B4} +AppName=PrintBridge +AppVersion={#AppVersion} +AppPublisher=PrintBridge contributors +AppPublisherURL=https://github.com/gcgov/printbridge +DefaultDirName={localappdata}\Programs\PrintBridge +DisableProgramGroupPage=yes +; Per-user install: no admin rights required. +PrivilegesRequired=lowest +OutputBaseFilename=PrintBridge-{#AppVersion}-setup +SetupIconFile=..\src\PrintBridge\Resources\printbridge.ico +UninstallDisplayIcon={app}\PrintBridge.exe +Compression=lzma2 +SolidCompression=yes +; Ask a running PrintBridge to close before replacing files. +CloseApplications=yes +WizardStyle=modern + +[Tasks] +Name: "autostart"; Description: "Start PrintBridge when I sign in to Windows"; GroupDescription: "Startup:" + +[Files] +Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs + +[Icons] +Name: "{userprograms}\PrintBridge"; Filename: "{app}\PrintBridge.exe" + +[Registry] +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \ + ValueType: string; ValueName: "PrintBridge"; ValueData: """{app}\PrintBridge.exe"" --minimized"; \ + Flags: uninsdeletevalue; Tasks: autostart + +[Run] +Filename: "{app}\PrintBridge.exe"; Description: "Launch PrintBridge now"; \ + Flags: nowait postinstall skipifsilent + +[UninstallRun] +Filename: "taskkill"; Parameters: "/im PrintBridge.exe /f"; Flags: runhidden; RunOnceId: "KillPrintBridge" diff --git a/src/PrintBridge/Api/ApiEndpoints.cs b/src/PrintBridge/Api/ApiEndpoints.cs new file mode 100644 index 0000000..c4332c3 --- /dev/null +++ b/src/PrintBridge/Api/ApiEndpoints.cs @@ -0,0 +1,138 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using PrintBridge.Printing; +using PrintBridge.Settings; + +namespace PrintBridge.Api; + +/// +/// Maps the localhost HTTP surface. Responses are camelCase JSON; the document to +/// print is sent as a raw PDF body rather than JSON so nothing has to be base64-encoded. +/// +public static class ApiEndpoints +{ + /// Largest PDF accepted, mirrored into the Kestrel request body limit. + public const long MaxBodyBytes = 100L * 1024 * 1024; + + public const int MinCopies = 1; + + public const int MaxCopies = 99; + + public static void Map(WebApplication app, SettingsStore settingsStore, PrintJobManager jobManager) + { + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; + var api = app.MapGroup("/api/v1"); + + api.MapGet("/status", () => + { + var settings = settingsStore.Current; + return Results.Ok(new StatusResponse( + App: "PrintBridge", + Version: version, + ApiVersion: 1, + QueuesConfigured: settings.Queues.Count, + DefaultQueue: settings.DefaultQueue)); + }); + + api.MapGet("/queues", () => + { + var settings = settingsStore.Current; + return Results.Ok(settings.Queues.Select(q => new QueueDto( + q.Name, + string.Equals(q.Name, settings.DefaultQueue, StringComparison.OrdinalIgnoreCase)))); + }); + + api.MapPost("/print-jobs", async (HttpRequest httpRequest, string? queue, int? copies, CancellationToken cancellationToken) => + { + var requestedCopies = copies ?? 1; + if (requestedCopies is < MinCopies or > MaxCopies) + { + return Error(StatusCodes.Status422UnprocessableEntity, PrintErrorCodes.InvalidCopies, + $"copies must be between {MinCopies} and {MaxCopies}."); + } + + var settings = settingsStore.Current; + if (!settings.TryResolveQueue(queue, out var resolvedQueue, out var errorCode)) + { + return Error(StatusCodes.Status422UnprocessableEntity, errorCode, errorCode switch + { + PrintErrorCodes.UnknownQueue => $"There is no print queue named '{queue}'.", + _ => "No print queue is configured. Open PrintBridge settings and add one.", + }); + } + + if (httpRequest.ContentLength > MaxBodyBytes) + { + return TooLarge(); + } + + byte[] pdfBytes; + try + { + using var buffer = new MemoryStream(); + await httpRequest.Body.CopyToAsync(buffer, cancellationToken); + pdfBytes = buffer.ToArray(); + } + catch (BadHttpRequestException) + { + // Kestrel aborts the body once it passes MaxRequestBodySize. + return TooLarge(); + } + + if (!PdfSniffer.LooksLikePdf(pdfBytes)) + { + return Error(StatusCodes.Status422UnprocessableEntity, PrintErrorCodes.InvalidDocument, + "The request body is not a PDF document."); + } + + var job = jobManager.Enqueue(new PrintRequest( + Queue: resolvedQueue.Name, + PrinterName: resolvedQueue.PrinterName, + Copies: requestedCopies, + PdfBytes: pdfBytes)); + + return Results.Accepted($"/api/v1/print-jobs/{job.Id}", new StartPrintResponse(job.Id)); + }); + + api.MapGet("/print-jobs/{jobId}", (string jobId) => + { + var job = jobManager.Get(jobId); + if (job is null) + { + return Error(StatusCodes.Status404NotFound, "notFound", "Unknown print job."); + } + + return Results.Ok(ToResponse(job)); + }); + + api.MapDelete("/print-jobs/{jobId}", (string jobId) => + { + jobManager.CancelOrDiscard(jobId); + return Results.NoContent(); + }); + } + + private static PrintJobResponse ToResponse(PrintJob job) => new( + JobId: job.Id, + Status: job.Status switch + { + PrintJobStatus.Queued => "queued", + PrintJobStatus.Printing => "printing", + PrintJobStatus.Completed => "completed", + PrintJobStatus.Failed => "failed", + PrintJobStatus.Canceled => "canceled", + _ => "unknown", + }, + Queue: job.Queue, + Copies: job.Copies, + PagesPrinted: job.PagesPrinted, + Error: job.ErrorCode is null ? null : new ApiErrorBody(job.ErrorCode, job.ErrorMessage ?? string.Empty)); + + private static IResult TooLarge() => + Error(StatusCodes.Status413PayloadTooLarge, PrintErrorCodes.DocumentTooLarge, + $"The document is larger than the {MaxBodyBytes / (1024 * 1024)} MB limit."); + + private static IResult Error(int statusCode, string code, string message) => + Results.Json(new ApiErrorResponse(new ApiErrorBody(code, message)), statusCode: statusCode); +} diff --git a/src/PrintBridge/Api/Contracts.cs b/src/PrintBridge/Api/Contracts.cs new file mode 100644 index 0000000..277dcf9 --- /dev/null +++ b/src/PrintBridge/Api/Contracts.cs @@ -0,0 +1,25 @@ +namespace PrintBridge.Api; + +public sealed record StatusResponse( + string App, + string Version, + int ApiVersion, + int QueuesConfigured, + string? DefaultQueue); + +/// A queue as web apps see it: a name, never the Windows printer behind it. +public sealed record QueueDto(string Name, bool IsDefault); + +public sealed record StartPrintResponse(string JobId); + +public sealed record ApiErrorBody(string Code, string Message); + +public sealed record ApiErrorResponse(ApiErrorBody Error); + +public sealed record PrintJobResponse( + string JobId, + string Status, + string Queue, + int Copies, + int PagesPrinted, + ApiErrorBody? Error); diff --git a/src/PrintBridge/App/QueueEditDialog.cs b/src/PrintBridge/App/QueueEditDialog.cs new file mode 100644 index 0000000..a6bae92 --- /dev/null +++ b/src/PrintBridge/App/QueueEditDialog.cs @@ -0,0 +1,143 @@ +using PrintBridge.Printing; +using PrintBridge.Settings; + +namespace PrintBridge.App; + +/// +/// Add/edit dialog for one named queue: the name web apps use, and the Windows +/// printer it maps to. Built in code to match the rest of the UI. +/// +public sealed class QueueEditDialog : Form +{ + private readonly TextBox _nameInput = new() { Width = 260, PlaceholderText = "labels" }; + private readonly ComboBox _printerCombo = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 340 }; + private readonly Label _printerStatusLabel = new() { AutoSize = true, ForeColor = SystemColors.GrayText, Text = string.Empty }; + + public QueueEditDialog(string? queueName, string? printerName) + { + Text = string.IsNullOrEmpty(queueName) ? "Add print queue" : "Edit print queue"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + ShowInTaskbar = false; + StartPosition = FormStartPosition.CenterParent; + AutoScaleMode = AutoScaleMode.Dpi; + Padding = new Padding(12); + + QueueName = queueName ?? string.Empty; + PrinterName = printerName ?? string.Empty; + _nameInput.Text = QueueName; + + BuildLayout(); + LoadPrinters(printerName); + } + + /// Normalized queue name, valid once the dialog returns . + public string QueueName { get; private set; } + + public string PrinterName { get; private set; } + + private void BuildLayout() + { + var layout = new TableLayoutPanel { AutoSize = true, ColumnCount = 2, Dock = DockStyle.Top }; + layout.Controls.Add(new Label { Text = "Queue name:", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 0); + layout.Controls.Add(_nameInput, 1, 0); + layout.Controls.Add(new Label { Text = "Printer:", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 1); + layout.Controls.Add(_printerCombo, 1, 1); + layout.Controls.Add(_printerStatusLabel, 1, 2); + var hint = new Label + { + Text = "Web apps ask for the queue name; they never see the printer name.", + AutoSize = true, + ForeColor = SystemColors.GrayText, + Anchor = AnchorStyles.Left, + }; + layout.Controls.Add(hint, 1, 3); + + var okButton = new Button { Text = "OK", AutoSize = true }; + var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + okButton.Click += (_, _) => Confirm(); + AcceptButton = okButton; + CancelButton = cancelButton; + + var buttonPanel = new FlowLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Bottom, + FlowDirection = FlowDirection.RightToLeft, + Padding = new Padding(4), + }; + buttonPanel.Controls.Add(okButton); + buttonPanel.Controls.Add(cancelButton); + + Controls.Add(buttonPanel); + Controls.Add(layout); + + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + MinimumSize = new Size(460, 0); + } + + private void LoadPrinters(string? selected) + { + List printers; + try + { + printers = PrintService.ListInstalledPrinters(); + } + catch (Exception ex) + { + printers = []; + _printerStatusLabel.Text = $"Could not list printers: {ex.Message}"; + } + + // A queue may point at a printer that has since been removed; keep it selectable + // so editing the name does not silently repoint the queue. + if (!string.IsNullOrEmpty(selected) && + !printers.Any(p => string.Equals(p, selected, StringComparison.OrdinalIgnoreCase))) + { + printers.Insert(0, selected); + _printerStatusLabel.Text = $"\"{selected}\" is not installed on this PC right now."; + } + + _printerCombo.Items.AddRange([.. printers.Cast()]); + if (!string.IsNullOrEmpty(selected)) + { + _printerCombo.SelectedItem = printers.FirstOrDefault(p => + string.Equals(p, selected, StringComparison.OrdinalIgnoreCase)); + } + + if (_printerCombo.SelectedIndex < 0 && _printerCombo.Items.Count > 0) + { + _printerCombo.SelectedIndex = 0; + } + + if (printers.Count == 0 && string.IsNullOrEmpty(_printerStatusLabel.Text)) + { + _printerStatusLabel.Text = "No printers are installed on this PC."; + } + } + + private void Confirm() + { + if (!AppSettings.TryNormalizeQueueName(_nameInput.Text, out var normalized)) + { + MessageBox.Show(this, + "Use 1–64 letters, digits, dots, underscores or hyphens, starting with a letter or digit.", + "Invalid queue name", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (_printerCombo.SelectedItem is not string printer || string.IsNullOrWhiteSpace(printer)) + { + MessageBox.Show(this, "Pick the printer this queue should print to.", + "No printer selected", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + QueueName = normalized; + PrinterName = printer; + DialogResult = DialogResult.OK; + Close(); + } +} diff --git a/src/PrintBridge/App/SettingsForm.cs b/src/PrintBridge/App/SettingsForm.cs new file mode 100644 index 0000000..bd90c5b --- /dev/null +++ b/src/PrintBridge/App/SettingsForm.cs @@ -0,0 +1,349 @@ +using PrintBridge.Hosting; +using PrintBridge.Settings; + +namespace PrintBridge.App; + +/// +/// The configuration window: the named print queues, the local server (port + allowed +/// browser origins) and the run-at-login toggle. +/// Built in code rather than with the WinForms designer to keep it reviewable. +/// +public sealed class SettingsForm : Form +{ + private readonly SettingsStore _settingsStore; + private readonly WebHostRunner _webHostRunner; + private readonly SynchronizationContext _syncContext; + + private readonly List _queues = new(); + private string? _defaultQueue; + + private readonly ListView _queuesList = new() + { + View = View.Details, + FullRowSelect = true, + MultiSelect = false, + HideSelection = false, + Width = 460, + Height = 130, + }; + + private readonly Button _addQueueButton = new() { Text = "Add…", AutoSize = true }; + private readonly Button _editQueueButton = new() { Text = "Edit…", AutoSize = true }; + private readonly Button _removeQueueButton = new() { Text = "Remove", AutoSize = true }; + private readonly Button _defaultQueueButton = new() { Text = "Set as default", AutoSize = true }; + + private readonly NumericUpDown _portInput = new() { Minimum = 1024, Maximum = 65535, Width = 100 }; + private readonly ListBox _originsList = new() { Width = 420, Height = 90 }; + private readonly TextBox _originInput = new() { Width = 300, PlaceholderText = "https://apps.example.gov" }; + private readonly Button _addOriginButton = new() { Text = "Add", AutoSize = true }; + private readonly Button _removeOriginButton = new() { Text = "Remove selected", AutoSize = true }; + private readonly Label _listenerStatusLabel = new() { AutoSize = true, ForeColor = SystemColors.GrayText, Text = string.Empty }; + + private readonly CheckBox _runAtLoginCheck = new() { Text = "Start PrintBridge when I sign in to Windows", AutoSize = true }; + + public SettingsForm(SettingsStore settingsStore, WebHostRunner webHostRunner) + { + _settingsStore = settingsStore; + _webHostRunner = webHostRunner; + _syncContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext(); + + Text = "PrintBridge Settings"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterScreen; + AutoScaleMode = AutoScaleMode.Dpi; + Padding = new Padding(12); + + BuildLayout(); + LoadFromSettings(_settingsStore.Current); + + _addQueueButton.Click += (_, _) => AddQueue(); + _editQueueButton.Click += (_, _) => EditSelectedQueue(); + _queuesList.DoubleClick += (_, _) => EditSelectedQueue(); + _removeQueueButton.Click += (_, _) => RemoveSelectedQueue(); + _defaultQueueButton.Click += (_, _) => MakeSelectedQueueDefault(); + _queuesList.SelectedIndexChanged += (_, _) => UpdateQueueButtons(); + + _addOriginButton.Click += (_, _) => AddOrigin(); + _originInput.KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Enter) + { + e.Handled = e.SuppressKeyPress = true; + AddOrigin(); + } + }; + _removeOriginButton.Click += (_, _) => + { + if (_originsList.SelectedItem is not null) + { + _originsList.Items.Remove(_originsList.SelectedItem); + } + }; + + _webHostRunner.StatusChanged += OnListenerStatusChanged; + FormClosed += (_, _) => _webHostRunner.StatusChanged -= OnListenerStatusChanged; + UpdateListenerStatusLabel(); + } + + private void BuildLayout() + { + _queuesList.Columns.Add("Queue", 150); + _queuesList.Columns.Add("Printer", 250); + _queuesList.Columns.Add("Default", 60); + + var queuesGroup = new GroupBox { Text = "Print queues", AutoSize = true, Dock = DockStyle.Top, Padding = new Padding(10) }; + var queuesLayout = new TableLayoutPanel { AutoSize = true, ColumnCount = 1, Dock = DockStyle.Fill }; + var queuesHint = new Label + { + Text = "Web apps request a queue by name — they never see the printer behind it.", + AutoSize = true, + Anchor = AnchorStyles.Left, + }; + queuesLayout.Controls.Add(queuesHint, 0, 0); + queuesLayout.Controls.Add(_queuesList, 0, 1); + var queueButtons = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = false }; + queueButtons.Controls.Add(_addQueueButton); + queueButtons.Controls.Add(_editQueueButton); + queueButtons.Controls.Add(_removeQueueButton); + queueButtons.Controls.Add(_defaultQueueButton); + queuesLayout.Controls.Add(queueButtons, 0, 2); + queuesGroup.Controls.Add(queuesLayout); + + var serverGroup = new GroupBox { Text = "Local server", AutoSize = true, Dock = DockStyle.Top, Padding = new Padding(10) }; + var serverLayout = new TableLayoutPanel { AutoSize = true, ColumnCount = 3, Dock = DockStyle.Fill }; + serverLayout.Controls.Add(new Label { Text = "Port:", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 0); + serverLayout.Controls.Add(_portInput, 1, 0); + serverLayout.Controls.Add(_listenerStatusLabel, 2, 0); + var originsLabel = new Label + { + Text = "Allowed website origins (only these sites may print from a browser):", + AutoSize = true, + Anchor = AnchorStyles.Left, + }; + serverLayout.Controls.Add(originsLabel, 0, 1); + serverLayout.SetColumnSpan(originsLabel, 3); + serverLayout.Controls.Add(_originsList, 0, 2); + serverLayout.SetColumnSpan(_originsList, 3); + var originAddPanel = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = false }; + originAddPanel.Controls.Add(_originInput); + originAddPanel.Controls.Add(_addOriginButton); + originAddPanel.Controls.Add(_removeOriginButton); + serverLayout.Controls.Add(originAddPanel, 0, 3); + serverLayout.SetColumnSpan(originAddPanel, 3); + serverGroup.Controls.Add(serverLayout); + + var startupPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Top, Padding = new Padding(4) }; + startupPanel.Controls.Add(_runAtLoginCheck); + + var saveButton = new Button { Text = "Save", AutoSize = true, DialogResult = DialogResult.OK }; + var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + saveButton.Click += (_, _) => SaveAndClose(); + cancelButton.Click += (_, _) => Close(); + AcceptButton = saveButton; + CancelButton = cancelButton; + var buttonPanel = new FlowLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Bottom, + FlowDirection = FlowDirection.RightToLeft, + Padding = new Padding(4), + }; + buttonPanel.Controls.Add(saveButton); + buttonPanel.Controls.Add(cancelButton); + + // Docked top-to-bottom; add in reverse so the queues group ends up on top. + Controls.Add(buttonPanel); + Controls.Add(startupPanel); + Controls.Add(serverGroup); + Controls.Add(queuesGroup); + + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + MinimumSize = new Size(540, 0); + } + + private void LoadFromSettings(AppSettings settings) + { + _queues.Clear(); + _queues.AddRange(settings.Queues.Select(q => q.Clone())); + _defaultQueue = settings.DefaultQueue; + RefreshQueueList(); + + _portInput.Value = Math.Clamp(settings.Port, (int)_portInput.Minimum, (int)_portInput.Maximum); + foreach (var origin in settings.AllowedOrigins) + { + _originsList.Items.Add(origin); + } + + _runAtLoginCheck.Checked = settings.RunAtLogin; + } + + private void RefreshQueueList() + { + var selectedName = SelectedQueue()?.Name; + _queuesList.BeginUpdate(); + _queuesList.Items.Clear(); + foreach (var queue in _queues) + { + var isDefault = string.Equals(queue.Name, _defaultQueue, StringComparison.OrdinalIgnoreCase); + var item = new ListViewItem([queue.Name, queue.PrinterName, isDefault ? "Yes" : string.Empty]) + { + Tag = queue, + Selected = string.Equals(queue.Name, selectedName, StringComparison.OrdinalIgnoreCase), + }; + _queuesList.Items.Add(item); + } + + _queuesList.EndUpdate(); + UpdateQueueButtons(); + } + + private PrintQueueDefinition? SelectedQueue() => + _queuesList.SelectedItems.Count > 0 ? _queuesList.SelectedItems[0].Tag as PrintQueueDefinition : null; + + private void UpdateQueueButtons() + { + var hasSelection = _queuesList.SelectedItems.Count > 0; + _editQueueButton.Enabled = hasSelection; + _removeQueueButton.Enabled = hasSelection; + _defaultQueueButton.Enabled = hasSelection; + } + + private void AddQueue() + { + using var dialog = new QueueEditDialog(null, null); + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + if (NameTaken(dialog.QueueName, existing: null)) + { + return; + } + + _queues.Add(new PrintQueueDefinition { Name = dialog.QueueName, PrinterName = dialog.PrinterName }); + + // The first queue added is the obvious default. + _defaultQueue ??= dialog.QueueName; + RefreshQueueList(); + } + + private void EditSelectedQueue() + { + if (SelectedQueue() is not { } queue) + { + return; + } + + using var dialog = new QueueEditDialog(queue.Name, queue.PrinterName); + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + if (NameTaken(dialog.QueueName, existing: queue)) + { + return; + } + + if (string.Equals(_defaultQueue, queue.Name, StringComparison.OrdinalIgnoreCase)) + { + _defaultQueue = dialog.QueueName; + } + + queue.Name = dialog.QueueName; + queue.PrinterName = dialog.PrinterName; + RefreshQueueList(); + } + + private bool NameTaken(string name, PrintQueueDefinition? existing) + { + var clash = _queues.Any(q => !ReferenceEquals(q, existing) && + string.Equals(q.Name, name, StringComparison.OrdinalIgnoreCase)); + if (clash) + { + MessageBox.Show(this, $"There is already a queue named \"{name}\".", + "Duplicate queue name", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + + return clash; + } + + private void RemoveSelectedQueue() + { + if (SelectedQueue() is not { } queue) + { + return; + } + + _queues.Remove(queue); + if (string.Equals(_defaultQueue, queue.Name, StringComparison.OrdinalIgnoreCase)) + { + _defaultQueue = _queues.FirstOrDefault()?.Name; + } + + RefreshQueueList(); + } + + private void MakeSelectedQueueDefault() + { + if (SelectedQueue() is { } queue) + { + _defaultQueue = queue.Name; + RefreshQueueList(); + } + } + + private void AddOrigin() + { + if (!AppSettings.TryNormalizeOrigin(_originInput.Text, out var normalized)) + { + MessageBox.Show(this, + "Enter a full origin such as https://apps.example.gov — scheme and host only, no path.", + "Invalid origin", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (!_originsList.Items.Contains(normalized)) + { + _originsList.Items.Add(normalized); + } + + _originInput.Clear(); + } + + private void SaveAndClose() + { + var settings = _settingsStore.Current.Clone(); + settings.Queues = _queues.Select(q => q.Clone()).ToList(); + settings.DefaultQueue = _defaultQueue; + settings.Port = (int)_portInput.Value; + settings.AllowedOrigins = _originsList.Items.Cast().ToList(); + settings.RunAtLogin = _runAtLoginCheck.Checked; + + _settingsStore.Save(settings); + Close(); + } + + private void OnListenerStatusChanged() + { + _syncContext.Post(_ => UpdateListenerStatusLabel(), null); + } + + private void UpdateListenerStatusLabel() + { + if (_webHostRunner.IsRunning) + { + _listenerStatusLabel.Text = $"Listening on http://127.0.0.1:{_settingsStore.Current.Port}"; + _listenerStatusLabel.ForeColor = Color.DarkGreen; + } + else + { + _listenerStatusLabel.Text = _webHostRunner.LastError ?? "Not listening"; + _listenerStatusLabel.ForeColor = Color.Firebrick; + } + } +} diff --git a/src/PrintBridge/App/TrayApplicationContext.cs b/src/PrintBridge/App/TrayApplicationContext.cs new file mode 100644 index 0000000..2b86699 --- /dev/null +++ b/src/PrintBridge/App/TrayApplicationContext.cs @@ -0,0 +1,172 @@ +using System.Diagnostics; +using PrintBridge.Hosting; +using PrintBridge.Printing; +using PrintBridge.Settings; +using Serilog; + +namespace PrintBridge.App; + +/// +/// The tray icon and its menu. PrintBridge has no main window — the tray icon is the app. +/// +public sealed class TrayApplicationContext : ApplicationContext +{ + private readonly SettingsStore _settingsStore; + private readonly PrintJobManager _jobManager; + private readonly WebHostRunner _webHostRunner; + private readonly NotifyIcon _notifyIcon; + private readonly SynchronizationContext _syncContext; + private SettingsForm? _settingsForm; + + public TrayApplicationContext( + SettingsStore settingsStore, + PrintJobManager jobManager, + WebHostRunner webHostRunner, + bool openSettingsOnStartup) + { + _settingsStore = settingsStore; + _jobManager = jobManager; + _webHostRunner = webHostRunner; + _syncContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext(); + + var menu = new ContextMenuStrip(); + menu.Items.Add("Settings…", null, (_, _) => ShowSettings()); + menu.Items.Add("Print test page", null, (_, _) => PrintTestPage()); + menu.Items.Add("Open log folder", null, (_, _) => OpenLogFolder()); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Exit", null, (_, _) => ExitApplication()); + + _notifyIcon = new NotifyIcon + { + Icon = LoadIcon(), + Text = "PrintBridge", + ContextMenuStrip = menu, + Visible = true, + }; + _notifyIcon.DoubleClick += (_, _) => ShowSettings(); + + _jobManager.JobFinished += OnJobFinished; + _webHostRunner.StatusChanged += OnListenerStatusChanged; + + if (_webHostRunner.LastError is { } startupError) + { + ShowBalloon("PrintBridge", startupError, ToolTipIcon.Warning); + } + + if (openSettingsOnStartup) + { + // Let the message loop start before opening the window. + _syncContext.Post(_ => ShowSettings(), null); + } + } + + private static Icon LoadIcon() + { + try + { + var iconPath = Path.Combine(AppContext.BaseDirectory, "Resources", "printbridge.ico"); + if (File.Exists(iconPath)) + { + return new Icon(iconPath); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Could not load the tray icon; falling back to the stock icon"); + } + + return SystemIcons.Application; + } + + private void ShowSettings() + { + if (_settingsForm is { IsDisposed: false }) + { + _settingsForm.Activate(); + return; + } + + _settingsForm = new SettingsForm(_settingsStore, _webHostRunner); + _settingsForm.FormClosed += (_, _) => _settingsForm = null; + _settingsForm.Show(); + } + + /// + /// Prints the bundled test page through the ordinary pipeline, so a successful test + /// proves the same path a web app uses. + /// + private void PrintTestPage() + { + var settings = _settingsStore.Current; + if (!settings.TryResolveQueue(null, out var queue, out _)) + { + ShowBalloon("PrintBridge", "No print queue is configured yet — open Settings first.", ToolTipIcon.Warning); + ShowSettings(); + return; + } + + byte[] pdfBytes; + var testPagePath = Path.Combine(AppContext.BaseDirectory, "Resources", "testpage.pdf"); + try + { + pdfBytes = File.ReadAllBytes(testPagePath); + } + catch (Exception ex) + { + Log.Error(ex, "Could not read the bundled test page from {Path}", testPagePath); + ShowBalloon("PrintBridge", $"Could not read the test page: {ex.Message}", ToolTipIcon.Error); + return; + } + + _jobManager.Enqueue(new PrintRequest(queue.Name, queue.PrinterName, Copies: 1, PdfBytes: pdfBytes)); + ShowBalloon("PrintBridge", $"Sent a test page to \"{queue.Name}\".", ToolTipIcon.Info); + } + + private void OpenLogFolder() + { + var logDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PrintBridge", "logs"); + Directory.CreateDirectory(logDirectory); + Process.Start(new ProcessStartInfo(logDirectory) { UseShellExecute = true }); + } + + private void OnJobFinished(PrintJob job) + { + if (job.Status == PrintJobStatus.Failed) + { + ShowBalloon("PrintBridge — print failed", job.ErrorMessage ?? "The print job failed.", ToolTipIcon.Error); + } + } + + private void OnListenerStatusChanged() + { + if (_webHostRunner.LastError is { } error) + { + ShowBalloon("PrintBridge", error, ToolTipIcon.Warning); + } + } + + private void ShowBalloon(string title, string message, ToolTipIcon icon) + { + // Events can fire on worker threads; NotifyIcon belongs to the UI thread. + _syncContext.Post(_ => _notifyIcon.ShowBalloonTip(5000, title, message, icon), null); + } + + private void ExitApplication() + { + _notifyIcon.Visible = false; + ExitThread(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _jobManager.JobFinished -= OnJobFinished; + _webHostRunner.StatusChanged -= OnListenerStatusChanged; + _notifyIcon.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/PrintBridge/Hosting/WebHostRunner.cs b/src/PrintBridge/Hosting/WebHostRunner.cs new file mode 100644 index 0000000..6f23c6e --- /dev/null +++ b/src/PrintBridge/Hosting/WebHostRunner.cs @@ -0,0 +1,139 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PrintBridge.Api; +using PrintBridge.Printing; +using PrintBridge.Settings; +using Serilog; + +namespace PrintBridge.Hosting; + +/// +/// Hosts Kestrel inside the WinForms process, bound to 127.0.0.1 only. Rebuilds the +/// host when settings (port / allowed origins) change. +/// +public sealed class WebHostRunner : IAsyncDisposable +{ + private const string CorsPolicyName = "browser"; + + private readonly SettingsStore _settingsStore; + private readonly PrintJobManager _jobManager; + private readonly Serilog.ILogger _logger; + private WebApplication? _app; + private readonly SemaphoreSlim _lifecycleLock = new(1, 1); + + public WebHostRunner(SettingsStore settingsStore, PrintJobManager jobManager, Serilog.ILogger logger) + { + _settingsStore = settingsStore; + _jobManager = jobManager; + _logger = logger; + } + + public bool IsRunning { get; private set; } + + public string? LastError { get; private set; } + + /// Raised on start/stop/failure so the tray and settings window can show listener state. + public event Action? StatusChanged; + + public async Task StartAsync() + { + await _lifecycleLock.WaitAsync().ConfigureAwait(false); + try + { + await StopCoreAsync().ConfigureAwait(false); + + var settings = _settingsStore.Current; + try + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ContentRootPath = AppContext.BaseDirectory, + }); + builder.Logging.ClearProviders(); + builder.Logging.AddSerilog(_logger); + builder.Services.AddCors(options => options.AddPolicy(CorsPolicyName, policy => policy + .WithOrigins(settings.AllowedOrigins.ToArray()) + .WithMethods("GET", "POST", "DELETE") + .AllowAnyHeader())); + builder.WebHost.ConfigureKestrel(kestrel => + { + kestrel.Listen(IPAddress.Loopback, settings.Port); + // Documents are posted as raw PDF bodies; the API turns the + // resulting 413 into a documentTooLarge error. + kestrel.Limits.MaxRequestBodySize = ApiEndpoints.MaxBodyBytes; + }); + + var app = builder.Build(); + app.UseCors(CorsPolicyName); + ApiEndpoints.Map(app, _settingsStore, _jobManager); + + await app.StartAsync().ConfigureAwait(false); + _app = app; + IsRunning = true; + LastError = null; + _logger.Information("PrintBridge API listening on http://127.0.0.1:{Port}", settings.Port); + } + catch (Exception ex) + { + IsRunning = false; + LastError = ex is IOException + ? $"Could not listen on port {settings.Port} — it may be in use by another program." + : ex.Message; + _logger.Error(ex, "Failed to start the PrintBridge API on port {Port}", settings.Port); + } + } + finally + { + _lifecycleLock.Release(); + StatusChanged?.Invoke(); + } + } + + public async Task StopAsync() + { + await _lifecycleLock.WaitAsync().ConfigureAwait(false); + try + { + await StopCoreAsync().ConfigureAwait(false); + } + finally + { + _lifecycleLock.Release(); + StatusChanged?.Invoke(); + } + } + + private async Task StopCoreAsync() + { + if (_app is null) + { + return; + } + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await _app.StopAsync(cts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Warning(ex, "Error stopping the PrintBridge API"); + } + finally + { + await _app.DisposeAsync().ConfigureAwait(false); + _app = null; + IsRunning = false; + } + } + + public async ValueTask DisposeAsync() + { + await StopAsync().ConfigureAwait(false); + _lifecycleLock.Dispose(); + } +} diff --git a/src/PrintBridge/PrintBridge.csproj b/src/PrintBridge/PrintBridge.csproj new file mode 100644 index 0000000..e5205b0 --- /dev/null +++ b/src/PrintBridge/PrintBridge.csproj @@ -0,0 +1,33 @@ + + + + WinExe + net10.0-windows + true + PrintBridge + PrintBridge + Resources\printbridge.ico + PerMonitorV2 + en + + + + + + + + + + + + + + + + + + + + diff --git a/src/PrintBridge/Printing/PdfSniffer.cs b/src/PrintBridge/Printing/PdfSniffer.cs new file mode 100644 index 0000000..9ff0085 --- /dev/null +++ b/src/PrintBridge/Printing/PdfSniffer.cs @@ -0,0 +1,25 @@ +namespace PrintBridge.Printing; + +/// +/// Cheap content check for uploaded bodies. The API does not require a +/// Content-Type of application/pdf; it looks for the PDF header instead, +/// which the spec allows to be preceded by junk (some producers emit a BOM or blank +/// lines), so the marker is searched for in the first 1024 bytes. +/// +public static class PdfSniffer +{ + private const int HeaderSearchWindow = 1024; + + private static ReadOnlySpan Marker => "%PDF-"u8; + + public static bool LooksLikePdf(ReadOnlySpan content) + { + if (content.Length < Marker.Length) + { + return false; + } + + var window = content.Length > HeaderSearchWindow ? content[..HeaderSearchWindow] : content; + return window.IndexOf(Marker) >= 0; + } +} diff --git a/src/PrintBridge/Printing/PrintJob.cs b/src/PrintBridge/Printing/PrintJob.cs new file mode 100644 index 0000000..b39273a --- /dev/null +++ b/src/PrintBridge/Printing/PrintJob.cs @@ -0,0 +1,81 @@ +namespace PrintBridge.Printing; + +public enum PrintJobStatus +{ + Queued, + Printing, + Completed, + Failed, + Canceled, +} + +/// Machine-readable error codes surfaced to API clients. +public static class PrintErrorCodes +{ + public const string NoQueueConfigured = "noQueueConfigured"; + public const string UnknownQueue = "unknownQueue"; + public const string InvalidDocument = "invalidDocument"; + public const string InvalidCopies = "invalidCopies"; + public const string DocumentTooLarge = "documentTooLarge"; + public const string PrinterUnavailable = "printerUnavailable"; + public const string Canceled = "canceled"; + public const string PrintFailed = "printFailed"; +} + +/// Thrown by the print pipeline with a machine-readable code from . +public sealed class PrintBridgeException : Exception +{ + public PrintBridgeException(string code, string message, Exception? inner = null) + : base(message, inner) + { + Code = code; + } + + public string Code { get; } +} + +/// +/// Resolved parameters for one print: the queue has already been matched against the +/// configuration, so the printing layer never sees a caller-supplied printer name. +/// +public sealed record PrintRequest( + string Queue, + string PrinterName, + int Copies, + byte[] PdfBytes); + +public sealed class PrintJob +{ + public PrintJob(string queue, int copies) + { + Queue = queue; + Copies = copies; + } + + public string Id { get; } = Guid.NewGuid().ToString("N"); + + /// Name of the queue the job was submitted to (never the Windows printer name). + public string Queue { get; } + + public int Copies { get; } + + public PrintJobStatus Status { get; internal set; } = PrintJobStatus.Queued; + + /// Pages handed to the spooler so far; multiplied out over copies. + public int PagesPrinted { get; internal set; } + + public string? ErrorCode { get; internal set; } + + public string? ErrorMessage { get; internal set; } + + /// The submitted PDF. Released (set to null) once the job reaches a terminal state. + public byte[]? PdfBytes { get; internal set; } + + public DateTimeOffset CreatedUtc { get; } = DateTimeOffset.UtcNow; + + public DateTimeOffset? FinishedUtc { get; internal set; } + + internal CancellationTokenSource Cts { get; } = new(); + + public bool IsTerminal => Status is PrintJobStatus.Completed or PrintJobStatus.Failed or PrintJobStatus.Canceled; +} diff --git a/src/PrintBridge/Printing/PrintJobManager.cs b/src/PrintBridge/Printing/PrintJobManager.cs new file mode 100644 index 0000000..72d94e9 --- /dev/null +++ b/src/PrintBridge/Printing/PrintJobManager.cs @@ -0,0 +1,167 @@ +using System.Collections.Concurrent; + +namespace PrintBridge.Printing; + +/// Prints one job; injected so the manager is testable without a printer. +public delegate Task PrintExecutor(PrintRequest request, IProgress pageProgress, CancellationToken cancellationToken); + +/// +/// In-memory print job registry. Submissions are always accepted and drained +/// first-in-first-out by a single loop: rendering a PDF at printer resolution is +/// memory-hungry, and serializing the work bounds that cost no matter how many tabs +/// hit the API at once. Terminal jobs are kept for so the +/// browser can observe the outcome, then pruned. +/// +public sealed class PrintJobManager : IDisposable +{ + private readonly PrintExecutor _executor; + private readonly TimeSpan _retention; + private readonly ConcurrentDictionary _jobs = new(); + private readonly ConcurrentQueue<(PrintJob Job, PrintRequest Request)> _pending = new(); + private readonly SemaphoreSlim _pendingSignal = new(0); + private readonly CancellationTokenSource _shutdown = new(); + private readonly System.Threading.Timer _pruneTimer; + + public PrintJobManager(PrintExecutor executor, TimeSpan? retention = null) + { + _executor = executor; + _retention = retention ?? TimeSpan.FromMinutes(10); + _pruneTimer = new System.Threading.Timer(_ => Prune(), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); + _ = Task.Run(DrainAsync); + } + + /// Raised when a job reaches a terminal state (completed, failed or canceled). + public event Action? JobFinished; + + /// Jobs waiting for the drain loop, not counting the one being printed. + public int QueuedCount => _pending.Count; + + /// Accepts a job for printing. Never rejects: the queue absorbs bursts. + public PrintJob Enqueue(PrintRequest request) + { + var job = new PrintJob(request.Queue, request.Copies) { PdfBytes = request.PdfBytes }; + _jobs[job.Id] = job; + _pending.Enqueue((job, request)); + _pendingSignal.Release(); + return job; + } + + public PrintJob? Get(string jobId) => _jobs.TryGetValue(jobId, out var job) ? job : null; + + /// Cancels a queued or printing job, or discards a finished one. Returns false when unknown. + public bool CancelOrDiscard(string jobId) + { + if (!_jobs.TryGetValue(jobId, out var job)) + { + return false; + } + + if (job.IsTerminal) + { + _jobs.TryRemove(jobId, out _); + return true; + } + + try + { + // A job still waiting in the queue stays there; the drain loop sees the + // canceled token when it gets to it and finishes it without printing. + job.Cts.Cancel(); + } + catch (ObjectDisposedException) + { + // Job finished between the lookup and the cancel; nothing to do. + } + + return true; + } + + private async Task DrainAsync() + { + while (!_shutdown.IsCancellationRequested) + { + try + { + await _pendingSignal.WaitAsync(_shutdown.Token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + return; + } + + if (_pending.TryDequeue(out var next)) + { + await RunAsync(next.Job, next.Request).ConfigureAwait(false); + } + } + } + + private async Task RunAsync(PrintJob job, PrintRequest request) + { + var progress = new Progress(pages => job.PagesPrinted = pages); + try + { + job.Cts.Token.ThrowIfCancellationRequested(); + job.Status = PrintJobStatus.Printing; + await _executor(request, progress, job.Cts.Token).ConfigureAwait(false); + job.Status = PrintJobStatus.Completed; + } + catch (OperationCanceledException) + { + job.Status = PrintJobStatus.Canceled; + job.ErrorCode = PrintErrorCodes.Canceled; + job.ErrorMessage = "The print job was canceled."; + } + catch (PrintBridgeException ex) + { + job.Status = PrintJobStatus.Failed; + job.ErrorCode = ex.Code; + job.ErrorMessage = ex.Message; + } + catch (Exception ex) + { + job.Status = PrintJobStatus.Failed; + job.ErrorCode = PrintErrorCodes.PrintFailed; + job.ErrorMessage = ex.Message; + } + finally + { + // The document can be tens of megabytes; a terminal job never needs it again. + job.PdfBytes = null; + job.FinishedUtc = DateTimeOffset.UtcNow; + job.Cts.Dispose(); + JobFinished?.Invoke(job); + } + } + + private void Prune() + { + var cutoff = DateTimeOffset.UtcNow - _retention; + foreach (var (id, job) in _jobs) + { + if (job.IsTerminal && job.FinishedUtc is { } finished && finished < cutoff) + { + _jobs.TryRemove(id, out _); + } + } + } + + public void Dispose() + { + _pruneTimer.Dispose(); + _shutdown.Cancel(); + foreach (var job in _jobs.Values) + { + if (!job.IsTerminal) + { + try + { + job.Cts.Cancel(); + } + catch (ObjectDisposedException) + { + } + } + } + } +} diff --git a/src/PrintBridge/Printing/PrintService.cs b/src/PrintBridge/Printing/PrintService.cs new file mode 100644 index 0000000..d0ce2eb --- /dev/null +++ b/src/PrintBridge/Printing/PrintService.cs @@ -0,0 +1,322 @@ +using System.ComponentModel; +using System.Drawing.Printing; +using System.Runtime.ExceptionServices; +using Microsoft.Extensions.Logging; +using PDFtoImage; +using PDFtoImage.Exceptions; + +namespace PrintBridge.Printing; + +/// +/// The only class that talks to PDFium (via PDFtoImage) and to +/// . Renders each PDF page at the printer's own +/// resolution and hands it to the Windows spooler with no user interaction: +/// is what makes the print silent — the default +/// controller would pop up a progress dialog. +/// +public sealed class PrintService +{ + /// Rasterization bounds. Below 150 dpi text looks ragged; above 600 the bitmaps get huge. + private const int MinRenderDpi = 150; + + private const int MaxRenderDpi = 600; + + private const int FallbackRenderDpi = 300; + + private readonly ILogger _logger; + + public PrintService(ILogger logger) + { + _logger = logger; + } + + /// Windows printers installed for the current user, in driver order. + public static List ListInstalledPrinters() => + PrinterSettings.InstalledPrinters.Cast().ToList(); + + /// True when Windows still knows the printer a queue points at. + public static bool PrinterExists(string? printerName) + { + if (string.IsNullOrWhiteSpace(printerName)) + { + return false; + } + + return ListInstalledPrinters().Any(p => string.Equals(p, printerName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Renders and spools the job. Completing means the spooler accepted the document — + /// what happens after that (paper jams, an offline printer) is not visible here. + /// + public Task PrintAsync(PrintRequest request, IProgress pageProgress, CancellationToken cancellationToken) => + // PrintDocument.Print() blocks until the whole document is spooled. + Task.Run(() => Print(request, pageProgress, cancellationToken), cancellationToken); + + private void Print(PrintRequest request, IProgress pageProgress, CancellationToken cancellationToken) + { + var pdfBytes = request.PdfBytes; + IList pageSizes; + try + { + // Doubles as the parse check: a document PDFium cannot open never gets here. + pageSizes = Conversion.GetPageSizes(pdfBytes); + } + catch (Exception ex) when (IsPdfFailure(ex)) + { + throw new PrintBridgeException(PrintErrorCodes.InvalidDocument, + "The document could not be read as a PDF.", ex); + } + + var pageCount = pageSizes.Count; + if (pageCount == 0) + { + throw new PrintBridgeException(PrintErrorCodes.InvalidDocument, "The PDF contains no pages."); + } + + using var document = new PrintDocument(); + try + { + document.PrinterSettings.PrinterName = request.PrinterName; + if (!document.PrinterSettings.IsValid) + { + throw new PrintBridgeException(PrintErrorCodes.PrinterUnavailable, + $"The printer for queue '{request.Queue}' is not available."); + } + } + catch (InvalidPrinterException ex) + { + throw new PrintBridgeException(PrintErrorCodes.PrinterUnavailable, + $"The printer for queue '{request.Queue}' is not available.", ex); + } + + document.DocumentName = $"PrintBridge — {request.Queue}"; + // Silent printing: the default controller shows a progress dialog. + document.PrintController = new StandardPrintController(); + // Origin at the top-left of the printable area, which is what GetPrintableArea measures. + document.OriginAtMargins = false; + + var renderDpi = ResolveRenderDpi(document.PrinterSettings); + var driverCopies = ApplyCopies(document.PrinterSettings, request.Copies); + var passes = request.Copies / driverCopies; + + var pageIndex = 0; + var pagesRendered = 0; + Exception? pageFailure = null; + + document.QueryPageSettings += (_, e) => + { + if (pageIndex < pageSizes.Count) + { + // Per-page orientation, taken from the PDF page itself. + e.PageSettings.Landscape = pageSizes[pageIndex].Width > pageSizes[pageIndex].Height; + } + }; + + document.PrintPage += (_, e) => + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + + using var page = RenderPage(pdfBytes, pageIndex, renderDpi); + if (e.Graphics is { } graphics) + { + graphics.DrawImage(page.Image, FitCentered(pageSizes[pageIndex], GetPrintableArea(e))); + } + + pageIndex++; + pagesRendered++; + pageProgress.Report(pagesRendered * driverCopies); + e.HasMorePages = pageIndex < pageCount; + } + catch (Exception ex) + { + // Throwing out of the event handler would surface as a wrapped GDI+ + // error; stop the document and rethrow the real exception below. + pageFailure = ex; + e.Cancel = true; + e.HasMorePages = false; + } + }; + + _logger.LogInformation( + "Printing {Pages} page(s) x{Copies} to queue {Queue} at {Dpi} dpi", + pageCount, request.Copies, request.Queue, renderDpi); + + for (var pass = 0; pass < passes; pass++) + { + pageIndex = 0; + try + { + document.Print(); + } + catch (InvalidPrinterException ex) + { + throw new PrintBridgeException(PrintErrorCodes.PrinterUnavailable, + $"The printer for queue '{request.Queue}' is not available.", ex); + } + catch (Win32Exception ex) + { + throw new PrintBridgeException(PrintErrorCodes.PrintFailed, + $"Windows refused the print job: {ex.Message}", ex); + } + + if (pageFailure is not null) + { + ExceptionDispatchInfo.Capture(pageFailure).Throw(); + } + + cancellationToken.ThrowIfCancellationRequested(); + } + + _logger.LogInformation("Print job for queue {Queue} spooled ({Pages} page(s))", request.Queue, pagesRendered * driverCopies); + } + + /// + /// Asks the driver for copies when it supports as many as requested, and reports how + /// many copies one pass produces; the caller loops + /// the document for the rest. + /// + private static int ApplyCopies(PrinterSettings settings, int copies) + { + if (copies <= 1) + { + return 1; + } + + var maximum = settings.MaximumCopies; + if (maximum >= copies && copies <= short.MaxValue) + { + settings.Copies = (short)copies; + return copies; + } + + return 1; + } + + private static int ResolveRenderDpi(PrinterSettings settings) + { + var dpi = FallbackRenderDpi; + try + { + // Drivers report negative values (-1…-4) for the named quality levels + // rather than a real resolution; only a positive number is usable. + var resolution = settings.DefaultPageSettings.PrinterResolution; + if (resolution is not null && resolution.X > 0) + { + dpi = resolution.X; + } + } + catch (Exception ex) when (ex is InvalidPrinterException or Win32Exception) + { + // Fall back to the default; the printer check above already passed. + } + + return Math.Clamp(dpi, MinRenderDpi, MaxRenderDpi); + } + + /// + /// The printable area in hundredths of an inch. Windows reports it in portrait + /// terms even for a landscape page, so it is swapped back here. + /// + private static SizeF GetPrintableArea(PrintPageEventArgs e) + { + var printable = e.PageSettings.PrintableArea; + var width = printable.Width; + var height = printable.Height; + if (e.PageSettings.Landscape) + { + (width, height) = (height, width); + } + + if (width <= 0 || height <= 0) + { + // Some drivers report an empty printable area; the full sheet is a safe stand-in. + var bounds = e.PageBounds; + (width, height) = (bounds.Width, bounds.Height); + } + + return new SizeF(width, height); + } + + /// + /// Places a PDF page (size in points, 1/72") centered inside the printable area, + /// scaled down to fit but never enlarged. Result is in hundredths of an inch. + /// + public static RectangleF FitCentered(SizeF pageSizeInPoints, SizeF printableArea) + { + var contentWidth = pageSizeInPoints.Width / 72f * 100f; + var contentHeight = pageSizeInPoints.Height / 72f * 100f; + if (contentWidth <= 0 || contentHeight <= 0) + { + return new RectangleF(0, 0, printableArea.Width, printableArea.Height); + } + + var scale = Math.Min(printableArea.Width / contentWidth, printableArea.Height / contentHeight); + scale = Math.Min(scale, 1f); + + var width = contentWidth * scale; + var height = contentHeight * scale; + return new RectangleF( + (printableArea.Width - width) / 2f, + (printableArea.Height - height) / 2f, + width, + height); + } + + /// + /// Rasterizes one page. Pages are rendered one at a time and released immediately — + /// a letter page at 600 dpi is over 100 MB as a bitmap. + /// + private static RenderedPage RenderPage(byte[] pdfBytes, int pageIndex, int dpi) + { + var png = new MemoryStream(); + try + { + Conversion.SavePng(png, pdfBytes, pageIndex, null, new RenderOptions( + Dpi: dpi, + WithAnnotations: true, + WithFormFill: true)); + png.Position = 0; + return new RenderedPage(png); + } + catch (Exception ex) when (IsPdfFailure(ex)) + { + png.Dispose(); + throw new PrintBridgeException(PrintErrorCodes.InvalidDocument, + $"Page {pageIndex + 1} of the PDF could not be rendered.", ex); + } + catch + { + png.Dispose(); + throw; + } + } + + private static bool IsPdfFailure(Exception ex) => + ex is PdfException or ArgumentException or Win32Exception or FormatException; + + /// + /// A rendered page plus the PNG stream behind it: GDI+ requires the stream an + /// was loaded from to stay open for the image's lifetime. + /// + private sealed class RenderedPage : IDisposable + { + private readonly MemoryStream _png; + + public RenderedPage(MemoryStream png) + { + _png = png; + Image = Image.FromStream(png); + } + + public Image Image { get; } + + public void Dispose() + { + Image.Dispose(); + _png.Dispose(); + } + } +} diff --git a/src/PrintBridge/Program.cs b/src/PrintBridge/Program.cs new file mode 100644 index 0000000..d815ff5 --- /dev/null +++ b/src/PrintBridge/Program.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.Logging; +using PrintBridge.App; +using PrintBridge.Hosting; +using PrintBridge.Printing; +using PrintBridge.Settings; +using Serilog; +using Serilog.Extensions.Logging; + +namespace PrintBridge; + +internal static class Program +{ + [STAThread] + private static void Main(string[] args) + { + using var singleInstanceMutex = new Mutex(initiallyOwned: true, @"Global\PrintBridge.SingleInstance", out var isFirstInstance); + if (!isFirstInstance) + { + // Another PrintBridge is already running (and owns the port); just exit quietly. + return; + } + + var logDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PrintBridge", "logs"); + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.File( + Path.Combine(logDirectory, "printbridge-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 14) + .CreateLogger(); + + try + { + Log.Information("PrintBridge starting"); + + var settingsStore = new SettingsStore(); + settingsStore.Load(); + + try + { + StartupRegistration.Apply(settingsStore.Current.RunAtLogin); + } + catch (Exception ex) + { + Log.Warning(ex, "Could not update the run-at-login registration"); + } + + using var loggerFactory = new SerilogLoggerFactory(Log.Logger); + var printService = new PrintService(loggerFactory.CreateLogger("PrintBridge.Printing")); + using var jobManager = new PrintJobManager(printService.PrintAsync); + var webHostRunner = new WebHostRunner(settingsStore, jobManager, Log.Logger); + + settingsStore.Changed += settings => + { + try + { + StartupRegistration.Apply(settings.RunAtLogin); + } + catch (Exception ex) + { + Log.Warning(ex, "Could not update the run-at-login registration"); + } + + // Port or origins may have changed; rebuild the listener. + _ = webHostRunner.StartAsync(); + }; + + // No message loop yet, so blocking here cannot deadlock the UI. + webHostRunner.StartAsync().GetAwaiter().GetResult(); + + var openSettingsOnStartup = settingsStore.IsFirstRun && !args.Contains("--minimized"); + + ApplicationConfiguration.Initialize(); + using var trayContext = new TrayApplicationContext( + settingsStore, jobManager, webHostRunner, openSettingsOnStartup); + Application.Run(trayContext); + + webHostRunner.DisposeAsync().AsTask().GetAwaiter().GetResult(); + Log.Information("PrintBridge exited cleanly"); + } + catch (Exception ex) + { + Log.Fatal(ex, "PrintBridge crashed"); + throw; + } + finally + { + Log.CloseAndFlush(); + } + } +} diff --git a/src/PrintBridge/Resources/printbridge.ico b/src/PrintBridge/Resources/printbridge.ico new file mode 100644 index 0000000000000000000000000000000000000000..a7309c78d47d975f4b1af652b4995c09ad2449bf GIT binary patch literal 15010 zcmajG1yo#3vo5>`1`qD;5+rDFcY<4Rf+R=?9^3|kySuwXaDol)?he6&y9B}|?|Z-d zpS#xi@98zOrfZ(+>fPN{wX3>U0{{p>05C9smqGztzyN^rOO1fwU)l={02Z(>2*tlN z01p5=2mrvw_Af1m_(G!sfT-xd^f@vByukwi2;^US8w&u)DgW00778Hir9CVc0EDP0 z$)F+=y>J7laMyTx_Fr-RgwH6kqhOJpFwF9vW&!?;$m>7x(UIZUqh+geTy2QAVju*Z!4bQS z6`+>2`ED+9qJvE;bbaC|U6e@{eyisFDmp@)!x_vQQT7L1%k)=07mB*8*c^+M%yhSB zS7c+Y7dWFU)bFiO8S*afqjN#pG<1MHoFAHxbhgmB=_`OJOpPwH$@CfSgU9Vvhyga(vAQi zaFFb)WF$z5FXFLu-=l;F-}(n=2rNkPU1P@N5bRGv{2ISv)Fy^^@ObpYibl6<}aF&55X9c?lc1BiW zVX=GIt|i&$)@h476SoF7dcL>6EnL^jU)S4yW?=ke>e4kvGuOG@;dB^Hb!2IF{RO@> zFjZArYWD8r0kS82=UXBT_3)`O05Dj?rA;fJ?$N^#$SH2yBT=_=b_=L$NitXauk|DX ziCcolRRfIZ=%^&W?TvelTlJ%#ZxWB&9V~IYxhy6kPBS$WagJv7{xR5}Rh5H(;(OC<)6DtcyV$)2tlty@#B^Cw43i_+TQnc6OYiY&1E5thRO`tEHU0SDT&u{~d zTnN;Xht)5-49H0-Nmhy*`TaK<|JTL{{?Ygi7oj@<089R>@ezpz!J3*l{XSNwUgH^# zGN+z*3FI-<{FV|Clm)nIgvn9TmgEFEZQuYo3NTV0`gN&^oQetg#~h(65K`MmS#&Y8 zZ3{M?cNU49iPQDXYaU~3WkHUoqc>gy$Zru0L2+wZTDrLEVw0zV#l3lWT4Q zcZd)S|7TcJ9Dw~bu1}~0@iTynfjd`GOTxeHX7Y^ZV8cYTE4n)$Ou>__LIEcA!Qxa% zJ=y7wSSD17OA)EJ<+Pu-VWdSY$%PX)8vEy@SSgo`5)L0tM>3@6mZKN(kIp)H8_T zn4Ww&9`DG=aU3%q0Wl4OVLePg#$T|~U~0cwc&mWyDGIhRBbwhL=b@!0y<9cr-EZCK zHPjWh_mgAIUB8BZLNTkpjwz@(pFKOwdY)J%V?TnmqzxE&OJoG&iz!z28fDMf!ETt* z-S$^@bYmQUrD*ax} zUu`kBR&=0-1{RrzD<9K?`@)qGYd^eCe}h2~MH~audzGEX0S1{UbV~bn)fF>s%+dl5 zm$mP~Ew*V{a8nw`iBx)~Y*gj#R_tZ#tIiT3sn%(e;wtFea$a7sNIkM%BQ3V2)>Pcr5f(I!r$`kn#rA^j}MESZXpCw zoSWle*Xg2@r^D{o$|q-X;gBB8H7kiUB*J3R-0I7%EsXfLPmY8?*oF?XvY@@qUlKy? z>VK8Q4}H;{?k{a${IYP3tAUlAia?G1(V-aMZp%>?sx(RsK_6;8aVBePL`ZYgAug?{ zGw1K+mLcJj9!AQ@VAmRogiPt12eH3ayktW~Mz-H>vU3Y(jmqlo!_-YveYz}~Ud$XP z(NGhw$m=m=%J;(}k#mdI&+)g1YsJrh-<_RK`o?}dH|5C7X2-u6r?`*|(|G$`0L}UQ zmx@w?y!=_ngK0g>pS#{!Yc??Ke%dxQze=C??JiSnkDOch4@@bJXW)GzkI z1jwsw&X%kQnO=rO^r7^(ZGmBiq8~r!&3k4`&6p7krt^G6`sBGVey?Y^?Mr=aD(Bo| z)UAgS<4TyidroG0+;mKH2J*W#Vj)P&-b7gQ*)4jweFi@74=wdZ<(mHw&WGX!r})SD zIJ&!E0RUY8zs{#1$vT+S9Jk+R&&}*)s{$_APu-p=DSNGtd zM!`br&i4VBETHDpiVJ0`l)@))9`|!#?MtIG2}I>Wonn$ySs<8)wI|6iM?E zF!GOe$a^8dcAIM0;mm(1Ae{wBV*8l&Hy{^853~o3whmMI`RF>F&bo1J>iqiTj7IBS zXr;X;lYjfXmIqD|&Di;Xt@6cFUX|%Ety<&4XU%Z9q%o}O#XL;(Zyi`pes!ja!`Qw( z=Q-%%HKinMLV9~w9!VHhiENay>~A@6YP_#8$d{{;H9S66LrSyN3U}t*N=Sx`0UA$*u{5ap?G>+IJ>2HCd$&yDenQiL7457UP%o|<(R`_%wjgqEueOI_ zyhx{J0b%!3pXk|KR2b0`aUu0+^scUg(i|4VmrG&Gl%@vsUq?D9XG((SEjD$Gd-o~o z&HPHu$U>MeA*BxA$-bL>g!snF=tkB0x0Aq!560WBI^c@@a7q9E4r0zi#W5}^Qun;* zbXkA&xGi0(+SM42c2`ijr>8GP731n}-PX;|4cKWXE3LG!Qz z!$CnOSsS-Lzj^lF%wdjyjn~JA_2ZXm=8&bmvM+6QdtlybAFBf8)Pb6iw=JYNn@U}ZHrK_YpZRC0+l25ou=&Zi{CK{F}VuK}W%)H7-9 zsy{CZYxc{`u5P|DlKims6A*dy7XCfy8}dES?CsA@>i(1xY2MjL zJA#<5{?u}8G&nK=5y!VyffA*oc#wH0Qpf zb3^B13X%Dn8IbG4cw1pk_BL!0(fNId;!)Nk82=ahGp`q!G&~!uyq?_$SPJ6Kc5_+S zEZp#_O1W?Qxg#XoxUP=SioP5QQ=Yr%uvRT}}sa-~SCZJmgx=~bH=I*ks& zPE0`$VWajB`t<{sIIPt@5!fVaUVX#k$3?TP2&O{%u@u@Eu``{JaZ?|`-mJCsH!CPh zEl`ukOA$x5{qa3X_&toYRsHbaHqOrPP=EJ)f7%cNOUP8W=gem3xiL<>LlHVfT6Z=; zq}3ahq$m~cQNMkPwwAHpbNKNz!P~jPV>ifKnrG3TIoS2JRloQbj2=9kdT(+^Y=bv6 zt|jY(w$ie8Yo^cn`P<@fG0#ti__<);jGeRX6dQwQIN2m80-Xfz$p69r|4rc7{_+1> zF!wz9srg|R0rWW@?@r%$m3LVq-pl;78Qg!Mx?P@ts| zAPUg~VPk{kD;OM1UHEWWllvt7jO^4G4q+pRpl7L2EdY0<{!B2QXz43mX#lWdqmNK7D1q zB7nY2;U4}itjK*U@YBR!w}Z%563SWBN1v{qKIbq%6l*ok#(jbMFz*>{S-iihB2g-D{rp6QXedvm=rnA# zoh;B2u$*WJF(=wfie31r#d@+g+ZM!5*zq7&T>DW^sB3HZpu^hnC+wWQ*c*DBs9tFN zc>|3!m{`ZVZugzA$08pplZa}&-RwPc(`k}qX^1bQ35Jx)1~zopU$`qsw3v3T7)E;E zVhlr1hP?)riA-1K3+a7$6q08ho`@_4a+8}%LqmyP*aBm_`Pr+Lb&&z47IagG+1C_^ zpIHn@6|YvC!6IkD8D(S7aWs>BpzP};)B*&_4d@wz$DiM1bmSZQpQOSO-!z0vZXFbS z7fjjLk2%GqU7g|oG`%WMtegAF2tK6H9w^C)KwLi}-%j8`H+B$nLJrBc7thJ^azk2# zrm5!Il8EY1^4{6_dId?VA#46=!?gQeu(9&GMCBI@1r74BKjc$J&3R=R@lva&F5`|< zSF`Z%8H>zqw@(k)P>N9zMHF1_w7u+D-gZM(F3)?A(z6*E z9-djlEtWC0xb`akomG?Dxtl&)N8IY~Aa)&}C9uw7NJi{1+;*+^&z?808`>}Ls&quE zapT4}OM6EdO~N~IL-ied#l8PPKi3eFQ-{Rxr`2xM<>r)1Q_l8Kv7M+Zi*&?wd!UQT zV=oW}Qo#DNcZ9I?_lItCSaRS{R;ne6Mi|$vNEN|7+LAY?(Pw#Ko<9-NQ)7ySVQR6q zCs*F!r8CmiYp~!{FvTR`W%9Iq^GN)l)mT%1Vy}y}C~05%`quncUubZ=ht=%%se3*p zAPwHqv`H(Id*?`XxbV0_W;Q|l1WPVME?-(J=p{t&&-F!bOa(OjpfZ;0WJg?U7 z(B0cJx9L%ji)8-!J+B;>(@a}XGlAFGTO=3n6ejmuWm(n97lHWhokw9+g(^vQk7oO zUFO-XP|*FY-gEayuP*`q@6W;MeuHr3zkAWlb~rt~&ztYm&!(IErc%tY73g5 ziBIi*D`!%_@1?jR1ze-Q?vU=EYrW8!{U81-=O zy@BUl`CQ+t+*PP^`&<)ApCDp#jv{IIw^kZDY(`_- zIa-%1SE-azI!XdOc)Y_cxEjMmVb+W;mKUBRQyH zNVm8%b#LHGJN$MdsUUNs{z(Qg%$^9$D2y>VgU9B4#r|O15p=>*a{tbj(RQ^ur0@n8 zXDtl!Yfm@)aNeD9&{YnVz3n3OvqB{;1#_!p@`o)a0RVE-zk$SYnn$?C_1{3kP0KKDTq3NiFC^;O0z6Lg73fgdLWfjz?0yI!+Hxx4LM$s|42(Z<;Ae$D{gYg9e@C&8jmu`;A$qi-$N(=jkN!jCuWCu z33G#t$2>S;uOsXZhsUhAwamhMP>YCy$Dju*DGEE>RLGU}=MP;?$%D*DlJFx@+4FaV zB6cYYCIeR0@N-PF^D94Vc*%2hR45J?O$}0lM0hk4xY)14)lxc$(LcvT)Bf}18_nK) zHEjX7&}N91iYMyljQrqu1#=9>Ly;vJj3uIQUr7CkU|WLyB2G`$`(wKIoeMzZyTDiv zOQ~%0YADZ%2OJuzpLuERQXf>*w?pX0V0R=vv_8}nE445JkyM1Z9Q1mA^9PD@k!?1h zpHY^O#?8-B9LXf_WU`5Ths_DVgjpSkM%}kMiF+ZvKtj&@udR@Z(->yHf9}$ zor%1}qtK?DnhfzoyuI7O*f4g1!0s2(*AihOP754*vyvB7>@vS>dnrTNWw>mv>&mbo z!A9UNXf6X7Peg;GByW%~<|8K+1EB`0xE~Z$M!<^PVclzN56Au-gM|1i$luGYhA`@U zl#njmmc}$ei~ATpa@L>5ND~vY;pAX*AuUhW;jEy>r(rth<6sn2<+QR)2^&emorB928w zp7}Q}TVM7)B&hvX^M#$}G^qG<`jh40oK#*&r&6 z2g*_G8hwF79F08f4qoyQ&bpJ#KMQ;FGANSZ`a}gfDT!aQgB&|V<(loDYACg1={*k> z&wYdLpDe}hxkYiuIX|k|3|`|99{)P|wAQA4wuaCW^R=|H%2#{1De%>}NKG_~dAeaK zj#!<_sAUyZhjt96Mue9~_=K*0|7Ig=`E~~GAzM*qFzJ!}$~9>UKrp|#k@?2P=L;cm zR?}YcPgCujGp0z`zzZ{l{FC(*p|D=oO0)HL8@tsdsyJ2nMmX|8v!nzY_ajWHv`S39?%NeJYKYx0V ztEi7?T@=F+oT=N(E ziO19}Bbc{ytybY&WoHFU!J19?KRF3RzK5)IdLw(eeJNgHH&qR{hj`(ab2$Bb&zr>F^ymxJX8EZ{o%a~*9 zTUuvqVcg~9A2yz! zg4cN%8)GnZ$xU7jGre(O?eB5xl}$pdw9iRe|eixZqP5|->j6{>4&tLeC zhpwd2KYZZKbY@!jeP2&Nuj@5xFj9*$k2j9m zYY$Rjr$PpQV)86Mk1msNm$!YQD#2(xa(tx=;aKbx25UYU~# zN5xU@{Yvh*V(eK7|JA4>fXk|Gu)OC3OND_a>Len)Cba{1LC#|8CS#nho@cjGz)Uo7 zH*ubkfl6{!qAZa5X+-*aj|GHX#JbMD2Dd;8IUXFChkxlp5)FcnHiL*eQd-i=b+qgR}(G7 z6E||zrO{c#VQkm7cYB?k?G`hV*K#Vf*XXlyGDwpmXBK;M&>r_k!%0;^`J~Np{PuC| z^zR3ai!V23$fh;Z><$xjk(O? zNIJP&%)1Yho1y59t+jNvR9eyV4?%BAS9XJ`=L_pdS@k{sR6eyI8j{u&3h?Q(lVk!v z=JXzZJPt^Ft+1shxC5GRZdDg>O-39j#ILJuoj&GfQkdKI%0Pwve)tpCYSr#`RZw?u z*s4PlB^r@~CK(y2c?=qCDEmh8xE|%og#wFFXCwp_mhKeMkYcW$77_h0T4C|gH{ya+ z7X9$5_WE{82;65Tn7pSNldmM4DjG+g3Z|A1bzVeJeE=H-M`Uw>e~E^O!F4J9XR}U$-mjMI) zb+A0KeriK|g9RfZ4lx+avhnk{Cm_mxy6EBHfc>g+Dg+J5tgG?ZkaVhX#{S?J+&1fv zFt(0l*M*o@0zLUdU%3BhP*C8-09g#Zz64+K`fN68d_MG=y$YV1=FDlx(EjFBg{D(0 zxEp&o-oI;knG*Ju&9}^AGJvMw%{UZ4LGT4nb>hzYkrPI~QrR z&z4OR+{xBdAzNvUxe3>JKBK-f=Cxt8;>#lXX{5vTnyRZ|`Aq2yDOkaC--4sQxUcT3 zKMulgxBL4jnCefnP18N+#14kaqU&n?v^<{C#ZTP&Q(qpLDX`QQaO12#^w`_B2C%dt z38nRCL_|y{Gmap*yTkMtBK75t@eqqphX-dnvCDTfCa=|E|CozwyL0OnQkU@v_7~c_aA^0%|`;EL$`knsOb@(1J^<7Vq ztt$A}9}aroXyGbcNt%e+83YM64FB8j!y~!22W5Hq7t%mc-#}qMCS^%}mFhbLb644k6-wnR<|QOa;fFQN(XF=N#cqeXY6UZg(QN_xdQJFX zNAg_gi0Du&8|svHLr8s>(ap?%DEY^_R|ou_gC6D-*VLco`8P7Y*QH@>tc19F7NywC zhDq1}m9Wd`t!n{iiMFp0%>Ifo5ESF)&q683ZpINNrTKD}j!v&{w;QtZ!vW+w_`dse z_@{^mEODjw0Ik+@v{;eRub=n?`j{GfoC|o9jdj{cn5WB7zgx7^z9#8oOi-5Vl7Pd? zRX{CL_(p4PJ}XODmdQ`;uS-atCtWT-UGNbV+Dt5As3cystn6ncW=RLU9X1vI-as0I z+tcl#7xY>Zw+DHw0M(WzBPjIarK#kZVaOLDmK4Obeo1RmSyw*bWeOJNVQ>)OEJ|8h z4BEXKBA&^@W8jm~Vk?Qnhxf^k*N1Vl0(i(<ANj@5RV}M9QZY3@c`o~H@& z<6tcMK&3Kta1-IM8c_;J*@Oti!YNo#i`08yma-ORz+|ET)3UvnIz12s4=YooAs#?gLs#x5}U;Lf%$eciLuNk8$L)B2jT{=wyR%hBzTuno9GTKT0=<8V zt8D9n&iQf`$43n@g{xCPmS95i%_h)$TWy>LR`)u-)hazH2eIPQ z5B$7Bp2}7++We68#5e!pkJuxxw<4u;`RWyyUZ#71N7fxGk?t3Zq8{|pW}yqq^KZ`l#y$t91RC2cgzM3VQW_z~n&L`{B?KwI?p>+l=9V`po9b)+GOAmq!a z3(ous0*3!bUbAjol*PM76y6^~GDn_2_D(5&|0+bmUX)1Yjbdc>z9qiDcEZ2xZ0^r( z30okteO*=plw&@sg!GcTqR6jZ7RE@U52KBD-pnfE)71pARl{R4&}QmbV`>>b+AQf( z%hna1=9;~v=HwocDXU|OSs6iEEch*zv!q1`szwZa<0_5{1r}6q8k(bI|-2>bQ zNKXP)J4S<7zo9*z#4%U5{1=paty``PeQ)*5DhHWG!TLWfPc4l9teE|`n7LB3i;B`KFb5SQNOg{!!LWSfK4bt(<95Hb8+!SiLad)HesAf@Xikce{FR8RIms9Hi!Ww6g;^%Ouo3=RAQ$$gbV3f) z`&RE3B!nR*4_>EBL2f^rE52wq1DKsNe9AQkuo^L;i#{6%S~?fwF5ljQIUOwDB`bxT+XKX_{$5@)ln<04-krfr#||!bAynzd6Ly0 zj#+Z3v|k962EP^0*>-(w7NNSw;8}}pfb?W4DnF4GpU$1Xpy7yh{s`++1zghg(|^h6 zN((Z!d5I}-3`6nrVbwFX0=DE-mnP>0WtNZ&ns-0OBte+&9P+uHczbQa&R%|s*tLLs z^=n8eR=msA$P1{>lYUCKI3+H*FxCco}bhs(Y3-TfPVH);jatmZkM0o~1> zZZWV*4hw#@@ea%ztfr^UJ^O@5cuX^=h70l!cNk$x0>Xqr+I!>b}5 ze5h_@ViKJe=~^axGQy<@$_Tfq4{%Y!mom;aq)_+;4{N}?pXdV9#D=@h?%8hOevyE@5o<(6~x z?Ds3UJdqH2`FwOZ?b9X}%4WPWtmueR>xF9ZyvZPh)P#@Q->PEWzVZo=#iH7Ae5HniIj$Z%Xv`H zqgq1LG%qX%j8$oXfFvYfU5>$GfvqF~T+rYY3z#Oo36#A|=?YIunJGep~1y2l)E zgc3ipLwm}iyT^48P6I9G$K_yB(=QKn=%lrZePDo4ufHBOA}aSgD;Pywb)p>AfP8(z z?mB5x4MfF51eZD)gyU-+V&SDr%FS}_u9OPXP@eM=$D_gpiAwoko1U%J{=N_hKWU?j z`TYts6kGNiUvoW4H zh(oSMd)dWD*9|v#q9sy@`4y94SMwYg@z>BX5GJhn@;-S29L2krW2NunzB-*x3yA?V zaLVr6AG%edL?^E708REj40L=<7ZE^!UnqPG>GA12u|?zqcu_KXd0c~w2w{l6ywAK< zUAQ((JJuwmp`QmdF~4KXOfJd=^=PCwC}6kj1EIkYn)SEn`pz?oEMDTxf29W`VXYWY z8O627KP9@qA1&s7=M^TJ9UEv)wml!>7`^xtTp41rfxiT(OF-&(3C-Fo1n6f&rbk^7JX zR;5lJ7KioimXL5vlNOU(8Nu&mTOFRq4W%87Ntt~a>8j&H1$Yk{JtCf~*W1+<(cPc# zP^8W1;d4uy#S7*mER`E+Zn{-ATM(s!Npx)Qk}aW+F4Q~VCZC+mC2 z+E*v#A0JX@w2u5iU2fz5IC_yR-?1(+GbdrsCimo9A`zZVD}$la-g<|V`-c%{eR==oNb2_Tub1*7WP(~8WR zH)%%37`qtH3S+#98S{8f%3Q z#6Q3)(uI`vgN?#}hRO4u_qMqH`j}&Tkh~m@{=SmJiQpxVeo0GXUJmoJV+dF8&L^;J zbk{^U(xbY?;1@2aCRdu@dG=$4etUY!OE(M}1Z${4P74!9vv;dy-LP+k73%(IC@V9@ zj}j=|P3u)pn#AVHw8_xK$2TKN)F%hDQKvY^ntepj4eDw&Lg8H8y$Xkj{LEpTQ?H=ypKEHJD(*# z(}4Ar5|KtqG_GddXoz?{&99hfhoI)2p9KWTpAEgY)0)78g;n@BYQq&>oTVr444J?K z-S8ZX{L+l#Z~3@-MD2Ns*8|jOhN@^^+Z4_` zuzATN0LhK7jfNK^C0wV2Y;*fL__A7Rzh(Hmvacjoi1!to?vw0|v*PTLh=O?GK%9o1 zBRoZ_=#7RM=PBO5r&Z*#P*Zp3hWlF4Skf%w`FT4(fxOZ~(J@pT)%oj#kkuXPC^D8!(UNSUG%DR};SZeu){BAlykTFcarnk5O6Ft!X zoc8LOpuC$DWp|E6lse6Zc`h#k;HqBi`)DwS4N3S?^Ia#)2@LoNp+k58V?*WL`G19D z1Bi40h5|s542ZIUAVx4irzdF>=LZ9?v8~o8UeJI3d};dfDF1SgI06~>;b}4d4^#-U zC&)Azh5er<|5pcs2ooveO@tx;J^EjJ`cE_M!K#PI(T8$s0QE}^yH3(II6G#P8}D$E z?gByVkwHetqry^G)&$0AU+1TL7EMpK=gVqWtlcW@V8nZ)L(gi1hJF`sBV}Ls*+-TZ z`8vET@_0As0Hg~mg8S`+K4!#7=KqN0B@4` z!@3q2k89OHz}#zm$U7%9!Py(FL3+?rRP|BC(nbf93ry2{r^)@Y3t>h#n5JDW(Xyd$ z&y}9QngMGeft_;K#D_2>Xgy09Guf6r@lTd_9SDHn|E2yhSup8?0yE?H`32-Jructh zez`&66!%{vJCvk5x`Wd1*~6{jXadsz5#4t zUtF3ZH{;<&6|Uni@Wg-_aoDv*tuUYjdj5XUXGB_Ap03=1P#rVcKeA*4N{2BLM0Gq6 z0ublOfFj_pN3szB1q11}-6S?0a2O!o@xJ>@G61|x{~}>x9nGFsL>BGel#wa8Qi9tX5=0_EfX!}r4Pj4-ae zzWI2zQ-9cjQ2XnuYw=c2v-E9W25+>FUD}_p{h)>L(6_Ngo_}Tr=kwo73Q7ZO zNTAp7&A4Pgscf)dO9?;jSE0Iy@q~bCiR@mgUiRoAFnNTY3;(_&Ac)VvhKPl+qz!N3 L-#5m8ZqEHLrNi1I literal 0 HcmV?d00001 diff --git a/src/PrintBridge/Resources/testpage.pdf b/src/PrintBridge/Resources/testpage.pdf new file mode 100644 index 0000000000000000000000000000000000000000..45948e904bd11d63e426d9363ba35a87ed35a695 GIT binary patch literal 1335 zcmZ`(+iuf95PkPo%u6JYN`0yAq^PPypcRNBN)yCG#lzMf+gp;|u)C%SKgk1s06)T6 zJ4r9dR%FGqGjqD_p-EfedOK0B8zU7qxeCfKlwk`bB(=3?IffrMf(!BDA<0zteNzX2gRBmcySu;7dj#kj?In8hB zY`zNu{XQtq`=ECfZFEb8P)IIHa*ebH^e`2~DA{mHf>0?Es-y-l7^EYo5itys0uIc+ zNR;Z%(#kU=yV82WhVG4YhipXQq*jN6yraBJId7OMBbH3Upt3!2TzifD z5_0nzi543aT&kkM%gPe6Aj+#k43X@?5J)zebRbsm#VHytz9C*9e<2o9)twRrYRkA) z4F$o)U&qM0afAG`wAcMp%PqeoJf2g@m1q}^zI!VbT2-=cz2;Iqs)c3e0A>e?C%xc) z0=ne%c}5oqg1RehoDtF;&;*^ernr}ehm%F<#zpV{Nz!FxWw~e<;tgql_xr+hWl052<@wjtPEZixX_G wC$=OVKefeC_8`_51>?{yyqWyTlw-MQ?RV97(OD;q>v+a-76+rz*~jzXANJXN_W%F@ literal 0 HcmV?d00001 diff --git a/src/PrintBridge/Settings/AppSettings.cs b/src/PrintBridge/Settings/AppSettings.cs new file mode 100644 index 0000000..6a6240e --- /dev/null +++ b/src/PrintBridge/Settings/AppSettings.cs @@ -0,0 +1,156 @@ +using PrintBridge.Printing; + +namespace PrintBridge.Settings; + +/// +/// A named print queue: web apps ask for and never learn the +/// Windows printer behind it, so printers can be swapped without touching the web app. +/// +public sealed class PrintQueueDefinition +{ + /// Queue name used by API callers (see ). + public string Name { get; set; } = string.Empty; + + /// Windows printer name this queue prints to. + public string PrinterName { get; set; } = string.Empty; + + public PrintQueueDefinition Clone() => new() { Name = Name, PrinterName = PrinterName }; +} + +/// +/// User-editable application settings, persisted as JSON in %AppData%\PrintBridge\settings.json. +/// +public sealed class AppSettings +{ + public const int DefaultPort = 7227; + + /// TCP port the local HTTP listener binds on 127.0.0.1. + public int Port { get; set; } = DefaultPort; + + /// + /// Exact web origins (scheme://host[:port]) allowed to call the API via CORS. + /// Empty means no browser origin is allowed (same-machine tools still work). + /// + public List AllowedOrigins { get; set; } = new(); + + /// Administrator-configured queues. Callers reference these by name. + public List Queues { get; set; } = new(); + + /// Name of the queue used when a request omits queue. + public string? DefaultQueue { get; set; } + + public bool RunAtLogin { get; set; } = true; + + public AppSettings Clone() => new() + { + Port = Port, + AllowedOrigins = new List(AllowedOrigins), + Queues = Queues.Select(q => q.Clone()).ToList(), + DefaultQueue = DefaultQueue, + RunAtLogin = RunAtLogin, + }; + + /// Machine-readable reasons a queue could not be resolved, as the API reports them. + public const string NoQueueConfiguredCode = PrintErrorCodes.NoQueueConfigured; + + public const string UnknownQueueCode = PrintErrorCodes.UnknownQueue; + + /// + /// Resolves the queue for a request: an empty/missing name picks the default queue. + /// Pure and side-effect free so the routing rules can be unit tested without a printer. + /// + public bool TryResolveQueue(string? requested, out PrintQueueDefinition queue, out string errorCode) + { + queue = null!; + errorCode = string.Empty; + + var namedByCaller = !string.IsNullOrWhiteSpace(requested); + var name = namedByCaller ? requested!.Trim() : DefaultQueue?.Trim(); + if (string.IsNullOrEmpty(name)) + { + errorCode = NoQueueConfiguredCode; + return false; + } + + var match = Queues.FirstOrDefault(q => string.Equals(q.Name, name, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + // The caller naming a queue that does not exist is a caller mistake; a + // default that points at nothing is an incomplete configuration. + errorCode = namedByCaller ? UnknownQueueCode : NoQueueConfiguredCode; + return false; + } + + queue = match; + return true; + } + + /// + /// Validates and canonicalizes a queue name: trimmed, 1–64 characters of + /// letters, digits, dot, underscore or hyphen, starting with a letter or digit. + /// + public static bool TryNormalizeQueueName(string? input, out string normalized) + { + normalized = string.Empty; + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + var name = input.Trim(); + if (name.Length > 64) + { + return false; + } + + if (!char.IsAsciiLetterOrDigit(name[0])) + { + return false; + } + + foreach (var c in name) + { + if (!char.IsAsciiLetterOrDigit(c) && c != '.' && c != '_' && c != '-') + { + return false; + } + } + + normalized = name; + return true; + } + + /// + /// Validates and canonicalizes a web origin: http/https, no path/query/fragment, + /// no credentials; lowercased scheme + host; default ports dropped. + /// + public static bool TryNormalizeOrigin(string? input, out string normalized) + { + normalized = string.Empty; + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + input = input.Trim().TrimEnd('/'); + if (!Uri.TryCreate(input, UriKind.Absolute, out var uri)) + { + return false; + } + + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + { + return false; + } + + if (!string.IsNullOrEmpty(uri.UserInfo) || uri.AbsolutePath != "/" || + !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) + { + return false; + } + + // GetLeftPart(Authority) lowercases scheme/host and omits default ports. + normalized = uri.GetLeftPart(UriPartial.Authority); + return true; + } +} diff --git a/src/PrintBridge/Settings/SettingsStore.cs b/src/PrintBridge/Settings/SettingsStore.cs new file mode 100644 index 0000000..ac250e5 --- /dev/null +++ b/src/PrintBridge/Settings/SettingsStore.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PrintBridge.Settings; + +/// +/// Loads and saves as JSON. Writes are atomic +/// (temp file + replace) so a crash mid-save never corrupts settings. +/// +public sealed class SettingsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + + private readonly string _directory; + private readonly Lock _sync = new(); + + public SettingsStore(string? directory = null) + { + _directory = directory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PrintBridge"); + } + + public string SettingsPath => Path.Combine(_directory, "settings.json"); + + public AppSettings Current { get; private set; } = new(); + + /// Raised after persists a new snapshot. + public event Action? Changed; + + /// True when no settings file existed at load time (first run). + public bool IsFirstRun { get; private set; } + + public AppSettings Load() + { + lock (_sync) + { + if (!File.Exists(SettingsPath)) + { + IsFirstRun = true; + Current = new AppSettings(); + return Current; + } + + try + { + var json = File.ReadAllText(SettingsPath); + Current = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + // Unreadable settings should not brick the app; fall back to defaults. + Current = new AppSettings(); + } + + return Current; + } + } + + public void Save(AppSettings settings) + { + lock (_sync) + { + Directory.CreateDirectory(_directory); + var json = JsonSerializer.Serialize(settings, JsonOptions); + var tempPath = SettingsPath + ".tmp"; + File.WriteAllText(tempPath, json); + if (File.Exists(SettingsPath)) + { + File.Replace(tempPath, SettingsPath, destinationBackupFileName: null); + } + else + { + File.Move(tempPath, SettingsPath); + } + + Current = settings; + IsFirstRun = false; + } + + Changed?.Invoke(settings); + } +} diff --git a/src/PrintBridge/Settings/StartupRegistration.cs b/src/PrintBridge/Settings/StartupRegistration.cs new file mode 100644 index 0000000..f5ebdd1 --- /dev/null +++ b/src/PrintBridge/Settings/StartupRegistration.cs @@ -0,0 +1,32 @@ +using Microsoft.Win32; + +namespace PrintBridge.Settings; + +/// +/// Registers/unregisters the app in the current user's Run key so it starts at login. +/// Per-user only — never requires elevation. +/// +public static class StartupRegistration +{ + private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string ValueName = "PrintBridge"; + + public static void Apply(bool runAtLogin) + { + using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true); + if (runAtLogin) + { + var exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath)) + { + return; + } + + key.SetValue(ValueName, $"\"{exePath}\" --minimized"); + } + else + { + key.DeleteValue(ValueName, throwOnMissingValue: false); + } + } +} diff --git a/tests/PrintBridge.Tests/OriginAllowlistTests.cs b/tests/PrintBridge.Tests/OriginAllowlistTests.cs new file mode 100644 index 0000000..f791f6a --- /dev/null +++ b/tests/PrintBridge.Tests/OriginAllowlistTests.cs @@ -0,0 +1,37 @@ +using PrintBridge.Settings; +using Xunit; + +namespace PrintBridge.Tests; + +public class OriginAllowlistTests +{ + [Theory] + [InlineData("https://apps.example.gov", "https://apps.example.gov")] + [InlineData("https://Apps.Example.GOV", "https://apps.example.gov")] + [InlineData("https://apps.example.gov/", "https://apps.example.gov")] + [InlineData(" https://apps.example.gov ", "https://apps.example.gov")] + [InlineData("https://localhost:8097", "https://localhost:8097")] + [InlineData("http://localhost:5173", "http://localhost:5173")] + [InlineData("https://apps.example.gov:443", "https://apps.example.gov")] + [InlineData("http://apps.example.gov:80", "http://apps.example.gov")] + public void ValidOriginsNormalize(string input, string expected) + { + Assert.True(AppSettings.TryNormalizeOrigin(input, out var normalized)); + Assert.Equal(expected, normalized); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("apps.example.gov")] + [InlineData("ftp://apps.example.gov")] + [InlineData("https://apps.example.gov/lab/app")] + [InlineData("https://apps.example.gov?x=1")] + [InlineData("https://user:pass@apps.example.gov")] + [InlineData("not a url")] + public void InvalidOriginsAreRejected(string? input) + { + Assert.False(AppSettings.TryNormalizeOrigin(input, out _)); + } +} diff --git a/tests/PrintBridge.Tests/PageFittingTests.cs b/tests/PrintBridge.Tests/PageFittingTests.cs new file mode 100644 index 0000000..b3c5a89 --- /dev/null +++ b/tests/PrintBridge.Tests/PageFittingTests.cs @@ -0,0 +1,72 @@ +using PrintBridge.Printing; +using Xunit; + +namespace PrintBridge.Tests; + +/// +/// Placement math for one page: PDF page size in points against a printable area in +/// hundredths of an inch. Scaling down is allowed, enlarging is not, and the result +/// is always centered. +/// +public class PageFittingTests +{ + /// US Letter: 612 x 792 pt, which is 850 x 1100 hundredths of an inch. + private static readonly SizeF Letter = new(612f, 792f); + + /// Printable area of a typical laser printer on Letter (about 0.17" hardware margins). + private static readonly SizeF LetterPrintable = new(816f, 1058f); + + [Fact] + public void APageSmallerThanTheAreaIsNotEnlarged() + { + var placed = PrintService.FitCentered(Letter, new SizeF(1200f, 1500f)); + + Assert.Equal(850f, placed.Width, 0.5f); + Assert.Equal(1100f, placed.Height, 0.5f); + Assert.Equal(175f, placed.X, 0.5f); + Assert.Equal(200f, placed.Y, 0.5f); + } + + [Fact] + public void AnOversizePageIsScaledDownKeepingItsAspectRatio() + { + // A4 (595 x 842 pt) is taller than the Letter printable area, so it shrinks to fit. + var placed = PrintService.FitCentered(new SizeF(595f, 842f), LetterPrintable); + + Assert.True(placed.Width <= LetterPrintable.Width + 0.5f, "the page is wider than the printable area"); + Assert.True(placed.Height <= LetterPrintable.Height + 0.5f, "the page is taller than the printable area"); + Assert.Equal(1058f, placed.Height, 0.5f); + Assert.Equal(595f / 842f, placed.Width / placed.Height, 0.001f); + } + + [Fact] + public void ThePageIsCenteredInThePrintableArea() + { + var area = new SizeF(1000f, 1000f); + var placed = PrintService.FitCentered(Letter, area); + + Assert.Equal(area.Width - placed.Right, placed.Left, 0.5f); + Assert.Equal(area.Height - placed.Bottom, placed.Top, 0.5f); + } + + [Fact] + public void ALandscapePageFillsALandscapeArea() + { + // Landscape Letter (1100 x 850) against the printable area swapped for orientation. + var placed = PrintService.FitCentered(new SizeF(792f, 612f), new SizeF(1058f, 816f)); + + Assert.Equal(1056f, placed.Width, 0.5f); + Assert.Equal(816f, placed.Height, 0.5f); + Assert.Equal(1f, placed.X, 0.5f); + Assert.Equal(0f, placed.Y, 0.5f); + } + + [Fact] + public void ADegeneratePageSizeFallsBackToTheWholeArea() + { + var placed = PrintService.FitCentered(new SizeF(0f, 0f), LetterPrintable); + + Assert.Equal(LetterPrintable.Width, placed.Width); + Assert.Equal(LetterPrintable.Height, placed.Height); + } +} diff --git a/tests/PrintBridge.Tests/PdfSnifferTests.cs b/tests/PrintBridge.Tests/PdfSnifferTests.cs new file mode 100644 index 0000000..d27b6ee --- /dev/null +++ b/tests/PrintBridge.Tests/PdfSnifferTests.cs @@ -0,0 +1,52 @@ +using System.Text; +using PrintBridge.Printing; +using Xunit; + +namespace PrintBridge.Tests; + +public class PdfSnifferTests +{ + [Fact] + public void PlainPdfHeaderIsAccepted() + { + Assert.True(PdfSniffer.LooksLikePdf("%PDF-1.7\n1 0 obj\n"u8)); + } + + [Fact] + public void HeaderLaterInTheFirstKilobyteIsAccepted() + { + var content = new byte[600]; + Encoding.ASCII.GetBytes("%PDF-1.4").CopyTo(content, 500); + Assert.True(PdfSniffer.LooksLikePdf(content)); + } + + [Fact] + public void HeaderPastTheFirstKilobyteIsRejected() + { + var content = new byte[4096]; + Encoding.ASCII.GetBytes("%PDF-1.4").CopyTo(content, 2000); + Assert.False(PdfSniffer.LooksLikePdf(content)); + } + + [Fact] + public void EmptyBodyIsRejected() + { + Assert.False(PdfSniffer.LooksLikePdf([])); + } + + [Fact] + public void OtherFormatsAreRejected() + { + Assert.False(PdfSniffer.LooksLikePdf("{\"queue\":\"labels\"}"u8)); + Assert.False(PdfSniffer.LooksLikePdf([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A])); + Assert.False(PdfSniffer.LooksLikePdf("%PDF"u8)); + } + + [Fact] + public void TheBundledTestPageIsRecognized() + { + var path = Path.Combine(AppContext.BaseDirectory, "Resources", "testpage.pdf"); + Assert.True(File.Exists(path), $"test page missing at {path}"); + Assert.True(PdfSniffer.LooksLikePdf(File.ReadAllBytes(path))); + } +} diff --git a/tests/PrintBridge.Tests/PrintBridge.Tests.csproj b/tests/PrintBridge.Tests/PrintBridge.Tests.csproj new file mode 100644 index 0000000..4466e37 --- /dev/null +++ b/tests/PrintBridge.Tests/PrintBridge.Tests.csproj @@ -0,0 +1,19 @@ + + + + net10.0-windows + true + false + + + + + + + + + + + + + diff --git a/tests/PrintBridge.Tests/PrintJobManagerTests.cs b/tests/PrintBridge.Tests/PrintJobManagerTests.cs new file mode 100644 index 0000000..420046b --- /dev/null +++ b/tests/PrintBridge.Tests/PrintJobManagerTests.cs @@ -0,0 +1,217 @@ +using PrintBridge.Printing; +using Xunit; + +namespace PrintBridge.Tests; + +public class PrintJobManagerTests +{ + private static PrintRequest Request(string queue = "labels", int copies = 1) => + new(Queue: queue, PrinterName: "Test Printer", Copies: copies, PdfBytes: [1, 2, 3]); + + private static async Task WaitForTerminal(PrintJob job) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(10); + while (!job.IsTerminal) + { + Assert.True(DateTimeOffset.UtcNow < deadline, "job did not reach a terminal state in time"); + await Task.Delay(10); + } + } + + private static async Task WaitFor(Func condition, string because) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(10); + while (!condition()) + { + Assert.True(DateTimeOffset.UtcNow < deadline, because); + await Task.Delay(10); + } + } + + [Fact] + public async Task CompletedJobCarriesQueueAndCopies() + { + using var manager = new PrintJobManager((_, progress, _) => + { + progress.Report(2); + return Task.CompletedTask; + }); + + var job = manager.Enqueue(Request(queue: "labels", copies: 3)); + Assert.Equal("labels", job.Queue); + Assert.Equal(3, job.Copies); + + await WaitForTerminal(job); + Assert.Equal(PrintJobStatus.Completed, job.Status); + Assert.Null(job.ErrorCode); + } + + [Fact] + public async Task JobsAreDrainedInSubmissionOrder() + { + var started = new List(); + var release = new TaskCompletionSource(); + using var manager = new PrintJobManager(async (request, _, _) => + { + lock (started) + { + started.Add(request.Queue); + } + + await release.Task; + }); + + var first = manager.Enqueue(Request("one")); + var second = manager.Enqueue(Request("two")); + var third = manager.Enqueue(Request("three")); + + // Only the first job may be in flight while the executor is blocked. + await WaitFor(() => first.Status == PrintJobStatus.Printing, "the first job never started"); + lock (started) + { + Assert.Equal(new[] { "one" }, started); + } + + Assert.Equal(PrintJobStatus.Queued, second.Status); + Assert.Equal(PrintJobStatus.Queued, third.Status); + + release.SetResult(); + await WaitForTerminal(third); + + lock (started) + { + Assert.Equal(new[] { "one", "two", "three" }, started); + } + } + + [Fact] + public async Task CancelMarksPrintingJobCanceled() + { + using var manager = new PrintJobManager(async (_, _, ct) => await Task.Delay(Timeout.Infinite, ct)); + + var job = manager.Enqueue(Request()); + await WaitFor(() => job.Status == PrintJobStatus.Printing, "the job never started printing"); + + Assert.True(manager.CancelOrDiscard(job.Id)); + await WaitForTerminal(job); + + Assert.Equal(PrintJobStatus.Canceled, job.Status); + Assert.Equal(PrintErrorCodes.Canceled, job.ErrorCode); + } + + [Fact] + public async Task CancelingAQueuedJobSkipsPrintingIt() + { + var printed = new List(); + var release = new TaskCompletionSource(); + using var manager = new PrintJobManager(async (request, _, _) => + { + lock (printed) + { + printed.Add(request.Queue); + } + + await release.Task; + }); + + var blocking = manager.Enqueue(Request("blocking")); + await WaitFor(() => blocking.Status == PrintJobStatus.Printing, "the first job never started"); + + var queued = manager.Enqueue(Request("queued")); + Assert.True(manager.CancelOrDiscard(queued.Id)); + + release.SetResult(); + await WaitForTerminal(queued); + + Assert.Equal(PrintJobStatus.Canceled, queued.Status); + Assert.Equal(PrintErrorCodes.Canceled, queued.ErrorCode); + lock (printed) + { + Assert.Equal(new[] { "blocking" }, printed); + } + } + + [Fact] + public async Task PrintBridgeExceptionMapsToItsCode() + { + using var manager = new PrintJobManager((_, _, _) => + Task.FromException(new PrintBridgeException(PrintErrorCodes.PrinterUnavailable, "Printer is offline"))); + + var job = manager.Enqueue(Request()); + await WaitForTerminal(job); + + Assert.Equal(PrintJobStatus.Failed, job.Status); + Assert.Equal(PrintErrorCodes.PrinterUnavailable, job.ErrorCode); + Assert.Equal("Printer is offline", job.ErrorMessage); + } + + [Fact] + public async Task UnexpectedExceptionMapsToPrintFailed() + { + using var manager = new PrintJobManager((_, _, _) => + Task.FromException(new InvalidOperationException("boom"))); + + var job = manager.Enqueue(Request()); + await WaitForTerminal(job); + + Assert.Equal(PrintJobStatus.Failed, job.Status); + Assert.Equal(PrintErrorCodes.PrintFailed, job.ErrorCode); + Assert.Equal("boom", job.ErrorMessage); + } + + [Fact] + public async Task ProgressUpdatesPagesPrinted() + { + var release = new TaskCompletionSource(); + using var manager = new PrintJobManager(async (_, progress, _) => + { + progress.Report(4); + await release.Task; + }); + + var job = manager.Enqueue(Request()); + + // Progress marshals via the thread pool, so poll briefly. + await WaitFor(() => job.PagesPrinted == 4, "progress was not observed in time"); + + release.SetResult(); + await WaitForTerminal(job); + Assert.Equal(PrintJobStatus.Completed, job.Status); + } + + [Fact] + public async Task TerminalJobReleasesTheDocument() + { + var release = new TaskCompletionSource(); + using var manager = new PrintJobManager(async (_, _, _) => await release.Task); + + var job = manager.Enqueue(Request()); + await WaitFor(() => job.Status == PrintJobStatus.Printing, "the job never started printing"); + Assert.NotNull(job.PdfBytes); + + release.SetResult(); + await WaitForTerminal(job); + Assert.Null(job.PdfBytes); + } + + [Fact] + public async Task DiscardRemovesFinishedJob() + { + using var manager = new PrintJobManager((_, _, _) => Task.CompletedTask); + + var job = manager.Enqueue(Request()); + await WaitForTerminal(job); + + Assert.NotNull(manager.Get(job.Id)); + Assert.True(manager.CancelOrDiscard(job.Id)); + Assert.Null(manager.Get(job.Id)); + } + + [Fact] + public void UnknownJobReturnsFalseAndNull() + { + using var manager = new PrintJobManager((_, _, _) => Task.CompletedTask); + Assert.Null(manager.Get("nope")); + Assert.False(manager.CancelOrDiscard("nope")); + } +} diff --git a/tests/PrintBridge.Tests/QueueResolutionTests.cs b/tests/PrintBridge.Tests/QueueResolutionTests.cs new file mode 100644 index 0000000..d0aa6ce --- /dev/null +++ b/tests/PrintBridge.Tests/QueueResolutionTests.cs @@ -0,0 +1,119 @@ +using PrintBridge.Settings; +using Xunit; + +namespace PrintBridge.Tests; + +public class QueueResolutionTests +{ + private static AppSettings Configured() => new() + { + Queues = + [ + new PrintQueueDefinition { Name = "labels", PrinterName = "ZDesigner GK420d" }, + new PrintQueueDefinition { Name = "front-desk", PrinterName = "HP LaserJet M404" }, + ], + DefaultQueue = "front-desk", + }; + + [Fact] + public void EmptyRequestUsesTheDefaultQueue() + { + Assert.True(Configured().TryResolveQueue(null, out var queue, out _)); + Assert.Equal("front-desk", queue.Name); + Assert.Equal("HP LaserJet M404", queue.PrinterName); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void BlankRequestUsesTheDefaultQueue(string requested) + { + Assert.True(Configured().TryResolveQueue(requested, out var queue, out _)); + Assert.Equal("front-desk", queue.Name); + } + + [Theory] + [InlineData("labels")] + [InlineData("LABELS")] + [InlineData(" labels ")] + public void NamedQueuesMatchCaseInsensitivelyAndTrimmed(string requested) + { + Assert.True(Configured().TryResolveQueue(requested, out var queue, out _)); + Assert.Equal("labels", queue.Name); + Assert.Equal("ZDesigner GK420d", queue.PrinterName); + } + + [Fact] + public void UnknownQueueIsReported() + { + Assert.False(Configured().TryResolveQueue("nope", out _, out var errorCode)); + Assert.Equal(AppSettings.UnknownQueueCode, errorCode); + } + + [Fact] + public void NoQueuesConfiguredIsReported() + { + var settings = new AppSettings(); + Assert.False(settings.TryResolveQueue(null, out _, out var errorCode)); + Assert.Equal(AppSettings.NoQueueConfiguredCode, errorCode); + } + + [Fact] + public void QueuesWithoutADefaultCannotResolveAnEmptyRequest() + { + var settings = Configured(); + settings.DefaultQueue = null; + + Assert.False(settings.TryResolveQueue(null, out _, out var errorCode)); + Assert.Equal(AppSettings.NoQueueConfiguredCode, errorCode); + + // Naming a queue explicitly still works. + Assert.True(settings.TryResolveQueue("labels", out var queue, out _)); + Assert.Equal("labels", queue.Name); + } + + [Fact] + public void ADefaultPointingAtNothingIsAConfigurationProblem() + { + var settings = Configured(); + settings.DefaultQueue = "removed-queue"; + + Assert.False(settings.TryResolveQueue(null, out _, out var errorCode)); + Assert.Equal(AppSettings.NoQueueConfiguredCode, errorCode); + } + + [Theory] + [InlineData("labels", "labels")] + [InlineData(" labels ", "labels")] + [InlineData("Front-Desk", "Front-Desk")] + [InlineData("queue.1_a-b", "queue.1_a-b")] + [InlineData("9lives", "9lives")] + public void ValidQueueNamesNormalize(string input, string expected) + { + Assert.True(AppSettings.TryNormalizeQueueName(input, out var normalized)); + Assert.Equal(expected, normalized); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("-leading-hyphen")] + [InlineData(".leading-dot")] + [InlineData("_leading-underscore")] + [InlineData("has space")] + [InlineData("has/slash")] + [InlineData("has:colon")] + [InlineData("café")] + public void InvalidQueueNamesAreRejected(string? input) + { + Assert.False(AppSettings.TryNormalizeQueueName(input, out _)); + } + + [Fact] + public void QueueNamesLongerThan64CharactersAreRejected() + { + Assert.True(AppSettings.TryNormalizeQueueName(new string('a', 64), out _)); + Assert.False(AppSettings.TryNormalizeQueueName(new string('a', 65), out _)); + } +} diff --git a/tests/PrintBridge.Tests/SettingsStoreTests.cs b/tests/PrintBridge.Tests/SettingsStoreTests.cs new file mode 100644 index 0000000..849e2ab --- /dev/null +++ b/tests/PrintBridge.Tests/SettingsStoreTests.cs @@ -0,0 +1,120 @@ +using PrintBridge.Settings; +using Xunit; + +namespace PrintBridge.Tests; + +public class SettingsStoreTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), "PrintBridgeTests-" + Guid.NewGuid().ToString("N")); + + [Fact] + public void LoadWithoutFileReturnsDefaultsAndFlagsFirstRun() + { + var store = new SettingsStore(_tempDir); + var settings = store.Load(); + + Assert.True(store.IsFirstRun); + Assert.Equal(AppSettings.DefaultPort, settings.Port); + Assert.Empty(settings.AllowedOrigins); + Assert.Empty(settings.Queues); + Assert.Null(settings.DefaultQueue); + } + + [Fact] + public void SaveThenLoadRoundTrips() + { + var store = new SettingsStore(_tempDir); + store.Load(); + + var settings = new AppSettings + { + Port = 9111, + AllowedOrigins = ["https://apps.example.gov"], + Queues = + [ + new PrintQueueDefinition { Name = "labels", PrinterName = "ZDesigner GK420d" }, + new PrintQueueDefinition { Name = "front-desk", PrinterName = "HP LaserJet M404" }, + ], + DefaultQueue = "labels", + RunAtLogin = false, + }; + store.Save(settings); + + var reloaded = new SettingsStore(_tempDir).Load(); + Assert.Equal(9111, reloaded.Port); + Assert.Equal(new[] { "https://apps.example.gov" }, reloaded.AllowedOrigins); + Assert.Equal(2, reloaded.Queues.Count); + Assert.Equal("labels", reloaded.Queues[0].Name); + Assert.Equal("ZDesigner GK420d", reloaded.Queues[0].PrinterName); + Assert.Equal("front-desk", reloaded.Queues[1].Name); + Assert.Equal("labels", reloaded.DefaultQueue); + Assert.False(reloaded.RunAtLogin); + } + + [Fact] + public void SavedFileUsesCamelCaseNames() + { + var store = new SettingsStore(_tempDir); + store.Load(); + store.Save(new AppSettings + { + Queues = [new PrintQueueDefinition { Name = "labels", PrinterName = "ZDesigner GK420d" }], + DefaultQueue = "labels", + }); + + var json = File.ReadAllText(store.SettingsPath); + Assert.Contains("\"defaultQueue\"", json); + Assert.Contains("\"printerName\"", json); + } + + [Fact] + public void SaveRaisesChanged() + { + var store = new SettingsStore(_tempDir); + store.Load(); + + AppSettings? observed = null; + store.Changed += s => observed = s; + store.Save(new AppSettings { Port = 9112 }); + + Assert.NotNull(observed); + Assert.Equal(9112, observed!.Port); + Assert.False(store.IsFirstRun); + } + + [Fact] + public void CorruptFileFallsBackToDefaults() + { + Directory.CreateDirectory(_tempDir); + File.WriteAllText(Path.Combine(_tempDir, "settings.json"), "{not json!!"); + + var settings = new SettingsStore(_tempDir).Load(); + Assert.Equal(AppSettings.DefaultPort, settings.Port); + Assert.Empty(settings.Queues); + } + + [Fact] + public void CloneDoesNotShareQueueInstances() + { + var settings = new AppSettings + { + Queues = [new PrintQueueDefinition { Name = "labels", PrinterName = "ZDesigner GK420d" }], + }; + + var clone = settings.Clone(); + clone.Queues[0].PrinterName = "Something else"; + + Assert.Equal("ZDesigner GK420d", settings.Queues[0].PrinterName); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (DirectoryNotFoundException) + { + } + } +} From 7b9939f44d9ef82ae5d3b99655b2c05269cb2863 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 12:36:22 +0000 Subject: [PATCH 2/3] Take the printable area from the device context, and fix a test race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes after the first CI run (which built clean and failed one test): - Page placement measured PageSettings.PrintableArea and swapped it by hand for landscape pages. The .NET implementation already swaps that value itself, and which way it lands depends on the driver, so the workaround was a coin flip. Graphics.VisibleClipBounds comes straight from the printer device context and is therefore in the page's real orientation whatever the driver does; PageBounds (verifiably orientation-aware) stays as the fallback. FitCentered now takes the area as a rectangle so a non-zero clip origin is honored. - JobsAreDrainedInSubmissionOrder waited for the job status to turn "printing", which the manager sets just before it calls the executor — so the assertion could run before the executor recorded anything. It now waits on the executor's own record. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013Ym9ifwDcWhR56pTn6BZzd --- src/PrintBridge/Printing/PrintService.cs | 41 +++++++++---------- tests/PrintBridge.Tests/PageFittingTests.cs | 28 +++++++++---- .../PrintBridge.Tests/PrintJobManagerTests.cs | 15 ++++++- 3 files changed, 51 insertions(+), 33 deletions(-) diff --git a/src/PrintBridge/Printing/PrintService.cs b/src/PrintBridge/Printing/PrintService.cs index d0ce2eb..9edb5ff 100644 --- a/src/PrintBridge/Printing/PrintService.cs +++ b/src/PrintBridge/Printing/PrintService.cs @@ -122,7 +122,7 @@ private void Print(PrintRequest request, IProgress pageProgress, Cancellati using var page = RenderPage(pdfBytes, pageIndex, renderDpi); if (e.Graphics is { } graphics) { - graphics.DrawImage(page.Image, FitCentered(pageSizes[pageIndex], GetPrintableArea(e))); + graphics.DrawImage(page.Image, FitCentered(pageSizes[pageIndex], GetPrintableArea(e, graphics))); } pageIndex++; @@ -217,40 +217,37 @@ private static int ResolveRenderDpi(PrinterSettings settings) } /// - /// The printable area in hundredths of an inch. Windows reports it in portrait - /// terms even for a landscape page, so it is swapped back here. + /// The drawable area, in hundredths of an inch. This comes from the printer device + /// context itself, so it is already expressed in the page's real orientation — + /// unlike , whose landscape handling is a + /// long-standing source of driver-dependent surprises. /// - private static SizeF GetPrintableArea(PrintPageEventArgs e) + private static RectangleF GetPrintableArea(PrintPageEventArgs e, Graphics graphics) { - var printable = e.PageSettings.PrintableArea; - var width = printable.Width; - var height = printable.Height; - if (e.PageSettings.Landscape) + var clip = graphics.VisibleClipBounds; + if (clip.Width > 0 && clip.Height > 0) { - (width, height) = (height, width); + return clip; } - if (width <= 0 || height <= 0) - { - // Some drivers report an empty printable area; the full sheet is a safe stand-in. - var bounds = e.PageBounds; - (width, height) = (bounds.Width, bounds.Height); - } - - return new SizeF(width, height); + // Some drivers report nothing usable; the full sheet is a safe stand-in + // (PageBounds is already swapped for a landscape page). + var bounds = e.PageBounds; + return new RectangleF(0, 0, bounds.Width, bounds.Height); } /// /// Places a PDF page (size in points, 1/72") centered inside the printable area, - /// scaled down to fit but never enlarged. Result is in hundredths of an inch. + /// scaled down to fit but never enlarged. Both the area and the result are in + /// hundredths of an inch, the units a printer draws in. /// - public static RectangleF FitCentered(SizeF pageSizeInPoints, SizeF printableArea) + public static RectangleF FitCentered(SizeF pageSizeInPoints, RectangleF printableArea) { var contentWidth = pageSizeInPoints.Width / 72f * 100f; var contentHeight = pageSizeInPoints.Height / 72f * 100f; if (contentWidth <= 0 || contentHeight <= 0) { - return new RectangleF(0, 0, printableArea.Width, printableArea.Height); + return printableArea; } var scale = Math.Min(printableArea.Width / contentWidth, printableArea.Height / contentHeight); @@ -259,8 +256,8 @@ public static RectangleF FitCentered(SizeF pageSizeInPoints, SizeF printableArea var width = contentWidth * scale; var height = contentHeight * scale; return new RectangleF( - (printableArea.Width - width) / 2f, - (printableArea.Height - height) / 2f, + printableArea.X + ((printableArea.Width - width) / 2f), + printableArea.Y + ((printableArea.Height - height) / 2f), width, height); } diff --git a/tests/PrintBridge.Tests/PageFittingTests.cs b/tests/PrintBridge.Tests/PageFittingTests.cs index b3c5a89..81709ef 100644 --- a/tests/PrintBridge.Tests/PageFittingTests.cs +++ b/tests/PrintBridge.Tests/PageFittingTests.cs @@ -14,12 +14,12 @@ public class PageFittingTests private static readonly SizeF Letter = new(612f, 792f); /// Printable area of a typical laser printer on Letter (about 0.17" hardware margins). - private static readonly SizeF LetterPrintable = new(816f, 1058f); + private static readonly RectangleF LetterPrintable = new(0f, 0f, 816f, 1058f); [Fact] public void APageSmallerThanTheAreaIsNotEnlarged() { - var placed = PrintService.FitCentered(Letter, new SizeF(1200f, 1500f)); + var placed = PrintService.FitCentered(Letter, new RectangleF(0f, 0f, 1200f, 1500f)); Assert.Equal(850f, placed.Width, 0.5f); Assert.Equal(1100f, placed.Height, 0.5f); @@ -42,18 +42,29 @@ public void AnOversizePageIsScaledDownKeepingItsAspectRatio() [Fact] public void ThePageIsCenteredInThePrintableArea() { - var area = new SizeF(1000f, 1000f); + var area = new RectangleF(0f, 0f, 1000f, 1000f); var placed = PrintService.FitCentered(Letter, area); - Assert.Equal(area.Width - placed.Right, placed.Left, 0.5f); - Assert.Equal(area.Height - placed.Bottom, placed.Top, 0.5f); + Assert.Equal(area.Right - placed.Right, placed.Left - area.Left, 0.5f); + Assert.Equal(area.Bottom - placed.Bottom, placed.Top - area.Top, 0.5f); + } + + [Fact] + public void APrintableAreaThatDoesNotStartAtTheOriginIsRespected() + { + var area = new RectangleF(17f, 17f, 1200f, 1500f); + var placed = PrintService.FitCentered(Letter, area); + + Assert.Equal(17f + 175f, placed.X, 0.5f); + Assert.Equal(17f + 200f, placed.Y, 0.5f); + Assert.Equal(850f, placed.Width, 0.5f); } [Fact] public void ALandscapePageFillsALandscapeArea() { - // Landscape Letter (1100 x 850) against the printable area swapped for orientation. - var placed = PrintService.FitCentered(new SizeF(792f, 612f), new SizeF(1058f, 816f)); + // Landscape Letter (1100 x 850) against the printable area for a landscape page. + var placed = PrintService.FitCentered(new SizeF(792f, 612f), new RectangleF(0f, 0f, 1058f, 816f)); Assert.Equal(1056f, placed.Width, 0.5f); Assert.Equal(816f, placed.Height, 0.5f); @@ -66,7 +77,6 @@ public void ADegeneratePageSizeFallsBackToTheWholeArea() { var placed = PrintService.FitCentered(new SizeF(0f, 0f), LetterPrintable); - Assert.Equal(LetterPrintable.Width, placed.Width); - Assert.Equal(LetterPrintable.Height, placed.Height); + Assert.Equal(LetterPrintable, placed); } } diff --git a/tests/PrintBridge.Tests/PrintJobManagerTests.cs b/tests/PrintBridge.Tests/PrintJobManagerTests.cs index 420046b..32c9cbd 100644 --- a/tests/PrintBridge.Tests/PrintJobManagerTests.cs +++ b/tests/PrintBridge.Tests/PrintJobManagerTests.cs @@ -65,13 +65,24 @@ public async Task JobsAreDrainedInSubmissionOrder() var second = manager.Enqueue(Request("two")); var third = manager.Enqueue(Request("three")); - // Only the first job may be in flight while the executor is blocked. - await WaitFor(() => first.Status == PrintJobStatus.Printing, "the first job never started"); + // Only the first job may be in flight while the executor is blocked. Wait on + // the executor's own record, not on the status, which is set just before it runs. + await WaitFor( + () => + { + lock (started) + { + return started.Count > 0; + } + }, + "the first job never reached the executor"); + lock (started) { Assert.Equal(new[] { "one" }, started); } + Assert.Equal(PrintJobStatus.Printing, first.Status); Assert.Equal(PrintJobStatus.Queued, second.Status); Assert.Equal(PrintJobStatus.Queued, third.Status); From 5c66d2af0aaee22b0c26e5067d729fb182ffd354 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 12:36:56 +0000 Subject: [PATCH 3/3] Drop the unused PrinterExists helper The queue editor already checks the installed-printer list itself when it has to show a queue whose printer is gone, so nothing calls this. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013Ym9ifwDcWhR56pTn6BZzd --- src/PrintBridge/Printing/PrintService.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/PrintBridge/Printing/PrintService.cs b/src/PrintBridge/Printing/PrintService.cs index 9edb5ff..8c77fad 100644 --- a/src/PrintBridge/Printing/PrintService.cs +++ b/src/PrintBridge/Printing/PrintService.cs @@ -34,17 +34,6 @@ public PrintService(ILogger logger) public static List ListInstalledPrinters() => PrinterSettings.InstalledPrinters.Cast().ToList(); - /// True when Windows still knows the printer a queue points at. - public static bool PrinterExists(string? printerName) - { - if (string.IsNullOrWhiteSpace(printerName)) - { - return false; - } - - return ListInstalledPrinters().Any(p => string.Equals(p, printerName, StringComparison.OrdinalIgnoreCase)); - } - /// /// Renders and spools the job. Completing means the spooler accepted the document — /// what happens after that (paper jams, an offline printer) is not visible here.