mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
Generated
+6
@@ -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"
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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, ¶ms);
|
remember_print(s, ¶ms);
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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;
|
||||||
@@ -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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user