# AirPrint Bridge — Administrator & Developer Guide

## 1. Architecture Overview

AirPrint Bridge is a single-file Bash script (`airprint_bridge.sh`, v1.3.2) that makes macOS-shared CUPS printers discoverable by iOS and iPadOS via Apple's AirPrint protocol. It works by using macOS's built-in `dns-sd` command to register each shared printer as a Bonjour mDNS service with the correct AirPrint TXT records. iOS devices on the same local network then see these printers natively — no app, no driver, and no cloud service required.

The project contains two operational components: the main script and a Homebrew formula that packages it for distribution.

```
macOS host (runs AirPrint Bridge)
┌─────────────────────────────────────────────────────┐
│                                                     │
│  airprint_bridge.sh ─── CUPS subsystem              │
│       │               (lpstat, lpoptions)           │
│       │                                             │
│       ├── check_firewall()    socketfilterfw        │
│       ├── check_cups_permissions()  cupsd.conf      │
│       ├── browse_printers()   lpstat -p             │
│       ├── generate_urf()      lpoptions -l          │
│       ├── resolve_printer()   lpstat -v / -l        │
│       └── generate_script()   → airprint_bridge_launcher.sh
│                                                     │
│  LaunchDaemon (com.sapireli.airprint_bridge.plist)  │
│       └── runs airprint_bridge_launcher.sh          │
│              └── dns-sd -R (one per printer)        │
│                     │                               │
└─────────────────────┼───────────────────────────────┘
                       │  Bonjour / mDNS (_ipp._tcp.,_universal)
        ───────────────┼───────────────
        iOS / iPadOS   │   discovers AirPrint printers
```

> [!NOTE]
> The architecture has zero runtime dependencies beyond what ships with macOS. There is no daemon, no configuration server, no database, and no network proxy. The entire bridge is the output of `dns-sd -R` commands keeping Bonjour records alive. If the process dies, the LaunchDaemon's `KeepAlive=true` restarts it automatically.

---

## 2. Component Inventory

| Component | Path | Role |
|---|---|---|
| Main script | `airprint_bridge.sh` | All logic: parsing, CUPS querying, URF generation, script/plist generation, install/uninstall |
| Homebrew formula | `Formula/airprint-bridge.rb` | Packages the script as a Homebrew installable; declares macOS dependency |
| Homebrew tap setup helper | `setup_homebrew_tap.sh` | Developer utility — guides through setting up the tap; not installed to end-user systems |
| Formula test script | `test_homebrew.sh` | Developer utility — validates formula file structure locally |
| LaunchDaemon plist | `/Library/LaunchDaemons/com.sapireli.airprint_bridge.plist` | Generated at install time; manages persistent service lifecycle |
| Printer registration launcher | `/usr/local/bin/airprint_bridge_launcher.sh` | Generated at install time; contains one `dns-sd -R` command per detected shared printer |

---

## 3. System Requirements

| Item | Requirement |
|---|---|
| OS | macOS (any modern version) |
| Shell | `/bin/bash` |
| Required tools | `dns-sd`, `lpstat`, `lpoptions`, `launchctl` (all built-in to macOS) |
| Firewall tool | `/usr/libexec/ApplicationFirewall/socketfilterfw` (built-in) |
| Privileges | `root` / `sudo` for install, uninstall, and test modes |
| CUPS | Must be running with at least one shared printer |
| Homebrew | Optional; only required for the formula install path |

---

## 4. Configuration Reference

### 4.1 Script Variables

These variables are declared at the top of `airprint_bridge.sh` and control runtime behavior. There is no external config file.

| Variable | Default | Type | Description |
|---|---|---|---|
| `SERVICE` | `_ipp._tcp.,_universal` | String | Bonjour service type used in `dns-sd -R` registration. This must match the AirPrint service type to be discoverable by iOS. |
| `DOMAIN` | `local` | String | mDNS domain passed to `dns-sd -R`. |
| `SCRIPT` | `airprint_bridge_launcher.sh` | String | Filename of the generated per-printer registration script. Overridable with `-f`. |
| `LOGGING` | `0` | Boolean (0/1) | When `1`, log output is written to `LOGFILE` in addition to stderr. When `0`, output goes only to stderr. |
| `LOGFILE` | `airprint_bridge.log` | String | Log file path. Relative to the working directory when the script runs. |
| `SCRIPT_FILE` | (empty) | String | Populated by the `-f` option; overrides `SCRIPT`. |
| `CUPS_CONF_CHANGED` | `0` | Boolean (0/1) | Set to `1` by `check_cups_permissions()` if `/etc/cups/cupsd.conf` was modified; used by `revert_cups_config()` during uninstall. |
| `HAS_COLOR` | `0` | Boolean (0/1) | Set to `1` by `generate_urf()` if the printer reports any color mode (`RGB`, `AdobeRGB`, `CMYK`). |
| `HAS_DUPLEX` | `0` | Boolean (0/1) | Set to `1` by `generate_urf()` if the printer reports any duplex mode other than simplex. |
| `URF` | (empty) | String | Populated by `generate_urf()` with the complete URF capabilities string (e.g., `V1.4,SRGB24,PQ3,DM1,MS_LETTER`). |

### 4.2 CUPS Configuration File

| File | Format | Location | Purpose |
|---|---|---|---|
| `cupsd.conf` | Apache-style directives | `/etc/cups/cupsd.conf` | Main CUPS daemon config. The script adds `Allow @LOCAL` to the `<Location />` block if absent. |
| `cupsd.conf.bak` | Same | `/etc/cups/cupsd.conf.bak` | Backup created by the script before any auto-modification. Restored by uninstall. |
| `printers.conf` | Apache-style | `/etc/cups/printers.conf` | Read by the script to detect `Shared Yes` printer entries as a fallback to `lpoptions`. |

### 4.3 Generated Files

| File | Location | Format | Created by |
|---|---|---|---|
| `airprint_bridge_launcher.sh` | `/usr/local/bin/airprint_bridge_launcher.sh` | Bash script | `generate_script()` |
| LaunchDaemon plist | `/Library/LaunchDaemons/com.sapireli.airprint_bridge.plist` | XML plist | `generate_plist()` |

#### LaunchDaemon plist key reference

| Key | Value | Description |
|---|---|---|
| `Label` | `com.sapireli.airprint_bridge.plist` | Unique identifier for `launchctl` |
| `ProgramArguments` | `["/usr/local/bin/airprint_bridge_launcher.sh"]` | Script to run |
| `LowPriorityIO` | `true` | Reduces I/O scheduling priority |
| `Nice` | `1` | Slight CPU de-prioritization |
| `UserName` | `root` | Runs as root |
| `RunAtLoad` | `true` | Start automatically when the plist is loaded |
| `KeepAlive` | `true` | Restart the process if it exits for any reason |

---

## 5. Deployment

### 5.1 Install Flow (Step by Step)

When `sudo airprint-bridge -i` is run, the following sequence executes:

1. `check_dependencies()` — verifies `dns-sd`, `lpstat`, `lpoptions`, `launchctl` are present.
2. `check_cups_permissions()` — reads `/etc/cups/cupsd.conf`; if `Allow @LOCAL` is absent, backs up the file, injects the directive, validates with `cupsd -t`, and restarts CUPS via `launchctl`. Also calls `lpadmin -p <name> -u allow:all` for any shared printer that restricts users.
3. `check_firewall()` — queries `socketfilterfw --getglobalstate`; if the firewall is enabled and `cupsd` is not already in the allow list, adds and unblocks it.
4. `browse_printers()` — enumerates all printers via `lpstat -p`; filters to those with `printer-is-shared=true` (via `lpoptions`) or `Shared Yes` (via `printers.conf`). Populates the `PRINTERS` array.
5. `generate_script()` — for each printer, calls `resolve_printer()` (which calls `generate_urf()`), assembles TXT records, and writes a `dns-sd -R` command to `airprint_bridge_launcher.sh`. The generated script uses a `trap` to kill all `dns-sd` child processes on `EXIT`, `INT`, or `TERM`.
6. `generate_plist()` — copies the launcher to `/usr/local/bin/`, writes the LaunchDaemon plist to `/Library/LaunchDaemons/`, and loads it with `launchctl load -w`.

### 5.2 Uninstall Flow

When `sudo airprint-bridge -u` is run:

1. `revert_cups_config()` — if `CUPS_CONF_CHANGED=1` and the backup exists, restores `/etc/cups/cupsd.conf.bak` and restarts CUPS.
2. Unloads and deletes `/Library/LaunchDaemons/com.sapireli.airprint_bridge.plist`.
3. Removes `/usr/local/bin/airprint_bridge_launcher.sh`.
4. Uses `pgrep -f "dns-sd -R"` to find all running `dns-sd` processes advertising `_ipp._tcp.,_universal`; kills each one.
5. Sleeps 2 seconds to allow Bonjour advertisements to expire from the network.

### 5.3 Homebrew Distribution

The Homebrew formula at `Formula/airprint-bridge.rb` taps directly from the main repository. There is no separate tap repository required.

```bash
brew tap sapireli/AirPrint_Bridge
brew install airprint-bridge
```

The formula:
- Downloads from the repository archive at commit `59e9440a529e99d3000762608af57b0db6a7b62b` (version 1.3.2)
- Installs `airprint_bridge.sh` to `#{bin}/airprint-bridge` with mode `0755`
- Declares `depends_on :macos`
- Passes the `test do` block by running `airprint-bridge -h` and asserting `"Usage:"` appears in output

> [!NOTE]
> The formula's locked commit (`59e9440`) differs from the documentation's `source_commit` (`743085a`). The formula was published at an earlier commit than the current tip. Users who install via Homebrew get the formula-locked version; users who clone from source get the latest commit.

---

## 6. Operations

### 6.1 Service Management

```bash
# Check if the LaunchDaemon is loaded
sudo launchctl list | grep airprint

# Start the service
sudo launchctl start com.sapireli.airprint_bridge.plist

# Stop the service
sudo launchctl stop com.sapireli.airprint_bridge.plist

# Check running dns-sd registrations
pgrep -fl "dns-sd -R"
```

> [!TIP]
> After any change to printer sharing, run a full uninstall+reinstall rather than just restarting the service. The registration script is generated once at install time and does not update dynamically when printers are added or removed.

### 6.2 CUPS Interaction Runbook

| Situation | Command | Notes |
|---|---|---|
| List all printers | `lpstat -p` | Shows printer names |
| Check if a printer is shared | `lpoptions -p <name>` | Look for `printer-is-shared=true` |
| Check printer capabilities | `lpoptions -l -p <name>` | Lists all options including ColorModel, Duplex, PageSize |
| Get device URI | `lpstat -v <name>` | Shows `ipp://...` or `usb://...` URI |
| Get printer description and location | `lpstat -l -p <name>` | Used for the Bonjour service name |
| Allow all users on a printer | `sudo lpadmin -p <name> -u allow:all` | Done automatically by `check_cups_permissions()` |
| Restart CUPS | `sudo launchctl stop org.cups.cupsd && sudo launchctl start org.cups.cupsd` | Done automatically after cupsd.conf edits |

> [!NOTE]
> The script forces `export LANG=C` and `export LC_ALL=C` before running to ensure CUPS output is parsed in English regardless of the system locale. This is important on macOS systems configured in non-English locales.

### 6.3 Monitoring

There is no built-in monitoring. To check health:

```bash
# 1. Confirm the LaunchDaemon is loaded
sudo launchctl list | grep airprint

# 2. Confirm dns-sd processes are running (one per shared printer)
pgrep -c "dns-sd"

# 3. Confirm the printer appears on an iOS device
#    (open any app with a print function)
```

Enable logging to `airprint_bridge.log` (set `LOGGING=1`) for a persistent record of what the script found and fixed during the last install.

---

## 7. Security

### 7.1 Privilege Requirements

> [!CAUTION]
> All three operational modes (`-i`, `-u`, `-t`) require root. The script modifies files in `/etc/cups/`, `/Library/LaunchDaemons/`, and `/usr/local/bin/` — all owned by root. There is no privilege separation or sandboxing. Run only on trusted, administrator-controlled machines.

### 7.2 CUPS Configuration Changes

The script automatically modifies `/etc/cups/cupsd.conf` to add `Allow @LOCAL`. This permits any device on the local subnet to submit print jobs to all shared CUPS printers. Evaluate whether this is appropriate for your network before installing.

- The script creates a backup at `/etc/cups/cupsd.conf.bak` before any modification.
- Changes are validated with `cupsd -t` before CUPS is restarted. If validation fails, the script restores the backup automatically and exits.
- The uninstaller restores the original configuration if the script had modified it.

> [!WARNING]
> Adding `Allow @LOCAL` opens CUPS to all devices on the local network segment, not just iOS devices. On untrusted networks (open Wi-Fi, shared office subnets), this may allow unintended print access. Adjust `/etc/cups/cupsd.conf` manually if you need finer-grained access control after installation.

### 7.3 Firewall Modification

The script uses `socketfilterfw --add` and `--unblockapp` to allow `cupsd` through the macOS Application Firewall. This is only performed if the firewall is already enabled and `cupsd` is not yet allowed. No firewall changes are reverted on uninstall.

### 7.4 Network Exposure

The Bonjour advertisements broadcast on `_ipp._tcp.,_universal` in the `local` mDNS domain. These are visible to all devices on the same local network segment. The advertisement includes the printer's make/model, location, description, and capability flags — no credentials, no authentication tokens.

---

## 8. Troubleshooting

### CUPS config validation fails during install

**Cause:** The `sed` edit to inject `Allow @LOCAL` corrupted the config, or the config had a non-standard structure.

**Symptom:** Script prints "CUPS config invalid. Restoring backup..." and exits.

**Solution:**

```bash
# The script will have restored the backup automatically.
# Manually edit /etc/cups/cupsd.conf to add Allow @LOCAL:
sudo nano /etc/cups/cupsd.conf
# Find the <Location /> block and add:
#   Allow @LOCAL
# Then restart:
sudo launchctl stop org.cups.cupsd && sudo launchctl start org.cups.cupsd
```

> [!WARNING]
> If the backup also appears corrupted, the original CUPS config can be recovered by running: `sudo cupsctl --no-remote-admin` to reset to a known-good state, then re-enabling sharing manually.

---

### LaunchDaemon loads but printers disappear from iOS after reboot

**Cause:** The `airprint_bridge_launcher.sh` script was removed, or `/usr/local/bin` was cleaned by a system tool after install.

**Solution:**

```bash
ls /usr/local/bin/airprint_bridge_launcher.sh
# If missing:
sudo airprint-bridge -u
sudo airprint-bridge -i
```

---

### `launchctl load` fails during install

**Cause:** A plist from a previous install is still loaded, or the plist file has a syntax error.

**Solution:**

```bash
sudo launchctl unload /Library/LaunchDaemons/com.sapireli.airprint_bridge.plist 2>/dev/null
sudo rm /Library/LaunchDaemons/com.sapireli.airprint_bridge.plist 2>/dev/null
sudo airprint-bridge -i
```

---

### dns-sd processes not killed on uninstall

**Cause:** `pgrep -f "dns-sd -R"` found PIDs but `kill` failed (processes owned by root, or already exited).

**Symptom:** Duplicate AirPrint entries may persist on iOS temporarily after uninstall.

**Solution:**

```bash
sudo pkill -f "dns-sd -R"
```

Bonjour advertisements expire from the iOS cache within a few seconds of the advertising process exiting.

> [!NOTE]
> The uninstall function already uses `sudo kill <pid>` for each matching process. If the issue persists, the iOS AirPrint cache will expire naturally within 30-60 seconds — no persistent state is stored on the iOS device.

---

### Firewall re-enables after macOS update and blocks printing

**Cause:** macOS security updates occasionally reset the Application Firewall allow list.

**Solution:**

Run a reinstall to re-apply the firewall exception:

```bash
sudo airprint-bridge -u
sudo airprint-bridge -i
```

---

## 9. Developer Reference

### 9.1 Repository Structure

```
AirPrint_Bridge/
├── airprint_bridge.sh         # Main script (v1.3.2) — all logic lives here
├── Formula/
│   └── airprint-bridge.rb     # Homebrew formula
├── homebrew-airprint-bridge/
│   └── README.md              # Tap README (empty directory in current tree)
├── setup_homebrew_tap.sh      # Dev utility: guides tap setup
├── test_homebrew.sh           # Dev utility: validates formula file structure
├── HOMEBREW_SETUP.md          # Documentation for the Homebrew tap setup
├── LICENSE                    # MIT
└── .gitignore
```

### 9.2 Function Reference

All functions are defined in `airprint_bridge.sh`.

| Function | Lines (approx.) | Description |
|---|---|---|
| `log()` | 3 | Writes to stderr; also appends to `$LOGFILE` if `LOGGING=1` |
| `check_dependencies()` | 8 | Verifies `dns-sd`, `lpstat`, `lpoptions`, `launchctl` are in `$PATH` |
| `check_firewall()` | 15 | Checks firewall state; adds `cupsd` to allow list if needed |
| `check_cups_permissions()` | 35 | Ensures `Allow @LOCAL` in `cupsd.conf` and all-user access on shared printers |
| `revert_cups_config()` | 10 | Restores `cupsd.conf.bak` if the script modified it; used in uninstall and on validation failure |
| `usage()` | 8 | Prints usage message and exits with code 1 |
| `browse_printers()` | 40 | Enumerates shared printers via `lpstat -p` + `lpoptions`/`printers.conf`; populates `PRINTERS[]` array |
| `generate_urf()` | 120 | Calls `lpoptions -l`; parses color mode, print quality, orientation, duplex, media size, media type, and input slot into a URF capabilities string |
| `resolve_printer()` | 50 | Resolves device URI, port, description, location, make/model, calls `generate_urf()`, builds `TXT_RECORDS[]` array |
| `generate_script()` | 25 | Iterates `PRINTERS[]`, calls `resolve_printer()` for each, writes a `dns-sd -R` command per printer into `airprint_bridge_launcher.sh` |
| `generate_plist()` | 40 | Copies launcher to `/usr/local/bin/`, writes LaunchDaemon plist, loads with `launchctl load -w` |
| `uninstall()` | 35 | Calls `revert_cups_config()`, unloads/removes plist, removes launcher, kills `dns-sd` processes |
| `install()` | 15 | Calls `check_dependencies`, `check_cups_permissions`, `check_firewall`, `browse_printers`, `generate_script`, `generate_plist` |
| `test_run()` | 12 | Same as `install()` but runs `generate_script` then executes it in the foreground instead of installing a LaunchDaemon |

### 9.3 URF Capability Mapping

The `generate_urf()` function maps CUPS `lpoptions -l` output to AirPrint URF codes. This is the core translation logic.

#### Color Mode → URF

| CUPS ColorModel value | URF code | `HAS_COLOR` |
|---|---|:---:|
| `*Gray*` or `*Black*` | `W8` | 0 |
| `*RGB*` or `*Color*` | `SRGB24` | 1 |
| `*AdobeRGB*` | `ADOBERGB24` | 1 |
| `*CMYK*` | `CMYK32` | 1 |

#### Print Quality → URF

| CUPS cupsPrintQuality value | URF code |
|---|---|
| `*Draft*` | `PQ1` |
| `*Normal*` | `PQ2` |
| `*High*` | `PQ3` |
| `*Photo*` or `*Best*` | `PQ4` |

#### Duplex Mode → URF

| CUPS Duplex value | URF code | `HAS_DUPLEX` |
|---|---|:---:|
| `*None*`, `*Off*`, `*Simplex*` | `DM1` | 0 |
| `*DuplexNoTumble*` | `DM2` | 1 |
| `*DuplexTumble*` | `DM3` | 1 |
| `*DuplexManual*` | `DM4` | 1 |

#### Media Size → URF

| CUPS PageSize value | URF code |
|---|---|
| `*Letter*` | `MS_LETTER` |
| `*Legal*` | `MS_LEGAL` |
| `*A4*` | `MS_A4` |
| `*A3*` | `MS_A3` |
| `*A5*` | `MS_A5` |
| `*A6*` | `MS_A6` |
| `*B5*` | `MS_B5` |
| `*Executive*` | `MS_EXECUTIVE` |
| `*Tabloid*` | `MS_TABLOID` |
| `*4x6*` or `*4X6*` | `MS_4X6` |
| `*5x7*` or `*5X7*` | `MS_5X7` |

The URF version prefix is always `V1.4`. If no codes are generated (printer reports no recognized capabilities), `URF` is set to `none`.

### 9.4 AirPrint TXT Record Structure

The `resolve_printer()` function assembles the following TXT records for each printer's `dns-sd -R` call:

| TXT key | Value source | Example |
|---|---|---|
| `txtvers` | Hardcoded `1` | `txtvers=1` |
| `qtotal` | Hardcoded `1` | `qtotal=1` |
| `rp` | `printers/<printer_name>` | `rp=printers/HP_LaserJet` |
| `ty` | `printer-make-and-model` from `lpoptions` | `ty=HP LaserJet P1102w` |
| `product` | Same as `ty` wrapped in parentheses | `product=(HP LaserJet P1102w)` |
| `note` | `<location> via <hostname>` | `note=Office via MyMac` |
| `pdl` | Hardcoded | `pdl=application/pdf,image/jpeg,image/urf` |
| `URF` | Output of `generate_urf()` | `URF=V1.4,SRGB24,PQ3,DM1,MS_LETTER` |
| `Color` | `T` if `HAS_COLOR=1`, else `F` | `Color=T` |
| `Duplex` | `T` if `HAS_DUPLEX=1`, else `F` | `Duplex=F` |

The port used in `dns-sd -R` is extracted from the printer's IPP/IPPS device URI. If the URI does not include a port, port `631` (the CUPS default) is used.

### 9.5 Code Patterns and Conventions

- **Locale forcing**: `export LANG=C; export LC_ALL=C` at the top of the script ensures all CUPS tool output is in English, making the `awk` and `grep` parsing patterns reliable regardless of system locale.
- **Job control suppression**: `set +m` disables job control messages (`[1] Done ...`) to keep output clean when background `dns-sd` processes exit.
- **Backup-before-edit**: CUPS config is only modified after creating a `.bak` copy. The `revert_cups_config()` function is called on both normal uninstall and on config validation failure — defensive programming against partial edits.
- **Array-based printer collection**: `PRINTERS[]` and `TXT_RECORDS[]` are Bash arrays. `generate_script()` iterates over `PRINTERS[]` and calls `resolve_printer()` per entry, keeping printer state in global variables (`TXT_RECORDS`, `URF`, `HAS_COLOR`, `HAS_DUPLEX`).
- **`getopts` parsing**: CLI flags are parsed with `getopts ":iutf:h"`. The leading `:` puts `getopts` in silent error mode; unknown options fall through to the `*) usage ;;` case.

### 9.6 Testing

The repository includes two developer utility scripts:

**`test_homebrew.sh`** — validates formula file structure locally before pushing:
- Checks that `Formula/` directory exists
- Checks that `Formula/airprint-bridge.rb` exists
- Checks that `airprint_bridge.sh` exists
- Prints the formula's description, homepage, and version lines

**`setup_homebrew_tap.sh`** — guides through the tap setup workflow:
- Checks Homebrew is installed
- Verifies the `Formula/` directory and formula file exist
- Prints the exact `brew tap` and `brew install` commands

The Homebrew formula itself includes a `test do` block:

```ruby
test do
  output = shell_output("#{bin}/airprint-bridge -h 2>&1", 1)
  assert_match "Usage:", output
end
```

This runs during `brew test airprint-bridge` and confirms the script is installed and executable.

There are no automated unit tests for the main `airprint_bridge.sh` logic. Testing is primarily manual via `sudo airprint-bridge -t`.

### 9.7 Potential Improvements

- **Dynamic printer refresh**: The current architecture requires a full uninstall+reinstall to pick up newly shared or removed printers. A `--refresh` mode that re-runs `browse_printers()` and regenerates the launcher without a full reinstall would improve the user experience.
- **Unattended CUPS config hardening**: `check_cups_permissions()` reverts the CUPS config on uninstall only if `CUPS_CONF_CHANGED=1`. If the user ran the script multiple times or manually edited the config between installs, the backup may not reflect the user's intended state.
- **Idempotent `dns-sd` management**: The uninstall uses `pgrep` to find `dns-sd` processes advertising `$SERVICE`. If multiple unrelated `dns-sd -R` instances exist on the system, the current `grep "$SERVICE"` filter on the process args is the only guard against over-killing.
- **Per-printer logging**: All printers share the same `$LOGFILE`. Adding per-printer log entries with timestamps would make diagnosing intermittent issues easier.
- **Firewall change tracking**: The script does not track whether it modified the firewall, so firewall changes are never reverted on uninstall. A `FIREWALL_CHANGED` flag mirroring `CUPS_CONF_CHANGED` would make cleanup deterministic.

### 9.8 Contributing

The repository is hosted at [https://github.com/sapireli/AirPrint_Bridge](https://github.com/sapireli/AirPrint_Bridge) under the MIT license.

Before submitting a pull request:

1. Test all three modes (`-i`, `-u`, `-t`) on a clean macOS installation.
2. Verify that `test_homebrew.sh` passes without errors.
3. Run `brew test airprint-bridge` if the formula was modified.
4. Ensure `CUPS_CONF_CHANGED` and backup/restore logic is exercised by your change.

---

_Generated May 2026 from sapireli/AirPrint_Bridge at 743085a. AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it. Unofficial; not affiliated with or reviewed by the project._
