Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<!-- Overridden by CI at release time: dotnet publish -p:Version=x.y.z -->
<Version Condition="'$(Version)' == ''">0.1.0</Version>
<Company>PrintBridge contributors</Company>
<Product>PrintBridge</Product>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>
27 changes: 27 additions & 0 deletions PrintBridge.sln
Original file line number Diff line number Diff line change
@@ -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
162 changes: 161 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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-<version>-setup.exe`** — per-user installer (no admin rights needed).
Installs to `%LocalAppData%\Programs\PrintBridge` and starts at login by default.
- **`PrintBridge-<version>-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/<jobId>
```

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).
44 changes: 44 additions & 0 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
@@ -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: <https://github.com/sungaila/PDFtoImage>

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: <https://pdfium.googlesource.com/pdfium/>
- Packaging source: <https://github.com/bblanchon/pdfium-binaries>

## 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: <https://github.com/mono/SkiaSharp>

## Serilog

- Packages: `Serilog`, `Serilog.Extensions.Logging`, `Serilog.Sinks.File`
- License: Apache-2.0
- Source: <https://github.com/serilog/serilog>

## 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: <https://github.com/dotnet/aspnetcore>

No component of PrintBridge or its dependencies is licensed under the GPL or LGPL.
Loading
Loading