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.
- How it works
- Requirements
- Installation
- Configuration
- Usage
- Adapting to other printers
- Troubleshooting
- Uninstall
- Security notes
- Limitations
- Repository contents
- License
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.
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…).
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.
- 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
lpadminto register the virtual printer with CUPS)
No Python requirements.txt — the listener uses only standard library modules (socket, subprocess, tempfile, datetime, os, sys).
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 qpdflpstat -pYou 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.
git clone https://github.com/<your-fork>/duplex-print.git ~/duplex-print
cd ~/duplex-printIf 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 dialogThen run the installer:
./install-virtual-printer.shThe script will:
- Install a LaunchAgent that runs the listener at login (no sudo)
- Verify the listener is accepting connections on
127.0.0.1:9101(no sudo) - Prompt before running one
sudo lpadmincommand to register the virtual printer with CUPS
lpstat -p Brother_HL_2240_Duplex # or whatever DUPLEX_PRINTER_NAME you usedOpen 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.
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.
- Open any document in any app
- Cmd+P to open the Print dialog
- Select your virtual printer (default name: Brother HL-2240 Duplex) from the Printer dropdown
- Use the dialog like any other print — choose page range, copies, preview, etc.
- Click Print
- The odd-numbered pages print
- 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
- The even-numbered pages print on the backs
~/duplex-print/duplex.sh /path/to/document.pdfSame logic, bypasses the virtual-printer machinery entirely. Useful when iterating on the script itself.
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.
lpstat -pThe 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.
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.shYou 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).
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:
-
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".
-
Print it with the current settings (both passes set to
outputorder=reverse). -
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?
-
If anything's off, edit
OUTPUTORDER_ODDSandOUTPUTORDER_EVENSinduplex.sh. Each can benormalorreverse— four combinations total, and usually one of them produces correct output for any given face-down printer + flip method. -
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.
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.
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:
-
Open
~/Library/LaunchAgents/local.duplex-print.plistin a text editor. -
Find the comment block starting with
<!-- Logging is intentionally OFFand uncomment the fourStandard*Pathlines inside it (delete the<!--and-->markers around them). -
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
-
Reproduce the problem. The log appears at
~/duplex-print/listener.log— one timestamped line per event. Tail it:tail -f ~/duplex-print/listener.log -
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.
launchctl print "gui/$UID/local.duplex-print" | head -10Should 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.plistpython3 -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.
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.
lpstat -W not-completed -o Brother_HL_2240_Duplex
cancel -a Brother_HL_2240_Duplex # clear all jobs from the virtual queueChange 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.plistApple 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.
~/duplex-print/uninstall-virtual-printer.shReverses 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 qpdfseparately) - The real printer queue you're targeting
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.
- 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
python3location and bakes it in. If you upgrade Python (e.g., 3.13 → 3.14) and the path changes, re-runinstall-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.
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.
MIT. See LICENSE.