Skip to content

awallender/duplex-print

Repository files navigation

duplex-print

A virtual macOS printer that adds double-sided printing to the Brother HL-2240 series.

Install it once, and a new printer called "Brother HL-2240 Duplex" shows up in your Mac's Print dialog. Print to it from any app — Cmd+P, pick it from the dropdown, click Print. Behind the scenes it runs a two-pass print with a friendly dialog telling you when to flip the paper.

The same approach should work for most face-down-output laser printers with some light configuration — see Adapting to other printers.

Built with Claude (Anthropic's AI assistant) as a coding collaborator. Design and testing by the author; code generation mostly by Claude.


Table of contents


How it works

   Any app  ─Cmd+P─▶  Print dialog  ─pick virtual printer─▶
                                                          │
                                       CUPS virtual queue │
                                                          ▼
                          socket://localhost:9101 (TCP, localhost-only)
                                                          │
                                                          ▼
        ┌─────────────────────────────────────────────────────────────┐
        │  duplex-listener.py  (running as a LaunchAgent)             │
        │   • accepts TCP connection                                  │
        │   • reads PDF bytes from the socket                         │
        │   • writes them to a temp file                              │
        │   • shells out to duplex.sh                                 │
        └─────────────────────────────────────────────────────────────┘
                                                          │
                                                          ▼
        ┌─────────────────────────────────────────────────────────────┐
        │  duplex.sh                                                  │
        │   1. qpdf splits PDF into odds and evens                    │
        │      (pads evens with a blank if total page count is odd)   │
        │   2. lp prints odds to the real printer (reverse order)     │
        │   3. polls lpstat until the odd-side job clears the queue   │
        │   4. osascript pops a modal "flip the paper" dialog         │
        │   5. lp prints evens (reverse order)                        │
        └─────────────────────────────────────────────────────────────┘
                                                          │
                                                          ▼
                                                 Physical printer

Throughout the rest of this README, "the listener" refers to the middle box in the diagram above — the duplex-listener.py background process started at login by the LaunchAgent. It sits idle on TCP port 9101 until a print job arrives, then hands the PDF off to duplex.sh for the actual printing.

Why both passes use outputorder=reverse

Face-down-output printers stack newly-printed sheets on top of older ones, printed-side down. With both passes printing in reverse order and a long-edge flip between them, the final stack reads top-to-bottom in document order (page 1 on top, then 2, 3, 4…).

Why odd-page-count documents need a blank page

For a document with N pages where N is odd, the input tray after the flip holds (N+1)/2 sheets but evens.pdf only has (N-1)/2 pages. Without intervention, the first even page lands on the wrong sheet and the document's last sheet sits unused in the input tray. The script appends a blank page to evens.pdf — with reverse ordering this blank prints onto the (otherwise unprinted) back of the document's last page, and the remaining even pages line up with their correct sheets.


Requirements

  • macOS Apple Silicon (tested on macOS 26 Tahoe; should work on macOS 12+)
  • The real printer already configured in System Settings → Printers. The script duplexes by submitting two passes to your existing printer queue.
  • Homebrew for installing qpdf
  • Python 3 (any modern version; stdlib only — no third-party packages)
  • Administrator password once during installation (for lpadmin to register the virtual printer with CUPS)

No Python requirements.txt — the listener uses only standard library modules (socket, subprocess, tempfile, datetime, os, sys).


Installation

1. Install dependencies

If you don't have Homebrew yet:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

Then install qpdf:

brew install qpdf

2. Confirm your real printer's queue name

lpstat -p

You should see a line like printer Brother_HL_2240_series is idle…. The first identifier (here Brother_HL_2240_series) is your real printer's queue name — duplex.sh needs to know this.

3. Clone and install

git clone https://github.com/<your-fork>/duplex-print.git ~/duplex-print
cd ~/duplex-print

If your real printer's queue name is not Brother_HL_2240_series, set it via environment variable before running the installer:

export DUPLEX_PRINTER="Your_Printer_Queue_Name"

Optional — customize the virtual printer's display name and internal queue name:

export DUPLEX_PRINTER_NAME="Manual_Duplex"          # CUPS internal name, no spaces
export DUPLEX_PRINTER_DESC="Manual Duplex"          # human-readable name shown in Print dialog

Then run the installer:

./install-virtual-printer.sh

The script will:

  1. Install a LaunchAgent that runs the listener at login (no sudo)
  2. Verify the listener is accepting connections on 127.0.0.1:9101 (no sudo)
  3. Prompt before running one sudo lpadmin command to register the virtual printer with CUPS

4. Verify

lpstat -p Brother_HL_2240_Duplex     # or whatever DUPLEX_PRINTER_NAME you used

Open any PDF and Cmd+P. The virtual printer should appear in the Printer dropdown of the Print dialog.

For end-to-end testing:

python3 make_test_pdf.py test.pdf 4       # 4-page labeled test PDF
python3 make_test_pdf.py test3.pdf 3      # 3-page (exercises odd-page padding)

Then print one of those via the virtual printer.


Configuration

All settings via environment variables. None of them are required for the default Brother HL-2240 setup.

Variable Default Read by Purpose
DUPLEX_PRINTER Brother_HL_2240_series duplex.sh The real printer queue that the script submits print jobs to.
QPDF_PATH /opt/homebrew/bin/qpdf duplex.sh Path to the qpdf binary. Override if you installed qpdf somewhere non-standard.
DUPLEX_PRINTER_NAME Brother_HL_2240_Duplex install-virtual-printer.sh Internal CUPS queue name for the virtual printer. No spaces.
DUPLEX_PRINTER_DESC Brother HL-2240 Duplex install-virtual-printer.sh Display name shown in the Print dialog.
DUPLEX_PORT 9101 install-virtual-printer.sh TCP port the listener binds to. Change if 9101 conflicts on your system. The listener itself also reads PORT from its source — keep both in sync if you change this.

To make duplex.sh overrides permanent without exporting in every shell, add them to your ~/.zshrc or edit the defaults at the top of duplex.sh directly.


Usage

Normal use

  1. Open any document in any app
  2. Cmd+P to open the Print dialog
  3. Select your virtual printer (default name: Brother HL-2240 Duplex) from the Printer dropdown
  4. Use the dialog like any other print — choose page range, copies, preview, etc.
  5. Click Print
  6. The odd-numbered pages print
  7. When the "Flip the paper" dialog appears:
    • Take the output stack
    • Long-edge flip it (turn it over like a book page — the short edges stay short, the long edges swap top↔bottom)
    • Re-insert into the input tray
    • Click OK
  8. The even-numbered pages print on the backs

Standalone CLI (for testing or scripting)

~/duplex-print/duplex.sh /path/to/document.pdf

Same logic, bypasses the virtual-printer machinery entirely. Useful when iterating on the script itself.


Adapting to other printers

The defaults in this repository are set up for the Brother HL-2240 series with a face-down output tray and a long-edge flip between passes. The underlying design — a virtual CUPS printer that forwards jobs to a small TCP listener, which splits the PDF and orchestrates two print passes with a flip dialog — isn't specific to any particular hardware. Adapting it to another printer is usually a matter of overriding a few environment variables and possibly re-tuning two flags.

Step 1: Find your printer's queue name

lpstat -p

The first identifier on each line (e.g. HP_LaserJet_4015) is the queue name CUPS uses internally. That's what you'll point this project at.

Step 2: Override the defaults

Set these environment variables before running install-virtual-printer.sh:

export DUPLEX_PRINTER="Your_Printer_Queue_Name"       # the real printer to forward to
export DUPLEX_PRINTER_NAME="Manual_Duplex"            # virtual printer's CUPS name (no spaces)
export DUPLEX_PRINTER_DESC="Manual Duplex"            # display name shown in Print dialog
./install-virtual-printer.sh

You may also want to edit the top of duplex.sh so the standalone CLI path uses the same defaults (otherwise you'll need to export DUPLEX_PRINTER=... in every shell).

Step 3: Tune the page-order flags

The print order depends on two things specific to your printer + flip method:

  • Face-down vs face-up output tray — does your printer stack pages printed-side down (most common) or printed-side up?
  • Long-edge vs short-edge flip — when you flip the stack between passes, do you turn it over like a book page (long-edge) or end-for-end (short-edge)?

To find the right combination:

  1. Generate a labeled test PDF:

    python3 make_test_pdf.py test.pdf 4

    Each page shows its page number and which edge is the "top".

  2. Print it with the current settings (both passes set to outputorder=reverse).

  3. Examine the result:

    • Which page was on top of the odd-side stack in the output tray? Face-up or face-down?
    • After flipping and the even pass, are pages 2 and 4 on the correct backs (back of 1 and back of 3, respectively)?
    • Is the final stack in reading order from top to bottom?
  4. If anything's off, edit OUTPUTORDER_ODDS and OUTPUTORDER_EVENS in duplex.sh. Each can be normal or reverse — four combinations total, and usually one of them produces correct output for any given face-down printer + flip method.

  5. Verify the odd-page-count case works — generate a 3-page test PDF and run it through:

    python3 make_test_pdf.py test3.pdf 3

    This exercises the blank-page padding logic in duplex.sh.

What's not easily adaptable

This project assumes a face-down output tray. Face-up output (less common in lasers) would require rethinking the page-ordering logic from scratch, because the relationship between print order and final stack order is inverted. The script's outputorder flags can't fix that on their own.

It also assumes you can flip the entire stack and reload it cleanly — printers with non-standard or finicky input trays may need extra care.

If you adapt this for a different printer and get it working, a PR with notes (or just a comment in duplex.sh describing your settings) is welcome.


Troubleshooting

Enable logging temporarily (for debugging)

By default the listener's stdout/stderr go to /dev/null — no log file is created, and no record of your printing activity is kept on disk. This is the right default for privacy and disk hygiene, but it does mean there's nothing to grep when something goes wrong.

When you need to diagnose a problem, enable logging temporarily:

  1. Open ~/Library/LaunchAgents/local.duplex-print.plist in a text editor.

  2. Find the comment block starting with <!-- Logging is intentionally OFF and uncomment the four Standard*Path lines inside it (delete the <!-- and --> markers around them).

  3. Reload the LaunchAgent so it picks up the change:

    launchctl bootout "gui/$UID/local.duplex-print"
    launchctl bootstrap "gui/$UID" ~/Library/LaunchAgents/local.duplex-print.plist
  4. Reproduce the problem. The log appears at ~/duplex-print/listener.log — one timestamped line per event. Tail it:

    tail -f ~/duplex-print/listener.log
  5. When you're done debugging, re-add the comment markers, reload the LaunchAgent again, and delete the log:

    rm ~/duplex-print/listener.log

A successful print job, when logging is enabled, looks like:

2026-05-16T15:54:35 [duplex-listener] ('127.0.0.1', 50983): received 303367 bytes -> /var/folders/.../duplex-cups-la2wjtxd.pdf
Input: /var/folders/.../duplex-cups-la2wjtxd.pdf (5 pages)
Odd page count — padding evens with a blank pass.
Printing odds pages (outputorder=reverse)...
…
Done.
2026-05-16T15:55:14 [duplex-listener] duplex.sh exited with status 0

Lines like 0-byte connection (health probe), ignored are normal — TCP probes from CUPS or other macOS background processes checking the printer is alive.

Listener isn't running

launchctl print "gui/$UID/local.duplex-print" | head -10

Should show state = running. If not, restart it:

launchctl bootout "gui/$UID/local.duplex-print" 2>/dev/null
launchctl bootstrap "gui/$UID" ~/Library/LaunchAgents/local.duplex-print.plist

Listener isn't accepting connections

python3 -c "import socket; s=socket.socket(); s.settimeout(2); s.connect(('127.0.0.1',9101)); print('OK')"

Hangs or errors → listener isn't running. Check the log and restart as above.

TCC (Automation) permissions

The first time duplex.sh runs the AppleScript dialog, macOS prompts for permission to control System Events. Click Allow. If you accidentally clicked Deny, fix it in System Settings → Privacy & Security → Automation.

Job stuck in the queue

lpstat -W not-completed -o Brother_HL_2240_Duplex
cancel -a Brother_HL_2240_Duplex      # clear all jobs from the virtual queue

Port 9101 conflict

Change PORT = 9101 in duplex-listener.py and re-run the installer with DUPLEX_PORT=NEW_PORT. Then restart the LaunchAgent to pick up the change:

launchctl bootout "gui/$UID/local.duplex-print"
launchctl bootstrap "gui/$UID" ~/Library/LaunchAgents/local.duplex-print.plist

CUPS warns about PPD deprecation during install

Apple is removing classic PPD-based drivers in a future CUPS version. As of macOS 26 Tahoe, classic PPDs still work fully. When that changes, the migration path is IPP Everywhere / driverless printing — the project will need to adapt accordingly.


Uninstall

~/duplex-print/uninstall-virtual-printer.sh

Reverses everything the installer did:

  • Removes the virtual CUPS printer (sudo lpadmin -x)
  • Stops and removes the LaunchAgent
  • Removes any leftover Stage-2 Automator workflow

Does not remove:

  • Files inside ~/duplex-print/ (delete the folder manually if desired)
  • Homebrew or qpdf (run brew uninstall qpdf separately)
  • The real printer queue you're targeting

Security notes

The listener binds to 127.0.0.1 (localhost only) — accessible only from this Mac. macOS's network stack refuses to route packets destined for 127.x.x.x over any external interface, so nothing on Wi-Fi, Ethernet, or the internet can reach the listener.

The only privilege escalation in the entire project is the one-time sudo lpadmin invocation during install, which writes to /etc/cups/printers.conf and copies the PPD into /etc/cups/ppd/. Nothing in /System/, nothing in /usr/libexec/, no SIP changes, no kernel extensions.

The listener runs as your user, not root.

The realistic worst case from a malicious local program connecting to port 9101: they could trigger a print job, wasting paper and toner. They cannot read files, send network traffic externally, or escalate privileges beyond what they already have.


Limitations

  • One paper size assumed for the blank page in the odd-pages path: US Letter (612×792 pt). On A4 etc., the printer scales the blank to fit the loaded paper.
  • One job at a time. The listener serializes prints — submitting a second print while the first is mid-flip will queue it. This is intentional; concurrent paper-flip prompts would be unworkable.
  • Output-order flags are hardware-specific. Tuned for face-down-output trays and long-edge flips. Other configurations need re-tuning.
  • Hardcoded Python path in the installed LaunchAgent plist. The installer detects your python3 location and bakes it in. If you upgrade Python (e.g., 3.13 → 3.14) and the path changes, re-run install-virtual-printer.sh.
  • No automatic recovery if the listener crashes mid-job (launchd restarts it, but the in-progress print is lost).
  • CUPS PPD deprecation — Apple has flagged classic PPDs for future removal. See troubleshooting section.

Repository contents

duplex-print/
├── README.md                          # this file
├── LICENSE                            # MIT
├── .gitignore                         # excludes listener.log, .DS_Store, etc.
│
├── duplex.sh                          # core printing logic (split, two-pass, dialog)
├── duplex-listener.py                 # TCP listener receiving PDFs from the virtual printer
├── duplex.ppd                         # minimal CUPS PPD declaring "PDF passes through unfiltered"
│
├── local.duplex-print.plist           # LaunchAgent template (with __HOME__/__PYTHON__ placeholders)
├── install-virtual-printer.sh         # idempotent installer; substitutes placeholders + runs lpadmin
├── uninstall-virtual-printer.sh       # reverses the install
│
└── make_test_pdf.py                   # generates labeled multi-page PDFs (for testing / re-tuning)

The listener does not write a log file by default (see Troubleshooting for how to enable logging temporarily for debugging). If you do enable it, the log lives at listener.log in this folder and is gitignored.


License

MIT. See LICENSE.

About

Provides manual duplex printing for the Brother HL-2240 on macOS.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors