43 Commits

Author SHA1 Message Date
skylar taylor-barrick f03ec27ab2 Merge pull request #28 from jcl-bit/claude/print-native-windows-73c826
Print spooler / print (macos-latest) (push) Has been cancelled
Print spooler / print (ubuntu-latest) (push) Has been cancelled
Print spooler / print (windows-latest) (push) Has been cancelled
Shared UI / UI (macos-latest) (push) Has been cancelled
Shared UI / UI (ubuntu-latest) (push) Has been cancelled
Shared UI / UI (windows-latest) (push) Has been cancelled
Shared UI / UI WASM (push) Has been cancelled
craft-print: print on Windows in-process, without PowerShell
2026-10-12 17:19:38 -06:00
skylartaylor eb49a247a3 Gate Unix-only atomic test imports by platform 2026-10-13 00:29:22 +02:00
skylar taylor-barrick 79e89dc129 Merge remote-tracking branch 'origin/main' into codex/craft-print-fix-merge-20261012T210558Z
# Conflicts:
#	Cargo.lock
2026-10-12 23:49:49 +02:00
Alejandro Londono 6006abb757 feat(settings): add shared config storage for Craft apps
Add shared config.toml storage while keeping settings types, defaults and UI policy in each app.

Includes isolated in-memory operation, configurable portable paths, atomic saves with private backups, damaged-file preservation, schema migrations, and documented best-effort handling of external edits.

Validation: native macOS formatting, strict workspace clippy, 172 workspace tests and the craft-settings WASM check. Independent backup-permission and failed external-migration repros fail before the fixes and pass afterward.

Hosted CI: all seven UI and printing jobs pass across macOS, Linux, Windows and UI WASM.

Refs #12
2026-10-12 15:40:50 -06:00
skylar taylor-barrick 697b473c0b Stop PDF work immediately after a parser budget is exhausted 2026-10-12 23:27:53 +02:00
skylar taylor-barrick c22935270b Parse duplicate PDF dictionary keys in linear time 2026-10-12 23:22:05 +02:00
skylar taylor-barrick 85ef8b92ac Bound PDF parsing and preserve indirect page content arrays 2026-10-12 23:14:06 +02:00
skylar taylor-barrick 02d08fe9cc fix(print): pin native completion repair and document bounded PDF handling 2026-10-12 23:12:41 +02:00
skylar taylor-barrick a7e3c76c2f Merge remote-tracking branch 'origin/main' into codex/craft-print-fix-merge-20261012T210558Z 2026-10-12 23:05:59 +02:00
James Locke 36ce57c929 Merge remote-tracking branch 'origin/main' into claude/print-native-windows-73c826
# Conflicts:
#	Cargo.lock
2026-10-12 11:33:06 -07:00
skylar taylor-barrick f58bc1a3df Merge pull request #16 from zachshallbetter/feat/craft-atomic
Add craft-atomic: crash-safe atomic write and document replacement
2026-10-12 12:25:33 -06:00
skylar taylor-barrick 811558a3ad Create Unix atomic-write staging files with private permissions 2026-10-12 20:23:17 +02:00
Zach Shallbetter 5b273dd82f Clarify application recovery sweep responsibility and hardware durability limits 2026-10-12 20:23:17 +02:00
Zach Shallbetter d4a38f90ee Clarify user-space cleanup semantics and best-effort directory sync in craft-atomic 2026-10-12 20:23:17 +02:00
Zach Shallbetter 1c9baae1bf Lock tempfile and fastrand dependencies for workspace 2026-10-12 20:23:17 +02:00
Zach Shallbetter f75e9c9bc8 Document atomicity vs durability guarantees and add sequence verification test 2026-10-12 20:23:17 +02:00
aiongg 6bcd350d49 README: credit PhotoCraft only, document what a replace does not keep
The crate holds PhotoCraft's engine, and unsafe code is forbidden by the
workspace lint rather than in lib.rs.
2026-10-12 20:23:17 +02:00
aiongg 33b393bd88 Remove the unused proptest dev-dependency 2026-10-12 20:23:17 +02:00
aiongg 9d422a18b1 Retry Windows sharing and lock violations, with a capped backoff
retry_rename also retries ERROR_SHARING_VIOLATION (32) and
ERROR_LOCK_VIOLATION (33), which a rename can report while a scanner
holds the file, and caps each pause at 200 ms.
2026-10-12 20:23:17 +02:00
aiongg 253fd775f0 Set the replaced file's permissions on the open temporary file
The original's permissions are applied to the temporary file before it
is flushed, and a failure fails the save instead of being ignored.
2026-10-12 20:23:17 +02:00
aiongg d78b352004 Refuse to replace a file the user cannot write (Unix)
A rename needs only a writable folder, so a read-only file would be
replaced where writing it in place would fail. Before staging, the
existing file is opened for writing without truncating; when that fails
the save fails with a read-only error.
2026-10-12 20:23:17 +02:00
aiongg 658ca37c8e Follow symbolic links without canonicalize
The target is found by reading links one hop at a time, resolving
relative links against the link's folder. A dangling link writes the
file it names and stays a link, and a loop of links is an error.
2026-10-12 20:23:17 +02:00
aiongg fb26a9f90f Report a missing folder instead of creating it
A save into a folder that does not exist fails with an error naming the
folder, so a save to an unmounted network share does not create a local
folder in its place.
2026-10-12 20:23:17 +02:00
aiongg 9e160690fc Never replace an existing file in write_new
write_new_with gives the staged file its name with a hard link on every
platform. Where hard links fail for any reason other than an existing
target (FAT/exFAT, some SMB and FUSE volumes), it copies the staged
content into a file opened with create_new. It shares the temporary-file
retry, error context and folder flush with the replace path, and its
errors only mention an original file when there is one.
2026-10-12 20:23:17 +02:00
aiongg 818c0c0753 Share one replace path between the byte and streaming writers
atomic_write, atomic_write_with and atomic_write_custom all run through
one internal function that takes the AtomicFs, the RenameRetry and a
writer callback, so fault injection also covers streaming writes.
AtomicFs creates and flushes the temporary file (create_new, sync)
instead of writing a byte slice.
2026-10-12 20:23:17 +02:00
Zach Shallbetter 8df3d87d8b Add craft-atomic: crash-safe atomic write and document replacement (refs #2, fixes #14) 2026-10-12 20:23:17 +02:00
skylar taylor-barrick 4a4ec8c1ca Merge pull request #21 from jmacek/quit-app-menu
craft-quit: macOS app menu whose Quit asks the app first
2026-10-12 12:22:49 -06:00
skylar taylor-barrick c68d145c9f docs: list craft-quit in the library catalog 2026-10-12 20:21:37 +02:00
skylar taylor-barrick 8e9e437e78 craft-quit: route menu Quit to the root viewport 2026-10-12 20:18:18 +02:00
Jake Macek f0ae2b3f13 craft-quit: return the app menu install error to the app
install_app_menu printed the error and carried on, so an app could not
tell that ⌘Q had fallen back to winit's Quit, which skips the prompt.
It now returns the error; the app decides whether it is fatal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:18:18 +02:00
Jake Macek 673c14439b craft-quit: Quit menu id and handler for apps with their own menu
Apps that build their own muda app menu can't use install_app_menu: it
replaces the whole menu and sets muda's process-wide event handler.
QUIT_MENU_ID and on_menu_event(ctx, id) let them give their own Quit
item the id and route its event through the app's close handling.
Neither needs muda, so any muda version works. install_app_menu now
uses both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:18:18 +02:00
Jake Macek ebf3f552cd craft-quit: macOS app menu whose Quit asks the app first
winit's default app menu sends Quit (Cmd+Q) straight to
-[NSApplication terminate:] and winit doesn't implement
applicationShouldTerminate:, so a Craft app quits without its
unsaved-changes prompt. The new opt-in `menu` feature adds
`install_app_menu(ctx, app_name)`, which replaces that menu with a
muda-built copy whose Quit sends ViewportCommand::Close. The app's own
close handling then runs, as for the close button, and
on_close_request quits.

muda is a macOS-only, optional dependency; no unsafe code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:18:18 +02:00
skylar taylor-barrick f207cdd016 Merge pull request #5 from mjohnson7cd/macos-quit-terminate
Add craft-quit: quit macOS apps through NSApplication terminate:
2026-10-12 12:17:38 -06:00
skylar taylor-barrick bafeec6b7d Merge remote-tracking branch 'origin/main' into fix-quit5-20261012 2026-10-12 20:15:22 +02:00
skylar taylor-barrick c6a847b7d0 craft-quit: document logic-based close handling for hidden windows 2026-10-12 20:11:55 +02:00
skylar taylor-barrick 3c9702725d Merge pull request #26 from storytold/codex/craft-print-settings
Share printer settings and asynchronous print feedback
2026-10-12 08:25:02 -06:00
James Locke 754f4d70e0 Print on Windows in-process, without PowerShell
craft-print's Windows backend ran an embedded PowerShell script for the printer list and every
job. Under App Control for Business or AppLocker script enforcement that script runs in
Constrained Language Mode and every job fails (storytold/pdfcraft#1107). The backend now runs
in this process and starts no program:

- Printers come from EnumPrinters and the default from GetDefaultPrinter; Printer::two_sided
  from each driver's PrintCapabilities. The list is sorted, each name once.
- A job is drawn as vectors by the in-box Windows.Data.Pdf with Direct2D into the XPS print
  API (winprint). Copies, collation, duplex (left out for duplex: None), colour, orientation
  and the driver's own paper option go in a PrintTicket the driver validates; a ticket the
  driver changed (paper, orientation, scaling) is refused before anything is sent. The driver
  paper named like the standard size is preferred (pdfcraft#599).
- A job has one ticket, so one paper shape. A small lenient reader (sheets.rs) reads the
  print-ready PDF's sheets and, when needed, appends an incremental update: /Rotate for sheets
  of the other orientation, and for Scaling::ShrinkToFit a reframe onto the driver's matching
  or default paper (shrunk inside its widest hardware margin, never enlarged, centred).
  Exact size refuses mixed paper sizes and paper the driver lacks.
- command_preview describes the in-process job on Windows ("xps-print", key=value options).
- windows_print.ps1 and the PowerShell helpers are gone. open_printer_preferences (rundll32)
  and the CUPS path are unchanged, as is the public API.

The end-to-end test puts a paused Generic / Text Only printer on LPT1: (no port to remove
afterwards; a paused FILE: queue never takes the job) and checks a mixed-orientation exact job
and a shrink-to-fit job each land as one job with all sheets. The reader is tested on every
platform with classic and compressed cross-references and thousands of truncated and corrupted
files. Windows-only dependencies: winprint =0.2.1 (default features off), winsafe 0.0.29,
roxmltree 0.20, miniz_oxide 0.8.

Ported from PdfCraft's native printing branch (pdfcraft#1132), which carries #599's approach.

Co-authored-by: durtyrobbin <76844303+durtyrobbin@users.noreply.github.com>
Co-authored-by: Dimitar Klaturov <7221384+bulgariamitko@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:03:54 -07:00
skylar taylor-barrick 5d41e29e46 Install software Vulkan for shared UI rendering tests 2026-10-12 02:41:42 +02:00
skylar taylor-barrick 4cae45dbe8 Handle texture deltas in print control tests 2026-10-12 02:37:47 +02:00
skylar taylor-barrick d27646a5f7 Preserve accessible print control labels and narrow layout 2026-10-12 02:34:53 +02:00
skylar taylor-barrick b700ae753f Add shared printer settings and asynchronous job feedback 2026-10-12 02:32:49 +02:00
Mara Johnson 9d6610afd1 craft-quit: document calling on_close_request last in the frame
eframe calls logic before ui, so on_close_request goes after all of the
app's own close handling, at the end of ui when the app handles closes
there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:47:11 -04:00
Mara Johnson 0549f672a3 Add craft-quit: quit macOS apps through NSApplication terminate:
Closing an eframe window on macOS can crash in AppKit's Touch Bar finder
after eframe drops the window while the run loop is still running (#3).
craft-quit routes a window close through -[NSApplication terminate:],
the path Cmd+Q already takes, so the process ends before another display
cycle runs.

- quit(): schedules terminate: on the main queue on macOS, no-op elsewhere
- on_close_request() (feature "egui"): cancels the root window's close
  and quits through the platform path instead

Also commits Cargo.lock and ignores .idea/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:47:11 -04:00
45 changed files with 8601 additions and 654 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Shared UI
on:
push:
branches: [main]
paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
pull_request:
paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: shared-ui-${{ github.ref }}
cancel-in-progress: true
jobs:
native:
name: UI (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install software Vulkan renderer
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y mesa-vulkan-drivers libvulkan1
echo 'WGPU_BACKEND=vulkan' >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
key: craft-ui
- run: cargo fmt --check
- run: cargo clippy -p craft-ui --all-targets --locked -- -D warnings
- run: cargo clippy -p craft-ui --all-targets --all-features --locked -- -D warnings
- run: cargo test -p craft-ui --locked
- run: cargo test -p craft-ui --all-features --locked
wasm:
name: UI WASM
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: craft-ui-wasm
- run: cargo check -p craft-ui --all-features --locked --target wasm32-unknown-unknown
+1
View File
@@ -4,3 +4,4 @@ plans/
/target
.DS_Store
/.claude/
.idea/
Generated
+1794 -11
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -11,7 +11,10 @@ repository = "https://github.com/storytold/craft-libs"
[workspace.dependencies]
# internal: add each crate here as `craft-<name> = { path = "crates/<name>" }`
craft-atomic = { path = "crates/atomic" }
craft-print = { path = "crates/print" }
craft-quit = { path = "crates/quit" }
craft-settings = { path = "crates/settings" }
craft-ui = { path = "crates/ui" }
egui = { version = "0.36", default-features = false }
+10
View File
@@ -52,3 +52,13 @@ The PDF spooler in crates/print is extracted from:
The Windows printer duplex-capability query and parser additionally draw on PdfCraft,
crates/print/src/spool.rs, revision fb97ce958dfe92b6762ce40615c7836551710139 (PR #1154), under the same licenses above.
The native Windows backend (crates/print/src/windows.rs, ticket.rs and the printer-list and
Windows spooler tests) is ported from PdfCraft's native printing work, which carries the
approach of PdfCraft PR #599 (Win32 printer list, Windows.Data.Pdf into the XPS print API, the
PrintTicket and its driver-paper preference):
- PdfCraft, crates/print/src/spool/windows.rs, spool.rs and tests.rs, branch
claude/native-windows-print-826c99 of PR #1132, revision a435117 (jcl-bit/printcraft).
https://github.com/storytold/pdfcraft
Copyright (c) 2026 ArtCraft Team and the PdfCraft contributors.
Licensed MIT OR Apache-2.0.
+6 -1
View File
@@ -23,10 +23,15 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
## Libraries
- [`craft-atomic`](crates/atomic/README.md): Crash-safe atomic file writing and replacement with same-directory staging, directory fsync, Apple `F_FULLFSYNC` fallback, transient lock backoff, and fault injection for the Craft suite.
- [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery,
duplex/copies/colour and driver preferences. PDF generation stays in each app.
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking and theme helpers for egui frontends;
- [`craft-quit`](crates/quit/src/lib.rs): native macOS quit, egui close handling and an optional
app menu. Unsaved-document decisions and save prompts stay in each app.
- [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md).
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking, print settings and theme helpers for egui frontends;
assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
and compatibility boundaries across the Craft suite.
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "craft-atomic"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
description = "Crash-safe atomic file writing and replacement for the Craft suite"
[lints]
workspace = true
[target.'cfg(target_vendor = "apple")'.dependencies]
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
+82
View File
@@ -0,0 +1,82 @@
# Craft Atomic
`craft-atomic` provides crash-safe atomic file writing and replacement for the Craft suite.
Extracted from the storage engine in **PhotoCraft** (refs #2, #14), it guarantees that a failed save or abnormal termination never leaves user documents truncated, corrupted, or missing.
## Core Guarantees: Atomicity vs. Durability
1. **Atomicity (All-or-Nothing Replacement)**: `atomic_write` never truncates or overwrites the destination in place. It stages content in a hidden temporary file beside the destination (`.{stem}.{pid}-{n}.craft_tmp`), guaranteeing same-volume rename semantics (`EXDEV`-free). An external reader never sees a partial write. If an error occurs during staging or rename, the destination retains its prior bytes and the staging file is unlinked in user space. An abnormal process exit (e.g. `SIGKILL`, crash, or power loss) bypasses user-space cleanup and leaves the staging file on disk; sweeping orphaned staging files is delegated to consuming application recovery routines. The destination file itself is never corrupted.
2. **Durability & Ordering Boundaries**:
- **Content Flush**: Before rename, staging file bytes are flushed to physical storage media (`sync_file`, issuing `F_FULLFSYNC` on macOS/APFS with SMB `fsync` fallback).
- **Directory Flush (Unix)**: After rename, syncing the parent directory table (`sync_dir`) is attempted on a best-effort basis. If directory sync fails, the error is ignored because the rename has already committed and the destination has already been replaced.
- **Hardware Durability Limits**: The sequence `sync_file < rename < sync_dir` proves ordering and kernel dispatch. Software tests do not prove survival across physical power interruption or unbuffered drive controller write caches.
3. **Transient Lock Backoff (Windows)**: Antivirus scanners and search indexers hold newly written files momentarily. `RenameRetry` automatically retries renames encountering `PermissionDenied`, sharing violations (32), or lock violations (33) with exponential backoff capped at 200ms.
4. **Permission & Symlink Invariants**: On Unix, staging files are created with mode `0600` (restricted by the process umask), so the payload starts private even in a shared folder. Existing destination permissions are restored after the streaming callback. New files remain private by default; a callback may select their final permissions, which are kept both with hard links and the exclusive-copy fallback. Symlink chains are resolved with a hop limit, writing through the symlink to the ultimate target without replacing the link itself.
5. **Safe Exclusive Creation (`write_new`)**: Uses hard links where supported, or exclusive create (`O_EXCL`), guaranteeing that an existing file is never replaced under race conditions.
6. **Fault-Injection Testability**: The `AtomicFs` trait enables unit and integration test suites to simulate disk-full, locked files, and permission failures without physical disk I/O.
A replaced file is a new file (a new inode), so its owner and group, extended attributes and ACLs, Finder tags, alternate data streams and hard links are not kept; a crash between the write and the rename can leave a `.<name>.<pid>-<n>.craft_tmp` file beside the destination.
## Usage
### One-Shot Atomic Write
```rust
use std::path::Path;
use craft_atomic::atomic_write;
let path = Path::new("project.craft");
let data = b"precious document state";
// Atomically replaces project.craft. If writing fails, project.craft retains
// its prior bytes and temporary staging files are unlinked in user space.
atomic_write(path, data).expect("safe atomic write");
```
### Streaming Atomic Writer
```rust
use std::io::Write;
use std::path::Path;
use craft_atomic::atomic_write_with;
let path = Path::new("large_project.craft");
atomic_write_with(path, |file| {
file.write_all(b"header...")?;
file.write_all(b"payload...")?;
Ok(())
}).expect("streaming atomic write");
```
### Exclusive Creation (`write_new`)
```rust
use std::path::Path;
use craft_atomic::write_new;
let path = Path::new("export.png");
// Writes only if the destination does not already exist; fails with AlreadyExists
// without modifying existing content.
write_new(path, b"png bytes...").expect("exclusive write");
```
### Fault Injection & Testing
```rust
use craft_atomic::{AtomicFs, RenameRetry, atomic_write_custom};
// Inject simulated failures (e.g. storage full mid-stream)
struct FaultyFs;
impl AtomicFs for FaultyFs {
// ... custom failure simulation ...
}
```
## Guarantees & Doctrines
- **Zero App Code**: Zero dependencies on application crates.
- **Never Crash**: `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- **No Unsafe**: the workspace lints forbid `unsafe` code.
+793
View File
@@ -0,0 +1,793 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Crash-safe file replacement: the unified atomic save engine for the Craft suite.
//!
//! [`atomic_write`] never truncates the destination file in place. It writes a temporary file
//! in the **same directory** as the destination, flushes it to non-volatile storage (`sync_all`),
//! renames it over the destination atomically, and on Unix, attempts to flush the directory so the rename
//! survives a power loss. If write or rename fails, the destination retains its original bytes and
//! the temporary staging file is removed.
//!
//! ### Guarantees: Atomicity vs. Durability
//! - **Atomicity Guarantee (All-or-Nothing)**:
//! [`atomic_write`] guarantees that external readers never observe a partial write. Either the target
//! file retains its previous bytes, or it is replaced completely with the new contents. If an error occurs
//! during staging or rename, the target remains unmodified and the staging file is removed in user space.
//! An abnormal process termination (e.g. `SIGKILL`, crash, power loss) bypasses user-space cleanup and
//! leaves the temporary staging file on disk. Sweeping orphaned staging files is the responsibility of the
//! consuming application; the library does not perform background sweeping. The destination file itself is
//! never corrupted.
//! - **Durability & Ordering Boundaries**:
//! Data persistence across power failures involves two sync points:
//! 1. **Content Flush (`sync_file`)**: The staging file's payload is forced to non-volatile storage
//! (`F_FULLFSYNC` on macOS, `sync_all` on Windows/Linux) *before* the atomic rename.
//! 2. **Directory Flush (`sync_dir`, Unix only)**: On Unix, flushing the parent directory table is
//! attempted *after* the rename on a best-effort basis. If directory sync fails, the error is ignored
//! because the rename has already committed and the destination has already been replaced.
//! 3. **Hardware Durability Limits**: While this enforces strict kernel dispatch order
//! (`sync_file < rename < sync_dir`), software flushes cannot guarantee data survival across
//! unbuffered drive controller write-caching or physical power cutoffs.
//!
//! ### Platform Characteristics
//! - **Windows**: Antivirus scanners, search indexers, and backup utilities briefly hold files
//! open with sharing violations. The rename is retried with exponential backoff (`RenameRetry`).
//! - **Cross-Device (`EXDEV`)**: Staging files are always created beside the destination in the
//! same folder, guaranteeing same-filesystem replace semantics.
//! - **Apple Network Shares (macOS)**: `sync_all` issues `F_FULLFSYNC`. Where SMB shares or
//! virtual filesystems reject this with `ENOTSUP` or `ENOTTY`, [`sync_file`] falls back to standard
//! `fsync`.
//! - **Web / WASM**: On `wasm32`, returns standard unsupported `io::Error` rather than panicking.
//! - **Failure Injection**: [`atomic_write_custom`] accepts an [`AtomicFs`] implementation so
//! callers and test suites can inject disk-full, lock-contention, or permission errors at each stage.
use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// File-system operations performed by atomic writes.
/// [`RealFs`] provides the production implementation; test suites implement this to inject faults.
pub trait AtomicFs {
/// Create `path`, which must not exist, and open it for writing.
/// On Unix, create it with owner-only permissions so payload written afterward is private.
fn create_new(&self, path: &Path) -> io::Result<File>;
/// Flush a written file to durable storage.
fn sync(&self, file: &File) -> io::Result<()>;
/// Rename `from` over `to`, replacing it atomically.
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
/// Give the file `from` the second name `to`; fails with `AlreadyExists` when `to` exists.
fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()>;
/// Remove a file.
fn remove(&self, path: &Path) -> io::Result<()>;
/// Flush a directory entry change to durable storage (Unix); no-op elsewhere.
fn sync_dir(&self, dir: &Path) -> io::Result<()>;
/// Give the open temporary file the permissions of the file it replaces.
fn set_permissions(&self, file: &File, perms: std::fs::Permissions) -> io::Result<()>;
}
/// The production file system implementation.
#[derive(Debug, Clone, Copy, Default)]
pub struct RealFs;
impl AtomicFs for RealFs {
fn create_new(&self, path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// Set the mode at creation: chmod afterward leaves a window in which another
// user can open the stage, including a stage left behind by an abrupt exit.
options.mode(0o600);
}
options.open(path)
}
fn sync(&self, file: &File) -> io::Result<()> {
sync_file(file)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
std::fs::rename(from, to)
}
fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()> {
std::fs::hard_link(from, to)
}
fn remove(&self, path: &Path) -> io::Result<()> {
std::fs::remove_file(path)
}
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
#[cfg(unix)]
{
sync_file(&File::open(dir)?)
}
#[cfg(not(unix))]
{
let _ = dir;
Ok(())
}
}
fn set_permissions(&self, file: &File, perms: std::fs::Permissions) -> io::Result<()> {
file.set_permissions(perms)
}
}
/// Flush `file` to stable storage (`sync_all`).
/// On Apple platforms, this issues an `F_FULLFSYNC`. Where the file system rejects full sync
/// (such as SMB network shares or virtual file systems), it falls back to a plain `fsync`.
pub fn sync_file(file: &File) -> io::Result<()> {
#[cfg(target_vendor = "apple")]
{
or_plain_fsync(file.sync_all(), || rustix::fs::fsync(file).map_err(io::Error::from))
}
#[cfg(not(target_vendor = "apple"))]
{
file.sync_all()
}
}
/// Returns `full`, or calls `fsync()` if `full` failed only because `F_FULLFSYNC` is unsupported.
pub fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
match full {
Err(e) if full_flush_unsupported(&e) => fsync(),
other => other,
}
}
fn full_flush_unsupported(e: &io::Error) -> bool {
const ENOTTY: i32 = 25;
const ENOTSUP: i32 = 45;
cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | ENOTTY))
}
/// Retry configuration for renames that encounter transient file locks (see [`retry_rename`]).
#[derive(Debug, Clone, Copy)]
pub struct RenameRetry {
/// Number of retries after the initial attempt (0 = no retry).
pub attempts: u32,
/// Delay before the first retry in milliseconds; doubles on each subsequent attempt, up to
/// 200 ms per attempt.
pub first_delay_ms: u64,
}
impl RenameRetry {
/// Platform default: retry on Windows (7 attempts with 10ms exponential backoff), no-op elsewhere.
pub fn platform() -> Self {
if cfg!(windows) { Self { attempts: 7, first_delay_ms: 10 } } else { Self { attempts: 0, first_delay_ms: 0 } }
}
}
/// Atomically writes `bytes` to `path`, replacing any existing content.
///
/// If write or rename fails, the target file is untouched and any temporary staging file is removed.
pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
atomic_write_custom(&RealFs, RenameRetry::platform(), path, bytes)
}
/// Atomically writes to `path` using a streaming writer callback `F`.
///
/// On Unix, the staging file starts with mode `0600` (restricted by the process umask).
/// Existing destination permissions are restored after the callback. For a new destination,
/// callback-selected permissions are kept, or the file remains private by default.
pub fn atomic_write_with<F>(path: &Path, write_fn: F) -> io::Result<()>
where
F: FnOnce(&mut File) -> io::Result<()>,
{
replace(&RealFs, RenameRetry::platform(), path, write_fn)
}
/// Atomically writes `bytes` to `path` through custom [`AtomicFs`] and [`RenameRetry`].
pub fn atomic_write_custom(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8]) -> io::Result<()> {
replace(fs, retry, path, |f| f.write_all(bytes))
}
/// The replace path behind every `atomic_write*` function: stage the new content beside the
/// target, then rename it over the target.
fn replace(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let target = resolve_links(path)?;
let (dir, name) = split(&target, path)?;
let existing = std::fs::metadata(&target).ok();
let existed = existing.is_some();
if existing.as_ref().is_some_and(|m| m.is_file()) {
check_writable(&target).map_err(|e| read_only_error(e, path))?;
}
let tmp = stage(fs, &dir, &name, path, existing.as_ref(), fill)?;
if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) {
let _ = fs.remove(&tmp);
return Err(rename_error(e, path, existed));
}
// The new bytes are in place; a failed directory fsync can't be undone and must not report
// the save as failed, so it is best effort.
let _ = fs.sync_dir(&dir);
Ok(())
}
/// A rename replaces a file the user can't write (Unix only needs the folder to be writable), so
/// refuse, as writing it in place would. Opening for writing without truncating changes nothing.
#[cfg(unix)]
fn check_writable(target: &Path) -> io::Result<()> {
OpenOptions::new().write(true).open(target).map(drop)
}
/// Windows refuses to rename over a read-only file by itself.
#[cfg(not(unix))]
fn check_writable(_: &Path) -> io::Result<()> {
Ok(())
}
/// The folder `target` is in and its file name. `path` is the caller's path, for the error.
fn split(target: &Path, path: &Path) -> io::Result<(PathBuf, String)> {
let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?;
let dir = match target.parent() {
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
_ => PathBuf::from("."),
};
Ok((dir, name.to_string_lossy().into_owned()))
}
/// Create a temporary file in `dir`, fill it with `fill`, give it the permissions of the file it
/// replaces (`replaced`, Unix) and flush it. A leftover file with the same name is skipped. When
/// any step fails, the temporary file is removed.
fn stage(
fs: &dyn AtomicFs,
dir: &Path,
name: &str,
path: &Path,
replaced: Option<&std::fs::Metadata>,
fill: impl FnOnce(&mut File) -> io::Result<()>,
) -> io::Result<PathBuf> {
let existed = replaced.is_some();
// Windows files carry only a read-only flag, and a read-only file can't be replaced.
let perms = replaced.filter(|_| cfg!(unix)).map(|m| m.permissions());
for _ in 0..8 {
let tmp = dir.join(temp_name(name));
let mut file = match fs.create_new(&tmp) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(write_error(e, path, dir, existed)),
};
let written = fill(&mut file)
.map_err(|e| write_error(e, path, dir, existed))
.and_then(|()| perms.map_or(Ok(()), |p| fs.set_permissions(&file, p).map_err(|e| permissions_error(e, path))))
.and_then(|()| fs.sync(&file).map_err(|e| write_error(e, path, dir, existed)));
drop(file);
if written.is_err() {
let _ = fs.remove(&tmp);
}
return written.map(|()| tmp);
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))
}
/// Exclusively creates and writes `path` without replacing existing files.
pub fn write_new(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_new_with(path, |f| f.write_all(bytes))
}
/// Exclusively creates and writes `path` with a streaming writer callback `F`.
/// On Unix, the file starts private (`0600`, restricted by the process umask); the callback
/// may choose its final permissions.
pub fn write_new_with<F>(path: &Path, write_fn: F) -> io::Result<()>
where
F: FnOnce(&mut File) -> io::Result<()>,
{
create(&RealFs, path, write_fn)
}
/// Exclusively creates and writes `bytes` to `path` through a custom [`AtomicFs`].
pub fn write_new_custom(fs: &dyn AtomicFs, path: &Path, bytes: &[u8]) -> io::Result<()> {
create(fs, path, |f| f.write_all(bytes))
}
/// The create path behind every `write_new*` function: stage the content beside the target,
/// then give it the target's name with a hard link, which fails rather than replace a file that
/// appeared in the meantime. Volumes without hard links get a copy into a newly created file.
fn create(fs: &dyn AtomicFs, path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let target = resolve_links(path)?;
let (dir, name) = split(&target, path)?;
if target.exists() {
return Err(write_error(io::Error::from(io::ErrorKind::AlreadyExists), path, &dir, true));
}
let tmp = stage(fs, &dir, &name, path, None, fill)?;
let placed = match fs.hard_link(&tmp, &target) {
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => copy_new(fs, &tmp, &target),
linked => linked,
};
let _ = fs.remove(&tmp);
if let Err(e) = placed {
// A file that appeared in the meantime is someone else's and stays as it is.
let existed = e.kind() == io::ErrorKind::AlreadyExists;
return Err(write_error(e, path, &dir, existed));
}
// The new file is in place; a failed directory fsync can't be undone and is best effort.
let _ = fs.sync_dir(&dir);
Ok(())
}
/// Copy `tmp` into `target`, which must not exist yet, and flush it. A partly written `target` is
/// removed.
fn copy_new(fs: &dyn AtomicFs, tmp: &Path, target: &Path) -> io::Result<()> {
let mut to = fs.create_new(target)?;
let copied = File::open(tmp).and_then(|mut from| {
io::copy(&mut from, &mut to)?;
// Keep callback-selected modes just as hard-link publication does, but only
// after copying the payload into a destination that was created privately.
#[cfg(unix)]
fs.set_permissions(&to, from.metadata()?.permissions())?;
fs.sync(&to)
});
drop(to);
if copied.is_err() {
let _ = fs.remove(target);
}
copied
}
/// The longest pause between two rename attempts.
const MAX_RETRY_DELAY_MS: u64 = 200;
/// Retries an operation with exponential backoff while it fails because another process holds
/// the file: `PermissionDenied`, or on Windows a sharing or lock violation.
pub fn retry_rename(retry: RenameRetry, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
let mut delay = retry.first_delay_ms.min(MAX_RETRY_DELAY_MS);
let mut left = retry.attempts;
loop {
match rename() {
Err(e) if is_transient_lock(&e) && left > 0 => {
left -= 1;
sleep_ms(delay);
delay = delay.saturating_mul(2).min(MAX_RETRY_DELAY_MS);
}
r => return r,
}
}
}
fn is_transient_lock(e: &io::Error) -> bool {
const ERROR_SHARING_VIOLATION: i32 = 32;
const ERROR_LOCK_VIOLATION: i32 = 33;
e.kind() == io::ErrorKind::PermissionDenied || (cfg!(windows) && matches!(e.raw_os_error(), Some(ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION)))
}
#[cfg(not(target_arch = "wasm32"))]
fn sleep_ms(ms: u64) {
if ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(ms));
}
}
#[cfg(target_arch = "wasm32")]
fn sleep_ms(_ms: u64) {}
/// Symbolic links followed before giving up (a loop, or an absurd chain).
const MAX_LINKS: usize = 40;
/// The file a chain of symbolic links ends at (`path` itself when it is not a link), so the link
/// is kept and the file it names is written. A dangling link resolves to the missing file it
/// names, which is then created.
fn resolve_links(path: &Path) -> io::Result<PathBuf> {
let mut p = path.to_path_buf();
for _ in 0..MAX_LINKS {
match std::fs::symlink_metadata(&p) {
Ok(m) if m.file_type().is_symlink() => {
let to = std::fs::read_link(&p).map_err(|e| io::Error::new(e.kind(), format!("cannot save {}: {e}", path.display())))?;
p = match p.parent() {
Some(dir) if to.is_relative() => dir.join(to),
_ => to,
};
}
_ => return Ok(p),
}
}
Err(io::Error::new(io::ErrorKind::InvalidInput, format!("cannot save {}: too many levels of symbolic links", path.display())))
}
/// Generates a unique, hidden temporary file name beside `file`.
pub fn temp_name(file: &str) -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let stem: String = file.chars().take(64).collect();
format!(".{stem}.{}-{n}.craft_tmp", std::process::id())
}
/// The end of an error message: whether an original file was left as it was.
fn unchanged(existed: bool) -> &'static str {
if existed { "; the original file was not changed" } else { "" }
}
fn write_error(e: io::Error, path: &Path, dir: &Path, existed: bool) -> io::Error {
let kept = unchanged(existed);
let msg = match e.kind() {
io::ErrorKind::AlreadyExists => format!("cannot save {}: the file already exists{kept}", path.display()),
io::ErrorKind::NotFound => format!("cannot save {}: the folder {} does not exist ({e}){kept}", path.display(), dir.display()),
io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem => {
format!("cannot save {}: the folder {} is not writable ({e}){kept}", path.display(), dir.display())
}
io::ErrorKind::StorageFull => format!("cannot save {}: the disk is full ({e}){kept}", path.display()),
_ => format!("cannot save {}: {e}{kept}", path.display()),
};
io::Error::new(e.kind(), msg)
}
fn permissions_error(e: io::Error, path: &Path) -> io::Error {
let msg = format!("cannot save {}: the new file cannot be given the original's permissions ({e}){}", path.display(), unchanged(true));
io::Error::new(e.kind(), msg)
}
fn read_only_error(e: io::Error, path: &Path) -> io::Error {
let kept = unchanged(true);
let msg = match e.kind() {
io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem => {
format!("cannot save {}: the file is read-only ({e}){kept}", path.display())
}
_ => format!("cannot save {}: {e}{kept}", path.display()),
};
io::Error::new(e.kind(), msg)
}
fn rename_error(e: io::Error, path: &Path, existed: bool) -> io::Error {
let kept = unchanged(existed);
let msg = if is_cross_device(&e) {
format!("cannot save {}: the temporary file is on a different volume than the destination ({e}){kept}", path.display())
} else if e.kind() == io::ErrorKind::PermissionDenied {
format!("cannot save {}: the file is locked or read-only ({e}){kept}", path.display())
} else {
format!("cannot save {}: {e}{kept}", path.display())
};
io::Error::new(e.kind(), msg)
}
fn is_cross_device(e: &io::Error) -> bool {
e.kind() == io::ErrorKind::CrossesDevices || (cfg!(unix) && e.raw_os_error() == Some(18)) || (cfg!(windows) && e.raw_os_error() == Some(17))
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static N: AtomicU64 = AtomicU64::new(0);
let d = std::env::temp_dir().join(format!("craft-atomic-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).ok();
Self(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
}
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn assert_no_temp(dir: &Path) {
let leftovers: Vec<_> = std::fs::read_dir(dir)
.ok()
.map(|entries| entries.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".craft_tmp")).collect())
.unwrap_or_default();
assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}");
}
const ORIGINAL: &[u8] = b"the user's precious original document";
#[derive(Default)]
struct Faulty {
events: std::cell::RefCell<Vec<&'static str>>,
#[cfg(unix)]
created_modes: std::cell::RefCell<Vec<u32>>,
fail_sync: bool,
/// Syncs fail once this many have succeeded.
syncs_before_failure: Option<u32>,
syncs: Cell<u32>,
fail_rename: Option<io::ErrorKind>,
locked_renames: Cell<u32>,
renames: Cell<u32>,
no_hard_links: bool,
fail_permissions: bool,
fail_sync_dir: bool,
/// Another process creates the target just before the hard link.
created_meanwhile: Option<&'static [u8]>,
}
impl AtomicFs for Faulty {
fn create_new(&self, path: &Path) -> io::Result<File> {
self.events.borrow_mut().push("create_new");
let file = RealFs.create_new(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
self.created_modes.borrow_mut().push(file.metadata()?.permissions().mode() & 0o777);
}
Ok(file)
}
fn sync(&self, file: &File) -> io::Result<()> {
self.events.borrow_mut().push("sync_file");
let done = self.syncs.replace(self.syncs.get() + 1);
if self.fail_sync || self.syncs_before_failure.is_some_and(|n| done >= n) {
return Err(io::Error::new(io::ErrorKind::StorageFull, "injected: no space left on device"));
}
RealFs.sync(file)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
self.events.borrow_mut().push("rename");
self.renames.set(self.renames.get() + 1);
if let Some(kind) = self.fail_rename {
return Err(io::Error::new(kind, "injected rename failure"));
}
if self.locked_renames.get() > 0 {
self.locked_renames.set(self.locked_renames.get() - 1);
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: sharing violation"));
}
RealFs.rename(from, to)
}
fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()> {
self.events.borrow_mut().push("hard_link");
if let Some(bytes) = self.created_meanwhile {
std::fs::write(to, bytes)?;
}
if self.no_hard_links {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: hard links not supported"));
}
RealFs.hard_link(from, to)
}
fn remove(&self, path: &Path) -> io::Result<()> {
self.events.borrow_mut().push("remove");
RealFs.remove(path)
}
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
self.events.borrow_mut().push("sync_dir");
if self.fail_sync_dir {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: directory sync failed"));
}
RealFs.sync_dir(dir)
}
fn set_permissions(&self, file: &File, perms: std::fs::Permissions) -> io::Result<()> {
self.events.borrow_mut().push("set_permissions");
if self.fail_permissions {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: operation not permitted"));
}
RealFs.set_permissions(file, perms)
}
}
const NO_RETRY: RenameRetry = RenameRetry { attempts: 0, first_delay_ms: 0 };
#[test]
fn replaces_and_creates() {
let d = TempDir::new("ok");
let p = d.0.join("doc.craft");
atomic_write(&p, b"first").ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"first".to_vec()));
atomic_write(&p, b"second, longer").ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"second, longer".to_vec()));
assert_no_temp(&d.0);
}
#[test]
fn a_missing_folder_is_an_error_naming_it() {
let d = TempDir::new("parent");
let folder = d.0.join("a");
let p = folder.join("b/out.png");
for r in [atomic_write(&p, b"x"), write_new(&p, b"x")] {
let msg = r.err().map(|e| e.to_string()).unwrap_or_default();
assert!(msg.contains(&folder.join("b").display().to_string()), "{msg}");
}
assert!(!folder.exists(), "no folder is created");
}
#[test]
fn failed_flush_keeps_original() {
let d = TempDir::new("write");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let fs = Faulty { fail_sync: true, ..Default::default() };
let e = atomic_write_custom(&fs, NO_RETRY, &p, &[7u8; 4096]);
assert_eq!(e.map_err(|e| e.kind()), Err(io::ErrorKind::StorageFull));
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_eq!(fs.renames.get(), 0);
assert_no_temp(&d.0);
}
#[test]
fn streaming_writes_go_through_the_injected_fs() {
let d = TempDir::new("stream");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let chunks = |f: &mut File| {
f.write_all(b"chunk1 ")?;
f.write_all(b"chunk2")
};
let fs = Faulty { fail_sync: true, ..Default::default() };
assert!(replace(&fs, NO_RETRY, &p, chunks).is_err());
let fs = Faulty { fail_rename: Some(io::ErrorKind::Other), ..Default::default() };
assert!(replace(&fs, NO_RETRY, &p, chunks).is_err());
assert_eq!(fs.renames.get(), 1);
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_no_temp(&d.0);
let fs = Faulty::default();
assert!(replace(&fs, NO_RETRY, &p, chunks).is_ok());
assert_eq!(std::fs::read(&p).ok(), Some(b"chunk1 chunk2".to_vec()));
}
#[test]
fn rename_failure_keeps_original() {
let d = TempDir::new("rename");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let fs = Faulty { fail_rename: Some(io::ErrorKind::Other), ..Default::default() };
assert!(atomic_write_custom(&fs, NO_RETRY, &p, b"new").is_err());
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_no_temp(&d.0);
}
#[test]
fn locked_target_rename_is_retried() {
let d = TempDir::new("locked");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let fs = Faulty { locked_renames: Cell::new(2), ..Default::default() };
atomic_write_custom(&fs, RenameRetry { attempts: 3, first_delay_ms: 1 }, &p, b"new").ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"new".to_vec()));
assert_eq!(fs.renames.get(), 3);
assert_no_temp(&d.0);
}
#[test]
fn write_new_never_overwrites_existing() {
let d = TempDir::new("new");
let p = d.0.join("file.craft");
write_new(&p, b"first").ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"first".to_vec()));
let res = write_new(&p, b"second");
assert!(res.is_err());
assert_eq!(std::fs::read(&p).ok(), Some(b"first".to_vec()));
assert_no_temp(&d.0);
}
#[test]
fn write_new_copies_where_hard_links_fail_and_never_replaces() {
let d = TempDir::new("new-copy");
let p = d.0.join("export.png");
let fs = Faulty { no_hard_links: true, ..Default::default() };
assert!(create(&fs, &p, |f| f.write_all(b"copied")).is_ok());
assert_eq!(std::fs::read(&p).ok(), Some(b"copied".to_vec()));
assert_no_temp(&d.0);
// A file that appears after the existence check is kept, whether the copy or the hard
// link finds it.
for no_hard_links in [true, false] {
let p = d.0.join(format!("raced-{no_hard_links}.png"));
let fs = Faulty { no_hard_links, created_meanwhile: Some(ORIGINAL), ..Default::default() };
let e = create(&fs, &p, |f| f.write_all(b"mine"));
assert_eq!(e.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists));
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_eq!(fs.renames.get(), 0);
}
assert_no_temp(&d.0);
// A failed new file has no original to speak of.
let p = d.0.join("full.png");
let fs = Faulty { fail_sync: true, ..Default::default() };
let msg = create(&fs, &p, |f| f.write_all(b"x")).err().map(|e| e.to_string()).unwrap_or_default();
assert!(msg.contains("disk is full") && !msg.contains("original"), "{msg}");
assert!(!p.exists());
assert_no_temp(&d.0);
}
#[test]
fn a_failed_copy_leaves_no_partial_file() {
let d = TempDir::new("new-copy-fails");
let p = d.0.join("export.png");
// The temporary file flushes; the copy made in its place does not.
let fs = Faulty { no_hard_links: true, syncs_before_failure: Some(1), ..Default::default() };
let e = write_new_custom(&fs, &p, b"copied");
assert_eq!(e.map_err(|e| e.kind()), Err(io::ErrorKind::StorageFull));
assert_eq!(fs.syncs.get(), 2);
assert!(!p.exists());
assert_no_temp(&d.0);
}
#[cfg(unix)]
#[test]
fn exclusive_copy_starts_private_and_keeps_callback_mode() -> io::Result<()> {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("private-copy");
for mode in [0o600, 0o640] {
let p = d.0.join(format!("export-{mode:o}.craft"));
let fs = Faulty { no_hard_links: true, ..Default::default() };
create(&fs, &p, |f| {
f.write_all(b"private export")?;
f.set_permissions(Permissions::from_mode(mode))
})?;
// Check both the staging file and the exclusive-copy destination at creation.
assert_eq!(*fs.created_modes.borrow(), [0o600, 0o600]);
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, mode);
assert_eq!(std::fs::read(&p)?, b"private export");
}
assert_no_temp(&d.0);
Ok(())
}
#[cfg(unix)]
#[test]
fn failing_to_keep_permissions_fails_the_save() {
let d = TempDir::new("perms");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let fs = Faulty { fail_permissions: true, ..Default::default() };
let msg = atomic_write_custom(&fs, NO_RETRY, &p, b"new").err().map(|e| e.to_string()).unwrap_or_default();
assert!(msg.contains("permissions"), "{msg}");
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_eq!(fs.renames.get(), 0);
assert_no_temp(&d.0);
}
#[test]
fn retry_delays_are_capped() {
let d = TempDir::new("cap");
let p = d.0.join("doc.craft");
let fs = Faulty { locked_renames: Cell::new(2), ..Default::default() };
let started = std::time::Instant::now();
assert!(atomic_write_custom(&fs, RenameRetry { attempts: 2, first_delay_ms: 60_000 }, &p, b"new").is_ok());
assert!(started.elapsed() < std::time::Duration::from_secs(5));
assert_eq!(fs.renames.get(), 3);
}
#[test]
fn distinguishes_atomicity_and_durability_order() {
let d = TempDir::new("durability_order");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let fs = Faulty::default();
atomic_write_custom(&fs, NO_RETRY, &p, b"durable payload").expect("successful atomic write");
let events = fs.events.into_inner();
let sync_file_idx = events.iter().position(|&e| e == "sync_file").expect("file content must be flushed");
let rename_idx = events.iter().position(|&e| e == "rename").expect("atomic rename must be performed");
// 1. Content Durability: staging file must be synced to stable media BEFORE pointer switch (rename)
assert!(
sync_file_idx < rename_idx,
"Durability invariant violated: file content must be flushed to stable storage before rename (sync={sync_file_idx}, rename={rename_idx})"
);
// 2. Directory Durability: on Unix, parent directory must be synced AFTER rename to commit directory entry
#[cfg(unix)]
{
let sync_dir_idx = events.iter().position(|&e| e == "sync_dir").expect("directory must be flushed on Unix");
assert!(
rename_idx < sync_dir_idx,
"Durability invariant violated: parent directory must be flushed after rename (rename={rename_idx}, sync_dir={sync_dir_idx})"
);
}
}
#[test]
fn sync_dir_failure_is_best_effort() {
let d = TempDir::new("best_effort_sync_dir");
let p = d.0.join("doc.craft");
let fs = Faulty { fail_sync_dir: true, ..Default::default() };
assert!(atomic_write_custom(&fs, NO_RETRY, &p, b"best effort data").is_ok());
assert_eq!(std::fs::read(&p).ok(), Some(b"best effort data".to_vec()));
assert_no_temp(&d.0);
}
}
+239
View File
@@ -0,0 +1,239 @@
#[cfg(unix)]
use craft_atomic::{atomic_write, write_new};
use craft_atomic::{atomic_write_with, or_plain_fsync, write_new_with};
use std::cell::Cell;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static N: AtomicU64 = AtomicU64::new(0);
let d = std::env::temp_dir().join(format!("craft-atomic-int-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).ok();
Self(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
}
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn assert_no_temp(dir: &Path) {
let leftovers: Vec<_> = std::fs::read_dir(dir)
.ok()
.map(|entries| entries.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".craft_tmp")).collect())
.unwrap_or_default();
assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}");
}
const ORIGINAL: &[u8] = b"original precious document";
#[test]
fn atomic_write_with_streaming_writer() {
let d = TempDir::new("stream");
let p = d.0.join("doc.dat");
std::fs::write(&p, ORIGINAL).ok();
atomic_write_with(&p, |f| {
f.write_all(b"chunk1 ")?;
f.write_all(b"chunk2")
})
.ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"chunk1 chunk2".to_vec()));
assert_no_temp(&d.0);
}
#[test]
fn write_new_with_streaming_writer() {
let d = TempDir::new("write_new_stream");
let p = d.0.join("new.dat");
write_new_with(&p, |f| f.write_all(b"new content")).ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"new content".to_vec()));
assert_no_temp(&d.0);
// Second write to existing file must fail with AlreadyExists
let res = write_new_with(&p, |f| f.write_all(b"overwritten?"));
assert!(res.is_err());
assert_eq!(std::fs::read(&p).ok(), Some(b"new content".to_vec()));
}
#[test]
fn streaming_failure_aborts_and_keeps_original() {
let d = TempDir::new("stream_fail");
let p = d.0.join("doc.dat");
std::fs::write(&p, ORIGINAL).ok();
let res = atomic_write_with(&p, |f| {
f.write_all(b"half a file")?;
Err(io::Error::new(io::ErrorKind::StorageFull, "disk full"))
});
assert!(res.is_err());
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_no_temp(&d.0);
}
#[test]
fn apple_full_fsync_fallback_behavior() {
let refused = |errno| Err(io::Error::from_raw_os_error(errno));
for (errno, name) in [(45, "ENOTSUP (SMB)"), (25, "ENOTTY")] {
let fsynced = Cell::new(false);
let r = or_plain_fsync(refused(errno), || {
fsynced.set(true);
Ok(())
});
let apple = cfg!(target_vendor = "apple");
assert_eq!(r.is_ok(), apple, "{name}");
assert_eq!(fsynced.get(), apple, "{name}: fsync fallback only runs on Apple");
}
// Unrelated errors (e.g. EIO = 5) must not fallback
let r = or_plain_fsync(refused(5), || panic!("should not run for EIO"));
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5)));
}
#[cfg(unix)]
#[test]
fn mode_and_symlink_preservation() {
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("mode_symlink");
// Permissions
let p = d.0.join("secret.key");
std::fs::write(&p, ORIGINAL).ok();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).ok();
atomic_write(&p, b"updated secret").ok();
let mode = std::fs::metadata(&p).map(|m| m.permissions().mode() & 0o777).ok();
assert_eq!(mode, Some(0o600));
// Symlink
let real = d.0.join("real.txt");
let link = d.0.join("link.txt");
std::fs::write(&real, ORIGINAL).ok();
std::os::unix::fs::symlink(&real, &link).ok();
atomic_write(&link, b"through symlink").ok();
assert!(std::fs::symlink_metadata(&link).map(|m| m.file_type().is_symlink()).unwrap_or(false));
assert_eq!(std::fs::read(&real).ok(), Some(b"through symlink".to_vec()));
}
#[cfg(unix)]
#[test]
fn dangling_relative_symlink_stays_a_link() {
let d = TempDir::new("dangling");
std::fs::create_dir(d.0.join("real")).ok();
let link = d.0.join("link.txt");
std::os::unix::fs::symlink("real/doc.txt", &link).ok();
assert!(atomic_write(&link, b"created through the link").is_ok());
assert!(std::fs::symlink_metadata(&link).map(|m| m.file_type().is_symlink()).unwrap_or(false));
assert_eq!(std::fs::read(d.0.join("real/doc.txt")).ok(), Some(b"created through the link".to_vec()));
assert_no_temp(&d.0);
assert_no_temp(&d.0.join("real"));
}
#[cfg(unix)]
#[test]
fn symlink_loop_is_an_error() {
let d = TempDir::new("loop");
std::os::unix::fs::symlink("b", d.0.join("a")).ok();
std::os::unix::fs::symlink("a", d.0.join("b")).ok();
assert!(atomic_write(&d.0.join("a"), b"x").is_err_and(|e| e.to_string().starts_with("cannot save") && e.to_string().contains("symbolic links")));
assert!(write_new(&d.0.join("a"), b"x").is_err());
assert!(std::fs::symlink_metadata(d.0.join("a")).map(|m| m.file_type().is_symlink()).unwrap_or(false));
assert_no_temp(&d.0);
}
#[cfg(unix)]
#[test]
fn read_only_target_is_not_replaced() {
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("read_only");
let p = d.0.join("locked.txt");
std::fs::write(&p, ORIGINAL).ok();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).ok();
// Root may write the file anyway; the check only means something for an ordinary user.
if std::fs::OpenOptions::new().write(true).open(&p).is_ok() {
return;
}
let msg = atomic_write(&p, b"new").err().map(|e| e.to_string()).unwrap_or_default();
assert!(msg.contains("read-only"), "{msg}");
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_no_temp(&d.0);
}
#[cfg(unix)]
#[test]
fn private_staging_preserves_existing_modes_after_callback() -> io::Result<()> {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("private_staging");
std::fs::set_permissions(&d.0, Permissions::from_mode(0o755))?;
for mode in [0o600, 0o640, 0o664, 0o200] {
let p = d.0.join(format!("doc-{mode:o}.craft"));
std::fs::write(&p, ORIGINAL)?;
std::fs::set_permissions(&p, Permissions::from_mode(mode))?;
atomic_write_with(&p, |f| {
assert_eq!(f.metadata()?.permissions().mode() & 0o777, 0o600, "stage must be private before the callback, even for a shared destination");
// Settings writers restrict the stage themselves; this must not change the
// destination mode that atomic replacement promises to preserve.
f.set_permissions(Permissions::from_mode(0o600))?;
f.write_all(b"new private payload")?;
assert_eq!(f.metadata()?.permissions().mode() & 0o777, 0o600, "payload must stay private while writing");
Ok(())
})?;
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, mode);
std::fs::set_permissions(&p, Permissions::from_mode(0o600))?;
assert_eq!(std::fs::read(&p)?, b"new private payload");
}
assert_no_temp(&d.0);
Ok(())
}
#[cfg(unix)]
#[test]
fn new_files_start_private_and_keep_callback_permissions() -> io::Result<()> {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("private_new");
for exclusive in [false, true] {
let p = d.0.join(format!("new-{exclusive}.craft"));
let fill = |f: &mut std::fs::File| {
assert_eq!(f.metadata()?.permissions().mode() & 0o777, 0o600);
f.write_all(b"new payload")?;
// A new file has no existing mode to restore, so the callback may select it.
f.set_permissions(Permissions::from_mode(0o640))
};
if exclusive {
write_new_with(&p, fill)?;
} else {
atomic_write_with(&p, fill)?;
}
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, 0o640);
assert_eq!(std::fs::read(&p)?, b"new payload");
}
for exclusive in [false, true] {
let p = d.0.join(format!("default-{exclusive}.craft"));
if exclusive {
write_new(&p, b"private by default")?;
} else {
atomic_write(&p, b"private by default")?;
}
assert_eq!(std::fs::metadata(&p)?.permissions().mode() & 0o777, 0o600);
}
assert_no_temp(&d.0);
Ok(())
}
+16 -1
View File
@@ -11,8 +11,23 @@ description = "System PDF print spooler for the Craft apps: CUPS and Windows"
workspace = true
[target.'cfg(windows)'.dependencies]
# Printing without starting anything (storytold/pdfcraft#1107): the printer list (EnumPrinters)
# and the job (Windows.Data.Pdf drawn with Direct2D into the XPS print API, options as a
# PrintTicket) are safe wrappers over Win32 and WinRT (BSD-3-Clause). Its default `pdfium`
# feature downloads PDFium at build time and is off. The maintained BSD-3-Clause fork
# preserves terminal failures/cancellation and bounds the completion notification wait.
# Pinned to an immutable revision; review before bumping.
winprint = { git = "https://github.com/storytold/winprint.rs", rev = "5f80b9baf6b58937c401656634e42d33f00ddf49", default-features = false }
# GetDefaultPrinter (MIT).
winsafe = { version = "0.0.29", default-features = false, features = ["winspool"] }
# The job file's private folder.
tempfile = "3"
# Reading the PrintTicket and PrintCapabilities the driver hands back.
roxmltree = "0.20"
# Inflating the print-ready PDF's cross-reference and object streams (src/sheets.rs).
miniz_oxide = "0.8"
[dev-dependencies]
tempfile = "3"
serde_json = "1"
roxmltree = "0.20"
miniz_oxide = "0.8"
+69 -27
View File
@@ -15,39 +15,81 @@ opens Windows printer preferences and submits jobs with copies, collation, duple
- **macOS/Linux:** CUPS `lpstat`, `lpoptions` and `lp`. Jobs stream through stdin. Exact-size
sheets use `fit-to-page=false`; shrink-to-fit uses IPP `print-scaling=auto-fit`.
- **Windows:** inbox Windows PowerShell, `Windows.Data.Pdf` and `System.Drawing.Printing`.
Each sheet is rasterized, with paper and orientation selected per page. Exact-size mode
preserves physical size and clips hardware margins. Shrink-to-fit uses matching driver
paper or its default, centers in the printable area and never enlarges smaller sheets.
Long-edge duplex uses `Vertical`; short-edge uses
`Horizontal`. Driver-specific defaults remain in Windows printer preferences.
- **Windows:** in this process, with no PowerShell, script or other program started, so
printing works where App Control for Business or AppLocker enforce script rules. Printers
come from `EnumPrinters` (local queues and `\\server\queue` connections) and the default from
`GetDefaultPrinter`. A job is drawn as vectors by the in-box `Windows.Data.Pdf` with Direct2D
into the XPS print API (the `winprint` crate), and copies, collation, duplex, colour, paper,
orientation and "no scaling" travel in a PrintTicket that the driver validates first; a
driver that would change the paper, orientation or scaling is refused before anything is
sent. Paper is the driver's own option for the sheet's size (to 0.25 mm), preferring the one
named like the standard size ("ARCH D" over a custom 24 × 36 in entry). This backend comes
from storytold/pdfcraft#599 and #1132.
A job has one ticket, so one paper and orientation. Sheets of the other orientation are
turned back with `/Rotate`, so portrait and landscape sheets still print as one job. Exact
size refuses sheets of different sizes and paper the driver lacks. Shrink-to-fit uses the
driver's matching paper, else its default paper, and puts every sheet on it: a sheet that
doesn't fit inside the paper's widest hardware margin is shrunk, a smaller one keeps its
size, and both are centred. Both are done by appending an incremental update to the PDF
(new versions of the pages that need it, the original bytes untouched); the crate reads
only cross-reference sections, object streams and the page tree for this, leniently and
with every size bounded. Encrypted PDFs can be turned but not reframed. Annotations on a
reframed page are dropped; print-ready PDFs have them flattened. Nondefault or invalid
`/UserUnit` is refused because the native renderer does not reliably honor physical units.
Parsing caps cumulative cross-reference/object-stream records at 500,000, decoded streams
at 16 MiB each and 32 MiB per job, and parsed nodes/tokens at 250,000/16 MiB per job and
32,000/1 MiB per object. Files exceeding these limits return a clear error.
Long-edge duplex is `TwoSidedLongEdge` and short-edge `TwoSidedShortEdge`; `duplex: None`
leaves duplex out of the ticket. Driver-specific defaults remain in Windows printer
preferences (`rundll32 printui.dll`, the one process this crate starts, and only when asked).
- **Browser/other targets:** discovery is empty and submission reports an unsupported
platform; callers can offer PDF export instead.
Windows jobs briefly use a random, exclusively created temporary file, removed on drop
on both success and failure. CUPS jobs never use a PDF temporary file. Printing is synchronous;
UI callers should submit on a worker. Receipts are backend messages, not portable job IDs.
`Job::duplex = None` preserves the queue's duplex default; `Some(Duplex::Off)` forces
simplex. `Job::scaling` defaults to `Scaling::Exact`; callers exporting arbitrary document
sizes can choose `Scaling::ShrinkToFit`. Exact mode reports a driver paper-size mismatch.
Windows raster allocation is bounded to 64 million pixels per sheet and 32768 per dimension;
large sheets use lower raster resolution while retaining physical size. The backend supports
sheets up to 128 inches and reports invalid sizes. It does not provide PostScript output,
separations, trapping, colour-managed device rasterization or PDF passthrough on Windows.
Windows jobs briefly use a file in a private temporary folder (random name, this user's only),
named after the job's title because the spooler shows it as the document name; the folder is
removed once the spooler has the job, on success and failure. Failed and canceled terminal
package statuses are errors, including failures received after every sheet was processed.
A missing completion notification times out after two minutes after the native print control
is closed and requests cancellation. Calls into a driver and interactive output-file dialogs
are not covered by this notification timeout. Windows answers when the job is in
the queue ("spooled 3 sheet(s)"): a paused queue or an offline printer keeps it there. CUPS jobs
never use a PDF temporary file. Printing is synchronous; UI callers should submit on a worker.
Receipts are backend messages, not portable job IDs. `Job::duplex = None` preserves the queue's
duplex default; `Some(Duplex::Off)` forces simplex. `Job::scaling` defaults to `Scaling::Exact`;
callers exporting arbitrary document sizes can choose `Scaling::ShrinkToFit`. Exact mode reports
a driver paper-size mismatch. On Windows `command_preview` describes the in-process job
(`xps-print`, then the printer and options as `key=value`) instead of a command line. The crate
does not provide PostScript output, separations, trapping or colour-managed device rendering.
`cargo test -p craft-print` tests CUPS transport (including pipe-sized jobs and refusal),
printer parsing, PPD options and argument mapping. On Windows it also parses the embedded
PowerShell, checks physical sheet geometry, duplex and raster bounds without hardware,
renders a mixed-orientation PDF through WinRT, and attempts submission to a temporary paused
queue. The queue portion explicitly skips if the driver or administrative rights are absent;
rendering and geometry tests do not skip. Windows CI installs the inbox test driver and sets
`CRAFT_PRINT_REQUIRE_QUEUE=1`, making queue preparation/submission mandatory on its runner.
Physical printer fidelity remains a hardware check.
printer parsing, PPD options and argument mapping, and on every platform the Windows pieces that
need no printer: the PrintTicket, the checks on the driver's validated ticket and capabilities,
paper matching, job file names, and the PDF sheet reader and its update (classic and compressed
cross-references, turning, reframing, refusal, and thousands of truncated and corrupted files
that must fail without a panic). On Windows it also submits a mixed-orientation exact-size job
and a shrink-to-fit job to a temporary paused queue (inbox "Generic / Text Only" driver on
`LPT1:`) and checks each arrives as one job with all its sheets. The queue portion explicitly
skips if the driver or administrative rights are absent. Windows CI installs the inbox test
driver and sets `CRAFT_PRINT_REQUIRE_QUEUE=1`, making queue preparation/submission mandatory on
its runner. Two ignored tests check a real printer by hand: `an_installed_driver_validates_the_ticket`
(`CRAFT_PRINT_TEST_PRINTER`; sends no job) and `an_installed_printer_prints_a_file`
(`CRAFT_PRINT_TEST_PRINTER`, `CRAFT_PRINT_TEST_FILE`, optional `CRAFT_PRINT_TEST_SCALING=shrink`
and `CRAFT_PRINT_TEST_DUPLEX=long`). Physical printer fidelity remains a hardware check.
The cross-platform GitHub Actions workflow is defined in `.github/workflows/print.yml`.
It runs formatting, Clippy and this crate's own tests on Windows, Linux and macOS when print
code changes, and supports manual runs. The Windows job executes the WinRT and spooler
regressions above; physical printer fidelity still requires hardware verification.
code changes, and supports manual runs. The Windows job executes the spooler regression above;
physical printer fidelity still requires hardware verification.
Windows discovery reports driver duplex support in `Printer::two_sided`; CUPS leaves it unknown.
Callers can warn when a Windows driver ignores the job’s duplex choice.
Windows discovery reports driver duplex support in `Printer::two_sided` (a two-sided option in
the driver's PrintCapabilities); CUPS leaves it unknown. Callers can warn when a Windows driver
ignores the job’s duplex choice.
Windows dependencies: the maintained [`winprint` fork](https://github.com/storytold/winprint.rs)
(BSD-3-Clause, pinned to an immutable Git revision, its PDFium-downloading `pdfium` feature off) and `winsafe` (MIT) hold every Win32 and WinRT call, so the crate keeps `unsafe` forbidden;
`roxmltree` reads PrintTickets and `miniz_oxide` inflates PDF streams. `winprint` 0.2.1 lists
reqwest (with rustls) and sigstore crates as non-optional build-dependencies, used only by its
`pdfium` feature: they compile into its build script on Windows but not into the library. Apps
running `cargo deny` need a licence exception for `webpki-root-certs` (CDLA-Permissive-2.0, CA
list data reqwest brings) and an advisory ignore for RUSTSEC-2024-0370 (`proc-macro-error`
unmaintained, a compile-time proc macro of winprint's `fmt-derive`).
+58 -115
View File
@@ -1,15 +1,21 @@
//! The system print spooler. On macOS and Linux this is CUPS: printers come from `lpstat` — its
//! queues, and the driverless destinations it can print to without one — and jobs are piped to
//! `lp` with the job options (copies, collation, duplex, colour). On Windows the printers come
//! from `Win32_Printer` and jobs are rendered with the in-box `Windows.Data.Pdf` and spooled
//! with the driver's own settings — both through `powershell`, so no Win32 API is called here.
//! Other platforms report that printing isn't available yet; the print-ready PDF can still be
//! saved.
//! from the spooler and jobs are drawn by the in-box `Windows.Data.Pdf` into the XPS print API
//! with the options as a PrintTicket, all in this process (`windows.rs`): no PowerShell or other
//! program is started, so printing works where scripts are locked down. Other platforms report
//! that printing isn't available yet; the print-ready PDF can still be saved.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(test, windows))]
mod sheets;
#[cfg(test)]
mod tests;
#[cfg(any(test, windows))]
mod ticket;
#[cfg(windows)]
mod windows;
/// A system spooler failure. Document permissions belong to the caller.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -48,6 +54,7 @@ pub enum Scaling {
Exact,
/// Use matching driver paper or its default and shrink oversized sheets to fit.
/// Smaller sheets retain their size; the printable area centers the result.
/// On Windows every sheet of the job goes on that one paper.
ShrinkToFit,
}
@@ -359,14 +366,7 @@ pub fn printers() -> Vec<Printer> {
}
#[cfg(windows)]
{
// Win32_Printer through CIM: `wmic` was removed from Windows 11 (24H2) and
// `Get-CimInstance` is on every desktop install. A machine whose PowerShell is locked
// down lists no printers and the dialog offers only Save as PDF, as with CUPS that
// isn't running.
match powershell_command(POWERSHELL_PRINTERS).output() {
Ok(o) if o.status.success() => parse_windows_printers(&String::from_utf8_lossy(&o.stdout)),
_ => Vec::new(),
}
windows::printers()
}
#[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))]
{
@@ -392,29 +392,21 @@ pub fn printers_parsed(queues: &str, available: Option<&str>) -> Vec<Printer> {
printers
}
/// The `powershell` command that runs a script. `-NoProfile` keeps a user's profile from
/// writing banners into what is parsed, `-NonInteractive` from waiting for input.
/// The full system path avoids running a powershell.exe beside a portable app.
#[cfg(windows)]
fn powershell_command(script: &str) -> std::process::Command {
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
let exe = std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe");
let mut c = std::process::Command::new(exe);
use std::os::windows::process::CommandExt as _;
c.args(["-NoProfile", "-NonInteractive", "-Command", script]).creation_flags(0x0800_0000);
c.env_remove("CRAFT_PRINT_DRYRUN").env_remove("CRAFT_PRINT_TEST_SETTINGS");
c
/// The Print dialog's printers from the Windows spooler's queues (name, and whether the driver
/// takes two-sided settings when that is known): sorted by name, each once, the default marked
/// when it is one of them.
#[cfg(any(test, windows))]
pub(crate) fn printer_list(queues: impl IntoIterator<Item = (String, Option<bool>)>, default: Option<&str>) -> Vec<Printer> {
let mut queues: Vec<(String, Option<bool>)> = queues.into_iter().filter(|(n, _)| !n.trim().is_empty()).collect();
queues.sort_by(|a, b| a.0.cmp(&b.0));
queues.dedup_by(|a, b| a.0 == b.0);
queues.into_iter().map(|(name, two_sided)| Printer { default: default == Some(name.as_str()), name, two_sided }).collect()
}
/// The Windows printer list: one line per printer, `*` on the system default. UTF-8, through
/// the output encoding the script sets first. Only the properties the list needs are
/// pulled, so a machine with many printers doesn't wait for the rest of each record.
#[cfg(windows)]
const POWERSHELL_PRINTERS: &str = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-CimInstance Win32_Printer -Property Name, Default, Capabilities | ForEach-Object { $sides = if (@($_.Capabilities) -contains 3) { '2' } else { '1' }; if ($_.Default) { '*' + $_.Name + [char]9 + $sides } else { $_.Name + [char]9 + $sides } }";
/// The Print dialog's printers from the Windows printer list: one per line, `*` before the
/// system default, followed by a tab and 2/1 for duplex support. The first `*` wins when a broken
/// spooler marks more than one, so the dialog still has a single default to preselect.
/// A printer list in the text form craft-print's former PowerShell backend printed: one per line,
/// `*` before the system default, followed by a tab and 2/1 for duplex support. The first `*`
/// wins when a broken spooler marks more than one. [`printers`] no longer uses it; it stays for
/// callers that parse lists in this form.
pub fn parse_windows_printers(out: &str) -> Vec<Printer> {
let mut printers: Vec<Printer> = out
.lines()
@@ -442,7 +434,8 @@ pub fn parse_windows_printers(out: &str) -> Vec<Printer> {
printers
}
/// Send a print-ready PDF to the spooler. Returns the spooler's message: CUPS includes a job id; Windows confirms submission only.
/// Send a print-ready PDF to the spooler. Returns the spooler's message: CUPS includes a job id;
/// Windows confirms submission only ("spooled 3 sheet(s)").
pub fn submit(pdf: &[u8], job: &Job) -> Result<String, Error> {
#[cfg(all(unix, not(target_arch = "wasm32")))]
{
@@ -450,7 +443,7 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result<String, Error> {
}
#[cfg(windows)]
{
windows_submit(pdf, job, &[])
windows::submit(pdf, job)
}
#[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))]
{
@@ -459,82 +452,6 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result<String, Error> {
}
}
/// [`submit`] on Windows: the print-ready PDF goes to a job file of its own (WinRT reads from
/// a path), the embedded [`WINDOWS_PRINT_SCRIPT`] spools it, and the file is gone again however
/// the job ended. `extra_envs` reaches the script too — the tests use it for
/// `CRAFT_PRINT_DRYRUN`, which renders without a printer.
#[cfg(windows)]
pub(crate) fn windows_submit(pdf: &[u8], job: &Job, extra_envs: &[(&str, &str)]) -> Result<String, Error> {
let path = windows_job_file(pdf)?;
let mut ps = powershell_command(WINDOWS_PRINT_SCRIPT);
for (key, value) in windows_env_vars(&path, job).into_iter().chain(extra_envs.iter().map(|(k, v)| (k.to_string(), v.to_string()))) {
ps.env(key, value);
}
let out = ps.output();
// Remove the job file before reporting: a spooler that refused the job must not leave the
// document behind.
drop(path);
let out = out.map_err(|e| Error::Spool(format!("the print spooler is not available: {e}")))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
let err = if err.is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_string() } else { err };
return Err(Error::Spool(if err.is_empty() { "the print job was refused".into() } else { err }));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
/// The job file [`windows_submit`] hands to `powershell`, in this user's own temporary folder:
/// a randomly named, exclusively created file, removed on drop (including partial-write errors). WinRT reads a path, so unlike
/// the job piped to `lp` this one touches disk for the seconds the spooler reads it.
#[cfg(windows)]
fn windows_job_file(pdf: &[u8]) -> Result<tempfile::TempPath, Error> {
use std::io::Write as _;
let written = |e: std::io::Error| Error::Spool(format!("the print job could not be written: {e}"));
let mut file = tempfile::Builder::new().prefix("craft-print-").suffix(".pdf").tempfile().map_err(written)?;
file.write_all(pdf).map_err(written)?;
file.flush().map_err(written)?;
// Close the handle before WinRT opens it, retaining automatic path removal.
Ok(file.into_temp_path())
}
/// The print script's environment: no arguments, so a printer name with quotes or spaces
/// survives the command line and the document's path is not visible in the process list while
/// it prints. The driver options in [`Job::options`] are CUPS' PPD keywords; on Windows the
/// driver's own per-printer preferences apply (Print ▸ Properties…), so they are not sent.
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn windows_env_vars(path: &std::path::Path, job: &Job) -> Vec<(String, String)> {
vec![
("CRAFT_PRINT_FILE".into(), path.to_string_lossy().into_owned()),
("CRAFT_PRINT_PRINTER".into(), job.printer.clone().unwrap_or_default()),
("CRAFT_PRINT_COPIES".into(), job.copies.to_string()),
("CRAFT_PRINT_COLLATE".into(), if job.collate { "1" } else { "0" }.into()),
(
"CRAFT_PRINT_DUPLEX".into(),
match job.duplex {
None => "",
Some(Duplex::Off) => "off",
Some(Duplex::LongEdge) => "long-edge",
Some(Duplex::ShortEdge) => "short-edge",
}
.into(),
),
(
"CRAFT_PRINT_SCALING".into(),
match job.scaling {
Scaling::Exact => "exact",
Scaling::ShrinkToFit => "shrink-to-fit",
}
.into(),
),
("CRAFT_PRINT_GRAYSCALE".into(), if job.grayscale { "1" } else { "0" }.into()),
("CRAFT_PRINT_TITLE".into(), job.title.clone()),
]
}
/// The embedded print script (see [`windows_submit`]).
#[cfg(windows)]
pub(crate) const WINDOWS_PRINT_SCRIPT: &str = include_str!("windows_print.ps1");
/// [`submit`] with the spooler command given, so tests can stand in for `lp`.
///
/// The job goes to `lp` on stdin, never through a file: a predictable job folder in a shared
@@ -596,6 +513,33 @@ pub(crate) fn job_message(stdout: &[u8]) -> String {
s.strip_suffix("(0 file(s))").map_or(s, str::trim_end).to_string()
}
/// [`command_preview`] for the Windows backend.
fn windows_preview(job: &Job) -> Vec<String> {
vec![
"xps-print".into(),
format!("printer={}", job.printer.as_deref().unwrap_or("(default)")),
format!("copies={}", job.copies.clamp(1, 999)),
format!("collate={}", job.collate),
format!(
"duplex={}",
match job.duplex {
None => "(queue default)",
Some(Duplex::Off) => "one-sided",
Some(Duplex::LongEdge) => "two-sided-long-edge",
Some(Duplex::ShortEdge) => "two-sided-short-edge",
}
),
format!(
"scaling={}",
match job.scaling {
Scaling::Exact => "exact",
Scaling::ShrinkToFit => "shrink-to-fit",
}
),
format!("color={}", if job.grayscale { "monochrome" } else { "color" }),
]
}
/// The platform backend. No backend is available in the browser.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backend {
@@ -614,14 +558,13 @@ pub const fn backend() -> Option<Backend> {
}
/// A diagnostic command preview; creates no job file and starts no process.
/// On Windows the last element describes the embedded script. Submit with [`submit`],
/// On Windows, where no program is started, it describes the in-process job instead: `xps-print`,
/// then the printer and the PrintTicket's options as `key=value`. Submit with [`submit`],
/// rather than executing this preview. PDF bytes are supplied separately.
pub fn command_preview(job: &Job) -> Result<Vec<String>, Error> {
match backend() {
Some(Backend::Cups) => Ok(std::iter::once("lp".to_string()).chain(lp_args(job)).collect()),
Some(Backend::WindowsPdf) => {
Ok(["powershell", "-NoProfile", "-NonInteractive", "-Command", "<embedded craft-print PDF spooler>"].map(str::to_string).to_vec())
}
Some(Backend::WindowsPdf) => Ok(windows_preview(job)),
None => Err(Error::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into())),
}
}
File diff suppressed because it is too large Load Diff
+6 -241
View File
@@ -1,6 +1,8 @@
use super::*;
use crate as spool;
mod windows_native;
#[test]
fn spooler_arguments_and_printer_list() {
let out = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nprinter Label_Writer disabled since …\nsystem default destination: Office_Laser\n";
@@ -166,154 +168,6 @@ fn windows_printers_carry_one_default_and_keep_their_names() {
assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) });
}
#[test]
fn the_windows_job_is_the_environment_of_the_print_script() {
let path = std::path::Path::new("C:\\Users\\me\\AppData\\Local\\Temp\\craft-print-42.pdf");
let job = Job {
printer: Some("HP \"Laser\" Jet".into()),
copies: 3,
duplex: Some(Duplex::ShortEdge),
grayscale: true,
title: "Angebot.pdf".into(),
..Job::default()
};
let vars = windows_env_vars(path, &job);
let env = |key: &str| vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()).unwrap();
assert_eq!(env("CRAFT_PRINT_FILE"), "C:\\Users\\me\\AppData\\Local\\Temp\\craft-print-42.pdf");
// Quotes and spaces in a printer name survive because nothing goes on the command line.
assert_eq!(env("CRAFT_PRINT_PRINTER"), "HP \"Laser\" Jet");
assert_eq!(env("CRAFT_PRINT_COPIES"), "3");
assert_eq!(env("CRAFT_PRINT_COLLATE"), "1");
assert_eq!(env("CRAFT_PRINT_DUPLEX"), "short-edge");
assert_eq!(env("CRAFT_PRINT_SCALING"), "exact");
assert_eq!(env("CRAFT_PRINT_GRAYSCALE"), "1");
assert_eq!(env("CRAFT_PRINT_TITLE"), "Angebot.pdf");
// No printer chosen means the system default: an empty name, not the word "default".
let one = |j: &Job, key: &str| windows_env_vars(path, j).into_iter().find(|(k, _)| k == key).unwrap().1;
assert_eq!(one(&Job::default(), "CRAFT_PRINT_PRINTER"), "");
assert_eq!(one(&Job::default(), "CRAFT_PRINT_DUPLEX"), "");
assert_eq!(one(&Job { scaling: Scaling::ShrinkToFit, ..Job::default() }, "CRAFT_PRINT_SCALING"), "shrink-to-fit");
assert_eq!(one(&Job { duplex: Some(Duplex::Off), ..Job::default() }, "CRAFT_PRINT_DUPLEX"), "off");
assert_eq!(one(&Job { duplex: Some(Duplex::LongEdge), ..Job::default() }, "CRAFT_PRINT_DUPLEX"), "long-edge");
}
/// Run a PowerShell script for the Windows-only tests: its stdout, or everything it said when
/// it failed — the tests print that as the skip reason.
#[cfg(windows)]
fn windows_powershell(script: &str, envs: &[(&str, &str)]) -> Result<String, String> {
let mut c = std::process::Command::new("powershell");
c.args(["-NoProfile", "-NonInteractive", "-Command", &format!("$ErrorActionPreference = 'Stop'; {script}")]);
for (k, v) in envs {
c.env(k, v);
}
match c.output() {
Ok(o) if o.status.success() => Ok(String::from_utf8_lossy(&o.stdout).into_owned()),
Ok(o) => Err(format!("{} {}", String::from_utf8_lossy(&o.stderr).trim(), String::from_utf8_lossy(&o.stdout).trim())),
Err(e) => Err(e.to_string()),
}
}
/// Windows: the printer list comes from the spooler. A bare machine may have no printer; the
/// list that exists has real names and at most one default.
#[cfg(windows)]
#[test]
fn windows_lists_the_printers_the_system_knows() {
let printers = spool::printers();
assert!(printers.iter().all(|p| !p.name.trim().is_empty() && p.two_sided.is_some()), "{printers:?}");
assert!(printers.iter().filter(|p| p.default).count() <= 1, "{printers:?}");
}
/// Windows: the embedded print script is valid PowerShell, checked on every machine — including
/// runners that have no printer to print to.
#[cfg(windows)]
#[test]
fn the_windows_print_script_parses() {
let path = std::env::temp_dir().join(format!("craft-print-script-{}.ps1", std::process::id()));
std::fs::write(&path, spool::WINDOWS_PRINT_SCRIPT).unwrap();
let errors = windows_powershell(
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile($env:CRAFT_TEST_SCRIPT, [ref]$tokens, [ref]$errors) > $null; $errors.Count",
&[("CRAFT_TEST_SCRIPT", path.to_str().unwrap())],
);
let _ = std::fs::remove_file(&path);
assert_eq!(errors.as_deref().map(str::trim), Ok("0"), "parse errors: {errors:?}");
}
/// Windows, end to end: the script renders the job's sheets with Windows.Data.Pdf on any
/// machine — CI included, no printer needed (the dry run renders sheets in memory) — and, where a paused test printer can be added, a real job lands in its queue.
/// Its driver is the inbox "Generic / Text Only"; a machine that cannot add one (no rights, no
/// driver) skips that half, as the CUPS tests skip where CUPS isn't running.
#[cfg(windows)]
#[test]
fn the_print_script_renders_the_job_and_reaches_the_spooler() {
let pdf = fixture();
// The printer name is deliberately wrong: the dry run stops before the driver is touched.
let job = Job { printer: Some("no such printer".into()), title: "test.pdf".into(), ..Job::default() };
let dry = spool::windows_submit(&pdf, &job, &[("CRAFT_PRINT_DRYRUN", "1")]);
assert_eq!(dry.as_deref(), Ok("rendered 2 sheet(s)"), "{dry:?}");
let name = format!("Craft Test Print {}", std::process::id());
let name = name.as_str();
let added = windows_powershell(
r#"
$name = $env:CRAFT_TEST_PRINTER
$port = $name + '.prn'
if (Get-Printer -Name $name -ErrorAction SilentlyContinue) { throw 'test queue already exists' }
if ((Get-PrinterPort -Name $port -ErrorAction SilentlyContinue) -or (Test-Path -LiteralPath $port)) {
throw 'test port already exists'
}
$portCreated = $false
try {
# FILE: can ask GDI for an output filename even for a paused queue. A uniquely named
# local port has no prompt; the paused job is removed before it can write.
Add-PrinterPort -Name $port
$portCreated = $true
Add-Printer -Name $name -DriverName 'Generic / Text Only' -PortName $port
$queue = Get-CimInstance Win32_Printer | Where-Object Name -eq $name
if (-not $queue) { throw 'test queue could not be found' }
$paused = Invoke-CimMethod -InputObject $queue -MethodName Pause
if ($paused.ReturnValue -ne 0) { throw 'test queue could not be paused' }
} catch {
Remove-Printer -Name $name -ErrorAction SilentlyContinue
if ($portCreated) { Remove-PrinterPort -Name $port -ComputerName $env:COMPUTERNAME -ErrorAction SilentlyContinue }
throw
}
"#,
&[("CRAFT_TEST_PRINTER", name)],
);
if let Err(why) = added {
assert_ne!(std::env::var("CRAFT_PRINT_REQUIRE_QUEUE").as_deref(), Ok("1"), "CI requires a prepared paused printer: {why}");
eprintln!("skipping the real print: the test printer could not be prepared: {why}");
return;
}
let job = Job { printer: Some(name.into()), title: "test.pdf".into(), ..Job::default() };
let sent = spool::submit(&pdf, &job);
let queued = windows_powershell(
&format!("if (Get-PrintJob -PrinterName '{name}') {{ (Get-PrintJob -PrinterName '{name}' | Measure-Object).Count }} else {{ 0 }}"),
&[],
);
let cleaned = windows_powershell(
r#"
$name = $env:CRAFT_TEST_PRINTER
$port = $name + '.prn'
try { Get-PrintJob -PrinterName $name | Remove-PrintJob } finally {
try { Remove-Printer -Name $name } finally {
Remove-PrinterPort -Name $port -ComputerName $env:COMPUTERNAME
if (Test-Path -LiteralPath $port) { Remove-Item -LiteralPath $port }
}
}
if (Get-Printer -Name $name -ErrorAction SilentlyContinue) { throw 'test queue was not removed' }
if ((Get-PrinterPort -Name $port -ErrorAction SilentlyContinue) -or (Test-Path -LiteralPath $port)) {
throw 'test port was not removed'
}
Write-Output 'cleaned'
"#,
&[("CRAFT_TEST_PRINTER", name)],
);
assert!(sent.is_ok(), "{sent:?}");
assert_eq!(queued.as_deref().map(str::trim), Ok("1"), "the job is in the paused printer's queue: {queued:?}");
assert!(cleaned.is_ok(), "the test printer is removed again: {cleaned:?}");
}
/// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1
/// labels, options the Print dialog sets itself.
const FIERY_PPD: &[u8] = b"*PPD-Adobe: \"4.3\"\n\
@@ -393,86 +247,6 @@ fn printer_option_defaults_and_job_arguments() {
assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name");
}
#[cfg(windows)]
fn fixture() -> Vec<u8> {
// Minimal, asset-free PDF with portrait and landscape Letter sheets.
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Contents 5 0 R >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 792 612] /Resources << >> /Contents 5 0 R >>",
"<< /Length 26 >>\nstream\n0 0 0 rg 36 36 72 72 re f\nendstream",
];
let mut pdf = String::from("%PDF-1.4\n");
let mut offsets = vec![0];
for (i, object) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.push_str(&format!("{} 0 obj\n{object}\nendobj\n", i + 1));
}
let xref = pdf.len();
pdf.push_str(&format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len()));
for offset in offsets.iter().skip(1) {
pdf.push_str(&format!("{offset:010} 00000 n \n"));
}
pdf.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len()));
pdf.into_bytes()
}
#[cfg(windows)]
#[test]
fn windows_preserves_sheet_size_orientation_and_binding_edge() {
let output = windows_powershell(WINDOWS_PRINT_SCRIPT, &[("CRAFT_PRINT_TEST_SETTINGS", "1")]).unwrap();
let r: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
assert_eq!(r["LongEdge"], "Vertical");
assert_eq!(r["ShortEdge"], "Horizontal");
assert_eq!(r["Off"], "Simplex");
assert_eq!(r["DefaultDuplex"], "Vertical");
assert_eq!(r["ForcedSimplex"], "Simplex");
assert_eq!(r["FallbackPaper"], "Driver Letter");
// Windows PowerShell serializes whole-valued floats as JSON integers. Compare their
// numeric values rather than requiring one particular JSON number representation.
let boxes = r["ShrinkBoxes"].as_array().unwrap();
let expected = [[0.0, 20.0, 800.0, 1000.0], [200.0, 270.0, 400.0, 500.0]];
assert_eq!(boxes.len(), expected.len());
for (actual, expected) in boxes.iter().zip(expected) {
let actual = actual.as_array().unwrap();
assert_eq!(actual.len(), expected.len());
for (actual, expected) in actual.iter().zip(expected) {
assert_eq!(actual.as_f64(), Some(expected));
}
}
let sheets = r["Sheets"].as_array().unwrap();
assert_eq!((sheets[0]["Width"].as_u64(), sheets[0]["Height"].as_u64()), (Some(850), Some(1100)));
assert_eq!(sheets[0]["Name"], "Driver Letter");
assert_eq!(sheets[0]["Pixels"], serde_json::json!([1275, 1650]));
assert_eq!(sheets[1]["Landscape"], true);
assert_eq!(sheets[1]["Pixels"], serde_json::json!([1650, 1275]));
assert_eq!((sheets[2]["Width"].as_u64(), sheets[2]["Height"].as_u64()), (Some(827), Some(1169)));
assert_eq!(sheets[3]["Height"], 1400);
for s in sheets {
assert_eq!(s["Margins"], serde_json::json!([0, 0, 0, 0]));
assert_eq!(s["X"].as_f64(), Some(-25.0));
assert_eq!(s["Y"].as_f64(), Some(-30.0));
}
assert_eq!(sheets[0]["DrawWidth"].as_f64(), Some(850.0));
assert_eq!(sheets[1]["DrawWidth"].as_f64(), Some(1100.0));
let pixels = r["LargePixels"].as_array().unwrap();
assert!(pixels[0].as_u64().unwrap() * pixels[1].as_u64().unwrap() <= 64_000_000);
assert_eq!(r["InvalidRejected"], true);
}
#[cfg(windows)]
#[test]
fn windows_job_files_are_distinct_and_removed_on_drop() {
let first = windows_job_file(b"first").unwrap();
let second = windows_job_file(b"second").unwrap();
assert_ne!(first.to_path_buf(), second.to_path_buf());
assert_eq!(std::fs::read(&first).unwrap(), b"first");
let path = first.to_path_buf();
drop(first);
assert!(!path.exists());
}
#[test]
fn command_previews_do_not_require_pdf_files() {
let job = Job { printer: Some("Office (east)".into()), copies: 2, ..Job::default() };
@@ -481,8 +255,10 @@ fn command_previews_do_not_require_pdf_files() {
assert_eq!(preview[0], "lp");
assert_eq!(preview[1..], lp_args(&job));
} else {
assert_eq!(preview[0], "powershell");
assert!(!preview.join(" ").contains(".pdf"));
assert_eq!(
preview,
["xps-print", "printer=Office (east)", "copies=2", "collate=true", "duplex=(queue default)", "scaling=exact", "color=color"]
);
}
}
@@ -496,14 +272,3 @@ fn queue_duplex_defaults_and_explicit_scaling_reach_the_spooler() {
assert!(shrink.iter().any(|a| a == "print-scaling=auto-fit"));
assert!(!shrink.iter().any(|a| a.starts_with("fit-to-page=")));
}
/// A portable app must never resolve PowerShell from its own directory.
#[cfg(windows)]
#[test]
fn windows_starts_powershell_by_its_full_path() {
let command = powershell_command("$null");
let program = std::path::Path::new(command.get_program());
assert!(program.is_absolute(), "{program:?}");
assert!(program.ends_with("System32/WindowsPowerShell/v1.0/powershell.exe"), "{program:?}");
assert!(program.is_file(), "{program:?}");
}
+256
View File
@@ -0,0 +1,256 @@
use super::*;
fn fixture(contents: &str, compressed: bool, unit: &str) -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_string(),
"<< /Length 26 >>\nstream\n0 0 0 rg 36 36 72 72 re f\nendstream".to_string(),
format!("<< /Type /Page /Parent 2 0 R /Resources <<>> /MediaBox [0 0 792 1224] /Contents {contents} {unit} >>"),
"[3 0 R]".to_string(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
if !compressed {
let mut offsets = vec![0];
for (i, object) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(b"xref\n0 6\n0000000000 65535 f\r\n");
for offset in offsets.iter().skip(1) {
pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
return pdf;
}
let content_at = pdf.len();
pdf.extend_from_slice(format!("3 0 obj\n{}\nendobj\n", objects[2]).as_bytes());
let (mut header, mut body) = (String::new(), String::new());
let inside = [1usize, 2, 4, 5];
for &i in &inside {
header += &format!("{i} {} ", body.len());
body += &objects[i - 1];
body.push('\n');
}
let packed = miniz_oxide::deflate::compress_to_vec_zlib(format!("{header}{body}").as_bytes(), 6);
let stm_at = pdf.len();
pdf.extend_from_slice(
format!("6 0 obj\n<< /Type /ObjStm /N 4 /First {} /Filter /FlateDecode /Length {} >>\nstream\n", header.len(), packed.len()).as_bytes(),
);
pdf.extend_from_slice(&packed);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
let xref_at = pdf.len();
let mut rows = Vec::new();
for i in 0..8 {
let (kind, value, generation) = match i {
0 => (0u8, 0u32, 65535u16),
3 => (1, u32::try_from(content_at).unwrap(), 0),
6 => (1, u32::try_from(stm_at).unwrap(), 0),
7 => (1, u32::try_from(xref_at).unwrap(), 0),
i => (2, 6, u16::try_from(inside.iter().position(|&n| n == i).unwrap()).unwrap()),
};
rows.push(kind);
rows.extend_from_slice(&value.to_be_bytes());
rows.extend_from_slice(&generation.to_be_bytes());
}
pdf.extend_from_slice(format!("7 0 obj\n<< /Type /XRef /Size 8 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", rows.len()).as_bytes());
pdf.extend_from_slice(&rows);
pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_at}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn reframe_preserves_single_direct_and_indirect_content_streams() {
for compressed in [false, true] {
for (name, contents) in [("single", "3 0 R"), ("direct-array", "[3 0 R]"), ("indirect-array", "5 0 R")] {
let original = fixture(contents, compressed, "");
let sheets = read(&original).unwrap();
let updated = sheets.update(&plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap()).unwrap();
let sheets = read(&updated).unwrap();
let contents = get(&sheets.sheets[0].dict, b"Contents").unwrap().array().unwrap();
assert_eq!(contents.len(), 3);
assert_eq!(contents[1], Obj::Ref(3, 0), "the square stream must survive, not a reference to the array");
if let Some(dir) = std::env::var_os("CRAFT_PRINT_PDF_FIXTURES") {
let dir = std::path::PathBuf::from(dir);
std::fs::write(dir.join(format!("{name}-{compressed}-original.pdf")), &original).unwrap();
std::fs::write(dir.join(format!("{name}-{compressed}-updated.pdf")), &updated).unwrap();
}
}
}
}
#[test]
fn nondefault_or_invalid_user_unit_is_rejected_before_any_update() {
for unit in ["2", "0", "-1", "(invalid)", "5 0 R"] {
for compressed in [false, true] {
let pdf = fixture("3 0 R", compressed, &format!("/UserUnit {unit}"));
assert!(matches!(read(&pdf), Err(Error::Spool(message)) if message.contains("UserUnit")));
}
}
for unit in ["", "/UserUnit 1"] {
assert!(read(&fixture("3 0 R", false, unit)).is_ok());
}
for (unit, accepted) in [("1 ", true), ("2 ", false)] {
let pdf = fixture("3 0 R", false, "/UserUnit 5 0 R");
let mut pdf = pdf;
let at = pdf.windows(7).position(|bytes| bytes == b"[3 0 R]").unwrap();
pdf[at..at + 7].copy_from_slice(unit.as_bytes());
assert_eq!(read(&pdf).is_ok(), accepted, "indirect numeric UserUnit");
}
}
fn xref_bomb(count: usize, index: Option<&str>) -> Vec<u8> {
let packed = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; count], 6);
let mut pdf = b"%PDF-1.7\n".to_vec();
let start = pdf.len();
let index = index.map(|value| format!("/Index {value}")).unwrap_or_default();
pdf.extend_from_slice(
format!("1 0 obj\n<< /Type /XRef /Size {count} {index} /W [0 1 0] /Filter /FlateDecode /Length {} >>\nstream\n", packed.len()).as_bytes(),
);
pdf.extend_from_slice(&packed);
pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{start}\n%%EOF\n").as_bytes());
pdf
}
#[test]
fn compressed_xref_record_counts_fail_without_expanding_the_map() {
for index in [None, Some("[0 8000000]"), Some("[0 250001 0 250000]")] {
let pdf = xref_bomb(8_000_000, index);
let mut entries = HashMap::new();
let mut budget = Budget::default();
assert!(xref_section(&pdf, startxref(&pdf).unwrap(), &mut entries, &mut budget).is_none());
assert!(entries.is_empty());
assert_eq!(budget.decoded, 0, "record limits precede decompression");
assert!(budget.exhausted);
assert!(read(&pdf).is_err());
}
}
#[test]
fn classic_xref_declared_count_is_bounded_before_reading_records() {
let pdf = b"xref\n0 8000000\n";
let mut entries = HashMap::new();
let mut budget = Budget::default();
assert!(xref_section(pdf, 0, &mut entries, &mut budget).is_none());
assert!(budget.exhausted);
assert!(entries.is_empty());
}
#[test]
fn object_stream_and_parsed_container_budgets_are_job_wide() {
let dict = vec![];
let mut budget = Budget { decoded: MAX_DECODED - 4, ..Budget::default() };
assert_eq!(decode(&dict, b"1234", &mut budget), Some(b"1234".to_vec()));
assert!(decode(&dict, b"1", &mut budget).is_none());
assert!(budget.exhausted);
let array = format!("[{}]", "null ".repeat(MAX_OBJECT_NODES));
assert!(Lexer::new(array.as_bytes(), 0).object(0).is_none());
let token = format!("({})", "x".repeat(MAX_OBJECT_TOKENS));
assert!(Lexer::new(token.as_bytes(), 0).object(0).is_none());
let mut budget = Budget { nodes: MAX_NODES, ..Budget::default() };
let mut lx = Lexer::new(b"null", 0);
assert!(lx.object(0).is_some());
assert!(budget.parsed(&lx).is_none());
assert!(budget.exhausted);
}
#[test]
fn predictor_rows_cannot_allocate_beyond_decoded_budget() {
let parms = vec![
(b"Predictor".to_vec(), Obj::Num(b"12".to_vec())),
(b"Columns".to_vec(), Obj::Num(b"1048576".to_vec())),
(b"Colors".to_vec(), Obj::Num(b"32".to_vec())),
(b"BitsPerComponent".to_vec(), Obj::Num(b"16".to_vec())),
];
assert!(unpredict(&[0], Some(&parms), MAX_STREAM).is_none());
}
#[test]
fn reframe_keeps_array_order_and_treats_null_as_an_empty_page() {
for (contents, expected) in [("[3 0 R 3 0 R]", 4), ("null", 2)] {
let original = fixture(contents, false, "");
let sheets = read(&original).unwrap();
let updated = sheets.update(&plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 0.0 }).unwrap()).unwrap();
let sheets = read(&updated).unwrap();
let contents = get(&sheets.sheets[0].dict, b"Contents").unwrap().array().unwrap();
assert_eq!(contents.len(), expected);
for item in &contents[1..expected - 1] {
assert_eq!(*item, Obj::Ref(3, 0));
}
}
for contents in ["[5 0 R [3 0 R]]", "123", "(text)"] {
assert!(read(&fixture(contents, false, "")).is_err());
}
}
#[test]
fn separate_cached_object_streams_cannot_exceed_the_job_budget() {
let mut pdf = b"%PDF-1.7\n".to_vec();
let values = [
(1, "<< /Type /Catalog /Pages 2 0 R >>"),
(2, "<< /Type /Pages /Kids [4 0 R] /Count 1 >>"),
(4, "<< /Type /Page /MediaBox [0 0 612 792] >>"),
];
let mut offsets = Vec::new();
for (i, (num, value)) in values.iter().enumerate() {
offsets.push(pdf.len());
let mut raw = format!("{num} 0 {value}").into_bytes();
raw.resize(12 << 20, b' ');
let packed = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
pdf.extend_from_slice(
format!("{} 0 obj\n<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /Length {} >>\nstream\n", i + 6, packed.len()).as_bytes(),
);
pdf.extend_from_slice(&packed);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
}
let xref = pdf.len();
let mut rows = Vec::new();
for num in 0..10 {
let (kind, value, index) = match num {
1 => (2u8, 6u32, 0u16),
2 => (2, 7, 0),
4 => (2, 8, 0),
6..=8 => (1, u32::try_from(offsets[num - 6]).unwrap(), 0),
9 => (1, u32::try_from(xref).unwrap(), 0),
_ => (0, 0, 65535),
};
rows.push(kind);
rows.extend_from_slice(&value.to_be_bytes());
rows.extend_from_slice(&index.to_be_bytes());
}
pdf.extend_from_slice(format!("9 0 obj\n<< /Type /XRef /Size 10 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", rows.len()).as_bytes());
pdf.extend_from_slice(&rows);
pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes());
assert!(read(&pdf).is_err(), "three small compressed streams must not cache 36 MiB");
}
#[test]
fn duplicate_dictionary_keys_keep_the_last_value_and_last_position() {
let dict = Lexer::new(b"<< /A 1 /B 2 /A 3 /C 4 /B 5 >>", 0).object(0).unwrap();
let dict = dict.dict().unwrap();
assert_eq!(
dict.iter().map(|(key, value)| (key.as_slice(), value.int().unwrap())).collect::<Vec<_>>(),
[(b"A".as_slice(), 3), (b"C".as_slice(), 4), (b"B".as_slice(), 5)]
);
let escaped = Lexer::new(b"<< /A#42 1 /C 2 /AB 3 >>", 0).object(0).unwrap();
assert_eq!(escaped.dict().unwrap(), &vec![(b"C".to_vec(), Obj::Num(b"2".to_vec())), (b"AB".to_vec(), Obj::Num(b"3".to_vec()))]);
}
#[test]
fn an_inflate_limit_stops_later_stream_and_object_work() {
let packed = miniz_oxide::deflate::compress_to_vec_zlib(&[0; 32], 6);
let dict = vec![(b"Filter".to_vec(), Obj::Name(b"FlateDecode".to_vec()))];
let mut budget = Budget { decoded: MAX_DECODED - 4, ..Budget::default() };
assert!(decode(&dict, &packed, &mut budget).is_none());
assert!(budget.exhausted);
// A rejected inflate has not charged its partial output. A later tiny stream
// would otherwise fit, letting geometry fallback repeatedly try more streams.
assert_eq!(budget.decoded, MAX_DECODED - 4);
assert!(decode(&vec![], b"ok", &mut budget).is_none());
assert!(budget.charge(1, 0, 0, 0).is_none());
let mut reader = Reader { data: b"1 0 obj (hello) endobj", entries: HashMap::from([(1, Entry::At(0))]), objstms: HashMap::new(), budget };
assert!(reader.object(1).is_none());
assert_eq!(reader.budget.nodes, 0);
assert_eq!(reader.budget.tokens, 0);
}
+395
View File
@@ -0,0 +1,395 @@
//! The Windows backend's tests: the printer list, the sheets read from a print-ready PDF and
//! the incremental update that gives them one shape (all platforms), and a job reaching a
//! paused test printer (Windows).
use super::*;
#[test]
fn windows_printers_are_sorted_once_with_their_default() {
// Queue names hold spaces, commas, parentheses and non-ASCII letters; a connection can be
// listed twice; the default is matched whole, never by prefix; whether the driver takes
// two-sided settings travels with each name (unknown when its capabilities can't be read).
let queues = [
("Zebra Label", Some(false)),
("HP LaserJet 400 (Copy 2)", Some(true)),
("Копир Canon iR", None),
(r"\\print01\Office, 2nd floor", Some(true)),
("Zebra Label", Some(false)),
(" ", None),
];
let printers = spool::printer_list(queues.map(|(n, s)| (n.to_string(), s)), Some(r"\\print01\Office, 2nd floor"));
assert_eq!(
printers,
[
Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: Some(true) },
Printer { name: "Zebra Label".into(), default: false, two_sided: Some(false) },
Printer { name: r"\\print01\Office, 2nd floor".into(), default: true, two_sided: Some(true) },
Printer { name: "Копир Canon iR".into(), default: false, two_sided: None },
]
);
assert!(spool::printer_list([("Office".to_string(), None)], Some("Office 2")).iter().all(|p| !p.default));
assert!(spool::printer_list([("Office".to_string(), None)], None).iter().all(|p| !p.default));
}
/// One page of a test PDF: its MediaBox (`None` = inherited from the page tree's root, which is
/// Letter), CropBox and `/Rotate`.
#[derive(Clone, Copy)]
struct TestPage {
media: Option<[f64; 4]>,
crop: Option<[f64; 4]>,
rotate: Option<i32>,
}
const LETTER: TestPage = TestPage { media: Some([0.0, 0.0, 612.0, 792.0]), crop: None, rotate: None };
const LETTER_LANDSCAPE: TestPage = TestPage { media: Some([0.0, 0.0, 792.0, 612.0]), crop: None, rotate: None };
const TABLOID: TestPage = TestPage { media: Some([0.0, 0.0, 792.0, 1224.0]), crop: None, rotate: None };
/// A print-ready PDF of `pages`, each with a small black square: a classic cross-reference
/// table, or (`compressed`) the pages inside a Flate object stream with a predicted
/// cross-reference stream, as modern writers save. The root page node sets a Letter MediaBox
/// for pages that inherit it.
fn test_pdf(pages: &[TestPage], compressed: bool) -> Vec<u8> {
let n = pages.len();
// 1 catalog, 2 page tree, 3 content, then the pages.
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + i)).collect();
let mut objects = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 612 792] >>", kids.join(" ")),
"<< /Length 26 >>\nstream\n0 0 0 rg 36 36 72 72 re f\nendstream".to_string(),
];
let rect = |r: [f64; 4]| format!("[{} {} {} {}]", r[0], r[1], r[2], r[3]);
for p in pages {
let mut page = "<< /Type /Page /Parent 2 0 R /Resources << >> /Contents 3 0 R".to_string();
if let Some(m) = p.media {
page += &format!(" /MediaBox {}", rect(m));
}
if let Some(c) = p.crop {
page += &format!(" /CropBox {}", rect(c));
}
if let Some(r) = p.rotate {
page += &format!(" /Rotate {r}");
}
objects.push(page + " >>");
}
let mut pdf = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n".to_vec();
if !compressed {
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", offsets.len() + 1).as_bytes());
for offset in &offsets {
pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes());
return pdf;
}
// Objects 1, 2 and the pages go in object stream `stm`; the content stream can't.
let stm = objects.len() + 1;
let inside: Vec<usize> = (1..=objects.len()).filter(|&i| i != 3).collect();
let (mut header, mut body) = (String::new(), String::new());
for &i in &inside {
header += &format!("{i} {} ", body.len());
body += &objects[i - 1];
body += "\n";
}
let packed = miniz_oxide::deflate::compress_to_vec_zlib(format!("{header}{body}").as_bytes(), 6);
let content_at = pdf.len();
pdf.extend_from_slice(format!("3 0 obj\n{}\nendobj\n", objects[2]).as_bytes());
let stm_at = pdf.len();
pdf.extend_from_slice(
format!(
"{stm} 0 obj\n<< /Type /ObjStm /N {} /First {} /Filter /FlateDecode /Length {} >>\nstream\n",
inside.len(),
header.len(),
packed.len()
)
.as_bytes(),
);
pdf.extend_from_slice(&packed);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
let xref_num = stm + 1;
let xref_at = pdf.len();
// Rows of 1 + 2 + 1 bytes, PNG "Up"-predicted (filter byte 2), as Acrobat writes them.
let mut rows: Vec<[u8; 4]> = vec![[0, 0, 0, 255]];
for i in 1..=xref_num {
rows.push(match i {
3 => [1, (content_at >> 8) as u8, content_at as u8, 0],
i if i == stm => [1, (stm_at >> 8) as u8, stm_at as u8, 0],
i if i == xref_num => [1, (xref_at >> 8) as u8, xref_at as u8, 0],
i => [2, 0, stm as u8, inside.iter().position(|&x| x == i).unwrap() as u8],
});
}
let mut predicted = Vec::new();
let mut prev = [0u8; 4];
for row in rows {
predicted.push(2);
predicted.extend(row.iter().zip(prev).map(|(b, p)| b.wrapping_sub(p)));
prev = row;
}
let packed = miniz_oxide::deflate::compress_to_vec_zlib(&predicted, 6);
pdf.extend_from_slice(
format!(
"{xref_num} 0 obj\n<< /Type /XRef /Size {} /W [1 2 1] /Root 1 0 R /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 4 >> /Length {} >>\nstream\n",
xref_num + 1,
packed.len()
)
.as_bytes(),
);
pdf.extend_from_slice(&packed);
pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_at}\n%%EOF\n").as_bytes());
pdf
}
/// `pdf` with `from` replaced by `to` (bytes, so the binary comment line survives; the same
/// length, unless after the last object, so the cross-reference offsets still hold).
fn replaced(pdf: &[u8], from: &str, to: &str) -> Vec<u8> {
let at = pdf.windows(from.len()).position(|w| w == from.as_bytes()).unwrap();
[&pdf[..at], to.as_bytes(), &pdf[at + from.len()..]].concat()
}
/// Each sheet's printed size (rounded to a point) and rotation.
fn shapes(pdf: &[u8]) -> Vec<((i64, i64), u32)> {
let read = crate::sheets::read(pdf).unwrap();
read.sheets.iter().map(|s| ((s.size().0.round() as i64, s.size().1.round() as i64), s.rotate)).collect()
}
#[test]
fn sheets_are_read_from_classic_and_compressed_files() {
let pages = [
LETTER,
TestPage { media: None, crop: None, rotate: Some(90) },
TestPage { media: Some([0.0, 0.0, 595.28, 841.89]), crop: Some([10.0, 20.0, 410.0, 320.0]), rotate: Some(-90) },
TestPage { media: Some([0.0, 0.0, 612.0, 792.0]), crop: None, rotate: Some(45) },
];
for compressed in [false, true] {
assert_eq!(
shapes(&test_pdf(&pages, compressed)),
[((612, 792), 0), ((792, 612), 90), ((300, 400), 270), ((612, 792), 0)],
"inherited MediaBox, CropBox, -90 is 270, 45 is not a rotation (compressed: {compressed})"
);
}
}
#[test]
fn mixed_orientations_turn_to_the_first_sheets() {
use crate::sheets::{Fit, plan, read};
for compressed in [false, true] {
let pdf = test_pdf(&[LETTER, LETTER_LANDSCAPE, LETTER], compressed);
let sheets = read(&pdf).unwrap();
let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap();
assert_eq!(edits.len(), 1, "only the landscape sheet changes");
let updated = sheets.update(&edits).unwrap();
assert!(updated.starts_with(&pdf), "an incremental update: the original bytes stay as they were");
assert_eq!(
shapes(&updated),
[((612, 792), 0), ((612, 792), 270), ((612, 792), 0)],
"the landscape sheet turned a quarter anticlockwise (compressed: {compressed})"
);
let tail = String::from_utf8_lossy(&updated[pdf.len()..]).into_owned();
assert!(tail.contains("/Contents 3 0 R"), "a turned sheet keeps its content: {tail}");
assert_eq!(tail.contains("/Type /XRef"), compressed, "the update's cross-reference is the kind the file had: {tail}");
// The updated file reads again, its /Prev chain followed, and needs nothing more.
let again = read(&updated).unwrap();
assert!(plan(&again.sheets, (612.0, 792.0), Fit::Exact).unwrap().is_empty());
}
// A landscape first sheet makes it a landscape job: the portrait sheets turn the other way.
let pdf = test_pdf(&[LETTER_LANDSCAPE, TestPage { rotate: Some(180), ..LETTER }], false);
let sheets = read(&pdf).unwrap();
let updated = sheets.update(&plan(&sheets.sheets, (792.0, 612.0), Fit::Exact).unwrap()).unwrap();
assert_eq!(shapes(&updated), [((792, 612), 0), ((792, 612), 270)], "180 + 90");
// One shape already: nothing to do, and the job is the PDF as it came.
let pdf = test_pdf(&[LETTER, LETTER], true);
let sheets = read(&pdf).unwrap();
assert!(plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap().is_empty());
assert_eq!(sheets.update(&[]).unwrap(), pdf);
}
#[test]
fn exact_size_refuses_a_sheet_of_another_size() {
let pdf = test_pdf(&[LETTER, TestPage { media: Some([0.0, 0.0, 612.0, 1008.0]), ..LETTER }], false);
let sheets = crate::sheets::read(&pdf).unwrap();
let refused = crate::sheets::plan(&sheets.sheets, (612.0, 792.0), crate::sheets::Fit::Exact);
assert!(matches!(refused, Err(Error::Spool(ref m)) if m.contains("sheet 2") && m.contains("one paper size")), "{refused:?}");
}
/// The `q … cm … re W n` line a reframed page's content starts with.
fn frame_of(updated: &[u8], original: usize) -> String {
let tail = String::from_utf8_lossy(&updated[original..]).into_owned();
tail.lines().find(|l| l.starts_with("q ")).unwrap_or_default().to_string()
}
#[test]
fn shrink_to_fit_reframes_sheets_on_the_paper() {
use crate::sheets::{Fit, plan, read};
// Tabloid onto Letter with an 18 pt margin: 756 / 1224 of the height wins, centred.
let pdf = test_pdf(&[TABLOID], true);
let sheets = read(&pdf).unwrap();
let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap();
let updated = sheets.update(&edits).unwrap();
assert_eq!(shapes(&updated), [((612, 792), 0)]);
assert_eq!(frame_of(&updated, pdf.len()), "q 0.6176 0 0 0.6176 61.4118 18 cm 0 0 792 1224 re W n");
let tail = String::from_utf8_lossy(&updated[pdf.len()..]).into_owned();
assert!(tail.contains("/Contents [7 0 R 3 0 R 8 0 R]"), "the page's own content between q … Q: {tail}");
// A smaller sheet keeps its size, centred; a sheet with a CropBox is clipped to it, and the
// crop is what lands centred.
let card = TestPage { media: Some([0.0, 0.0, 360.0, 504.0]), ..LETTER };
let cropped = TestPage { crop: Some([100.0, 100.0, 400.0, 500.0]), ..LETTER };
let pdf = test_pdf(&[card, cropped], false);
let sheets = read(&pdf).unwrap();
let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap();
assert_eq!(edits.len(), 2);
assert_eq!(edits[0].frame.as_ref().unwrap().matrix, [1.0, 0.0, 0.0, 1.0, 126.0, 144.0], "never enlarged");
assert_eq!(edits[1].frame.as_ref().unwrap().matrix, [1.0, 0.0, 0.0, 1.0, 56.0, 96.0]);
assert_eq!(edits[1].frame.as_ref().unwrap().clip, [100.0, 100.0, 300.0, 400.0]);
assert_eq!(shapes(&sheets.update(&edits).unwrap()), [((612, 792), 0), ((612, 792), 0)]);
// The paper's own size: shrunk into the printable area when the printer has margins, as
// CUPS' auto-fit does, and left alone when it prints to the edge.
let pdf = test_pdf(&[LETTER], false);
let sheets = read(&pdf).unwrap();
assert_eq!(plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 12.0 }).unwrap().len(), 1);
assert!(plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 0.0 }).unwrap().is_empty());
// Sheets of the other orientation turn first, then fit. A Tabloid page with /Rotate 90 is a
// landscape sheet: turned back to 0 and shrunk; a landscape Tabloid page is turned to 270.
let pdf = test_pdf(&[LETTER, TestPage { media: Some([0.0, 0.0, 1224.0, 792.0]), ..LETTER }, TestPage { rotate: Some(90), ..TABLOID }], true);
let sheets = read(&pdf).unwrap();
let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 0.0 }).unwrap();
assert_eq!(edits.iter().map(|e| (e.sheet, e.rotate)).collect::<Vec<_>>(), [(1, 270), (2, 0)]);
assert_eq!(edits[0].frame.as_ref().unwrap().media, (792.0, 612.0), "the paper in the page's unrotated space");
assert_eq!(shapes(&sheets.update(&edits).unwrap()), [((612, 792), 0), ((612, 792), 270), ((612, 792), 0)]);
}
#[test]
fn an_encrypted_file_is_turned_but_not_reframed() {
// An /Encrypt entry is all it takes: new content streams would have to be encrypted too.
let pdf = test_pdf(&[LETTER, LETTER_LANDSCAPE], false);
let encrypted = replaced(&pdf, "/Root 1 0 R >>", "/Root 1 0 R /Encrypt << /Filter /Standard >> >>");
let sheets = crate::sheets::read(&encrypted).unwrap();
let turned = crate::sheets::plan(&sheets.sheets, (612.0, 792.0), crate::sheets::Fit::Exact).unwrap();
let updated = sheets.update(&turned).unwrap();
assert_eq!(shapes(&updated), [((612, 792), 0), ((612, 792), 270)]);
assert!(String::from_utf8_lossy(&updated[encrypted.len()..]).contains("/Encrypt"), "the new trailer keeps /Encrypt");
let shrunk = crate::sheets::plan(&sheets.sheets, (612.0, 792.0), crate::sheets::Fit::Shrink { margin: 18.0 }).unwrap();
assert!(matches!(sheets.update(&shrunk), Err(Error::Spool(ref m)) if m.contains("encrypted")));
}
#[test]
fn unreadable_sheets_are_errors_never_panics() {
for bad in [&b""[..], b"not a pdf", b"%PDF-1.7\nstartxref\n999999\n%%EOF", b"%PDF-1.7\nxref\n0 1\ntrailer\n<< >>\nstartxref\n9\n%%EOF"] {
assert!(crate::sheets::read(bad).is_err());
}
// A page tree that contains itself, and one without pages.
let pdf = test_pdf(&[LETTER], false);
let looped = replaced(&pdf, "/Kids [4 0 R]", "/Kids [2 0 R]");
assert!(matches!(crate::sheets::read(&looped), Err(Error::Spool(ref m)) if m.contains("loops")));
let empty = replaced(&pdf, "/Kids [4 0 R]", "/Kids [ ]");
assert!(matches!(crate::sheets::read(&empty), Err(Error::Spool(ref m)) if m.contains("no pages")));
// Every truncation and thousands of corruptions of both kinds of file: read and update what
// is still readable, without panicking.
for compressed in [false, true] {
let pdf = test_pdf(&[LETTER, LETTER_LANDSCAPE, TABLOID], compressed);
let mut variants: Vec<Vec<u8>> = (0..pdf.len()).map(|n| pdf[..n].to_vec()).collect();
for (i, b) in [b'0', b'9', b'/', b'<', b'(', b']', b' ', 0xff].into_iter().cycle().take(4000).enumerate() {
let mut v = pdf.clone();
let at = (i * 7919) % v.len();
v[at] = b;
variants.push(v);
}
for v in variants {
if let Ok(sheets) = crate::sheets::read(&v)
&& let Ok(edits) = crate::sheets::plan(&sheets.sheets, (612.0, 792.0), crate::sheets::Fit::Shrink { margin: 18.0 })
{
let _ = sheets.update(&edits);
}
}
}
}
/// Windows: the printer list comes from the spooler. A bare machine may have no printer; the
/// list that exists has real names and at most one default.
#[cfg(windows)]
#[test]
fn windows_lists_the_printers_the_system_knows() {
let printers = spool::printers();
assert!(printers.iter().all(|p| !p.name.trim().is_empty()), "{printers:?}");
assert!(printers.iter().filter(|p| p.default).count() <= 1, "{printers:?}");
}
/// Run a PowerShell script for the Windows test's printer setup: its stdout, or everything it
/// said when it failed (the test prints that as the skip reason). Any error stops the script and
/// fails it: `-Command` otherwise exits 0 after a failed command, such as a parameter the cmdlet
/// doesn't have. Started by its full path: given a bare name, Rust looks in the test binary's own
/// folder before System32. The library itself starts no PowerShell.
#[cfg(windows)]
fn windows_powershell(script: &str, printer: &str) -> Result<String, String> {
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
let exe = std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe");
let mut c = std::process::Command::new(exe);
c.args(["-NoProfile", "-NonInteractive", "-Command", &format!("$ErrorActionPreference = 'Stop'; $name = $env:CRAFT_TEST_PRINTER; {script}")]);
c.env("CRAFT_TEST_PRINTER", printer);
match c.output() {
Ok(o) if o.status.success() => Ok(String::from_utf8_lossy(&o.stdout).into_owned()),
Ok(o) => Err(format!("{} {}", String::from_utf8_lossy(&o.stderr).trim(), String::from_utf8_lossy(&o.stdout).trim())),
Err(e) => Err(e.to_string()),
}
}
/// Windows, end to end: two jobs reach a paused test printer's queue, each as one job under its
/// title. One mixes portrait and landscape Letter sheets at exact size (the landscape sheet is
/// turned); the other is shrink-to-fit with a Tabloid sheet (reframed onto the driver's paper),
/// so both kinds of update pass through Windows.Data.Pdf. craft-print starts no program to print
/// them; the test uses PowerShell only to add, inspect and remove the printer. Its driver is the
/// inbox "Generic / Text Only"; a machine that cannot add one (no rights, no driver) skips the
/// test, unless `CRAFT_PRINT_REQUIRE_QUEUE=1` (CI) makes it mandatory.
#[cfg(windows)]
#[test]
fn windows_jobs_reach_the_spooler() {
let name = format!("Craft Test Print {}", std::process::id());
let name = name.as_str();
// The printer goes on LPT1:, one of the ports every Windows install has, so the test adds no
// port: Remove-PrinterPort can't remove a local port again (it reports an invalid name). Not
// FILE:, which asks where to save; a paused FILE: queue never takes the job and the print call
// waits for it. Paused through Win32_Printer, since Set-Printer can't pause a printer: the
// job stays in the queue and nothing is sent to the port.
let added = windows_powershell(
"if (Get-Printer -Name $name -ErrorAction SilentlyContinue) { throw 'test queue already exists' }; Add-Printer -Name $name -DriverName 'Generic / Text Only' -PortName 'LPT1:'; $r = Invoke-CimMethod -InputObject (Get-CimInstance Win32_Printer | Where-Object Name -eq $name) -MethodName Pause; if ($r.ReturnValue -ne 0) { Remove-Printer -Name $name; throw ('Pause returned ' + $r.ReturnValue) }",
name,
);
if let Err(why) = added {
assert_ne!(std::env::var("CRAFT_PRINT_REQUIRE_QUEUE").as_deref(), Ok("1"), "CI requires a prepared paused printer: {why}");
eprintln!("skipping the real print: the test printer could not be prepared: {why}");
return;
}
let listed = spool::printers().into_iter().find(|p| p.name == name);
let mixed = test_pdf(&[LETTER, LETTER_LANDSCAPE, LETTER], true);
let job = Job { printer: Some(name.into()), copies: 2, duplex: Some(Duplex::LongEdge), title: "mixed.pdf".into(), ..Job::default() };
let sent = spool::submit(&mixed, &job);
let tabloid = test_pdf(&[TABLOID, LETTER], false);
let shrink = Job { printer: Some(name.into()), scaling: Scaling::ShrinkToFit, title: "shrink".into(), ..Job::default() };
let shrunk = spool::submit(&tabloid, &shrink);
let exact_refused = spool::submit(&tabloid, &Job { printer: Some(name.into()), ..Job::default() });
let queued = windows_powershell(
"@(Get-PrintJob -PrinterName $name | Sort-Object Id | ForEach-Object { $_.DocumentName + ' ' + $_.TotalPages }) -join '|'",
name,
);
// A printer that still has jobs is only marked for deletion, so the queue is emptied first.
// `exit 0`: `-Command` exits 1 when its last command failed, even silently.
let cleaned = windows_powershell(
"Get-PrintJob -PrinterName $name | Remove-PrintJob; foreach ($i in 1..20) { if (-not (Get-PrintJob -PrinterName $name)) { break }; Start-Sleep -Milliseconds 250 }; Remove-Printer -Name $name; if (Get-Printer -Name $name -ErrorAction SilentlyContinue) { throw 'the printer is still there' }; exit 0",
name,
);
assert!(listed.as_ref().is_some_and(|p| p.two_sided.is_some()), "the new printer is listed, its duplex support known: {listed:?}");
assert_eq!(sent.as_deref(), Ok("spooled 3 sheet(s)"), "{sent:?}");
assert_eq!(shrunk.as_deref(), Ok("spooled 2 sheet(s)"), "{shrunk:?}");
assert!(matches!(exact_refused, Err(Error::Spool(ref m)) if m.contains("one paper size")), "{exact_refused:?}");
assert_eq!(queued.as_deref().map(str::trim), Ok("mixed.pdf 3|shrink.pdf 2"), "one job each, all sheets, in the paused queue: {queued:?}");
assert!(cleaned.is_ok(), "the test printer is removed again: {cleaned:?}");
// Asking for a printer that isn't there is an error, never a job on another one.
let missing = spool::submit(&mixed, &Job { printer: Some(name.into()), ..Job::default() });
assert!(matches!(missing, Err(Error::Spool(ref m)) if m.contains(name)), "{missing:?}");
}
+341
View File
@@ -0,0 +1,341 @@
//! The Windows job's PrintTicket, and what is read back from the driver's: plain XML and
//! numbers, so it is tested on every platform. The calls that hand them to the driver are in
//! `windows.rs`.
use crate::{Duplex, Error, Job};
pub(crate) const NS_PSF: &str = "http://schemas.microsoft.com/windows/2003/08/printing/printschemaframework";
pub(crate) const NS_PSK: &str = "http://schemas.microsoft.com/windows/2003/08/printing/printschemakeywords";
/// Paper sizes, in micrometres, this close are the same paper (a quarter of a millimetre).
pub(crate) const PAPER_TOLERANCE: u32 = 250;
fn spool_error(e: impl std::fmt::Display) -> Error {
Error::Spool(e.to_string())
}
/// Points to micrometres; `None` for a size no printer takes (at most 200 inches, so the
/// conversion can't overflow).
pub(crate) fn micrometres(points: f64) -> Option<u32> {
(points.is_finite() && points > 0.0 && points <= 14_400.0).then(|| (points * 25_400.0 / 72.0).round() as u32)
}
pub(crate) fn points(micrometres: u32) -> f64 {
f64::from(micrometres) * 72.0 / 25_400.0
}
/// The PrintTicket the job asks for: the job's options, the orientation, and no scaling (the
/// sheets are laid out at their final size; `sheets.rs` reframes them for shrink-to-fit). The
/// paper is merged in separately, as the driver's own option. Only schema keywords and bounded
/// numbers go into the XML; the printer name and the document title never do. Two-sided is left
/// out when the job doesn't set it, so the queue's default applies.
pub(crate) fn ticket_xml(job: &Job, landscape: bool) -> String {
let orientation = if landscape { "Landscape" } else { "Portrait" };
let duplex = match job.duplex {
None => String::new(),
Some(duplex) => {
let option = match duplex {
Duplex::Off => "OneSided",
Duplex::LongEdge => "TwoSidedLongEdge",
Duplex::ShortEdge => "TwoSidedShortEdge",
};
format!("\n <psf:Feature name=\"psk:JobDuplexAllDocumentsContiguously\"><psf:Option name=\"psk:{option}\"/></psf:Feature>")
}
};
let collate = if job.collate { "Collated" } else { "Uncollated" };
let color = if job.grayscale { "Monochrome" } else { "Color" };
let copies = job.copies.clamp(1, 999);
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<psf:PrintTicket version="1" xmlns:psf="{NS_PSF}" xmlns:psk="{NS_PSK}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<psf:ParameterInit name="psk:JobCopiesAllDocuments"><psf:Value xsi:type="xsd:integer">{copies}</psf:Value></psf:ParameterInit>
<psf:Feature name="psk:DocumentCollate"><psf:Option name="psk:{collate}"/></psf:Feature>{duplex}
<psf:Feature name="psk:PageOutputColor"><psf:Option name="psk:{color}"/></psf:Feature>
<psf:Feature name="psk:PageScaling"><psf:Option name="psk:None"/></psf:Feature>
<psf:Feature name="psk:PageOrientation"><psf:Option name="psk:{orientation}"/></psf:Feature>
</psf:PrintTicket>"#
)
}
/// Standard paper sizes (points, portrait) and the names drivers give them. A driver can list
/// one size twice, a plotter's "ARCH D" next to a custom 24 × 36 in entry; the named one is
/// preferred (from storytold/pdfcraft#599).
const NAMED_PAPERS: &[((f64, f64), &[&str])] = &[
((612.0, 792.0), &["Letter", "US Letter"]),
((612.0, 1008.0), &["Legal", "US Legal"]),
((792.0, 1224.0), &["Tabloid", "Ledger", "ANSI B"]),
((419.53, 595.28), &["A5"]),
((595.28, 841.89), &["A4"]),
((841.89, 1190.55), &["A3"]),
((1190.55, 1683.78), &["A2"]),
((1683.78, 2383.94), &["A1"]),
((2383.94, 3370.39), &["A0"]),
((648.0, 864.0), &["ARCH A"]),
((864.0, 1296.0), &["ARCH B"]),
((1296.0, 1728.0), &["ARCH C"]),
((1728.0, 2592.0), &["ARCH D"]),
((2160.0, 3024.0), &["ARCH E1"]),
((2592.0, 3456.0), &["ARCH E"]),
((1224.0, 1584.0), &["ANSI C"]),
((1584.0, 2448.0), &["ANSI D"]),
((2448.0, 3168.0), &["ANSI E"]),
];
/// The usual names of a paper of this size (either way round, to half a point).
pub(crate) fn paper_names(width: f64, height: f64) -> &'static [&'static str] {
let (w, h) = (width.min(height), width.max(height));
NAMED_PAPERS.iter().find(|((pw, ph), _)| (pw - w).abs() <= 0.5 && (ph - h).abs() <= 0.5).map_or(&[], |(_, names)| names)
}
/// Whether a driver's paper name is `name`: it starts with it, ignoring case, and the name ends
/// there ("A4 210 x 297 mm", not "A4 Extra" for "A4" or "ARCH E1" for "ARCH E").
pub(crate) fn names_paper(display: Option<&str>, name: &str) -> bool {
let Some(display) = display else { return false };
display.get(..name.len()).is_some_and(|start| start.eq_ignore_ascii_case(name))
&& display.get(name.len()..).is_some_and(|rest| rest.chars().next().is_none_or(|c| !c.is_alphanumeric()))
}
/// A driver paper offered for the job: its size in micrometres (as the driver lists it), its
/// display name, and whether its option is a Print Schema keyword (`psk:ISOA4`) rather than a
/// vendor's own.
#[derive(Clone)]
pub(crate) struct Paper<'a> {
pub width: u32,
pub height: u32,
pub name: Option<&'a str>,
pub keyword: bool,
}
/// The driver paper for a sheet of `width` × `height` points (either way round): within a
/// quarter of a millimetre, preferring the one named like the standard size, then a Print Schema
/// keyword, then the closest; on a tie the driver's own order. Its index in `papers`.
pub(crate) fn matching_paper(papers: &[Paper<'_>], width: f64, height: f64) -> Option<usize> {
let (w, h) = (micrometres(width.min(height))?, micrometres(width.max(height))?);
let names = paper_names(width, height);
let mut candidates: Vec<(usize, &Paper<'_>, u32)> = papers
.iter()
.enumerate()
.filter_map(|(i, p)| {
let (pw, ph) = (p.width.min(p.height), p.width.max(p.height));
let off = pw.abs_diff(w).max(ph.abs_diff(h));
(off <= PAPER_TOLERANCE).then_some((i, p, pw.abs_diff(w) + ph.abs_diff(h)))
})
.collect();
candidates.sort_by_key(|(_, p, off)| (!names.iter().any(|n| names_paper(p.name, n)), !p.keyword, *off));
candidates.first().map(|(i, _, _)| *i)
}
fn named(node: roxmltree::Node<'_, '_>, local: &str) -> bool {
node.attribute("name")
.and_then(|v| v.split_once(':'))
.is_some_and(|(prefix, name)| name == local && node.lookup_namespace_uri(Some(prefix)) == Some(NS_PSK))
}
/// The integer value of the Print Schema element under `node` with this keyword name.
fn integer(node: roxmltree::Node<'_, '_>, tag: &str, name: &str) -> Option<u32> {
node.descendants()
.find(|n| n.has_tag_name((NS_PSF, tag)) && named(*n, name))
.and_then(|n| n.children().find(|v| v.has_tag_name((NS_PSF, "Value"))))
.and_then(|n| n.text())
.and_then(|v| v.trim().parse::<u32>().ok())
}
/// Check the driver's validated ticket: it kept the orientation, added no scaling and has a
/// paper (when `paper` is given, that one, to a quarter of a millimetre). Returns the paper's
/// width and height in micrometres as the driver lists it. Only those schema fields are read:
/// some drivers return empty vendor parameters that a stricter parser would refuse.
pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: bool) -> Result<(u32, u32), Error> {
let document = roxmltree::Document::parse(xml).map_err(spool_error)?;
let feature = |name: &str| document.root_element().children().find(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, name));
let media = feature("PageMediaSize").ok_or_else(|| spool_error("the printer's driver did not confirm the paper size; nothing was sent"))?;
let size = integer(media, "ScoredProperty", "MediaSizeWidth")
.zip(integer(media, "ScoredProperty", "MediaSizeHeight"))
.filter(|(w, h)| *w > 0 && *h > 0)
.ok_or_else(|| spool_error("the printer's driver gave a paper without a size (a roll?); nothing was sent"))?;
if let Some((w, h)) = paper {
let same = |a: (u32, u32), b: (u32, u32)| a.0.abs_diff(b.0) <= PAPER_TOLERANCE && a.1.abs_diff(b.1) <= PAPER_TOLERANCE;
if !same(size, (w, h)) && !same(size, (h, w)) {
return Err(spool_error("the printer's driver changed the paper size; nothing was sent (choose a paper size it has)"));
}
}
let orientation = feature("PageOrientation").and_then(|f| f.children().find(|n| n.has_tag_name((NS_PSF, "Option"))));
if !orientation.is_some_and(|n| named(n, if landscape { "Landscape" } else { "Portrait" })) {
return Err(spool_error("the printer's driver changed the orientation; nothing was sent"));
}
if let Some(scaling) = feature("PageScaling") {
// Microsoft's IPP class driver names its "None" in a namespace of its own.
if !scaling.children().any(|n| n.has_tag_name((NS_PSF, "Option")) && n.attribute("name").and_then(|v| v.rsplit(':').next()) == Some("None")) {
return Err(spool_error("the printer's driver would scale the sheets; nothing was sent"));
}
}
Ok(size)
}
/// The widest unprintable margin of the paper, in micrometres, from PrintCapabilities computed
/// for the job's ticket (`PageImageableSize`). The largest of the four, so content centred inside
/// it is clear of every edge whichever way the driver measures. `None` when the driver doesn't say.
pub(crate) fn imageable_margin(capabilities_xml: &str) -> Option<u32> {
let document = roxmltree::Document::parse(capabilities_xml).ok()?;
let imageable = document.root_element().children().find(|n| n.has_tag_name((NS_PSF, "Property")) && named(*n, "PageImageableSize"))?;
let width = integer(imageable, "Property", "ImageableSizeWidth")?;
let height = integer(imageable, "Property", "ImageableSizeHeight")?;
let area = imageable.children().find(|n| n.has_tag_name((NS_PSF, "Property")) && named(*n, "ImageableArea"))?;
let (x, y) = (integer(area, "Property", "OriginWidth")?, integer(area, "Property", "OriginHeight")?);
let (w, h) = (integer(area, "Property", "ExtentWidth")?, integer(area, "Property", "ExtentHeight")?);
let right = width.checked_sub(x)?.checked_sub(w)?;
let bottom = height.checked_sub(y)?.checked_sub(h)?;
Some(x.max(y).max(right).max(bottom))
}
/// The job file's name, which the spooler shows as the document's: the title, with anything that
/// could leave the folder replaced and Windows' device names (`CON`, `LPT1`) avoided.
pub(crate) fn job_filename(title: &str) -> String {
let stem = title.strip_suffix(".pdf").or_else(|| title.strip_suffix(".PDF")).unwrap_or(title);
let safe: String =
stem.chars().take(100).map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.' | '(' | ')') { c } else { '_' }).collect();
// Windows drops trailing dots and spaces from a name.
let safe = safe.trim_end_matches(['.', ' ']).trim_start();
let device = safe.split('.').next().map(str::trim_end).is_some_and(|base| {
let base = base.to_ascii_uppercase();
["CON", "PRN", "AUX", "NUL"].contains(&base.as_str())
|| ((base.starts_with("COM") || base.starts_with("LPT")) && base.len() == 4 && base.ends_with(|c: char| c.is_ascii_digit()))
});
match (safe.is_empty(), device) {
(true, _) => "Print.pdf".into(),
(false, true) => format!("_{safe}.pdf"),
(false, false) => format!("{safe}.pdf"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_ticket_carries_the_job_options() {
let job = Job { copies: 3, collate: false, duplex: Some(Duplex::ShortEdge), grayscale: true, title: "<&>".into(), ..Job::default() };
let xml = ticket_xml(&job, true);
for value in [">3<", "psk:Uncollated", "psk:TwoSidedShortEdge", "psk:Monochrome", "psk:Landscape", "psk:None"] {
assert!(xml.contains(value), "missing {value}");
}
assert!(!xml.contains("<&>"), "the title never enters the XML");
assert!(roxmltree::Document::parse(&xml).is_ok());
let xml = ticket_xml(&Job { copies: 5000, ..Job::default() }, false);
for value in [">999<", "psk:Collated", "psk:Color", "psk:Portrait"] {
assert!(xml.contains(value), "missing {value}");
}
assert!(!xml.contains("JobDuplex"), "no duplex asked: the queue's default applies");
assert!(ticket_xml(&Job { duplex: Some(Duplex::Off), ..Job::default() }, false).contains("psk:OneSided"));
assert!(ticket_xml(&Job { duplex: Some(Duplex::LongEdge), ..Job::default() }, false).contains("psk:TwoSidedLongEdge"));
assert!(!ticket_xml(&Job { printer: Some("Office \"east\" <1>".into()), ..Job::default() }, false).contains("east"));
}
/// A validated ticket as a driver returns it.
fn validated(paper: (u32, u32), orientation: &str, scaling: Option<&str>) -> String {
let scaling = scaling.map_or(String::new(), |s| format!(r#"<psf:Feature name="psk:PageScaling"><psf:Option name="{s}"/></psf:Feature>"#));
format!(
r#"<psf:PrintTicket version="1" xmlns:psf="{NS_PSF}" xmlns:psk="{NS_PSK}" xmlns:ns0000="http://example.com/vendor" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<psf:Feature name="psk:PageMediaSize"><psf:Option name="psk:NorthAmericaLetter">
<psf:ScoredProperty name="psk:MediaSizeWidth"><psf:Value xsi:type="xsd:integer">{}</psf:Value></psf:ScoredProperty>
<psf:ScoredProperty name="psk:MediaSizeHeight"><psf:Value xsi:type="xsd:integer">{}</psf:Value></psf:ScoredProperty>
</psf:Option></psf:Feature>
<psf:Feature name="psk:PageOrientation"><psf:Option name="psk:{orientation}"/></psf:Feature>{scaling}
<psf:ParameterInit name="ns0000:Empty"><psf:Value xsi:type="xsd:string"></psf:Value></psf:ParameterInit>
</psf:PrintTicket>"#,
paper.0, paper.1
)
}
#[test]
fn a_driver_that_changes_the_sheet_is_refused() {
let letter = (215_900, 279_400);
let ticket = validated(letter, "Portrait", Some("psk:None"));
assert_eq!(verify_ticket(&ticket, Some(letter), false), Ok(letter));
assert_eq!(verify_ticket(&ticket, Some((279_400, 215_900)), false), Ok(letter), "either way round");
assert_eq!(verify_ticket(&ticket, None, false), Ok(letter), "shrink-to-fit takes the driver's paper");
assert!(verify_ticket(&ticket, Some((210_000, 297_000)), false).is_err(), "A4 asked, Letter returned");
assert!(verify_ticket(&ticket, Some(letter), true).is_err(), "landscape asked, portrait returned");
assert!(verify_ticket(&validated(letter, "Portrait", Some("psk:FitApplicationBleedSizeToPageImageableSize")), Some(letter), false).is_err());
assert!(verify_ticket(&validated(letter, "Portrait", Some("ns0000:None")), Some(letter), false).is_ok(), "the IPP class driver's None");
assert!(verify_ticket(&validated(letter, "Portrait", None), Some(letter), false).is_ok(), "a driver without scaling");
assert!(verify_ticket(&validated((0, 279_400), "Portrait", None), None, false).is_err(), "a roll has no length");
assert!(verify_ticket("<not xml", Some(letter), false).is_err());
}
#[test]
fn the_driver_paper_named_like_the_standard_size_is_preferred() {
let paper = |width, height, name, keyword| Paper { width, height, name, keyword };
// A plotter listing 24 × 36 in three times: a custom entry first, "ARCH D", and one a hair off.
let papers = [
paper(609_600, 914_400, Some("Custom 24x36"), false),
paper(609_700, 914_400, Some("ARCH D 24 x 36 in"), false),
paper(609_600, 914_400, Some("24 x 36"), true),
];
assert_eq!(matching_paper(&papers, 2592.0, 1728.0), Some(1), "named, either way round");
assert_eq!(matching_paper(&papers[..1], 1728.0, 2592.0), Some(0));
assert_eq!(matching_paper(&[papers[0].clone(), papers[2].clone()], 1728.0, 2592.0), Some(1), "then a schema keyword");
assert_eq!(matching_paper(&papers, 1728.0, 2600.0), None, "3 mm off is another paper");
assert_eq!(matching_paper(&papers, f64::NAN, 2592.0), None);
assert_eq!(paper_names(595.0, 842.0), ["A4"], "rounded metric points");
assert_eq!(paper_names(792.0, 612.0), ["Letter", "US Letter"]);
assert!(paper_names(360.0, 504.0).is_empty());
assert!(names_paper(Some("ARCH D 24 x 36 in"), "ARCH D"));
assert!(names_paper(Some("arch d"), "ARCH D"));
assert!(!names_paper(Some("ANSI D"), "ARCH D"));
assert!(!names_paper(Some("ARCH E1"), "ARCH E"));
assert!(!names_paper(Some("A4Extra"), "A4"));
assert!(!names_paper(Some("Ä"), "A4"), "never slices inside a character");
assert!(!names_paper(None, "A4"));
}
#[test]
fn the_unprintable_margin_comes_from_the_capabilities() {
let caps = |x: u32, y: u32, w: u32, h: u32| {
format!(
r#"<psf:PrintCapabilities version="1" xmlns:psf="{NS_PSF}" xmlns:psk="{NS_PSK}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<psf:Property name="psk:PageImageableSize">
<psf:Property name="psk:ImageableSizeWidth"><psf:Value xsi:type="xsd:integer">215900</psf:Value></psf:Property>
<psf:Property name="psk:ImageableSizeHeight"><psf:Value xsi:type="xsd:integer">279400</psf:Value></psf:Property>
<psf:Property name="psk:ImageableArea">
<psf:Property name="psk:OriginWidth"><psf:Value xsi:type="xsd:integer">{x}</psf:Value></psf:Property>
<psf:Property name="psk:OriginHeight"><psf:Value xsi:type="xsd:integer">{y}</psf:Value></psf:Property>
<psf:Property name="psk:ExtentWidth"><psf:Value xsi:type="xsd:integer">{w}</psf:Value></psf:Property>
<psf:Property name="psk:ExtentHeight"><psf:Value xsi:type="xsd:integer">{h}</psf:Value></psf:Property>
</psf:Property>
</psf:Property>
</psf:PrintCapabilities>"#
)
};
assert_eq!(imageable_margin(&caps(4_233, 4_233, 207_434, 270_934)), Some(4_233));
assert_eq!(imageable_margin(&caps(3_000, 5_000, 209_900, 270_000)), Some(5_000), "the widest of the four");
assert_eq!(imageable_margin(&caps(0, 0, 215_900, 279_400)), Some(0), "borderless");
assert_eq!(imageable_margin(&caps(10_000, 0, 215_900, 279_400)), None, "an area past the paper is not believed");
assert_eq!(imageable_margin("<psf:PrintCapabilities/>"), None);
}
#[test]
fn points_and_micrometres_convert_both_ways() {
assert_eq!(micrometres(612.0), Some(215_900));
assert_eq!(micrometres(841.89), Some(297_000));
assert!((points(215_900) - 612.0).abs() < 1e-9);
assert!((points(4_064) - 11.52).abs() < 1e-9, "a 4 mm hardware margin");
for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, 14_401.0] {
assert_eq!(micrometres(bad), None, "{bad}");
}
}
#[test]
fn the_job_file_stays_in_its_folder() {
let name = job_filename(r"..\..\CON:/unsafe?name");
assert_eq!(std::path::Path::new(&name).components().count(), 1, "{name}");
assert!(name.ends_with(".pdf"));
assert_eq!(job_filename("Invoice 42.pdf"), "Invoice 42.pdf");
assert_eq!(job_filename("Report (draft) v1.2"), "Report (draft) v1.2.pdf");
assert_eq!(job_filename("con"), "_con.pdf");
assert_eq!(job_filename("LPT1.txt"), "_LPT1.txt.pdf");
assert_eq!(job_filename("COMPLETE"), "COMPLETE.pdf");
assert_eq!(job_filename("..."), "Print.pdf");
assert_eq!(job_filename(""), "Print.pdf");
assert_eq!(job_filename("Отчёт"), "Отчёт.pdf");
}
}
+225
View File
@@ -0,0 +1,225 @@
//! The Windows spooler, in this process: no PowerShell, script or other program is started, so
//! printing works where scripts are locked down (App Control for Business or AppLocker run
//! PowerShell in Constrained Language Mode; storytold/pdfcraft#1107).
//!
//! The printers come from `EnumPrinters` (local queues and the user's connections, such as
//! `\\server\queue`), the default from `GetDefaultPrinter`, and whether each driver takes
//! two-sided settings from its PrintCapabilities. A job is drawn by the in-box
//! `Windows.Data.Pdf` with Direct2D into the XPS print API, as vectors, and its options (copies,
//! collation, two-sided, colour, paper, orientation, no driver scaling) travel as a PrintTicket
//! that the driver validates before anything is sent. `winprint` and `winsafe` wrap the Win32
//! and WinRT calls, so there is no `unsafe` here.
use std::io::Write as _;
use winprint::printer::{FilePrinter as _, PrinterDevice, WinPdfPrinter};
use winprint::ticket::document::{NS_PSK, OwnedName, PrintFeature, PrintTicketDocument, reader::ParsableXmlDocument as _};
use winprint::ticket::{FeatureOptionPack as _, PageMediaSize, PrintCapabilities, PrintTicket, PrintTicketBuilder};
use crate::sheets::{self, Fit, SAME};
use crate::ticket::{self, Paper};
use crate::{Error, Job, Printer, Scaling};
fn spool_error(e: impl std::fmt::Display) -> Error {
Error::Spool(e.to_string())
}
/// The user's default printer; `None` when there is none.
fn default_printer() -> Option<String> {
winsafe::GetDefaultPrinter().ok().filter(|n| !n.is_empty())
}
pub(crate) fn printers() -> Vec<Printer> {
match PrinterDevice::all() {
Ok(devices) => crate::printer_list(devices.iter().map(|d| (d.name().to_string(), two_sided(d))), default_printer().as_deref()),
Err(_) => Vec::new(),
}
}
/// Whether the printer's driver takes two-sided settings: its PrintCapabilities offer a two-sided
/// option for the feature the job's ticket sets. `None` when the capabilities can't be read. A
/// driver without one drops two-sided from the ticket and prints its own way.
fn two_sided(device: &PrinterDevice) -> Option<bool> {
let capabilities = PrintCapabilities::fetch(device).ok()?;
let duplex = OwnedName::qualified("JobDuplexAllDocumentsContiguously", NS_PSK, Some("psk"));
Some(capabilities.options_for_feature(duplex).any(|o| o.name.as_ref().is_some_and(|n| n.local_name.starts_with("TwoSided"))))
}
/// The queue the job goes to: the one asked for, else the default. Never another one: a job
/// for a queue that has gone must not come out of a different printer.
fn select_device(devices: Vec<PrinterDevice>, requested: Option<&str>, default: Option<&str>) -> Result<PrinterDevice, Error> {
let name = requested.or(default).ok_or_else(|| spool_error("there is no default printer; choose a printer"))?;
devices
.into_iter()
.find(|p| p.name() == name)
.ok_or_else(|| spool_error(format!("the printer is not available: {name} (reopen Print to refresh the list)")))
}
/// The ticket the driver accepted: [`ticket::ticket_xml`] with the driver's own paper option for
/// `sheet` (its exact option, so a vendor's sizes survive; a ticket with dimensions only can come
/// back as Letter) and its own "no scaling" option, merged and validated against the printer's
/// capabilities. For exact size the driver must have that paper; for shrink-to-fit without one,
/// its default paper is used. Returns the ticket and its paper in micrometres. Refused when the
/// driver changed the paper, the orientation or the scaling: then nothing is sent, rather than a
/// job at the wrong size.
fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape: bool) -> Result<(PrintTicket, (u32, u32)), Error> {
let capabilities = PrintCapabilities::fetch(device).map_err(spool_error)?;
let medias: Vec<PageMediaSize> = capabilities.page_media_sizes().collect();
let papers: Vec<Paper<'_>> = medias
.iter()
.map(|m| Paper {
width: m.size().width_in_micron(),
height: m.size().height_in_micron(),
name: m.display_name(),
keyword: m.option().name.as_ref().is_some_and(|n| n.namespace_ref() == Some(NS_PSK)),
})
.collect();
let media = ticket::matching_paper(&papers, sheet.0, sheet.1).and_then(|i| medias.get(i));
let wanted = match (media, job.scaling) {
(Some(m), _) => Some((m.size().width_in_micron(), m.size().height_in_micron())),
(None, Scaling::ShrinkToFit) => None,
(None, Scaling::Exact) => {
return Err(spool_error(format!(
"{} has no {:.2} × {:.2} inch paper; choose a paper size it has, or add this one in its driver",
device.name(),
sheet.0 / 72.0,
sheet.1 / 72.0
)));
}
};
let requested = PrintTicket::from_xml(ticket::ticket_xml(job, landscape));
let mut document = PrintTicketDocument::parse_from_bytes(requested.get_xml()).map_err(spool_error)?;
if let Some(media) = media {
let media_ticket: PrintTicket = media.clone().into();
let media_document = PrintTicketDocument::parse_from_bytes(media_ticket.get_xml()).map_err(spool_error)?;
document.features.extend(media_document.features);
document.parameter_inits.extend(media_document.parameter_inits);
}
let scaling_name = OwnedName::qualified("PageScaling", NS_PSK, Some("psk"));
if let Some(no_scaling) = capabilities.options_for_feature(scaling_name.clone()).find(|o| o.name.as_ref().is_some_and(|n| n.local_name == "None"))
{
document.features.retain(|f| !(f.name.local_name == "PageScaling" && f.name.namespace_ref() == Some(NS_PSK)));
document.features.push(PrintFeature { name: scaling_name, properties: Vec::new(), options: vec![no_scaling.clone()], features: Vec::new() });
}
let mut builder = PrintTicketBuilder::new(device).map_err(spool_error)?;
builder.merge(PrintTicket::from(document)).map_err(spool_error)?;
let validated = builder.build().map_err(spool_error)?;
let xml = std::str::from_utf8(validated.get_xml()).map_err(spool_error)?;
let paper = ticket::verify_ticket(xml, wanted, landscape)?;
Ok((validated, paper))
}
/// The widest unprintable margin of the ticket's paper, in points; 0 when the driver doesn't say.
fn imageable_margin(device: &PrinterDevice, ticket: &PrintTicket) -> f64 {
PrintCapabilities::fetch_xml_for_ticket(device, Some(ticket))
.ok()
.and_then(|xml| String::from_utf8(xml).ok())
.and_then(|xml| ticket::imageable_margin(&xml))
.map_or(0.0, ticket::points)
}
/// Spool a print-ready PDF. The sheets are read first ([`sheets::read`]); everything that talks
/// to the driver runs on a thread of its own, so WinRT and COM get an apartment of their own
/// whoever calls. Windows.Data.Pdf reads from a path, so the job goes to a file in a private
/// temporary folder (an unpredictable name, only this user's) for as long as the spooler takes
/// it, and the folder is removed however the job ended. Windows answers once the job is in the
/// queue: a paused queue or an offline printer keeps the job there, as with any program.
pub(crate) fn submit(pdf: &[u8], job: &Job) -> Result<String, Error> {
let read = sheets::read(pdf)?;
let count = read.sheets.len();
let first = read.sheets.first().map(sheets::Sheet::size).ok_or_else(|| spool_error("the print-ready PDF has no pages"))?;
let landscape = first.0 > first.1 + SAME;
// Exact size: every sheet must be the first one's size, either way round; checked before
// the driver is asked anything.
if job.scaling == Scaling::Exact {
sheets::plan(&read.sheets, first, Fit::Exact)?;
}
let device = select_device(PrinterDevice::all().map_err(spool_error)?, job.printer.as_deref(), default_printer().as_deref())?;
let written = |e: std::io::Error| spool_error(format!("the print job could not be written: {e}"));
let dir = tempfile::Builder::new().prefix("craft-print-").tempdir().map_err(written)?;
let path = dir.path().join(ticket::job_filename(&job.title));
let read = &read;
// Joined before `dir` is dropped: the spooler reads the file.
let result = std::thread::scope(|s| {
std::thread::Builder::new()
.name("print job".into())
.spawn_scoped(s, move || {
let (ticket, paper) = driver_ticket(&device, job, first, landscape)?;
let (fit, paper) = match job.scaling {
Scaling::Exact => (Fit::Exact, first),
Scaling::ShrinkToFit => {
let (short, long) = (ticket::points(paper.0.min(paper.1)), ticket::points(paper.0.max(paper.1)));
(Fit::Shrink { margin: imageable_margin(&device, &ticket) }, if landscape { (long, short) } else { (short, long) })
}
};
let edits = sheets::plan(&read.sheets, paper, fit)?;
let updated = if edits.is_empty() { None } else { Some(read.update(&edits)?) };
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(written)?;
file.write_all(updated.as_deref().unwrap_or(pdf)).map_err(written)?;
drop(file);
WinPdfPrinter::new(device).print(&path, ticket).map_err(|e| {
let mut message = e.to_string();
let mut cause = std::error::Error::source(&e);
while let Some(source) = cause {
message.push_str(&format!(": {source}"));
cause = source.source();
}
spool_error(message)
})
})
.map_err(|e| spool_error(format!("the print job could not be started: {e}")))?
.join()
.map_err(|_| spool_error("the print job stopped unexpectedly; check the printer's queue before printing again"))?
});
drop(dir);
result?;
Ok(format!("spooled {count} sheet(s)"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_printer_never_falls_back_to_another_queue() {
assert!(select_device(Vec::new(), Some("missing"), Some("default")).is_err());
assert!(select_device(Vec::new(), None, None).is_err());
}
/// What a real driver makes of a job's ticket: Letter, portrait, two copies, two-sided on
/// the long edge, grayscale. Sends no job. `CRAFT_PRINT_TEST_PRINTER` names the queue;
/// `CRAFT_PRINT_TEST_TICKET_OUTPUT` keeps the validated ticket, to see which options it kept.
#[test]
#[ignore = "needs CRAFT_PRINT_TEST_PRINTER naming an installed printer; sends no job"]
fn an_installed_driver_validates_the_ticket() {
let name = std::env::var("CRAFT_PRINT_TEST_PRINTER").unwrap();
let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap();
let job = Job { copies: 2, duplex: Some(crate::Duplex::LongEdge), grayscale: true, ..Job::default() };
let (ticket, paper) = driver_ticket(&device, &job, (612.0, 792.0), false).unwrap();
assert_eq!(paper, (215_900, 279_400));
eprintln!("unprintable margin: {} pt", imageable_margin(&device, &ticket));
if let Ok(path) = std::env::var("CRAFT_PRINT_TEST_TICKET_OUTPUT") {
std::fs::write(path, ticket.get_xml()).unwrap();
}
}
/// A real job, for checking a printer by hand (paper, or a paused queue's spool file):
/// `CRAFT_PRINT_TEST_FILE` printed on `CRAFT_PRINT_TEST_PRINTER`, shrink-to-fit when
/// `CRAFT_PRINT_TEST_SCALING=shrink`, two-sided on the long edge when
/// `CRAFT_PRINT_TEST_DUPLEX=long`.
#[test]
#[ignore = "sends a real job: needs CRAFT_PRINT_TEST_PRINTER and CRAFT_PRINT_TEST_FILE"]
fn an_installed_printer_prints_a_file() {
let pdf = std::fs::read(std::env::var("CRAFT_PRINT_TEST_FILE").unwrap()).unwrap();
let job = Job {
printer: Some(std::env::var("CRAFT_PRINT_TEST_PRINTER").unwrap()),
scaling: if std::env::var("CRAFT_PRINT_TEST_SCALING").as_deref() == Ok("shrink") { Scaling::ShrinkToFit } else { Scaling::Exact },
duplex: (std::env::var("CRAFT_PRINT_TEST_DUPLEX").as_deref() == Ok("long")).then_some(crate::Duplex::LongEdge),
title: "craft-print test".into(),
..Job::default()
};
let sent = submit(&pdf, &job);
eprintln!("{sent:?}");
assert!(sent.is_ok());
}
}
-258
View File
@@ -1,258 +0,0 @@
# Print a print-ready PDF on Windows (CUPS-independent backend). The job arrives in CRAFT_* environment
# variables, never as arguments: printer names contain spaces and quotes, and the document's
# path must not be visible in the process list while it prints.
#
# The in-box Windows.Data.Pdf renders each sheet at the printer's own resolution and
# System.Drawing.Printing spools it with the driver's settings — copies, collation, duplex and
# colour reach the spooler as the job's, the way `lp -o` sends them on CUPS. No PDF reader is
# needed, and the calling code touches no Win32 API.
#
# CRAFT_PRINT_DRYRUN=1 renders every sheet in memory and prints
# nothing: the tests run the whole rendering path on machines without a printer, CI included.
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
Add-Type -AssemblyName System.Drawing
# Windows duplex values are binding edges: Vertical = long edge, Horizontal = short edge.
# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea
function Get-Duplex($edge) {
switch ($edge) {
'long-edge' { [System.Drawing.Printing.Duplex]::Vertical }
'short-edge' { [System.Drawing.Printing.Duplex]::Horizontal }
default { [System.Drawing.Printing.Duplex]::Simplex }
}
}
function Set-JobDuplex($settings, $edge) {
if ($edge) { $settings.Duplex = Get-Duplex $edge }
}
# WinRT dimensions are DIPs (96/inch), printing dimensions are hundredths of an inch.
# The PDF is already composed: preserve physical size, even if the printer clips its edges.
function Get-SheetGeometry($width, $height) {
if ([double]::IsNaN($width) -or [double]::IsNaN($height) -or
[double]::IsInfinity($width) -or [double]::IsInfinity($height) -or
$width -le 0 -or $height -le 0 -or $width -gt 12288 -or $height -gt 12288) {
throw 'the PDF sheet dimensions are invalid or exceed 128 inches'
}
# DEVMODE paper lengths use signed 16-bit tenths of a millimetre (128 inches fits).
$drawW = $width * 100.0 / 96.0
$drawH = $height * 100.0 / 96.0
[pscustomobject]@{
Width = $drawW; Height = $drawH; Landscape = ($width -gt $height)
PaperWidth = [int][Math]::Max(1, [Math]::Round([Math]::Min($drawW, $drawH)))
PaperHeight = [int][Math]::Max(1, [Math]::Round([Math]::Max($drawW, $drawH)))
}
}
function Set-SheetSettings($settings, $papers, $sheet, $scaling = 'exact', $fallbackPaper = $null) {
# Prefer the driver's paper identifier over a custom form when the dimensions match.
$paper = $papers | Where-Object {
[Math]::Abs($_.Width - $sheet.PaperWidth) -le 1 -and
[Math]::Abs($_.Height - $sheet.PaperHeight) -le 1
} | Select-Object -First 1
if (-not $paper -and $scaling -eq 'shrink-to-fit') {
if (-not $fallbackPaper) { throw 'the printer has no default paper for shrink-to-fit' }
$paper = $fallbackPaper
}
if (-not $paper) {
$paper = [System.Drawing.Printing.PaperSize]::new('PDF sheet', $sheet.PaperWidth, $sheet.PaperHeight)
}
$settings.PaperSize = $paper
$settings.Landscape = $sheet.Landscape
$settings.Margins = [System.Drawing.Printing.Margins]::new(0, 0, 0, 0)
}
function Get-DrawBox($sheet, $hardMarginX, $hardMarginY) {
# GDI's origin is the printable area's corner. Offset to the physical paper's corner.
[System.Drawing.RectangleF]::new(-$hardMarginX, -$hardMarginY, $sheet.Width, $sheet.Height)
}
function Get-ShrinkDrawBox($sheet, $area, $hardMarginX, $hardMarginY) {
foreach ($value in @($area.X, $area.Y, $area.Width, $area.Height, $hardMarginX, $hardMarginY)) {
if ([double]::IsNaN($value) -or [double]::IsInfinity($value)) {
throw 'the printer reported an invalid printable area'
}
}
if ($area.Width -le 0 -or $area.Height -le 0) { throw 'the printer has no printable area' }
$scale = [Math]::Min(1.0, [Math]::Min($area.Width / $sheet.Width, $area.Height / $sheet.Height))
$width = $sheet.Width * $scale
$height = $sheet.Height * $scale
[System.Drawing.RectangleF]::new(
$area.X + ($area.Width - $width) / 2.0 - $hardMarginX,
$area.Y + ($area.Height - $height) / 2.0 - $hardMarginY,
$width, $height)
}
function Get-RenderSize($sheet, $dpi) {
$dpi = [Math]::Min(600, [Math]::Max(72, $dpi))
$w = [Math]::Ceiling($sheet.Width / 100.0 * $dpi)
$h = [Math]::Ceiling($sheet.Height / 100.0 * $dpi)
# Bound allocation without changing the physical draw rectangle.
$scale = [Math]::Min(1.0, [Math]::Min(32768.0 / [Math]::Max($w, $h), [Math]::Sqrt(64000000.0 / ($w * $h))))
[pscustomobject]@{ Width = [int][Math]::Max(1, [Math]::Floor($w * $scale)); Height = [int][Math]::Max(1, [Math]::Floor($h * $scale)) }
}
# Exercise the production settings/placement helpers without an installed printer. The
# caller clears this internal test flag for ordinary submissions.
if ($env:CRAFT_PRINT_TEST_SETTINGS -eq '1') {
$papers = @([System.Drawing.Printing.PaperSize]::new('Driver Letter', 850, 1100))
$results = foreach ($size in @(@(816, 1056), @(1056, 816), @(793.92, 1122.24), @(816, 1344))) {
$sheet = Get-SheetGeometry $size[0] $size[1]
$settings = [System.Drawing.Printing.PageSettings]::new()
Set-SheetSettings $settings $papers $sheet
$box = Get-DrawBox $sheet 25 30
$pixels = Get-RenderSize $sheet 150
[pscustomobject]@{ Width = $settings.PaperSize.Width; Height = $settings.PaperSize.Height;
Name = $settings.PaperSize.PaperName; Landscape = $settings.Landscape;
Margins = @($settings.Margins.Left, $settings.Margins.Right, $settings.Margins.Top, $settings.Margins.Bottom);
X = $box.X; Y = $box.Y; DrawWidth = $box.Width; DrawHeight = $box.Height;
Pixels = @($pixels.Width, $pixels.Height) }
}
$large = Get-RenderSize (Get-SheetGeometry 12288 12288) 600
$invalidRejected = $false
try { $null = Get-SheetGeometry ([double]::NaN) 100 } catch { $invalidRejected = $true }
$fallback = [System.Drawing.Printing.PageSettings]::new()
Set-SheetSettings $fallback $papers (Get-SheetGeometry 1536 1920) 'shrink-to-fit' $papers[0]
$area = [System.Drawing.RectangleF]::new(25, 30, 800, 1040)
$shrinkBoxes = foreach ($size in @(@(1536, 1920), @(384, 480))) {
$box = Get-ShrinkDrawBox (Get-SheetGeometry $size[0] $size[1]) $area 25 30
,@($box.X, $box.Y, $box.Width, $box.Height)
}
$defaultDuplex = [pscustomobject]@{ Duplex = [System.Drawing.Printing.Duplex]::Vertical }
Set-JobDuplex $defaultDuplex ''
$preservedDuplex = $defaultDuplex.Duplex.ToString()
Set-JobDuplex $defaultDuplex 'off'
[pscustomobject]@{ Sheets = @($results); LongEdge = (Get-Duplex 'long-edge').ToString();
ShortEdge = (Get-Duplex 'short-edge').ToString(); Off = (Get-Duplex 'off').ToString();
DefaultDuplex = $preservedDuplex; ForcedSimplex = $defaultDuplex.Duplex.ToString();
FallbackPaper = $fallback.PaperSize.PaperName; ShrinkBoxes = @($shrinkBoxes);
LargePixels = @($large.Width, $large.Height); InvalidRejected = $invalidRejected } | ConvertTo-Json -Depth 5 -Compress
return
}
Add-Type -AssemblyName System.Runtime.WindowsRuntime
try {
$null = [Windows.Data.Pdf.PdfDocument, Windows.Data.Pdf, ContentType = WindowsRuntime]
} catch {
throw 'this Windows installation cannot render PDFs: the Windows.Data.Pdf component is missing'
}
$null = [Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime]
# Await the WinRT asynchronous APIs from .NET Framework's PowerShell. AsTask is an extension
# method: found by reflecting over the static class — with `.`, an instance call on the type
# object, since `GetMethods` is not static and `::` would not find it.
$type = [System.WindowsRuntimeSystemExtensions]
$opTask = $type.GetMethods() | Where-Object {
$_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1'
} | Select-Object -First 1
$actTask = $type.GetMethods() | Where-Object {
$_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncAction'
} | Select-Object -First 1
function AwaitOp($operation, $resultType) {
$task = $opTask.MakeGenericMethod($resultType).Invoke($null, @($operation))
$task.Wait()
$task.Result
}
function AwaitAct($action) {
$task = $actTask.Invoke($null, @($action))
$task.Wait()
}
# Render one sheet at its physical size; only raster resolution can be reduced for memory.
function Render-Sheet($page, $sheet, $dpi) {
$pixels = Get-RenderSize $sheet $dpi
$options = [Windows.Data.Pdf.PdfPageRenderOptions]::new()
$options.DestinationWidth = $pixels.Width
$options.DestinationHeight = $pixels.Height
$stream = [System.IO.MemoryStream]::new()
try {
$random = [System.IO.WindowsRuntimeStreamExtensions]::AsRandomAccessStream($stream)
AwaitAct ($page.RenderToStreamAsync($random, $options))
$stream.Position = 0
$png = [System.Drawing.Image]::FromStream($stream)
try { return [System.Drawing.Bitmap]::new($png) } finally { $png.Dispose() }
} finally { $stream.Dispose() }
}
$source = AwaitOp ([Windows.Storage.StorageFile]::GetFileFromPathAsync($env:CRAFT_PRINT_FILE)) ([Windows.Storage.StorageFile])
$document = AwaitOp ([Windows.Data.Pdf.PdfDocument]::LoadFromFileAsync($source)) ([Windows.Data.Pdf.PdfDocument])
if ($document.PageCount -lt 1) { throw 'the print-ready PDF has no pages' }
if ($env:CRAFT_PRINT_DRYRUN -eq '1') {
# Render at actual sheet dimensions without touching a printer or leaving PNG files.
for ($i = 0; $i -lt $document.PageCount; $i++) {
$page = $document.GetPage($i)
try {
$sheet = Get-SheetGeometry $page.Size.Width $page.Size.Height
$pixels = Get-RenderSize $sheet 150
$bitmap = Render-Sheet $page $sheet 150
try {
if ($bitmap.Width -ne $pixels.Width -or $bitmap.Height -ne $pixels.Height) {
throw 'the PDF renderer changed the requested sheet dimensions'
}
} finally { $bitmap.Dispose() }
} finally { $page.Dispose() }
}
Write-Output ('rendered ' + $document.PageCount + ' sheet(s)')
return
}
$printer = [System.Drawing.Printing.PrintDocument]::new()
$printer.DocumentName = $env:CRAFT_PRINT_TITLE
$settings = $printer.PrinterSettings
if ($env:CRAFT_PRINT_PRINTER) {
try {
$settings.PrinterName = $env:CRAFT_PRINT_PRINTER
} catch {
throw ('the printer is not available: ' + $env:CRAFT_PRINT_PRINTER)
}
}
$settings.Copies = [Math]::Min(32767, [Math]::Max(1, [int]$env:CRAFT_PRINT_COPIES))
$settings.Collate = ($env:CRAFT_PRINT_COLLATE -eq '1')
Set-JobDuplex $settings $env:CRAFT_PRINT_DUPLEX
if ($env:CRAFT_PRINT_GRAYSCALE -eq '1') { $printer.DefaultPageSettings.Color = $false }
$fallbackPaper = $printer.DefaultPageSettings.PaperSize
# QueryPageSettings runs before each PrintPage, allowing mixed sizes/orientations in one job.
# https://learn.microsoft.com/en-us/dotnet/api/system.drawing.printing.printdocument.querypagesettings
$printer.OriginAtMargins = $false
$printer.PrintController = [System.Drawing.Printing.StandardPrintController]::new()
$script:sheets = 0
$printer.add_QueryPageSettings({
param($sender, $e)
$page = $document.GetPage($script:sheets)
try {
$sheet = Get-SheetGeometry $page.Size.Width $page.Size.Height
Set-SheetSettings $e.PageSettings $sender.PrinterSettings.PaperSizes $sheet $env:CRAFT_PRINT_SCALING $fallbackPaper
} finally { $page.Dispose() }
})
$printer.add_PrintPage({
param($sender, $e)
$page = $document.GetPage($script:sheets)
$bitmap = $null
try {
$sheet = Get-SheetGeometry $page.Size.Width $page.Size.Height
if ($env:CRAFT_PRINT_SCALING -ne 'shrink-to-fit' -and
([Math]::Abs($e.PageBounds.Width - $sheet.Width) -gt 2 -or
[Math]::Abs($e.PageBounds.Height - $sheet.Height) -gt 2)) {
throw 'the printer cannot use the PDF sheet size; select matching paper and try again'
}
$bitmap = Render-Sheet $page $sheet $e.Graphics.DpiX
if ($env:CRAFT_PRINT_SCALING -eq 'shrink-to-fit') {
$box = Get-ShrinkDrawBox $sheet $e.PageSettings.PrintableArea $e.PageSettings.HardMarginX $e.PageSettings.HardMarginY
} else {
$box = Get-DrawBox $sheet $e.PageSettings.HardMarginX $e.PageSettings.HardMarginY
}
$e.Graphics.DrawImage($bitmap, $box)
} finally {
if ($bitmap) { $bitmap.Dispose() }
$page.Dispose()
}
$script:sheets++
$e.HasMorePages = ($script:sheets -lt $document.PageCount)
})
try {
$printer.Print()
Write-Output ('spooled ' + $document.PageCount + ' sheet(s)')
} finally { $printer.Dispose() }
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "craft-quit"
description = "Quit a Craft app the platform's own way: on macOS through `-[NSApplication terminate:]`."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[features]
# `on_close_request`: route an egui/eframe window close through the platform quit.
egui = ["dep:egui"]
# `install_app_menu`: a macOS app menu whose Quit (⌘Q) asks the app first, like the close button.
menu = ["egui", "dep:muda"]
[dependencies]
egui = { workspace = true, optional = true }
# Already in the apps' Cargo.lock: objc2 and objc2-app-kit through eframe, dispatch2 through its other dependencies.
[target.'cfg(target_os = "macos")'.dependencies]
dispatch2 = { version = "0.3.1", default-features = false, features = ["std"] }
objc2 = { version = "0.6.4", default-features = false, features = ["std"] }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
muda = { version = "0.21.2", default-features = false, optional = true }
[lints]
workspace = true
+200
View File
@@ -0,0 +1,200 @@
//! Route an egui window close through the platform quit.
use egui::{Context, ViewportCommand, ViewportId};
/// Call once per frame at the end of `eframe::App::logic`, after the app's own close decision.
/// Move that decision out of `ui` as well: `logic` runs first, and `ui` does not run while the
/// window is minimized or hidden. See the crate example for keeping a save prompt in app state.
/// When the root window was asked
/// to close (close button, the app's own `ViewportCommand::Close`, the OS) and this platform has
/// its own quit (see [`crate::quit`]), cancels the close and quits that way instead. The window
/// stays for the quit to close.
///
/// Returns whether it took the close over. `false` means there was no close request, the app had
/// already cancelled it this frame, or the platform has no quit of its own, and eframe goes on as
/// it would without this call.
pub fn on_close_request(ctx: &Context) -> bool {
take_over_close(ctx, crate::quit)
}
/// The id of a Quit menu item that asks the app first.
///
/// An app that builds its own native app menu (muda, any version) gives its Quit item this id,
/// instead of using muda's `PredefinedMenuItem::quit`, which ends the process without asking, and
/// passes its menu events to [`on_menu_event`]. Apps without their own menu use
/// [`crate::install_app_menu`], which does both.
pub const QUIT_MENU_ID: &str = "craft-quit.quit";
/// Handle a native menu event by the id of the item chosen. For [`QUIT_MENU_ID`], asks the root
/// window to close (`ViewportCommand::Close`), so the app's unsaved-changes check runs as it does
/// for the close button and [`on_close_request`] then quits. Returns whether the item was Quit;
/// other items are left to the caller. Safe to call from a menu-event handler between frames:
/// egui keeps the command for the next frame.
pub fn on_menu_event(ctx: &Context, id: &str) -> bool {
if id != QUIT_MENU_ID {
return false;
}
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::Close);
true
}
/// [`on_close_request`] with the platform quit passed in. `quit` returns whether it quits.
fn take_over_close(ctx: &Context, quit: impl FnOnce() -> bool) -> bool {
if ctx.viewport_id() != ViewportId::ROOT || !ctx.input(|i| i.viewport().close_requested()) {
return false;
}
// The app's unsaved-changes prompt (or anything else) is holding the window open.
if ctx.viewport(|v| v.commands.contains(&ViewportCommand::CancelClose)) {
return false;
}
if !quit() {
return false;
}
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
true
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use egui::{RawInput, ViewportEvent};
use super::*;
/// One frame of the root window, with its close button pressed or not. `app` runs before the
/// take-over, like the app's own unsaved-changes check. Returns whether the close was taken
/// over, how often the quit ran and the frame's commands for the root window.
fn frame(close: bool, quits: bool, app: impl Fn(&Context)) -> (bool, u32, Vec<ViewportCommand>) {
let ctx = Context::default();
let mut input = RawInput::default();
if close {
input.viewports.entry(ViewportId::ROOT).or_default().events.push(ViewportEvent::Close);
}
let (taken, quit_calls) = (Cell::new(false), Cell::new(0));
let mut out = ctx.run_ui(input, |ui| {
app(ui.ctx());
taken.set(take_over_close(ui.ctx(), || {
quit_calls.set(quit_calls.get() + 1);
quits
}));
});
out.textures_delta.clear(); // not painted
let commands = out.viewport_output.remove(&ViewportId::ROOT).map(|o| o.commands).unwrap_or_default();
(taken.get(), quit_calls.get(), commands)
}
fn cancels(commands: &[ViewportCommand]) -> bool {
commands.contains(&ViewportCommand::CancelClose)
}
#[test]
fn a_close_request_quits_and_keeps_the_window() {
let (taken, quit_calls, commands) = frame(true, true, |_| {});
assert!(taken);
assert_eq!(quit_calls, 1);
assert!(cancels(&commands), "the window stays for the quit to close");
}
#[test]
fn nothing_happens_without_a_close_request() {
let (taken, quit_calls, commands) = frame(false, true, |_| {});
assert!(!taken);
assert_eq!(quit_calls, 0);
assert!(!cancels(&commands));
}
#[test]
fn a_close_the_app_cancelled_does_not_quit() {
let (taken, quit_calls, _) = frame(true, true, |ctx| ctx.send_viewport_cmd(ViewportCommand::CancelClose));
assert!(!taken);
assert_eq!(quit_calls, 0, "unsaved work asks first");
}
/// The root window's commands from one frame run after `before`, which runs between frames
/// like a native menu-event handler.
fn commands_after(before: impl FnOnce(&Context)) -> Vec<ViewportCommand> {
let ctx = Context::default();
before(&ctx);
let mut out = ctx.run_ui(RawInput::default(), |_| {});
out.textures_delta.clear(); // not painted
out.viewport_output.remove(&ViewportId::ROOT).map(|o| o.commands).unwrap_or_default()
}
#[test]
fn the_quit_menu_item_asks_the_window_to_close() {
let commands = commands_after(|ctx| assert!(on_menu_event(ctx, QUIT_MENU_ID)));
assert!(commands.contains(&ViewportCommand::Close), "the app's close handling decides");
}
#[test]
fn quit_from_a_child_viewport_still_asks_the_root_to_close() {
let ctx = Context::default();
let child = ViewportId::from_hash_of("document-child");
let mut input = RawInput { viewport_id: child, ..RawInput::default() };
input.viewports.entry(child).or_default().parent = Some(ViewportId::ROOT);
let mut out = ctx.run_ui(input, |ui| {
assert_eq!(ui.ctx().viewport_id(), child);
assert!(on_menu_event(ui.ctx(), QUIT_MENU_ID));
});
out.textures_delta.clear(); // not painted
let root_commands = out.viewport_output.get(&ViewportId::ROOT).map(|o| o.commands.as_slice()).unwrap_or_default();
let child_commands = out.viewport_output.get(&child).map(|o| o.commands.as_slice()).unwrap_or_default();
assert!(root_commands.contains(&ViewportCommand::Close), "root unsaved handling decides whether the app exits");
assert!(!child_commands.contains(&ViewportCommand::Close), "Quit must not just close the current child");
}
#[test]
fn other_menu_items_are_left_to_the_app() {
let commands = commands_after(|ctx| assert!(!on_menu_event(ctx, "edit.copy")));
assert!(!commands.contains(&ViewportCommand::Close));
}
#[test]
fn without_a_platform_quit_the_window_closes_as_usual() {
let (taken, quit_calls, commands) = frame(true, false, |_| {});
assert!(!taken);
assert_eq!(quit_calls, 1);
assert!(!cancels(&commands), "eframe closes the window");
}
/// eframe uses only the logic callback while minimized; no UI callback runs here.
fn minimized_logic_frame(unsaved: bool) -> (bool, u32, bool, Vec<ViewportCommand>) {
let ctx = Context::default();
let mut input = RawInput::default();
let root = input.viewports.entry(ViewportId::ROOT).or_default();
root.minimized = Some(true);
root.events.push(ViewportEvent::Close);
let (taken, quit_calls, show_save_prompt) = (Cell::new(false), Cell::new(0), Cell::new(false));
let mut out = ctx.run_logic(&input, |ctx| {
if ctx.input(|i| i.viewport().close_requested()) && unsaved {
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
show_save_prompt.set(true);
}
taken.set(take_over_close(ctx, || {
quit_calls.set(quit_calls.get() + 1);
true
}));
});
let commands = out.viewport_commands.remove(&ViewportId::ROOT).unwrap_or_default();
(taken.get(), quit_calls.get(), show_save_prompt.get(), commands)
}
#[test]
fn minimized_unsaved_close_waits_for_the_prompt_without_running_ui() {
let (taken, quit_calls, show_save_prompt, commands) = minimized_logic_frame(true);
assert!(!taken);
assert_eq!(quit_calls, 0);
assert!(show_save_prompt, "UI can render the decision stored by logic");
assert!(cancels(&commands));
}
#[test]
fn minimized_clean_close_takes_over_without_running_ui() {
let (taken, quit_calls, show_save_prompt, commands) = minimized_logic_frame(false);
assert!(taken);
assert_eq!(quit_calls, 1);
assert!(!show_save_prompt);
assert!(cancels(&commands), "eframe must not tear down the window before platform quit");
}
}
+101
View File
@@ -0,0 +1,101 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Quit a Craft app the platform's own way.
//!
//! On macOS an eframe app must not leave by closing its window. eframe drops the window while
//! AppKit's run loop is still running, and in the next display cycle AppKit's Touch Bar finder
//! tries to remove a KVO observer from the view that is gone
//! (`-[_NSTouchBarFinderObservation invalidate]`). That is an uncaught Objective-C exception and
//! the app crashes (seen on macOS 27 on a Touch Bar Mac).
//! `-[NSApplication terminate:]`, what ⌘Q does in winit's default menu, ends the process before
//! another display cycle runs.
//!
//! [`quit`] is that quit. With the `egui` feature, [`on_close_request`] routes a window close
//! through it. Put both the app's close decision and this hook in `eframe::App::logic`, with the
//! hook last. eframe calls `logic` even while the window is minimized or hidden, when `ui` does
//! not run. Move any unsaved-changes check out of `ui` too: `logic` runs before `ui`, so a check
//! left in `ui` would happen after the quit. Keep prompt state in the app and render it in `ui`:
//!
//! ```ignore
//! fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
//! if ctx.input(|i| i.viewport().close_requested())
//! && self.has_unsaved_changes()
//! && !self.discard_confirmed
//! {
//! ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
//! self.show_save_prompt = true;
//! }
//! craft_quit::on_close_request(ctx); // last, so it sees the app's `CancelClose`
//! }
//!
//! fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
//! // ... the app's UI, including the prompt when show_save_prompt is true ...
//! }
//! ```
//!
//! The prompt sends `ViewportCommand::Close` only after Save has succeeded (and cleared the
//! dirty state), or Don't Save has set `discard_confirmed`. Cancel, a cancelled file picker and
//! a failed save keep the app open; reset prompt/confirmation state when the decision is cancelled
//! or a new document is opened. An asynchronous save must finish before requesting Close. If the
//! window is hidden, bring it back before showing the prompt so the user can answer it.
//!
//! The app still saves what it keeps in `eframe::App::on_exit`: winit's
//! `applicationWillTerminate:` runs eframe's exit handling before the process ends. The process
//! ends there, so code after `eframe::run_native` in `main` does not run; move it to `on_exit`.
#[cfg(feature = "egui")]
mod close;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(all(feature = "menu", target_os = "macos"))]
mod menu;
#[cfg(feature = "egui")]
pub use close::{QUIT_MENU_ID, on_close_request, on_menu_event};
/// Replace winit's default macOS app menu with one whose Quit (⌘Q) asks the app first.
///
/// winit's Quit ends the process without asking, so unsaved work is lost. This menu's Quit sends
/// the root window a `ViewportCommand::Close` instead: the app's unsaved-changes check runs as it
/// does for the close button, and [`on_close_request`] then quits. Call it once, from the eframe
/// app creator: winit builds its default menu before that and would replace one installed
/// earlier. `app_name` is shown in the menu ("Quit WordCraft").
///
/// For apps without an app menu of their own. It replaces the whole menu and sets muda's
/// process-wide menu-event handler, so an app that builds its own muda menu should not call it:
/// give that menu's Quit item [`QUIT_MENU_ID`] and pass its events to [`on_menu_event`] instead.
///
/// On error winit's menu stays: the app still runs, but ⌘Q quits without asking. The app decides
/// whether that is fatal; `?` passes it out of the eframe app creator.
///
/// Other platforms: does nothing and returns `Ok`.
#[cfg(feature = "menu")]
pub fn install_app_menu(ctx: &egui::Context, app_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(target_os = "macos")]
menu::install(ctx, app_name)?;
#[cfg(not(target_os = "macos"))]
let _ = (ctx, app_name);
Ok(())
}
/// Quit through the platform's own path, where leaving by closing the window is not safe.
///
/// macOS: schedules `-[NSApplication terminate:]` and returns `true`; the caller keeps its window
/// open and the process ends shortly after, once the current event has been handled. Must be
/// called while the app's event loop is running (any eframe callback is). Return to the event loop
/// before blocking: a blocking native dialog opened in the same callback would run the quit while
/// winit is still handling the event, which panics.
///
/// Other platforms: does nothing and returns `false`; the caller closes its window as usual.
pub fn quit() -> bool {
#[cfg(target_os = "macos")]
{
macos::terminate_later();
true
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
+19
View File
@@ -0,0 +1,19 @@
//! `-[NSApplication terminate:]`, scheduled on the main queue. All safe objc2 calls.
use dispatch2::DispatchQueue;
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
/// Send `terminate:` from the main queue rather than from the caller. The caller is inside winit's
/// event handler, and `terminate:` makes winit deliver its exit event, which it can't do while
/// another event is being handled. From the main queue winit is idle: its
/// `applicationWillTerminate:` closes the windows and runs the app's exit handling (eframe saves
/// and calls `on_exit`), and AppKit ends the process.
pub(crate) fn terminate_later() {
DispatchQueue::main().exec_async(|| {
// The main queue runs on the main thread, so there is always a marker.
if let Some(mtm) = MainThreadMarker::new() {
NSApplication::sharedApplication(mtm).terminate(None);
}
});
}
+48
View File
@@ -0,0 +1,48 @@
//! The macOS app menu, with a Quit that asks the app first.
//!
//! winit's default app menu sends Quit (⌘Q) straight to `-[NSApplication terminate:]`, and winit
//! does not implement `applicationShouldTerminate:`, so the app is never asked: unsaved work is
//! lost without a prompt. This menu has the same items, built with muda, but its Quit asks the
//! window to close (`ViewportCommand::Close`). That goes through the app's own close handling,
//! the same as the close button, and [`crate::on_close_request`] then quits. Apps with their own
//! muda menu use [`crate::QUIT_MENU_ID`] and [`crate::on_menu_event`] directly.
use std::cell::RefCell;
use egui::Context;
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
thread_local! {
/// The installed menu. muda's items must outlive the native menu that shows them.
static MENU: RefCell<Option<Menu>> = const { RefCell::new(None) };
}
pub(crate) fn install(ctx: &Context, app_name: &str) -> muda::Result<()> {
if MENU.with_borrow(Option::is_some) {
return Ok(());
}
let quit = MenuItem::with_id(crate::QUIT_MENU_ID, format!("Quit {app_name}"), true, Some(Accelerator::new(Modifiers::META, Code::KeyQ)));
let app_menu = Submenu::new(app_name, true);
app_menu.append_items(&[
&PredefinedMenuItem::about(Some(&format!("About {app_name}")), None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(Some(&format!("Hide {app_name}"))),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&quit,
])?;
let menu = Menu::new();
menu.append(&app_menu)?;
menu.init_for_nsapp();
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
crate::on_menu_event(&ctx, event.id.as_ref());
}));
MENU.set(Some(menu));
Ok(())
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "craft-settings"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
description = "Shared settings storage for the Craft suite: platform folders, config.toml, atomic saves, recovery and migration"
[lints]
workspace = true
[dependencies]
craft-atomic = { workspace = true }
serde = { version = "1", features = ["derive"] }
toml_edit = { version = "0.25", features = ["serde"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
dirs = "7"
[dev-dependencies]
tempfile = "3"
+182
View File
@@ -0,0 +1,182 @@
# Craft Settings
`craft-settings` stores an application's settings in `config.toml`: it picks the folder, loads
without ever failing, saves atomically while the application runs, keeps a backup, moves damaged
files aside and runs schema migrations. Each application keeps its own settings type and
defaults. The suite-wide policy (what belongs where) is in
[docs/settings-storage.md](../../docs/settings-storage.md).
Atomic writes come from [`craft-atomic`](../atomic/README.md).
## Boundaries
- Never crashes: every error comes back in a `LoadReport`, `TickResult` or `Result`; the crate
denies `unwrap`, `expect`, `panic` and friends, and the workspace forbids `unsafe`.
- No application code: it depends only on `craft-atomic`, `serde`, `toml_edit` and `dirs`, and
knows nothing about any app's settings, defaults, UI or file formats. Apps supply their type
and their own parsers for older files.
- No UI: it reports; the application decides what to show.
## Defining settings
```rust
use craft_settings::{DocumentMut, MigrateError, Settings};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
#[serde(default)]
struct Prefs {
general: General,
appearance: Appearance,
}
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
#[serde(default)]
struct General { language: String, autosave_minutes: u32 }
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
#[serde(default)]
struct Appearance { theme: String, ui_scale: f64 }
impl Settings for Prefs {
const APP_ID: &'static str = "photocraft";
const SCHEMA_VERSION: u32 = 1;
fn migrate(from: u32, doc: &mut DocumentMut) -> Result<(), MigrateError> {
// Called once per step for files older than SCHEMA_VERSION, before typed parsing.
Ok(())
}
}
```
Rules for the type:
- Put `#[serde(default)]` on every struct, so a missing key falls back to its default.
- Do not use `#[serde(deny_unknown_fields)]`: keys the application does not know are kept in
the file on purpose.
- Each top-level field becomes a `[section]`. A value that does not fit resets only its section.
## Using the store
```rust,ignore
use craft_settings::{Location, SaveStatus, SettingsStore};
// Once at startup. Never fails; look at the report for anything to show the user.
let (mut store, report) = SettingsStore::<Prefs>::open(Location::Auto);
if report.has_problems() {
// report.broken_moved_to, report.section_errors, report.read_only
}
// Every frame: read is in-memory.
let scale = store.get().appearance.ui_scale;
// On change: only marks the store dirty if the value actually changed.
store.update(|p| p.appearance.theme = "dark".into());
// Every frame: saves 500 ms after the last change, retries failures with backoff.
let tick = store.tick(std::time::Instant::now());
if let SaveStatus::Failed(err) = &tick.status {
// show it; the store keeps retrying
}
if let Some(deadline) = tick.next_deadline {
// eframe only repaints on input, so ask for one at the deadline or an idle app never saves:
ctx.request_repaint_after(deadline.saturating_duration_since(std::time::Instant::now()));
}
// On exit.
let _ = store.flush();
```
`SettingsStore::open_at(dir)` opens a store in a given folder, for settings that travel with a
library or project. `SettingsStore::open(Location::Memory)` keeps settings in memory only and
reads and writes nothing, for tests, demos and switches like `DESIGNCRAFT_NO_PREFS`; the app
uses the same `get`/`update`/`tick`/`flush` calls either way. `OpenOptions` adds older files to
import, a different debounce, and a `Probe` for tests.
## Where the file goes
`Location::Auto` applies these rules in order; the report says which one won:
1. `Location::Dir(path)`: an application override, such as a `--config-dir` flag.
2. `<APP>_CONFIG_DIR` (`PHOTOCRAFT_CONFIG_DIR`). Set but **empty** means isolated: the store
is in memory only and nothing is read or written, so tests and agents never touch the real
profile.
3. A portable marker next to the executable (next to the `.app` bundle on macOS): by default a
file named `portable`, with settings in `config/` beside it. An app with an existing portable
layout keeps it by overriding `Settings::PORTABLE_MARKERS` and `Settings::PORTABLE_DIR`:
```rust,ignore
const PORTABLE_MARKERS: &'static [&'static str] = &["portable.txt", "PhotoCraft.portable"];
const PORTABLE_DIR: &'static str = "PhotoCraftData";
```
4. The platform config folder: `$XDG_CONFIG_HOME/<app>/` or `~/.config/<app>/` on Linux,
`~/Library/Application Support/<app>/` on macOS, `%APPDATA%\<app>\` on Windows.
With no folder (isolated or `Location::Memory`), `store.dir()` and `store.path()` are `None`,
`is_persistent()` is false, legacy files are not imported, and `flush()` returns `Ok`.
Otherwise the folder is created at open. If it cannot be created or written, the store opens read-only
and the report says so, so the application can tell the user at startup rather than on the
first save. `paths::cache_dir` and `paths::data_dir` give the matching cache and data folders for
things that do not belong in `config.toml`.
## Files
- `config.toml`: the settings, starting with `schema_version = N`. Written on the first
`flush()` even when nothing changed, so users can find it. Created `0600` on Unix; a mode the
user changes by hand is kept on later saves.
- `config.toml.bak`: the previous version, refreshed before each save. On Unix it always has
the same mode as `config.toml`; an older, broader backup is tightened on the next save.
- `config.broken-<unix-ts>.toml`: a file that was not valid TOML, moved aside. Never deleted.
## What happens when loading
| File state | Result |
|---|---|
| Missing | Defaults, or the first `LegacyFile` that parses. The old file is left in place so a rollback still works. |
| Not valid TOML | Moved aside; defaults; `report.broken_moved_to`. If it cannot be moved, read-only. |
| Valid TOML, a section does not fit | That section's defaults; the rest kept; `report.section_errors`. |
| Older `schema_version` | `Settings::migrate` once per step, then saved; `report.migrated_from`. |
| Newer `schema_version` | Values shown, store read-only, so an older build never overwrites a newer file. |
| Migration step fails | Store read-only, file untouched. |
## What happens when saving
Values are merged into the document that was loaded: tables merge key by key, arrays and
scalars are replaced whole, comments, key order, unknown keys and each replaced line's trailing
comment stay. Each write goes through `craft-atomic`, so a failed save leaves the old file intact.
Just before each write the store reads the file and compares its **contents** (not its mtime,
which can stay the same on FAT, HFS+ or network shares) with what it last read or wrote:
| File on disk | What the save does |
|---|---|
| Unchanged | Backs it up to `config.toml.bak`, then writes. |
| Changed, parses | Adopts it so its comments and unknown keys survive, backs it up, then writes this store's values over its known keys. |
| Changed, does not parse | Moves it to `config.broken-<ts>.toml` (the edit is kept; `take_moved_aside()` returns the path to show), leaves the previous backup alone, then writes. |
| Blank | Leaves the previous backup alone, then writes. |
| From a newer build, or its migration fails | Writes nothing; the store becomes read-only and the save returns `SettingsError::ReadOnly`, the same as at startup. |
### Concurrency contract
Saving is **best effort, last writer wins**, not multi-process locking. Hand edits and a
second instance's unknown keys and comments are preserved as above, but two processes saving
within the same moment can still race: the later write wins on known keys. Settings are small
and change rarely, so this is the contract apps should assume. An app that needs strict
coordination between processes (for example a launcher writing another app's settings) should
add its own lock around `flush()`.
## Validation
Before merging (from `AGENTS.md`), plus the wasm check:
```sh
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo check --target wasm32-unknown-unknown -p craft-settings
```
The tests in `tests/` write only to temporary folders and never touch the real config folder.
On `wasm32` only the `Settings` trait, the document helpers and the error types are available.
+109
View File
@@ -0,0 +1,109 @@
//! TOML document handling: `schema_version`, per-section parsing and comment-preserving merges.
use toml_edit::{DocumentMut, Item, Table, TableLike};
use crate::Settings;
use crate::error::SectionError;
/// The key written first in every `config.toml`.
pub const SCHEMA_VERSION_KEY: &str = "schema_version";
/// Parses `text` as TOML, keeping comments and formatting.
pub fn parse(text: &str) -> Result<DocumentMut, toml_edit::TomlError> {
text.parse()
}
/// Reads `schema_version`; `None` when the key is missing or not a non-negative integer.
pub fn schema_version(doc: &DocumentMut) -> Option<u32> {
doc.get(SCHEMA_VERSION_KEY)?.as_integer().and_then(|v| u32::try_from(v).ok())
}
/// Sets `schema_version`, keeping its position if it already exists.
pub fn set_schema_version(doc: &mut DocumentMut, version: u32) {
doc[SCHEMA_VERSION_KEY] = toml_edit::value(i64::from(version));
}
/// An empty document carrying only `schema_version`.
pub fn new_document(version: u32) -> DocumentMut {
let mut doc = DocumentMut::new();
set_schema_version(&mut doc, version);
doc
}
/// Turns `doc` into `T`, one top-level section at a time.
///
/// A section that does not fit `T` is reported and replaced by its default; every other section
/// is kept. This is what keeps a renamed enum variant from resetting the whole file.
pub fn deserialize_sections<T: Settings>(doc: &DocumentMut) -> (T, Vec<SectionError>) {
let defaults = T::default();
let Ok(mut merged) = toml_edit::ser::to_document(&defaults) else {
return (defaults, Vec::new());
};
let mut errors = Vec::new();
for (key, item) in doc.iter() {
if key == SCHEMA_VERSION_KEY {
continue;
}
let mut candidate = merged.clone();
candidate[key] = item.clone();
match toml_edit::de::from_document::<T>(candidate.clone()) {
Ok(_) => merged = candidate,
Err(e) => errors.push(SectionError { section: key.to_owned(), message: e.to_string() }),
}
}
let value = toml_edit::de::from_document::<T>(merged).unwrap_or(defaults);
(value, errors)
}
/// Writes `value` into `doc`, keeping comments, key order and keys `value` does not have.
///
/// Tables merge key by key; arrays, arrays of tables and scalars are replaced whole.
pub fn merge_into<T: Settings>(doc: &mut DocumentMut, value: &T) -> Result<(), toml_edit::ser::Error> {
let mut fresh = toml_edit::ser::to_document(value)?;
expand_inline_tables(fresh.as_table_mut());
merge_tables(doc.as_table_mut(), fresh.as_table());
Ok(())
}
/// Serde writes nested structs as inline tables (`general = { … }`); files are nicer to read
/// and edit as `[general]` sections, so new sections are converted before merging.
fn expand_inline_tables(table: &mut Table) {
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_owned()).collect();
for key in keys {
let Some(item) = table.get_mut(&key) else { continue };
if let Some(inline) = item.as_inline_table() {
*item = Item::Table(inline.clone().into_table());
}
if let Some(sub) = item.as_table_mut() {
expand_inline_tables(sub);
}
}
}
fn merge_tables(into: &mut dyn TableLike, from: &dyn TableLike) {
for (key, item) in from.iter() {
match into.get_mut(key) {
Some(existing) if existing.is_table_like() && item.is_table_like() => {
if let (Some(existing), Some(fresh)) = (existing.as_table_like_mut(), item.as_table_like()) {
merge_tables(existing, fresh);
}
}
Some(existing) => {
// Keep the spacing and trailing comment of the line being replaced.
let mut fresh = item.clone();
if let (Some(old), Some(new)) = (existing.as_value(), fresh.as_value_mut()) {
*new.decor_mut() = old.decor().clone();
}
*existing = fresh;
}
None => {
into.insert(key, item.clone());
}
}
}
}
/// Renders `doc` as text.
pub fn render(doc: &DocumentMut) -> String {
doc.to_string()
}
+163
View File
@@ -0,0 +1,163 @@
//! Reports and errors returned by the store.
use std::fmt;
use std::io;
use std::path::PathBuf;
use std::time::Instant;
/// Why a schema migration step failed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrateError {
/// The `schema_version` the step started from.
pub from: u32,
/// What went wrong.
pub message: String,
}
impl MigrateError {
/// A failure migrating from version `from`.
pub fn new(from: u32, message: impl Into<String>) -> Self {
Self { from, message: message.into() }
}
}
impl fmt::Display for MigrateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "migration from schema version {} failed: {}", self.from, self.message)
}
}
impl std::error::Error for MigrateError {}
/// A `[section]` that parsed as TOML but did not fit the settings type; its defaults were used.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SectionError {
/// The top-level key.
pub section: String,
/// The deserialization error.
pub message: String,
}
/// Why the store will not write `config.toml`.
#[derive(Debug)]
pub enum ReadOnlyReason {
/// The configuration folder could not be created.
DirUnavailable(io::Error),
/// The configuration folder exists but is not writable.
DirNotWritable(io::Error),
/// `config.toml` exists but could not be read.
Unreadable(io::Error),
/// `config.toml` is not valid TOML and could not be moved aside.
CannotMoveAside(io::Error),
/// The file was written by a newer build.
NewerSchema {
/// Version in the file.
file: u32,
/// Version this build knows.
app: u32,
},
/// A schema migration step failed; the file is left untouched.
Migration(MigrateError),
}
impl fmt::Display for ReadOnlyReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DirUnavailable(e) => write!(f, "settings folder cannot be created: {e}"),
Self::DirNotWritable(e) => write!(f, "settings folder is not writable: {e}"),
Self::Unreadable(e) => write!(f, "config.toml cannot be read: {e}"),
Self::CannotMoveAside(e) => write!(f, "config.toml is damaged and cannot be moved aside: {e}"),
Self::NewerSchema { file, app } => write!(f, "config.toml has schema version {file}, newer than this build's {app}"),
Self::Migration(e) => write!(f, "{e}"),
}
}
}
/// Where the loaded values came from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
/// Parsed from `config.toml`.
File,
/// `config.toml` did not exist or was unusable; defaults were used.
Defaults,
/// Imported from an older per-app file (left in place).
Legacy(PathBuf),
}
/// What happened while opening the store. Nothing in here is fatal; the store always opens.
#[derive(Debug)]
pub struct LoadReport {
/// The configuration folder; `None` when nothing is read or written (isolated or in memory).
pub dir: Option<PathBuf>,
/// The rule that chose `dir` (see [`crate::Rule`]), as text so the report stays `Debug`-only.
pub rule: String,
/// Where the values came from.
pub source: Source,
/// Set when a damaged `config.toml` was renamed; the new name.
pub broken_moved_to: Option<PathBuf>,
/// Sections whose values were replaced by defaults.
pub section_errors: Vec<SectionError>,
/// Set when the file was upgraded from an older `schema_version`.
pub migrated_from: Option<u32>,
/// Set when the store will not save.
pub read_only: Option<ReadOnlyReason>,
}
impl LoadReport {
/// True when something should be shown to the user.
pub fn has_problems(&self) -> bool {
self.broken_moved_to.is_some() || !self.section_errors.is_empty() || self.read_only.is_some()
}
}
/// A save failure.
#[derive(Debug)]
pub enum SettingsError {
/// The store is read-only; see the reason in the load report.
ReadOnly(String),
/// The settings could not be turned into TOML.
Serialize(String),
/// Writing `config.toml` failed.
Io(io::Error),
}
impl fmt::Display for SettingsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ReadOnly(why) => write!(f, "settings are read-only: {why}"),
Self::Serialize(e) => write!(f, "settings cannot be serialized: {e}"),
Self::Io(e) => write!(f, "settings could not be saved: {e}"),
}
}
}
impl std::error::Error for SettingsError {}
impl From<io::Error> for SettingsError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
/// Outcome of one [`crate::SettingsStore::tick`].
#[derive(Debug)]
pub enum SaveStatus {
/// Nothing to save.
Idle,
/// Changes are waiting for the debounce or retry delay.
Pending,
/// A save just succeeded.
Saved,
/// A save just failed; it will be retried.
Failed(SettingsError),
}
/// What [`crate::SettingsStore::tick`] returns.
#[derive(Debug)]
pub struct TickResult {
/// What happened this tick.
pub status: SaveStatus,
/// When to call `tick` again so a pending save happens even if the app is idle.
/// Pass it to `egui::Context::request_repaint_after`.
pub next_deadline: Option<Instant>,
}
+91
View File
@@ -0,0 +1,91 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Shared settings storage for the Craft suite.
//!
//! Each application keeps its own settings type and defaults. This crate decides where the
//! `config.toml` lives, loads it without ever failing, saves it atomically while the
//! application runs, keeps a backup, moves damaged files aside and runs schema migrations.
//!
//! Hand edits survive: the file is kept in memory as a [`toml_edit::DocumentMut`] and saves
//! merge values into it, so comments and keys the application does not know stay in place.
//!
//! ```no_run
//! use craft_settings::{Location, Settings, SettingsStore};
//! use serde::{Deserialize, Serialize};
//!
//! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
//! #[serde(default)]
//! struct Prefs {
//! appearance: Appearance,
//! }
//!
//! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
//! #[serde(default)]
//! struct Appearance {
//! theme: String,
//! }
//!
//! impl Settings for Prefs {
//! const APP_ID: &'static str = "photocraft";
//! const SCHEMA_VERSION: u32 = 1;
//! }
//!
//! let (mut store, report) = SettingsStore::<Prefs>::open(Location::Auto);
//! store.update(|p| p.appearance.theme = "dark".into());
//! // Call `store.tick(Instant::now())` every frame and `store.flush()` on exit.
//! ```
pub mod document;
pub mod error;
pub mod migrate;
#[cfg(not(target_arch = "wasm32"))]
pub mod paths;
#[cfg(not(target_arch = "wasm32"))]
pub mod recovery;
#[cfg(not(target_arch = "wasm32"))]
pub mod store;
pub use error::{LoadReport, MigrateError, ReadOnlyReason, SaveStatus, SectionError, SettingsError, Source, TickResult};
pub use migrate::LegacyFile;
#[cfg(not(target_arch = "wasm32"))]
pub use paths::{Location, Portable, Probe, Resolved, Rule};
#[cfg(not(target_arch = "wasm32"))]
pub use store::{OpenOptions, SettingsStore};
pub use toml_edit::DocumentMut;
use serde::Serialize;
use serde::de::DeserializeOwned;
/// Suite-default portable names, kept here so the trait also compiles on wasm.
mod paths_default {
pub const MARKERS: &[&str] = &["portable"];
pub const DIR: &str = "config";
}
/// An application's settings type.
///
/// Derive `Serialize`, `Deserialize`, `Default`, `Clone` and `PartialEq`, and put
/// `#[serde(default)]` on every struct so missing keys fall back to defaults. Do not use
/// `#[serde(deny_unknown_fields)]`: unknown keys are kept in the file on purpose.
pub trait Settings: Serialize + DeserializeOwned + Default + Clone + PartialEq {
/// Lowercase application id, used for the folder name and the `<APP>_CONFIG_DIR` variable.
const APP_ID: &'static str;
/// Version written as `schema_version` at the top of `config.toml`.
const SCHEMA_VERSION: u32;
/// File names next to the executable that turn on portable mode (any one; contents ignored).
/// Override to keep an existing portable layout, e.g. `&["portable.txt", "PhotoCraft.portable"]`.
const PORTABLE_MARKERS: &'static [&'static str] = paths_default::MARKERS;
/// Folder beside the executable that holds settings in portable mode.
const PORTABLE_DIR: &'static str = paths_default::DIR;
/// Upgrade a document from `from` to `from + 1`. Called once per step for files whose
/// `schema_version` is older than [`Settings::SCHEMA_VERSION`], before typed parsing.
fn migrate(from: u32, doc: &mut DocumentMut) -> Result<(), MigrateError> {
let _ = (from, doc);
Ok(())
}
}
+69
View File
@@ -0,0 +1,69 @@
//! Schema migrations and import of older per-app settings files.
use std::path::PathBuf;
use toml_edit::DocumentMut;
use crate::Settings;
use crate::document::{schema_version, set_schema_version};
use crate::error::MigrateError;
/// What [`upgrade`] found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Upgrade {
/// Already at the current version, or no `schema_version` key (treated as current).
Current,
/// Upgraded step by step from this older version.
Migrated { from: u32 },
/// Written by a newer build; the document was left alone.
Newer { file: u32 },
}
/// Brings `doc` up to [`Settings::SCHEMA_VERSION`] by calling [`Settings::migrate`] once per step.
///
/// A missing `schema_version` means a hand-written file; it is taken as current and the key is
/// added. A failed step leaves the document as that step left it; callers must not save it.
pub fn upgrade<T: Settings>(doc: &mut DocumentMut) -> Result<Upgrade, MigrateError> {
let target = T::SCHEMA_VERSION;
let Some(file) = schema_version(doc) else {
set_schema_version(doc, target);
return Ok(Upgrade::Current);
};
if file > target {
return Ok(Upgrade::Newer { file });
}
if file == target {
return Ok(Upgrade::Current);
}
for step in file..target {
T::migrate(step, doc)?;
set_schema_version(doc, step + 1);
}
Ok(Upgrade::Migrated { from: file })
}
/// An older settings file an application used before `config.toml`.
///
/// The application parses it with its own code, so this crate needs no JSON or RON support.
/// The file is read, never renamed or removed, so an older build keeps working after a rollback.
pub struct LegacyFile<T> {
/// Where the old file lives on this platform.
pub path: PathBuf,
/// Parses the file's bytes into the current settings type.
pub parse: fn(&[u8]) -> Result<T, String>,
}
impl<T> std::fmt::Debug for LegacyFile<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LegacyFile").field("path", &self.path).finish_non_exhaustive()
}
}
/// Tries each legacy file in order and returns the first one that parses, with its path.
#[cfg(not(target_arch = "wasm32"))]
pub fn import_legacy<T>(files: &[LegacyFile<T>]) -> Option<(T, PathBuf)> {
files.iter().find_map(|f| {
let bytes = std::fs::read(&f.path).ok()?;
(f.parse)(&bytes).ok().map(|v| (v, f.path.clone()))
})
}
+170
View File
@@ -0,0 +1,170 @@
//! Choosing the configuration folder.
//!
//! First match wins: an override from the application, the `<APP>_CONFIG_DIR` variable, a
//! portable marker next to the executable, then the platform's own config folder. An empty
//! `<APP>_CONFIG_DIR`, or [`Location::Memory`], means no folder at all: nothing is read or written.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// How an application spells portable mode: the marker files that turn it on (any one, contents
/// ignored) and the folder created beside the executable. Comes from [`crate::Settings`], so an
/// app that already ships portable builds keeps its existing names.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Portable {
/// File names next to the executable that switch portable mode on.
pub markers: &'static [&'static str],
/// Folder beside the executable that holds the settings in portable mode.
pub dir: &'static str,
}
impl Portable {
/// The suite default: a `portable` file puts settings in `config/`.
pub const DEFAULT: Self = Self { markers: crate::paths_default::MARKERS, dir: crate::paths_default::DIR };
}
/// Where the application wants its settings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Location {
/// Apply the rules in order.
Auto,
/// Use this folder, no questions asked (a `--config-dir` flag, or a library folder).
Dir(PathBuf),
/// Keep settings in memory only; read and write nothing (tests, demos, `<APP>_NO_PREFS`).
Memory,
}
/// Which rule chose the folder.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Rule {
/// [`Location::Dir`].
Override,
/// The named environment variable.
EnvVar(String),
/// The named environment variable was set but empty: isolated, nothing read or written.
Isolated(String),
/// [`Location::Memory`].
Memory,
/// A portable marker next to the executable.
Portable,
/// The platform's config folder.
Platform,
/// No platform folder could be found; the current directory is used.
Fallback,
}
impl std::fmt::Display for Rule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Override => f.write_str("override"),
Self::EnvVar(name) => write!(f, "environment variable {name}"),
Self::Isolated(name) => write!(f, "{name} is empty: isolated, nothing read or written"),
Self::Memory => f.write_str("in memory only"),
Self::Portable => f.write_str("portable marker"),
Self::Platform => f.write_str("platform default"),
Self::Fallback => f.write_str("fallback to current directory"),
}
}
}
/// A chosen folder and the rule behind it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
/// The configuration folder; `None` for [`Rule::Isolated`] and [`Rule::Memory`].
pub dir: Option<PathBuf>,
/// Why.
pub rule: Rule,
}
/// Reads an environment variable by name.
pub type EnvReader = Box<dyn Fn(&str) -> Option<OsString>>;
/// What the rules look at. The defaults read the real process; tests supply their own.
pub struct Probe {
/// Reads an environment variable.
pub env: EnvReader,
/// The folder holding the executable, if known.
pub exe_dir: Option<PathBuf>,
/// The platform config folder (`~/.config`, `Application Support`, `%APPDATA%`).
pub platform_config_dir: Option<PathBuf>,
}
impl Default for Probe {
fn default() -> Self {
Self {
env: Box::new(|name| std::env::var_os(name)),
exe_dir: std::env::current_exe().ok().and_then(|p| p.parent().map(Path::to_path_buf)),
platform_config_dir: dirs::config_dir(),
}
}
}
impl std::fmt::Debug for Probe {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Probe").field("exe_dir", &self.exe_dir).field("platform_config_dir", &self.platform_config_dir).finish_non_exhaustive()
}
}
/// The `<APP>_CONFIG_DIR` variable name for an app id (`photo-craft` → `PHOTO_CRAFT_CONFIG_DIR`).
pub fn env_var_name(app_id: &str) -> String {
let mut name: String = app_id.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }).collect();
name.push_str("_CONFIG_DIR");
name
}
/// Applies the rules for `app_id`, with the app's portable-mode names.
pub fn resolve(app_id: &str, portable: &Portable, location: &Location, probe: &Probe) -> Resolved {
match location {
Location::Dir(dir) => return Resolved { dir: Some(dir.clone()), rule: Rule::Override },
Location::Memory => return Resolved { dir: None, rule: Rule::Memory },
Location::Auto => {}
}
let var = env_var_name(app_id);
if let Some(value) = (probe.env)(&var) {
if value.is_empty() {
return Resolved { dir: None, rule: Rule::Isolated(var) };
}
return Resolved { dir: Some(PathBuf::from(value)), rule: Rule::EnvVar(var) };
}
if let Some(dir) = probe.exe_dir.as_deref().and_then(|d| portable_dir(d, portable)) {
return Resolved { dir: Some(dir), rule: Rule::Portable };
}
match &probe.platform_config_dir {
Some(base) => Resolved { dir: Some(base.join(app_id)), rule: Rule::Platform },
None => Resolved { dir: Some(PathBuf::from(".").join(app_id)), rule: Rule::Fallback },
}
}
/// The folder a portable marker points at, when `exe_dir` has one.
///
/// On macOS the executable sits in `X.app/Contents/MacOS`; the marker is looked for next to the
/// `.app` bundle, because writing inside a signed bundle breaks its signature.
fn portable_dir(exe_dir: &Path, portable: &Portable) -> Option<PathBuf> {
let base = bundle_parent(exe_dir).unwrap_or(exe_dir);
portable.markers.iter().any(|m| base.join(m).is_file()).then(|| base.join(portable.dir))
}
fn bundle_parent(exe_dir: &Path) -> Option<&Path> {
if !cfg!(target_os = "macos") || exe_dir.file_name()? != "MacOS" {
return None;
}
let contents = exe_dir.parent()?;
if contents.file_name()? != "Contents" {
return None;
}
let bundle = contents.parent()?;
if bundle.extension()? != "app" {
return None;
}
bundle.parent()
}
/// The platform cache folder for `app_id` (`%LOCALAPPDATA%` on Windows, so it does not roam).
pub fn cache_dir(app_id: &str) -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join(app_id))
}
/// The platform data folder for `app_id`, for recovery files and other non-settings state.
pub fn data_dir(app_id: &str) -> Option<PathBuf> {
dirs::data_local_dir().map(|d| d.join(app_id))
}
+67
View File
@@ -0,0 +1,67 @@
//! Keeping damaged and previous files around.
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Renames a damaged `config.toml` to `config.broken-<unix-ts>.toml` beside it and returns the
/// new path. Never overwrites: a name that exists gets a `-<n>` suffix.
pub fn move_aside(path: &Path) -> io::Result<PathBuf> {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("config");
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("toml");
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let ts = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let mut n = 0u32;
loop {
let name = if n == 0 { format!("{stem}.broken-{ts}.{ext}") } else { format!("{stem}.broken-{ts}-{n}.{ext}") };
let target = dir.join(name);
if target.exists() {
n = n.saturating_add(1);
if n > 1000 {
return Err(io::Error::new(io::ErrorKind::AlreadyExists, "too many broken copies"));
}
continue;
}
std::fs::rename(path, &target)?;
return Ok(target);
}
}
/// The backup path for a config file: `config.toml` → `config.toml.bak`.
pub fn backup_path(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
name.push(".bak");
path.with_file_name(name)
}
/// Writes `text`, the last version that parsed, to the backup file atomically.
///
/// The caller decides what is safe to keep: a blank or unparsable file must never replace the
/// previous backup. On Unix the backup always gets the same mode as `path` (`0600` when there is
/// none), so it is never readable by more people than the settings themselves.
pub fn write_backup(path: &Path, text: &str) -> io::Result<()> {
let backup = backup_path(path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path).map(|m| m.permissions().mode() & 0o777).unwrap_or(0o600);
// An atomic replace keeps the existing file's mode, so narrow an old, broader backup
// before new contents go into it, then match the config once written.
if backup.exists() {
std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(mode))?;
}
craft_atomic::atomic_write(&backup, text.as_bytes())?;
std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
{
craft_atomic::atomic_write(&backup, text.as_bytes())
}
}
/// Checks that `dir` accepts a new file by creating and removing a probe.
pub fn check_writable(dir: &Path) -> io::Result<()> {
let probe = dir.join(format!(".craft-settings-probe-{}", std::process::id()));
std::fs::OpenOptions::new().write(true).create_new(true).open(&probe)?;
std::fs::remove_file(&probe)
}
+425
View File
@@ -0,0 +1,425 @@
//! The in-memory store: load once, read every frame, save while the application runs.
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use toml_edit::DocumentMut;
use crate::Settings;
use crate::document::{self, merge_into, set_schema_version};
use crate::error::{LoadReport, ReadOnlyReason, SaveStatus, SettingsError, Source, TickResult};
use crate::migrate::{self, LegacyFile, Upgrade};
use crate::paths::{self, Location, Portable, Probe};
use crate::recovery;
/// The settings file name.
pub const CONFIG_FILE: &str = "config.toml";
/// How long after the last change a save is attempted.
pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500);
/// First retry delay after a failed save; each failure doubles it, up to [`MAX_RETRY_DELAY`].
pub const FIRST_RETRY_DELAY: Duration = Duration::from_secs(1);
/// Longest wait between retries.
pub const MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
/// How to open a store.
pub struct OpenOptions<T> {
/// Where the settings live.
pub location: Location,
/// Older files to import when there is no `config.toml` yet.
pub legacy: Vec<LegacyFile<T>>,
/// Quiet period after a change before saving.
pub debounce: Duration,
/// What the folder rules look at.
pub probe: Probe,
}
impl<T> Default for OpenOptions<T> {
fn default() -> Self {
Self { location: Location::Auto, legacy: Vec::new(), debounce: DEFAULT_DEBOUNCE, probe: Probe::default() }
}
}
impl<T> OpenOptions<T> {
/// Options for `location`.
pub fn new(location: Location) -> Self {
Self { location, ..Self::default() }
}
/// Older files to import on first run.
pub fn legacy(mut self, files: Vec<LegacyFile<T>>) -> Self {
self.legacy = files;
self
}
/// Quiet period after a change before saving.
pub fn debounce(mut self, debounce: Duration) -> Self {
self.debounce = debounce;
self
}
/// What the folder rules look at.
pub fn probe(mut self, probe: Probe) -> Self {
self.probe = probe;
self
}
}
/// An application's settings, with the file they came from.
///
/// With no folder (an empty `<APP>_CONFIG_DIR`, or [`Location::Memory`]) the store keeps
/// everything in memory: `get`/`update` work as usual, nothing is read or written.
pub struct SettingsStore<T: Settings> {
dir: Option<PathBuf>,
path: Option<PathBuf>,
doc: DocumentMut,
value: T,
read_only: Option<String>,
dirty: bool,
written: bool,
last_change: Option<Instant>,
debounce: Duration,
failures: u32,
retry_at: Option<Instant>,
/// The file's contents as last read or written. Compared by content, not mtime, so an edit
/// within the same second (coarse timestamps on FAT, HFS+, network shares) is still seen.
seen_text: Option<String>,
/// Files that were damaged on disk at save time and moved aside; see [`SettingsStore::take_moved_aside`].
moved_aside: Vec<PathBuf>,
}
impl<T: Settings> std::fmt::Debug for SettingsStore<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SettingsStore")
.field("path", &self.path)
.field("read_only", &self.read_only)
.field("dirty", &self.dirty)
.finish_non_exhaustive()
}
}
impl<T: Settings> SettingsStore<T> {
/// Opens the store for `location`. Never fails: problems come back in the report.
pub fn open(location: Location) -> (Self, LoadReport) {
Self::open_with(OpenOptions::new(location))
}
/// Opens a store in `dir`, for settings that travel with a folder such as a library.
pub fn open_at(dir: impl Into<PathBuf>) -> (Self, LoadReport) {
Self::open(Location::Dir(dir.into()))
}
/// Opens the store with full options.
pub fn open_with(options: OpenOptions<T>) -> (Self, LoadReport) {
let portable = Portable { markers: T::PORTABLE_MARKERS, dir: T::PORTABLE_DIR };
let resolved = paths::resolve(T::APP_ID, &portable, &options.location, &options.probe);
let path = resolved.dir.as_ref().map(|d| d.join(CONFIG_FILE));
let mut report = LoadReport {
dir: resolved.dir.clone(),
rule: resolved.rule.to_string(),
source: Source::Defaults,
broken_moved_to: None,
section_errors: Vec::new(),
migrated_from: None,
read_only: None,
};
let mut store = Self {
dir: resolved.dir,
path,
doc: document::new_document(T::SCHEMA_VERSION),
value: T::default(),
read_only: None,
dirty: false,
written: false,
last_change: None,
debounce: options.debounce,
failures: 0,
retry_at: None,
seen_text: None,
moved_aside: Vec::new(),
};
let (Some(dir), Some(path)) = (store.dir.clone(), store.path.clone()) else {
return (store, report);
};
if let Err(e) = std::fs::create_dir_all(&dir) {
store.load_best_effort(&mut report);
store.set_read_only(&mut report, ReadOnlyReason::DirUnavailable(e));
return (store, report);
}
if let Err(e) = recovery::check_writable(&dir) {
store.load_best_effort(&mut report);
store.set_read_only(&mut report, ReadOnlyReason::DirNotWritable(e));
return (store, report);
}
match std::fs::read_to_string(&path) {
Ok(text) => store.load_text(&path, &text, &mut report),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
if let Some((value, from)) = migrate::import_legacy(&options.legacy) {
store.value = value;
store.dirty = true;
report.source = Source::Legacy(from);
}
}
Err(e) => store.set_read_only(&mut report, ReadOnlyReason::Unreadable(e)),
}
(store, report)
}
/// The configuration folder; `None` when the store is in memory only.
pub fn dir(&self) -> Option<&Path> {
self.dir.as_deref()
}
/// The `config.toml` path; `None` when the store is in memory only.
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
/// True when the store reads and writes a file; false when it is in memory only.
pub fn is_persistent(&self) -> bool {
self.path.is_some()
}
/// The current settings. In memory; cheap to call every frame.
pub fn get(&self) -> &T {
&self.value
}
/// True when the store will not write the file; the load report says why.
pub fn is_read_only(&self) -> bool {
self.read_only.is_some()
}
/// Damaged `config.toml` files found and moved aside while saving (someone broke the file
/// while the app was running). Drains the list; show these to the user like
/// [`LoadReport::broken_moved_to`].
pub fn take_moved_aside(&mut self) -> Vec<PathBuf> {
std::mem::take(&mut self.moved_aside)
}
/// True when changes are waiting to be saved.
pub fn is_dirty(&self) -> bool {
self.dirty
}
/// Changes the settings. Returns true, and schedules a save, only if the value changed.
pub fn update(&mut self, f: impl FnOnce(&mut T)) -> bool {
let mut next = self.value.clone();
f(&mut next);
self.set(next)
}
/// Replaces the settings. Returns true, and schedules a save, only if the value changed.
pub fn set(&mut self, value: T) -> bool {
if value == self.value {
return false;
}
self.value = value;
self.mark_changed(Instant::now());
true
}
/// Like [`SettingsStore::update`] with an explicit clock, for tests.
pub fn update_at(&mut self, now: Instant, f: impl FnOnce(&mut T)) -> bool {
let mut next = self.value.clone();
f(&mut next);
if next == self.value {
return false;
}
self.value = next;
self.mark_changed(now);
true
}
/// Saves pending changes once the debounce or retry delay has passed. Call every frame
/// and again at `next_deadline`, so an idle application still saves.
pub fn tick(&mut self, now: Instant) -> TickResult {
if !self.dirty || self.read_only.is_some() {
return TickResult { status: SaveStatus::Idle, next_deadline: None };
}
let due = match (self.retry_at, self.last_change) {
(Some(retry), _) => retry,
(None, Some(changed)) => changed + self.debounce,
(None, None) => now,
};
if now < due {
return TickResult { status: SaveStatus::Pending, next_deadline: Some(due) };
}
match self.save() {
Ok(()) => TickResult { status: SaveStatus::Saved, next_deadline: None },
Err(e) => {
self.failures = self.failures.saturating_add(1);
let delay = retry_delay(self.failures);
self.retry_at = Some(now + delay);
TickResult { status: SaveStatus::Failed(e), next_deadline: Some(now + delay) }
}
}
}
/// Saves now. Writes the file even when nothing changed if it does not exist yet, so users
/// can find it. Call on exit.
pub fn flush(&mut self) -> Result<(), SettingsError> {
if let Some(why) = &self.read_only {
return Err(SettingsError::ReadOnly(why.clone()));
}
let Some(path) = &self.path else {
return Ok(());
};
if !self.dirty && (self.written || path.exists()) {
return Ok(());
}
self.save()
}
fn mark_changed(&mut self, now: Instant) {
// An in-memory store has nothing to save, so it never becomes dirty.
self.dirty = self.path.is_some();
self.last_change = Some(now);
}
fn set_read_only(&mut self, report: &mut LoadReport, reason: ReadOnlyReason) {
self.read_only = Some(reason.to_string());
report.read_only = Some(reason);
}
/// Reads the file if it happens to be readable, so a read-only store still shows the
/// user's values instead of defaults.
fn load_best_effort(&mut self, report: &mut LoadReport) {
if let Some(path) = &self.path
&& let Ok(text) = std::fs::read_to_string(path)
&& let Ok(mut doc) = document::parse(&text)
{
// A failed migration leaves the document half-upgraded; show defaults instead.
let Ok(upgraded) = migrate::upgrade::<T>(&mut doc) else {
return;
};
let (value, errors) = document::deserialize_sections::<T>(&doc);
self.value = value;
self.doc = doc;
self.seen_text = Some(text);
report.source = Source::File;
report.section_errors = errors;
if let Upgrade::Migrated { from } = upgraded {
report.migrated_from = Some(from);
}
}
}
fn load_text(&mut self, path: &Path, text: &str, report: &mut LoadReport) {
let mut doc = match document::parse(text) {
Ok(doc) => doc,
Err(_) => {
match recovery::move_aside(path) {
Ok(moved) => report.broken_moved_to = Some(moved),
Err(e) => self.set_read_only(report, ReadOnlyReason::CannotMoveAside(e)),
}
return;
}
};
match migrate::upgrade::<T>(&mut doc) {
Ok(Upgrade::Current) => {}
Ok(Upgrade::Migrated { from }) => {
report.migrated_from = Some(from);
self.dirty = true;
}
Ok(Upgrade::Newer { file }) => self.set_read_only(report, ReadOnlyReason::NewerSchema { file, app: T::SCHEMA_VERSION }),
Err(e) => {
self.set_read_only(report, ReadOnlyReason::Migration(e));
return;
}
}
let (value, errors) = document::deserialize_sections::<T>(&doc);
self.value = value;
self.doc = doc;
self.seen_text = Some(text.to_owned());
report.source = Source::File;
report.section_errors = errors;
}
fn save(&mut self) -> Result<(), SettingsError> {
if let Some(why) = &self.read_only {
return Err(SettingsError::ReadOnly(why.clone()));
}
let Some(path) = self.path.clone() else {
self.dirty = false;
return Ok(());
};
if let Some(on_disk) = self.check_disk(&path)? {
recovery::write_backup(&path, &on_disk)?;
}
merge_into(&mut self.doc, &self.value).map_err(|e| SettingsError::Serialize(e.to_string()))?;
set_schema_version(&mut self.doc, T::SCHEMA_VERSION);
let text = document::render(&self.doc);
craft_atomic::atomic_write_with(&path, |file| {
restrict_permissions(file)?;
file.write_all(text.as_bytes())
})?;
self.seen_text = Some(text);
self.dirty = false;
self.written = true;
self.failures = 0;
self.retry_at = None;
Ok(())
}
/// Looks at the file on disk just before a save, and returns its text when it is a good
/// backup (it parses and is not blank).
///
/// The contract is best effort, not multi-process locking: if another instance or a hand
/// edit changed the file since this store last read or wrote it, the new document is adopted
/// so its comments and unknown keys survive, and this store's values then win on known keys.
/// A file that no longer parses is moved aside (the edit is kept, the previous backup stays),
/// and a file from a newer build, or one whose migration fails, makes the store read-only
/// instead of overwriting it, the same as at startup.
fn check_disk(&mut self, path: &Path) -> Result<Option<String>, SettingsError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let Ok(mut doc) = document::parse(&text) else {
self.moved_aside.push(recovery::move_aside(path)?);
return Ok(None);
};
if self.seen_text.as_deref() != Some(text.as_str()) {
match migrate::upgrade::<T>(&mut doc) {
Ok(Upgrade::Newer { file }) => {
let why = ReadOnlyReason::NewerSchema { file, app: T::SCHEMA_VERSION }.to_string();
self.read_only = Some(why.clone());
return Err(SettingsError::ReadOnly(why));
}
Ok(_) => self.doc = doc,
// Same as at startup: leave the outside file untouched and stop saving.
Err(e) => {
let why = ReadOnlyReason::Migration(e).to_string();
self.read_only = Some(why.clone());
return Err(SettingsError::ReadOnly(why));
}
}
}
Ok((!text.trim().is_empty()).then_some(text))
}
}
/// Exponential backoff: 1 s, 2 s, 4 s, … capped at [`MAX_RETRY_DELAY`].
pub fn retry_delay(failures: u32) -> Duration {
let exp = failures.saturating_sub(1).min(16);
(FIRST_RETRY_DELAY * 2u32.saturating_pow(exp)).min(MAX_RETRY_DELAY)
}
/// New files are owner-only; on a replace `craft-atomic` keeps the existing file's mode.
#[cfg(unix)]
fn restrict_permissions(file: &std::fs::File) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
}
#[cfg(not(unix))]
fn restrict_permissions(_file: &std::fs::File) -> io::Result<()> {
Ok(())
}
+96
View File
@@ -0,0 +1,96 @@
#![allow(dead_code)]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::Duration;
use craft_settings::{DocumentMut, Location, MigrateError, OpenOptions, Probe, Settings, SettingsStore};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)]
#[serde(default)]
pub struct Prefs {
pub general: General,
pub appearance: Appearance,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
#[serde(default)]
pub struct General {
pub language: String,
pub autosave_minutes: u32,
pub recent: Vec<String>,
}
impl Default for General {
fn default() -> Self {
Self { language: "en".into(), autosave_minutes: 5, recent: Vec::new() }
}
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
#[serde(default)]
pub struct Appearance {
pub theme: Theme,
pub ui_scale: f64,
}
impl Default for Appearance {
fn default() -> Self {
Self { theme: Theme::Light, ui_scale: 1.0 }
}
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
Light,
Dark,
}
impl Settings for Prefs {
const APP_ID: &'static str = "testcraft";
const SCHEMA_VERSION: u32 = 2;
/// Version 1 stored the theme as `[appearance] dark = true`.
fn migrate(from: u32, doc: &mut DocumentMut) -> Result<(), MigrateError> {
match from {
1 => {
let dark = doc.get("appearance").and_then(|a| a.get("dark")).and_then(|d| d.as_bool());
if let Some(dark) = dark {
doc["appearance"]["theme"] = toml_edit::value(if dark { "dark" } else { "light" });
if let Some(table) = doc["appearance"].as_table_like_mut() {
table.remove("dark");
}
}
Ok(())
}
other => Err(MigrateError::new(other, "unknown version")),
}
}
}
/// A probe that sees no environment, no executable and no platform folder.
pub fn empty_probe() -> Probe {
Probe { env: Box::new(|_| None), exe_dir: None, platform_config_dir: None }
}
pub fn probe_with_env(name: &'static str, value: &'static str) -> Probe {
Probe { env: Box::new(move |n| (n == name).then(|| OsString::from(value))), exe_dir: None, platform_config_dir: None }
}
pub fn open_in(dir: &Path) -> (SettingsStore<Prefs>, craft_settings::LoadReport) {
SettingsStore::open_with(OpenOptions::new(Location::Dir(dir.to_path_buf())).probe(empty_probe()).debounce(Duration::from_millis(100)))
}
pub fn config_path(dir: &Path) -> PathBuf {
dir.join("config.toml")
}
pub fn write_config(dir: &Path, text: &str) {
std::fs::write(config_path(dir), text).unwrap();
}
pub fn read_config(dir: &Path) -> String {
std::fs::read_to_string(config_path(dir)).unwrap()
}
+74
View File
@@ -0,0 +1,74 @@
mod common;
use std::time::{Duration, Instant};
use common::{Prefs, Theme, empty_probe, probe_with_env};
use craft_settings::{Location, OpenOptions, SaveStatus, SettingsStore, Source};
/// A probe whose platform folder is `dir`, so any write would land there.
fn probe_in(dir: &std::path::Path, env: Option<(&'static str, &'static str)>) -> craft_settings::Probe {
let mut probe = match env {
Some((name, value)) => probe_with_env(name, value),
None => empty_probe(),
};
probe.platform_config_dir = Some(dir.to_path_buf());
probe
}
fn assert_untouched(dir: &std::path::Path) {
let entries: Vec<_> = std::fs::read_dir(dir).unwrap().collect();
assert!(entries.is_empty(), "nothing may be written: {entries:?}");
}
fn exercise(store: &mut SettingsStore<Prefs>) {
assert!(!store.is_persistent());
assert_eq!(store.dir(), None);
assert_eq!(store.path(), None);
assert!(store.update(|p| p.appearance.theme = Theme::Dark), "updates still apply");
assert_eq!(store.get().appearance.theme, Theme::Dark);
assert!(!store.is_dirty(), "nothing to save");
assert!(matches!(store.tick(Instant::now() + Duration::from_secs(5)).status, SaveStatus::Idle));
store.flush().unwrap();
}
#[test]
fn empty_env_var_reads_and_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
// A real settings file in the platform folder must not be read either.
std::fs::create_dir(tmp.path().join("testcraft")).unwrap();
std::fs::write(tmp.path().join("testcraft/config.toml"), "schema_version = 2\n[general]\nlanguage = \"de\"\n").unwrap();
let (mut store, report) =
SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Auto).probe(probe_in(tmp.path(), Some(("TESTCRAFT_CONFIG_DIR", "")))));
assert_eq!(report.dir, None);
assert_eq!(report.source, Source::Defaults);
assert!(report.rule.contains("isolated"), "{}", report.rule);
assert!(!report.has_problems(), "{report:?}");
assert_eq!(store.get().general.language, "en", "the real profile is not read");
exercise(&mut store);
assert_eq!(
std::fs::read_to_string(tmp.path().join("testcraft/config.toml")).unwrap(),
"schema_version = 2\n[general]\nlanguage = \"de\"\n",
"the real profile is not written"
);
assert_eq!(std::fs::read_dir(tmp.path().join("testcraft")).unwrap().count(), 1);
}
#[test]
fn memory_location_reads_and_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, report) = SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Memory).probe(probe_in(tmp.path(), None)));
assert_eq!(report.rule, "in memory only");
exercise(&mut store);
assert_untouched(tmp.path());
}
#[test]
fn memory_ignores_legacy_files() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("prefs.json");
std::fs::write(&old, "{}").unwrap();
let files = vec![craft_settings::LegacyFile { path: old, parse: |_| Ok(Prefs::default()) }];
let (_, report) = SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Memory).probe(empty_probe()).legacy(files));
assert_eq!(report.source, Source::Defaults, "an isolated store must not import from the real profile");
}
+105
View File
@@ -0,0 +1,105 @@
mod common;
use std::time::Duration;
use common::{Prefs, Theme, empty_probe, open_in, read_config, write_config};
use craft_settings::{LegacyFile, Location, OpenOptions, SettingsStore, Source};
#[test]
fn older_schema_is_migrated_step_by_step_and_saved() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 1\n\n# keep me\n[appearance]\ndark = true\nui_scale = 1.25\n");
let (mut store, report) = open_in(tmp.path());
assert_eq!(report.migrated_from, Some(1), "{report:?}");
assert!(report.section_errors.is_empty(), "{report:?}");
assert_eq!(store.get().appearance.theme, Theme::Dark);
assert_eq!(store.get().appearance.ui_scale, 1.25);
assert!(store.is_dirty(), "a migrated file is written back");
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.starts_with("schema_version = 2\n"), "{text}");
assert!(text.contains("# keep me"), "{text}");
assert!(text.contains("theme = \"dark\""), "{text}");
assert!(!text.contains("dark = true"), "{text}");
}
#[test]
fn missing_schema_version_is_treated_as_current() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "[general]\nlanguage = \"pt\"\n");
let (mut store, report) = open_in(tmp.path());
assert_eq!(report.migrated_from, None);
assert!(!store.is_read_only(), "{report:?}");
assert_eq!(store.get().general.language, "pt");
store.update(|p| p.general.autosave_minutes = 9);
store.flush().unwrap();
assert!(read_config(tmp.path()).starts_with("schema_version = 2\n"));
}
fn parse_legacy_json(bytes: &[u8]) -> Result<Prefs, String> {
// Stand-in for an app's real JSON parser: `{"lang":"de","dark":true}`.
let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
let mut prefs = Prefs::default();
if text.contains("\"dark\":true") {
prefs.appearance.theme = Theme::Dark;
}
if let Some(start) = text.find("\"lang\":\"") {
let rest = &text[start + 8..];
prefs.general.language = rest[..rest.find('"').ok_or("unterminated")?].to_owned();
}
Ok(prefs)
}
fn open_with_legacy(dir: &std::path::Path, files: Vec<LegacyFile<Prefs>>) -> (SettingsStore<Prefs>, craft_settings::LoadReport) {
SettingsStore::open_with(
OpenOptions::new(Location::Dir(dir.to_path_buf())).probe(empty_probe()).debounce(Duration::from_millis(100)).legacy(files),
)
}
#[test]
fn legacy_file_is_imported_on_first_run_and_left_in_place() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("prefs.json");
std::fs::write(&old, r#"{"lang":"de","dark":true}"#).unwrap();
let (mut store, report) = open_with_legacy(tmp.path(), vec![LegacyFile { path: old.clone(), parse: parse_legacy_json }]);
assert_eq!(report.source, Source::Legacy(old.clone()));
assert_eq!(store.get().general.language, "de");
assert_eq!(store.get().appearance.theme, Theme::Dark);
assert!(store.is_dirty());
store.flush().unwrap();
assert!(old.exists(), "the old file is copied, not removed, so a rollback still works");
assert_eq!(std::fs::read_to_string(&old).unwrap(), r#"{"lang":"de","dark":true}"#);
assert!(read_config(tmp.path()).contains("language = \"de\""));
}
#[test]
fn legacy_import_is_skipped_once_config_exists() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("prefs.json");
std::fs::write(&old, r#"{"lang":"de"}"#).unwrap();
write_config(tmp.path(), "schema_version = 2\n[general]\nlanguage = \"it\"\n");
let (store, report) = open_with_legacy(tmp.path(), vec![LegacyFile { path: old, parse: parse_legacy_json }]);
assert_eq!(report.source, Source::File);
assert_eq!(store.get().general.language, "it");
}
#[test]
fn first_legacy_file_that_parses_wins() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("nope.json");
let bad = tmp.path().join("bad.json");
std::fs::write(&bad, [0xff, 0xfe]).unwrap();
let good = tmp.path().join("good.json");
std::fs::write(&good, r#"{"lang":"nl"}"#).unwrap();
let files = vec![
LegacyFile { path: missing, parse: parse_legacy_json },
LegacyFile { path: bad, parse: parse_legacy_json },
LegacyFile { path: good.clone(), parse: parse_legacy_json },
];
let (store, report) = open_with_legacy(tmp.path(), files);
assert_eq!(report.source, Source::Legacy(good));
assert_eq!(store.get().general.language, "nl");
}
+143
View File
@@ -0,0 +1,143 @@
mod common;
use std::path::PathBuf;
use common::{Prefs, empty_probe, open_in, probe_with_env};
use craft_settings::paths::{Location, Portable, Probe, Rule, env_var_name, resolve};
use craft_settings::{OpenOptions, Settings, SettingsStore};
#[test]
fn env_var_name_uppercases_and_replaces_punctuation() {
assert_eq!(env_var_name("photocraft"), "PHOTOCRAFT_CONFIG_DIR");
assert_eq!(env_var_name("photo-craft"), "PHOTO_CRAFT_CONFIG_DIR");
}
#[test]
fn override_wins_over_everything() {
let probe = probe_with_env("TESTCRAFT_CONFIG_DIR", "/from/env");
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Dir(PathBuf::from("/explicit")), &probe);
assert_eq!(r.dir, Some(PathBuf::from("/explicit")));
assert_eq!(r.rule, Rule::Override);
}
#[test]
fn env_var_wins_over_portable_and_platform() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("portable"), "").unwrap();
let mut probe = probe_with_env("TESTCRAFT_CONFIG_DIR", "/from/env");
probe.exe_dir = Some(tmp.path().to_path_buf());
probe.platform_config_dir = Some(PathBuf::from("/platform"));
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
assert_eq!(r.dir, Some(PathBuf::from("/from/env")));
assert_eq!(r.rule, Rule::EnvVar("TESTCRAFT_CONFIG_DIR".into()));
}
#[test]
fn empty_env_var_means_isolated_not_unset() {
let mut probe = probe_with_env("TESTCRAFT_CONFIG_DIR", "");
probe.platform_config_dir = Some(PathBuf::from("/platform"));
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
assert_eq!(r.rule, Rule::Isolated("TESTCRAFT_CONFIG_DIR".into()));
assert_eq!(r.dir, None, "never falls through to the real profile");
}
#[test]
fn memory_location_has_no_folder() {
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Memory, &probe_with_env("TESTCRAFT_CONFIG_DIR", "/from/env"));
assert_eq!(r.rule, Rule::Memory);
assert_eq!(r.dir, None);
}
#[test]
fn app_supplied_portable_names_are_used() {
const LEGACY: Portable = Portable { markers: &["portable.txt", "PhotoCraft.portable"], dir: "PhotoCraftData" };
for marker in ["portable.txt", "PhotoCraft.portable"] {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(marker), "").unwrap();
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
let r = resolve("testcraft", &LEGACY, &Location::Auto, &probe);
assert_eq!(r.rule, Rule::Portable, "{marker}");
assert_eq!(r.dir, Some(tmp.path().join("PhotoCraftData")), "{marker}");
}
}
#[test]
fn default_marker_is_ignored_when_the_app_uses_its_own() {
const LEGACY: Portable = Portable { markers: &["portable.txt"], dir: "PhotoCraftData" };
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("portable"), "").unwrap();
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
assert_eq!(resolve("testcraft", &LEGACY, &Location::Auto, &probe).rule, Rule::Platform);
}
#[test]
fn portable_marker_next_to_executable_uses_config_folder_beside_it() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("portable"), "").unwrap();
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
assert_eq!(r.rule, Rule::Portable);
assert_eq!(r.dir, Some(tmp.path().join("config")));
}
#[test]
fn no_marker_means_platform_folder() {
let tmp = tempfile::tempdir().unwrap();
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
assert_eq!(r.rule, Rule::Platform);
assert_eq!(r.dir, Some(PathBuf::from("/platform/testcraft")));
}
#[cfg(target_os = "macos")]
#[test]
fn portable_marker_is_looked_for_next_to_the_app_bundle() {
let tmp = tempfile::tempdir().unwrap();
let exe_dir = tmp.path().join("Test.app/Contents/MacOS");
std::fs::create_dir_all(&exe_dir).unwrap();
std::fs::write(tmp.path().join("portable"), "").unwrap();
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(exe_dir), platform_config_dir: None };
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
assert_eq!(r.rule, Rule::Portable);
assert_eq!(r.dir, Some(tmp.path().join("config")));
}
#[test]
fn no_platform_folder_falls_back_to_current_directory() {
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &empty_probe());
assert_eq!(r.rule, Rule::Fallback);
assert_eq!(r.dir, Some(PathBuf::from("./testcraft")));
}
#[test]
fn report_names_the_rule_and_folder() {
let tmp = tempfile::tempdir().unwrap();
let (_store, report) = open_in(tmp.path());
assert_eq!(report.dir.as_deref(), Some(tmp.path()));
assert_eq!(report.rule, "override");
assert!(!report.has_problems(), "{report:?}");
}
#[test]
fn open_creates_the_folder() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("nested").join(Prefs::APP_ID);
let (store, report) = SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Dir(dir.clone())).probe(empty_probe()));
assert!(dir.is_dir());
assert!(!store.is_read_only(), "{report:?}");
}
#[cfg(unix)]
#[test]
fn unwritable_folder_is_reported_at_open_not_first_save() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("ro");
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let (mut store, report) = open_in(&dir);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(store.is_read_only());
assert!(matches!(report.read_only, Some(craft_settings::ReadOnlyReason::DirNotWritable(_))), "{report:?}");
assert!(matches!(store.flush(), Err(craft_settings::SettingsError::ReadOnly(_))));
}
+180
View File
@@ -0,0 +1,180 @@
mod common;
use common::{Theme, config_path, open_in, read_config, write_config};
use craft_settings::{ReadOnlyReason, SettingsError, Source};
#[test]
fn syntax_error_moves_the_file_aside_and_uses_defaults() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 2\n[general\nlanguage = \"de\"\n");
let (mut store, report) = open_in(tmp.path());
assert_eq!(report.source, Source::Defaults);
assert!(!store.is_read_only());
let moved = report.broken_moved_to.clone().expect("moved aside");
assert!(moved.file_name().unwrap().to_str().unwrap().starts_with("config.broken-"), "{moved:?}");
assert!(moved.exists());
assert!(!config_path(tmp.path()).exists());
assert_eq!(store.get().general.language, "en");
store.flush().unwrap();
assert!(moved.exists(), "the damaged copy is never deleted");
assert!(read_config(tmp.path()).starts_with("schema_version = 2\n"));
}
#[test]
fn two_broken_files_get_different_names() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "not = [toml");
let (_, first) = open_in(tmp.path());
write_config(tmp.path(), "still = [broken");
let (_, second) = open_in(tmp.path());
assert_ne!(first.broken_moved_to, second.broken_moved_to);
assert!(second.broken_moved_to.unwrap().exists());
}
#[test]
fn bad_value_only_resets_its_section() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 2\n[general]\nlanguage = \"de\"\n\n[appearance]\ntheme = \"sepia\"\nui_scale = 2.0\n");
let (store, report) = open_in(tmp.path());
assert_eq!(report.source, Source::File);
assert!(report.broken_moved_to.is_none());
assert!(!store.is_read_only());
assert_eq!(report.section_errors.len(), 1, "{report:?}");
assert_eq!(report.section_errors[0].section, "appearance");
assert_eq!(store.get().general.language, "de", "the good section survives");
assert_eq!(store.get().appearance.theme, Theme::Light, "the bad section falls back to defaults");
assert!(config_path(tmp.path()).exists(), "the file is not moved aside");
}
#[test]
fn newer_schema_opens_read_only_and_never_overwrites() {
let tmp = tempfile::tempdir().unwrap();
let original = "schema_version = 9\n[general]\nlanguage = \"de\"\n";
write_config(tmp.path(), original);
let (mut store, report) = open_in(tmp.path());
assert!(matches!(report.read_only, Some(ReadOnlyReason::NewerSchema { file: 9, app: 2 })), "{report:?}");
assert_eq!(store.get().general.language, "de", "values are still shown");
store.update(|p| p.general.language = "fr".into());
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert_eq!(read_config(tmp.path()), original);
}
#[test]
fn failed_migration_opens_read_only_and_leaves_the_file() {
let tmp = tempfile::tempdir().unwrap();
let original = "schema_version = 0\n[general]\nlanguage = \"de\"\n";
write_config(tmp.path(), original);
let (mut store, report) = open_in(tmp.path());
assert!(matches!(report.read_only, Some(ReadOnlyReason::Migration(_))), "{report:?}");
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert_eq!(read_config(tmp.path()), original);
}
#[cfg(unix)]
#[test]
fn damaged_file_that_cannot_be_moved_is_left_alone() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "not = [toml");
// The folder passes the write probe only while it is writable, so open first, then lock it.
let dir = tmp.path();
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let (mut store, report) = open_in(dir);
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(store.is_read_only(), "{report:?}");
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert_eq!(read_config(dir), "not = [toml");
}
// --- Review on #22: the backup must never be replaced by a damaged or emptied file. ---
#[test]
fn file_damaged_while_running_is_moved_aside_and_backup_kept() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
store.update(|p| p.general.language = "es".into());
store.flush().unwrap();
let good_backup = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
assert!(good_backup.contains("language = \"fr\""));
// A hand edit with a typo while the app is running.
write_config(tmp.path(), "schema_version = 2\n[general\nlanguage = \"it\"\n");
store.update(|p| p.general.language = "pt".into());
store.flush().unwrap();
assert_eq!(std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap(), good_backup, "backup untouched");
let moved = store.take_moved_aside();
assert_eq!(moved.len(), 1);
assert_eq!(std::fs::read_to_string(&moved[0]).unwrap(), "schema_version = 2\n[general\nlanguage = \"it\"\n", "the user's edit is kept");
assert!(read_config(tmp.path()).contains("language = \"pt\""));
assert!(store.take_moved_aside().is_empty(), "drained");
}
#[test]
fn emptied_file_does_not_replace_the_backup() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
store.update(|p| p.general.language = "es".into());
store.flush().unwrap();
let good_backup = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
write_config(tmp.path(), "");
store.update(|p| p.general.language = "pt".into());
store.flush().unwrap();
assert_eq!(std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap(), good_backup);
assert!(read_config(tmp.path()).contains("language = \"pt\""));
}
#[test]
fn newer_build_writing_while_running_is_not_overwritten() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
let newer = "schema_version = 9\n[general]\nlanguage = \"de\"\n";
write_config(tmp.path(), newer);
store.update(|p| p.general.language = "fr".into());
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert!(store.is_read_only());
assert_eq!(read_config(tmp.path()), newer);
}
// --- Review on #22: a failed migration must not leave half-migrated values in memory. ---
#[cfg(unix)]
#[test]
fn failed_migration_in_an_unwritable_folder_shows_defaults() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
// Version 0 has no migration step, so upgrading fails.
write_config(tmp.path(), "schema_version = 0\n[general]\nlanguage = \"de\"\n");
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let (store, report) = open_in(tmp.path());
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(matches!(report.read_only, Some(ReadOnlyReason::DirNotWritable(_))), "{report:?}");
assert_eq!(store.get().general.language, "en", "defaults, not half-migrated values");
assert_eq!(report.source, Source::Defaults);
}
// --- Review on #22 (skylartaylor): an outside file whose migration fails must not be overwritten. ---
#[test]
fn outside_file_with_failing_migration_is_not_overwritten() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
// Another writer leaves a file at a version this build cannot migrate (0 has no step).
let external = "schema_version = 0\n[general]\nlanguage = 'external'\n";
write_config(tmp.path(), external);
store.update(|p| p.general.language = "local".into());
let result = store.flush();
assert!(matches!(result, Err(SettingsError::ReadOnly(_))), "{result:?}");
assert!(store.is_read_only());
assert_eq!(read_config(tmp.path()), external, "the outside file is left untouched");
assert!(matches!(store.tick(std::time::Instant::now()).status, craft_settings::SaveStatus::Idle), "no retries once read-only");
}
+284
View File
@@ -0,0 +1,284 @@
mod common;
use std::time::{Duration, Instant};
use common::{Theme, config_path, open_in, read_config, write_config};
use craft_settings::{SaveStatus, Source};
#[test]
fn first_run_gives_defaults_and_writes_the_file_on_flush() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, report) = open_in(tmp.path());
assert_eq!(report.source, Source::Defaults);
assert_eq!(store.get().general.language, "en");
assert!(!config_path(tmp.path()).exists());
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.starts_with("schema_version = 2\n"), "{text}");
assert!(text.contains("[general]"), "{text}");
assert!(text.contains("[appearance]"), "{text}");
}
#[test]
fn round_trip() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
assert!(store.update(|p| {
p.appearance.theme = Theme::Dark;
p.general.recent = vec!["a.craft".into(), "b.craft".into()];
}));
store.flush().unwrap();
let (store, report) = open_in(tmp.path());
assert_eq!(report.source, Source::File);
assert_eq!(store.get().appearance.theme, Theme::Dark);
assert_eq!(store.get().general.recent, vec!["a.craft", "b.craft"]);
}
#[test]
fn comments_unknown_keys_and_order_survive_a_save() {
let tmp = tempfile::tempdir().unwrap();
write_config(
tmp.path(),
"schema_version = 2\n\n# my notes\n[appearance]\nui_scale = 1.5 # big\nfuture_key = \"kept\"\n\n[general]\nlanguage = \"de\"\n\n[plugins]\nenabled = true\n",
);
let (mut store, report) = open_in(tmp.path());
assert!(report.section_errors.is_empty(), "{report:?}");
assert_eq!(store.get().general.language, "de");
store.update(|p| p.appearance.theme = Theme::Dark);
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.contains("# my notes"), "{text}");
assert!(text.contains("ui_scale = 1.5 # big"), "{text}");
assert!(text.contains("future_key = \"kept\""), "{text}");
assert!(text.contains("[plugins]\nenabled = true"), "{text}");
assert!(text.contains("theme = \"dark\""), "{text}");
assert!(text.find("[appearance]").unwrap() < text.find("[general]").unwrap(), "section order changed:\n{text}");
}
#[test]
fn arrays_are_replaced_whole() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 2\n[general]\nrecent = [\"old1\", \"old2\", \"old3\"]\n");
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.recent = vec!["new".into()]);
store.flush().unwrap();
assert!(read_config(tmp.path()).contains("recent = [\"new\"]"));
}
#[test]
fn update_with_no_change_is_not_dirty() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
assert!(!store.update(|p| p.general.autosave_minutes = 5));
assert!(!store.is_dirty());
assert!(store.update(|p| p.general.autosave_minutes = 6));
assert!(store.is_dirty());
}
#[test]
fn tick_waits_for_the_debounce_and_reports_the_deadline() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
let t0 = Instant::now();
assert!(matches!(store.tick(t0).status, SaveStatus::Idle));
store.update_at(t0, |p| p.appearance.theme = Theme::Dark);
let r = store.tick(t0);
assert!(matches!(r.status, SaveStatus::Pending));
assert_eq!(r.next_deadline, Some(t0 + Duration::from_millis(100)));
assert!(!config_path(tmp.path()).exists());
let r = store.tick(t0 + Duration::from_millis(100));
assert!(matches!(r.status, SaveStatus::Saved), "{r:?}");
assert_eq!(r.next_deadline, None);
assert!(read_config(tmp.path()).contains("theme = \"dark\""));
assert!(matches!(store.tick(t0 + Duration::from_millis(200)).status, SaveStatus::Idle));
}
#[test]
fn further_changes_push_the_save_back() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
let t0 = Instant::now();
store.update_at(t0, |p| p.general.autosave_minutes = 1);
store.update_at(t0 + Duration::from_millis(80), |p| p.general.autosave_minutes = 2);
let r = store.tick(t0 + Duration::from_millis(100));
assert!(matches!(r.status, SaveStatus::Pending));
assert_eq!(r.next_deadline, Some(t0 + Duration::from_millis(180)));
}
#[test]
fn failed_save_is_retried_with_backoff() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
// A folder where the file should be makes the write fail.
std::fs::create_dir(config_path(tmp.path())).unwrap();
let t0 = Instant::now();
store.update_at(t0, |p| p.appearance.theme = Theme::Dark);
let r = store.tick(t0 + Duration::from_millis(100));
assert!(matches!(r.status, SaveStatus::Failed(_)), "{r:?}");
let first_retry = t0 + Duration::from_millis(100) + Duration::from_secs(1);
assert_eq!(r.next_deadline, Some(first_retry));
assert!(matches!(store.tick(first_retry - Duration::from_millis(1)).status, SaveStatus::Pending));
let r = store.tick(first_retry);
assert!(matches!(r.status, SaveStatus::Failed(_)));
assert_eq!(r.next_deadline, Some(first_retry + Duration::from_secs(2)));
std::fs::remove_dir(config_path(tmp.path())).unwrap();
let r = store.tick(first_retry + Duration::from_secs(2));
assert!(matches!(r.status, SaveStatus::Saved), "{r:?}");
assert!(read_config(tmp.path()).contains("theme = \"dark\""));
}
#[test]
fn retry_delay_doubles_and_caps() {
use craft_settings::store::retry_delay;
assert_eq!(retry_delay(1), Duration::from_secs(1));
assert_eq!(retry_delay(2), Duration::from_secs(2));
assert_eq!(retry_delay(3), Duration::from_secs(4));
assert_eq!(retry_delay(6), Duration::from_secs(30));
assert_eq!(retry_delay(40), Duration::from_secs(30));
}
#[test]
fn backup_holds_the_previous_version() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
store.update(|p| p.general.language = "es".into());
store.flush().unwrap();
let bak = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
assert!(bak.contains("language = \"fr\""), "{bak}");
assert!(read_config(tmp.path()).contains("language = \"es\""));
}
#[test]
fn outside_edits_between_load_and_save_are_kept() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
store.update(|p| p.general.language = "fr".into());
// Another instance (or the user) edits the file meanwhile.
std::thread::sleep(Duration::from_millis(20));
let edited = read_config(tmp.path()).replace("[general]", "# added by hand\n[other]\nx = 1\n\n[general]");
write_config(tmp.path(), &edited);
let later = std::time::SystemTime::now() + Duration::from_secs(2);
std::fs::File::options().write(true).open(config_path(tmp.path())).unwrap().set_modified(later).unwrap();
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.contains("# added by hand"), "{text}");
assert!(text.contains("[other]\nx = 1"), "{text}");
assert!(text.contains("language = \"fr\""), "{text}");
}
#[cfg(unix)]
#[test]
fn new_file_is_owner_only_and_keeps_its_mode_on_replace() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
let mode = std::fs::metadata(config_path(tmp.path())).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
std::fs::set_permissions(config_path(tmp.path()), std::fs::Permissions::from_mode(0o644)).unwrap();
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
let mode = std::fs::metadata(config_path(tmp.path())).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o644, "a user's deliberate mode change is kept");
}
#[cfg(unix)]
#[test]
fn read_only_file_makes_the_save_fail_visibly() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
std::fs::set_permissions(config_path(tmp.path()), std::fs::Permissions::from_mode(0o400)).unwrap();
store.update(|p| p.general.language = "fr".into());
let r = store.tick(Instant::now() + Duration::from_secs(1));
assert!(matches!(r.status, SaveStatus::Failed(craft_settings::SettingsError::Io(_))), "{r:?}");
assert!(!read_config(tmp.path()).contains("language = \"fr\""));
}
#[test]
fn outside_edit_with_the_same_mtime_is_still_seen() {
// Coarse timestamps (FAT, HFS+, network shares) can leave mtime unchanged after an edit.
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
let mtime = std::fs::metadata(config_path(tmp.path())).unwrap().modified().unwrap();
let edited = read_config(tmp.path()).replace("[general]", "# same second\n[general]");
write_config(tmp.path(), &edited);
std::fs::File::options().write(true).open(config_path(tmp.path())).unwrap().set_modified(mtime).unwrap();
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.contains("# same second"), "{text}");
assert!(text.contains("language = \"fr\""), "{text}");
}
// --- Review on #22 (skylartaylor): the backup must never be more readable than config.toml. ---
#[cfg(unix)]
fn mode(path: &std::path::Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[cfg(unix)]
#[test]
fn new_backup_is_as_private_as_the_config() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
store.update(|p| p.general.language = "private".into());
store.flush().unwrap();
assert_eq!(mode(&config_path(tmp.path())), 0o600);
assert_eq!(mode(&tmp.path().join("config.toml.bak")), 0o600);
}
#[cfg(unix)]
#[test]
fn existing_broader_backup_is_tightened() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
// A backup left behind by an older build, readable by everyone.
let bak = tmp.path().join("config.toml.bak");
std::fs::write(&bak, b"old backup").unwrap();
std::fs::set_permissions(&bak, std::fs::Permissions::from_mode(0o664)).unwrap();
store.update(|p| p.general.language = "private".into());
store.flush().unwrap();
assert_eq!(mode(&bak), mode(&config_path(tmp.path())), "backup must not be more readable than config.toml");
assert_eq!(mode(&bak), 0o600);
}
#[cfg(unix)]
#[test]
fn backup_follows_a_mode_the_user_chose_for_the_config() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
std::fs::set_permissions(config_path(tmp.path()), std::fs::Permissions::from_mode(0o640)).unwrap();
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
assert_eq!(mode(&tmp.path().join("config.toml.bak")), 0o640);
}
+21
View File
@@ -4,6 +4,7 @@
embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles tab fitting and
interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.
`docking` supplies transactional panel moves and an optional docking surface.
`printing` supplies printer/job controls and asynchronous feedback used by WordCraft and GridCraft.
The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoCraft, FilmCraft, EffectCraft, CADCraft, PDFCraft, LightCraft and SoundCraft); prior basic and office adapters are separate. The [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)
records their scope and acceptance limits. Assets, complete themes, localization and command handling
remain in each application. No icons, fonts or application engines are bundled.
@@ -211,6 +212,26 @@ selection colors, shadows, spacing, context storage and preference serialization
application. See the [eight-app comparison](../../docs/theme-infrastructure.md) for the exact
common operation and the differences preserved by the adapters.
## Print settings and feedback
`printing::State` holds the transient printer selection, copies, duplex and grayscale settings,
plus discovery and submission receivers. Hosts supply `Discover` and `Submit` workers; neither
the state nor the controls invokes an OS spooler or generates PDF content. Apps map `Settings`
to `craft-print::Job` and own the Print action, page selection, preview and export behavior.
`State::show` takes a stable `egui::Id` and app-localized `Labels`. It draws the settings only;
apps render typed `Feedback` using their own translations. `poll` requests occasional repaints
while work is pending. Refresh disables submission until the new printer list arrives, preserves
a still-present selection, and otherwise chooses the system default or first available printer.
An empty or failed discovery cannot leave a stale printer ready to submit.
`ready` is false while a job is pending. `begin` refuses replacing an active job receiver;
dropping a receiver does not cancel a native job. Apps must test readiness before invoking the
host submit callback and keep active work alive when closing a window. Queue-default duplex is
represented by `None`, independently of explicit one-sided, long-edge or short-edge choices.
Receipts and backend errors remain unmodified data for the app to display. These controls work
on WASM too; browser PDF printing remains an app-owned service.
## Compatibility and checks
The crate uses egui 0.36 and Rust 1.95 or later. Its production dependency disables egui's default
+1
View File
@@ -9,5 +9,6 @@ pub mod buttons;
pub mod docking;
pub mod icons;
pub mod layout;
pub mod printing;
pub mod tabs;
pub mod theme;
+208
View File
@@ -0,0 +1,208 @@
//! Printer selection and asynchronous job feedback, independent of any spooler or document.
//! Hosts provide workers; applications own PDF generation, commands and translated wording.
use std::sync::mpsc::{Receiver, TryRecvError};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Printer {
pub name: String,
pub default: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Duplex {
Off,
LongEdge,
ShortEdge,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Settings {
pub printer: String,
pub copies: u32,
/// None preserves the printer's default instead of forcing simplex.
pub duplex: Option<Duplex>,
pub grayscale: bool,
}
impl Default for Settings {
fn default() -> Self {
Self { printer: String::new(), copies: 1, duplex: None, grayscale: false }
}
}
pub type Discover = Box<dyn Fn() -> Receiver<Vec<Printer>>>;
pub type Submission = Receiver<Result<String, String>>;
pub type Submit = Box<dyn Fn(Vec<u8>, Settings) -> Submission>;
/// Apps format these states in their own language. Receipts and errors are backend text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Feedback {
FindingPrinters,
NoPrinters,
DiscoveryFailed,
Submitting,
Submitted(String),
Failed(String),
WorkerStopped,
}
/// Translated labels supplied by the app; identity never depends on their text.
pub struct Labels<'a> {
pub printer: &'a str,
pub select_printer: &'a str,
pub refresh: &'a str,
pub copies: &'a str,
pub printer_default: &'a str,
pub duplex: &'a str,
pub one_sided: &'a str,
pub long_edge: &'a str,
pub short_edge: &'a str,
pub grayscale: &'a str,
}
#[derive(Default)]
pub struct State {
pub settings: Settings,
pub printers: Vec<Printer>,
pub message: Option<Feedback>,
discovery: Option<Receiver<Vec<Printer>>>,
submission: Option<Submission>,
loaded: bool,
}
impl State {
pub fn busy(&self) -> bool {
self.submission.is_some()
}
pub fn discovering(&self) -> bool {
self.discovery.is_some()
}
/// A refresh invalidates the old list until discovery completes: a removed queue must
/// not remain selectable while the host is checking it.
pub fn ready(&self) -> bool {
!self.busy() && !self.discovering() && self.printers.iter().any(|p| p.name == self.settings.printer)
}
/// Refuse replacing an active job's receiver. The caller still owns the refused receiver;
/// dropping a receiver cannot cancel a job already handed to a native worker.
pub fn begin(&mut self, receiver: Submission) -> Result<(), Submission> {
if self.busy() {
return Err(receiver);
}
self.submission = Some(receiver);
self.message = Some(Feedback::Submitting);
Ok(())
}
/// False means another discovery or job is active; no worker was started.
pub fn refresh(&mut self, discover: &Discover) -> bool {
if self.discovering() || self.busy() {
return false;
}
self.discovery = Some(discover());
self.loaded = true;
self.message = Some(Feedback::FindingPrinters);
true
}
pub fn poll(&mut self, ctx: &egui::Context) {
if let Some(receiver) = &self.discovery {
match receiver.try_recv() {
Ok(printers) => {
self.printers = printers;
if !self.printers.iter().any(|p| p.name == self.settings.printer) {
self.settings.printer =
self.printers.iter().find(|p| p.default).or(self.printers.first()).map(|p| p.name.clone()).unwrap_or_default();
}
self.message = self.printers.is_empty().then_some(Feedback::NoPrinters);
self.discovery = None;
}
Err(TryRecvError::Disconnected) => {
self.printers.clear();
self.message = Some(Feedback::DiscoveryFailed);
self.discovery = None;
}
Err(TryRecvError::Empty) => {}
}
}
if let Some(receiver) = &self.submission {
match receiver.try_recv() {
Ok(result) => {
self.message = Some(match result {
Ok(receipt) => Feedback::Submitted(receipt),
Err(error) => Feedback::Failed(error),
});
self.submission = None;
}
Err(TryRecvError::Disconnected) => {
self.message = Some(Feedback::WorkerStopped);
self.submission = None;
}
Err(TryRecvError::Empty) => {}
}
}
if self.discovering() || self.busy() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
/// Draw just the controls. Apps render feedback, their preview and the submit/export
/// buttons around this component, using their own command and document state.
pub fn show(&mut self, ui: &mut egui::Ui, id: egui::Id, discover: &Discover, labels: &Labels<'_>) {
if !self.loaded {
self.refresh(discover);
}
self.poll(ui.ctx());
ui.push_id(id, |ui| {
ui.add_enabled_ui(!self.busy(), |ui| {
let width = ui.available_width().clamp(32.0, 230.0);
let printer_label = ui.label(labels.printer);
egui::ComboBox::from_id_salt("printer")
.width(width)
.wrap_mode(egui::TextWrapMode::Truncate)
.selected_text(if self.settings.printer.is_empty() { labels.select_printer } else { &self.settings.printer })
.show_ui(ui, |ui| {
for printer in &self.printers {
ui.selectable_value(&mut self.settings.printer, printer.name.clone(), &printer.name);
}
})
.response
.labelled_by(printer_label.id);
if ui.add_enabled(!self.discovering(), egui::Button::new(labels.refresh)).clicked() {
self.refresh(discover);
}
ui.horizontal(|ui| {
let label = ui.label(labels.copies);
ui.add(egui::DragValue::new(&mut self.settings.copies).range(1..=999)).labelled_by(label.id);
});
let choices = [
(None, labels.printer_default),
(Some(Duplex::Off), labels.one_sided),
(Some(Duplex::LongEdge), labels.long_edge),
(Some(Duplex::ShortEdge), labels.short_edge),
];
let duplex_label = ui.label(labels.duplex);
egui::ComboBox::from_id_salt("duplex")
.width(width)
.wrap_mode(egui::TextWrapMode::Truncate)
.selected_text(
choices.iter().find(|(value, _)| *value == self.settings.duplex).map(|(_, text)| *text).unwrap_or(labels.printer_default),
)
.show_ui(ui, |ui| {
for (value, text) in choices {
ui.selectable_value(&mut self.settings.duplex, value, text);
}
})
.response
.labelled_by(duplex_label.id);
ui.checkbox(&mut self.settings.grayscale, labels.grayscale);
});
});
}
}
#[cfg(test)]
mod tests;
+161
View File
@@ -0,0 +1,161 @@
use super::*;
fn queue(name: &str, default: bool) -> Printer {
Printer { name: name.into(), default }
}
#[test]
fn discovery_preserves_choice_and_blocks_stale_or_removed_queues() {
let ctx = egui::Context::default();
let mut state = State::default();
state.settings.printer = "Chosen".into();
let discover: Discover = Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(vec![queue("Default", true), queue("Chosen", false)]).unwrap();
receiver
});
assert!(state.refresh(&discover));
assert!(!state.ready());
state.poll(&ctx);
assert_eq!(state.settings.printer, "Chosen");
assert!(state.ready());
let empty: Discover = Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Vec::new()).unwrap();
receiver
});
assert!(state.refresh(&empty));
assert!(!state.ready());
state.poll(&ctx);
assert!(!state.ready());
assert_eq!(state.message, Some(Feedback::NoPrinters));
assert!(state.settings.printer.is_empty());
assert!(state.refresh(&discover));
state.poll(&ctx);
assert_eq!(state.settings.printer, "Default");
}
#[test]
fn failed_discovery_cannot_reuse_a_previously_available_queue() {
let mut state = State::default();
state.printers.push(queue("Old", true));
state.settings.printer = "Old".into();
let discover: Discover = Box::new(|| std::sync::mpsc::channel().1);
assert!(state.refresh(&discover));
state.poll(&egui::Context::default());
assert_eq!(state.message, Some(Feedback::DiscoveryFailed));
assert!(!state.ready());
}
#[test]
fn active_jobs_keep_their_receiver_and_report_receipt_refusal_or_worker_failure() {
let ctx = egui::Context::default();
let mut state = State::default();
state.printers.push(queue("Queue", true));
state.settings.printer = "Queue".into();
let (sender, receiver) = std::sync::mpsc::channel();
assert!(state.begin(receiver).is_ok());
assert!(!state.ready());
let (other_sender, other_receiver) = std::sync::mpsc::channel();
assert!(state.begin(other_receiver).is_err());
drop(other_sender);
state.poll(&ctx);
assert!(state.busy());
sender.send(Ok("Queue-42".into())).unwrap();
state.poll(&ctx);
assert_eq!(state.message, Some(Feedback::Submitted("Queue-42".into())));
assert!(state.ready());
let (sender, receiver) = std::sync::mpsc::channel();
assert!(state.begin(receiver).is_ok());
sender.send(Err("queue refused the job".into())).unwrap();
state.poll(&ctx);
assert_eq!(state.message, Some(Feedback::Failed("queue refused the job".into())));
let (sender, receiver) = std::sync::mpsc::channel();
assert!(state.begin(receiver).is_ok());
drop(sender);
state.poll(&ctx);
assert_eq!(state.message, Some(Feedback::WorkerStopped));
assert!(state.ready());
}
#[test]
fn discovery_and_controls_do_not_submit_a_job() {
let mut state = State::default();
let discover: Discover = Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(vec![queue("Office", true)]).unwrap();
receiver
});
let labels = Labels {
printer: "Drucker",
select_printer: "Wählen",
refresh: "Aktualisieren",
copies: "Kopien",
printer_default: "Standard",
duplex: "Seiten",
one_sided: "Einseitig",
long_edge: "Lange Kante",
short_edge: "Kurze Kante",
grayscale: "Graustufen",
};
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| state.show(ui, egui::Id::new("print"), &discover, &labels));
output.textures_delta.clear();
assert_eq!(state.settings.printer, "Office");
assert!(state.ready());
assert!(!state.busy());
assert_eq!(state.settings.copies, 1);
assert_eq!(state.settings.duplex, None);
assert!(!state.settings.grayscale);
}
#[test]
fn localized_controls_fit_narrow_ui_and_disable_while_submitting() {
let ctx = egui::Context::default();
ctx.enable_accesskit();
let mut state = State::default();
let discover: Discover = Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(vec![queue("Office printer with a very long destination name", true)]).unwrap();
receiver
});
let labels = Labels {
printer: "Drucker",
select_printer: "Wählen",
refresh: "Aktualisieren",
copies: "Kopien",
printer_default: "Standard",
duplex: "Seiten",
one_sided: "Einseitig",
long_edge: "Lange Kante",
short_edge: "Kurze Kante",
grayscale: "Graustufen",
};
let frame = |state: &mut State, events| {
let mut output = ctx.run_ui(
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(220.0, 360.0))), events, ..Default::default() },
|ui| state.show(ui, egui::Id::new("print"), &discover, &labels),
);
output.textures_delta.clear();
output
};
let output = frame(&mut state, Vec::new());
let tree = output.platform_output.accesskit_update.unwrap();
let checkbox = tree.nodes.iter().find(|(_, node)| node.label() == Some("Graustufen")).unwrap().1.clone();
assert!(!checkbox.is_disabled());
let bounds = checkbox.bounds().unwrap();
assert!(bounds.x0 >= 0.0 && bounds.x1 <= 220.0 && bounds.y1 <= 360.0, "control stays reachable in a narrow pane");
let center = egui::pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32);
let pointer =
|pressed| egui::Event::PointerButton { pos: center, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
frame(&mut state, vec![egui::Event::PointerMoved(center), pointer(true)]);
frame(&mut state, vec![pointer(false)]);
assert!(state.settings.grayscale, "the localized checkbox changes the job setting");
let (_sender, receiver) = std::sync::mpsc::channel();
assert!(state.begin(receiver).is_ok());
let output = frame(&mut state, Vec::new());
assert!(output.platform_output.accesskit_update.unwrap().nodes.iter().any(|(_, node)| node.label() == Some("Graustufen") && node.is_disabled()));
frame(&mut state, vec![egui::Event::PointerMoved(center), pointer(true)]);
frame(&mut state, vec![pointer(false)]);
assert!(state.settings.grayscale, "pending work disables editing its settings");
}
+102
View File
@@ -0,0 +1,102 @@
# Settings storage across the Craft apps
How every Craft app stores its settings, and what goes where. The shared code is
[`craft-settings`](../crates/settings/README.md); this page is the policy it implements.
Discussion: [storytold/craft-libs#12](https://github.com/storytold/craft-libs/issues/12).
## What goes where
| Kind | Where | Why |
|---|---|---|
| Settings people might edit | `config.toml` in the config folder | One readable file with comments, atomic saves, backup, recovery, migrations |
| Window and UI state | `ui.ron` / `app.ron` beside `config.toml` | Changes every session; not worth comments or hand edits |
| Caches and models | Platform cache folder (`paths::cache_dir`); `%LOCALAPPDATA%` on Windows so it does not roam | Disposable, large |
| Recovery and autosave files | Platform data folder (`paths::data_dir`) | Not settings; must survive a settings reset |
| Settings that belong to a library or project (LightCraft) | Inside that folder, via `SettingsStore::open_at` | Travels with the library |
| API keys and tokens | The OS keychain | Never in a plain-text file |
## The config folder
First match wins. The store reports which rule applied, so apps can show the path in their
about or settings screen.
1. An override passed by the app, such as a `--config-dir` flag
2. `<APP>_CONFIG_DIR`, for example `PHOTOCRAFT_CONFIG_DIR` (one per app, so redirecting one
app does not move the others). Set but **empty** means isolated: nothing is read or written
anywhere, the same as DesignCraft's existing behaviour, so tests and agents can never touch a
user's real settings
3. Portable mode: a marker file next to the executable (next to the `.app` on macOS, since
writing inside a signed bundle breaks its signature). The default is a file named `portable`
with settings in `config/` beside it; apps that already ship portable builds keep their own
names (PhotoCraft: `portable.txt` or `PhotoCraft.portable`, folder `PhotoCraftData`)
4. The platform default:
Apps can also open a store with `Location::Memory` (for a switch like `DESIGNCRAFT_NO_PREFS`):
settings work in memory and nothing touches the disk.
| Platform | Folder |
|---|---|
| Linux / BSD | `$XDG_CONFIG_HOME/<app>/`, or `~/.config/<app>/` if unset |
| macOS | `~/Library/Application Support/<app>/` |
| Windows | `%APPDATA%\<app>\` (Roaming) |
`<app>` is the app's lowercase id (`photocraft`, `designcraft`, …) on every platform, macOS
included (decided in #12). Existing Linux folder names do not change; the Launcher moves from
`ai.storyteller.artcraft-launcher` to `artcraft-launcher`.
If the folder cannot be created or written (a portable marker in `/Applications`, an AppImage
mount), the store opens read-only and says so at startup.
## Files in the folder
- `config.toml`: starts with `schema_version = N`, then one `[section]` per settings area.
Written on first launch so people can find it. `0600` on Unix.
- `config.toml.bak`: the previous version, refreshed before every save.
- `config.broken-<unix-ts>.toml`: a file that was not valid TOML, moved aside and never deleted.
## Saving and recovery
- Saves happen during use, half a second after the last change, atomically through
`craft-atomic`. A failed save leaves the old file intact and is retried with backoff; the app
shows the error.
- A file with a TOML syntax error is moved aside and defaults are used. A file that parses but
has a value that no longer fits (a renamed theme, a typo in a hand edit) is not treated as
broken: only that `[section]` falls back to defaults and the app is told which one.
- A file written by a newer build is shown but never overwritten.
- Comments, key order and keys the app does not know survive saves. If two instances of an app
are open, each re-reads the file before writing so those survive; known keys are
last-writer-wins.
## Migration
- Schema changes: `Settings::migrate` upgrades the document one version at a time before it is
parsed. A failed step leaves the file untouched and the store read-only.
- Older per-app files (`prefs.json`, `ui.json`, `*.ron`): imported on the first run that finds no
`config.toml`, using the app's existing parser. The old file is **left in place**, so rolling
back to an older build keeps working; apps can remove it a few releases later.
## Where the apps are today
From the audit in #12 (Linux defaults, from source; overrides and portable mode change them).
Before an app moves over, check where it saves on macOS and Windows so the importer finds the old
files there too.
| App | Today | Moving to `craft-settings` |
|---|---|---|
| PhotoCraft | `~/.config/photocraft/preferences.json` + eframe `ui.ron`; atomic saves with retries; portable via `portable.txt`/`PhotoCraft.portable` → `PhotoCraftData/` | Import `preferences.json`; keep `ui.ron`; keep its portable names via `PORTABLE_MARKERS`/`PORTABLE_DIR` |
| DesignCraft | `~/.config/designcraft/prefs.json` + `ui.json`; saves on exit, ignores failures; empty `DESIGNCRAFT_CONFIG_DIR` and `DESIGNCRAFT_NO_PREFS` touch nothing | Import `prefs.json`; keep `ui.json`; gain save-during-use and error reporting; `DESIGNCRAFT_NO_PREFS` → `Location::Memory` |
| EffectCraft | `~/.config/effectcraft/prefs.json`, `shortcuts.json`, `script_settings.json`; atomic | Import all three into sections of one `config.toml` |
| FilmCraft | `~/.local/share/filmcraft/preferences.json`; atomic | Import from the data folder; settings move to the config folder |
| GridCraft | `~/.config/gridcraft/prefs.json` + `ui.json`; saves on exit, ignores failures | Same as DesignCraft |
| LightCraft | global `ui.json`; library `prefs.json`, `presets.json`, `view.json`; atomic, backups, overwrite protection | Global settings via `open`; library settings via `open_at(library_dir)` |
| VectorCraft | `~/.config/vectorcraft/ui.json` with engine preferences inside; atomic, ignores failures | Split engine preferences out into `config.toml`; keep UI state in `ui.json` |
| ArtCraft Launcher | `~/.local/share/ai.storyteller.artcraft-launcher/app.ron` (eframe) | Import from `app.ron`; needs its own tick timer while in the tray |
Adoption order: PhotoCraft and DesignCraft first (closest to and furthest from the target
behaviour), then the rest.
## Not yet
- Reloading settings when the file changes on disk while the app runs
- Removing imported legacy files
- Settings on the web (`wasm32`), where only the trait, document helpers and error types exist