Print through CUPS over IPP and the print portal, so File › Print works in the Flatpak (#2728) (#3017)

Main prints through craft-print, which runs `lp`; a Flatpak has no `lp`,
so File › Print fails there. This keeps craft-print as the route wherever
`lp` is on the path (and on Windows) and adds two more behind it:

- `photocraft-ipp`, a pure-Rust IPP client (CUPS-Get-Printers/Classes/
  Default, Print-Job over HTTP/1.1) that talks to CUPS over its socket or
  TCP; the Flatpak manifests grant --socket=cups for it. A `printServer`
  named in the request always takes this route. `file.print.printers`
  lists the queues.
- The XDG Print portal as the app's spooler (`Session::print_spooler`),
  for a sandbox without the CUPS socket or a system without a printer:
  the system print dialog, seeded with copies, paper and orientation.
  PHOTOCRAFT_PRINT_PORTAL=1 prefers it, =0 removes it.

A route that exists but fails (unknown printer, refused job) is an error;
only a missing route moves on. Results gain `backend` (lp, windows, ipp,
portal). docs/printing.md describes the routes.
This commit is contained in:
Diego Chagas
2026-10-17 15:55:54 -06:00
committed by GitHub
parent 1d65bd605b
commit 235e7578f3
20 changed files with 1884 additions and 21 deletions
Generated
+6
View File
@@ -4651,6 +4651,7 @@ dependencies = [
"rfd", "rfd",
"ron", "ron",
"serde_json", "serde_json",
"tempfile",
"winit", "winit",
"winresource", "winresource",
"winsafe", "winsafe",
@@ -4800,6 +4801,7 @@ dependencies = [
"photocraft-format", "photocraft-format",
"photocraft-geom", "photocraft-geom",
"photocraft-io", "photocraft-io",
"photocraft-ipp",
"photocraft-ops", "photocraft-ops",
"photocraft-paint", "photocraft-paint",
"photocraft-plugins", "photocraft-plugins",
@@ -4899,6 +4901,10 @@ dependencies = [
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
[[package]]
name = "photocraft-ipp"
version = "0.9.0"
[[package]] [[package]]
name = "photocraft-ops" name = "photocraft-ops"
version = "0.9.0" version = "0.9.0"
+1
View File
@@ -36,6 +36,7 @@ photocraft-engine = { path = "crates/engine" }
photocraft-ui-egui = { path = "crates/ui-egui" } photocraft-ui-egui = { path = "crates/ui-egui" }
photocraft-testkit = { path = "crates/testkit" } photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" } photocraft-tablet = { path = "crates/tablet" }
photocraft-ipp = { path = "crates/ipp" }
# external # external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
+2
View File
@@ -275,6 +275,8 @@ flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-a
flatpak run ai.storyteller.photocraft 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. 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-<version>-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). 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-<version>-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).
+2
View File
@@ -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. # Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true } 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"] } 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 # 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. # image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
+12 -1
View File
@@ -63,6 +63,9 @@ mod photoshop_settings;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod portal_save; mod portal_save;
mod presentation; 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 screen_color;
mod services; mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events). // 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()); 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.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
app.integrated_titlebar = cfg!(target_os = "macos"); app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = custom_titlebar; app.custom_titlebar = custom_titlebar;
+239
View File
@@ -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<Spooler> {
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<String, String> {
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<zbus::blocking::Proxy<'static>, 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<String, String> {
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<zbus::blocking::proxy::SignalIterator<'static>, 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<HashMap<String, OwnedValue>, String> {
let message = signals.next().ok_or("the print portal went away")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = 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")));
}
}
+4
View File
@@ -43,6 +43,10 @@ roxmltree = "0.20"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = "3" 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] [dev-dependencies]
photocraft-affinity = { workspace = true, features = ["synth"] } photocraft-affinity = { workspace = true, features = ["synth"] }
# Synthetic camera raw files for the open tests. # Synthetic camera raw files for the open tests.
+3
View File
@@ -486,6 +486,9 @@ pub struct Session {
/// the same check a top-level request sees. `None` runs everything, which is what a local /// the same check a top-level request sees. `None` runs everything, which is what a local
/// UI and `photocraft-cli run` do. /// UI and `photocraft-cli run` do.
pub authorize: Option<fn(&str, &serde_json::Value) -> Result<()>>, pub authorize: Option<fn(&str, &serde_json::Value) -> 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<print_cmds::Spooler>,
/// Background jobs (see [`jobs`]). /// Background jobs (see [`jobs`]).
jobs: jobs::Jobs, jobs: jobs::Jobs,
/// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size, /// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size,
+218 -17
View File
@@ -8,6 +8,12 @@
//! `"dryRun": true` reports the backend command without spooling; a PDF is kept only at an //! `"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. //! 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 //! 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` //! 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. //! 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()) 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<u8>,
/// The printer asked for; `None` means the default.
pub printer: Option<String>,
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<String>,
/// 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<dyn Fn(&PrintJob) -> std::result::Result<String, String> + 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<String, SpoolError> {
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<photocraft_ipp::Endpoint, SpoolError> {
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<String, SpoolError> {
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<String, SpoolError> {
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<Value> {
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<Value> = 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<Value> = 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 ---------- // ---------- PDF ----------
/// Paper sizes in points (portrait). /// Paper sizes in points (portrait).
@@ -343,6 +534,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let params = p.clone(); let params = p.clone();
let worker_params = params.clone(); let worker_params = params.clone();
let command = cmd.to_string(); let command = cmd.to_string();
let spooler = s.print_spooler.clone();
if send && !dry { if send && !dry {
// Session::execute remains synchronous; the desktop's Session::start snapshots here // Session::execute remains synchronous; the desktop's Session::start snapshots here
// and runs PDF composition and the potentially slow driver on a worker. // 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<Value> {
false, false,
move |ctx| { move |ctx| {
ctx.progress(0.0, "Print"); 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| { move |s, result| {
remember_print(s, &params); remember_print(s, &params);
@@ -370,13 +562,13 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
}, },
); );
} }
let result = render_and_spool(&doc, p, cmd)?; let result = render_and_spool(&doc, p, cmd, spooler.as_ref())?;
remember_print(s, p); remember_print(s, p);
crate::automate_cmds::fire_event(s, "print"); crate::automate_cmds::fire_event(s, "print");
Ok(result) Ok(result)
} }
fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> { fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spooler>) -> Result<Value> {
let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?; 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 (img, color) = print_image(doc, p, cmd)?;
let description = p let description = p
@@ -394,7 +586,12 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
// Sending follows the shared command contract; fuzz tests explicitly request dryRun. // Sending follows the shared command contract; fuzz tests explicitly request dryRun.
// The worker returns its result before repeat settings and hooks are applied once. // 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"); 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()?; require_printing_enabled()?;
let printed = print_windows(&page, p, &doc.name, copies)?; let printed = print_windows(&page, p, &doc.name, copies)?;
return Ok(json!({ return Ok(json!({
@@ -402,10 +599,11 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
"copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, "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.")); 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 = print_pdf(&page);
let pdf_len = pdf.len();
let job = craft_print::Job { let job = craft_print::Job {
printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string),
copies, copies,
@@ -425,13 +623,25 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
}; };
let mut sent = false; let mut sent = false;
let mut spool = Value::Null; let mut spool = Value::Null;
let mut backend = Value::Null;
if send && !dry { if send && !dry {
require_printing_enabled()?; 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; sent = true;
} }
Ok( 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<Value> {
let list = craft_print::printers();
let default = list.iter().find(|p| p.default).map(|p| p.name.clone());
let printers: Vec<Value> = 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<Value> { fn print(s: &mut Session, p: &Value) -> Result<Value> {
do_print(s, p, "file.print") do_print(s, p, "file.print")
} }
@@ -759,7 +960,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } 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![ vec![
spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print),
+290 -1
View File
@@ -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; 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!((printed.size.width, printed.size.height), (150, 300), "print hook ran on the source document");
assert_eq!(out["sent"], true); 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"); assert!(out["pdf"].is_null(), "spool-only printing leaves no diagnostic PDF");
let pdf = std::fs::read(format!("{dir}/received.pdf")).unwrap(); let pdf = std::fs::read(format!("{dir}/received.pdf")).unwrap();
assert!(pdf.starts_with(b"%PDF-1.4")); 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)), 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<std::sync::Mutex<Vec<PrintJob>>>) -> 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<Attribute>, Vec<u8>);
/// 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<Seen>) {
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");
}
}
+12
View File
@@ -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
+22
View File
@@ -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`.
+600
View File
@@ -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<T> = std::result::Result<T, Error>;
/// 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<Endpoint> {
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<Stream> {
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<usize> {
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<usize> {
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<u8>,
}
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<u8> {
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<Vec<u8>>,
}
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<i32> {
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<Attribute>,
}
impl Reply {
/// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag.
pub fn parse(b: &[u8]) -> Result<Reply> {
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<Attribute> = 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<Reply> {
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<Vec<u8>> {
let mut raw = Vec::new();
let mut buf = [0u8; 16 * 1024];
let mut body_at = None;
let mut expected: Option<usize> = 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<Vec<u8>> {
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/<name>` or `/classes/<name>`.
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<Vec<Printer>> {
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<Vec<Printer>> {
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<Printer> = 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<Option<String>> {
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<u32> {
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<Attribute>, Vec<u8>)> {
let (reply, end) = Reply::parse_at(b)?;
Ok((reply.status, reply.attributes, b.get(end..).unwrap_or(&[]).to_vec()))
}
#[cfg(test)]
mod tests;
+395
View File
@@ -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<Attribute>, Vec<u8>);
/// 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<u8>, chunked: bool, http_status: u16) -> (Endpoint, mpsc::Receiver<Seen>) {
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<u8>, chunked: bool, http_status: u16, tx: &mpsc::Sender<Seen>) {
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<u8> {
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::<Printer>::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:?}"),
}
}
}
+1
View File
@@ -126,6 +126,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`.
| `PHOTOCRAFT_STROKE_TRACE=1` (or `=<file.csv>`; also `--stroke-trace <stderr|file.csv>`, 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_STROKE_TRACE=1` (or `=<file.csv>`; also `--stroke-trace <stderr|file.csv>`, 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_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_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 | | `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change |
### Live design tokens ### Live design tokens
+67
View File
@@ -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).
+2 -2
View File
@@ -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) | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) |
| [Type](#type) | 4 | 3 | 2 | | | [Type](#type) | 4 | 3 | 2 | |
| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [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 | | | [Distribution](#distribution) | 5 | 4 | 2 | |
| [Open bugs](#open-bugs) | 29 | 2 | 1 | | | [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 ## Reliability
Never-crash attribute on 29 of 29 crate roots. Never-crash attribute on 30 of 30 crate roots.
| Id | Target | Status | Issue | Note | | Id | Target | Status | Issue | Note |
|---|---|---|---|---| |---|---|---|---|---|
@@ -24,6 +24,9 @@ finish-args:
- --share=ipc - --share=ipc
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback). # GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
- --device=dri - --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 # 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 # 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 # filesystem access. Pictures and Documents are granted so files passed by path (the command
@@ -25,6 +25,9 @@ finish-args:
- --share=ipc - --share=ipc
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback). # GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
- --device=dri - --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 # 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 # 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 # filesystem access. Pictures and Documents are granted so files passed by path (the command
+2
View File
@@ -41,6 +41,8 @@ pub const TABLE: &[(&str, Class)] = &[
("codecs", Class::Standalone), ("codecs", Class::Standalone),
// Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature. // Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature.
("heif", Class::Standalone), ("heif", Class::Standalone),
// IPP client for CUPS printing (pure Rust, no dependencies), used by `engine` on Unix.
("ipp", Class::Standalone),
("raw", Class::Standalone), ("raw", Class::Standalone),
("affinity", Class::Standalone), ("affinity", Class::Standalone),
("adobe-assets", Class::Standalone), ("adobe-assets", Class::Standalone),