mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
print: add CUPS+Avahi virtual printer + spool watcher
Adds Docker assets for a macOS-friendly print-to-PDF pipeline: CUPS (cups-pdf) writes to a shared spool and a watcher uploads PDFs to Omnivore via the existing GraphQL signed-upload flow.
This commit is contained in:
parent
99c16be31a
commit
277300ef04
7 changed files with 361 additions and 0 deletions
52
packages/print/README.md
Normal file
52
packages/print/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Omnivore Print-to-PDF (macOS)
|
||||
|
||||
This adds a local IPP “virtual printer” (running in Docker) that writes PDFs to a shared spool volume, then uploads each PDF into your Omnivore library as a File/PDF item via the normal GraphQL signed-upload flow.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Self-hosting stack running via docker compose
|
||||
- A dedicated watcher env file:
|
||||
- `self-hosting/docker-compose/print-watcher/.env` (copy from `.env.example`)
|
||||
- `print-server` runs on its own LAN IP (macvlan) for Bonjour/mDNS. You must set:
|
||||
- `PRINT_MACVLAN_PARENT` (host interface name, e.g. `eth0`)
|
||||
- `PRINT_SERVER_IP` (an unused LAN IP, e.g. `10.0.1.250`)
|
||||
- Optional: `PRINT_MACVLAN_SUBNET`, `PRINT_MACVLAN_GATEWAY`
|
||||
|
||||
## Start services
|
||||
|
||||
From `self-hosting/docker-compose/` (canonical) or `self-hosting/docker-compose/self-build/`:
|
||||
|
||||
```sh
|
||||
COMPOSE_PROFILES=print docker compose up -d --build
|
||||
```
|
||||
|
||||
This exposes IPP on:
|
||||
|
||||
- `PRINT_SERVER_IP:631` (CUPS on the print server container’s LAN IP)
|
||||
- Bonjour/mDNS advertisement (should appear automatically in macOS “Add Printer…”)
|
||||
|
||||
## Add the printer in macOS
|
||||
|
||||
Option A (preferred): use Bonjour discovery
|
||||
|
||||
1. System Settings → Printers & Scanners → Add Printer…
|
||||
2. Default tab → select `OmnivorePDF` (or similar) when it appears.
|
||||
|
||||
Option B: manual IPP URL
|
||||
|
||||
1. System Settings → Printers & Scanners → Add Printer…
|
||||
2. IP tab
|
||||
3. Address: `PRINT_SERVER_IP`
|
||||
4. Protocol: `Internet Printing Protocol - IPP`
|
||||
5. Queue: `printers/OmnivorePDF`
|
||||
|
||||
When you print to this printer, PDFs should appear in Omnivore automatically.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Verify the services:
|
||||
- `docker compose logs -f print-server`
|
||||
- `docker compose logs -f print-watcher`
|
||||
- If uploads fail, confirm:
|
||||
- `OMNIVORE_API_KEY` is present in your compose `.env`.
|
||||
- `OMNIVORE_GRAPHQL_ENDPOINT` is reachable from the watcher container (defaults to `http://api:8080/api/graphql`).
|
||||
24
packages/print/ippserver/Dockerfile
Normal file
24
packages/print/ippserver/Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
cups \
|
||||
cups-pdf \
|
||||
avahi-daemon \
|
||||
avahi-utils \
|
||||
libnss-mdns \
|
||||
dbus \
|
||||
ghostscript \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENV PRINT_SPOOL_DIR=/spool/incoming
|
||||
ENV PRINTER_NAME=OmnivorePDF
|
||||
|
||||
VOLUME ["/spool"]
|
||||
|
||||
EXPOSE 631/tcp 5353/udp
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
103
packages/print/ippserver/entrypoint.sh
Normal file
103
packages/print/ippserver/entrypoint.sh
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SPOOL_DIR="${PRINT_SPOOL_DIR:-/spool/incoming}"
|
||||
PRINTER_NAME="${PRINTER_NAME:-OmnivorePDF}"
|
||||
|
||||
mkdir -p "$SPOOL_DIR"
|
||||
|
||||
# Configure cups-pdf output directory.
|
||||
if [[ -f /etc/cups/cups-pdf.conf ]]; then
|
||||
# Default config ships with "#Out ...". Un-comment (or replace) it so output goes
|
||||
# into the shared spool volume.
|
||||
sed -i -E "s|^[[:space:]]*#?[[:space:]]*Out[[:space:]]+.*$|Out ${SPOOL_DIR}|" /etc/cups/cups-pdf.conf || true
|
||||
fi
|
||||
|
||||
# Configure CUPS to listen on LAN + allow printing + advertise via DNS-SD.
|
||||
if [[ -f /etc/cups/cupsd.conf ]]; then
|
||||
tmp="$(mktemp)"
|
||||
awk '
|
||||
BEGIN { in_root=0; found_printers=0 }
|
||||
$0 == "Listen localhost:631" { print "Port 631"; next }
|
||||
$0 == "<Location />" {
|
||||
print "<Location />"
|
||||
print " Order allow,deny"
|
||||
print " Allow all"
|
||||
print "</Location>"
|
||||
in_root=1
|
||||
next
|
||||
}
|
||||
in_root {
|
||||
if ($0 == "</Location>") in_root=0
|
||||
next
|
||||
}
|
||||
$0 == "<Location /printers>" { found_printers=1 }
|
||||
{ print }
|
||||
END {
|
||||
if (!found_printers) {
|
||||
print ""
|
||||
print "<Location /printers>"
|
||||
print " Order allow,deny"
|
||||
print " Allow all"
|
||||
print "</Location>"
|
||||
}
|
||||
}
|
||||
' /etc/cups/cupsd.conf > "$tmp"
|
||||
mv "$tmp" /etc/cups/cupsd.conf
|
||||
|
||||
if ! grep -q '^ServerAlias \\*$' /etc/cups/cupsd.conf; then
|
||||
echo 'ServerAlias *' >> /etc/cups/cupsd.conf
|
||||
fi
|
||||
if ! grep -q '^DefaultShared Yes$' /etc/cups/cupsd.conf; then
|
||||
echo 'DefaultShared Yes' >> /etc/cups/cupsd.conf
|
||||
fi
|
||||
if ! grep -q '^Browsing On$' /etc/cups/cupsd.conf; then
|
||||
echo 'Browsing On' >> /etc/cups/cupsd.conf
|
||||
fi
|
||||
if ! grep -q '^BrowseLocalProtocols dnssd$' /etc/cups/cupsd.conf; then
|
||||
echo 'BrowseLocalProtocols dnssd' >> /etc/cups/cupsd.conf
|
||||
fi
|
||||
fi
|
||||
|
||||
# Avahi requires a system dbus.
|
||||
mkdir -p /run/dbus
|
||||
dbus-daemon --system --fork
|
||||
|
||||
# Ensure avahi runs without chroot (common in containers).
|
||||
mkdir -p /run/avahi-daemon
|
||||
avahi-daemon --no-chroot --daemonize || true
|
||||
|
||||
# Start CUPS in the background to allow printer setup.
|
||||
cupsd -f &
|
||||
CUPSD_PID=$!
|
||||
|
||||
# Wait briefly for cupsd socket.
|
||||
for _ in $(seq 1 20); do
|
||||
if lpstat -r >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# Create the PDF printer if missing.
|
||||
if ! lpstat -p "${PRINTER_NAME}" >/dev/null 2>&1; then
|
||||
PPD="$(find /usr/share -maxdepth 6 -type f -iname '*cups-pdf*.ppd' -print -quit 2>/dev/null || true)"
|
||||
if [[ -z "$PPD" ]]; then
|
||||
PPD="$(find /usr/share -maxdepth 6 -type f -iname '*CUPS-PDF*.ppd' -print -quit 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ -n "$PPD" && -f "$PPD" ]]; then
|
||||
lpadmin -p "${PRINTER_NAME}" -E -v "cups-pdf:/" -P "$PPD" -o printer-is-shared=true || true
|
||||
else
|
||||
# If no PPD is found, we can still create the queue but driver selection may vary by client.
|
||||
lpadmin -p "${PRINTER_NAME}" -E -v "cups-pdf:/" -m raw -o printer-is-shared=true || true
|
||||
fi
|
||||
|
||||
cupsenable "${PRINTER_NAME}" || true
|
||||
cupsaccept "${PRINTER_NAME}" || true
|
||||
fi
|
||||
|
||||
echo "CUPS ready. Printer: ${PRINTER_NAME}. Spool: ${SPOOL_DIR}"
|
||||
|
||||
# Keep the container running.
|
||||
wait "${CUPSD_PID}"
|
||||
6
packages/print/package.json
Normal file
6
packages/print/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@omnivore/print",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Docker-based print-to-Omnivore pipeline assets (IPP server + watcher)."
|
||||
}
|
||||
17
packages/print/watcher/Dockerfile
Normal file
17
packages/print/watcher/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache bash curl jq inotify-tools coreutils ca-certificates
|
||||
|
||||
COPY omnivore-print-upload.sh /usr/local/bin/omnivore-print-upload
|
||||
COPY omnivore-print-watch.sh /usr/local/bin/omnivore-print-watch
|
||||
|
||||
RUN chmod +x /usr/local/bin/omnivore-print-upload /usr/local/bin/omnivore-print-watch
|
||||
|
||||
ENV OMNIVORE_GRAPHQL_ENDPOINT=http://api:8080/api/graphql
|
||||
ENV PRINT_SPOOL_DIR=/spool/incoming
|
||||
ENV PRINT_PROCESSING_DIR=/spool/processing
|
||||
ENV PRINT_DONE_DIR=/spool/done
|
||||
ENV PRINT_FAILED_DIR=/spool/failed
|
||||
|
||||
CMD ["/usr/local/bin/omnivore-print-watch"]
|
||||
|
||||
100
packages/print/watcher/omnivore-print-upload.sh
Normal file
100
packages/print/watcher/omnivore-print-upload.sh
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: omnivore-print-upload <pdf-path>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PDF_PATH="$1"
|
||||
|
||||
if [[ ! -f "$PDF_PATH" ]]; then
|
||||
echo "file not found: $PDF_PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "${PDF_PATH,,}" != *.pdf ]]; then
|
||||
echo "not a pdf: $PDF_PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -z "${OMNIVORE_API_KEY:-}" ]]; then
|
||||
echo "Missing OMNIVORE_API_KEY" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
GRAPHQL_ENDPOINT="${OMNIVORE_GRAPHQL_ENDPOINT:-http://api:8080/api/graphql}"
|
||||
|
||||
base="$(basename "$PDF_PATH")"
|
||||
encoded_base="$(printf '%s' "$base" | jq -sRr @uri)"
|
||||
file_url="file:///omnivore-print/${encoded_base}"
|
||||
|
||||
UPLOAD_MUTATION='mutation UploadFileRequest($input: UploadFileRequestInput!) { uploadFileRequest(input:$input) { __typename ... on UploadFileRequestSuccess { id uploadSignedUrl createdPageId } ... on UploadFileRequestError { errorCodes } } }'
|
||||
SAVE_MUTATION='mutation SaveFile($input: SaveFileInput!) { saveFile(input:$input) { __typename ... on SaveSuccess { url clientRequestId } ... on SaveError { errorCodes message } } }'
|
||||
|
||||
graphql() {
|
||||
local payload="$1"
|
||||
curl -sS \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Omnivore-Authorization: ${OMNIVORE_API_KEY}" \
|
||||
-H "User-Agent: omnivore-print-watcher/1.0" \
|
||||
--data "$payload" \
|
||||
"$GRAPHQL_ENDPOINT"
|
||||
}
|
||||
|
||||
upload_payload="$(
|
||||
jq -nc \
|
||||
--arg query "$UPLOAD_MUTATION" \
|
||||
--arg url "$file_url" \
|
||||
--arg contentType "application/pdf" \
|
||||
'{query:$query, variables:{input:{url:$url, contentType:$contentType, createPageEntry:true}}}'
|
||||
)"
|
||||
|
||||
upload_resp="$(graphql "$upload_payload")"
|
||||
|
||||
if [[ "$(jq -r '.errors | length // 0' <<<"$upload_resp")" != "0" ]]; then
|
||||
echo "uploadFileRequest graphql errors: $(jq -c '.errors' <<<"$upload_resp")" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
upload_typename="$(jq -r '.data.uploadFileRequest.__typename // empty' <<<"$upload_resp")"
|
||||
if [[ "$upload_typename" != "UploadFileRequestSuccess" ]]; then
|
||||
echo "uploadFileRequest failed: $(jq -c '.data.uploadFileRequest' <<<"$upload_resp")" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
upload_file_id="$(jq -r '.data.uploadFileRequest.id' <<<"$upload_resp")"
|
||||
upload_signed_url="$(jq -r '.data.uploadFileRequest.uploadSignedUrl' <<<"$upload_resp")"
|
||||
created_page_id="$(jq -r '.data.uploadFileRequest.createdPageId' <<<"$upload_resp")"
|
||||
|
||||
curl -sS --fail \
|
||||
-X PUT \
|
||||
-H "Content-Type: application/pdf" \
|
||||
--data-binary @"$PDF_PATH" \
|
||||
"$upload_signed_url" >/dev/null
|
||||
|
||||
save_payload="$(
|
||||
jq -nc \
|
||||
--arg query "$SAVE_MUTATION" \
|
||||
--arg url "$file_url" \
|
||||
--arg source "print" \
|
||||
--arg clientRequestId "$created_page_id" \
|
||||
--arg uploadFileId "$upload_file_id" \
|
||||
'{query:$query, variables:{input:{url:$url, source:$source, clientRequestId:$clientRequestId, uploadFileId:$uploadFileId}}}'
|
||||
)"
|
||||
|
||||
save_resp="$(graphql "$save_payload")"
|
||||
|
||||
if [[ "$(jq -r '.errors | length // 0' <<<"$save_resp")" != "0" ]]; then
|
||||
echo "saveFile graphql errors: $(jq -c '.errors' <<<"$save_resp")" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
save_typename="$(jq -r '.data.saveFile.__typename // empty' <<<"$save_resp")"
|
||||
if [[ "$save_typename" != "SaveSuccess" ]]; then
|
||||
echo "saveFile failed: $(jq -c '.data.saveFile' <<<"$save_resp")" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "uploaded ${base} -> pageId=${created_page_id}"
|
||||
|
||||
59
packages/print/watcher/omnivore-print-watch.sh
Normal file
59
packages/print/watcher/omnivore-print-watch.sh
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INCOMING_DIR="${PRINT_SPOOL_DIR:-/spool/incoming}"
|
||||
PROCESSING_DIR="${PRINT_PROCESSING_DIR:-/spool/processing}"
|
||||
DONE_DIR="${PRINT_DONE_DIR:-/spool/done}"
|
||||
FAILED_DIR="${PRINT_FAILED_DIR:-/spool/failed}"
|
||||
|
||||
mkdir -p "$INCOMING_DIR" "$PROCESSING_DIR" "$DONE_DIR" "$FAILED_DIR"
|
||||
|
||||
unique_dest() {
|
||||
local dir="$1"
|
||||
local base="$2"
|
||||
local dest="${dir}/${base}"
|
||||
if [[ ! -e "$dest" ]]; then
|
||||
echo "$dest"
|
||||
return
|
||||
fi
|
||||
local ts
|
||||
ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
echo "${dir}/${ts}.$$-${base}"
|
||||
}
|
||||
|
||||
process_one() {
|
||||
local src="$1"
|
||||
if [[ ! -f "$src" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "${src,,}" != *.pdf ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local base processing done failed
|
||||
base="$(basename "$src")"
|
||||
processing="$(unique_dest "$PROCESSING_DIR" "$base")"
|
||||
mv "$src" "$processing"
|
||||
|
||||
if /usr/local/bin/omnivore-print-upload "$processing"; then
|
||||
done="$(unique_dest "$DONE_DIR" "$(basename "$processing")")"
|
||||
mv "$processing" "$done"
|
||||
else
|
||||
failed="$(unique_dest "$FAILED_DIR" "$(basename "$processing")")"
|
||||
mv "$processing" "$failed"
|
||||
fi
|
||||
}
|
||||
|
||||
while IFS= read -r -d '' f; do
|
||||
process_one "$f"
|
||||
done < <(find "$INCOMING_DIR" -type f \( -iname '*.pdf' -o -iname '*.PDF' \) -print0 2>/dev/null || true)
|
||||
|
||||
echo "watching $INCOMING_DIR for printed PDFs..."
|
||||
|
||||
inotifywait -m -r \
|
||||
-e close_write \
|
||||
-e moved_to \
|
||||
--format '%w%f' \
|
||||
"$INCOMING_DIR" | while read -r path; do
|
||||
process_one "$path"
|
||||
done
|
||||
Loading…
Reference in a new issue