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..8c77fad --- /dev/null +++ b/src/PrintBridge/Printing/PrintService.cs @@ -0,0 +1,308 @@ +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(); + + /// + /// 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, graphics))); + } + + 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 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 RectangleF GetPrintableArea(PrintPageEventArgs e, Graphics graphics) + { + var clip = graphics.VisibleClipBounds; + if (clip.Width > 0 && clip.Height > 0) + { + return clip; + } + + // 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. Both the area and the result are in + /// hundredths of an inch, the units a printer draws in. + /// + 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 printableArea; + } + + 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.X + ((printableArea.Width - width) / 2f), + printableArea.Y + ((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 0000000..a7309c7 Binary files /dev/null and b/src/PrintBridge/Resources/printbridge.ico differ diff --git a/src/PrintBridge/Resources/testpage.pdf b/src/PrintBridge/Resources/testpage.pdf new file mode 100644 index 0000000..45948e9 Binary files /dev/null and b/src/PrintBridge/Resources/testpage.pdf differ 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..81709ef --- /dev/null +++ b/tests/PrintBridge.Tests/PageFittingTests.cs @@ -0,0 +1,82 @@ +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 RectangleF LetterPrintable = new(0f, 0f, 816f, 1058f); + + [Fact] + public void APageSmallerThanTheAreaIsNotEnlarged() + { + 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); + 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 RectangleF(0f, 0f, 1000f, 1000f); + var placed = PrintService.FitCentered(Letter, area); + + 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 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); + 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, placed); + } +} 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..32c9cbd --- /dev/null +++ b/tests/PrintBridge.Tests/PrintJobManagerTests.cs @@ -0,0 +1,228 @@ +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. 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); + + 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) + { + } + } +}