diff --git a/Cargo.lock b/Cargo.lock index 43a4a44f65..fefc7e5771 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4651,6 +4651,7 @@ dependencies = [ "rfd", "ron", "serde_json", + "tempfile", "winit", "winresource", "winsafe", @@ -4800,6 +4801,7 @@ dependencies = [ "photocraft-format", "photocraft-geom", "photocraft-io", + "photocraft-ipp", "photocraft-ops", "photocraft-paint", "photocraft-plugins", @@ -4899,6 +4901,10 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "photocraft-ipp" +version = "0.9.0" + [[package]] name = "photocraft-ops" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 4a9b20133c..25a7a101c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ photocraft-engine = { path = "crates/engine" } photocraft-ui-egui = { path = "crates/ui-egui" } photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } +photocraft-ipp = { path = "crates/ipp" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } diff --git a/README.md b/README.md index 973fb5330d..12e2ef4b12 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,8 @@ flatpak install --user photocraft--linux-x86_64.flatpak # or -linux-a flatpak run ai.storyteller.photocraft ``` +The Flatpak prints through the host's CUPS socket (File › Print talks IPP to it directly, no `lp` needed); without CUPS, or without a printer, File › Print opens the system print dialog through the print portal. See [printing](docs/printing.md). + The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it. On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072). diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 8b7c832bb2..505947e222 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -65,6 +65,8 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } # Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(target_os = "linux")'.dependencies] photocraft-cms = { workspace = true } +# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done. +tempfile = "3" x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] } # Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for # image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly. diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 721bbc7349..05355e55bf 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -63,6 +63,9 @@ mod photoshop_settings; #[cfg(target_os = "linux")] mod portal_save; mod presentation; +// The print portal needs the session bus (zbus, Linux only); its pure helpers are tested everywhere. +#[cfg(any(target_os = "linux", test))] +mod print_portal; mod screen_color; mod services; // macOS gets one instance from LaunchServices (open-documents Apple events). @@ -565,7 +568,15 @@ fn main() -> eframe::Result { services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some()); } } - let mut app = PhotocraftApp::new(Session::new(), services); + #[cfg(target_os = "linux")] + let session = { + let mut session = Session::new(); + session.print_spooler = print_portal::spooler(); + session + }; + #[cfg(not(target_os = "linux"))] + let session = Session::new(); + let mut app = PhotocraftApp::new(session, services); app.min_frame_ms = uncapped.then_some(1000.0 / 240.0); app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = custom_titlebar; diff --git a/apps/photocraft/src/print_portal.rs b/apps/photocraft/src/print_portal.rs new file mode 100644 index 0000000000..0eef02a01c --- /dev/null +++ b/apps/photocraft/src/print_portal.rs @@ -0,0 +1,239 @@ +//! Printing through the XDG Print portal (`org.freedesktop.portal.Print`): the system print +//! dialog, which a sandboxed app can reach without a CUPS socket. The engine prints over IPP +//! when it can reach a print server (`photocraft-ipp`); this is its fallback when it cannot, and +//! the only route with `PHOTOCRAFT_PRINT_PORTAL=1` (`0` disables it). `PreparePrint` shows the +//! dialog seeded with the job's copies and paper; `Print` hands it the PDF. Both answer through a +//! `Request` object's `Response` signal, which this module waits for on its own thread so the +//! window keeps painting while the dialog is open; the outcome goes to the log. +//! https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html + +#[cfg(target_os = "linux")] +use photocraft_engine::print_cmds::{PrintJob, Spooler}; + +/// The spooler to install on the session, unless `PHOTOCRAFT_PRINT_PORTAL=0`. The portal itself +/// is looked up per job, so a missing one costs nothing at start-up. +#[cfg(target_os = "linux")] +pub fn spooler() -> Option { + let setting = std::env::var("PHOTOCRAFT_PRINT_PORTAL").ok(); + if setting.as_deref() == Some("0") { + return None; + } + Some(Spooler { name: "portal", preferred: setting.as_deref() == Some("1"), run: std::sync::Arc::new(print) }) +} + +/// Opens the system print dialog for the job and returns as soon as it is up. +#[cfg(target_os = "linux")] +fn print(job: &PrintJob) -> Result { + let conn = zbus::blocking::connection::Builder::session() + .and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) + .map_err(|e| format!("no session bus for the print portal ({e}); print to PDF instead"))?; + let proxy = portal(&conn)?; + let version: u32 = proxy.get_property("version").map_err(|e| format!("no print portal on this desktop ({e}); print to PDF instead"))?; + // The document goes to a private temporary file (0600, created atomically by `tempfile`), + // not a guessable name in a shared /tmp. The guard owns the file: it lives on the portal + // thread until the dialog is done, and a failure to start that thread drops it here. + let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(|e| format!("cannot create the print file: {e}"))?; + std::io::Write::write_all(&mut file, &job.pdf).map_err(|e| format!("cannot write the print file: {e}"))?; + let (title, settings, page_setup) = (job.title.clone(), settings(job), page_setup(job)); + std::thread::Builder::new() + .name("print-portal".into()) + .spawn(move || { + let outcome = dialog(&conn, &title, &settings, &page_setup, file.path()); + drop(file); + match outcome { + Ok(what) => log::info!("print portal (v{version}): {what}"), + Err(e) => log::warn!("print portal (v{version}): {e}"), + } + }) + .map_err(|e| format!("cannot start the print thread: {e}"))?; + Ok("the system print dialog".into()) +} + +#[cfg(target_os = "linux")] +fn portal(conn: &zbus::blocking::Connection) -> Result, String> { + zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Print") + .map_err(|e| e.to_string()) +} + +/// `PreparePrint` then `Print`, each waited for on its request's `Response` signal. +#[cfg(target_os = "linux")] +fn dialog( + conn: &zbus::blocking::Connection, + title: &str, + settings: &[(&'static str, Seed)], + page_setup: &[(&'static str, Seed)], + pdf: &std::path::Path, +) -> Result { + use std::collections::HashMap; + use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + type Dict<'a> = HashMap<&'a str, Value<'a>>; + fn as_dict(pairs: &[(&'static str, Seed)]) -> Dict<'static> { + pairs + .iter() + .map(|(k, v)| { + let value = match v { + Seed::Text(t) => Value::from(t.clone()), + Seed::Mm(mm) => Value::from(*mm), + }; + (*k, value) + }) + .collect() + } + let proxy = portal(conn)?; + let sender = conn.unique_name().map(|n| n.as_str().to_string()).ok_or("the bus connection has no name")?; + let wait = |token: &str| -> Result, String> { + let path = request_path(&sender, token); + let request = zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", path, "org.freedesktop.portal.Request").map_err(|e| e.to_string())?; + request.receive_signal("Response").map_err(|e| e.to_string()) + }; + let response = |mut signals: zbus::blocking::proxy::SignalIterator<'static>| -> Result, String> { + let message = signals.next().ok_or("the print portal went away")?; + let (code, results): (u32, HashMap) = message.body().deserialize().map_err(|e| format!("odd portal reply: {e}"))?; + match code { + 0 => Ok(results), + 1 => Err("cancelled in the print dialog".into()), + _ => Err("the print dialog failed".into()), + } + }; + + let token = format!("photocraft_prepare_{}", nonce()); + let signals = wait(&token)?; + let mut options: Dict<'_> = HashMap::new(); + options.insert("handle_token", Value::from(token.as_str())); + let _: OwnedObjectPath = + proxy.call("PreparePrint", &("", title, as_dict(settings), as_dict(page_setup), options)).map_err(|e| format!("PreparePrint failed: {e}"))?; + let results = response(signals)?; + let prepared = results.get("token").and_then(|v| u32::try_from(v.clone()).ok()).ok_or("the print dialog gave no token")?; + + let file = std::fs::File::open(pdf).map_err(|e| format!("cannot reopen the print file: {e}"))?; + let token = format!("photocraft_print_{}", nonce()); + let signals = wait(&token)?; + let mut options: Dict<'_> = HashMap::new(); + options.insert("handle_token", Value::from(token.as_str())); + options.insert("token", Value::from(prepared)); + let fd = zbus::zvariant::Fd::from(std::os::fd::AsFd::as_fd(&file)); + let _: OwnedObjectPath = proxy.call("Print", &("", title, fd, options)).map_err(|e| format!("Print failed: {e}"))?; + response(signals)?; + Ok("printed through the system print dialog".into()) +} + +#[cfg(target_os = "linux")] +fn nonce() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0); + t ^ N.fetch_add(1, Ordering::Relaxed) +} + +/// The request object the portal answers on: the sender's unique name without its colon and +/// with dots as underscores, then the handle token. +fn request_path(unique_name: &str, token: &str) -> String { + format!("/org/freedesktop/portal/desktop/request/{}/{token}", unique_name.trim_start_matches(':').replace('.', "_")) +} + +/// A value seeded into the dialog: GtkPrintSettings are strings; a GtkPageSetup's sizes are +/// doubles in millimetres (`gtk_paper_size_new_from_gvariant` ignores anything else). +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +enum Seed { + Text(String), + Mm(f64), +} + +fn text(s: &str) -> Seed { + Seed::Text(s.to_string()) +} + +/// The standard paper sizes, as (PWG name, PPD name, width mm, height mm), portrait. GTK's +/// print settings take the PWG name; its page setup finds the size by the PPD name. +const PAPERS: [(&str, &str, f64, f64); 8] = [ + ("na_letter", "Letter", 215.9, 279.4), + ("na_legal", "Legal", 215.9, 355.6), + ("na_ledger", "Ledger", 279.4, 431.8), + ("iso_a3", "A3", 297.0, 420.0), + ("iso_a4", "A4", 210.0, 297.0), + ("iso_a5", "A5", 148.0, 210.0), + ("na_index-4x6", "4x6", 101.6, 152.4), + ("na_5x7", "5x7", 127.0, 177.8), +]; + +const MM_PER_PT: f64 = 25.4 / 72.0; + +type Paper = (&'static str, &'static str, f64, f64); + +/// The job's paper: its row in `PAPERS` when it is a standard size, and the portrait size in +/// millimetres. +fn paper(paper_pt: (f64, f64)) -> (Option<&'static Paper>, f64, f64) { + let (w, h) = (paper_pt.0.min(paper_pt.1) * MM_PER_PT, paper_pt.0.max(paper_pt.1) * MM_PER_PT); + let row = PAPERS.iter().find(|(_, _, pw, ph)| (pw - w).abs() < 1.0 && (ph - h).abs() < 1.0); + (row, w, h) +} + +fn orientation(landscape: bool) -> Seed { + text(if landscape { "landscape" } else { "portrait" }) +} + +/// `PreparePrint`'s settings (GtkPrintSettings keys, string values). +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn settings_for(copies: u32, paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> { + let (row, w, h) = paper(paper_pt); + let mut v = vec![("n-copies", text(&copies.to_string())), ("orientation", orientation(landscape))]; + match row { + Some((pwg, _, _, _)) => v.push(("paper-format", text(pwg))), + None => v.extend([("paper-width", text(&format!("{w:.2}"))), ("paper-height", text(&format!("{h:.2}")))]), + } + v +} + +/// `PreparePrint`'s page setup (GtkPageSetup keys): the size in millimetres, found by its PPD +/// name when it has one, else a custom size named by its dimensions. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn page_setup_for(paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> { + let (row, w, h) = paper(paper_pt); + let mut v = vec![("Orientation", orientation(landscape)), ("Width", Seed::Mm(w)), ("Height", Seed::Mm(h))]; + match row { + Some((_, ppd, _, _)) => v.push(("PPDName", text(ppd))), + None => v.push(("Name", text(&format!("custom_{w:.0}x{h:.0}mm")))), + } + v +} + +#[cfg(target_os = "linux")] +fn settings(job: &PrintJob) -> Vec<(&'static str, Seed)> { + settings_for(job.copies, job.paper, job.landscape) +} + +#[cfg(target_os = "linux")] +fn page_setup(job: &PrintJob) -> Vec<(&'static str, Seed)> { + page_setup_for(job.paper, job.landscape) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_path_follows_the_portal_rule() { + assert_eq!(request_path(":1.42", "photocraft_prepare_7"), "/org/freedesktop/portal/desktop/request/1_42/photocraft_prepare_7"); + } + + #[test] + fn standard_papers_get_their_pwg_and_ppd_names_and_custom_ones_their_size() { + // A4 landscape as the engine lays it out: width > height, still A4. + let s = settings_for(2, (841.89, 595.28), true); + assert_eq!(s, vec![("n-copies", text("2")), ("orientation", text("landscape")), ("paper-format", text("iso_a4"))]); + let p = page_setup_for((841.89, 595.28), true); + assert_eq!(p[0], ("Orientation", text("landscape"))); + let Seed::Mm(w) = p[1].1 else { panic!("width is millimetres") }; + let Seed::Mm(h) = p[2].1 else { panic!("height is millimetres") }; + assert!((w - 210.0).abs() < 0.01 && (h - 297.0).abs() < 0.01, "{w} x {h}"); + assert_eq!(p[3], ("PPDName", text("A4"))); + assert_eq!(settings_for(1, (612.0, 792.0), false)[2], ("paper-format", text("na_letter"))); + assert_eq!(page_setup_for((612.0, 792.0), false)[3], ("PPDName", text("Letter"))); + assert_eq!(settings_for(1, (288.0, 432.0), false)[2], ("paper-format", text("na_index-4x6"))); + // 100 x 200 mm is nobody's standard. + let custom = settings_for(1, (283.46, 566.93), false); + assert_eq!(&custom[2..], &[("paper-width", text("100.00")), ("paper-height", text("200.00"))]); + assert_eq!(page_setup_for((283.46, 566.93), false)[3], ("Name", text("custom_100x200mm"))); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 741d927a3f..65dd1e16e0 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,6 +43,10 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +# Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print. +[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies] +photocraft-ipp = { workspace = true } + [dev-dependencies] photocraft-affinity = { workspace = true, features = ["synth"] } # Synthetic camera raw files for the open tests. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e6486c2a1b..b076769040 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -486,6 +486,9 @@ pub struct Session { /// the same check a top-level request sees. `None` runs everything, which is what a local /// UI and `photocraft-cli run` do. pub authorize: Option Result<()>>, + /// A further route for File › Print when neither `lp` nor a CUPS server over IPP is + /// reachable (the XDG Print portal in a Flatpak); see `print_cmds::Spooler`. + pub print_spooler: Option, /// Background jobs (see [`jobs`]). jobs: jobs::Jobs, /// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size, diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index de16a94ac4..ed0f2cefce 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -8,6 +8,12 @@ //! `"dryRun": true` reports the backend command without spooling; a PDF is kept only at an //! explicit output path. Shared spooler transport files are private and automatically removed. //! +//! Where `lp` is not on the path (a Flatpak has none), the job goes to CUPS over IPP in-process +//! (`photocraft-ipp`, through the sandbox's `--socket=cups`), and when no print server or no +//! printer is reachable, to the app's spooler, the XDG Print portal (`Session::print_spooler`; +//! `PHOTOCRAFT_PRINT_PORTAL=1` prefers it, `=0` removes it). `"printServer"` names a server for +//! one job and always takes the IPP route; `file.printers` lists the queues (#2728). +//! //! Paths to Illustrator writes the document's paths as an Adobe Illustrator 3 compatible //! PostScript file (`m`/`L`/`C` path construction, `n`/`N` unpainted closed/open paths, `*u`/`*U` //! compound paths), following the public PostScript Language Reference and the AI3 format notes. @@ -31,6 +37,191 @@ fn other(e: impl std::fmt::Display) -> EngineError { EngineError::Other(e.to_string()) } +// ---------- Spooling ---------- + +/// A rendered page on its way to the printer: what the IPP route and the app's spooler need. +#[derive(Debug, Clone)] +pub struct PrintJob { + pub pdf: Vec, + /// The printer asked for; `None` means the default. + pub printer: Option, + pub copies: u32, + /// The queue entry's name: the document's. + pub title: String, + /// A print server named in the request (`printServer`), else the system's. + pub server: Option, + /// The page size in points, as laid out (landscape already applied). + pub paper: (f64, f64), + pub landscape: bool, +} + +/// A spooler's work: the job in, where it went out. +pub type SpoolFn = std::sync::Arc std::result::Result + Send + Sync>; + +/// A further route to the printer, installed by the app on `Session::print_spooler`: the XDG +/// Print portal, which shows the system print dialog from inside a sandbox. It takes the jobs +/// no other route can (no print server reachable, no printer set up), or every job when +/// `preferred`. +#[derive(Clone)] +pub struct Spooler { + /// Reported as the job's `backend`. + pub name: &'static str, + pub preferred: bool, + /// Returns where the job went ("the system print dialog"). + pub run: SpoolFn, +} + +/// Why the IPP route could not spool: no route at all (the app's spooler may have one), or a +/// failure on a route that exists (a wrong printer name, a rejected job). +enum SpoolError { + NoRoute(String), + Failed(String), +} + +/// Sends the job and says where it went and through what. A named print server takes the IPP +/// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the +/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had +/// no route either. A route that exists but fails (an unknown printer, a refused job) is an +/// error, never a fall-through. +fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> { + let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other); + if job.server.is_none() { + if let Some(sp) = spooler.filter(|sp| sp.preferred) { + return app(sp); + } + if let Some(name) = system_spooler() { + return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other); + } + } + match cups_spool(job) { + Ok(m) => Ok((m, "ipp".into())), + Err(SpoolError::NoRoute(why)) => match spooler { + Some(sp) => app(sp), + None => Err(other(why)), + }, + Err(SpoolError::Failed(why)) => Err(other(why)), + } +} + +/// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command +/// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the +/// browser. +fn system_spooler() -> Option<&'static str> { + match craft_print::backend()? { + craft_print::Backend::WindowsPdf => Some("windows"), + craft_print::Backend::Cups => { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).any(|dir| dir.join("lp").is_file()).then_some("lp") + } + } +} + +/// The printer a job goes to: the one asked for when the server knows it, else the default, +/// else the only one; otherwise an error that names what there is. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn resolve_printer(requested: Option<&str>, default: Option<&str>, printers: &[photocraft_ipp::Printer]) -> std::result::Result { + let names: Vec<&str> = printers.iter().map(|p| p.name.as_str()).collect(); + let known = |n: &str| names.iter().find(|k| k.eq_ignore_ascii_case(n)).map(|k| (*k).to_string()); + if let Some(r) = requested { + let choices = if names.is_empty() { "none".to_string() } else { names.join(", ") }; + return known(r).ok_or_else(|| SpoolError::Failed(format!("no printer called `{r}`; the print server knows: {choices}"))); + } + if printers.is_empty() { + return Err(SpoolError::NoRoute("no printer is set up on this computer; add one in the system settings, or print to PDF".into())); + } + if let Some(d) = default.and_then(known) { + return Ok(d); + } + match names.as_slice() { + [only] => Ok((*only).to_string()), + _ => Err(SpoolError::Failed(format!("no default printer; choose one in the Printer field: {}", names.join(", ")))), + } +} + +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn cups_server(named: Option<&str>) -> std::result::Result { + match named { + Some(server) => { + let ep = photocraft_ipp::Endpoint::parse(server); + if !ep.reachable() { + return Err(SpoolError::Failed(format!("no print server at {ep}"))); + } + Ok(ep) + } + None => photocraft_ipp::Endpoint::detect() + .ok_or_else(|| SpoolError::NoRoute("no print server: CUPS is not running, or not reachable from this sandbox; print to PDF instead".into())), + } +} + +/// The IPP route: the job to the system's (or the named) CUPS server. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn cups_spool(job: &PrintJob) -> std::result::Result { + let failed = |e: photocraft_ipp::Error| SpoolError::Failed(e.to_string()); + let ep = cups_server(job.server.as_deref())?; + let printers = photocraft_ipp::printers(&ep).map_err(failed)?; + let default = printers.iter().find(|p| p.is_default).map(|p| p.name.clone()); + let printer = resolve_printer(job.printer.as_deref(), default.as_deref(), &printers)?; + let is_class = printers.iter().any(|p| p.name == printer && p.is_class); + let user = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_else(|_| "photocraft".into()); + let id = photocraft_ipp::print_job( + &ep, + &photocraft_ipp::Job { printer: &printer, is_class, title: &job.title, user: &user, copies: job.copies, document: &job.pdf, format: "application/pdf" }, + ) + .map_err(failed)?; + Ok(format!("job {id} on {printer}")) +} + +/// No IPP route here: a named server is a failure, the system's a missing route (the app's +/// spooler may have one). +#[cfg(any(target_arch = "wasm32", target_os = "windows"))] +fn cups_spool(job: &PrintJob) -> std::result::Result { + let why = if cfg!(target_arch = "wasm32") { + "printing needs the desktop app" + } else { + "a print server over IPP is not available on Windows yet; print to PDF instead" + }; + match job.server { + Some(_) => Err(SpoolError::Failed(why.into())), + None => Err(SpoolError::NoRoute(why.into())), + } +} + +/// `file.printers`: the printers the system's (or `printServer`'s) print server knows, with the default. From +/// `craft-print` (`lpstat`, the Windows spooler) where that is the route; over IPP for a named +/// server and where there is no `lp` (a sandbox). +fn system_printers(_s: &mut Session, p: &Value) -> Result { + let named = p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()); + if named.is_none() && system_spooler().is_some() { + let list = craft_print::printers(); + let default = list.iter().find(|p| p.default).map(|p| p.name.clone()); + let printers: Vec = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect(); + return Ok(json!({"server": Value::Null, "default": default, "printers": printers})); + } + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] + { + let ep = match cups_server(named) { + Ok(ep) => ep, + Err(SpoolError::NoRoute(why) | SpoolError::Failed(why)) => { + return Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": why})); + } + }; + let list = photocraft_ipp::printers(&ep).map_err(other)?; + let default = list.iter().find(|p| p.is_default).map(|p| p.name.clone()); + let printers: Vec = list + .iter() + .map(|p| { + json!({"name": p.name, "info": p.info, "state": match p.state { 4 => "printing", 5 => "stopped", _ => "idle" }, "acceptingJobs": p.accepting_jobs, "default": p.is_default, "class": p.is_class}) + }) + .collect(); + Ok(json!({"server": ep.to_string(), "default": default, "printers": printers})) + } + #[cfg(not(all(not(target_arch = "wasm32"), not(target_os = "windows"))))] + { + let _ = named; + Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": "listing printers is not available on this platform yet"})) + } +} + // ---------- PDF ---------- /// Paper sizes in points (portrait). @@ -343,6 +534,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { let params = p.clone(); let worker_params = params.clone(); let command = cmd.to_string(); + let spooler = s.print_spooler.clone(); if send && !dry { // Session::execute remains synchronous; the desktop's Session::start snapshots here // and runs PDF composition and the potentially slow driver on a worker. @@ -352,7 +544,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { false, move |ctx| { ctx.progress(0.0, "Print"); - render_and_spool(&doc, &worker_params, &command) + render_and_spool(&doc, &worker_params, &command, spooler.as_ref()) }, move |s, result| { remember_print(s, ¶ms); @@ -370,13 +562,13 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { }, ); } - let result = render_and_spool(&doc, p, cmd)?; + let result = render_and_spool(&doc, p, cmd, spooler.as_ref())?; remember_print(s, p); crate::automate_cmds::fire_event(s, "print"); Ok(result) } -fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { +fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spooler>) -> Result { let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?; let (img, color) = print_image(doc, p, cmd)?; let description = p @@ -394,7 +586,12 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { // Sending follows the shared command contract; fuzz tests explicitly request dryRun. // The worker returns its result before repeat settings and hooks are applied once. let handling = p.get("colorHandling").and_then(Value::as_str).unwrap_or("printerManages"); - if cfg!(windows) && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) { + // A named print server always takes the IPP route and a preferred app spooler takes every + // job (the route order in `spool_job`), on Windows too: neither may enter the native path + // below, which would print on the local default printer instead. + let named_server = p.get("printServer").and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + let skip_native = named_server || spooler.is_some_and(|sp| sp.preferred); + if cfg!(windows) && !skip_native && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) { require_printing_enabled()?; let printed = print_windows(&page, p, &doc.name, copies)?; return Ok(json!({ @@ -402,10 +599,11 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { "copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, })); } - if cfg!(windows) && send && !dry && output.is_none() && printer_settings(p)?.is_some() { + if cfg!(windows) && !skip_native && send && !dry && output.is_none() && printer_settings(p)?.is_some() { return Err(other("the selected color profile or page text requires PDF printing. Save as PDF, or clear Print Settings to use PDF printing.")); } let pdf = print_pdf(&page); + let pdf_len = pdf.len(); let job = craft_print::Job { printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), copies, @@ -425,13 +623,25 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { }; let mut sent = false; let mut spool = Value::Null; + let mut backend = Value::Null; if send && !dry { require_printing_enabled()?; - spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); + let print_job = PrintJob { + printer: job.printer.clone(), + copies, + title: doc.name.clone(), + server: p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), + paper: (pw, ph), + landscape: p.get("orientation").and_then(Value::as_str) == Some("landscape"), + pdf, + }; + let (where_, via) = spool_job(spooler, &print_job, &job)?; + spool = json!(where_); + backend = json!(via); sent = true; } Ok( - json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}), + json!({"pdf": pdf_path, "bytes": pdf_len, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "backend": backend, "color": color}), ) } @@ -573,15 +783,6 @@ fn require_printing_enabled() -> Result<()> { } } -/// The printers the system spooler knows (Print's Printer menu): CUPS queues and driverless -/// destinations on macOS and Linux, `Win32_Printer` on Windows, none in the browser. -fn system_printers(_: &mut Session, _: &Value) -> Result { - let list = craft_print::printers(); - let default = list.iter().find(|p| p.default).map(|p| p.name.clone()); - let printers: Vec = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect(); - Ok(json!({"printers": printers, "default": default})) -} - fn print(s: &mut Session, p: &Value) -> Result { do_print(s, p, "file.print") } @@ -759,7 +960,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##; + const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"printServer":"host[:port]|socket path"? (default: the system's CUPS; a named server is always printed to over IPP),"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent, spooler:where the job went|null, backend:"lp|windows|ipp|portal"|null}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index d012f6de41..34c1cda6fa 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -360,7 +360,7 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { let printed = &s.documents().iter().find(|st| st.doc.id == source).unwrap().doc; assert_eq!((printed.size.width, printed.size.height), (150, 300), "print hook ran on the source document"); assert_eq!(out["sent"], true); - assert_eq!(out["spooler"], "request id is Office-42"); + assert_eq!((out["spooler"].as_str(), out["backend"].as_str()), (Some("request id is Office-42"), Some("lp"))); assert!(out["pdf"].is_null(), "spool-only printing leaves no diagnostic PDF"); let pdf = std::fs::read(format!("{dir}/received.pdf")).unwrap(); assert!(pdf.starts_with(b"%PDF-1.4")); @@ -434,3 +434,292 @@ fn printers_lists_the_spooler_destinations_without_a_document() { None => assert!(list.iter().all(|p| p["default"] == false)), } } + +/// Spooling tests run in a child process: `disable_printing` is process-wide and a test above +/// sets it, and CI sets `PHOTOCRAFT_NO_PRINT` for the whole shell. True in the child, which runs +/// the test body; the parent starts the child and checks that it passed. +fn in_child(test: &str, envs: &[(&str, &str)]) -> bool { + const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_CHILD"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let mut cmd = std::process::Command::new(std::env::current_exe().unwrap()); + cmd.args(["--exact", test, "--nocapture"]).env(CHILD, "1").env_remove("PHOTOCRAFT_NO_PRINT"); + for (k, v) in envs { + cmd.env(k, v); + } + // Bounded: a child stuck in a printer dialog or a driver must not hang the suite. + let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).spawn().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break Some(status); + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + break None; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + }; + let out = child.wait_with_output().unwrap(); + assert!( + status.is_some_and(|s| s.success()), + "{}\nstdout: {}\nstderr: {}", + if status.is_none() { "the child did not finish within 120 s and was killed" } else { "the child failed" }, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + false +} + +/// A spooler that keeps every job it gets. +fn recording_spooler(preferred: bool, seen: &std::sync::Arc>>) -> Spooler { + let seen = std::sync::Arc::clone(seen); + Spooler { + name: "test", + preferred, + run: std::sync::Arc::new(move |job: &PrintJob| { + seen.lock().unwrap().push(job.clone()); + Ok("queued".into()) + }), + } +} + +#[test] +fn a_preferred_app_spooler_takes_the_job() { + if !in_child("print_cmds::tests::a_preferred_app_spooler_takes_the_job", &[]) { + return; + } + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut s = session("rgb", 8); + s.print_spooler = Some(recording_spooler(true, &seen)); + let r = s.execute("file.print", json!({"copies": 2, "paper": "a4", "orientation": "landscape"})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("test"), Some("queued"))); + assert!(r["pdf"].is_null(), "{r}"); + let jobs = seen.lock().unwrap(); + let job = &jobs[0]; + assert!(job.pdf.starts_with(b"%PDF-1.4")); + assert_eq!((job.copies, job.title.as_str(), job.printer.as_deref(), job.landscape), (2, "Print Me.psd", None, true)); + assert!((job.paper.0 - 841.89).abs() < 0.01 && (job.paper.1 - 595.28).abs() < 0.01); + drop(jobs); + // A named print server asks for the IPP route, preferred spooler or not. + let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(e.contains("no print server at 127.0.0.1:1") || e.contains("not available on Windows"), "{e}"); + assert_eq!(seen.lock().unwrap().len(), 1); + // "output" keeps the file and does not spool unless asked. + let out = format!("{}/out.pdf", tmp("output")); + let r = s.execute("file.print", json!({"output": out})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["pdf"].as_str()), (Some(false), Some(out.as_str()))); + assert!(std::path::Path::new(&out).exists()); +} + +#[cfg(not(target_os = "windows"))] +mod cups { + use super::*; + use photocraft_ipp::{ + Attribute, OP_CUPS_GET_CLASSES, OP_CUPS_GET_DEFAULT, OP_CUPS_GET_PRINTERS, OP_PRINT_JOB, Printer, Request, TAG_JOB_GROUP, TAG_NAME, TAG_PRINTER_GROUP, + }; + use std::io::{BufRead, BufReader, Read, Write}; + use std::sync::mpsc; + + fn printer(name: &str, is_default: bool) -> Printer { + Printer { name: name.into(), info: String::new(), state: 3, accepting_jobs: true, is_default, is_class: false } + } + + #[test] + fn the_printer_is_the_requested_one_else_the_default_else_the_only_one() { + let two = [printer("Office", false), printer("Lab", true)]; + assert_eq!(resolve_printer(None, Some("Lab"), &two).ok(), Some("Lab".into())); + assert_eq!(resolve_printer(Some("office"), Some("Lab"), &two).ok(), Some("Office".into())); + assert_eq!(resolve_printer(None, None, &two[..1]).ok(), Some("Office".into())); + match resolve_printer(None, None, &two) { + Err(SpoolError::Failed(e)) => assert!(e.contains("no default printer") && e.contains("Office, Lab"), "{e}"), + _ => panic!("several printers and no default must fail"), + } + match resolve_printer(Some("Nope"), Some("Lab"), &two) { + Err(SpoolError::Failed(e)) => assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}"), + _ => panic!("an unknown printer must fail"), + } + match resolve_printer(None, None, &[]) { + Err(SpoolError::NoRoute(e)) => assert!(e.contains("no printer is set up"), "{e}"), + _ => panic!("no printers is no route"), + } + // A printer asked for by name on a server without any is a failure, not a missing route. + match resolve_printer(Some("Office"), None, &[]) { + Err(SpoolError::Failed(e)) => assert!(e.contains("`Office`") && e.contains("none"), "{e}"), + _ => panic!("a named printer never falls through"), + } + // A stale default that the server no longer lists falls through to the only printer. + assert_eq!(resolve_printer(None, Some("Gone"), &two[..1]).ok(), Some("Office".into())); + } + + /// What the fake received per request: op, attributes, document. + type Seen = (u16, Vec, Vec); + + /// A fake CUPS on a TCP port: lists `printers` and `classes`, names `default`, accepts jobs + /// as id 42. Answers for as long as the test process lives. + fn fake_cups(printers: &'static [&'static str], classes: &'static [&'static str], default: Option<&'static str>) -> (String, mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let server = format!("127.0.0.1:{}", listener.local_addr().unwrap().port()); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { break }; + let mut reader = BufReader::new(stream); + let (mut first, mut line, mut len) = (String::new(), String::new(), 0usize); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if first.is_empty() { + first = line.clone(); + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap(); + } + } + if first.is_empty() { + continue; // a reachability probe + } + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).unwrap(); + let (op, attrs, doc) = photocraft_ipp::parse_request(&body).unwrap(); + let list = |names: &[&str]| { + if names.is_empty() { + return Request::new(0x0406, 1).end(); + } + let mut r = Request::new(0, 1); + for p in names { + r.group(TAG_PRINTER_GROUP); + r.attr(TAG_NAME, "printer-name", p.as_bytes()); + } + r.end() + }; + let reply = match op { + OP_CUPS_GET_PRINTERS => list(printers), + OP_CUPS_GET_CLASSES => list(classes), + OP_CUPS_GET_DEFAULT => match default { + Some(d) => { + let mut r = Request::new(0, 1); + r.group(TAG_PRINTER_GROUP); + r.attr(TAG_NAME, "printer-name", d.as_bytes()); + r.end() + } + None => Request::new(0x0406, 1).end(), + }, + OP_PRINT_JOB => { + let mut r = Request::new(0, 1); + r.group(TAG_JOB_GROUP); + r.integer("job-id", 42); + r.end() + } + _ => Request::new(0x0501, 1).end(), + }; + let _ = tx.send((op, attrs, doc)); + let mut out = reader.into_inner(); + let _ = out.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes()); + let _ = out.write_all(&reply); + let _ = out.flush(); + } + }); + (server, rx) + } + + #[test] + fn a_named_server_is_printed_to_over_ipp() { + if !in_child("print_cmds::tests::cups::a_named_server_is_printed_to_over_ipp", &[]) { + return; + } + let (server, rx) = fake_cups(&["Office"], &["Lab"], Some("Lab")); + let mut s = session("rgb", 8); + let r = s.execute("file.print", json!({"printServer": server, "copies": 3})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Lab"))); + assert!(r["pdf"].is_null()); + let job = std::iter::from_fn(|| rx.try_recv().ok()).find(|(op, _, _)| *op == OP_PRINT_JOB).expect("a Print-Job request"); + let attr = |name: &str| job.1.iter().find(|a| a.name == name).unwrap_or_else(|| panic!("{name}")); + assert!(attr("printer-uri").text().ends_with("/classes/Lab"), "{}", attr("printer-uri").text()); + assert_eq!( + (attr("copies").int(), attr("job-name").text().as_str(), attr("document-format").text().as_str()), + (Some(3), "Print Me.psd", "application/pdf") + ); + assert!(job.2.starts_with(b"%PDF-1.4")); + // The requested printer, in any case; an unknown one names the choices. + let r = s.execute("file.print", json!({"printServer": server, "printer": "office"})).unwrap(); + assert_eq!(r["spooler"], "job 42 on Office"); + let job = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).last().expect("the second Print-Job"); + assert!(job.1.iter().any(|a| a.name == "printer-uri" && a.text().ends_with("/printers/Office"))); + let e = s.execute("file.print", json!({"printServer": server, "printer": "Nope"})).unwrap_err().to_string(); + assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}"); + // Printers can be listed. + let r = s.execute("file.printers", json!({"printServer": server})).unwrap(); + let names: Vec<&str> = r["printers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!((names, r["default"].as_str()), (vec!["Office", "Lab"], Some("Lab"))); + assert_eq!( + (r["printers"][1]["default"].as_bool(), r["printers"][1]["class"].as_bool(), r["printers"][0]["class"].as_bool()), + (Some(true), Some(true), Some(false)) + ); + } + + #[test] + fn without_a_printer_the_app_spooler_takes_over() { + if !in_child("print_cmds::tests::cups::without_a_printer_the_app_spooler_takes_over", &[]) { + return; + } + let (server, _rx) = fake_cups(&[], &[], None); + let mut s = session("gray", 8); + let e = s.execute("file.print", json!({"printServer": server})).unwrap_err().to_string(); + assert!(e.contains("no printer is set up"), "{e}"); + // Asking for a printer by name never falls through to another route. + let e = s.execute("file.print", json!({"printServer": server, "printer": "Office"})).unwrap_err().to_string(); + assert!(e.contains("`Office`"), "{e}"); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + s.print_spooler = Some(recording_spooler(false, &seen)); + let r = s.execute("file.print", json!({"printServer": server})).unwrap(); + assert_eq!((r["backend"].as_str(), r["spooler"].as_str()), (Some("test"), Some("queued"))); + assert_eq!(seen.lock().unwrap().len(), 1); + // An unreachable named server is a failure, not a missing route. + let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(e.contains("no print server at"), "{e}"); + let r = s.execute("file.printers", json!({"printServer": "127.0.0.1:1"})).unwrap(); + assert!(r["printers"].as_array().unwrap().is_empty() && r["note"].as_str().unwrap().contains("no print server")); + } + + /// The Flatpak case: no `lp` on the path, so the job goes to the system's CUPS over IPP + /// (`CUPS_SERVER` names it here), and with no print server at all to the app's spooler. + /// Runs in a child process, since the path is process-wide. + #[test] + fn without_lp_the_job_goes_over_ipp_then_to_the_app_spooler() { + const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_NO_LP"; + if std::env::var_os(CHILD).is_some() { + let mut s = session("rgb", 8); + let r = s.execute("file.print", json!({"copies": 2})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Office"))); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + s.print_spooler = Some(recording_spooler(false, &seen)); + // The IPP route first, then the app's spooler only when there is no server. + let r = s.execute("file.print", json!({})).unwrap(); + assert_eq!(r["backend"], "ipp"); + assert!(seen.lock().unwrap().is_empty()); + // (CUPS_SERVER is read per job; an unreachable one is no route.) + let r = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(r.contains("no print server at"), "{r}"); + println!("child ok"); + return; + } + let (server, rx) = fake_cups(&["Office"], &[], Some("Office")); + let no_lp = tempfile::tempdir().unwrap(); + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "print_cmds::tests::cups::without_lp_the_job_goes_over_ipp_then_to_the_app_spooler", "--nocapture"]) + .env(CHILD, "1") + .env("PATH", no_lp.path()) + .env("CUPS_SERVER", &server) + .env_remove("PHOTOCRAFT_NO_PRINT") + .output() + .unwrap(); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success() && stdout.contains("child ok"), "stdout: {stdout}\nstderr: {stderr}"); + let jobs = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).count(); + assert_eq!(jobs, 2, "both prints reached the fake CUPS"); + } +} diff --git a/crates/ipp/Cargo.toml b/crates/ipp/Cargo.toml new file mode 100644 index 0000000000..cc9f6aec31 --- /dev/null +++ b/crates/ipp/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "photocraft-ipp" +description = "A small IPP client for CUPS (list printers, default printer, print a PDF), pure Rust, no dependencies" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/ipp/README.md b/crates/ipp/README.md new file mode 100644 index 0000000000..a6c03d1113 --- /dev/null +++ b/crates/ipp/README.md @@ -0,0 +1,22 @@ +# photocraft-ipp + +A small IPP (Internet Printing Protocol, RFC 8010/8011) client for the local CUPS server, in pure +Rust with no dependencies. It does in-process what `lp` does from the command line, so PhotoCraft +can print where there is no `lp` binary (a Flatpak sandbox with `--socket=cups`, a minimal +container) and can read the printer list and the default printer first instead of handing +CUPS's errors to the user. + +```rust +let ep = photocraft_ipp::Endpoint::detect().ok_or("no CUPS server")?; // socket, then localhost:631 +let printers = photocraft_ipp::printers(&ep)?; // name, info, state, default +let id = photocraft_ipp::print_job(&ep, &photocraft_ipp::Job { printer: "Office", title: "photo", user: "me", copies: 2, document: &pdf, format: "application/pdf" })?; +``` + +Three operations: `CUPS-Get-Printers`, `CUPS-Get-Default`, `Print-Job`. HTTP/1.1 over the CUPS +domain socket (`/run/cups/cups.sock`, or `CUPS_SERVER` as a path) or TCP (`localhost:631`, or +`CUPS_SERVER` as `host[:port]`), chunked replies included. Replies are capped at 16 MiB and +parsed with bounds checks; an IPP error status becomes `Error::Status` with the server's own +message. Tests run against an in-process fake server over TCP and a Unix socket. + +Standalone: it depends on no other PhotoCraft crate. Used by `photocraft-engine`'s print +commands on Unix; Windows printing lives in `photocraft-winprint`. diff --git a/crates/ipp/src/lib.rs b/crates/ipp/src/lib.rs new file mode 100644 index 0000000000..9f1209df45 --- /dev/null +++ b/crates/ipp/src/lib.rs @@ -0,0 +1,600 @@ +//! `photocraft-ipp`: a small IPP client for CUPS, in pure Rust with no dependencies. +//! +//! Talks to the local print server the way `lp` does, but in-process: over the CUPS domain +//! socket (`/run/cups/cups.sock`, or `CUPS_SERVER` when it names a path) or TCP (`localhost:631`, +//! or `CUPS_SERVER` as `host[:port]`). Three operations, from RFC 8010 / 8011 and CUPS's own +//! extensions: list the printers, read the default, and submit a PDF as a job. +//! +//! Never panics: every read is bounded and every malformed reply is an error. Works on any Unix +//! and on Windows for TCP (CUPS rarely runs there; the Windows print path is separate). + +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::PathBuf; +use std::time::Duration; + +/// Where the print server listens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Endpoint { + /// A Unix domain socket (CUPS's default, also what a Flatpak sees with `--socket=cups`). + Unix(PathBuf), + /// A TCP host and port. + Tcp(String, u16), +} + +/// Why a request failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// No server could be reached. + Unreachable(String), + /// The HTTP or IPP reply could not be read. + Protocol(String), + /// The server answered with an IPP error status (the code and its message). + Status(u16, String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Unreachable(m) => write!(f, "no print server: {m}"), + Error::Protocol(m) => write!(f, "print server reply: {m}"), + Error::Status(code, m) => write!(f, "print server refused ({code:#06x}): {m}"), + } + } +} + +impl std::error::Error for Error {} + +type Result = std::result::Result; + +/// A printer the server knows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Printer { + pub name: String, + /// `printer-info`, the human description. + pub info: String, + /// `printer-state`: 3 idle, 4 printing, 5 stopped. + pub state: u32, + pub accepting_jobs: bool, + pub is_default: bool, + /// A CUPS class (a pool of printers) rather than one printer. + pub is_class: bool, +} + +/// Timeouts: connecting, and each request (a Print-Job carries the whole PDF). +const CONNECT: Duration = Duration::from_secs(3); +const REQUEST: Duration = Duration::from_secs(60); +/// Largest reply read. +const MAX_REPLY: usize = 16 << 20; +/// Largest number of attributes taken from a reply. +const MAX_ATTRIBUTES: usize = 100_000; + +pub const OP_PRINT_JOB: u16 = 0x0002; +pub const OP_CUPS_GET_DEFAULT: u16 = 0x4001; +pub const OP_CUPS_GET_PRINTERS: u16 = 0x4002; +pub const OP_CUPS_GET_CLASSES: u16 = 0x4005; + +pub const TAG_OPERATION_GROUP: u8 = 0x01; +pub const TAG_JOB_GROUP: u8 = 0x02; +pub const TAG_END: u8 = 0x03; +pub const TAG_PRINTER_GROUP: u8 = 0x04; +pub const TAG_INTEGER: u8 = 0x21; +pub const TAG_BOOLEAN: u8 = 0x22; +pub const TAG_ENUM: u8 = 0x23; +pub const TAG_TEXT: u8 = 0x41; +pub const TAG_NAME: u8 = 0x42; +pub const TAG_KEYWORD: u8 = 0x44; +pub const TAG_URI: u8 = 0x45; +pub const TAG_CHARSET: u8 = 0x47; +pub const TAG_LANGUAGE: u8 = 0x48; +pub const TAG_MIME: u8 = 0x49; + +impl Endpoint { + /// The server this machine uses: `CUPS_SERVER` (a socket path, or `host[:port]`), else the + /// CUPS socket, else `localhost:631`. `None` when nothing answers. + pub fn detect() -> Option { + if let Some(server) = std::env::var_os("CUPS_SERVER").filter(|v| !v.is_empty()) { + let ep = Endpoint::parse(&server.to_string_lossy()); + return ep.reachable().then_some(ep); + } + [ + Endpoint::Unix(PathBuf::from("/run/cups/cups.sock")), + Endpoint::Unix(PathBuf::from("/var/run/cups/cups.sock")), + // macOS + Endpoint::Unix(PathBuf::from("/private/var/run/cupsd")), + Endpoint::Tcp("localhost".into(), 631), + ] + .into_iter() + .find(Endpoint::reachable) + } + + /// `server` as a socket path (starts with `/`) or `host[:port]` (port 631 by default). + pub fn parse(server: &str) -> Endpoint { + if server.starts_with('/') { + return Endpoint::Unix(PathBuf::from(server)); + } + let (host, port) = server.rsplit_once(':').and_then(|(h, p)| p.parse().ok().map(|p| (h.to_string(), p))).unwrap_or((server.to_string(), 631)); + Endpoint::Tcp(host, port) + } + + /// Whether a connection opens. + pub fn reachable(&self) -> bool { + self.connect().is_ok() + } + + fn connect(&self) -> Result { + match self { + #[cfg(unix)] + Endpoint::Unix(path) => { + if !path.exists() { + return Err(Error::Unreachable(format!("{} does not exist", path.display()))); + } + let s = std::os::unix::net::UnixStream::connect(path).map_err(|e| Error::Unreachable(format!("{}: {e}", path.display())))?; + let _ = s.set_read_timeout(Some(REQUEST)); + let _ = s.set_write_timeout(Some(REQUEST)); + Ok(Stream::Unix(s)) + } + #[cfg(not(unix))] + Endpoint::Unix(path) => Err(Error::Unreachable(format!("{}: Unix sockets are not available here", path.display()))), + Endpoint::Tcp(host, port) => { + let addr = (host.as_str(), *port).to_socket_addrs().map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?.next(); + let addr = addr.ok_or_else(|| Error::Unreachable(format!("{host}:{port}: no address")))?; + let s = TcpStream::connect_timeout(&addr, CONNECT).map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?; + let _ = s.set_read_timeout(Some(REQUEST)); + let _ = s.set_write_timeout(Some(REQUEST)); + Ok(Stream::Tcp(s)) + } + } + } + + fn host(&self) -> String { + match self { + Endpoint::Unix(_) => "localhost".into(), + Endpoint::Tcp(host, port) => format!("{host}:{port}"), + } + } +} + +impl std::fmt::Display for Endpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Endpoint::Unix(p) => write!(f, "{}", p.display()), + Endpoint::Tcp(h, p) => write!(f, "{h}:{p}"), + } + } +} + +enum Stream { + #[cfg(unix)] + Unix(std::os::unix::net::UnixStream), + Tcp(TcpStream), +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + #[cfg(unix)] + 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 { + match self { + #[cfg(unix)] + Stream::Unix(s) => s.write(buf), + Stream::Tcp(s) => s.write(buf), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + #[cfg(unix)] + Stream::Unix(s) => s.flush(), + Stream::Tcp(s) => s.flush(), + } + } +} + +/// Builds an IPP request body. +pub struct Request { + buf: Vec, +} + +impl Request { + pub fn new(op: u16, id: u32) -> Self { + let mut buf = vec![2, 0]; + buf.extend_from_slice(&op.to_be_bytes()); + buf.extend_from_slice(&id.to_be_bytes()); + buf.push(TAG_OPERATION_GROUP); + let mut r = Request { buf }; + r.attr(TAG_CHARSET, "attributes-charset", b"utf-8"); + r.attr(TAG_LANGUAGE, "attributes-natural-language", b"en"); + r + } + + /// Appends an attribute to the current group. + pub fn attr(&mut self, tag: u8, name: &str, value: &[u8]) { + // IPP lengths are 16-bit; longer names and values are cut, both the length and the bytes. + let name = name.as_bytes().get(..name.len().min(0xffff)).unwrap_or_default(); + let value = value.get(..value.len().min(0xffff)).unwrap_or_default(); + self.buf.push(tag); + self.buf.extend_from_slice(&(name.len() as u16).to_be_bytes()); + self.buf.extend_from_slice(name); + self.buf.extend_from_slice(&(value.len() as u16).to_be_bytes()); + self.buf.extend_from_slice(value); + } + + /// An attribute with several values (the extra ones carry an empty name). + pub fn attr_many(&mut self, tag: u8, name: &str, values: &[&str]) { + for (i, v) in values.iter().enumerate() { + self.attr(tag, if i == 0 { name } else { "" }, v.as_bytes()); + } + } + + pub fn integer(&mut self, name: &str, v: i32) { + self.attr(TAG_INTEGER, name, &v.to_be_bytes()); + } + + /// Starts a new attribute group. + pub fn group(&mut self, tag: u8) { + self.buf.push(tag); + } + + /// The message with its end tag. + pub fn end(mut self) -> Vec { + self.buf.push(TAG_END); + self.buf + } +} + +/// One attribute of a reply: the group it sits in, its tag, name and values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attribute { + pub group: u8, + pub tag: u8, + pub name: String, + pub values: Vec>, +} + +impl Attribute { + /// The first value as text. + pub fn text(&self) -> String { + self.values.first().map(|v| String::from_utf8_lossy(v).into_owned()).unwrap_or_default() + } + /// The first value as a big-endian integer. + pub fn int(&self) -> Option { + self.values.first().and_then(|v| <[u8; 4]>::try_from(v.as_slice()).ok()).map(i32::from_be_bytes) + } +} + +/// A decoded reply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Reply { + pub status: u16, + pub request_id: u32, + pub attributes: Vec, +} + +impl Reply { + /// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag. + pub fn parse(b: &[u8]) -> Result { + Self::parse_at(b).map(|(r, _)| r) + } + + /// Parses a message and returns where its attributes end (document data may follow). + fn parse_at(b: &[u8]) -> Result<(Reply, usize)> { + let bad = |m: &str| Error::Protocol(m.to_string()); + if b.len() < 9 { + return Err(bad("reply shorter than an IPP header")); + } + let status = u16::from_be_bytes([b[2], b[3]]); + let request_id = u32::from_be_bytes([b[4], b[5], b[6], b[7]]); + let mut pos = 8; + let mut group = 0u8; + let mut attributes: Vec = Vec::new(); + loop { + let tag = *b.get(pos).ok_or_else(|| bad("reply ends before the end-of-attributes tag"))?; + pos += 1; + if tag == TAG_END { + return Ok((Reply { status, request_id, attributes }, pos)); + } + if tag < 0x10 { + group = tag; + continue; + } + let name_len = + usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?])); + pos += 2; + let name = b.get(pos..pos + name_len).ok_or_else(|| bad("cut attribute name"))?; + pos += name_len; + let value_len = + usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?])); + pos += 2; + let value = b.get(pos..pos + value_len).ok_or_else(|| bad("cut attribute value"))?.to_vec(); + pos += value_len; + if name_len == 0 { + // An additional value of the previous attribute. + match attributes.last_mut() { + Some(last) if last.group == group => last.values.push(value), + _ => return Err(bad("an additional value with no attribute before it")), + } + } else { + attributes.push(Attribute { group, tag, name: String::from_utf8_lossy(name).into_owned(), values: vec![value] }); + } + if attributes.len() > MAX_ATTRIBUTES { + return Err(bad("too many attributes")); + } + } + } + + /// The server's own message for an error status, or a name for the code. + fn status_message(&self) -> String { + self.attributes.iter().find(|a| a.name == "status-message").map(Attribute::text).filter(|m| !m.is_empty()).unwrap_or_else(|| { + match self.status { + 0x0400 => "bad request", + 0x0401 => "forbidden", + 0x0402 => "not authenticated", + 0x0403 => "not authorized", + 0x0406 => "not found", + 0x0409 => "unsupported document format", + 0x0500 => "internal server error", + 0x0506 => "the printer is not accepting jobs", + _ => "error", + } + .to_string() + }) + } +} + +/// POSTs `body` (IPP, with any document data after it) to `path` and returns the reply. +fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result { + let mut stream = endpoint.connect()?; + let head = format!( + "POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + endpoint.host(), + body.len() + ); + stream + .write_all(head.as_bytes()) + .and_then(|()| stream.write_all(body)) + .and_then(|()| stream.flush()) + .map_err(|e| Error::Protocol(format!("sending: {e}")))?; + let raw = read_response(&mut stream)?; + let (status, headers, body) = http_response(&raw)?; + if status != 200 { + return Err(Error::Protocol(match status { + 401 => "the print server asks for authentication (HTTP 401)".to_string(), + 403 => "the print server forbids this request (HTTP 403)".to_string(), + s => format!("HTTP {s}"), + })); + } + let body = if headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")) { dechunk(body)? } else { body.to_vec() }; + let reply = Reply::parse(&body)?; + if reply.status >= 0x0400 { + return Err(Error::Status(reply.status, reply.status_message())); + } + Ok(reply) +} + +/// Lowercase HTTP headers. +type Headers = Vec<(String, String)>; + +/// Reads one HTTP response: the headers, then as much body as `Content-Length` says, or up to +/// the last chunk, or to the end of the stream when the server says neither. A server that keeps +/// the connection open would otherwise hold the reply until the read times out. +fn read_response(stream: &mut Stream) -> Result> { + let mut raw = Vec::new(); + let mut buf = [0u8; 16 * 1024]; + let mut body_at = None; + let mut expected: Option = None; + let mut chunked = false; + loop { + let n = stream.read(&mut buf).map_err(|e| Error::Protocol(format!("reading: {e}")))?; + if n == 0 { + return Ok(raw); + } + raw.extend_from_slice(buf.get(..n).unwrap_or_default()); + if raw.len() > MAX_REPLY { + return Err(Error::Protocol("the reply is larger than 16 MiB".into())); + } + if body_at.is_none() + && let Some(end) = raw.windows(4).position(|w| w == b"\r\n\r\n") + { + body_at = Some(end + 4); + let (_, headers, _) = http_response(&raw)?; + expected = headers.iter().find(|(k, _)| k == "content-length").and_then(|(_, v)| v.parse().ok()); + chunked = headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")); + } + let Some(at) = body_at else { continue }; + let body = raw.get(at..).unwrap_or_default(); + let complete = match expected { + Some(len) if !chunked => body.len() >= len, + _ if chunked => chunked_complete(body), + _ => false, + }; + if complete { + if let Some(len) = expected.filter(|_| !chunked) { + raw.truncate(at + len); + } + return Ok(raw); + } + } +} + +/// Splits an HTTP/1.1 response into status, lowercase headers and body. +fn http_response(raw: &[u8]) -> Result<(u16, Headers, &[u8])> { + let end = raw.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| Error::Protocol("no HTTP header".into()))?; + let head = String::from_utf8_lossy(raw.get(..end).unwrap_or(&[])); + let mut lines = head.split("\r\n"); + let status_line = lines.next().unwrap_or_default(); + let status: u16 = + status_line.split_whitespace().nth(1).and_then(|s| s.parse().ok()).ok_or_else(|| Error::Protocol(format!("bad status line `{status_line}`")))?; + let headers = lines.filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_ascii_lowercase())).collect(); + Ok((status, headers, raw.get(end + 4..).unwrap_or(&[]))) +} + +/// Whether a chunked body has reached its last (zero-size) chunk, walking the chunk sizes so a +/// `0\r\n\r\n` inside a chunk's payload (an attribute value can hold it) does not count. A +/// malformed or impossible size counts as complete, so [`dechunk`] reports it instead of +/// waiting for more. +fn chunked_complete(mut b: &[u8]) -> bool { + loop { + let Some(line_end) = b.windows(2).position(|w| w == b"\r\n") else { return false }; + let size_str = String::from_utf8_lossy(b.get(..line_end).unwrap_or(&[])); + let Ok(size) = usize::from_str_radix(size_str.split(';').next().unwrap_or_default().trim(), 16) else { return true }; + b = b.get(line_end + 2..).unwrap_or(&[]); + if size == 0 { + return true; + } + // A size past the reply cap can never be satisfied: complete, so `dechunk` rejects it + // (checked, so `ffffffffffffffff` cannot overflow the slice arithmetic). + let Some(end) = size.checked_add(2).filter(|_| size <= MAX_REPLY) else { return true }; + let Some(rest) = b.get(end..) else { return false }; + b = rest; + } +} + +/// Joins the chunks of a chunked transfer encoding. +fn dechunk(mut b: &[u8]) -> Result> { + let mut out = Vec::new(); + loop { + let line_end = b.windows(2).position(|w| w == b"\r\n").ok_or_else(|| Error::Protocol("cut chunk size".into()))?; + let size_str = String::from_utf8_lossy(b.get(..line_end).unwrap_or(&[])); + let size = usize::from_str_radix(size_str.split(';').next().unwrap_or_default().trim(), 16).map_err(|_| Error::Protocol("bad chunk size".into()))?; + b = b.get(line_end + 2..).unwrap_or(&[]); + if size == 0 { + return Ok(out); + } + let chunk = b.get(..size).ok_or_else(|| Error::Protocol("cut chunk".into()))?; + out.extend_from_slice(chunk); + if out.len() > MAX_REPLY { + return Err(Error::Protocol("the reply is larger than 16 MiB".into())); + } + b = b.get(size + 2..).ok_or_else(|| Error::Protocol("cut chunk end".into()))?; + } +} + +/// Where `/path` lands on the server: `/printers/` or `/classes/`. +fn queue_path(name: &str, is_class: bool) -> String { + format!("/{}/{name}", if is_class { "classes" } else { "printers" }) +} + +/// The printers the server knows (CUPS-Get-Printers), then its classes (CUPS-Get-Classes, which +/// `lp -d` accepts just the same), the default one flagged. +pub fn printers(endpoint: &Endpoint) -> Result> { + let mut out = queues(endpoint, OP_CUPS_GET_PRINTERS, false)?; + // A server without classes answers "not found"; an older server may not know the operation. + out.extend(queues(endpoint, OP_CUPS_GET_CLASSES, true).unwrap_or_default()); + let default = default_printer(endpoint).unwrap_or(None); + for p in &mut out { + p.is_default = default.as_deref() == Some(p.name.as_str()); + } + Ok(out) +} + +fn queues(endpoint: &Endpoint, op: u16, is_class: bool) -> Result> { + let mut r = Request::new(op, 1); + r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name", "printer-info", "printer-state", "printer-is-accepting-jobs"]); + let reply = match post(endpoint, "/", &r.end()) { + Ok(reply) => reply, + // CUPS answers "not found" ("No destinations added.") when no printer is set up. + Err(Error::Status(0x0406, _)) => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + let mut out = Vec::new(); + let mut current: Option = None; + for a in reply.attributes.iter().filter(|a| a.group == TAG_PRINTER_GROUP) { + match a.name.as_str() { + "printer-name" => { + if let Some(p) = current.take() { + out.push(p); + } + current = Some(Printer { name: a.text(), info: String::new(), state: 3, accepting_jobs: true, is_default: false, is_class }); + } + "printer-info" => { + if let Some(p) = &mut current { + p.info = a.text(); + } + } + "printer-state" => { + if let Some(p) = &mut current { + p.state = a.int().unwrap_or(3).clamp(0, 5) as u32; + } + } + "printer-is-accepting-jobs" => { + if let Some(p) = &mut current { + p.accepting_jobs = a.values.first().is_none_or(|v| v.first() != Some(&0)); + } + } + _ => {} + } + } + out.extend(current); + Ok(out) +} + +/// The server's default printer (CUPS-Get-Default), `None` when there is none. +pub fn default_printer(endpoint: &Endpoint) -> Result> { + let mut r = Request::new(OP_CUPS_GET_DEFAULT, 2); + r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name"]); + match post(endpoint, "/", &r.end()) { + Ok(reply) => Ok(reply.attributes.iter().find(|a| a.name == "printer-name").map(Attribute::text).filter(|n| !n.is_empty())), + // CUPS answers "not found" when no default is set. + Err(Error::Status(0x0406, _)) => Ok(None), + Err(e) => Err(e), + } +} + +/// What to print. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Job<'a> { + pub printer: &'a str, + /// `printer` names a class. + pub is_class: bool, + /// Shown in the queue. + pub title: &'a str, + pub user: &'a str, + pub copies: u32, + /// The document, as `format` (`application/pdf`). + pub document: &'a [u8], + pub format: &'a str, +} + +/// Submits `job` (Print-Job) and returns the job id. +pub fn print_job(endpoint: &Endpoint, job: &Job<'_>) -> Result { + let mut r = Request::new(OP_PRINT_JOB, 3); + let path = queue_path(job.printer, job.is_class); + r.attr(TAG_URI, "printer-uri", format!("ipp://{}{path}", endpoint.host()).as_bytes()); + r.attr(TAG_NAME, "requesting-user-name", job.user.as_bytes()); + r.attr(TAG_NAME, "job-name", job.title.as_bytes()); + r.attr(TAG_MIME, "document-format", job.format.as_bytes()); + if job.copies > 1 { + r.buf.push(TAG_JOB_GROUP); + r.integer("copies", job.copies.clamp(1, 999) as i32); + } + let mut body = r.end(); + body.extend_from_slice(job.document); + let reply = post(endpoint, &path, &body)?; + reply + .attributes + .iter() + .find(|a| a.name == "job-id") + .and_then(Attribute::int) + .map(|id| id.max(0) as u32) + .ok_or_else(|| Error::Protocol("no job id in the reply".into())) +} + +/// A request as a server sees it: the operation (the request's status field), its attributes +/// and the document data after the end tag. For fakes in tests and tools. +pub fn parse_request(b: &[u8]) -> Result<(u16, Vec, Vec)> { + let (reply, end) = Reply::parse_at(b)?; + Ok((reply.status, reply.attributes, b.get(end..).unwrap_or(&[]).to_vec())) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ipp/src/tests.rs b/crates/ipp/src/tests.rs new file mode 100644 index 0000000000..ff3ccb6a49 --- /dev/null +++ b/crates/ipp/src/tests.rs @@ -0,0 +1,395 @@ +//! The client against an in-process fake CUPS: TCP and Unix socket, content-length and chunked +//! replies, every operation, error statuses and broken replies. + +use super::*; +use std::io::{BufRead, BufReader}; +use std::sync::mpsc; + +/// What the fake received: op, attributes, document. +type Seen = (u16, Vec, Vec); + +/// A fake server answering one request with `reply_body` (IPP bytes), chunked or not, under an +/// HTTP status. Returns the endpoint and a channel with what it received. +fn fake(reply: Vec, chunked: bool, http_status: u16) -> (Endpoint, mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + serve(Stream::Tcp(stream), reply, chunked, http_status, &tx); + }); + (Endpoint::Tcp("127.0.0.1".into(), port), rx) +} + +fn serve(stream: Stream, reply: Vec, chunked: bool, http_status: u16, tx: &mpsc::Sender) { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + let mut content_length = 0usize; + let mut first = String::new(); + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if first.is_empty() { + first = line.clone(); + } + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = v.trim().parse().unwrap(); + } + } + if first.is_empty() { + return; // a reachability probe: connect and close + } + assert!(first.starts_with("POST "), "{first}"); + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body).unwrap(); + let _ = tx.send(parse_request(&body).unwrap()); + let mut out = reader.into_inner(); + let status_text = if http_status == 200 { "OK" } else { "Nope" }; + if chunked { + let mut resp = format!("HTTP/1.1 {http_status} {status_text}\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n").into_bytes(); + for chunk in reply.chunks(7) { + resp.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes()); + resp.extend_from_slice(chunk); + resp.extend_from_slice(b"\r\n"); + } + resp.extend_from_slice(b"0\r\n\r\n"); + out.write_all(&resp).unwrap(); + } else { + out.write_all(format!("HTTP/1.1 {http_status} {status_text}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes()) + .unwrap(); + out.write_all(&reply).unwrap(); + } + out.flush().unwrap(); +} + +/// An attribute to write: tag, name, values. +type Attr<'a> = (u8, &'a str, &'a [&'a [u8]]); +/// A group to write: tag and attributes. +type Group<'a> = (u8, &'a [Attr<'a>]); + +/// An IPP reply body. +fn ipp(status: u16, groups: &[Group<'_>]) -> Vec { + let mut b = vec![2, 0]; + b.extend_from_slice(&status.to_be_bytes()); + b.extend_from_slice(&1u32.to_be_bytes()); + for (group, attrs) in groups { + b.push(*group); + for (tag, name, values) in *attrs { + for (i, v) in values.iter().enumerate() { + b.push(*tag); + let n = if i == 0 { name.as_bytes() } else { b"" }; + b.extend_from_slice(&(n.len() as u16).to_be_bytes()); + b.extend_from_slice(n); + b.extend_from_slice(&(v.len() as u16).to_be_bytes()); + b.extend_from_slice(v); + } + } + } + b.push(TAG_END); + b +} + +fn ok_header() -> Group<'static> { + (TAG_OPERATION_GROUP, &[(TAG_CHARSET, "attributes-charset", &[b"utf-8"]), (TAG_LANGUAGE, "attributes-natural-language", &[b"en"])]) +} + +#[test] +fn printers_are_listed_with_state_and_default() { + let three = ipp( + 0, + &[ + ok_header(), + ( + TAG_PRINTER_GROUP, + &[ + (TAG_NAME, "printer-name", &[b"Office"]), + (TAG_TEXT, "printer-info", &[b"HP in the office"]), + (TAG_ENUM, "printer-state", &[&3u32.to_be_bytes()]), + (TAG_BOOLEAN, "printer-is-accepting-jobs", &[&[1]]), + ], + ), + ( + TAG_PRINTER_GROUP, + &[ + (TAG_NAME, "printer-name", &[b"Stopped"]), + (TAG_ENUM, "printer-state", &[&5u32.to_be_bytes()]), + (TAG_BOOLEAN, "printer-is-accepting-jobs", &[&[0]]), + ], + ), + ], + ); + for chunked in [false, true] { + let (ep, rx) = fake(three.clone(), chunked, 200); + // `printers` asks for the default too; that second request reaches a server that has + // closed, so the default is simply unknown here. + let list = printers(&ep).unwrap(); + let (op, attrs, doc) = rx.recv().unwrap(); + assert_eq!(op, OP_CUPS_GET_PRINTERS); + assert!(attrs.iter().any(|a| a.name == "requested-attributes" && a.values.len() == 4 && a.tag == TAG_KEYWORD)); + assert!(doc.is_empty()); + assert_eq!(list.len(), 2, "chunked {chunked}"); + assert_eq!((list[0].name.as_str(), list[0].info.as_str(), list[0].state, list[0].accepting_jobs), ("Office", "HP in the office", 3, true)); + assert_eq!((list[1].name.as_str(), list[1].state, list[1].accepting_jobs), ("Stopped", 5, false)); + assert!(!list[0].is_default && !list[1].is_default); + } +} + +#[test] +fn default_printer_is_read_and_absence_is_not_an_error() { + let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]), false, 200); + assert_eq!(default_printer(&ep).unwrap(), Some("Office".into())); + assert_eq!(rx.recv().unwrap().0, OP_CUPS_GET_DEFAULT); + let (ep, _rx) = fake(ipp(0x0406, &[ok_header(), (TAG_OPERATION_GROUP, &[(TAG_TEXT, "status-message", &[b"No default printer"])])]), false, 200); + assert_eq!(default_printer(&ep).unwrap(), None); +} + +#[test] +fn a_print_job_carries_the_document_and_returns_its_id() { + let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_JOB_GROUP, &[(TAG_INTEGER, "job-id", &[&42u32.to_be_bytes()])])]), true, 200); + let pdf = b"%PDF-1.4 fake".to_vec(); + let id = + print_job(&ep, &Job { printer: "Office", is_class: false, title: "photo.psd", user: "diego", copies: 2, document: &pdf, format: "application/pdf" }) + .unwrap(); + assert_eq!(id, 42); + let (op, attrs, doc) = rx.recv().unwrap(); + assert_eq!(op, OP_PRINT_JOB); + assert_eq!(doc, pdf); + let get = |n: &str| attrs.iter().find(|a| a.name == n).cloned().unwrap_or_else(|| panic!("{n}")); + assert_eq!(get("printer-uri").values[0], format!("ipp://{}/printers/Office", ep.host()).as_bytes()); + assert_eq!(get("job-name").values[0], b"photo.psd"); + assert_eq!(get("requesting-user-name").values[0], b"diego"); + assert_eq!(get("document-format").values[0], b"application/pdf"); + let copies = get("copies"); + assert_eq!((copies.group, copies.tag, copies.int()), (TAG_JOB_GROUP, TAG_INTEGER, Some(2))); + // One copy: no job attributes group at all. + let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_JOB_GROUP, &[(TAG_INTEGER, "job-id", &[&7u32.to_be_bytes()])])]), false, 200); + print_job(&ep, &Job { printer: "P", is_class: false, title: "t", user: "u", copies: 1, document: b"x", format: "application/pdf" }).unwrap(); + assert!(!rx.recv().unwrap().1.iter().any(|a| a.name == "copies")); +} + +#[test] +fn error_statuses_and_broken_replies_are_errors() { + let (ep, _rx) = fake(ipp(0x0506, &[ok_header(), (TAG_OPERATION_GROUP, &[(TAG_TEXT, "status-message", &[b"Printer is not accepting jobs"])])]), false, 200); + let e = print_job(&ep, &Job { printer: "P", is_class: false, title: "t", user: "u", copies: 1, document: b"x", format: "application/pdf" }).unwrap_err(); + assert_eq!(e, Error::Status(0x0506, "Printer is not accepting jobs".into())); + assert!(e.to_string().contains("0x0506")); + // A CUPS with no printer at all answers "not found": that is an empty list, not an error. + let (ep, _rx) = fake(ipp(0x0406, &[ok_header(), (TAG_OPERATION_GROUP, &[(TAG_TEXT, "status-message", &[b"No destinations added."])])]), false, 200); + assert_eq!(printers(&ep).unwrap(), Vec::::new()); + // HTTP 401, a reply cut short, garbage. + let (ep, _rx) = fake(ipp(0, &[ok_header()]), false, 401); + assert!(matches!(printers(&ep), Err(Error::Protocol(m)) if m.contains("401"))); + let (ep, _rx) = fake(vec![2, 0, 0, 0, 0, 0, 0, 1, TAG_OPERATION_GROUP, TAG_NAME, 0, 5], true, 200); + assert!(matches!(printers(&ep), Err(Error::Protocol(_)))); + let (ep, _rx) = fake(b"not ipp at all".to_vec(), false, 200); + assert!(matches!(printers(&ep), Err(Error::Protocol(_)))); + // Nobody listening. + let e = printers(&Endpoint::Tcp("127.0.0.1".into(), 1)).unwrap_err(); + assert!(matches!(e, Error::Unreachable(_)), "{e}"); +} + +#[test] +fn reply_parsing_is_bounded() { + assert!(Reply::parse(&[]).is_err()); + assert!(Reply::parse(&[2, 0, 0, 0, 0, 0, 0, 1]).is_err(), "no end tag"); + let mut many = vec![2, 0, 0, 0, 0, 0, 0, 1, TAG_OPERATION_GROUP]; + for _ in 0..MAX_ATTRIBUTES + 1 { + many.extend_from_slice(&[TAG_NAME, 0, 1, b'a', 0, 0]); + } + many.push(TAG_END); + assert!(matches!(Reply::parse(&many), Err(Error::Protocol(m)) if m.contains("too many"))); + // An additional value before any attribute. + assert!(Reply::parse(&[2, 0, 0, 0, 0, 0, 0, 1, TAG_OPERATION_GROUP, TAG_NAME, 0, 0, 0, 1, b'x', TAG_END]).is_err()); + assert!(dechunk(b"zz\r\n").is_err() && dechunk(b"5\r\nab").is_err()); + assert_eq!(dechunk(b"3\r\nabc\r\n0\r\n\r\n").unwrap(), b"abc"); +} + +#[cfg(unix)] +#[test] +fn the_unix_socket_endpoint_works_and_detect_honours_cups_server() { + let dir = std::env::temp_dir().join(format!("pc-ipp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let sock = dir.join("cups.sock"); + let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap(); + let (tx, rx) = mpsc::channel(); + let reply = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Sock"])])]); + std::thread::spawn(move || { + for _ in 0..3 { + let (stream, _) = listener.accept().unwrap(); + serve(Stream::Unix(stream), reply.clone(), false, 200, &tx); + } + }); + let ep = Endpoint::Unix(sock.clone()); + assert!(ep.reachable()); + assert_eq!(default_printer(&ep).unwrap(), Some("Sock".into())); + assert_eq!(rx.recv().unwrap().0, OP_CUPS_GET_DEFAULT); + assert!(!Endpoint::Unix(dir.join("missing.sock")).reachable()); + assert_eq!(ep.to_string(), sock.display().to_string()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn requests_start_with_the_mandatory_attributes() { + let r = Request::new(OP_PRINT_JOB, 9).end(); + let (op, attrs, doc) = parse_request(&r).unwrap(); + assert_eq!(op, OP_PRINT_JOB); + assert_eq!(Reply::parse(&r).unwrap().request_id, 9); + assert_eq!(attrs[0].name, "attributes-charset"); + assert_eq!(attrs[1].name, "attributes-natural-language"); + assert!(doc.is_empty()); +} + +/// A server that keeps the connection open after its reply (a printer reached directly, a proxy) +/// must not hold the client until the read times out: the reply ends at Content-Length, or at +/// the last chunk. +#[test] +fn replies_end_at_content_length_or_the_last_chunk_without_waiting_for_close() { + for chunked in [false, true] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let one = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]); + let none = ipp(0x0406, &[ok_header()]); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { return }; + let mut reader = BufReader::new(stream); + let (mut line, mut len) = (String::new(), 0usize); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + if len == 0 { + continue; + } + let mut body = vec![0u8; len]; + if reader.read_exact(&mut body).is_err() { + continue; + } + let op = parse_request(&body).map(|r| r.0).unwrap_or(0); + let reply = if op == OP_CUPS_GET_PRINTERS { one.clone() } else { none.clone() }; + let mut out = reader.into_inner(); + let mut resp = Vec::new(); + if chunked { + resp.extend_from_slice(b"HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n"); + for chunk in reply.chunks(5) { + resp.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes()); + resp.extend_from_slice(chunk); + resp.extend_from_slice(b"\r\n"); + } + resp.extend_from_slice(b"0\r\n\r\n"); + } else { + resp.extend_from_slice(format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes()); + resp.extend_from_slice(&reply); + } + if out.write_all(&resp).and_then(|()| out.flush()).is_err() { + continue; + } + // Keep this connection open for a while, on its own thread, and go on serving. + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(10)); + drop(out); + }); + } + }); + let ep = Endpoint::Tcp("127.0.0.1".into(), port); + let started = std::time::Instant::now(); + let list = printers(&ep).unwrap(); + assert_eq!(list.len(), 1, "chunked {chunked}"); + assert!(started.elapsed() < Duration::from_secs(5), "waited for the server to close (chunked {chunked}): {:?}", started.elapsed()); + } +} + +/// A chunked reply whose payload contains the bytes of a last chunk (`\r\n0\r\n\r\n`, here inside +/// a printer name) and arrives split right after them: the reader must wait for the real last +/// chunk instead of returning a cut reply. +#[test] +fn a_last_chunk_pattern_inside_a_value_does_not_end_the_reply() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let reply = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Of\r\n0\r\n\r\nfice"])])]); + let cut = reply.windows(7).position(|w| w == b"\r\n0\r\n\r\n").unwrap() + 7; + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let (mut line, mut len) = (String::new(), 0usize); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).unwrap(); + let mut out = reader.into_inner(); + let (first, second) = reply.split_at(cut); + // Part one ends exactly on the pattern, inside the first chunk's payload. + let mut a = b"HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec(); + a.extend_from_slice(format!("{:x}\r\n", first.len()).as_bytes()); + a.extend_from_slice(first); + out.write_all(&a).and_then(|()| out.flush()).unwrap(); + std::thread::sleep(Duration::from_millis(400)); + let mut b = b"\r\n".to_vec(); + b.extend_from_slice(format!("{:x}\r\n", second.len()).as_bytes()); + b.extend_from_slice(second); + b.extend_from_slice(b"\r\n0\r\n\r\n"); + out.write_all(&b).and_then(|()| out.flush()).unwrap(); + }); + let ep = Endpoint::Tcp("127.0.0.1".into(), port); + let list = printers(&ep).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "Of\r\n0\r\n\r\nfice"); + // The boundary walk itself: complete only at a zero-size chunk, never on payload bytes. + assert!(!chunked_complete(b"5\r\nab\r\n0")); + assert!(!chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n")); + assert!(chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n0\r\n\r\n")); + assert!(chunked_complete(b"zz\r\n") && dechunk(b"zz\r\n").is_err()); +} + +/// A chunk size that cannot be satisfied (`usize::MAX`, `usize::MAX - 1`, anything past the +/// reply cap) is a protocol error, through the real reader, never an overflow panic. +#[test] +fn an_impossible_chunk_size_is_a_protocol_error_not_a_panic() { + for size_line in ["ffffffffffffffff", "fffffffffffffffe", "1000001"] { + assert!(chunked_complete(format!("{size_line}\r\n").as_bytes())); + assert!(dechunk(format!("{size_line}\r\nab\r\n").as_bytes()).is_err()); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let line = size_line.to_string(); + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let (mut l, mut len) = (String::new(), 0usize); + loop { + l.clear(); + if reader.read_line(&mut l).unwrap_or(0) == 0 || l == "\r\n" { + break; + } + if let Some(v) = l.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).unwrap(); + let mut out = reader.into_inner(); + let resp = format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n{line}\r\n"); + let _ = out.write_all(resp.as_bytes()).and_then(|()| out.flush()); + }); + let ep = Endpoint::Tcp("127.0.0.1".into(), port); + match printers(&ep) { + Err(Error::Protocol(_)) => {} + other => panic!("{size_line}: expected a protocol error, got {other:?}"), + } + } +} diff --git a/docs/development.md b/docs/development.md index 239f9456bf..6891e88a9c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -126,6 +126,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_STROKE_TRACE=1` (or `=`; also `--stroke-trace `, or `ui.set {"strokeTrace": true|false}` at runtime, without a file) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) | | `PHOTOCRAFT_PRESENT_MODE=novsync` (or `--present-mode`) | Present mode for this launch: `fifo`, `autovsync`, or `novsync` (also `immediate`, `mailbox`: no vsync, the first of Immediate, Mailbox, Fifo the driver offers). Default: vsync, or no vsync when Preferences › Performance › Low-Latency Drawing is on (it ships off). For latency comparisons; shown in the stroke trace | | `PHOTOCRAFT_FRAME_LATENCY=1` (or `--frame-latency`) | Frames the swap chain may queue, 1 to 3 (wgpu's GL backend ignores it). For latency comparisons; shown in the stroke trace | +| `PHOTOCRAFT_PRINT_PORTAL=1` | Linux: File › Print always opens the system print dialog (the XDG print portal) instead of talking to CUPS; `0` never does. See [printing](printing.md). | | `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change | ### Live design tokens diff --git a/docs/printing.md b/docs/printing.md new file mode 100644 index 0000000000..68015d278e --- /dev/null +++ b/docs/printing.md @@ -0,0 +1,67 @@ +# Printing + +File › Print renders the flattened document onto one PDF page (paper size and orientation, +position and scale, crop and registration marks, labels, colour handling) and hands that page +to a printer. The rendering is `print_cmds.rs` in `photocraft-engine`, shared by the dialog, the +batch commands and automation (`file.print`, `file.printOneCopy`); this page is about the hand-off. + +## Routes to the printer + +| Route | When | What the user sees | +|---|---|---| +| **`craft-print`** (`lp` on CUPS, the Windows spooler) | Whenever `lp` is on the path (macOS, Linux outside a sandbox) or on Windows | `Sent to the printer (request id is Office-42)` | +| **CUPS over IPP** (`photocraft-ipp`) | No `lp` on the path (a Flatpak) and a print server answers: `CUPS_SERVER`, then `/run/cups/cups.sock`, `/var/run/cups/cups.sock`, `/private/var/run/cupsd`, then `localhost:631`; always for a `printServer` named in the request | `Sent to the printer (job 42 on Office)` | +| **Print portal** (`org.freedesktop.portal.Print`, desktop app on Linux) | No print server reachable or no printer set up, or always with `PHOTOCRAFT_PRINT_PORTAL=1` | The system print dialog, seeded with the copies and paper; the outcome goes to the log | +| **Print to PDF** | `output` given (the Output field of the dialog) | The file; nothing is spooled unless `send` is true | + +A route that exists but fails (an unknown printer, a job the queue refuses) is an error; only a +missing route (no `lp`, no print server, no printer) moves on to the next one. + +The IPP client is in-process, pure Rust (RFC 8010/8011: `CUPS-Get-Printers`, `CUPS-Get-Classes`, +`CUPS-Get-Default`, `Print-Job` with the PDF as the document), so no `lp` binary is needed. That +is what makes printing work in the Flatpak: the manifest grants `--socket=cups`, which mounts the +host's CUPS socket, and the sandbox has no `lp`. On a system without CUPS, or in a sandbox +without the socket, the app falls back to the print portal, whose dialog runs outside the sandbox. + +## Which printer + +The dialog's Printer field, or `"printer"` in the command, names a queue (case-insensitively; a +CUPS class counts, as with `lp -d`). +Left empty, the server's default printer is used; without a default, the only printer; with +several and no default, the error names them. With no printer at all, the portal takes over when +it is available, otherwise the error says to add one in the system settings or print to PDF. + +`file.printers` lists what the print server knows: `{"printServer": "host[:port]|socket"}` +is optional and defaults to the system's CUPS; the result is +`{server, default, printers: [{name, info, state, acceptingJobs, default, class}]}`, or an empty list +with a `note` when no server answers. `"printServer"` on `file.print` sends one job to a named +server; `"dryRun": true` renders the page and reports the equivalent `lp` command without +spooling. + +## Results + +`file.print` returns `sent`, `backend` (`lp`, `windows`, `ipp`, `portal`), `spooler` (where the +job went) and `pdf` (the page's path, only when `output` named one). + +## Environment + +| Variable | Effect | +|---|---| +| `CUPS_SERVER` | The print server, as CUPS itself reads it: `host[:port]` or a socket path | +| `PHOTOCRAFT_PRINT_PORTAL=1` | Always use the system print dialog (the portal) instead of `lp` or CUPS directly | +| `PHOTOCRAFT_PRINT_PORTAL=0` | Never use the portal; a missing print server is then an error | + +## Verification + +- `crates/ipp/src/tests.rs`: the client against a fake CUPS over TCP and a Unix socket: + printer listing with state and default, the default printer and its absence, `Print-Job` + with the document and the job id, chunked and content-length replies, HTTP and IPP error + statuses, broken replies. +- `crates/engine/src/print_cmds/tests.rs`: printer resolution (requested, default, only one, + several without a default, unknown, none); a complete `file.print` against a fake CUPS + (printer URI, copies, job name, document format, the PDF itself) and `file.printers`; + `lp` taking the job when it is on the path; the IPP route when it is not (a child process + with an empty path); the app's spooler when preferred, as the fallback without a printer, and + skipped when a server is named; print to PDF untouched. +- `apps/photocraft/src/print_portal.rs`: the request path rule and the paper/copies seeding of + the dialog (PWG names for the standard papers, millimetres for custom sizes). diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..3b36e7c611 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -12,7 +12,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | -| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | +| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -243,7 +243,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T ## Reliability -Never-crash attribute on 29 of 29 crate roots. +Never-crash attribute on 30 of 30 crate roots. | Id | Target | Status | Issue | Note | |---|---|---|---|---| diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml index 0b771ae7c7..c7d30ac7d3 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml @@ -24,6 +24,9 @@ finish-args: - --share=ipc # GPU access for the wgpu compositor (Vulkan, or GL as a fallback). - --device=dri + # Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP, + # in process). Without it the app shows the system print dialog through the print portal. + - --socket=cups # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Pictures and Documents are granted so files passed by path (the command diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.yml index ee6bbfac1b..e3ac74cffd 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.yml @@ -25,6 +25,9 @@ finish-args: - --share=ipc # GPU access for the wgpu compositor (Vulkan, or GL as a fallback). - --device=dri + # Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP, + # in process). Without it the app shows the system print dialog through the print portal. + - --socket=cups # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Pictures and Documents are granted so files passed by path (the command diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3d162dd458..f2e6f53514 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -41,6 +41,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("codecs", Class::Standalone), // Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature. ("heif", Class::Standalone), + // IPP client for CUPS printing (pure Rust, no dependencies), used by `engine` on Unix. + ("ipp", Class::Standalone), ("raw", Class::Standalone), ("affinity", Class::Standalone), ("adobe-assets", Class::Standalone),