diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml index deb7402..b2dc66f 100644 --- a/.gitea/workflows/build-macos.yml +++ b/.gitea/workflows/build-macos.yml @@ -94,11 +94,22 @@ jobs: run: | mkdir -p release-assets - # Stage DMG package - DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1) - if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then - cp "$DMG_FILE" "release-assets/${PREFIX}.dmg" + APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1) + if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then + echo "Error: Could not locate built .app bundle in src-tauri/target" + exit 1 fi + echo "Found .app bundle: ${APP_PATH}" + + # Setup isolated Python virtual environment for dmgbuild + python3 -m venv "${{ runner.temp }}/dmgbuild-venv" + "${{ runner.temp }}/dmgbuild-venv/bin/pip" install --quiet dmgbuild + + # Build styled DMG with background art and custom icon locations + "${{ runner.temp }}/dmgbuild-venv/bin/python" scripts/build-dmg.py \ + --app "${APP_PATH}" \ + --output "release-assets/${PREFIX}.dmg" \ + --volname "ICCery" - name: Upload Artifacts diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 02fc38f..641c694 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -84,22 +84,9 @@ jobs: echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV - - name: Warm up macOS GUI for DMG layout - if: runner.os == 'macOS' - run: | - # Tauri's DMG bundler runs an AppleScript that asks Finder to set the - # background picture and icon positions. On CI runners, Finder or the - # Aqua session may be idle, which can cause AppleEvent timeouts. Try to - # start/awaken Finder and System Events before the real build. - open -g -a Finder || true - open -g -a "System Events" || true - # Send a harmless probe to force Finder to initialise a scripting session. - osascript -e 'tell application "Finder" to get name' || true - sleep 10 - - name: Build Tauri App env: - TAURI_BUNDLER_DMG_IGNORE_CI: "true" + CI: "true" run: npm run tauri build -- --target ${{ matrix.platform.target }} - name: Prepare Release Assets @@ -107,11 +94,22 @@ jobs: run: | mkdir -p release-assets - # Stage DMG package - DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1) - if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then - cp "$DMG_FILE" "release-assets/${PREFIX}.dmg" + APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1) + if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then + echo "Error: Could not locate built .app bundle in src-tauri/target" + exit 1 fi + echo "Found .app bundle: ${APP_PATH}" + + # Setup isolated Python virtual environment for dmgbuild + python3 -m venv "${{ runner.temp }}/dmgbuild-venv" + "${{ runner.temp }}/dmgbuild-venv/bin/pip" install --quiet dmgbuild + + # Build styled DMG with background art and custom icon locations + "${{ runner.temp }}/dmgbuild-venv/bin/python" scripts/build-dmg.py \ + --app "${APP_PATH}" \ + --output "release-assets/${PREFIX}.dmg" \ + --volname "ICCery" - name: Upload Artifacts diff --git a/AGENTS.md b/AGENTS.md index 13d3a8a..6edc3d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,23 @@ # ICCery Agent Notes +## Printer Calibration (`printcal` / `applycal`) (#224) + +- Optional Stage 0 dashboard, opened from **Calibrate Printer**. The 1–5 wizard is unchanged when calibration is skipped. +- Calibration charts use a `CAL_` basename so they never collide with the profiling `.ti1`/`.ti2`/`.ti3`. +- `printtarg -K file.cal` is applied only to **profiling** layouts, never to the calibration chart itself. +- After Stage 4 `colprof`, `applycal` embeds the curves into the ICC/ICM when Apply Calibration is on. +- `.cal` overwrite requires an explicit Overwrite / Rename / Cancel choice. +- Warn when a loaded `.cal` is older than `calibration_stale_days` (default 30) or the stored printer name differs. +- Tests: `src-tauri/src/calibration.rs` (arg builders + `.cal` parser) and `src/js/calibration.test.js`. + +## Stage 5 System Profile Install (#223) + +- `install_profile_to_system` copies the working-directory `.icc`/`.icm` into the OS colour store. It never moves or deletes the project artefact. +- Destinations: Windows `%WINDIR%\System32\spool\drivers\color` (`.icm`); macOS `~/Library/ColorSync/Profiles` or `/Library/ColorSync/Profiles`; Linux `~/.local/share/icc` or `/usr/share/color/icc` (colormgr when present). +- Collisions require Overwrite / Rename / Cancel. Permission errors must mention elevation. +- If Apply Calibration is on, the success toast notes which `.cal` was embedded. +- Tests: `src-tauri/src/profile_install.rs` and `src/js/profile_install.test.js`. + ## Stage 5 Verification / Profcheck - `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats. @@ -10,6 +28,9 @@ ## 3D Gamut Viewer - The viewer renders the measured/derived `.gam` volume and an optional sRGB reference wireframe in CIELAB. +- **Do not** create `THREE.WebGLRenderer` during `DOMContentLoaded`. Call `ensureGamutViewer()` from `state.js` only when Stage 5 becomes visible. Eager WebGL on a hidden canvas respawns WKWebView on macOS Monterey Intel (#225). +- Feature-detect WebGL first; missing/lost context must leave a fallback message in `#gamutViewerContainer` and must not take down the app. +- Pause the rAF loop when leaving Stage 5 (`pauseGamutViewer`). - Layer controls (profile, sRGB, axes) each have visibility toggles and opacity sliders. - Click **Reset View** or press **R** to return the camera to its default position. - Full JSDoc is provided on the public API in `src/js/gamut_viewer.js`. @@ -61,6 +82,8 @@ The frontend uses a tiered button sizing system defined in `src/styles/main.css` - Verification & drift tests: `node src/js/profcheck.test.js` (21 tests) - Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests) - Gamut viewer tests: `node src/js/gamut_viewer.test.js` + - Calibration helpers: `node src/js/calibration.test.js` + - Profile install helpers: `node src/js/profile_install.test.js` - Browser devtools console: `import('./profcheck.test.js').then(m => m.runAll())` - **Frontend development server**: `npm run tauri dev` - **Production package build**: `npm run tauri build` diff --git a/README.md b/README.md index ef6ef2f..72cdf8d 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,20 @@ > Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS. -[![Release](https://img.shields.io/badge/version-v0.8.4-blue.svg)](https://git.i3omb.com/gronod/ICCery) +[![Release](https://img.shields.io/badge/version-v0.8.5-blue.svg)](https://git.i3omb.com/gronod/ICCery) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery) [![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app) [![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md) -**ICCery** is a native GUI frontend designed to make creating custom ICC/ICM printer profiles seamless, visual, and reliable. It wraps the powerful color management capabilities of [ArgyllCMS](https://www.argyllcms.com/) within an intuitive, artefact-gated 5-stage wizard. +**ICCery** is a native GUI frontend designed to make creating custom ICC/ICM printer profiles seamless, visual, and reliable. It wraps the powerful color management capabilities of [ArgyllCMS](https://www.argyllcms.com/) within an intuitive, artefact-gated 5-stage wizard, with an optional printer calibration (linearization) workflow. --- ## Key Features - πŸͺ„ **Linear 5-Stage Wizard Workflow**: - 1. **Stage 1 β€” Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, neutral/grey axis boosting, and 11 advanced generation parameters with contextual guidance tooltips. Supports direct-resume from existing `.ti2` target files to jump straight to measurement. + 0. **Optional β€” Printer Calibration (`printcal` / `applycal`)** (#224): Per-channel linearization and ink-limit discovery before a full profile. Generate a short `CAL_` chart, print and measure it with the existing Stage 2/3 engines, compute `.cal` curves, inspect channel-response plots, and toggle **Apply Calibration** so subsequent `printtarg` (`-K`) and `colprof` (`applycal`) runs consume the curves. Skip entirely for simple RGB photo printers. + 1. **Stage 1 β€” Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, neutral/grey axis boosting, and 11 advanced generation parameters with contextual guidance tooltips. Supports direct-resume from existing `.ti2` target files to jump straight to measurement. CMYK / RIP workflows show a reminder when no calibration is applied. 2. **Stage 2 β€” Target Creation & Raw Printing (`printtarg`)**: Format patch targets for handheld spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint) and automated XY tables (i1iO, SpectroScan). View high-resolution downscaled TIFF previews and print directly using native OS unmanaged pathways: - **macOS**: Native `NSPrintPanel` driver preferences with automatic ColorSync suppression (`AP_ColorMatchingMode=AP_ApplicationColorMatching`), CUPS media type selection, and driver-specific color adjustment bypass detection (Canon `CNIJIntent2`, Epson `ColorCorrection`, Gutenprint). - **Windows**: GDI uncorrected raw printing and DEVMODE preferences. @@ -30,6 +31,7 @@ - **Longitudinal Printer Drift Analytics (#95)**: Historical verification logging persisted to `verification_history.json` (up to 1,000 records), an interactive dual-series SVG trend chart with shaded ICCery verification reference bands, consecutive-breach alert recommendation card (detecting drift across distinct dates or $\ge 1$ hour apart), and RFC-4180 compliant CSV export. - **Mathematical Accuracy Report**: Peak, Average, and RMS CIEDE2000 metrics with robust parsing of both Argyll JSON summaries (`-u`) and legacy plain-text reports. - **Interactive 3D Gamut Viewer**: CIELAB coordinate scaffold with crisp CSS2D labels, per-vertex true-colour profile gamut shading, layer visibility toggles and opacity sliders, camera reset (press **R**), touch controls, and bundled sRGB reference wireframe comparison. + - **Install Profile to System (#223)**: After a successful verification, copy the ICC/ICM into the OS colour-management store (Windows ICM Color folder, macOS ColorSync Profiles, Linux colord / `~/.local/share/icc`) without moving the working-directory artefact. Collisions prompt Overwrite / Rename / Cancel. - πŸ“Š **CGATS Dataset Interoperability (#94)**: Native parser for external CGATS and Argyll `.ti3` datasets with canonical normalization (0–255 scaling, field aliasing, metadata synthesis) and direct-jump workflows to Stage 4 (Profile Calculation) and Stage 5 (Verification). - πŸ“‹ **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation. - 🍎 **macOS Universal Binary**: Native Apple Silicon (`arm64`) and Intel (`x86_64`) support with universal binary bundling and fallback resolution. @@ -54,10 +56,12 @@ flowchart TD QualityStore[Verification History & Drift Analytics] PrintEngine["Raw Print Subsystem (GDI / CUPS / NSPrintPanel)"] ProcMgr[Async Subprocess IPC Manager] + CalStore[Calibration .cal library] UI <--> State State <--> ProcMgr State <--> QualityStore + State <--> CalStore ProcMgr --> ThreeJS UI --> PrintEngine QualityStore --> UI @@ -67,6 +71,7 @@ flowchart TD BIN_TAR[targen] BIN_PRT[printtarg] BIN_CHR[chartread] + BIN_CAL[printcal / applycal] BIN_COL[colprof] BIN_CHK[profcheck] BIN_GAM[iccgamut] @@ -75,6 +80,7 @@ flowchart TD ProcMgr -- stdin/stdout/stderr pipes --> BIN_TAR ProcMgr -- stdin/stdout/stderr pipes --> BIN_PRT ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHR + ProcMgr -- stdin/stdout/stderr pipes --> BIN_CAL ProcMgr -- stdin/stdout/stderr pipes --> BIN_COL ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHK ProcMgr -- stdin/stdout/stderr pipes --> BIN_GAM @@ -88,7 +94,7 @@ flowchart TD - [Node.js](https://nodejs.org/) (v18 or newer) - [Rust](https://www.rust-lang.org/) (1.78+ stable) - Operating system dependencies: - - **macOS**: macOS 11.0 (Big Sur) or newer, Xcode Command Line Tools (`xcode-select --install`). + - **macOS**: macOS 12.0 (Monterey) or newer, Xcode Command Line Tools (`xcode-select --install`). - **Windows**: Microsoft Visual Studio C++ Build Tools & WebView2 runtime. - **Linux (Debian/Ubuntu)**: `libwebkit2gtk-4.1-dev`, `build-essential`, `curl`, `wget`, `file`, `libxdo-dev`, `libssl-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libcups2-dev`. @@ -127,6 +133,40 @@ npm run tauri build --- +## Platform support + +### macOS + +The packaged app declares `LSMinimumSystemVersion = 12.0`. Installers refuse Catalina and Big Sur rather than launching into a WKWebView crash loop. + +| macOS | Status | +|---|---| +| 13+ (Ventura and newer), Apple Silicon | Supported | +| 13+, Intel | Supported | +| 12.7.x Monterey, Apple Silicon | Supported, WebGL best-effort | +| 12.0–12.6 Monterey, Intel | Best-effort; WebGL is deferred until Stage 5; known WKWebView GPU process crashes | +| 11 Big Sur | Not supported (installer refuses) | +| 10.15 Catalina | Not supported | + +The 3D gamut viewer (Stage 5) creates a WebGL context only when that stage is shown. Stages 1–4 remain usable if WebGL is missing or the GPU process is lost. + +### macOS troubleshooting + +If the window flashes white and disappears, this is almost always the WKWebView **Web Content** or **GPU** helper dying β€” Apple Crash Reporter will not attach to `ICCery.app`. + +- Launch from Terminal to see `web content process terminated`: + ```text + /Applications/ICCery.app/Contents/MacOS/ICCery + ``` +- Check `~/Library/Logs/DiagnosticReports` for `com.apple.WebKit.WebContent` or `com.apple.WebKit.GPU`. +- ICCery log file (rotated, last 5 segments kept): + ```text + ~/Library/Logs/com.gronod.iccery/iccery.log + ``` +- Custom ColorSync display profiles can crash toolkit UIs on Monterey. Testing with the default display profile (or Safe Mode) is a valid support question. + +--- + ## Licence The ICCery GUI application is proprietary software licensed under the terms of the [EULA](LICENCE.md). ArgyllCMS binaries and source code are licensed under the GNU Affero General Public License (AGPLv3). diff --git a/ROADMAP.md b/ROADMAP.md index 7d0c931..3cb89e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ ICCery is a native, cross-platform desktop application built with: - **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout. - **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons. - **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`). -- **Current Version**: `v0.8.4` (Production release). +- **Current Version**: `v0.8.5` (Production release). --- @@ -116,6 +116,11 @@ ICCery is a native, cross-platform desktop application built with: ### Maintenance & Reliability Release (`v0.8.4`) - [x] **Atomic Verification History Persistence (#213)**: Hardened `quality_store.rs` with atomic temporary file writes (`.tmp`), explicit flush/sync, and atomic rename to prevent historical drift data loss or corruption upon unexpected system crashes. - [x] **Frontend Unit Testing & CI Integration (#215)**: Added standard `npm test` script executing the 3 frontend test suites (`profcheck`, `chartread`, and `gamut_viewer`) and integrated automated frontend test validation into macOS, Linux, and Windows CI workflows. +- [x] **macOS Monterey WKWebView survival (#225)**: Deferred Stage 5 WebGL until the gamut viewer is shown, hid the main window until first paint, painted a dark WKWebView backing, logged Web Content termination, and raised `minimumSystemVersion` to 12.0. + +### Printer Calibration Release (`v0.8.5`) +- [x] **Printer Calibration Curves (#224)**: Optional Stage 0 dashboard for `printcal` linearization and ink limits. `CAL_` artefacts, Apply Calibration toggle feeding `printtarg -K` and `applycal`, channel-response plots, stale-cal warnings, and project/library persistence. +- [x] **System-Wide Profile Installation (#223)**: Stage 5 β€œInstall Profile to System” copies the verified ICC/ICM into the platform colour store (user or system), with overwrite/rename/cancel, elevation guidance, and a note when printcal curves were applied. --- diff --git a/package.json b/package.json index d8e26bc..c0e9798 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "iccery", "private": true, - "version": "0.8.4", + "version": "0.8.5", "type": "module", "scripts": { "fetch-argyll": "node scripts/fetch-argyll.mjs", "tauri": "tauri", - "test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js" + "test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js && node src/js/calibration.test.js && node src/js/profile_install.test.js" }, "devDependencies": { "@tauri-apps/cli": "^2" diff --git a/scripts/build-dmg.py b/scripts/build-dmg.py new file mode 100644 index 0000000..0ede7c8 --- /dev/null +++ b/scripts/build-dmg.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Headless macOS DMG Builder for ICCery. + +Uses `dmgbuild` to package the .app bundle into a styled DMG with custom +background art, window bounds, and icon locations without requiring Finder +AppleScript automation or an interactive display session. +""" + +import argparse +import os +import sys + +def parse_args(): + parser = argparse.ArgumentParser(description="Build styled macOS DMG installer") + parser.add_argument( + "--app", + required=True, + help="Path to the .app application bundle", + ) + parser.add_argument( + "--output", + required=True, + help="Path to the output .dmg file", + ) + parser.add_argument( + "--volname", + default="ICCery", + help="Volume name for the mounted disk image (default: ICCery)", + ) + parser.add_argument( + "--background", + default="src-tauri/icons/dmg-background.png", + help="Path to background image (default: src-tauri/icons/dmg-background.png)", + ) + parser.add_argument( + "--icon", + default="src-tauri/icons/icon.icns", + help="Path to volume icon .icns (default: src-tauri/icons/icon.icns)", + ) + parser.add_argument( + "--window-size", + nargs=2, + type=int, + default=[660, 400], + metavar=("WIDTH", "HEIGHT"), + help="Window width and height in points (default: 660 400)", + ) + parser.add_argument( + "--icon-size", + type=int, + default=100, + help="Icon size in points (default: 100)", + ) + parser.add_argument( + "--app-pos", + nargs=2, + type=int, + default=[180, 220], + metavar=("X", "Y"), + help="App icon location (default: 180 220)", + ) + parser.add_argument( + "--apps-link-pos", + nargs=2, + type=int, + default=[480, 220], + metavar=("X", "Y"), + help="Applications symlink location (default: 480 220)", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + + app_path = os.path.abspath(args.app) + output_path = os.path.abspath(args.output) + bg_path = os.path.abspath(args.background) if args.background else None + icon_path = os.path.abspath(args.icon) if args.icon else None + + if not os.path.isdir(app_path): + print(f"Error: Application bundle does not exist at '{app_path}'", file=sys.stderr) + sys.exit(1) + + if bg_path and not os.path.isfile(bg_path): + print(f"Error: Background image not found at '{bg_path}'", file=sys.stderr) + sys.exit(1) + + try: + import dmgbuild + except ImportError: + print("Error: 'dmgbuild' is required. Install via: pip install dmgbuild", file=sys.stderr) + sys.exit(1) + + app_name = os.path.basename(app_path) + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + if os.path.exists(output_path): + os.remove(output_path) + + win_w, win_h = args.window_size + app_x, app_y = args.app_pos + apps_x, apps_y = args.apps_link_pos + + settings = { + "files": [app_path], + "symlinks": {"Applications": "/Applications"}, + "background": bg_path, + "icon": icon_path if icon_path and os.path.isfile(icon_path) else None, + "icon_size": args.icon_size, + "window_rect": ((100, 100), (win_w, win_h)), + "icon_locations": { + app_name: (app_x, app_y), + "Applications": (apps_x, apps_y), + }, + "format": "UDZO", + } + + print(f"Building DMG for {app_name}...") + print(f" Volume Name: {args.volname}") + print(f" App Bundle: {app_path}") + print(f" Background: {bg_path}") + print(f" Window Size: {win_w}x{win_h}") + print(f" App Position: ({app_x}, {app_y})") + print(f" Apps Position: ({apps_x}, {apps_y})") + print(f" Output Path: {output_path}") + + try: + dmgbuild.build_dmg(output_path, args.volname, settings=settings) + except Exception as e: + print(f"Error: dmgbuild failed: {e}", file=sys.stderr) + sys.exit(1) + + if not os.path.isfile(output_path): + print(f"Error: Expected output DMG file '{output_path}' was not created", file=sys.stderr) + sys.exit(1) + + size_mb = os.path.getsize(output_path) / (1024 * 1024) + print(f"Successfully generated DMG ({size_mb:.2f} MB): {output_path}") + + +if __name__ == "__main__": + main() diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 63cde58..11eaf7b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "iccery" -version = "0.8.4" +version = "0.8.5" dependencies = [ "base64 0.22.1", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bdc0735..ed60ad8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iccery" -version = "0.8.4" +version = "0.8.5" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" @@ -21,7 +21,7 @@ include = [ [lib] # The `_lib` suffix may seem redundant but it is necessary # to make the lib name unique and wouldn't conflict with the bin name. -# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +# This is only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 name = "iccery_lib" crate-type = ["staticlib", "cdylib", "rlib"] diff --git a/src-tauri/src/calibration.rs b/src-tauri/src/calibration.rs new file mode 100644 index 0000000..f014e04 --- /dev/null +++ b/src-tauri/src/calibration.rs @@ -0,0 +1,1083 @@ +//! Printer linearization via ArgyllCMS `printcal` / `applycal` / `targen` (#224). +//! +//! All orchestration stays in the proprietary host. Argyll binaries are invoked +//! as isolated subprocesses β€” never linked. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager, State}; + +use crate::commands::{resolve_binary, resolve_safe_cwd}; +use crate::process_manager::ProcessManager; + +pub const CAL_PREFIX: &str = "CAL_"; +pub const DEFAULT_STEPS: u32 = 21; +pub const MIN_STEPS: u32 = 11; +pub const MAX_STEPS: u32 = 51; +pub const DEFAULT_STALE_DAYS: u32 = 30; +pub const PROJECT_STATE_FILENAME: &str = "iccery-calibration.json"; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PrintcalTargetConfig { + pub colour_space: String, + #[serde(default = "default_steps")] + pub steps_per_channel: u32, + pub ink_limit_exploration: Option, + pub channels: Option, + #[serde(default)] + pub white_patches: Option, + #[serde(default)] + pub neutral_emphasis: bool, + pub basename: String, + pub cwd: String, +} + +fn default_steps() -> u32 { + DEFAULT_STEPS +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PrintcalConfig { + pub ti3_basename: String, + pub cwd: String, + pub output_cal: Option, + pub previous_cal: Option, + #[serde(default)] + pub force_overwrite: bool, + #[serde(default)] + pub no_ink_limit: bool, + #[serde(default)] + pub verify: bool, + pub total_ink_limit: Option, + #[serde(default)] + pub channel_limits: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct ChannelLimit { + pub channel: String, + pub percent: f64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ApplycalConfig { + pub cal_path: String, + pub input_path: String, + pub output_path: Option, + #[serde(default)] + pub unapply: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct CalCurve { + pub channel: String, + pub points: Vec<[f64; 2]>, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct CalMetadata { + pub path: String, + pub filename: String, + pub color_rep: Option, + pub created: Option, + pub description: Option, + pub modified_ms: u128, + pub age_days: f64, + pub ink_limits: Vec, + pub total_ink_limit: Option, + pub curves: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct CalResult { + pub cal_path: String, + pub stdout: String, + pub stderr: String, + pub ink_limits: Vec, + pub total_ink_limit: Option, + pub recommended_power: Option, + pub metadata: Option, + pub message: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct ProjectCalibrationState { + pub cal_path: Option, + pub apply_enabled: bool, + pub printer_name: Option, + pub colour_space: Option, + pub created: Option, + pub cal_basename: Option, + pub ink_limit_overrides: Vec, + pub total_ink_override: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ApplycalResult { + pub output_path: String, + pub stdout: String, + pub message: String, +} + +pub fn clamp_steps(steps: u32) -> u32 { + steps.clamp(MIN_STEPS, MAX_STEPS) +} + +pub fn calibration_basename(profile_basename: &str) -> String { + let trimmed = profile_basename.trim(); + let base = trimmed + .strip_prefix(CAL_PREFIX) + .unwrap_or(trimmed) + .trim(); + let base = if base.is_empty() { "printer" } else { base }; + format!("{CAL_PREFIX}{base}") +} + +pub fn is_calibration_basename(basename: &str) -> bool { + basename.trim().starts_with(CAL_PREFIX) +} + +/// Build `targen` arguments for a short per-channel calibration chart. +/// +/// RGB: `-d2 -s{N} -g{N}` (and optional `-e` white patches). +/// CMYK: `-d4 -s{N} -g{N}` plus optional `-l` TAC exploration. +pub fn build_calibration_targen_args(config: &PrintcalTargetConfig) -> Result, String> { + let basename = sanitize_cal_basename(&config.basename)?; + let steps = clamp_steps(config.steps_per_channel); + let is_cmyk = config.colour_space.eq_ignore_ascii_case("cmyk"); + + let mut args = vec!["-v".to_string(), "-d".to_string()]; + args.push(if is_cmyk { "4".to_string() } else { "2".to_string() }); + + args.push("-s".to_string()); + args.push(steps.to_string()); + args.push("-g".to_string()); + args.push(steps.to_string()); + + if config.neutral_emphasis { + args.push("-n".to_string()); + args.push(steps.to_string()); + } + + if let Some(white) = config.white_patches { + if white > 0 { + args.push("-e".to_string()); + args.push(white.to_string()); + } + } else { + args.push("-e".to_string()); + args.push("4".to_string()); + } + + if is_cmyk { + if let Some(limit) = config.ink_limit_exploration { + if (200..=400).contains(&limit) { + args.push("-l".to_string()); + args.push(limit.to_string()); + } + } + } + + // Full-spread patches are not useful on a calibration wedge. + args.push("-f".to_string()); + args.push("0".to_string()); + + args.push(basename); + Ok(args) +} + +pub fn build_printcal_args(config: &PrintcalConfig) -> Result, String> { + let basename = sanitize_cal_basename(&config.ti3_basename)?; + let mut args = vec!["-v".to_string(), "-e".to_string()]; + + if config.no_ink_limit { + args.push("-I".to_string()); + } + if config.verify { + args.push("-z".to_string()); + } + if let Some(ref prev) = config.previous_cal { + let trimmed = prev.trim(); + if !trimmed.is_empty() { + args.push("-a".to_string()); + args.push(trimmed.to_string()); + } + } + if let Some(tac) = config.total_ink_limit { + if tac > 0.0 { + args.push("-m".to_string()); + args.push(format!("{tac:.1}")); + } + } + for limit in &config.channel_limits { + let ch = limit.channel.trim(); + if ch.is_empty() { + continue; + } + let flag = format!("-x{}", ch.chars().next().unwrap_or('C')); + args.push(flag); + args.push(format!("{:.1}", limit.percent)); + } + + let output = config + .output_cal + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("{basename}.cal")); + args.push("-o".to_string()); + args.push(output); + args.push(basename); + Ok(args) +} + +pub fn build_applycal_args(config: &ApplycalConfig) -> Result, String> { + let cal = config.cal_path.trim(); + let input = config.input_path.trim(); + if cal.is_empty() { + return Err("calibration file path is empty".to_string()); + } + if input.is_empty() { + return Err("input profile path is empty".to_string()); + } + let mut args = vec!["-v".to_string()]; + if config.unapply { + args.push("-u".to_string()); + } else { + args.push("-a".to_string()); + } + args.push(cal.to_string()); + args.push(input.to_string()); + if let Some(ref out) = config.output_path { + let trimmed = out.trim(); + if !trimmed.is_empty() { + args.push(trimmed.to_string()); + } + } + Ok(args) +} + +pub fn sanitize_cal_basename(basename: &str) -> Result { + let name = basename.trim(); + if name.is_empty() { + return Err("basename is empty".to_string()); + } + if name.contains('/') || name.contains('\\') || name.contains("..") { + return Err("basename must not contain path separators".to_string()); + } + Ok(name.to_string()) +} + +pub fn parse_printcal_stdout(stdout: &str) -> (Vec, Option, Option) { + let mut limits = Vec::new(); + let mut total = None; + let mut power = None; + + for raw in stdout.lines() { + let line = raw.trim(); + let lower = line.to_ascii_lowercase(); + + if lower.contains("ideal power") || lower.contains("device power") || lower.contains("power value") { + if let Some(v) = first_number(line) { + power = Some(v); + } + } + + if lower.contains("total") && (lower.contains("ink") || lower.contains("tac") || lower.contains("limit")) { + if let Some(v) = first_number(line) { + total = Some(v); + } + continue; + } + + if let Some(ch) = channel_from_limit_line(line) { + if let Some(v) = first_number(line) { + limits.push(ChannelLimit { + channel: ch, + percent: v, + }); + } + } + } + + (limits, total, power) +} + +fn channel_from_limit_line(line: &str) -> Option { + let t = line.trim(); + let letters = ["Cyan", "Magenta", "Yellow", "Black", "Red", "Green", "Blue"]; + let shorts = ["C", "M", "Y", "K", "R", "G", "B"]; + for (full, short) in letters.iter().zip(shorts) { + if t.starts_with(full) || t.starts_with(&format!("{full}:")) || t.starts_with(&format!("{short}:")) || t.starts_with(&format!("{short} ")) { + return Some((*short).to_string()); + } + } + None +} + +fn first_number(line: &str) -> Option { + let mut buf = String::new(); + let mut seen_digit = false; + for ch in line.chars() { + if ch.is_ascii_digit() || (ch == '.' && seen_digit && !buf.contains('.')) { + buf.push(ch); + seen_digit = true; + } else if seen_digit { + break; + } + } + if buf.is_empty() { + None + } else { + buf.parse().ok() + } +} + +pub fn parse_cal_file(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + parse_cal_contents(path, &content) +} + +pub fn parse_cal_contents(path: &Path, content: &str) -> Result { + let mut color_rep = None; + let mut created = None; + let mut description = None; + let mut total_ink_limit = None; + let mut format_fields: Vec = Vec::new(); + let mut in_format = false; + let mut in_data = false; + let mut rows: Vec> = Vec::new(); + let mut ink_limits: Vec = Vec::new(); + + for raw in content.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.eq_ignore_ascii_case("BEGIN_DATA_FORMAT") { + in_format = true; + continue; + } + if line.eq_ignore_ascii_case("END_DATA_FORMAT") { + in_format = false; + continue; + } + if line.eq_ignore_ascii_case("BEGIN_DATA") { + in_data = true; + continue; + } + if line.eq_ignore_ascii_case("END_DATA") { + in_data = false; + continue; + } + if in_format { + format_fields.extend( + line.split_whitespace() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + ); + continue; + } + if in_data { + let nums: Vec = line + .split_whitespace() + .filter_map(|t| t.parse().ok()) + .collect(); + if !nums.is_empty() { + rows.push(nums); + } + continue; + } + + let (key, value) = split_cgats_kv(line); + match key.to_ascii_uppercase().as_str() { + "COLOR_REP" | "COLORANT_COLOURSPACE" => color_rep = Some(unquote(&value)), + "CREATED" => created = Some(unquote(&value)), + "DESCRIPTOR" | "DESCRIPTION" => description = Some(unquote(&value)), + "MAX_TAC" | "TOTAL_INK_LIMIT" | "INK_LIMIT" => { + total_ink_limit = unquote(&value).parse().ok(); + } + other if other.starts_with("INK_LIMIT_") => { + let ch = other.rsplit('_').next().unwrap_or("").to_string(); + if let Ok(percent) = unquote(&value).parse::() { + ink_limits.push(ChannelLimit { channel: ch, percent }); + } + } + _ => {} + } + } + + let curves = curves_from_rows(&format_fields, &rows); + if ink_limits.is_empty() { + ink_limits = infer_channel_limits_from_curves(&curves); + } + + let modified_ms = fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis()) + .unwrap_or(0); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(modified_ms); + let age_days = if modified_ms == 0 { + 0.0 + } else { + (now_ms.saturating_sub(modified_ms) as f64) / 86_400_000.0 + }; + + Ok(CalMetadata { + path: path.to_string_lossy().to_string(), + filename: path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown.cal".to_string()), + color_rep, + created, + description, + modified_ms, + age_days, + ink_limits, + total_ink_limit, + curves, + }) +} + +fn split_cgats_kv(line: &str) -> (String, String) { + let mut parts = line.splitn(2, char::is_whitespace); + let key = parts.next().unwrap_or("").to_string(); + let value = parts.next().unwrap_or("").trim().to_string(); + (key, value) +} + +fn unquote(s: &str) -> String { + s.trim().trim_matches('"').to_string() +} + +fn curves_from_rows(fields: &[String], rows: &[Vec]) -> Vec { + if fields.is_empty() || rows.is_empty() { + return Vec::new(); + } + let input_idx = fields.iter().position(|f| { + let u = f.to_ascii_uppercase(); + u.ends_with("_I") || u == "RGB_I" || u == "CMYK_I" || u == "GRAY_I" + }); + let Some(input_idx) = input_idx else { + return Vec::new(); + }; + + let mut curves = Vec::new(); + for (idx, field) in fields.iter().enumerate() { + if idx == input_idx { + continue; + } + let channel = channel_from_field(field); + if channel.is_empty() { + continue; + } + let mut points = Vec::new(); + for row in rows { + if row.len() > input_idx && row.len() > idx { + points.push([row[input_idx], row[idx]]); + } + } + if !points.is_empty() { + curves.push(CalCurve { channel, points }); + } + } + curves +} + +fn channel_from_field(field: &str) -> String { + let u = field.to_ascii_uppercase(); + if let Some(rest) = u.strip_prefix("RGB_") { + return rest.to_string(); + } + if let Some(rest) = u.strip_prefix("CMYK_") { + return rest.to_string(); + } + if u.contains("CYAN") || u.ends_with("_C") { + return "C".to_string(); + } + if u.contains("MAGENTA") || u.ends_with("_M") { + return "M".to_string(); + } + if u.contains("YELLOW") || u.ends_with("_Y") { + return "Y".to_string(); + } + if u.contains("BLACK") || u.ends_with("_K") { + return "K".to_string(); + } + field.to_string() +} + +fn infer_channel_limits_from_curves(curves: &[CalCurve]) -> Vec { + curves + .iter() + .filter_map(|c| { + let max_out = c.points.iter().map(|p| p[1]).fold(0.0_f64, f64::max); + if max_out <= 0.0 { + None + } else { + Some(ChannelLimit { + channel: c.channel.clone(), + percent: (max_out * 100.0).clamp(0.0, 100.0), + }) + } + }) + .collect() +} + +pub fn is_cal_stale(age_days: f64, stale_days: u32) -> bool { + age_days > f64::from(stale_days.max(1)) +} + +pub fn list_cal_files_in_dir(dir: &Path) -> Vec { + let mut out = Vec::new(); + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return out, + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let ext = path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if ext != "cal" { + continue; + } + if let Ok(meta) = parse_cal_file(&path) { + out.push(meta); + } + } + out.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms)); + out +} + +pub fn project_state_path(cwd: &Path) -> PathBuf { + cwd.join(PROJECT_STATE_FILENAME) +} + +pub fn load_project_state(cwd: &Path) -> ProjectCalibrationState { + let path = project_state_path(cwd); + fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +pub fn save_project_state(cwd: &Path, state: &ProjectCalibrationState) -> Result<(), String> { + fs::create_dir_all(cwd).map_err(|e| e.to_string())?; + let path = project_state_path(cwd); + let tmp = cwd.join(format!("{PROJECT_STATE_FILENAME}.tmp")); + let json = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?; + fs::write(&tmp, json).map_err(|e| e.to_string())?; + fs::rename(&tmp, path).map_err(|e| e.to_string()) +} + +fn library_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("calibrations"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +#[tauri::command] +pub async fn generate_calibration_target( + app: AppHandle, + state: State<'_, ProcessManager>, + config: PrintcalTargetConfig, +) -> Result { + let basename = calibration_basename(&config.basename); + let mut cfg = config; + cfg.basename = basename.clone(); + let args = build_calibration_targen_args(&cfg)?; + let binary = resolve_binary(app.clone(), "targen".to_string()).await?; + let cwd = Some(resolve_safe_cwd(&app, &cfg.cwd)?); + let id = format!("targen_{basename}"); + state.spawn(app, id, binary, args, cwd).await?; + Ok(basename) +} + +#[tauri::command] +pub async fn compute_calibration_curves( + app: AppHandle, + config: PrintcalConfig, +) -> Result { + let cwd = resolve_safe_cwd(&app, &config.cwd)?; + let basename = sanitize_cal_basename(&config.ti3_basename)?; + let ti3 = Path::new(&cwd).join(format!("{basename}.ti3")); + if !ti3.is_file() { + return Err(format!( + "Measurement file not found: {}. Measure the calibration chart before computing curves.", + ti3.display() + )); + } + + let output_name = config + .output_cal + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + if s.to_ascii_lowercase().ends_with(".cal") { + s.to_string() + } else { + format!("{s}.cal") + } + }) + .unwrap_or_else(|| format!("{basename}.cal")); + if output_name.contains('/') || output_name.contains('\\') || output_name.contains("..") { + return Err("output calibration filename is invalid".to_string()); + } + let dest = Path::new(&cwd).join(&output_name); + if dest.exists() && !config.force_overwrite { + return Err(format!( + "Calibration file already exists: {}. Choose Overwrite, Rename, or Cancel.", + dest.display() + )); + } + + let mut run_cfg = config.clone(); + run_cfg.output_cal = Some(output_name.clone()); + let args = build_printcal_args(&run_cfg)?; + let binary = resolve_binary(app.clone(), "printcal".to_string()).await?; + let (code, stdout, stderr) = run_captured(&binary, &args, &cwd).await?; + if code != 0 { + return Err(format!( + "printcal exited with code {code}. {}", + stderr.lines().last().unwrap_or("No stderr.") + )); + } + if !dest.is_file() { + return Err(format!( + "printcal reported success but {} was not created.", + dest.display() + )); + } + + let (mut ink_limits, mut total, power) = parse_printcal_stdout(&stdout); + let metadata = parse_cal_file(&dest).ok(); + if let Some(ref meta) = metadata { + if ink_limits.is_empty() { + ink_limits = meta.ink_limits.clone(); + } + if total.is_none() { + total = meta.total_ink_limit; + } + } + + log::info!( + target: "calibration", + "Computed calibration {} (limits: {:?}, TAC: {:?})", + dest.display(), + ink_limits, + total + ); + + Ok(CalResult { + cal_path: dest.to_string_lossy().to_string(), + stdout, + stderr, + ink_limits, + total_ink_limit: total, + recommended_power: power, + metadata, + message: format!("Saved calibration curves to {}", dest.display()), + }) +} + +#[tauri::command] +pub async fn apply_calibration(app: AppHandle, config: ApplycalConfig) -> Result { + let cal = PathBuf::from(config.cal_path.trim()); + if !cal.is_file() { + return Err(format!("Calibration file not found: {}", cal.display())); + } + let input = PathBuf::from(config.input_path.trim()); + if !input.is_file() { + return Err(format!("Input file not found: {}", input.display())); + } + + let output = match config.output_path.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(p) => PathBuf::from(p), + None => input.clone(), + }; + + let tmp = if output == input { + let mut t = input.clone(); + t.set_extension("applycal.tmp"); + t + } else { + output.clone() + }; + + let run_cfg = ApplycalConfig { + cal_path: cal.to_string_lossy().to_string(), + input_path: input.to_string_lossy().to_string(), + output_path: Some(tmp.to_string_lossy().to_string()), + unapply: config.unapply, + }; + let args = build_applycal_args(&run_cfg)?; + let binary = resolve_binary(app.clone(), "applycal".to_string()).await?; + let cwd = input + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + let (code, stdout, stderr) = run_captured(&binary, &args, &cwd).await?; + if code != 0 { + let _ = fs::remove_file(&tmp); + return Err(format!( + "applycal exited with code {code}. {}", + stderr.lines().last().unwrap_or("No stderr.") + )); + } + if tmp != output { + fs::rename(&tmp, &output).map_err(|e| format!("Failed to replace profile with calibrated copy: {e}"))?; + } + log::info!( + target: "calibration", + "Applied {} to {} -> {}", + cal.display(), + input.display(), + output.display() + ); + Ok(ApplycalResult { + output_path: output.to_string_lossy().to_string(), + stdout, + message: format!("Applied calibration to {}", output.display()), + }) +} + +#[tauri::command] +pub fn parse_cal_file_cmd(path: String) -> Result { + parse_cal_file(Path::new(&path)) +} + +#[tauri::command] +pub fn list_saved_calibrations(app: AppHandle, cwd: Option) -> Result, String> { + let mut all = Vec::new(); + if let Some(dir) = cwd { + if !dir.trim().is_empty() { + all.extend(list_cal_files_in_dir(Path::new(&dir))); + } + } + if let Ok(lib) = library_dir(&app) { + for meta in list_cal_files_in_dir(&lib) { + if !all.iter().any(|m| m.path == meta.path) { + all.push(meta); + } + } + } + all.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms)); + Ok(all) +} + +#[tauri::command] +pub fn save_calibration_to_library(app: AppHandle, cal_path: String) -> Result { + let src = Path::new(&cal_path); + if !src.is_file() { + return Err(format!("Calibration file not found: {cal_path}")); + } + let lib = library_dir(&app)?; + let name = src + .file_name() + .ok_or_else(|| "invalid calibration filename".to_string())?; + let dest = lib.join(name); + fs::copy(src, &dest).map_err(|e| format!("Failed to copy into library: {e}"))?; + Ok(dest.to_string_lossy().to_string()) +} + +#[tauri::command] +pub async fn select_cal_file( + app: AppHandle, + default_dir: Option, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let mut builder = app + .dialog() + .file() + .add_filter("Argyll calibration (*.cal)", &["cal"]); + if let Some(ref dir) = default_dir { + if !dir.trim().is_empty() { + builder = builder.set_directory(PathBuf::from(dir)); + } + } + let (tx, rx) = tokio::sync::oneshot::channel(); + builder.pick_file(move |file_path| { + let res = file_path.map(|p| p.to_string()); + let _ = tx.send(res); + }); + rx.await.map_err(|e| format!("Dialog channel error: {e}")) +} + +#[tauri::command] +pub fn load_project_calibration(cwd: String) -> Result { + if cwd.trim().is_empty() { + return Ok(ProjectCalibrationState::default()); + } + Ok(load_project_state(Path::new(&cwd))) +} + +#[tauri::command] +pub fn save_project_calibration(cwd: String, state: ProjectCalibrationState) -> Result<(), String> { + if cwd.trim().is_empty() { + return Err("working directory is empty".to_string()); + } + save_project_state(Path::new(&cwd), &state) +} + +async fn run_captured(binary: &str, args: &[String], cwd: &str) -> Result<(i32, String, String), String> { + let mut cmd = tokio::process::Command::new(binary); + cmd.args(args) + .current_dir(cwd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .env("ARGYLL_NOT_INTERACTIVE", "1"); + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + log::info!( + target: "subprocess", + "Running captured {} {:?}", + crate::process_manager::sanitize_arg_for_logging(binary), + crate::process_manager::sanitize_args_for_logging(args) + ); + let output = cmd + .output() + .await + .map_err(|e| format!("Failed to launch {binary}: {e}"))?; + let code = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Ok((code, stdout, stderr)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rgb_target() -> PrintcalTargetConfig { + PrintcalTargetConfig { + colour_space: "rgb".to_string(), + steps_per_channel: 21, + ink_limit_exploration: Some(320), + channels: None, + white_patches: None, + neutral_emphasis: false, + basename: "photo".to_string(), + cwd: "/tmp".to_string(), + } + } + + #[test] + fn test_calibration_basename_prefix() { + assert_eq!(calibration_basename("photo"), "CAL_photo"); + assert_eq!(calibration_basename("CAL_photo"), "CAL_photo"); + assert_eq!(calibration_basename(" "), "CAL_printer"); + assert!(is_calibration_basename("CAL_photo")); + assert!(!is_calibration_basename("photo")); + } + + #[test] + fn test_clamp_steps() { + assert_eq!(clamp_steps(5), 11); + assert_eq!(clamp_steps(21), 21); + assert_eq!(clamp_steps(99), 51); + } + + #[test] + fn test_build_calibration_targen_rgb() { + let args = build_calibration_targen_args(&rgb_target()).unwrap(); + assert!(args.contains(&"-d".to_string())); + assert!(args.contains(&"2".to_string())); + assert!(args.contains(&"-s".to_string())); + assert!(args.contains(&"21".to_string())); + assert!(args.contains(&"-g".to_string())); + assert!(args.contains(&"-f".to_string())); + assert!(args.contains(&"0".to_string())); + assert_eq!(args.last().unwrap(), "photo"); + assert!(!args.contains(&"-l".to_string()), "RGB must not pass -l"); + } + + #[test] + fn test_build_calibration_targen_cmyk_ink_limit() { + let mut cfg = rgb_target(); + cfg.colour_space = "cmyk".to_string(); + cfg.basename = "CAL_press".to_string(); + cfg.neutral_emphasis = true; + let args = build_calibration_targen_args(&cfg).unwrap(); + assert!(args.contains(&"4".to_string())); + assert!(args.contains(&"-l".to_string())); + assert!(args.contains(&"320".to_string())); + assert!(args.contains(&"-n".to_string())); + assert_eq!(args.last().unwrap(), "CAL_press"); + } + + #[test] + fn test_build_calibration_targen_rejects_path() { + let mut cfg = rgb_target(); + cfg.basename = "../escape".to_string(); + assert!(build_calibration_targen_args(&cfg).is_err()); + } + + #[test] + fn test_build_printcal_args_defaults() { + let cfg = PrintcalConfig { + ti3_basename: "CAL_photo".to_string(), + cwd: "/tmp".to_string(), + output_cal: None, + previous_cal: None, + force_overwrite: false, + no_ink_limit: false, + verify: false, + total_ink_limit: None, + channel_limits: vec![], + }; + let args = build_printcal_args(&cfg).unwrap(); + assert_eq!( + args, + vec!["-v", "-e", "-o", "CAL_photo.cal", "CAL_photo"] + ); + } + + #[test] + fn test_build_printcal_args_overrides_and_prev() { + let cfg = PrintcalConfig { + ti3_basename: "CAL_press".to_string(), + cwd: "/tmp".to_string(), + output_cal: Some("press_lin.cal".to_string()), + previous_cal: Some("old.cal".to_string()), + force_overwrite: true, + no_ink_limit: true, + verify: true, + total_ink_limit: Some(280.0), + channel_limits: vec![ChannelLimit { + channel: "C".to_string(), + percent: 95.0, + }], + }; + let args = build_printcal_args(&cfg).unwrap(); + assert!(args.contains(&"-I".to_string())); + assert!(args.contains(&"-z".to_string())); + assert!(args.contains(&"-a".to_string())); + assert!(args.contains(&"old.cal".to_string())); + assert!(args.contains(&"-m".to_string())); + assert!(args.contains(&"280.0".to_string())); + assert!(args.contains(&"-xC".to_string())); + assert!(args.contains(&"95.0".to_string())); + assert!(args.contains(&"press_lin.cal".to_string())); + } + + #[test] + fn test_build_applycal_args() { + let cfg = ApplycalConfig { + cal_path: "lin.cal".to_string(), + input_path: "out.icc".to_string(), + output_path: Some("out_cal.icc".to_string()), + unapply: false, + }; + let args = build_applycal_args(&cfg).unwrap(); + assert_eq!(args, vec!["-v", "-a", "lin.cal", "out.icc", "out_cal.icc"]); + } + + #[test] + fn test_build_applycal_unapply_and_empty() { + let cfg = ApplycalConfig { + cal_path: "".to_string(), + input_path: "out.icc".to_string(), + output_path: None, + unapply: true, + }; + assert!(build_applycal_args(&cfg).is_err()); + let cfg = ApplycalConfig { + cal_path: "lin.cal".to_string(), + input_path: "out.icc".to_string(), + output_path: None, + unapply: true, + }; + let args = build_applycal_args(&cfg).unwrap(); + assert_eq!(args, vec!["-v", "-u", "lin.cal", "out.icc"]); + } + + #[test] + fn test_parse_printcal_stdout_limits() { + let stdout = r#" +printcal: Creating calibration +Ideal power value to apply to the test chart = 1.35 +Ink limits: + Cyan: 96.4% + Magenta: 94.1% + Yellow: 98.0% + Black: 90.2% + Total ink limit: 280.0% +"#; + let (limits, total, power) = parse_printcal_stdout(stdout); + assert_eq!(power, Some(1.35)); + assert_eq!(total, Some(280.0)); + assert_eq!(limits.len(), 4); + assert_eq!(limits[0].channel, "C"); + assert!((limits[0].percent - 96.4).abs() < 0.01); + } + + #[test] + fn test_parse_cal_contents_cmyk_curves() { + let cal = r#" +CAL +DESCRIPTOR "Argyll Device Calibration File" +CREATED "Mon Sep 7 17:00:00 2026" +KEYWORD "COLOR_REP" +COLOR_REP "CMYK" +KEYWORD "MAX_TAC" +MAX_TAC "280.000000" +KEYWORD "NUMBER_OF_FIELDS" +NUMBER_OF_FIELDS 5 +BEGIN_DATA_FORMAT +CMYK_I CMYK_C CMYK_M CMYK_Y CMYK_K +END_DATA_FORMAT +NUMBER_OF_SETS 3 +BEGIN_DATA +0.0 0.00 0.00 0.00 0.00 +0.5 0.42 0.40 0.45 0.38 +1.0 0.95 0.92 0.98 0.90 +END_DATA +"#; + let meta = parse_cal_contents(Path::new("/tmp/demo.cal"), cal).unwrap(); + assert_eq!(meta.color_rep.as_deref(), Some("CMYK")); + assert_eq!(meta.total_ink_limit, Some(280.0)); + assert_eq!(meta.curves.len(), 4); + assert_eq!(meta.curves[0].channel, "C"); + assert_eq!(meta.curves[0].points.len(), 3); + assert!((meta.ink_limits.iter().find(|l| l.channel == "C").unwrap().percent - 95.0).abs() < 0.01); + } + + #[test] + fn test_is_cal_stale() { + assert!(!is_cal_stale(10.0, 30)); + assert!(!is_cal_stale(30.0, 30)); + assert!(is_cal_stale(31.0, 30)); + } + + #[test] + fn test_missing_measurement_error_path_message() { + // The command itself needs a Tauri app; the path construction is covered + // by sanitize + ti3 join used in compute_calibration_curves. + let basename = sanitize_cal_basename("CAL_x").unwrap(); + let ti3 = Path::new("/tmp").join(format!("{basename}.ti3")); + assert_eq!(ti3, Path::new("/tmp/CAL_x.ti3")); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 87fdb13..77cf843 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -105,20 +105,101 @@ pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result, + #[serde(skip_serializing_if = "Option::is_none")] + pub macos_minor: Option, +} + +/// Parse `sw_vers -productVersion` output such as `"12.7.6"` or `"13.0"`. +pub fn parse_macos_product_version(version: &str) -> Option<(u32, u32, u32)> { + let mut parts = version.trim().split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().unwrap_or(0); + let patch = parts.next().unwrap_or("0").parse().unwrap_or(0); + Some((major, minor, patch)) +} + +fn macos_version_from_sw_vers() -> Option<(u32, u32, u32)> { + #[cfg(target_os = "macos")] + { + let output = std::process::Command::new("sw_vers") + .arg("-productVersion") + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_macos_product_version(&String::from_utf8_lossy(&output.stdout)) + } + #[cfg(not(target_os = "macos"))] + { + None + } +} + +pub fn collect_os_info() -> OsInfo { + let (macos_major, macos_minor) = match macos_version_from_sw_vers() { + Some((maj, min, _)) => (Some(maj), Some(min)), + None => (None, None), + }; + OsInfo { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + family: std::env::consts::FAMILY.to_string(), + macos_major, + macos_minor, + } +} + #[derive(Serialize)] pub struct AppInfo { pub version: String, pub build_date: String, + pub os: String, + pub arch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub macos_major: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub macos_minor: Option, +} + +#[tauri::command] +pub fn get_os_info() -> OsInfo { + collect_os_info() } #[tauri::command] pub fn get_app_info() -> AppInfo { + let os = collect_os_info(); AppInfo { version: env!("CARGO_PKG_VERSION").to_string(), build_date: env!("BUILD_DATE").to_string(), + os: os.os, + arch: os.arch, + macos_major: os.macos_major, + macos_minor: os.macos_minor, } } +#[tauri::command] +pub fn show_main_window(app: AppHandle) -> Result<(), String> { + if let Some(win) = app.get_webview_window("main") { + crate::macos_webview::paint_dark_webview(&win); + win.show().map_err(|e| e.to_string())?; + let _ = win.set_focus(); + log::info!("Main window shown after frontend ready"); + } else { + log::warn!("show_main_window: window 'main' not found"); + } + Ok(()) +} + pub fn resolve_safe_cwd(app: &AppHandle, cwd_input: &str) -> Result { if !cwd_input.trim().is_empty() { let p = std::path::Path::new(cwd_input.trim()); @@ -696,6 +777,12 @@ pub struct PrinttargConfig { pub no_randomize: bool, // If true, pass -r (raster layout / no randomization) pub basename: String, // Must match the .ti1 basename from Stage 1 pub cwd: String, // Working directory where the .ti1 file resides + /// Optional Argyll `.cal` applied via printtarg `-K` (or `-I` when embed-only). + #[serde(default)] + pub calibration_file: Option, + /// When true, embed the calibration (`-I`) without applying it to printed patches. + #[serde(default)] + pub calibration_embed_only: bool, } pub fn build_targen_args(config: &TargenConfig) -> Vec { @@ -846,6 +933,18 @@ pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec { } args.push(config.dpi.to_string()); + if let Some(ref cal) = config.calibration_file { + let trimmed = cal.trim(); + if !trimmed.is_empty() { + if config.calibration_embed_only { + args.push("-I".to_string()); + } else { + args.push("-K".to_string()); + } + args.push(trimmed.to_string()); + } + } + args.push(config.basename.clone()); args } @@ -1540,6 +1639,8 @@ mod tests { no_randomize: false, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "100", "my_profile"]); @@ -1557,6 +1658,8 @@ mod tests { no_randomize: false, basename: "cmyk_profile".to_string(), cwd: "/home/user".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-R", "1", "-T", "300", "cmyk_profile"]); @@ -1574,6 +1677,8 @@ mod tests { no_randomize: false, basename: "custom_target".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-R", "1", "-t", "150", "custom_target"]); @@ -1591,6 +1696,8 @@ mod tests { no_randomize: false, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!( @@ -1625,6 +1732,8 @@ mod tests { no_randomize: false, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "42", "-t", "300", "my_profile"]); @@ -1642,11 +1751,56 @@ mod tests { no_randomize: true, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-r", "-t", "300", "my_profile"]); } + #[test] + fn test_build_printtarg_args_with_calibration_apply() { + let config = PrinttargConfig { + instrument: "i1".to_string(), + page_size: "A4".to_string(), + bit_depth: 8, + dpi: 300, + custom_label: None, + random_seed: Some(1), + no_randomize: false, + basename: "my_profile".to_string(), + cwd: "/tmp".to_string(), + calibration_file: Some("CAL_photo.cal".to_string()), + calibration_embed_only: false, + }; + let args = build_printtarg_args(&config); + assert_eq!( + args, + vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "300", "-K", "CAL_photo.cal", "my_profile"] + ); + } + + #[test] + fn test_build_printtarg_args_with_calibration_embed_only() { + let config = PrinttargConfig { + instrument: "i1".to_string(), + page_size: "A4".to_string(), + bit_depth: 8, + dpi: 300, + custom_label: None, + random_seed: Some(1), + no_randomize: false, + basename: "my_profile".to_string(), + cwd: "/tmp".to_string(), + calibration_file: Some("lin.cal".to_string()), + calibration_embed_only: true, + }; + let args = build_printtarg_args(&config); + assert!(args.contains(&"-I".to_string())); + assert!(!args.contains(&"-K".to_string())); + assert!(args.contains(&"lin.cal".to_string())); + } + #[test] fn test_build_chartread_args_auto() { let config = ChartreadConfig { @@ -2096,4 +2250,33 @@ mod tests { let _ = std::fs::remove_dir_all(&temp_dir); } + + #[test] + fn test_parse_macos_product_version() { + assert_eq!(parse_macos_product_version("12.7.6"), Some((12, 7, 6))); + assert_eq!(parse_macos_product_version("13.0"), Some((13, 0, 0))); + assert_eq!(parse_macos_product_version(" 15.1.1\n"), Some((15, 1, 1))); + assert_eq!(parse_macos_product_version(""), None); + assert_eq!(parse_macos_product_version("ventura"), None); + } + + #[test] + fn test_collect_os_info_has_host_os_and_arch() { + let info = collect_os_info(); + assert_eq!(info.os, std::env::consts::OS); + assert_eq!(info.arch, std::env::consts::ARCH); + assert_eq!(info.family, std::env::consts::FAMILY); + if info.os != "macos" { + assert_eq!(info.macos_major, None); + assert_eq!(info.macos_minor, None); + } + } + + #[test] + fn test_get_app_info_includes_os_arch() { + let info = get_app_info(); + assert!(!info.version.is_empty()); + assert_eq!(info.os, std::env::consts::OS); + assert_eq!(info.arch, std::env::consts::ARCH); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a97f780..988ea89 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,11 +2,15 @@ use tauri::Manager; mod cgats; mod commands; +mod calibration; mod events; +mod macos_webview; mod print; mod process_manager; +mod profile_install; mod quality_store; mod settings; +mod window_lifecycle; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -21,6 +25,10 @@ pub fn run() { tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), ]) + // wry / tauri_runtime_wry log "web content process terminated" at info/debug. + // Surface those lines in iccery.log so Monterey GPU deaths are diagnosable. + .level_for("wry", log::LevelFilter::Info) + .level_for("tauri_runtime_wry", log::LevelFilter::Info) .max_file_size(5 * 1024 * 1024) .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll) .build(), @@ -33,6 +41,12 @@ pub fn run() { let filter = settings::parse_log_level_filter(settings.log_level.as_deref()); log::set_max_level(filter); log::info!("ICCery initialized. Effective log level: {:?}", filter); + + if let Some(win) = app.get_webview_window("main") { + // Dark backing before first paint. Do not show() here β€” that races + // the still-white WKWebView. Frontend invokes show_main_window. + macos_webview::paint_dark_webview(&win); + } Ok(()) }) .manage(process_manager::ProcessManager::new()) @@ -40,6 +54,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::spawn_process, commands::get_app_info, + commands::get_os_info, + commands::show_main_window, commands::get_default_working_dir, commands::get_log_path, commands::get_recent_log_excerpt, @@ -78,6 +94,17 @@ pub fn run() { commands::show_printer_properties, commands::print_target_native, commands::select_csv_save_path, + calibration::generate_calibration_target, + calibration::compute_calibration_curves, + calibration::apply_calibration, + calibration::parse_cal_file_cmd, + calibration::list_saved_calibrations, + calibration::save_calibration_to_library, + calibration::select_cal_file, + calibration::load_project_calibration, + calibration::save_project_calibration, + profile_install::install_profile_to_system, + profile_install::get_profile_install_dir, quality_store::save_verification_record, quality_store::get_verification_history, quality_store::clear_verification_history, @@ -95,21 +122,35 @@ pub fn run() { .run(|app_handle, event| { match event { tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } => { + window_lifecycle::mark_user_close_requested(); let pm = app_handle.state::(); tauri::async_runtime::block_on(async { pm.kill_all().await; }); } - tauri::RunEvent::WindowEvent { - event: tauri::WindowEvent::CloseRequested { .. }, - .. - } => { - let pm = app_handle.state::(); - tauri::async_runtime::block_on(async { - pm.kill_all().await; - }); + tauri::RunEvent::WindowEvent { label, event, .. } => { + match event { + tauri::WindowEvent::Destroyed => { + window_lifecycle::on_window_destroyed(&label); + } + tauri::WindowEvent::CloseRequested { .. } => { + window_lifecycle::mark_user_close_requested(); + let pm = app_handle.state::(); + tauri::async_runtime::block_on(async { + pm.kill_all().await; + }); + } + other => { + log::debug!("WindowEvent on {label}: {other:?}"); + } + } + } + other => { + let text = format!("{other:?}"); + if window_lifecycle::is_web_content_termination_event(&text) { + log::error!("WebView lifecycle event: {text}"); + } } - _ => {} } }); } diff --git a/src-tauri/src/macos_webview.rs b/src-tauri/src/macos_webview.rs new file mode 100644 index 0000000..0e2e886 --- /dev/null +++ b/src-tauri/src/macos_webview.rs @@ -0,0 +1,84 @@ +//! Dark native window + WKWebView backing for macOS (#225). +//! +//! `backgroundColor` in `tauri.conf.json` paints the NSWindow, but WKWebView +//! still composites an opaque white layer until `drawsBackground` is disabled +//! and (on macOS 12+) `underPageBackgroundColor` is set. We do **not** set +//! `transparent: true` β€” that changes hit-testing and titlebar compositing. + +/// Theme fallback already used in `index.html` (`var(--bg-card, #1a1a22)`). +pub const DARK_BG_U8: (u8, u8, u8, u8) = (0x1A, 0x1A, 0x22, 0xFF); + +pub fn paint_dark_webview(window: &tauri::WebviewWindow) { + let (r, g, b, a) = DARK_BG_U8; + let _ = window.set_background_color(Some(tauri::window::Color(r, g, b, a))); + + #[cfg(target_os = "macos")] + { + let _ = window.with_webview(|webview| unsafe { + apply_native_dark_backing(&webview); + }); + } +} + +#[cfg(target_os = "macos")] +unsafe fn apply_native_dark_backing(webview: &tauri::webview::PlatformWebview) { + use objc2::runtime::{AnyClass, AnyObject}; + use objc2::{msg_send, sel}; + use objc2_foundation::ns_string; + + let red = 26.0f64 / 255.0; + let green = 26.0f64 / 255.0; + let blue = 34.0f64 / 255.0; + let alpha = 1.0f64; + + let Some(nscolor_cls) = AnyClass::get(c"NSColor") else { + log::warn!("NSColor class missing; skipping dark window backing"); + return; + }; + + let color: *mut AnyObject = msg_send![ + nscolor_cls, + colorWithSRGBRed: red, + green: green, + blue: blue, + alpha: alpha + ]; + + let ns_window = webview.ns_window() as *mut AnyObject; + if !ns_window.is_null() && !color.is_null() { + let _: () = msg_send![ns_window, setBackgroundColor: color]; + } + + let wk = webview.inner() as *mut AnyObject; + if wk.is_null() { + log::warn!("WKWebView inner handle is null; cannot disable white backing"); + return; + } + + // Private KVC key wry uses for transparency / backgroundColor on macOS. + if let Some(nsnumber_cls) = AnyClass::get(c"NSNumber") { + let no: *mut AnyObject = msg_send![nsnumber_cls, numberWithBool: false]; + if !no.is_null() { + let _: () = msg_send![wk, setValue: no, forKey: ns_string!("drawsBackground")]; + } + } + + // Public API on macOS 12+: covers overscroll / unpainted page. + let setter = sel!(setUnderPageBackgroundColor:); + let responds: bool = msg_send![wk, respondsToSelector: setter]; + if responds && !color.is_null() { + let _: () = msg_send![wk, setUnderPageBackgroundColor: color]; + } + + log::info!("Applied dark WKWebView backing (#1A1A22)"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dark_bg_matches_theme_hex() { + assert_eq!(DARK_BG_U8, (26, 26, 34, 255)); + } +} diff --git a/src-tauri/src/profile_install.rs b/src-tauri/src/profile_install.rs new file mode 100644 index 0000000..cc9991d --- /dev/null +++ b/src-tauri/src/profile_install.rs @@ -0,0 +1,533 @@ +//! System-wide ICC/ICM profile installation (#223). +//! +//! Copies a generated profile into the OS colour-management directory and, +//! where available, registers it (Windows ICM, macOS ColorSync, Linux colord). +//! The working-directory artefact is never moved. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; + +const MIN_PROFILE_BYTES: u64 = 128; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct InstallOptions { + #[serde(default)] + pub force_overwrite: bool, + #[serde(default)] + pub prefer_system_wide: bool, + #[serde(default = "default_true")] + pub register_with_os: bool, + /// `overwrite` | `rename` | `cancel` β€” used when the destination exists. + #[serde(default = "default_collision")] + pub collision_policy: String, + #[serde(default)] + pub open_color_panel: bool, + #[serde(default)] + pub calibration_note: Option, +} + +fn default_true() -> bool { + true +} +fn default_collision() -> String { + "cancel".to_string() +} + +impl Default for InstallOptions { + fn default() -> Self { + Self { + force_overwrite: false, + prefer_system_wide: false, + register_with_os: true, + collision_policy: default_collision(), + open_color_panel: false, + calibration_note: None, + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct InstallResult { + pub dest_path: String, + pub registered: bool, + pub overwritten: bool, + pub renamed: bool, + pub opened_panel: bool, + pub message: String, + pub calibration_note: Option, +} + +#[derive(Debug, Clone)] +pub struct InstallEnv { + pub os: String, + pub home: Option, + pub windir: Option, +} + +impl InstallEnv { + pub fn from_process() -> Self { + Self { + os: std::env::consts::OS.to_string(), + home: std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from), + windir: std::env::var_os("WINDIR") + .or_else(|| std::env::var_os("SystemRoot")) + .map(PathBuf::from), + } + } +} + +pub fn profile_extension_for_os(os: &str) -> &'static str { + if os.eq_ignore_ascii_case("windows") { + "icm" + } else { + "icc" + } +} + +pub fn user_profile_dir(env: &InstallEnv) -> Result { + match env.os.as_str() { + "windows" => { + let home = env.home.clone().ok_or_else(|| { + "USERPROFILE is not set; cannot resolve the per-user Color directory.".to_string() + })?; + Ok(home.join("AppData").join("Local").join("Microsoft").join("Windows").join("Color")) + } + "macos" => { + let home = env.home.clone().ok_or_else(|| { + "HOME is not set; cannot resolve ~/Library/ColorSync/Profiles.".to_string() + })?; + Ok(home.join("Library").join("ColorSync").join("Profiles")) + } + _ => { + let home = env.home.clone().ok_or_else(|| { + "HOME is not set; cannot resolve ~/.local/share/icc.".to_string() + })?; + Ok(home.join(".local").join("share").join("icc")) + } + } +} + +pub fn system_profile_dir(env: &InstallEnv) -> Result { + match env.os.as_str() { + "windows" => { + let windir = env.windir.clone().ok_or_else(|| { + "WINDIR is not set; cannot resolve %WINDIR%\\System32\\spool\\drivers\\color.".to_string() + })?; + Ok(windir.join("System32").join("spool").join("drivers").join("color")) + } + "macos" => Ok(PathBuf::from("/Library/ColorSync/Profiles")), + _ => Ok(PathBuf::from("/usr/share/color/icc")), + } +} + +pub fn target_profile_dir(env: &InstallEnv, prefer_system_wide: bool) -> Result { + if prefer_system_wide { + system_profile_dir(env) + } else { + user_profile_dir(env) + } +} + +pub fn dest_filename(source: &Path, os: &str) -> Result { + let stem = source + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| "profile filename is invalid".to_string())?; + if stem.contains("..") || stem.contains('/') || stem.contains('\\') { + return Err("profile filename is invalid".to_string()); + } + Ok(format!("{stem}.{}", profile_extension_for_os(os))) +} + +pub fn timestamped_filename(name: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (stem, ext) = match name.rsplit_once('.') { + Some((s, e)) => (s, e), + None => (name, "icc"), + }; + format!("{stem}-{now}.{ext}") +} + +pub fn verify_source_profile(path: &Path) -> Result { + if !path.is_file() { + return Err(format!("Profile artefact not found: {}", path.display())); + } + let ext = path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if ext != "icc" && ext != "icm" { + return Err("Source file must be an .icc or .icm profile.".to_string()); + } + let len = fs::metadata(path) + .map_err(|e| format!("Cannot stat profile: {e}"))? + .len(); + if len < MIN_PROFILE_BYTES { + return Err(format!( + "Profile is too small ({len} bytes) to be a valid ICC header." + )); + } + Ok(len) +} + +pub fn resolve_destination( + dest_dir: &Path, + filename: &str, + options: &InstallOptions, +) -> Result<(PathBuf, bool, bool), String> { + let dest = dest_dir.join(filename); + if !dest.exists() { + return Ok((dest, false, false)); + } + let policy = options.collision_policy.to_ascii_lowercase(); + if options.force_overwrite || policy == "overwrite" { + return Ok((dest, true, false)); + } + if policy == "rename" { + return Ok((dest_dir.join(timestamped_filename(filename)), false, true)); + } + Err(format!( + "A profile named {filename} already exists at {}. Choose Overwrite, Rename, or Cancel.", + dest.display() + )) +} + +fn copy_atomic(src: &Path, dest: &Path) -> Result<(), String> { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| { + permission_message(parent, &e) + })?; + } + let tmp = dest.with_extension("iccery-install.tmp"); + fs::copy(src, &tmp).map_err(|e| permission_message(&tmp, &e))?; + fs::rename(&tmp, dest).map_err(|e| { + let _ = fs::remove_file(&tmp); + permission_message(dest, &e) + })?; + Ok(()) +} + +fn permission_message(path: &Path, err: &std::io::Error) -> String { + if err.kind() == std::io::ErrorKind::PermissionDenied { + #[cfg(target_os = "windows")] + { + return format!( + "Access denied writing {}. On Windows the system Color folder usually requires 'Run as Administrator'. Retry with elevation, or install to the per-user Color directory instead.", + path.display() + ); + } + #[cfg(target_os = "macos")] + { + return format!( + "Permission denied writing {}. Install to ~/Library/ColorSync/Profiles (no elevation) or authenticate to write /Library/ColorSync/Profiles.", + path.display() + ); + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + return format!( + "Permission denied writing {}. Install to ~/.local/share/icc (no root) or use pkexec/sudo for /usr/share/color/icc.", + path.display() + ); + } + } + format!("Failed to write {}: {err}", path.display()) +} + +fn register_profile(dest: &Path, os: &str) -> bool { + match os { + "windows" => register_windows(dest), + "macos" => true, // ColorSync discovers files in the Profiles folders. + _ => register_colord(dest), + } +} + +fn register_windows(dest: &Path) -> bool { + // Copy into the Color directory is sufficient for most apps. Try the + // classic InstallColorProfile helper when present; ignore failure. + let _ = Command::new("rundll32") + .args([ + "mscms.dll,InstallColorProfileW", + &dest.to_string_lossy(), + ]) + .status(); + true +} + +fn register_colord(dest: &Path) -> bool { + match Command::new("colormgr") + .args(["import-profile", &dest.to_string_lossy()]) + .output() + { + Ok(out) if out.status.success() => true, + _ => false, + } +} + +fn open_color_panel(os: &str) -> bool { + let result = match os { + "windows" => Command::new("colorcpl").status(), + "macos" => Command::new("open").args(["-a", "ColorSync Utility"]).status(), + _ => Command::new("colormgr") + .arg("get-profiles") + .status() + .or_else(|_| Command::new("gnome-control-center").arg("color").status()), + }; + result.map(|s| s.success()).unwrap_or(false) +} + +pub fn install_profile_with_env( + profile_path: &str, + options: &InstallOptions, + env: &InstallEnv, +) -> Result { + let src = PathBuf::from(profile_path.trim()); + let src_size = verify_source_profile(&src)?; + let dir = target_profile_dir(env, options.prefer_system_wide)?; + let filename = dest_filename(&src, &env.os)?; + let (dest, overwritten, renamed) = resolve_destination(&dir, &filename, options)?; + + copy_atomic(&src, &dest)?; + + let dest_size = fs::metadata(&dest) + .map_err(|e| format!("Installed file missing after copy: {e}"))? + .len(); + if dest_size != src_size { + return Err("Installed profile size does not match the working-directory artefact.".to_string()); + } + + let registered = if options.register_with_os { + register_profile(&dest, &env.os) + } else { + false + }; + let opened = if options.open_color_panel { + open_color_panel(&env.os) + } else { + false + }; + + let mut message = format!("Installed profile to {}", dest.display()); + if overwritten { + message.push_str(" (replaced existing file)"); + } else if renamed { + message.push_str(" (renamed to avoid collision)"); + } + if let Some(ref note) = options.calibration_note { + if !note.trim().is_empty() { + message.push_str(". "); + message.push_str(note.trim()); + } + } + + log::info!(target: "profile_install", "{message}"); + + Ok(InstallResult { + dest_path: dest.to_string_lossy().to_string(), + registered, + overwritten, + renamed, + opened_panel: opened, + message, + calibration_note: options.calibration_note.clone(), + }) +} + +#[tauri::command] +pub fn get_profile_install_dir(prefer_system_wide: Option) -> Result { + let env = InstallEnv::from_process(); + let dir = target_profile_dir(&env, prefer_system_wide.unwrap_or(false))?; + Ok(dir.to_string_lossy().to_string()) +} + +#[tauri::command] +pub fn install_profile_to_system( + _app: AppHandle, + profile_path: String, + options: Option, +) -> Result { + let options = options.unwrap_or_default(); + install_profile_with_env(&profile_path, &options, &InstallEnv::from_process()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env_linux() -> InstallEnv { + InstallEnv { + os: "linux".to_string(), + home: Some(PathBuf::from("/home/gordon")), + windir: None, + } + } + fn env_mac() -> InstallEnv { + InstallEnv { + os: "macos".to_string(), + home: Some(PathBuf::from("/Users/gordon")), + windir: None, + } + } + fn env_win() -> InstallEnv { + InstallEnv { + os: "windows".to_string(), + home: Some(PathBuf::from(r"C:\Users\gordon")), + windir: Some(PathBuf::from(r"C:\Windows")), + } + } + + #[test] + fn test_user_and_system_dirs_linux() { + let env = env_linux(); + assert_eq!( + user_profile_dir(&env).unwrap(), + PathBuf::from("/home/gordon/.local/share/icc") + ); + assert_eq!( + system_profile_dir(&env).unwrap(), + PathBuf::from("/usr/share/color/icc") + ); + assert_eq!( + target_profile_dir(&env, false).unwrap(), + user_profile_dir(&env).unwrap() + ); + assert_eq!( + target_profile_dir(&env, true).unwrap(), + system_profile_dir(&env).unwrap() + ); + } + + #[test] + fn test_user_and_system_dirs_macos() { + let env = env_mac(); + assert_eq!( + user_profile_dir(&env).unwrap(), + PathBuf::from("/Users/gordon/Library/ColorSync/Profiles") + ); + assert_eq!( + system_profile_dir(&env).unwrap(), + PathBuf::from("/Library/ColorSync/Profiles") + ); + assert_eq!(profile_extension_for_os("macos"), "icc"); + } + + #[test] + fn test_windows_system_color_dir_and_icm() { + let env = env_win(); + assert_eq!( + system_profile_dir(&env).unwrap(), + PathBuf::from(r"C:\Windows\System32\spool\drivers\color") + ); + assert_eq!(profile_extension_for_os("windows"), "icm"); + let dest = dest_filename(Path::new(r"C:\work\Press.icc"), "windows").unwrap(); + assert_eq!(dest, "Press.icm"); + } + + #[test] + fn test_dest_filename_unix_keeps_icc() { + let name = dest_filename(Path::new("/tmp/photo.icm"), "linux").unwrap(); + assert_eq!(name, "photo.icc"); + } + + #[test] + fn test_dest_filename_rejects_invalid() { + assert!(dest_filename(Path::new(""), "linux").is_err()); + assert!(dest_filename(Path::new(".."), "linux").is_err()); + } + + #[test] + fn test_resolve_destination_cancel() { + let dir = std::env::temp_dir(); + let existing = dir.join("iccery-install-collision.icc"); + fs::write(&existing, vec![0u8; 200]).unwrap(); + let opts = InstallOptions { + collision_policy: "cancel".to_string(), + ..Default::default() + }; + let err = resolve_destination(&dir, "iccery-install-collision.icc", &opts).unwrap_err(); + assert!(err.contains("already exists")); + let _ = fs::remove_file(existing); + } + + #[test] + fn test_resolve_destination_rename_and_overwrite() { + let dir = std::env::temp_dir(); + let filename = "iccery-install-exists.icc"; + let existing = dir.join(filename); + fs::write(&existing, vec![0u8; 200]).unwrap(); + let rename = InstallOptions { + collision_policy: "rename".to_string(), + ..Default::default() + }; + let (path, overwritten, renamed) = resolve_destination(&dir, filename, &rename).unwrap(); + assert!(!overwritten && renamed); + assert_ne!(path, existing); + let over = InstallOptions { + collision_policy: "overwrite".to_string(), + ..Default::default() + }; + let (path2, overwritten2, renamed2) = resolve_destination(&dir, filename, &over).unwrap(); + assert!(overwritten2 && !renamed2); + assert_eq!(path2, existing); + let _ = fs::remove_file(existing); + } + + #[test] + fn test_verify_source_profile_rejects_missing_and_tiny() { + let missing = PathBuf::from("/tmp/does-not-exist-iccery.icc"); + assert!(verify_source_profile(&missing).is_err()); + let tiny = std::env::temp_dir().join("iccery-tiny.icc"); + fs::write(&tiny, b"short").unwrap(); + assert!(verify_source_profile(&tiny).is_err()); + let _ = fs::remove_file(tiny); + } + + #[test] + fn test_timestamped_filename_preserves_extension() { + let name = timestamped_filename("Press.icm"); + assert!(name.starts_with("Press-")); + assert!(name.ends_with(".icm")); + } + + #[test] + fn test_install_profile_copy_roundtrip() { + let tmp = std::env::temp_dir().join(format!( + "iccery-install-{}", + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() + )); + fs::create_dir_all(&tmp).unwrap(); + let src = tmp.join("demo.icc"); + let bytes = vec![0u8; 256]; + fs::write(&src, &bytes).unwrap(); + let env = InstallEnv { + os: "linux".to_string(), + home: Some(tmp.join("home")), + windir: None, + }; + let result = install_profile_with_env( + &src.to_string_lossy(), + &InstallOptions { + register_with_os: false, + open_color_panel: false, + calibration_note: Some("Curves from CAL_demo.cal were applied.".to_string()), + ..Default::default() + }, + &env, + ) + .unwrap(); + assert!(Path::new(&result.dest_path).is_file()); + assert!(src.is_file(), "source artefact must remain"); + assert!(result.message.contains("Curves from CAL_demo.cal")); + let _ = fs::remove_dir_all(tmp); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 192a8e6..fcf3809 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -58,10 +58,17 @@ pub struct ProfilingPreset { pub random_seed: Option, #[serde(default)] pub no_randomize: Option, + #[serde(default)] + pub calibration_file: Option, + #[serde(default)] + pub apply_calibration: Option, } fn default_delta_e_good_max() -> f64 { 2.0 } fn default_delta_e_warning_max() -> f64 { 5.0 } +fn default_cal_stale_days() -> u32 { 30 } +fn default_install_location() -> String { "user".to_string() } +fn default_true_bool() -> bool { true } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct AppSettings { @@ -76,6 +83,15 @@ pub struct AppSettings { pub custom_presets: Vec, #[serde(default)] pub enable_i1pro2_leds: bool, + #[serde(default = "default_cal_stale_days")] + pub calibration_stale_days: u32, + /// `user` or `system` β€” default destination for Stage 5 profile install (#223). + #[serde(default = "default_install_location")] + pub default_install_location: String, + #[serde(default = "default_true_bool")] + pub ask_before_overwrite_profile: bool, + #[serde(default)] + pub open_color_panel_after_install: bool, } impl Default for AppSettings { @@ -88,6 +104,10 @@ impl Default for AppSettings { delta_e_warning_max: default_delta_e_warning_max(), custom_presets: Vec::new(), enable_i1pro2_leds: false, + calibration_stale_days: default_cal_stale_days(), + default_install_location: default_install_location(), + ask_before_overwrite_profile: true, + open_color_panel_after_install: false, } } } @@ -135,6 +155,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -169,6 +191,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -203,6 +227,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -237,6 +263,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -405,6 +433,8 @@ mod tests { device_power: Some(1.2), random_seed: Some(42), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -474,6 +504,10 @@ mod tests { fn test_default_enable_i1pro2_leds() { let settings = AppSettings::default(); assert_eq!(settings.enable_i1pro2_leds, false); + assert_eq!(settings.calibration_stale_days, 30); + assert_eq!(settings.default_install_location, "user"); + assert!(settings.ask_before_overwrite_profile); + assert!(!settings.open_color_panel_after_install); } #[test] diff --git a/src-tauri/src/window_lifecycle.rs b/src-tauri/src/window_lifecycle.rs new file mode 100644 index 0000000..8b02617 --- /dev/null +++ b/src-tauri/src/window_lifecycle.rs @@ -0,0 +1,88 @@ +//! Window / webview lifecycle helpers for #225. +//! +//! Helper-process death (WKWebView Web Content / GPU) does not produce an +//! ICCery Crash Reporter dialog. We log unexpected window destruction so the +//! rotating `iccery.log` still has a timestamped breadcrumb. + +use std::sync::atomic::{AtomicBool, Ordering}; + +static USER_CLOSE_REQUESTED: AtomicBool = AtomicBool::new(false); + +pub fn mark_user_close_requested() { + USER_CLOSE_REQUESTED.store(true, Ordering::SeqCst); +} + +pub fn was_user_close_requested() -> bool { + USER_CLOSE_REQUESTED.load(Ordering::SeqCst) +} + +pub fn describe_window_destroyed(label: &str, user_close: bool) -> String { + if user_close { + format!("Window destroyed after close request: {label}") + } else { + format!( + "Window destroyed unexpectedly: {label} (possible Web Content / GPU process death)" + ) + } +} + +pub fn on_window_destroyed(label: &str) { + let user_close = was_user_close_requested(); + let msg = describe_window_destroyed(label, user_close); + if user_close { + log::info!("{msg}"); + } else { + log::error!("{msg}"); + } +} + +/// True when a `RunEvent` debug string looks like WKWebView / wry helper death. +pub fn is_web_content_termination_event(debug_text: &str) -> bool { + let lower = debug_text.to_ascii_lowercase(); + lower.contains("web content process terminated") + || lower.contains("webcontentprocessdidterminate") + || (lower.contains("webview") && lower.contains("terminat")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expected_close_message() { + let msg = describe_window_destroyed("main", true); + assert!(msg.contains("main")); + assert!(msg.contains("close request")); + assert!(!msg.contains("unexpectedly")); + } + + #[test] + fn unexpected_destroy_message() { + let msg = describe_window_destroyed("main", false); + assert!(msg.contains("main")); + assert!(msg.contains("unexpectedly")); + assert!(msg.contains("Web Content")); + } + + #[test] + fn detects_wry_web_content_line() { + assert!(is_web_content_termination_event( + "web content process terminated" + )); + assert!(is_web_content_termination_event( + "WKWebView WebContentProcessDidTerminate" + )); + assert!(!is_web_content_termination_event("Main window shown")); + assert!(!is_web_content_termination_event("Ready")); + } + + #[test] + fn close_flag_roundtrip() { + // Reset in case another test ran first in this process. + USER_CLOSE_REQUESTED.store(false, Ordering::SeqCst); + assert!(!was_user_close_requested()); + mark_user_close_requested(); + assert!(was_user_close_requested()); + USER_CLOSE_REQUESTED.store(false, Ordering::SeqCst); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 54ae769..59ae2a1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ICCery", - "version": "0.8.4", + "version": "0.8.5", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src" @@ -10,11 +10,14 @@ "withGlobalTauri": true, "windows": [ { + "label": "main", "title": "ICCery", "width": 1280, "height": 800, "minWidth": 1100, - "minHeight": 700 + "minHeight": 700, + "visible": false, + "backgroundColor": "#1A1A22" } ], "security": { @@ -36,7 +39,7 @@ "argyll/**/*" ], "macOS": { - "minimumSystemVersion": "10.15", + "minimumSystemVersion": "12.0", "dmg": { "background": "icons/dmg-background.png", "windowSize": { diff --git a/src/index.html b/src/index.html index 9e4adc1..632ff78 100644 --- a/src/index.html +++ b/src/index.html @@ -49,6 +49,8 @@ + +
Calibration: None