Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c470aa935 | |||
| 7fcee66b8e | |||
| 3e6869e88c | |||
| f03ec27ab2 | |||
| eb49a247a3 | |||
| 79e89dc129 | |||
| 6006abb757 | |||
| 697b473c0b | |||
| c22935270b | |||
| 85ef8b92ac | |||
| 02d08fe9cc | |||
| a7e3c76c2f | |||
| 36ce57c929 | |||
| f58bc1a3df | |||
| 811558a3ad | |||
| 5b273dd82f | |||
| d4a38f90ee | |||
| 1c9baae1bf | |||
| f75e9c9bc8 | |||
| 6bcd350d49 | |||
| 33b393bd88 | |||
| 9d422a18b1 | |||
| 253fd775f0 | |||
| d78b352004 | |||
| 658ca37c8e | |||
| fb26a9f90f | |||
| 9e160690fc | |||
| 818c0c0753 | |||
| 8df3d87d8b | |||
| 4a4ec8c1ca | |||
| c68d145c9f | |||
| 8e9e437e78 | |||
| f0ae2b3f13 | |||
| 673c14439b | |||
| ebf3f552cd | |||
| f207cdd016 | |||
| bafeec6b7d | |||
| c6a847b7d0 | |||
| 3c9702725d | |||
| 754f4d70e0 | |||
| 5d41e29e46 | |||
| 4cae45dbe8 | |||
| d27646a5f7 | |||
| b700ae753f | |||
| 9d6610afd1 | |||
| 0549f672a3 |
@@ -0,0 +1,5 @@
|
||||
# Strip upstream (ArtCraft / Discord) branding. Run: tools/craft-skills/skills/debrand/debrand.pl (--check in CI)
|
||||
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
|
||||
sub ; ArtCraft trademarks remain outside such a library\. => ; trademarks remain outside such a library.
|
||||
forbid (?i)artcraft(?! Team and the [\w-]+ contributors)
|
||||
forbid (?i)discord
|
||||
@@ -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
|
||||
@@ -4,3 +4,4 @@ plans/
|
||||
/target
|
||||
.DS_Store
|
||||
/.claude/
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "tools/craft-skills"]
|
||||
path = tools/craft-skills
|
||||
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
|
||||
+3
-1
@@ -4,4 +4,6 @@ Third-party assets bundled in this repository (fonts, icons, images, test data),
|
||||
author, source and license. Library code and original assets are under the repository license
|
||||
(MIT OR Apache-2.0, see [NOTICE](NOTICE)).
|
||||
|
||||
None yet.
|
||||
| Path | Title | Author | Source | License |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `crates/ui/src/theme/catppuccin.rs` | Catppuccin Latte, Frappé, Macchiato and Mocha palettes | Catppuccin | https://catppuccin.com/palette | MIT (`crates/ui/LICENSE-catppuccin.txt`) |
|
||||
|
||||
Generated
+1794
-11
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(_))));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
Catppuccin
|
||||
https://catppuccin.com/palette
|
||||
https://github.com/catppuccin/catppuccin
|
||||
|
||||
The Catppuccin Latte, Frappé, Macchiato and Mocha themes' colour palettes in `catppuccin.rs`
|
||||
are the Catppuccin project's palettes, used under the MIT License below.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Catppuccin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -4,6 +4,9 @@
|
||||
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.
|
||||
`material` paints flat or acrylic surfaces (lit gradients, fine edges, a backdrop glow); `controls`
|
||||
holds a checkbox, toggle and dropdown select painted through it.
|
||||
`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 +214,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
|
||||
@@ -233,3 +256,16 @@ color, spacing and stroke changes fail those comparisons. To retain reviewable P
|
||||
```sh
|
||||
CRAFT_UI_VISUAL_DIR=/absolute/path/to/visuals cargo test -p craft-ui --test visual_regression
|
||||
```
|
||||
|
||||
## Material and controls
|
||||
|
||||
`material::Material` is `Solid` or `Acrylic` (serde behind the `serde` feature). Every painter
|
||||
takes the material, colours and `dark` explicitly and paints the plain fill under `Solid`, so callers
|
||||
don't branch: `paint_control`, `paint_selected`, `paint_accent`, `paint_gloss`, `paint_panel`,
|
||||
`backdrop_mesh`, `paint_backing` (call first inside a popup or modal) and `show_surface` (a
|
||||
`Frame::show` that lights the fill).
|
||||
|
||||
`controls::{checkbox, toggle, select, select_with_icons}` take a `ControlStyle` built from the app's
|
||||
tokens (fills, inks, accent, radii, fonts, menu size, animation time). The select menu fits its
|
||||
widest option up to `menu_max`, scrolls past its height, and shortens `Group / Sub / Name` options
|
||||
from the front (`… / Name`) with the full name in a tooltip.
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Form controls with application-supplied colours: a rounded checkbox, a pill toggle and a
|
||||
//! dropdown select whose menu fits its options. Each paints through [`crate::material`], so
|
||||
//! they take the app's material (flat or acrylic) as part of [`ControlStyle`].
|
||||
|
||||
use crate::material::{self, Material};
|
||||
use egui::{Align2, Color32, FontId, Galley, Painter, Rect, Response, Sense, Stroke, Ui, pos2, text::LayoutJob, vec2};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Colours, sizes and fonts the controls are painted with: built from the app's own tokens.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ControlStyle {
|
||||
pub material: Material,
|
||||
/// A field's or unchecked box's fill.
|
||||
pub field: Color32,
|
||||
/// Hover wash over a field or row (its alpha is the full-hover strength).
|
||||
pub hover: Color32,
|
||||
pub text: Color32,
|
||||
/// Secondary ink: a chevron, a path before a name, an unchecked toggle's knob.
|
||||
pub secondary: Color32,
|
||||
pub accent: Color32,
|
||||
/// Ink on the accent (a check mark, a toggle's knob).
|
||||
pub on_accent: Color32,
|
||||
/// Fill of a menu's selected row (under an accent wash with acrylic).
|
||||
pub selected: Color32,
|
||||
/// A field's and menu row's corner radius.
|
||||
pub radius: u8,
|
||||
/// A checkbox's corner radius.
|
||||
pub box_radius: u8,
|
||||
/// A select field's height.
|
||||
pub field_height: f32,
|
||||
pub font: FontId,
|
||||
/// The selected option's font in a menu.
|
||||
pub font_strong: FontId,
|
||||
/// The menu's widest and tallest size; longer options are shortened, more options scroll.
|
||||
pub menu_max: egui::Vec2,
|
||||
/// Seconds a hover or check animates over.
|
||||
pub animation_time: f32,
|
||||
}
|
||||
|
||||
fn hover_t(ui: &Ui, resp: &Response, style: &ControlStyle, salt: &str) -> f32 {
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
ui.ctx().animate_bool_with_time_and_easing(resp.id.with(salt), resp.hovered(), style.animation_time, egui::emath::easing::cubic_out)
|
||||
}
|
||||
|
||||
fn check_mark(painter: &Painter, c: egui::Pos2, ink: Color32) {
|
||||
let s = Stroke::new(2.0, ink);
|
||||
painter.line_segment([c + vec2(-4.0, 0.5), c + vec2(-1.0, 3.5)], s);
|
||||
painter.line_segment([c + vec2(-1.0, 3.5), c + vec2(4.5, -3.0)], s);
|
||||
}
|
||||
|
||||
/// A rounded checkbox with a label. Toggles `on` on click and marks the response changed.
|
||||
pub fn checkbox(ui: &mut Ui, style: &ControlStyle, on: &mut bool, label: &str) -> Response {
|
||||
const BOX: f32 = 18.0;
|
||||
const LABEL_X: f32 = 26.0;
|
||||
let label_w = ui.painter().layout_no_wrap(label.to_owned(), style.font.clone(), style.text).size().x;
|
||||
let (rect, mut resp) = ui.allocate_exact_size(vec2(LABEL_X + label_w + 4.0, 24.0), Sense::click());
|
||||
if resp.clicked() {
|
||||
*on = !*on;
|
||||
resp.mark_changed();
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, label));
|
||||
let t = ui.ctx().animate_bool_with_time_and_easing(resp.id.with("check"), *on, style.animation_time, egui::emath::easing::cubic_out);
|
||||
let hover = hover_t(ui, &resp, style, "check-hover");
|
||||
let bx = Rect::from_center_size(pos2(rect.left() + BOX / 2.0, rect.center().y), vec2(BOX, BOX));
|
||||
let fill = material::blend(material::blend(style.field, style.hover.gamma_multiply(hover)), style.accent.gamma_multiply(t));
|
||||
let dark = ui.visuals().dark_mode;
|
||||
if t > 0.5 {
|
||||
material::paint_accent(ui.painter(), style.material, bx, style.box_radius, fill);
|
||||
} else {
|
||||
material::paint_control(ui.painter(), style.material, bx, style.box_radius, fill, dark);
|
||||
}
|
||||
if t > 0.0 {
|
||||
check_mark(ui.painter(), bx.center(), style.on_accent.gamma_multiply(t));
|
||||
}
|
||||
ui.painter().text(pos2(rect.left() + LABEL_X, rect.center().y), Align2::LEFT_CENTER, label, style.font.clone(), style.text);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A pill switch. Toggles `on` on click and marks the response changed.
|
||||
pub fn toggle(ui: &mut Ui, style: &ControlStyle, on: &mut bool) -> Response {
|
||||
let (rect, mut resp) = ui.allocate_exact_size(vec2(42.0, 24.0), Sense::click());
|
||||
if resp.clicked() {
|
||||
*on = !*on;
|
||||
resp.mark_changed();
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, ""));
|
||||
let t = ui.ctx().animate_bool_with_time_and_easing(resp.id.with("toggle"), *on, style.animation_time, egui::emath::easing::cubic_out);
|
||||
let pill = (rect.height() / 2.0) as u8;
|
||||
let fill = material::blend(style.field, style.accent.gamma_multiply(t));
|
||||
if t > 0.5 {
|
||||
material::paint_accent(ui.painter(), style.material, rect, pill, fill);
|
||||
} else {
|
||||
material::paint_control(ui.painter(), style.material, rect, pill, fill, ui.visuals().dark_mode);
|
||||
}
|
||||
let x = egui::lerp((rect.left() + 12.0)..=(rect.right() - 12.0), t);
|
||||
ui.painter().circle_filled(pos2(x, rect.center().y), 8.0, if t > 0.5 { style.on_accent } else { style.secondary });
|
||||
resp
|
||||
}
|
||||
|
||||
/// Paints the icon of option `i` in `rect` with an ink, for [`select_with_icons`].
|
||||
pub type PaintOptionIcon<'a> = &'a dyn Fn(&Painter, Rect, usize, Color32);
|
||||
|
||||
/// A rounded dropdown: the current value in a field, a menu of options below it. Returns the
|
||||
/// newly chosen value, if any.
|
||||
pub fn select<T: PartialEq + Copy>(ui: &mut Ui, style: &ControlStyle, current: T, options: &[(T, &str)], width: f32) -> Option<T> {
|
||||
select_with_icons(ui, style, current, options, width, None)
|
||||
}
|
||||
|
||||
/// [`select`] with an icon before each option (in the field and the menu), painted by the app.
|
||||
/// `width` 0 takes the available width.
|
||||
pub fn select_with_icons<T: PartialEq + Copy>(
|
||||
ui: &mut Ui,
|
||||
style: &ControlStyle,
|
||||
current: T,
|
||||
options: &[(T, &str)],
|
||||
width: f32,
|
||||
icon: Option<PaintOptionIcon<'_>>,
|
||||
) -> Option<T> {
|
||||
const PAD: f32 = 12.0;
|
||||
const ICON: f32 = 14.0;
|
||||
let indent = if icon.is_some() { 22.0 } else { 0.0 };
|
||||
let cur = options.iter().position(|(o, _)| *o == current);
|
||||
let label = cur.map_or("\u{2014}", |i| options[i].1);
|
||||
let w = if width > 0.0 { width } else { ui.available_width() };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, style.field_height), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, ui.is_enabled(), label));
|
||||
let hover = hover_t(ui, &resp, style, "select");
|
||||
let dark = ui.visuals().dark_mode;
|
||||
material::paint_control(ui.painter(), style.material, rect, style.radius, material::blend(style.field, style.hover.gamma_multiply(hover)), dark);
|
||||
if let (Some(paint), Some(i)) = (icon, cur) {
|
||||
paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 19.0, rect.center().y), vec2(ICON, ICON)), i, style.secondary);
|
||||
}
|
||||
let g = option_label(ui, label, style.font.clone(), style.text, style.secondary, rect.width() - PAD - indent - 28.0);
|
||||
ui.painter().galley(pos2(rect.left() + PAD + indent, rect.center().y - g.size().y / 2.0), g, style.text);
|
||||
chevron(ui.painter(), pos2(rect.right() - 14.0, rect.center().y), style.secondary);
|
||||
|
||||
let mut chosen = None;
|
||||
let min_w = rect.width().max(150.0);
|
||||
// The menu grows to fit its longest option, up to the cap; measured only while open.
|
||||
let menu_w = if egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp)) {
|
||||
let widest = options
|
||||
.iter()
|
||||
.map(|(_, l)| ui.painter().layout_no_wrap((*l).to_owned(), style.font_strong.clone(), style.text).size().x)
|
||||
.fold(0.0, f32::max);
|
||||
(widest + PAD + indent + 40.0).clamp(min_w, style.menu_max.x.max(min_w))
|
||||
} else {
|
||||
min_w
|
||||
};
|
||||
egui::Popup::menu(&resp).width(menu_w).show(|ui| {
|
||||
let v = ui.visuals();
|
||||
material::paint_backing(ui, style.material, v.window_fill, v.menu_corner_radius.nw);
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
egui::ScrollArea::vertical().max_height(style.menu_max.y).show(ui, |ui| {
|
||||
for (k, (o, l)) in options.iter().enumerate() {
|
||||
let (row, r) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
||||
let on = *o == current;
|
||||
let h = hover_t(ui, &r, style, "opt");
|
||||
if on {
|
||||
material::paint_selected(ui.painter(), style.material, row, style.radius, style.selected, style.accent);
|
||||
} else if h > 0.0 {
|
||||
ui.painter().rect_filled(row, style.radius, style.hover.gamma_multiply(h));
|
||||
}
|
||||
if let Some(paint) = icon {
|
||||
paint(ui.painter(), Rect::from_center_size(pos2(row.left() + 19.0, row.center().y), vec2(ICON, ICON)), k, if on { style.text } else { style.secondary });
|
||||
}
|
||||
let font = if on { style.font_strong.clone() } else { style.font.clone() };
|
||||
let g = option_label(ui, l, font, style.text, style.secondary, row.width() - PAD - indent - 32.0);
|
||||
let cut = g.elided || g.text() != *l;
|
||||
ui.painter().galley(pos2(row.left() + PAD + indent, row.center().y - g.size().y / 2.0), g, style.text);
|
||||
let r = if cut { r.on_hover_text(*l) } else { r };
|
||||
if on {
|
||||
check_mark(ui.painter(), pos2(row.right() - 16.0, row.center().y), style.accent);
|
||||
}
|
||||
if r.clicked() {
|
||||
chosen = Some(*o);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
chosen.filter(|c| *c != current)
|
||||
}
|
||||
|
||||
fn chevron(painter: &Painter, c: egui::Pos2, ink: Color32) {
|
||||
let s = Stroke::new(1.5, ink);
|
||||
painter.line_segment([c + vec2(-4.0, -2.0), c + vec2(0.0, 2.0)], s);
|
||||
painter.line_segment([c + vec2(0.0, 2.0), c + vec2(4.0, -2.0)], s);
|
||||
}
|
||||
|
||||
/// One line of `text` within `max_w`. A `Group / Sub / Name` path keeps its last parts and drops
|
||||
/// leading ones (`… / Name`), since the end tells options apart; the path before the name is in
|
||||
/// `dim`. Anything still too long ends in "…".
|
||||
pub fn option_label(ui: &Ui, text: &str, font: FontId, ink: Color32, dim: Color32, max_w: f32) -> Arc<Galley> {
|
||||
let parts: Vec<&str> = text.split(" / ").collect();
|
||||
let job = |skip: usize, cut: bool| {
|
||||
let mut job = LayoutJob::default();
|
||||
let (prefix, name) = parts.split_at(parts.len() - 1);
|
||||
let mut head = prefix[skip..].join(" / ");
|
||||
if skip > 0 {
|
||||
head = if head.is_empty() { "\u{2026}".into() } else { format!("\u{2026} / {head}") };
|
||||
}
|
||||
if !head.is_empty() {
|
||||
job.append(&format!("{head} / "), 0.0, egui::TextFormat::simple(font.clone(), dim));
|
||||
}
|
||||
job.append(name[0], 0.0, egui::TextFormat::simple(font.clone(), ink));
|
||||
if cut {
|
||||
job.wrap.max_width = max_w;
|
||||
job.wrap.max_rows = 1;
|
||||
job.wrap.overflow_character = Some('\u{2026}');
|
||||
}
|
||||
job
|
||||
};
|
||||
for skip in 0..parts.len() {
|
||||
let g = ui.painter().layout_job(job(skip, false));
|
||||
if g.size().x <= max_w {
|
||||
return g;
|
||||
}
|
||||
}
|
||||
ui.painter().layout_job(job(parts.len() - 1, true))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::*;
|
||||
use egui::{Context, Event, PointerButton, Pos2, RawInput, Rect, Vec2};
|
||||
|
||||
fn style() -> ControlStyle {
|
||||
ControlStyle {
|
||||
material: Material::Solid,
|
||||
field: Color32::from_gray(40),
|
||||
hover: Color32::from_white_alpha(20),
|
||||
text: Color32::WHITE,
|
||||
secondary: Color32::GRAY,
|
||||
accent: Color32::from_rgb(120, 90, 250),
|
||||
on_accent: Color32::WHITE,
|
||||
selected: Color32::from_gray(50),
|
||||
radius: 8,
|
||||
box_radius: 6,
|
||||
field_height: 32.0,
|
||||
font: FontId::proportional(13.0),
|
||||
font_strong: FontId::proportional(13.0),
|
||||
menu_max: egui::vec2(320.0, 420.0),
|
||||
animation_time: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(ctx: &Context, events: Vec<Event>, mut f: impl FnMut(&mut Ui)) {
|
||||
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(600.0, 400.0))), events, ..Default::default() };
|
||||
ctx.run_ui(input, |ui| f(ui)).textures_delta.clear();
|
||||
}
|
||||
|
||||
fn click(at: Pos2) -> Vec<Event> {
|
||||
vec![
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() },
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() },
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_and_toggle_flip_on_click() {
|
||||
let ctx = Context::default();
|
||||
let s = style();
|
||||
let (mut on, mut sw, mut rects) = (false, false, (Rect::NOTHING, Rect::NOTHING));
|
||||
run(&ctx, vec![], |ui| {
|
||||
rects.0 = checkbox(ui, &s, &mut on, "Screen").rect;
|
||||
rects.1 = toggle(ui, &s, &mut sw).rect;
|
||||
});
|
||||
let mut changed = false;
|
||||
run(&ctx, click(rects.0.left_center() + egui::vec2(9.0, 0.0)), |ui| {
|
||||
changed = checkbox(ui, &s, &mut on, "Screen").changed();
|
||||
toggle(ui, &s, &mut sw);
|
||||
});
|
||||
assert!(on && changed);
|
||||
run(&ctx, click(rects.1.center()), |ui| {
|
||||
checkbox(ui, &s, &mut on, "Screen");
|
||||
toggle(ui, &s, &mut sw);
|
||||
});
|
||||
assert!(sw && on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_opens_and_picks_an_option() {
|
||||
let ctx = Context::default();
|
||||
let s = style();
|
||||
let opts = [(1, "One"), (2, "Two"), (3, "Three")];
|
||||
let field = Rect::from_min_size(Pos2::ZERO, egui::vec2(140.0, 32.0));
|
||||
let mut chosen = None;
|
||||
let mut frame = |events| {
|
||||
run(&ctx, events, |ui| chosen = chosen.or(select(ui, &s, 1, &opts, 140.0)));
|
||||
};
|
||||
frame(vec![]);
|
||||
frame(click(field.center()));
|
||||
frame(vec![]);
|
||||
let menu = ctx.memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id)));
|
||||
let menu = menu.expect("the menu is open");
|
||||
// Rows are 32 tall with 2 between, under the menu's margin: aim at the second one.
|
||||
let two = pos2(menu.center().x, menu.top() + menu.height() / 2.0);
|
||||
frame(click(two));
|
||||
assert_eq!(chosen, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_label_keeps_the_name_of_a_path() {
|
||||
let ctx = Context::default();
|
||||
let mut out = (String::new(), String::new(), String::new());
|
||||
run(&ctx, vec![], |ui| {
|
||||
let font = FontId::proportional(13.0);
|
||||
out.0 = option_label(ui, "Label / Entry Request / Waiting", font.clone(), Color32::WHITE, Color32::GRAY, 1000.0).text().to_owned();
|
||||
out.1 = option_label(ui, "Label / Entry Request / Waiting", font.clone(), Color32::WHITE, Color32::GRAY, 90.0).text().to_owned();
|
||||
let g = option_label(ui, "A very long name without a path at all", font, Color32::WHITE, Color32::GRAY, 60.0);
|
||||
out.2 = if g.elided && g.size().x <= 61.0 { String::new() } else { format!("not elided: {}", g.size().x) };
|
||||
});
|
||||
assert_eq!(out.0, "Label / Entry Request / Waiting");
|
||||
assert!(out.1.starts_with('\u{2026}') && out.1.ends_with("Waiting"), "{}", out.1);
|
||||
assert!(out.2.is_empty(), "{}", out.2);
|
||||
let _ = Vec2::ZERO;
|
||||
}
|
||||
@@ -6,8 +6,11 @@
|
||||
//! accept focused style values and return egui responses; no application engine is required.
|
||||
|
||||
pub mod buttons;
|
||||
pub mod controls;
|
||||
pub mod docking;
|
||||
pub mod icons;
|
||||
pub mod layout;
|
||||
pub mod material;
|
||||
pub mod printing;
|
||||
pub mod tabs;
|
||||
pub mod theme;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Surface materials: flat fills, or acrylic (surfaces lit from above with a fine edge, a soft
|
||||
//! accent glow behind the chrome, a glossy primary button).
|
||||
//!
|
||||
//! Every function takes the material, palette colours and `dark` explicitly; the application
|
||||
//! stores the user's choice and its palette. Under [`Material::Solid`] each one paints the plain
|
||||
//! fill it was given, so callers don't branch.
|
||||
|
||||
use egui::{Color32, CornerRadius, Frame, InnerResponse, Mesh, Painter, Pos2, Rect, Shadow, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// How surfaces are painted.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum Material {
|
||||
#[default]
|
||||
Solid,
|
||||
Acrylic,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
pub const ALL: [Material; 2] = [Material::Solid, Material::Acrylic];
|
||||
|
||||
pub fn is_acrylic(self) -> bool {
|
||||
self == Material::Acrylic
|
||||
}
|
||||
}
|
||||
|
||||
/// `over` composited on `base` (the result is opaque).
|
||||
pub fn blend(base: Color32, over: Color32) -> Color32 {
|
||||
let a = f32::from(over.a()) / 255.0;
|
||||
let [or, og, ob, _] = over.to_srgba_unmultiplied();
|
||||
let mix = |b: u8, o: u8| (f32::from(b) * (1.0 - a) + f32::from(o) * a).round() as u8;
|
||||
Color32::from_rgb(mix(base.r(), or), mix(base.g(), og), mix(base.b(), ob))
|
||||
}
|
||||
|
||||
/// The fine edge of acrylic surfaces.
|
||||
pub fn edge(dark: bool) -> Stroke {
|
||||
Stroke::new(1.0, if dark { Color32::from_white_alpha(22) } else { Color32::from_black_alpha(22) })
|
||||
}
|
||||
|
||||
/// The top and bottom of `fill` lit from above. A translucent fill (a tint over a panel) gets
|
||||
/// brighter at the top and fainter at the bottom instead.
|
||||
pub fn lit(fill: Color32, dark: bool) -> (Color32, Color32) {
|
||||
if fill.a() < 255 {
|
||||
let [r, g, b, a] = fill.to_srgba_unmultiplied();
|
||||
let with = |k: f32| Color32::from_rgba_unmultiplied(r, g, b, (f32::from(a) * k).min(255.0) as u8);
|
||||
return (with(1.6), with(0.8));
|
||||
}
|
||||
if dark {
|
||||
(blend(fill, Color32::from_white_alpha(26)), blend(fill, Color32::from_black_alpha(22)))
|
||||
} else {
|
||||
(blend(fill, Color32::from_white_alpha(140)), blend(fill, Color32::from_black_alpha(4)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded rectangle filled with a vertical gradient. A fan from the centre interpolates a
|
||||
/// colour that is linear in y exactly; an edge stroke covers the unsmoothed outline.
|
||||
pub fn vertical_gradient(rect: Rect, radius: f32, top: Color32, bottom: Color32) -> Mesh {
|
||||
let mut outline = Vec::new();
|
||||
egui::epaint::tessellator::path::rounded_rectangle(&mut outline, rect, radius.into());
|
||||
let at = |y: f32| top.lerp_to_gamma(bottom, ((y - rect.top()) / rect.height().max(f32::EPSILON)).clamp(0.0, 1.0));
|
||||
let mut mesh = Mesh::default();
|
||||
mesh.colored_vertex(rect.center(), at(rect.center().y));
|
||||
for q in &outline {
|
||||
mesh.colored_vertex(*q, at(q.y));
|
||||
}
|
||||
let n = outline.len() as u32;
|
||||
for i in 0..n {
|
||||
mesh.add_triangle(0, 1 + i, 1 + (i + 1) % n);
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
fn paint_lit(painter: &Painter, rect: Rect, radius: u8, top: Color32, bottom: Color32, stroke: Stroke) {
|
||||
painter.add(vertical_gradient(rect, f32::from(radius), top, bottom));
|
||||
painter.rect_stroke(rect, radius, stroke, StrokeKind::Inside);
|
||||
}
|
||||
|
||||
/// A control's surface (field, chip, select, secondary button, segmented track).
|
||||
pub fn paint_control(painter: &Painter, material: Material, rect: Rect, radius: u8, fill: Color32, dark: bool) {
|
||||
if !material.is_acrylic() {
|
||||
painter.rect_filled(rect, radius, fill);
|
||||
return;
|
||||
}
|
||||
let (top, bottom) = lit(fill, dark);
|
||||
paint_lit(painter, rect, radius, top, bottom, edge(dark));
|
||||
}
|
||||
|
||||
/// A selected row or item (nav, menu option, layer): an accent wash with an accent edge.
|
||||
pub fn paint_selected(painter: &Painter, material: Material, rect: Rect, radius: u8, fill: Color32, accent: Color32) {
|
||||
if !material.is_acrylic() {
|
||||
painter.rect_filled(rect, radius, fill);
|
||||
return;
|
||||
}
|
||||
let wash = |alpha| Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), alpha);
|
||||
paint_lit(painter, rect, radius, blend(fill, wash(56)), blend(fill, wash(20)), Stroke::new(1.0, wash(80)));
|
||||
}
|
||||
|
||||
/// An accent surface that is not the primary button (toggle on, active segment, checked box):
|
||||
/// glossy with a light edge, no glow.
|
||||
pub fn paint_accent(painter: &Painter, material: Material, rect: Rect, radius: u8, fill: Color32) {
|
||||
if !material.is_acrylic() {
|
||||
painter.rect_filled(rect, radius, fill);
|
||||
return;
|
||||
}
|
||||
paint_lit(
|
||||
painter,
|
||||
rect,
|
||||
radius,
|
||||
blend(fill, Color32::from_white_alpha(70)),
|
||||
blend(fill, Color32::from_black_alpha(40)),
|
||||
Stroke::new(1.0, Color32::from_white_alpha(90)),
|
||||
);
|
||||
}
|
||||
|
||||
/// The acrylic primary button: a soft glow in its own colour, a glossy gradient, a light edge.
|
||||
pub fn paint_gloss(painter: &Painter, rect: Rect, radius: u8, fill: Color32) {
|
||||
let glow = Shadow { offset: [0, 0], blur: 16, spread: 0, color: Color32::from_rgba_unmultiplied(fill.r(), fill.g(), fill.b(), 70) };
|
||||
painter.add(glow.as_shape(rect, CornerRadius::same(radius)));
|
||||
paint_accent(painter, Material::Acrylic, rect, radius, fill);
|
||||
}
|
||||
|
||||
/// An acrylic panel under its content: a soft shadow, the fill lit from above, a fine edge.
|
||||
pub fn paint_panel(painter: &Painter, rect: Rect, radius: u8, fill: Color32, dark: bool) {
|
||||
let shadow = Shadow { offset: [0, 4], blur: 12, spread: 0, color: Color32::from_black_alpha(if dark { 80 } else { 24 }) };
|
||||
painter.add(shadow.as_shape(rect, CornerRadius::same(radius)));
|
||||
let (top, bottom) = lit(fill, dark);
|
||||
paint_lit(painter, rect, radius, top, bottom, edge(dark));
|
||||
}
|
||||
|
||||
/// The acrylic backdrop at `pos`: `backdrop` lit by a soft `accent` glow that falls off from
|
||||
/// the top of `screen`, a third of the way across.
|
||||
pub fn backdrop_at(backdrop: Color32, accent: Color32, screen: Rect, pos: Pos2) -> Color32 {
|
||||
const GLOW_ALPHA: f32 = 46.0;
|
||||
const GLOW_REACH: f32 = 0.8;
|
||||
let centre = pos2(screen.left() + screen.width() * 0.3, screen.top());
|
||||
let reach = (screen.width().max(screen.height()) * GLOW_REACH).max(1.0);
|
||||
let d = ((pos - centre).length() / reach).min(1.0);
|
||||
let glow = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (GLOW_ALPHA * (1.0 - d) * (1.0 - d)) as u8);
|
||||
blend(backdrop, glow)
|
||||
}
|
||||
|
||||
/// `rect` filled with the acrylic backdrop: a grid of vertex colours, so the glow is smooth.
|
||||
pub fn backdrop_mesh(backdrop: Color32, accent: Color32, screen: Rect, rect: Rect) -> Mesh {
|
||||
const CELLS: u32 = 12;
|
||||
let mut mesh = Mesh::default();
|
||||
for j in 0..=CELLS {
|
||||
for i in 0..=CELLS {
|
||||
let pos = rect.min + rect.size() * vec2(i as f32, j as f32) / CELLS as f32;
|
||||
mesh.colored_vertex(pos, backdrop_at(backdrop, accent, screen, pos));
|
||||
}
|
||||
}
|
||||
let row = CELLS + 1;
|
||||
for j in 0..CELLS {
|
||||
for i in 0..CELLS {
|
||||
let k = j * row + i;
|
||||
mesh.add_triangle(k, k + 1, k + row);
|
||||
mesh.add_triangle(k + 1, k + row + 1, k + row);
|
||||
}
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
/// Lights a window, modal, popup or menu frame from inside its content: call first in the
|
||||
/// content closure. Paints over the frame's flat `fill` at the area's rect from the last frame,
|
||||
/// so nothing has to run after the content. No-op unless acrylic.
|
||||
pub fn paint_backing(ui: &Ui, material: Material, fill: Color32, radius: u8) {
|
||||
if !material.is_acrylic() {
|
||||
return;
|
||||
}
|
||||
let Some(rect) = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)) else { return };
|
||||
let dark = ui.visuals().dark_mode;
|
||||
let (top, bottom) = lit(fill, dark);
|
||||
paint_lit(&ui.painter().with_clip_rect(rect), rect, radius, top, bottom, edge(dark));
|
||||
}
|
||||
|
||||
/// `Frame::show` that, under acrylic, paints the frame's fill lit from above with a fine edge
|
||||
/// (the frame's own stroke when it has one). For cards, fields, popovers and docks.
|
||||
pub fn show_surface<R>(frame: Frame, ui: &mut Ui, material: Material, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
|
||||
if !material.is_acrylic() || frame.fill.a() == 0 {
|
||||
return frame.show(ui, add);
|
||||
}
|
||||
let dark = ui.visuals().dark_mode;
|
||||
let (fill, radius) = (frame.fill, f32::from(frame.corner_radius.nw));
|
||||
let slot = ui.painter().add(egui::Shape::Noop);
|
||||
let stroke = if frame.stroke.width > 0.0 { frame.stroke } else { edge(dark) };
|
||||
let r = frame.fill(Color32::TRANSPARENT).stroke(stroke).show(ui, add);
|
||||
let (top, bottom) = lit(fill, dark);
|
||||
ui.painter().set(slot, vertical_gradient(r.response.rect, radius, top, bottom));
|
||||
r
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blend_composites_alpha() {
|
||||
let c = blend(Color32::from_rgb(0, 0, 0), Color32::from_rgba_unmultiplied(255, 255, 255, 128));
|
||||
assert!((127..=129).contains(&c.r()) && c.a() == 255);
|
||||
assert_eq!(blend(Color32::RED, Color32::TRANSPARENT), Color32::RED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lit_is_lighter_on_top() {
|
||||
let fill = Color32::from_rgb(60, 60, 70);
|
||||
for dark in [true, false] {
|
||||
let (top, bottom) = lit(fill, dark);
|
||||
assert!(top.r() > bottom.r(), "{dark}: {top:?} {bottom:?}");
|
||||
}
|
||||
let (top, bottom) = lit(Color32::from_rgba_unmultiplied(255, 255, 255, 40), true);
|
||||
assert!(top.a() > bottom.a(), "a translucent tint fades downwards");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_is_linear_in_y() {
|
||||
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 40.0));
|
||||
let m = vertical_gradient(rect, 8.0, Color32::WHITE, Color32::BLACK);
|
||||
assert!(m.indices.len() >= 3 * 4);
|
||||
let mid = m.vertices[0];
|
||||
assert_eq!(mid.pos, rect.center());
|
||||
let grey = mid.color.r();
|
||||
assert!((100..=156).contains(&grey), "{grey}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backdrop_glows_at_the_top_only() {
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0));
|
||||
let bg = Color32::from_rgb(20, 20, 30);
|
||||
let accent = Color32::from_rgb(200, 100, 255);
|
||||
let near = backdrop_at(bg, accent, screen, pos2(300.0, 0.0));
|
||||
let far = backdrop_at(bg, accent, screen, pos2(1000.0, 800.0));
|
||||
assert_ne!(near, bg);
|
||||
assert_eq!(far, bg);
|
||||
assert_eq!(backdrop_mesh(bg, accent, screen, screen).vertices.len(), 13 * 13);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! The Catppuccin colour palettes (Latte, Frappé, Macchiato, Mocha).
|
||||
//!
|
||||
//! Colours only: how they map onto an application's tokens stays with the application.
|
||||
//! Palette: <https://catppuccin.com/palette> (MIT, see `LICENSE-catppuccin.txt`).
|
||||
|
||||
use egui::Color32;
|
||||
|
||||
/// The 26 named colours of one Catppuccin flavour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Palette {
|
||||
pub rosewater: Color32,
|
||||
pub flamingo: Color32,
|
||||
pub pink: Color32,
|
||||
pub mauve: Color32,
|
||||
pub red: Color32,
|
||||
pub maroon: Color32,
|
||||
pub peach: Color32,
|
||||
pub yellow: Color32,
|
||||
pub green: Color32,
|
||||
pub teal: Color32,
|
||||
pub sky: Color32,
|
||||
pub sapphire: Color32,
|
||||
pub blue: Color32,
|
||||
pub lavender: Color32,
|
||||
pub text: Color32,
|
||||
pub subtext1: Color32,
|
||||
pub subtext0: Color32,
|
||||
pub overlay2: Color32,
|
||||
pub overlay1: Color32,
|
||||
pub overlay0: Color32,
|
||||
pub surface2: Color32,
|
||||
pub surface1: Color32,
|
||||
pub surface0: Color32,
|
||||
pub base: Color32,
|
||||
pub mantle: Color32,
|
||||
pub crust: Color32,
|
||||
}
|
||||
|
||||
const fn rgb(hex: u32) -> Color32 {
|
||||
Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// `h` in the field order above, as 0xRRGGBB.
|
||||
const fn new(h: [u32; 26]) -> Self {
|
||||
Self {
|
||||
rosewater: rgb(h[0]),
|
||||
flamingo: rgb(h[1]),
|
||||
pink: rgb(h[2]),
|
||||
mauve: rgb(h[3]),
|
||||
red: rgb(h[4]),
|
||||
maroon: rgb(h[5]),
|
||||
peach: rgb(h[6]),
|
||||
yellow: rgb(h[7]),
|
||||
green: rgb(h[8]),
|
||||
teal: rgb(h[9]),
|
||||
sky: rgb(h[10]),
|
||||
sapphire: rgb(h[11]),
|
||||
blue: rgb(h[12]),
|
||||
lavender: rgb(h[13]),
|
||||
text: rgb(h[14]),
|
||||
subtext1: rgb(h[15]),
|
||||
subtext0: rgb(h[16]),
|
||||
overlay2: rgb(h[17]),
|
||||
overlay1: rgb(h[18]),
|
||||
overlay0: rgb(h[19]),
|
||||
surface2: rgb(h[20]),
|
||||
surface1: rgb(h[21]),
|
||||
surface0: rgb(h[22]),
|
||||
base: rgb(h[23]),
|
||||
mantle: rgb(h[24]),
|
||||
crust: rgb(h[25]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const LATTE: Palette = Palette::new([
|
||||
0xdc8a78, 0xdd7878, 0xea76cb, 0x8839ef, 0xd20f39, 0xe64553, 0xfe640b, 0xdf8e1d, 0x40a02b, 0x179299, 0x04a5e5, 0x209fb5, 0x1e66f5, 0x7287fd,
|
||||
0x4c4f69, 0x5c5f77, 0x6c6f85, 0x7c7f93, 0x8c8fa1, 0x9ca0b0, 0xacb0be, 0xbcc0cc, 0xccd0da, 0xeff1f5, 0xe6e9ef, 0xdce0e8,
|
||||
]);
|
||||
pub const FRAPPE: Palette = Palette::new([
|
||||
0xf2d5cf, 0xeebebe, 0xf4b8e4, 0xca9ee6, 0xe78284, 0xea999c, 0xef9f76, 0xe5c890, 0xa6d189, 0x81c8be, 0x99d1db, 0x85c1dc, 0x8caaee, 0xbabbf1,
|
||||
0xc6d0f5, 0xb5bfe2, 0xa5adce, 0x949cbb, 0x838ba7, 0x737994, 0x626880, 0x51576d, 0x414559, 0x303446, 0x292c3c, 0x232634,
|
||||
]);
|
||||
pub const MACCHIATO: Palette = Palette::new([
|
||||
0xf4dbd6, 0xf0c6c6, 0xf5bde6, 0xc6a0f6, 0xed8796, 0xee99a0, 0xf5a97f, 0xeed49f, 0xa6da95, 0x8bd5ca, 0x91d7e3, 0x7dc4e4, 0x8aadf4, 0xb7bdf8,
|
||||
0xcad3f5, 0xb8c0e0, 0xa5adcb, 0x939ab7, 0x8087a2, 0x6e738d, 0x5b6078, 0x494d64, 0x363a4f, 0x24273a, 0x1e2030, 0x181926,
|
||||
]);
|
||||
pub const MOCHA: Palette = Palette::new([
|
||||
0xf5e0dc, 0xf2cdcd, 0xf5c2e7, 0xcba6f7, 0xf38ba8, 0xeba0ac, 0xfab387, 0xf9e2af, 0xa6e3a1, 0x94e2d5, 0x89dceb, 0x74c7ec, 0x89b4fa, 0xb4befe,
|
||||
0xcdd6f4, 0xbac2de, 0xa6adc8, 0x9399b2, 0x7f849c, 0x6c7086, 0x585b70, 0x45475a, 0x313244, 0x1e1e2e, 0x181825, 0x11111b,
|
||||
]);
|
||||
|
||||
/// The four Catppuccin flavours.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum Flavor {
|
||||
Latte,
|
||||
Frappe,
|
||||
Macchiato,
|
||||
Mocha,
|
||||
}
|
||||
|
||||
impl Flavor {
|
||||
pub const ALL: [Flavor; 4] = [Flavor::Latte, Flavor::Frappe, Flavor::Macchiato, Flavor::Mocha];
|
||||
|
||||
pub const fn palette(self) -> &'static Palette {
|
||||
match self {
|
||||
Flavor::Latte => &LATTE,
|
||||
Flavor::Frappe => &FRAPPE,
|
||||
Flavor::Macchiato => &MACCHIATO,
|
||||
Flavor::Mocha => &MOCHA,
|
||||
}
|
||||
}
|
||||
|
||||
/// Latte is the only light flavour.
|
||||
pub const fn is_dark(self) -> bool {
|
||||
!matches!(self, Flavor::Latte)
|
||||
}
|
||||
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Flavor::Latte => "Latte",
|
||||
Flavor::Frappe => "Frappé",
|
||||
Flavor::Macchiato => "Macchiato",
|
||||
Flavor::Mocha => "Mocha",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_values_and_brightness() {
|
||||
assert_eq!(Flavor::Mocha.palette().base, Color32::from_rgb(0x1e, 0x1e, 0x2e));
|
||||
assert_eq!(Flavor::Latte.palette().mauve, Color32::from_rgb(0x88, 0x39, 0xef));
|
||||
assert_eq!(Flavor::Frappe.palette().crust, Color32::from_rgb(0x23, 0x26, 0x34));
|
||||
assert_eq!(Flavor::Macchiato.palette().text, Color32::from_rgb(0xca, 0xd3, 0xf5));
|
||||
assert_eq!(Flavor::ALL.iter().filter(|f| !f.is_dark()).count(), 1);
|
||||
for f in Flavor::ALL {
|
||||
let p = f.palette();
|
||||
// Text must contrast with the base surface in the right direction.
|
||||
assert_eq!(p.text.r() > p.base.r(), f.is_dark(), "{}", f.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! This module does not install fonts, store a theme, or change global visuals. Applications
|
||||
//! decide their colors, borders, typography, spacing and saved preferences independently.
|
||||
|
||||
pub mod catppuccin;
|
||||
|
||||
use egui::{Color32, CornerRadius, Stroke, style::Widgets};
|
||||
|
||||
/// Background colors for egui's five widget interaction states.
|
||||
|
||||
@@ -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
|
||||
@@ -66,7 +66,7 @@ The SVG substitution is deliberately narrow: it preserves the existing literal r
|
||||
|
||||
PdfCraft resolves the fallback name before requesting a source, preserving its existing URI policy. This replaces its indexed fallback lookup with the shared non-panicking lookup; the shared absent-fallback test covers a catalog without that entry. No icon assets move.
|
||||
|
||||
Most other Rust apps draw icons with egui's painter. Converting them all to SVG would be a separate visual change with different cost and fidelity questions. Common icon assets are a future candidate after checking file hashes, ownership and each app's asset manifest; ArtCraft trademarks remain outside such a library.
|
||||
Most other Rust apps draw icons with egui's painter. Converting them all to SVG would be a separate visual change with different cost and fidelity questions. Common icon assets are a future candidate after checking file hashes, ownership and each app's asset manifest; trademarks remain outside such a library.
|
||||
|
||||
## Themes and common controls
|
||||
|
||||
|
||||
Submodule
+1
Submodule tools/craft-skills added at 26c1c201a7
Reference in New Issue
Block a user