diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -135,6 +135,126 @@ dependencies = [
  "libloading",
 ]
 
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix 1.1.5",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix 1.1.5",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix 1.1.5",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
 [[package]]
 name = "async-trait"
 version = "0.1.92"
@@ -254,6 +374,19 @@ dependencies = [
  "objc2 0.6.5",
 ]
 
+[[package]]
+name = "blocking"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
 [[package]]
 name = "brotli"
 version = "8.0.4"
@@ -623,6 +756,7 @@ dependencies = [
  "tempfile",
  "winprint",
  "winsafe",
+ "zbus",
 ]
 
 [[package]]
@@ -1032,6 +1166,12 @@ dependencies = [
  "simdutf8",
 ]
 
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
 [[package]]
 name = "enum-map"
 version = "2.7.3"
@@ -1052,6 +1192,27 @@ dependencies = [
  "syn 2.0.119",
 ]
 
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
 [[package]]
 name = "epaint"
 version = "0.36.2"
@@ -1114,6 +1275,26 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
 [[package]]
 name = "fastrand"
 version = "2.5.0"
@@ -1299,6 +1480,19 @@ version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
 
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
 [[package]]
 name = "futures-sink"
 version = "0.3.34"
@@ -2209,6 +2403,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "mime"
 version = "0.3.17"
@@ -2758,6 +2961,22 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
 [[package]]
 name = "parking_lot"
 version = "0.12.5"
@@ -2915,6 +3134,17 @@ version = "0.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
 
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
 [[package]]
 name = "pkg-config"
 version = "0.3.34"
@@ -3667,6 +3897,17 @@ dependencies = [
  "serde_json",
 ]
 
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
 [[package]]
 name = "serde_spanned"
 version = "1.1.2"
@@ -3716,6 +3957,16 @@ version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
 
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
 [[package]]
 name = "signature"
 version = "2.2.0"
@@ -4561,6 +4812,17 @@ version = "1.20.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
 
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "unicode-bidi"
 version = "0.3.18"
@@ -4681,6 +4943,11 @@ name = "uuid"
 version = "1.27.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
 
 [[package]]
 name = "vello_common"
@@ -5736,6 +6003,76 @@ dependencies = [
  "synstructure",
 ]
 
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix 1.1.5",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "zerocopy"
 version = "0.8.62"
@@ -5856,3 +6193,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
 dependencies = [
  "zune-core",
 ]
+
+[[package]]
+name = "zvariant"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.6",
+ "winnow",
+]
diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml
--- a/crates/print/Cargo.toml
+++ b/crates/print/Cargo.toml
@@ -31,6 +31,13 @@ roxmltree = "0.20"
 # Inflating the print-ready PDF's cross-reference and object streams (src/sheets.rs).
 miniz_oxide = "0.8"
 
+[target.'cfg(all(unix, not(target_arch = "wasm32")))'.dependencies]
+# The XDG print portal on the session bus, for installs with no CUPS server to talk IPP to
+# (MIT OR Apache-2.0); pinned to its major version, review before bumping.
+zbus = "5"
+# The job's private file, handed to the portal as a file descriptor.
+tempfile = "3"
+
 [dev-dependencies]
 tempfile = "3"
 roxmltree = "0.20"
diff --git a/crates/print/src/ipp.rs b/crates/print/src/ipp.rs
new file mode 100644
--- /dev/null
+++ b/crates/print/src/ipp.rs
@@ -0,0 +1,519 @@
+//! IPP/1.1 straight to the local CUPS server: printer discovery (`CUPS-Get-Printers`,
+//! `CUPS-Get-Default`) and job submission (`Print-Job`) over HTTP/1.1, for when `lpstat`/`lp`
+//! are missing (a sandboxed install, where the print dialog would otherwise have no printers).
+//! Implemented from the public IPP encoding (RFC 2910/8010) and semantics (RFC 8011) and
+//! CUPS's IPP documentation; the scheduler is reached on its socket or loopback port.
+
+use super::{Duplex, Error, Job, Printer, Scaling};
+use std::io::{Read, Write};
+use std::net::{SocketAddr, TcpStream};
+use std::os::unix::net::UnixStream;
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::time::Duration;
+
+/// Where the local scheduler listens: its socket, else its loopback port.
+const CUPS_SOCKET: &str = "/run/cups/cups.sock";
+
+/// Caps on what the server may hand back: a reply is read into memory before it is parsed.
+const MAX_BODY: usize = 1 << 20;
+const MAX_HEADERS: usize = 8 << 10;
+const MAX_PRINTERS: usize = 256;
+const MAX_NAME: usize = 256;
+/// A job's title on the wire; IPP values are u16-length, and no real title is this long.
+const MAX_JOB_NAME: usize = 255;
+
+/// A wedged scheduler must not hang the dialog; a job may carry megabytes and is given longer.
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
+const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
+const JOB_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// IPP operation codes: the standard Print-Job, and CUPS's discovery extensions.
+const OPERATION_PRINT_JOB: u16 = 0x0002;
+const OPERATION_CUPS_GET_DEFAULT: u16 = 0x4001;
+const OPERATION_CUPS_GET_PRINTERS: u16 = 0x4002;
+
+/// IPP value tags and group delimiters (RFC 8010 §5.1); the ones this client sends or reads.
+const GROUP_OPERATION: u8 = 0x01;
+const GROUP_JOB: u8 = 0x02;
+const GROUP_END: u8 = 0x03;
+const GROUP_PRINTER: u8 = 0x04;
+const TAG_INTEGER: u8 = 0x21;
+const TAG_BOOLEAN: u8 = 0x22;
+const TAG_NAME: u8 = 0x42;
+const TAG_KEYWORD: u8 = 0x44;
+const TAG_URI: u8 = 0x45;
+const TAG_CHARSET: u8 = 0x47;
+const TAG_LANGUAGE: u8 = 0x48;
+const TAG_MIME: u8 = 0x49;
+
+static REQUEST_ID: AtomicU32 = AtomicU32::new(1);
+
+/// How the scheduler is reached: its unix socket, else its loopback port.
+pub(crate) enum Server {
+    Unix(PathBuf),
+    Tcp(SocketAddr),
+}
+
+fn endpoints() -> [Server; 2] {
+    [Server::Unix(PathBuf::from(CUPS_SOCKET)), Server::Tcp(([127, 0, 0, 1], 631).into())]
+}
+
+/// Whether the local CUPS server is reachable: its socket, else its loopback port. A missing
+/// scheduler is a normal `false`, not an error.
+pub fn cups_reachable() -> bool {
+    endpoints().iter().any(reachable)
+}
+
+pub(crate) fn reachable(server: &Server) -> bool {
+    connect(server).is_ok()
+}
+
+fn connect(server: &Server) -> std::io::Result<Stream> {
+    match server {
+        Server::Unix(path) => UnixStream::connect(path).map(Stream::Unix),
+        // A loopback connect either answers promptly or the server is not there.
+        Server::Tcp(addr) => TcpStream::connect_timeout(addr, CONNECT_TIMEOUT).map(|s| {
+            let _ = s.set_nodelay(true);
+            Stream::Tcp(s)
+        }),
+    }
+}
+
+/// A connection to the scheduler.
+enum Stream {
+    Unix(UnixStream),
+    Tcp(TcpStream),
+}
+
+impl Stream {
+    fn set_timeouts(&self, timeout: Duration) -> std::io::Result<()> {
+        let (read, write) = match self {
+            Stream::Unix(s) => (s.set_read_timeout(Some(timeout)), s.set_write_timeout(Some(timeout))),
+            Stream::Tcp(s) => (s.set_read_timeout(Some(timeout)), s.set_write_timeout(Some(timeout))),
+        };
+        read.and(write)
+    }
+}
+
+impl Read for Stream {
+    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
+        match self {
+            Stream::Unix(s) => s.read(buf),
+            Stream::Tcp(s) => s.read(buf),
+        }
+    }
+}
+
+impl Write for Stream {
+    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+        match self {
+            Stream::Unix(s) => s.write(buf),
+            Stream::Tcp(s) => s.write(buf),
+        }
+    }
+    fn flush(&mut self) -> std::io::Result<()> {
+        match self {
+            Stream::Unix(s) => s.flush(),
+            Stream::Tcp(s) => s.flush(),
+        }
+    }
+}
+
+/// The server's queues (CUPS-Get-Printers) and its default destination (CUPS-Get-Default), for
+/// the Print dialog when `lpstat` is missing. A default the queue list lacks is appended, so
+/// the dialog always has a selection to restore; a default that cannot be named still leaves
+/// the queues usable.
+pub fn printers() -> Result<Vec<Printer>, Error> {
+    printers_at(&endpoints())
+}
+
+pub(crate) fn printers_at(endpoints: &[Server]) -> Result<Vec<Printer>, Error> {
+    let queues = discover(endpoints, OPERATION_CUPS_GET_PRINTERS)?;
+    let default = discover(endpoints, OPERATION_CUPS_GET_DEFAULT).ok().and_then(|names| names.into_iter().next());
+    let mut printers: Vec<Printer> = Vec::new();
+    for name in queues {
+        if printers.len() >= MAX_PRINTERS {
+            break;
+        }
+        if !printers.iter().any(|p| p.name == name) {
+            printers.push(Printer { default: default.as_deref() == Some(name.as_str()), name, two_sided: None });
+        }
+    }
+    if let Some(name) = default
+        && !printers.iter().any(|p| p.name == name)
+    {
+        printers.push(Printer { name, default: true, two_sided: None });
+    }
+    Ok(printers)
+}
+
+/// The printer names a discovery reply carries: every queue (CUPS-Get-Printers), or the default
+/// destination (CUPS-Get-Default).
+fn discover(endpoints: &[Server], operation: u16) -> Result<Vec<String>, Error> {
+    let reply = post(endpoints, "/", &discovery_request(operation), DISCOVERY_TIMEOUT, true)?;
+    let parsed = parse_reply(&reply)?;
+    if status_ok(parsed.status) { Ok(parsed.names) } else { Err(refused(parsed.status)) }
+}
+
+/// Submit the PDF as a Print-Job to the printer (the job's, else the server's default); the
+/// reply is the job message, like `lp` prints it (`request id is Office-12`). The driver's own
+/// job options (PPD keywords) are not sent on this path: they have no IPP equivalents without
+/// reading the printer's capabilities, which is another request.
+pub fn print_job(pdf: &[u8], job: &Job) -> Result<String, Error> {
+    print_job_at(&endpoints(), pdf, job)
+}
+
+pub(crate) fn print_job_at(endpoints: &[Server], pdf: &[u8], job: &Job) -> Result<String, Error> {
+    let queue = match &job.printer {
+        Some(name) => name.clone(),
+        None => discover(endpoints, OPERATION_CUPS_GET_DEFAULT)?
+            .into_iter()
+            .next()
+            .ok_or_else(|| Error::Spool("the print server has no default printer; choose one in the dialog".into()))?,
+    };
+    if !is_queue_name(&queue) {
+        return Err(Error::Spool(format!("not a printer name: {queue}")));
+    }
+    let reply = post(endpoints, &format!("/printers/{queue}"), &print_job_request(&queue, pdf, job), JOB_TIMEOUT, false)?;
+    let parsed = parse_reply(&reply)?;
+    if !status_ok(parsed.status) {
+        return Err(refused(parsed.status));
+    }
+    Ok(match parsed.job_id {
+        Some(id) => format!("request id is {queue}-{id}"),
+        None => format!("the job was accepted by {queue}"),
+    })
+}
+
+/// A CUPS queue name: ASCII letters, digits and `_`, `-`, `.` — what the scheduler accepts, and
+/// what cannot break the printer URI's path.
+fn is_queue_name(name: &str) -> bool {
+    !name.is_empty() && name.len() <= MAX_NAME && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
+}
+
+/// One IPP request and its reply body. Discovery may try each endpoint in turn; a job is given
+/// to the first endpoint that accepts a connection, once: a retry could print it twice.
+pub(crate) fn post(endpoints: &[Server], path: &str, body: &[u8], timeout: Duration, retry: bool) -> Result<Vec<u8>, Error> {
+    let mut failed: Option<Error> = None;
+    for server in endpoints {
+        let mut stream = match connect(server) {
+            Ok(stream) => stream,
+            Err(e) => {
+                failed = Some(Error::Spool(format!("the print server could not be reached: {e}")));
+                continue;
+            }
+        };
+        match exchange(&mut stream, path, body, timeout) {
+            Ok(reply) => return Ok(reply),
+            Err(e) => {
+                failed = Some(e);
+                if !retry {
+                    break;
+                }
+            }
+        }
+    }
+    Err(failed.unwrap_or_else(|| Error::Spool("the print server could not be reached".into())))
+}
+
+/// Send one IPP request and read the reply's body.
+fn exchange(stream: &mut Stream, path: &str, body: &[u8], timeout: Duration) -> Result<Vec<u8>, Error> {
+    stream.set_timeouts(timeout).map_err(|e| Error::Spool(format!("the print server could not be set up: {e}")))?;
+    let head = format!(
+        "POST {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+        body.len()
+    );
+    stream
+        .write_all(head.as_bytes())
+        .and_then(|()| stream.write_all(body))
+        .and_then(|()| stream.flush())
+        .map_err(|e| Error::Spool(format!("the request could not be sent to the print server: {e}")))?;
+    read_reply(stream)
+}
+
+/// The reply's body: a strict status line and headers, then exactly `Content-Length` bytes. CUPS
+/// answers with Content-Length; a chunked or unterminated reply is refused, not guessed.
+fn read_reply(stream: &mut Stream) -> Result<Vec<u8>, Error> {
+    let mut head = Vec::new();
+    let mut byte = [0u8; 1];
+    loop {
+        if head.len() >= MAX_HEADERS {
+            return Err(Error::Spool("the print server sent a header block that is too large".into()));
+        }
+        match stream.read(&mut byte) {
+            Ok(0) => return Err(Error::Spool("the print server closed the connection before sending its reply".into())),
+            Ok(_) => head.push(byte[0]),
+            Err(e) => return Err(Error::Spool(format!("the print server's reply could not be read: {e}"))),
+        }
+        if head.ends_with(b"\r\n\r\n") {
+            break;
+        }
+    }
+    let head = String::from_utf8_lossy(&head);
+    let Some(status_line) = head.lines().next() else { return Err(Error::Spool("the print server sent an unreadable reply".into())) };
+    let code = status_line
+        .strip_prefix("HTTP/")
+        .and_then(|rest| rest.split_once(' '))
+        .and_then(|(_, code)| code.split_whitespace().next())
+        .and_then(|code| code.parse::<u16>().ok())
+        .ok_or_else(|| Error::Spool("the print server sent an unreadable reply".into()))?;
+    match code {
+        200..=299 => {}
+        401 => return Err(Error::Spool("the print server requires authentication".into())),
+        426 => return Err(Error::Spool("the print server only accepts encrypted connections".into())),
+        _ => return Err(Error::Spool(format!("the print server refused the request (HTTP {code})"))),
+    }
+    let mut content_length: Option<usize> = None;
+    for line in head.lines().skip(1) {
+        // The empty line ends the header block; any other line without a colon is malformed.
+        if line.is_empty() {
+            continue;
+        }
+        let Some((name, value)) = line.split_once(':') else {
+            return Err(Error::Spool("the print server sent an unreadable header".into()));
+        };
+        match name.trim().to_ascii_lowercase().as_str() {
+            "content-length" => content_length = value.trim().parse::<usize>().ok(),
+            // Only `identity` is not a transfer encoding to be decoded.
+            "transfer-encoding" if !value.trim().eq_ignore_ascii_case("identity") => {
+                return Err(Error::Spool("the print server sent a chunked reply".into()));
+            }
+            _ => {}
+        }
+    }
+    let Some(len) = content_length else { return Err(Error::Spool("the print server sent no Content-Length".into())) };
+    if len > MAX_BODY {
+        return Err(Error::Spool(format!("the print server's reply is too large ({len} bytes)")));
+    }
+    let mut body = vec![0u8; len];
+    stream.read_exact(&mut body).map_err(|e| Error::Spool(format!("the print server's reply was cut short: {e}")))?;
+    Ok(body)
+}
+
+/// An IPP request being built: a version, an operation and a request id, then attributes, then
+/// the document.
+struct Request {
+    bytes: Vec<u8>,
+}
+
+impl Request {
+    fn new(operation: u16) -> Self {
+        // IPP 1.1: version, operation, a request id (any nonzero value).
+        let mut bytes = vec![1, 1];
+        bytes.extend(operation.to_be_bytes());
+        bytes.extend(REQUEST_ID.fetch_add(1, Ordering::Relaxed).max(1).to_be_bytes());
+        Request { bytes }
+    }
+
+    fn group(&mut self, tag: u8) {
+        self.bytes.push(tag);
+    }
+
+    fn end(&mut self) {
+        self.bytes.push(GROUP_END);
+    }
+
+    fn attribute(&mut self, tag: u8, name: &str, value: &[u8]) {
+        let (Ok(name_len), Ok(value_len)) = (u16::try_from(name.len()), u16::try_from(value.len())) else {
+            return;
+        };
+        self.bytes.push(tag);
+        self.bytes.extend(name_len.to_be_bytes());
+        self.bytes.extend(name.as_bytes());
+        self.bytes.extend(value_len.to_be_bytes());
+        self.bytes.extend(value);
+    }
+
+    /// An additional value of the previous attribute (a `1setOf`): the name is empty.
+    fn additional(&mut self, tag: u8, value: &str) {
+        self.attribute(tag, "", value.as_bytes());
+    }
+}
+
+/// A discovery request: the operation's required attributes, then the names to be answered with.
+fn discovery_request(operation: u16) -> Vec<u8> {
+    let mut r = Request::new(operation);
+    r.group(GROUP_OPERATION);
+    r.attribute(TAG_CHARSET, "attributes-charset", b"utf-8");
+    r.attribute(TAG_LANGUAGE, "attributes-natural-language", b"en");
+    r.attribute(TAG_URI, "printer-uri", b"ipp://localhost/");
+    r.attribute(TAG_KEYWORD, "requested-attributes", b"printer-name");
+    r.additional(TAG_KEYWORD, "printer-state");
+    r.additional(TAG_KEYWORD, "printer-is-accepting-jobs");
+    r.end();
+    r.bytes
+}
+
+/// A Print-Job request for `queue`: the job's options as IPP attributes, the PDF as the document
+/// data that follows the end of attributes.
+fn print_job_request(queue: &str, pdf: &[u8], job: &Job) -> Vec<u8> {
+    let mut r = Request::new(OPERATION_PRINT_JOB);
+    r.group(GROUP_OPERATION);
+    r.attribute(TAG_CHARSET, "attributes-charset", b"utf-8");
+    r.attribute(TAG_LANGUAGE, "attributes-natural-language", b"en");
+    r.attribute(TAG_URI, "printer-uri", format!("ipp://localhost/printers/{queue}").as_bytes());
+    r.attribute(TAG_NAME, "job-name", truncate_utf8(&job.title, MAX_JOB_NAME).as_bytes());
+    r.attribute(TAG_MIME, "document-format", b"application/pdf");
+    r.group(GROUP_JOB);
+    r.attribute(TAG_INTEGER, "copies", &job.copies.clamp(1, 999).to_be_bytes());
+    r.attribute(TAG_BOOLEAN, "collate", &[u8::from(job.collate)]);
+    if let Some(duplex) = job.duplex {
+        r.attribute(
+            TAG_KEYWORD,
+            "sides",
+            match duplex {
+                Duplex::Off => "one-sided",
+                Duplex::LongEdge => "two-sided-long-edge",
+                Duplex::ShortEdge => "two-sided-short-edge",
+            }
+            .as_bytes(),
+        );
+    }
+    if job.grayscale {
+        r.attribute(TAG_KEYWORD, "print-color-mode", b"monochrome");
+    }
+    // PWG 5100.13 §6.2.5: `none` keeps every sheet at its size, `auto-fit` shrinks only
+    // oversized ones — the same choices `lp`'s fit-to-page and print-scaling options make.
+    r.attribute(
+        TAG_KEYWORD,
+        "print-scaling",
+        match job.scaling {
+            Scaling::Exact => "none",
+            Scaling::ShrinkToFit => "auto-fit",
+        }
+        .as_bytes(),
+    );
+    r.end();
+    r.bytes.extend(pdf);
+    r.bytes
+}
+
+/// The string cut to at most `max` bytes, at a character boundary.
+fn truncate_utf8(s: &str, max: usize) -> &str {
+    if s.len() <= max {
+        return s;
+    }
+    let mut end = max;
+    while !s.is_char_boundary(end) {
+        end -= 1;
+    }
+    // `end` is a checked boundary; nothing is the only name that cannot be cut.
+    s.get(..end).unwrap_or_default()
+}
+
+/// The parsed parts of a reply: the IPP status, the printer names in its printer groups, and a
+/// submission's job id.
+struct Reply {
+    status: u16,
+    names: Vec<String>,
+    job_id: Option<u32>,
+}
+
+/// A cursor over a reply: `None` at the end or past a length, never an over-read.
+struct Reader<'a> {
+    bytes: &'a [u8],
+    pos: usize,
+}
+
+impl<'a> Reader<'a> {
+    fn take(&mut self, len: usize) -> Option<&'a [u8]> {
+        let end = self.pos.checked_add(len)?;
+        let taken = self.bytes.get(self.pos..end)?;
+        self.pos = end;
+        Some(taken)
+    }
+
+    fn array<const N: usize>(&mut self) -> Option<[u8; N]> {
+        <[u8; N]>::try_from(self.take(N)?).ok()
+    }
+
+    fn u16(&mut self) -> Option<u16> {
+        self.array::<2>().map(u16::from_be_bytes)
+    }
+}
+
+fn parse_reply(reply: &[u8]) -> Result<Reply, Error> {
+    let malformed = || Error::Spool("the print server sent a malformed reply".into());
+    let mut r = Reader { bytes: reply, pos: 0 };
+    let _version = r.array::<2>().ok_or_else(malformed)?;
+    let status = r.u16().ok_or_else(malformed)?;
+    let _request_id = r.array::<4>().ok_or_else(malformed)?;
+    let (mut names, mut job_id, mut group) = (Vec::new(), None, 0u8);
+    loop {
+        let Some(tag) = r.array::<1>().map(|t| t[0]) else { return Err(malformed()) };
+        match tag {
+            GROUP_END => break,
+            GROUP_OPERATION | GROUP_JOB | GROUP_PRINTER | 0x05 => {
+                group = tag;
+            }
+            // An attribute: name and value, each u16-length. An empty name is an additional
+            // value of the previous one (the attributes read here are single-valued, so those
+            // are ignored). Group tags not in the registry and collections (the attributes
+            // requested here are never collections) are refused, not guessed.
+            0x10..=0x33 | 0x35..=0x4A => {
+                let name_len = r.u16().ok_or_else(malformed)?;
+                if usize::from(name_len) > MAX_NAME {
+                    return Err(malformed());
+                }
+                let name_bytes = r.take(usize::from(name_len)).ok_or_else(malformed)?;
+                let name = std::str::from_utf8(name_bytes).map_err(|_| malformed())?;
+                let value_len = r.u16().ok_or_else(malformed)?;
+                let value = r.take(usize::from(value_len)).ok_or_else(malformed)?;
+                match (group, name, tag) {
+                    (GROUP_PRINTER, "printer-name", TAG_NAME) => {
+                        if names.len() < MAX_PRINTERS
+                            && let Some(printer) = std::str::from_utf8(value).ok().filter(|n| !n.is_empty() && n.len() <= MAX_NAME)
+                        {
+                            names.push(printer.to_string());
+                        }
+                    }
+                    (GROUP_JOB, "job-id", TAG_INTEGER) => {
+                        if let Ok(id) = <[u8; 4]>::try_from(value) {
+                            job_id = Some(u32::from_be_bytes(id));
+                        }
+                    }
+                    _ => {}
+                }
+            }
+            _ => return Err(malformed()),
+        }
+    }
+    Ok(Reply { status, names, job_id })
+}
+
+/// Successful IPP status codes are `0x00xx` (RFC 8011 Appendix B).
+fn status_ok(status: u16) -> bool {
+    status < 0x0100
+}
+
+/// The IPP status code's name, for the error the user sees.
+fn status_name(status: u16) -> Option<&'static str> {
+    Some(match status {
+        0x0400 => "bad request",
+        0x0401 => "forbidden",
+        0x0402 => "not authenticated",
+        0x0403 => "not authorized",
+        0x0404 => "not possible",
+        0x0406 => "printer not found",
+        0x0408 => "request too large",
+        0x040A => "PDF is not a supported document format for this printer",
+        0x0412 => "the document could not be accessed",
+        0x0500 => "internal error",
+        0x0501 => "operation not supported",
+        0x0502 => "service unavailable",
+        0x0503 => "IPP version not supported",
+        0x0504 => "device error",
+        0x0505 => "temporary error",
+        0x0506 => "the printer is not accepting jobs",
+        0x0507 => "the printer is busy",
+        _ => return None,
+    })
+}
+
+fn refused(status: u16) -> Error {
+    match status_name(status) {
+        Some(name) => Error::Spool(format!("the print server refused the request ({name})")),
+        None => Error::Spool(format!("the print server refused the request (IPP status 0x{status:04X})")),
+    }
+}
diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs
--- a/crates/print/src/lib.rs
+++ b/crates/print/src/lib.rs
@@ -1,6 +1,10 @@
 //! The system print spooler. On macOS and Linux this is CUPS: printers come from `lpstat` — its
 //! queues, and the driverless destinations it can print to without one — and jobs are piped to
-//! `lp` with the job options (copies, collation, duplex, colour). On Windows the printers come
+//! `lp` with the job options (copies, collation, duplex, colour). When those commands are
+//! missing (a sandboxed install, storytold/photocraft#6081) or answer nothing, the server is
+//! asked directly over IPP ([`ipp`]) and jobs go to it as Print-Job; when no CUPS server is
+//! reachable, the job is handed to the desktop's print portal ([`portal`]), whose own dialog
+//! restores the printer selection (storytold/pdfcraft#1905). On Windows the printers come
 //! from the spooler and jobs are drawn by the in-box `Windows.Data.Pdf` into the XPS print API
 //! with the options as a PrintTicket, all in this process (`windows.rs`): no PowerShell or other
 //! program is started, so printing works where scripts are locked down. Other platforms report
@@ -8,6 +12,16 @@
 
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 
+/// IPP/1.1 straight to the local CUPS server (discovery and Print-Job): the transport the print
+/// path falls back to when `lpstat`/`lp` are missing (a sandboxed install), exposed for callers
+/// that need the server directly.
+#[cfg(all(unix, not(target_arch = "wasm32")))]
+pub mod ipp;
+
+/// The XDG desktop portal's print dialog: the print path when no CUPS server is reachable.
+#[cfg(all(unix, not(target_arch = "wasm32")))]
+pub mod portal;
+
 #[cfg(any(test, windows))]
 mod progress;
 #[cfg(any(test, windows))]
@@ -361,17 +375,45 @@ fn lpstat_e_command() -> std::process::Command {
     lpstat_with(&["-e"])
 }
 
+/// Whether `name` can be started: an executable `name` on `PATH`, the way a `Command` resolves
+/// one. Gated like its unix callers.
+#[cfg(all(unix, not(target_arch = "wasm32")))]
+fn spawnable(name: &str) -> bool {
+    std::env::var_os("PATH").is_some_and(|path| spawnable_in(&path, name))
+}
+
+/// [`spawnable`] under a given search path, so tests can stand in for the environment.
+#[cfg(all(unix, not(target_arch = "wasm32")))]
+fn spawnable_in(path: &std::ffi::OsStr, name: &str) -> bool {
+    std::env::split_paths(path).any(|dir| {
+        std::fs::metadata(dir.join(name)).is_ok_and(|m| {
+            use std::os::unix::fs::PermissionsExt;
+            m.is_file() && m.permissions().mode() & 0o111 != 0
+        })
+    })
+}
+
 /// The printers the system knows (empty when there are none or no spooler): its queues, then the
-/// driverless destinations CUPS can print to without one.
+/// driverless destinations CUPS can print to without one — from `lpstat` when it answers, else
+/// over IPP ([`ipp::printers`]).
 pub fn printers() -> Vec<Printer> {
     #[cfg(all(unix, not(target_arch = "wasm32")))]
     {
-        let Some(queues) = lpstat_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) else {
-            return Vec::new();
-        };
-        // A spooler without `-e`, or one whose discovery times out, only loses the driverless names.
-        let available = lpstat_e_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
-        printers_parsed(&queues, available.as_deref())
+        if let Some(queues) = lpstat_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) {
+            // A spooler without `-e`, or one whose discovery times out, only loses the driverless names.
+            let available = lpstat_e_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
+            let parsed = printers_parsed(&queues, available.as_deref());
+            if !parsed.is_empty() {
+                return parsed;
+            }
+        }
+        // `lpstat` missing (a sandboxed install) or answering nothing (a stopped scheduler) is
+        // not "no printers" when the server itself is there: ask it directly. A server that
+        // cannot be reached, or whose answer cannot be parsed, leaves the list empty as before.
+        if ipp::cups_reachable() {
+            return ipp::printers().unwrap_or_default();
+        }
+        Vec::new()
     }
     #[cfg(windows)]
     {
@@ -449,6 +491,10 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result<String, Error> {
     submit_with_progress(pdf, job, &mut |_, _| {})
 }
 
+/// The message when no transport can take the job at all: no spooler command, no CUPS server,
+/// no print portal.
+const PRINTING_UNAVAILABLE: &str = "printing to a printer isn't available on this platform yet; save the print-ready PDF instead";
+
 /// [`submit`] with Windows sheet-processing progress: `(processed, sheets)` before the first
 /// sheet and after each sheet is successfully added to the native print job. Counts refer to
 /// source PDF sheets, regardless of copies or duplex. CUPS reports no progress.
@@ -461,7 +507,18 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz
     #[cfg(all(unix, not(target_arch = "wasm32")))]
     {
         let _ = progress;
-        submit_via(std::process::Command::new("lp"), pdf, job)
+        if spawnable("lp") {
+            return submit_via(std::process::Command::new("lp"), pdf, job);
+        }
+        // No `lp`: the server itself takes the job as Print-Job (the driver's PPD options are
+        // not sent on this path), else the portal's dialog asks the user for a printer.
+        if ipp::cups_reachable() {
+            return ipp::print_job(pdf, job);
+        }
+        if portal::print_dialog_available() {
+            return portal::print(pdf, job);
+        }
+        Err(Error::Spool(PRINTING_UNAVAILABLE.into()))
     }
     #[cfg(windows)]
     {
@@ -470,7 +527,7 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz
     #[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))]
     {
         let _ = (pdf, job, progress);
-        Err(Error::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into()))
+        Err(Error::Spool(PRINTING_UNAVAILABLE.into()))
     }
 }
 
@@ -535,10 +592,10 @@ pub(crate) fn job_message(stdout: &[u8]) -> String {
     s.strip_suffix("(0 file(s))").map_or(s, str::trim_end).to_string()
 }
 
-/// [`command_preview`] for the Windows backend.
-fn windows_preview(job: &Job) -> Vec<String> {
+/// The job's dialog options as `key=value`: the lines both in-process previews print after the
+/// transport name.
+fn job_options(job: &Job) -> Vec<String> {
     vec![
-        "xps-print".into(),
         format!("printer={}", job.printer.as_deref().unwrap_or("(default)")),
         format!("copies={}", job.copies.clamp(1, 999)),
         format!("collate={}", job.collate),
@@ -562,6 +619,47 @@ fn windows_preview(job: &Job) -> Vec<String> {
     ]
 }
 
+/// [`command_preview`] for the Windows backend.
+fn windows_preview(job: &Job) -> Vec<String> {
+    std::iter::once("xps-print".to_string()).chain(job_options(job)).collect()
+}
+
+/// [`command_preview`] for the IPP transport: the Print-Job's attributes. The driver's PPD
+/// options have no equivalent on this path, so they are not listed.
+#[cfg(all(unix, not(target_arch = "wasm32")))]
+fn ipp_preview(job: &Job) -> Vec<String> {
+    std::iter::once("ipp".to_string()).chain(job_options(job)).collect()
+}
+
+/// [`command_preview`] for the print portal: the printer and the options are the user's to
+/// choose in the portal's own dialog.
+#[cfg(all(unix, not(target_arch = "wasm32")))]
+fn portal_preview() -> Vec<String> {
+    vec!["portal".into(), "printer=(dialog)".into()]
+}
+
+/// [`command_preview`] for the CUPS backend: the transport that would run — `lp` when the
+/// spooler's command is there, else IPP to the reachable server, else the portal's dialog.
+fn cups_preview(job: &Job) -> Result<Vec<String>, Error> {
+    #[cfg(all(unix, not(target_arch = "wasm32")))]
+    {
+        if spawnable("lp") {
+            return Ok(std::iter::once("lp".to_string()).chain(lp_args(job)).collect());
+        }
+        if ipp::cups_reachable() {
+            return Ok(ipp_preview(job));
+        }
+        if portal::print_dialog_available() {
+            return Ok(portal_preview());
+        }
+    }
+    #[cfg(not(all(unix, not(target_arch = "wasm32"))))]
+    {
+        let _ = job;
+    }
+    Err(Error::Spool(PRINTING_UNAVAILABLE.into()))
+}
+
 /// The platform backend. No backend is available in the browser.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum Backend {
@@ -579,14 +677,15 @@ pub const fn backend() -> Option<Backend> {
     }
 }
 
-/// A diagnostic command preview; creates no job file and starts no process.
-/// On Windows, where no program is started, it describes the in-process job instead: `xps-print`,
-/// then the printer and the PrintTicket's options as `key=value`. Submit with [`submit`],
+/// A diagnostic command preview; creates no job file and starts no process. It names the
+/// transport that would run: on CUPS `lp`, else IPP to the reachable server, else the portal's
+/// dialog; on Windows, where no program is started, the in-process job instead (`xps-print`,
+/// then the printer and the PrintTicket's options as `key=value`). Submit with [`submit`],
 /// rather than executing this preview. PDF bytes are supplied separately.
 pub fn command_preview(job: &Job) -> Result<Vec<String>, Error> {
     match backend() {
-        Some(Backend::Cups) => Ok(std::iter::once("lp".to_string()).chain(lp_args(job)).collect()),
+        Some(Backend::Cups) => cups_preview(job),
         Some(Backend::WindowsPdf) => Ok(windows_preview(job)),
-        None => Err(Error::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into())),
+        None => Err(Error::Spool(PRINTING_UNAVAILABLE.into())),
     }
 }
diff --git a/crates/print/src/portal.rs b/crates/print/src/portal.rs
new file mode 100644
--- /dev/null
+++ b/crates/print/src/portal.rs
@@ -0,0 +1,59 @@
+//! The XDG desktop portal's print dialog (`org.freedesktop.portal.Print`): the print path for
+//! installs where no CUPS server is reachable (a sandboxed one), where `lpstat`/`lp` are
+//! missing too. The dialog is the portal backend's own, so the printer and the job's options
+//! are the user's to choose there; this side hands over the print-ready PDF and reports it.
+
+use super::{Error, Job};
+use std::collections::HashMap;
+use std::io::{Seek as _, Write as _};
+use std::os::fd::AsFd as _;
+use std::sync::OnceLock;
+use std::time::Duration;
+
+/// The desktop portal service, whichever backend provides it.
+const PORTAL_SERVICE: &str = "org.freedesktop.portal.Desktop";
+const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
+const PORTAL_INTERFACE: &str = "org.freedesktop.portal.Print";
+
+/// A backend that has taken the call must not hold the worker forever.
+const PORTAL_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// Whether the print dialog can be opened: the session bus connects. The probe is asked once;
+/// a missing bus (a sandbox without one) is a normal `false`, not an error.
+pub fn print_dialog_available() -> bool {
+    static AVAILABLE: OnceLock<bool> = OnceLock::new();
+    *AVAILABLE.get_or_init(|| session_bus().is_ok())
+}
+
+/// Hand the print-ready PDF to the portal's print dialog; the returned message is the receipt
+/// of the handoff. The job goes through a private temporary file (0600, a random name, this
+/// user's temporary folder) whose file descriptor the portal receives: the file is deleted as
+/// soon as the portal has its own copy of the descriptor, and it reads the job through it when
+/// the dialog closes. The printer and the job's options are chosen in the dialog, not here.
+pub fn print(pdf: &[u8], job: &Job) -> Result<String, Error> {
+    let connection = session_bus()?;
+    let portal = zbus::blocking::Proxy::new(&connection, PORTAL_SERVICE, PORTAL_PATH, PORTAL_INTERFACE)
+        .map_err(|e| Error::Spool(format!("the print portal could not be reached: {e}")))?;
+    let mut file = tempfile::NamedTempFile::new().map_err(|e| Error::Spool(format!("the print job's file could not be created: {e}")))?;
+    file.write_all(pdf)
+        .and_then(|()| file.flush())
+        // The portal reads through the descriptor from the start, and a descriptor shares the
+        // offset with this file.
+        .and_then(|()| file.rewind())
+        .map_err(|e| Error::Spool(format!("the print job could not be written: {e}")))?;
+    // `Print` takes the parent window (none here), the dialog title, the job's file descriptor
+    // and the options; the dialog's own defaults apply. The reply is the request's object
+    // path, on which the portal later reports the dialog's outcome.
+    let options: HashMap<String, zbus::zvariant::Value> = HashMap::new();
+    let _request: zbus::zvariant::OwnedObjectPath = portal
+        .call("Print", &("", job.title.as_str(), zbus::zvariant::Fd::Borrowed(file.as_fd()), &options))
+        .map_err(|e| Error::Spool(format!("the print dialog could not be opened: {e}")))?;
+    Ok("the print dialog was opened; choose a printer there".into())
+}
+
+/// The user's session bus, which the portal lives on.
+fn session_bus() -> Result<zbus::blocking::Connection, Error> {
+    zbus::blocking::connection::Builder::session()
+        .and_then(|builder| builder.method_timeout(PORTAL_TIMEOUT).build())
+        .map_err(|e| Error::Spool(format!("the session bus could not be reached: {e}")))
+}
diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs
--- a/crates/print/src/tests.rs
+++ b/crates/print/src/tests.rs
@@ -1,6 +1,15 @@
 use super::*;
 use crate as spool;
 
+#[cfg(all(unix, not(target_arch = "wasm32")))]
+mod ipp;
+
+#[cfg(all(unix, not(target_arch = "wasm32")))]
+mod portal;
+
+#[cfg(all(unix, not(target_arch = "wasm32")))]
+mod route;
+
 mod windows_native;
 
 #[test]
@@ -250,11 +259,20 @@ fn printer_option_defaults_and_job_arguments() {
 #[test]
 fn command_previews_do_not_require_pdf_files() {
     let job = Job { printer: Some("Office (east)".into()), copies: 2, ..Job::default() };
-    let preview = command_preview(&job).unwrap();
     if backend() == Some(Backend::Cups) {
+        // Without `lp` this host answers from the fallback transports, or refuses with none at
+        // all; tests::route covers those forms under a controlled PATH.
+        #[cfg(all(unix, not(target_arch = "wasm32")))]
+        {
+            if !spool::spawnable("lp") {
+                return;
+            }
+        }
+        let preview = command_preview(&job).unwrap();
         assert_eq!(preview[0], "lp");
         assert_eq!(preview[1..], lp_args(&job));
     } else {
+        let preview = command_preview(&job).unwrap();
         assert_eq!(
             preview,
             ["xps-print", "printer=Office (east)", "copies=2", "collate=true", "duplex=(queue default)", "scaling=exact", "color=color"]
diff --git a/crates/print/src/tests/ipp.rs b/crates/print/src/tests/ipp.rs
new file mode 100644
--- /dev/null
+++ b/crates/print/src/tests/ipp.rs
@@ -0,0 +1,343 @@
+//! The IPP client against a fake CUPS server (a unix socket or a loopback port): discovery,
+//! Print-Job, and the malformed replies it must refuse without a panic.
+
+use super::*;
+use crate as spool;
+use crate::ipp::{self, Server};
+use std::collections::VecDeque;
+use std::io::{Read, Write};
+use std::sync::Mutex;
+use std::sync::mpsc::{self, Receiver};
+use std::time::Duration;
+
+const TIMEOUT: Duration = Duration::from_secs(2);
+
+/// A fake CUPS server: each connection carries one request; the next canned reply goes back
+/// (nothing, when the list is out — the client then sees a hang-up). Every request is recorded
+/// for the assertions.
+struct FakeCups {
+    server: Server,
+    requests: Receiver<Vec<u8>>,
+}
+
+impl FakeCups {
+    fn on_unix(replies: Vec<Vec<u8>>) -> Self {
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("cups.sock");
+        let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
+        // The socket must outlive this function; the folder is left for the process to reap.
+        std::mem::forget(dir);
+        let requests = listen_unix(listener, replies);
+        FakeCups { server: Server::Unix(path), requests }
+    }
+
+    fn on_tcp(replies: Vec<Vec<u8>>) -> Self {
+        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+        let server = Server::Tcp(listener.local_addr().unwrap());
+        let requests = listen_tcp(listener, replies);
+        FakeCups { server, requests }
+    }
+
+    fn request(&self) -> Vec<u8> {
+        self.requests.recv_timeout(Duration::from_secs(5)).expect("the fake server took the request")
+    }
+
+    /// The server as the one-endpoint list the client functions take.
+    fn endpoint(&self) -> &[Server] {
+        std::slice::from_ref(&self.server)
+    }
+}
+
+fn listen_unix(listener: std::os::unix::net::UnixListener, replies: Vec<Vec<u8>>) -> Receiver<Vec<u8>> {
+    let (tx, rx) = mpsc::channel();
+    let replies = Mutex::new(VecDeque::from(replies));
+    std::thread::spawn(move || {
+        for stream in listener.incoming().flatten() {
+            serve(stream, &replies, &tx);
+        }
+    });
+    rx
+}
+
+fn listen_tcp(listener: std::net::TcpListener, replies: Vec<Vec<u8>>) -> Receiver<Vec<u8>> {
+    let (tx, rx) = mpsc::channel();
+    let replies = Mutex::new(VecDeque::from(replies));
+    std::thread::spawn(move || {
+        for stream in listener.incoming().flatten() {
+            serve(stream, &replies, &tx);
+        }
+    });
+    rx
+}
+
+fn serve(mut stream: impl Read + Write, replies: &Mutex<VecDeque<Vec<u8>>>, requests: &mpsc::Sender<Vec<u8>>) {
+    let Some(request) = read_request(&mut stream) else { return };
+    let _ = requests.send(request);
+    // A list out of replies is a server that hangs up before answering.
+    if let Some(reply) = replies.lock().unwrap().pop_front() {
+        let _ = stream.write_all(&reply);
+        let _ = stream.flush();
+    }
+}
+
+/// One HTTP request: the headers (bounded), then the `Content-Length` body it announces.
+fn read_request(stream: &mut impl Read) -> Option<Vec<u8>> {
+    let mut buf = Vec::new();
+    let mut byte = [0u8; 1];
+    loop {
+        match stream.read(&mut byte) {
+            Ok(0) | Err(_) => return None,
+            Ok(_) => buf.push(byte[0]),
+        }
+        if buf.ends_with(b"\r\n\r\n") {
+            break;
+        }
+        if buf.len() > 1 << 16 {
+            return None;
+        }
+    }
+    let head = String::from_utf8_lossy(&buf);
+    let len: usize = head.lines().find_map(|l| l.strip_prefix("Content-Length:")).and_then(|v| v.trim().parse().ok())?;
+    let head_len = buf.len();
+    buf.resize(head_len + len, 0);
+    stream.read_exact(&mut buf[head_len..]).ok()?;
+    Some(buf)
+}
+
+/// An HTTP reply carrying `body`, as CUPS would send it.
+fn http_reply(body: &[u8]) -> Vec<u8> {
+    let mut out = format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", body.len()).into_bytes();
+    out.extend(body);
+    out
+}
+
+/// An IPP reply body: `status`, then the attribute groups as raw bytes, then end of attributes.
+fn ipp_reply(status: u16, groups: &[&[u8]]) -> Vec<u8> {
+    let mut out = vec![1, 1];
+    out.extend(status.to_be_bytes());
+    out.extend(1u32.to_be_bytes());
+    for &group in groups {
+        out.extend(group);
+    }
+    out.push(0x03);
+    out
+}
+
+fn group(tag: u8) -> Vec<u8> {
+    vec![tag]
+}
+
+fn printer_name(name: &str) -> Vec<u8> {
+    attr(0x42, "printer-name", name.as_bytes())
+}
+
+/// One attribute, framed the way the client frames them, so `has` finds it in a request.
+fn attr(tag: u8, name: &str, value: &[u8]) -> Vec<u8> {
+    let mut out = vec![tag];
+    out.extend((name.len() as u16).to_be_bytes());
+    out.extend(name.as_bytes());
+    out.extend((value.len() as u16).to_be_bytes());
+    out.extend(value);
+    out
+}
+
+fn has(bytes: &[u8], needle: &[u8]) -> bool {
+    !needle.is_empty() && bytes.windows(needle.len()).any(|w| w == needle)
+}
+
+/// The IPP part of a recorded request: everything after the HTTP head.
+fn ipp_body(request: &[u8]) -> &[u8] {
+    let at = request.windows(4).position(|w| w == b"\r\n\r\n").expect("an HTTP request has a head");
+    &request[at + 4..]
+}
+
+fn entry(name: &str, default: bool) -> spool::Printer {
+    spool::Printer { name: name.into(), default, two_sided: None }
+}
+
+#[test]
+fn discovery_finds_queues_and_the_default_over_a_unix_socket() {
+    let fake = FakeCups::on_unix(vec![
+        http_reply(&ipp_reply(
+            0x0000,
+            &[
+                &group(0x01),
+                &printer_name("Office_Laser"),
+                &group(0x04),
+                &printer_name("Office_Laser"),
+                &group(0x04),
+                &printer_name("Label_Writer"),
+                &group(0x04),
+                &printer_name("Office_Laser"),
+            ],
+        )),
+        http_reply(&ipp_reply(0x0000, &[&group(0x01), &group(0x04), &printer_name("Office_Laser")])),
+    ]);
+    let printers = ipp::printers_at(fake.endpoint()).unwrap();
+    assert_eq!(printers, [entry("Office_Laser", true), entry("Label_Writer", false)], "each queue once, the default marked");
+    let taken = fake.request();
+    let queues = ipp_body(&taken);
+    assert_eq!(&queues[..4], &[1, 1, 0x40, 0x02], "IPP 1.1, CUPS-Get-Printers");
+    assert!(has(queues, b"ipp://localhost/"));
+    assert!(has(queues, &attr(0x44, "requested-attributes", b"printer-name")));
+    assert!(has(queues, b"printer-is-accepting-jobs"), "only the attributes the dialog needs are asked for");
+    let taken = fake.request();
+    let default = ipp_body(&taken);
+    assert_eq!(&default[..4], &[1, 1, 0x40, 0x01], "CUPS-Get-Default");
+}
+
+#[test]
+fn a_print_job_carries_the_pdf_and_the_options() {
+    let pdf: Vec<u8> = b"%PDF-1.7 fake print job\n".iter().copied().cycle().take(1 << 20).collect();
+    let job = spool::Job {
+        printer: Some("Office".into()),
+        copies: 2,
+        collate: false,
+        duplex: Some(spool::Duplex::LongEdge),
+        grayscale: true,
+        scaling: spool::Scaling::ShrinkToFit,
+        title: "memo.pdf".into(),
+        ..Job::default()
+    };
+    let fake = FakeCups::on_tcp(vec![http_reply(&ipp_reply(
+        0x0000,
+        &[&group(0x01), &group(0x02), &attr(0x21, "job-id", &12u32.to_be_bytes()), &attr(0x23, "job-state", &[3u8])],
+    ))]);
+    let message = ipp::print_job_at(fake.endpoint(), &pdf, &job).unwrap();
+    assert_eq!(message, "request id is Office-12", "the same message lp prints");
+    let taken = fake.request();
+    let request = ipp_body(&taken);
+    assert_eq!(&request[..4], &[1, 1, 0x00, 0x02], "IPP 1.1, Print-Job");
+    assert!(has(request, b"ipp://localhost/printers/Office"));
+    let mut copies = group(0x02);
+    copies.extend(attr(0x21, "copies", &2u32.to_be_bytes()));
+    assert!(has(request, &copies), "copies are a job attribute");
+    assert!(has(request, &attr(0x22, "collate", &[0])));
+    assert!(has(request, &attr(0x44, "sides", b"two-sided-long-edge")));
+    assert!(has(request, &attr(0x44, "print-color-mode", b"monochrome")));
+    assert!(has(request, &attr(0x44, "print-scaling", b"auto-fit")));
+    assert!(has(request, &attr(0x49, "document-format", b"application/pdf")));
+    assert!(has(request, &attr(0x42, "job-name", b"memo.pdf")));
+    assert!(request.ends_with(&pdf), "the whole job is the document data");
+}
+
+#[test]
+fn the_default_printer_is_used_when_the_job_has_none() {
+    let fake = FakeCups::on_tcp(vec![
+        http_reply(&ipp_reply(0x0000, &[&group(0x01), &group(0x04), &printer_name("Office")])),
+        http_reply(&ipp_reply(0x0000, &[&group(0x01), &group(0x02), &attr(0x21, "job-id", &7u32.to_be_bytes())])),
+    ]);
+    let message = ipp::print_job_at(fake.endpoint(), b"%PDF-1.7\n", &Job::default()).unwrap();
+    assert_eq!(message, "request id is Office-7");
+    let default = fake.request();
+    assert_eq!(&ipp_body(&default)[..4], &[1, 1, 0x40, 0x01], "the default comes from CUPS-Get-Default");
+    let taken = fake.request();
+    assert!(has(ipp_body(&taken), b"ipp://localhost/printers/Office"), "the resolved default is the target");
+}
+
+#[test]
+fn a_default_outside_the_queue_list_still_gets_its_marker() {
+    let fake = FakeCups::on_unix(vec![
+        http_reply(&ipp_reply(0x0000, &[&group(0x01), &group(0x04), &printer_name("Office_Laser")])),
+        http_reply(&ipp_reply(0x0000, &[&group(0x01), &group(0x04), &printer_name("Label_Writer")])),
+    ]);
+    let printers = ipp::printers_at(fake.endpoint()).unwrap();
+    assert_eq!(
+        printers,
+        [entry("Office_Laser", false), entry("Label_Writer", true)],
+        "the default joins the list, so the dialog has a selection to restore"
+    );
+}
+
+#[test]
+fn no_default_printer_is_an_error_when_the_job_names_none() {
+    let fake = FakeCups::on_unix(vec![http_reply(&ipp_reply(0x0000, &[&group(0x01)]))]);
+    let err = ipp::print_job_at(fake.endpoint(), b"%PDF-1.7\n", &Job::default()).unwrap_err();
+    assert!(err.to_string().contains("no default printer"), "{err}");
+}
+
+#[test]
+fn a_printer_name_that_cannot_be_a_uri_path_is_refused() {
+    let fake = FakeCups::on_tcp(vec![]);
+    let mut long = "x".repeat(257);
+    long.push('y');
+    for name in ["Office (east)", "../../admin", "", "a b", "Brother/HL", "Копир", long.as_str()] {
+        let job = spool::Job { printer: Some(name.into()), ..Job::default() };
+        assert_eq!(
+            ipp::print_job_at(fake.endpoint(), b"pdf", &job),
+            Err(spool::Error::Spool(format!("not a printer name: {name}"))),
+            "{name:?} cannot go in the printer URI's path"
+        );
+    }
+}
+
+#[test]
+fn an_ipp_refusal_is_an_error_that_names_the_problem() {
+    let fake = FakeCups::on_tcp(vec![http_reply(&ipp_reply(0x0506, &[&group(0x01)]))]);
+    let job = spool::Job { printer: Some("Office".into()), ..Job::default() };
+    assert_eq!(
+        ipp::print_job_at(fake.endpoint(), b"%PDF-1.7\n", &job),
+        Err(spool::Error::Spool("the print server refused the request (the printer is not accepting jobs)".into()))
+    );
+}
+
+#[test]
+fn malformed_replies_are_errors_never_a_panic() {
+    let bodies: Vec<(&str, Vec<u8>)> = vec![
+        // Shorter than an IPP header.
+        ("shorter than an IPP header", vec![1, 1, 0]),
+        // A reply that ends inside an attribute: the name is announced, the value never comes.
+        ("cut inside an attribute", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x42, 0x00, 0x20, b'p']),
+        // A value tag the registry does not know.
+        ("unknown value tag", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0xFF, 0x00, 0x00, 0x00, 0x00]),
+        // A collection, which the attributes asked for never are.
+        ("collection", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x34, 0x00, 0x00, 0x00, 0x00]),
+        // A group tag that is no delimiter in the registry.
+        ("reserved delimiter", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x06]),
+        // A length within the cap that runs past the reply, and one past the cap.
+        ("length past the reply", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x42, 0x00, 0xFE, b'p']),
+        ("length past the cap", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x42, 0xFF, 0xFF, b'p']),
+        // No end of attributes before the body runs out.
+        ("no end of attributes", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x42, 0x00, 0x01, b'x']),
+    ];
+    for (why, body) in bodies {
+        let fake = FakeCups::on_unix(vec![http_reply(&body)]);
+        let result = ipp::printers_at(fake.endpoint());
+        assert!(result.is_err(), "{why}: the reply must be an error, not a panic or a success");
+    }
+}
+
+#[test]
+fn http_replies_that_cannot_carry_an_answer_are_refused() {
+    let replies: Vec<(&str, Vec<u8>)> = vec![
+        ("requires authentication", b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n".to_vec()),
+        ("demands a TLS upgrade", b"HTTP/1.1 426 Upgrade Required\r\nContent-Length: 0\r\n\r\n".to_vec()),
+        ("chunked", b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n25\r\nthe body is chunked\r\n0\r\n\r\n".to_vec()),
+        ("no Content-Length", b"HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\n\r\n".to_vec()),
+        ("Content-Length past the cap", b"HTTP/1.1 200 OK\r\nContent-Length: 99999999\r\n\r\n".to_vec()),
+        ("unreadable status line", b"not HTTP at all\r\n\r\n".to_vec()),
+    ];
+    for (why, reply) in replies {
+        let fake = FakeCups::on_unix(vec![reply]);
+        let result = ipp::post(&[fake.server], "/", b"ignored", TIMEOUT, true);
+        assert!(result.is_err(), "{why}: the reply must be an error, not a panic or a success");
+    }
+    // A server that takes the request and hangs up.
+    let fake = FakeCups::on_unix(vec![]);
+    assert!(ipp::post(&[fake.server], "/", b"ignored", TIMEOUT, true).is_err(), "a hang-up is an error");
+}
+
+#[test]
+fn a_server_that_answers_is_reachable_and_silence_is_not() {
+    let fake = FakeCups::on_unix(vec![]);
+    assert!(ipp::reachable(&fake.server), "a unix socket that accepts connections is reachable");
+    let fake = FakeCups::on_tcp(vec![]);
+    assert!(ipp::reachable(&fake.server), "a loopback port that accepts connections is reachable");
+    let absent = Server::Unix("/nonexistent/craft-print-test.sock".into());
+    assert!(!ipp::reachable(&absent), "a socket nothing listens on is not reachable");
+    let addr = {
+        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+        listener.local_addr().unwrap()
+    };
+    assert!(!ipp::reachable(&Server::Tcp(addr)), "a port nothing listens on is not reachable");
+}
diff --git a/crates/print/src/tests/portal.rs b/crates/print/src/tests/portal.rs
new file mode 100644
--- /dev/null
+++ b/crates/print/src/tests/portal.rs
@@ -0,0 +1,34 @@
+//! The portal path's tests: without a session bus, the dialog is a normal `false` and the job
+//! a clean `Err` — checked in a child process, because the bus address is process state and
+//! changing the environment here is forbidden.
+
+use super::*;
+use crate as spool;
+
+/// Set on the child process when a test runs this binary as a print caller without a session
+/// bus: the child's `DBUS_SESSION_BUS_ADDRESS` then names a socket that does not exist.
+const NO_BUS: &str = "CRAFT_STAND_IN_NO_BUS";
+
+#[test]
+fn stand_in_without_a_session_bus() {
+    if std::env::var_os(NO_BUS).is_none() {
+        return;
+    }
+    // The bus address names a socket that does not exist: the session bus is simply missing.
+    assert!(!spool::portal::print_dialog_available(), "a missing bus is false, not an error or a panic");
+    let err = spool::portal::print(b"%PDF-1.7\n", &Job::default()).unwrap_err();
+    assert!(err.to_string().contains("session bus"), "{err}");
+}
+
+#[test]
+fn a_missing_session_bus_is_a_normal_false_and_a_clean_error() {
+    let dead = std::env::temp_dir().join(format!("craft-print-no-session-bus-{}", std::process::id()));
+    let _ = std::fs::remove_file(&dead);
+    let mut child = std::process::Command::new(std::env::current_exe().unwrap())
+        .args(["--exact", "tests::portal::stand_in_without_a_session_bus", "--nocapture"])
+        .env(NO_BUS, "1")
+        .env("DBUS_SESSION_BUS_ADDRESS", format!("unix:path={}", dead.display()))
+        .spawn()
+        .unwrap();
+    assert!(child.wait().unwrap().success(), "without a session bus, the portal path fails cleanly");
+}
diff --git a/crates/print/src/tests/route.rs b/crates/print/src/tests/route.rs
new file mode 100644
--- /dev/null
+++ b/crates/print/src/tests/route.rs
@@ -0,0 +1,173 @@
+//! The routing between the transports: `lpstat`/`lp` when the CUPS command-line tools are
+//! there, then IPP to the reachable server, then the portal. Every environment-dependent route
+//! runs in a child process under a controlled `PATH` and a session bus pointed at a socket that
+//! does not exist (the environment is process state, and changing it here is forbidden), so no
+//! test can reach this host's spooler, CUPS server or portal. A reachable CUPS server cannot be
+//! faked — `cups_reachable` probes fixed paths — so the routes that follow it skip when one is
+//! really reachable; ipp.rs's fake-server tests are the oracle for the IPP transport itself.
+
+use super::*;
+use crate as spool;
+
+/// Set on the child process that re-runs a routing test under the stand-in commands.
+const ROUTE_CHILD: &str = "CRAFT_ROUTE_CHILD";
+
+/// A folder of stand-in spooler commands: `name` → the script body under a `#!/bin/sh` shebang.
+fn stand_in_dir(commands: &[(&str, &str)]) -> (tempfile::TempDir, std::path::PathBuf) {
+    let dir = tempfile::TempDir::new().unwrap();
+    use std::os::unix::fs::PermissionsExt;
+    for (name, body) in commands {
+        let path = dir.path().join(name);
+        std::fs::write(&path, format!("#!/bin/sh\n{body}")).unwrap();
+        let mut permissions = std::fs::metadata(&path).unwrap().permissions();
+        permissions.set_mode(0o755);
+        std::fs::set_permissions(&path, permissions).unwrap();
+    }
+    let bin = dir.path().to_path_buf();
+    (dir, bin)
+}
+
+/// Re-runs `name` as a child with `bin` as its `PATH` and the session bus pointed at a socket
+/// that does not exist, so routing cannot open this host's print dialog.
+fn route_child(name: &str, bin: &std::path::Path) -> std::process::Output {
+    let dead_bus = std::env::temp_dir().join(format!("craft-print-no-session-bus-{}", std::process::id()));
+    let _ = std::fs::remove_file(&dead_bus);
+    std::process::Command::new(std::env::current_exe().unwrap())
+        .args(["--exact", name, "--nocapture"])
+        .env("PATH", bin)
+        .env(ROUTE_CHILD, "1")
+        .env("DBUS_SESSION_BUS_ADDRESS", format!("unix:path={}", dead_bus.display()))
+        .output()
+        .unwrap()
+}
+
+/// Runs `scenario` when this binary was re-run as a routing child; otherwise runs it as a child
+/// under the stand-in environment and asserts that it passed.
+fn routed(name: &str, bin: &std::path::Path, scenario: impl FnOnce()) {
+    if std::env::var_os(ROUTE_CHILD).is_some() {
+        scenario();
+        return;
+    }
+    let out = route_child(name, bin);
+    assert!(out.status.success(), "the routing child failed:\n{}\n{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
+}
+
+#[test]
+fn spawnable_needs_an_executable_file_on_the_search_path() {
+    use std::os::unix::fs::PermissionsExt;
+    let (dir, bin) = stand_in_dir(&[("lp", ":")]);
+    assert!(spool::spawnable_in(bin.as_os_str(), "lp"), "the stand-in is spawnable");
+    std::fs::set_permissions(dir.path().join("lp"), std::fs::Permissions::from_mode(0o644)).unwrap();
+    assert!(!spool::spawnable_in(bin.as_os_str(), "lp"), "a file without the execute bits is not");
+    std::fs::create_dir(dir.path().join("lpstat")).unwrap();
+    assert!(!spool::spawnable_in(bin.as_os_str(), "lpstat"), "a directory is not");
+    assert!(!spool::spawnable_in(bin.as_os_str(), "lpoptions"), "a name the path lacks is not");
+    assert!(!spool::spawnable_in("".as_ref(), "lp"), "an empty search path spawns nothing");
+}
+
+#[test]
+fn lpstat_that_answers_wins_over_the_fallbacks() {
+    let (_dir, bin) = stand_in_dir(&[(
+        "lpstat",
+        r#"if [ "$1" = "-e" ]; then echo "Office_Laser"; else echo "printer Office_Laser is idle."; echo "system default destination: Office_Laser"; fi"#,
+    )]);
+    routed("tests::route::lpstat_that_answers_wins_over_the_fallbacks", &bin, || {
+        let printers = spool::printers();
+        assert_eq!(printers.len(), 1, "{printers:?}");
+        assert_eq!(printers[0], spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None });
+    });
+}
+
+#[test]
+fn lpstat_that_answers_nothing_falls_back_to_the_server() {
+    if spool::ipp::cups_reachable() {
+        // A real server is reachable: the fallback would ask it, and its queues are not this
+        // test's business (ipp.rs tests the server answers). Skip rather than depend on the host.
+        return;
+    }
+    let (_dir, bin) = stand_in_dir(&[(
+        "lpstat",
+        r#"if [ "$1" = "-e" ]; then exit 0; fi; echo "no system default destination"; echo "lpstat: Scheduler is not running." >&2; exit 1"#,
+    )]);
+    routed("tests::route::lpstat_that_answers_nothing_falls_back_to_the_server", &bin, || {
+        assert!(spool::printers().is_empty(), "a stopped scheduler with no reachable server has no printers");
+    });
+}
+
+#[test]
+fn without_lpstat_the_list_stays_empty_without_a_server() {
+    if spool::ipp::cups_reachable() {
+        return;
+    }
+    let (_dir, bin) = stand_in_dir(&[]);
+    routed("tests::route::without_lpstat_the_list_stays_empty_without_a_server", &bin, || {
+        assert!(spool::printers().is_empty(), "a sandbox without the tools and without a server has no printers");
+    });
+}
+
+#[test]
+fn print_jobs_go_to_lp_when_it_is_there() {
+    let (_dir, bin) = stand_in_dir(&[("lp", "PATH=/usr/bin:/bin; export PATH; cat >/dev/null; echo \"request id is Route-1\"")]);
+    routed("tests::route::print_jobs_go_to_lp_when_it_is_there", &bin, || {
+        let sent = spool::submit_with_progress(b"%PDF-1.7 synthetic routing job\n", &Job::default(), &mut |_, _| {});
+        assert_eq!(sent, Ok("request id is Route-1".to_string()), "the stand-in lp spooled the job");
+    });
+}
+
+#[test]
+fn without_a_transport_the_job_is_refused() {
+    if spool::ipp::cups_reachable() {
+        return;
+    }
+    let (_dir, bin) = stand_in_dir(&[]);
+    routed("tests::route::without_a_transport_the_job_is_refused", &bin, || {
+        let err = spool::submit_with_progress(b"%PDF-1.7\n", &Job::default(), &mut |_, _| {}).unwrap_err();
+        assert_eq!(err, Error::Spool(spool::PRINTING_UNAVAILABLE.into()), "no spooler, no server, no portal");
+    });
+}
+
+#[test]
+fn the_ipp_preview_describes_the_print_job() {
+    let job = Job {
+        printer: Some("Office_Laser".into()),
+        copies: 2,
+        duplex: Some(Duplex::LongEdge),
+        grayscale: true,
+        scaling: Scaling::ShrinkToFit,
+        ..Job::default()
+    };
+    assert_eq!(
+        spool::ipp_preview(&job),
+        ["ipp", "printer=Office_Laser", "copies=2", "collate=true", "duplex=two-sided-long-edge", "scaling=shrink-to-fit", "color=monochrome"]
+    );
+    assert_eq!(
+        spool::ipp_preview(&Job::default()),
+        ["ipp", "printer=(default)", "copies=1", "collate=true", "duplex=(queue default)", "scaling=exact", "color=color"]
+    );
+}
+
+#[test]
+fn the_portal_preview_points_at_its_dialog() {
+    assert_eq!(spool::portal_preview(), ["portal", "printer=(dialog)"]);
+}
+
+#[test]
+fn the_preview_names_lp_when_it_would_run() {
+    let (_dir, bin) = stand_in_dir(&[("lp", ":")]);
+    routed("tests::route::the_preview_names_lp_when_it_would_run", &bin, || {
+        let preview = spool::command_preview(&Job::default()).unwrap();
+        assert_eq!(preview[0], "lp", "{preview:?}");
+        assert_eq!(preview[1..], lp_args(&Job::default()));
+    });
+}
+
+#[test]
+fn the_preview_refuses_when_no_transport_exists() {
+    if spool::ipp::cups_reachable() {
+        return;
+    }
+    let (_dir, bin) = stand_in_dir(&[]);
+    routed("tests::route::the_preview_refuses_when_no_transport_exists", &bin, || {
+        assert_eq!(spool::command_preview(&Job::default()), Err(Error::Spool(spool::PRINTING_UNAVAILABLE.into())));
+    });
+}
