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