Author SHA1 Message Date
gronod 95a19132fb Merge pull request 'feat(stage5): install generated ICC/ICM profiles into the OS (#223)' (#228) from feat/223-systemic-profile-installation into development
Reviewed-on: #228
2026-09-07 18:31:24 +01:00
gronod 520d0a2ab5 Merge pull request 'feat(cal): printer linearization via printcal / applycal (#224)' (#227) from feat/224-printer-calibration-curves into development
Reviewed-on: #227
2026-09-07 18:31:09 +01:00
gronod 9cc87ca295 Merge pull request 'feat(ci): build styled macOS DMG headlessly via dmgbuild (#189)' (#222) from feat/headless-dmg-dmgbuild into development
Reviewed-on: #222
2026-09-07 18:06:38 +01:00
gronod 980b44bccb feat(stage5): install generated ICC/ICM profiles into the OS (#223)
Build Windows Packages / Build Windows (push) Successful in 7m45s
Build Linux Packages / Build Linux (push) Successful in 9m59s
Build macOS Packages / Build macOS (Intel) (push) Successful in 11m12s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 11m18s
Add Install Profile to System after a successful profcheck run. Copies the
working-directory artefact into the platform colour store (Windows ICM,
macOS ColorSync, Linux colord) without moving it. Collisions require
Overwrite / Rename / Cancel; permission errors mention elevation. When
printcal curves were applied (#224) the success toast records that.
2026-09-07 17:03:10 +00:00
gronod 24318bca58 feat(cal): printer linearization via printcal / applycal (#224)
Add an optional Stage 0 calibration dashboard so CMYK / RIP workflows can
linearize and set ink limits before a full profile. CAL_ artefacts keep
the 5-stage wizard unchanged when calibration is skipped.

printtarg receives -K only for profiling layouts; colprof embeds curves
with applycal. Existing .cal files require Overwrite / Rename / Cancel.
Bump version to v0.8.5.
2026-09-07 16:59:35 +00:00
gronod a147064722 Merge pull request 'fix(macos): stop Monterey WKWebView flash-loop (#225)' (#226) from fix/225-monterey-webview into development
Fixes #225.

Hide the window until first paint, force dark WKWebView backing, defer Stage 5 WebGL,
log unexpected window/Web Content death, and document macOS 12+ / Monterey support.
Five commits kept for Monterey bisection.
2026-09-07 17:40:40 +01:00
gbolton2008 87e8e65771 docs(macos): support matrix, log path, and Monterey troubleshooting (#225)
Build Windows Packages / Build Windows (push) Canceled after 25s
Build Linux Packages / Build Linux (push) Canceled after 27s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 10m39s
Build macOS Packages / Build macOS (Intel) (push) Successful in 10m50s
Document the 12.0+ policy, WebGL best-effort on Monterey Intel,
iccery.log location, and DiagnosticReports WebKit helpers.
2026-09-07 15:55:04 +00:00
gbolton2008 3cf2a0f441 fix(macos): hide window, dark backgroundColor, require 12.0 (#225)
visible:false avoids the first white WKWebView frame. backgroundColor
matches the dark theme. LSMinimumSystemVersion 12.0 stops Catalina
and Big Sur installing a binary that flash-loops.
2026-09-07 15:55:04 +00:00
gbolton2008 fb766c15d6 fix(window): show_main_window, dark WKWebView backing, os info (#225)
Keep the window hidden until the frontend signals first paint,
re-apply #1A1A22 NSWindow/WKWebView backing (drawsBackground=NO),
and expose os/arch/macos version for the Intel Monterey notice.
2026-09-07 15:55:04 +00:00
gbolton2008 26b2337a16 fix(host): log unexpected window destroy and Web Content death (#225)
Helper-process crashes do not produce an ICCery Crash Reporter
dialog. Record CloseRequested vs unexpected Destroyed, and detect
wry 'web content process terminated' strings for iccery.log.
2026-09-07 15:55:04 +00:00
gbolton2008 3e7ece746b fix(webgl): defer Stage 5 renderer and survive context loss (#225)
Eager THREE.WebGLRenderer + rAF during DOMContentLoaded respawns
WKWebView on Monterey Intel. Create the context only when Stage 5
is shown, feature-detect WebGL, pause the loop when hidden, and
leave a fallback UI if the GPU context is missing or lost.
2026-09-07 15:55:04 +00:00
29 changed files with 3885 additions and 53 deletions
+23
View File
@@ -1,5 +1,23 @@
# ICCery Agent Notes # ICCery Agent Notes
## Printer Calibration (`printcal` / `applycal`) (#224)
- Optional Stage 0 dashboard, opened from **Calibrate Printer**. The 15 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 ## Stage 5 Verification / Profcheck
- `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats. - `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats.
@@ -10,6 +28,9 @@
## 3D Gamut Viewer ## 3D Gamut Viewer
- The viewer renders the measured/derived `.gam` volume and an optional sRGB reference wireframe in CIELAB. - 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. - 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. - 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`. - 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) - Verification & drift tests: `node src/js/profcheck.test.js` (21 tests)
- Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests) - Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests)
- Gamut viewer tests: `node src/js/gamut_viewer.test.js` - 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())` - Browser devtools console: `import('./profcheck.test.js').then(m => m.runAll())`
- **Frontend development server**: `npm run tauri dev` - **Frontend development server**: `npm run tauri dev`
- **Production package build**: `npm run tauri build` - **Production package build**: `npm run tauri build`
+44 -4
View File
@@ -2,19 +2,20 @@
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS. > 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) [![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) [![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) [![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 ## Key Features
- 🪄 **Linear 5-Stage Wizard Workflow**: - 🪄 **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: 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). - **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. - **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. - **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. - **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. - **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 (0255 scaling, field aliasing, metadata synthesis) and direct-jump workflows to Stage 4 (Profile Calculation) and Stage 5 (Verification). - 📊 **CGATS Dataset Interoperability (#94)**: Native parser for external CGATS and Argyll `.ti3` datasets with canonical normalization (0255 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. - 📋 **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. - 🍎 **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] QualityStore[Verification History & Drift Analytics]
PrintEngine["Raw Print Subsystem (GDI / CUPS / NSPrintPanel)"] PrintEngine["Raw Print Subsystem (GDI / CUPS / NSPrintPanel)"]
ProcMgr[Async Subprocess IPC Manager] ProcMgr[Async Subprocess IPC Manager]
CalStore[Calibration .cal library]
UI <--> State UI <--> State
State <--> ProcMgr State <--> ProcMgr
State <--> QualityStore State <--> QualityStore
State <--> CalStore
ProcMgr --> ThreeJS ProcMgr --> ThreeJS
UI --> PrintEngine UI --> PrintEngine
QualityStore --> UI QualityStore --> UI
@@ -67,6 +71,7 @@ flowchart TD
BIN_TAR[targen] BIN_TAR[targen]
BIN_PRT[printtarg] BIN_PRT[printtarg]
BIN_CHR[chartread] BIN_CHR[chartread]
BIN_CAL[printcal / applycal]
BIN_COL[colprof] BIN_COL[colprof]
BIN_CHK[profcheck] BIN_CHK[profcheck]
BIN_GAM[iccgamut] BIN_GAM[iccgamut]
@@ -75,6 +80,7 @@ flowchart TD
ProcMgr -- stdin/stdout/stderr pipes --> BIN_TAR ProcMgr -- stdin/stdout/stderr pipes --> BIN_TAR
ProcMgr -- stdin/stdout/stderr pipes --> BIN_PRT ProcMgr -- stdin/stdout/stderr pipes --> BIN_PRT
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHR 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_COL
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHK ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHK
ProcMgr -- stdin/stdout/stderr pipes --> BIN_GAM ProcMgr -- stdin/stdout/stderr pipes --> BIN_GAM
@@ -88,7 +94,7 @@ flowchart TD
- [Node.js](https://nodejs.org/) (v18 or newer) - [Node.js](https://nodejs.org/) (v18 or newer)
- [Rust](https://www.rust-lang.org/) (1.78+ stable) - [Rust](https://www.rust-lang.org/) (1.78+ stable)
- Operating system dependencies: - 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. - **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`. - **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.012.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 14 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 ## 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). 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).
+6 -1
View File
@@ -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. - **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. - **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`). - **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`) ### 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] **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] **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.
--- ---
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "iccery", "name": "iccery",
"private": true, "private": true,
"version": "0.8.4", "version": "0.8.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"fetch-argyll": "node scripts/fetch-argyll.mjs", "fetch-argyll": "node scripts/fetch-argyll.mjs",
"tauri": "tauri", "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": { "devDependencies": {
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2"
+1 -1
View File
@@ -1423,7 +1423,7 @@ dependencies = [
[[package]] [[package]]
name = "iccery" name = "iccery"
version = "0.8.4" version = "0.8.5"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"image", "image",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "iccery" name = "iccery"
version = "0.8.4" version = "0.8.5"
description = "Modern Printer Profiling UI frontend for ArgyllCMS" description = "Modern Printer Profiling UI frontend for ArgyllCMS"
authors = ["Gordon"] authors = ["Gordon"]
edition = "2021" edition = "2021"
@@ -21,7 +21,7 @@ include = [
[lib] [lib]
# The `_lib` suffix may seem redundant but it is necessary # The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name. # 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" name = "iccery_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
File diff suppressed because it is too large Load Diff
+183
View File
@@ -105,20 +105,101 @@ pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result<Strin
Ok(resource_path.to_string_lossy().to_string()) Ok(resource_path.to_string_lossy().to_string())
} }
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
pub struct OsInfo {
pub os: String,
pub arch: String,
pub family: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub macos_major: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub macos_minor: Option<u32>,
}
/// 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)] #[derive(Serialize)]
pub struct AppInfo { pub struct AppInfo {
pub version: String, pub version: String,
pub build_date: String, pub build_date: String,
pub os: String,
pub arch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub macos_major: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub macos_minor: Option<u32>,
}
#[tauri::command]
pub fn get_os_info() -> OsInfo {
collect_os_info()
} }
#[tauri::command] #[tauri::command]
pub fn get_app_info() -> AppInfo { pub fn get_app_info() -> AppInfo {
let os = collect_os_info();
AppInfo { AppInfo {
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
build_date: env!("BUILD_DATE").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<String, String> { pub fn resolve_safe_cwd(app: &AppHandle, cwd_input: &str) -> Result<String, String> {
if !cwd_input.trim().is_empty() { if !cwd_input.trim().is_empty() {
let p = std::path::Path::new(cwd_input.trim()); 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 no_randomize: bool, // If true, pass -r (raster layout / no randomization)
pub basename: String, // Must match the .ti1 basename from Stage 1 pub basename: String, // Must match the .ti1 basename from Stage 1
pub cwd: String, // Working directory where the .ti1 file resides 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<String>,
/// 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<String> { pub fn build_targen_args(config: &TargenConfig) -> Vec<String> {
@@ -846,6 +933,18 @@ pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec<String> {
} }
args.push(config.dpi.to_string()); 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.push(config.basename.clone());
args args
} }
@@ -1540,6 +1639,8 @@ mod tests {
no_randomize: false, no_randomize: false,
basename: "my_profile".to_string(), basename: "my_profile".to_string(),
cwd: "/tmp".to_string(), cwd: "/tmp".to_string(),
calibration_file: None,
calibration_embed_only: false,
}; };
let args = build_printtarg_args(&config); let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "100", "my_profile"]); 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, no_randomize: false,
basename: "cmyk_profile".to_string(), basename: "cmyk_profile".to_string(),
cwd: "/home/user".to_string(), cwd: "/home/user".to_string(),
calibration_file: None,
calibration_embed_only: false,
}; };
let args = build_printtarg_args(&config); let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-R", "1", "-T", "300", "cmyk_profile"]); 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, no_randomize: false,
basename: "custom_target".to_string(), basename: "custom_target".to_string(),
cwd: "/tmp".to_string(), cwd: "/tmp".to_string(),
calibration_file: None,
calibration_embed_only: false,
}; };
let args = build_printtarg_args(&config); let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-R", "1", "-t", "150", "custom_target"]); 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, no_randomize: false,
basename: "my_profile".to_string(), basename: "my_profile".to_string(),
cwd: "/tmp".to_string(), cwd: "/tmp".to_string(),
calibration_file: None,
calibration_embed_only: false,
}; };
let args = build_printtarg_args(&config); let args = build_printtarg_args(&config);
assert_eq!( assert_eq!(
@@ -1625,6 +1732,8 @@ mod tests {
no_randomize: false, no_randomize: false,
basename: "my_profile".to_string(), basename: "my_profile".to_string(),
cwd: "/tmp".to_string(), cwd: "/tmp".to_string(),
calibration_file: None,
calibration_embed_only: false,
}; };
let args = build_printtarg_args(&config); let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "42", "-t", "300", "my_profile"]); 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, no_randomize: true,
basename: "my_profile".to_string(), basename: "my_profile".to_string(),
cwd: "/tmp".to_string(), cwd: "/tmp".to_string(),
calibration_file: None,
calibration_embed_only: false,
}; };
let args = build_printtarg_args(&config); let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-r", "-t", "300", "my_profile"]); 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] #[test]
fn test_build_chartread_args_auto() { fn test_build_chartread_args_auto() {
let config = ChartreadConfig { let config = ChartreadConfig {
@@ -2096,4 +2250,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&temp_dir); 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);
}
} }
+46 -5
View File
@@ -2,11 +2,15 @@ use tauri::Manager;
mod cgats; mod cgats;
mod commands; mod commands;
mod calibration;
mod events; mod events;
mod macos_webview;
mod print; mod print;
mod process_manager; mod process_manager;
mod profile_install;
mod quality_store; mod quality_store;
mod settings; mod settings;
mod window_lifecycle;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { 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::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), 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) .max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll) .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.build(), .build(),
@@ -33,6 +41,12 @@ pub fn run() {
let filter = settings::parse_log_level_filter(settings.log_level.as_deref()); let filter = settings::parse_log_level_filter(settings.log_level.as_deref());
log::set_max_level(filter); log::set_max_level(filter);
log::info!("ICCery initialized. Effective log 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(()) Ok(())
}) })
.manage(process_manager::ProcessManager::new()) .manage(process_manager::ProcessManager::new())
@@ -40,6 +54,8 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::spawn_process, commands::spawn_process,
commands::get_app_info, commands::get_app_info,
commands::get_os_info,
commands::show_main_window,
commands::get_default_working_dir, commands::get_default_working_dir,
commands::get_log_path, commands::get_log_path,
commands::get_recent_log_excerpt, commands::get_recent_log_excerpt,
@@ -78,6 +94,17 @@ pub fn run() {
commands::show_printer_properties, commands::show_printer_properties,
commands::print_target_native, commands::print_target_native,
commands::select_csv_save_path, 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::save_verification_record,
quality_store::get_verification_history, quality_store::get_verification_history,
quality_store::clear_verification_history, quality_store::clear_verification_history,
@@ -95,21 +122,35 @@ pub fn run() {
.run(|app_handle, event| { .run(|app_handle, event| {
match event { match event {
tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } => { tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } => {
window_lifecycle::mark_user_close_requested();
let pm = app_handle.state::<process_manager::ProcessManager>(); let pm = app_handle.state::<process_manager::ProcessManager>();
tauri::async_runtime::block_on(async { tauri::async_runtime::block_on(async {
pm.kill_all().await; pm.kill_all().await;
}); });
} }
tauri::RunEvent::WindowEvent { tauri::RunEvent::WindowEvent { label, event, .. } => {
event: tauri::WindowEvent::CloseRequested { .. }, 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::<process_manager::ProcessManager>(); let pm = app_handle.state::<process_manager::ProcessManager>();
tauri::async_runtime::block_on(async { tauri::async_runtime::block_on(async {
pm.kill_all().await; 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}");
}
}
} }
}); });
} }
+84
View File
@@ -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));
}
}
+533
View File
@@ -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<String>,
}
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<String>,
}
#[derive(Debug, Clone)]
pub struct InstallEnv {
pub os: String,
pub home: Option<PathBuf>,
pub windir: Option<PathBuf>,
}
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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
if prefer_system_wide {
system_profile_dir(env)
} else {
user_profile_dir(env)
}
}
pub fn dest_filename(source: &Path, os: &str) -> Result<String, String> {
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<u64, String> {
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<InstallResult, String> {
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<bool>) -> Result<String, String> {
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<InstallOptions>,
) -> Result<InstallResult, String> {
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);
}
}
+34
View File
@@ -58,10 +58,17 @@ pub struct ProfilingPreset {
pub random_seed: Option<u32>, pub random_seed: Option<u32>,
#[serde(default)] #[serde(default)]
pub no_randomize: Option<bool>, pub no_randomize: Option<bool>,
#[serde(default)]
pub calibration_file: Option<String>,
#[serde(default)]
pub apply_calibration: Option<bool>,
} }
fn default_delta_e_good_max() -> f64 { 2.0 } fn default_delta_e_good_max() -> f64 { 2.0 }
fn default_delta_e_warning_max() -> f64 { 5.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)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AppSettings { pub struct AppSettings {
@@ -76,6 +83,15 @@ pub struct AppSettings {
pub custom_presets: Vec<ProfilingPreset>, pub custom_presets: Vec<ProfilingPreset>,
#[serde(default)] #[serde(default)]
pub enable_i1pro2_leds: bool, 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 { impl Default for AppSettings {
@@ -88,6 +104,10 @@ impl Default for AppSettings {
delta_e_warning_max: default_delta_e_warning_max(), delta_e_warning_max: default_delta_e_warning_max(),
custom_presets: Vec::new(), custom_presets: Vec::new(),
enable_i1pro2_leds: false, 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<ProfilingPreset> {
device_power: None, device_power: None,
random_seed: Some(1), random_seed: Some(1),
no_randomize: Some(false), no_randomize: Some(false),
calibration_file: None,
apply_calibration: None,
colprof_fwa: Some("D50".to_string()), colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None, colprof_illuminant: None,
colprof_observer: None, colprof_observer: None,
@@ -169,6 +191,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
device_power: None, device_power: None,
random_seed: Some(1), random_seed: Some(1),
no_randomize: Some(false), no_randomize: Some(false),
calibration_file: None,
apply_calibration: None,
colprof_fwa: Some("D50".to_string()), colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None, colprof_illuminant: None,
colprof_observer: None, colprof_observer: None,
@@ -203,6 +227,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
device_power: None, device_power: None,
random_seed: Some(1), random_seed: Some(1),
no_randomize: Some(false), no_randomize: Some(false),
calibration_file: None,
apply_calibration: None,
colprof_fwa: Some("D50".to_string()), colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None, colprof_illuminant: None,
colprof_observer: None, colprof_observer: None,
@@ -237,6 +263,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
device_power: None, device_power: None,
random_seed: Some(1), random_seed: Some(1),
no_randomize: Some(false), no_randomize: Some(false),
calibration_file: None,
apply_calibration: None,
colprof_fwa: Some("D50".to_string()), colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None, colprof_illuminant: None,
colprof_observer: None, colprof_observer: None,
@@ -405,6 +433,8 @@ mod tests {
device_power: Some(1.2), device_power: Some(1.2),
random_seed: Some(42), random_seed: Some(42),
no_randomize: Some(false), no_randomize: Some(false),
calibration_file: None,
apply_calibration: None,
colprof_fwa: Some("D50".to_string()), colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None, colprof_illuminant: None,
colprof_observer: None, colprof_observer: None,
@@ -474,6 +504,10 @@ mod tests {
fn test_default_enable_i1pro2_leds() { fn test_default_enable_i1pro2_leds() {
let settings = AppSettings::default(); let settings = AppSettings::default();
assert_eq!(settings.enable_i1pro2_leds, false); 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] #[test]
+88
View File
@@ -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);
}
}
+6 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery", "productName": "ICCery",
"version": "0.8.4", "version": "0.8.5",
"identifier": "com.gronod.iccery", "identifier": "com.gronod.iccery",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"
@@ -10,11 +10,14 @@
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [ "windows": [
{ {
"label": "main",
"title": "ICCery", "title": "ICCery",
"width": 1280, "width": 1280,
"height": 800, "height": 800,
"minWidth": 1100, "minWidth": 1100,
"minHeight": 700 "minHeight": 700,
"visible": false,
"backgroundColor": "#1A1A22"
} }
], ],
"security": { "security": {
@@ -36,7 +39,7 @@
"argyll/**/*" "argyll/**/*"
], ],
"macOS": { "macOS": {
"minimumSystemVersion": "10.15", "minimumSystemVersion": "12.0",
"dmg": { "dmg": {
"background": "icons/dmg-background.png", "background": "icons/dmg-background.png",
"windowSize": { "windowSize": {
+167 -1
View File
@@ -49,6 +49,8 @@
<select id="presetSelect" style="width: 100%; font-size: 0.85rem; padding: 6px 8px; border-radius: 6px; background: var(--bg-card, #1a1a22); color: var(--text-color, #eee); border: 1px solid var(--border-color, #333); cursor: pointer;"> <select id="presetSelect" style="width: 100%; font-size: 0.85rem; padding: 6px 8px; border-radius: 6px; background: var(--bg-card, #1a1a22); color: var(--text-color, #eee); border: 1px solid var(--border-color, #333); cursor: pointer;">
<option value="" disabled selected>Loading presets...</option> <option value="" disabled selected>Loading presets...</option>
</select> </select>
<button type="button" id="btnCalibratePrinter" class="secondary btn-md" style="width:100%; margin-top:10px;" title="Optional printer linearization via printcal before profiling">Calibrate Printer</button>
<div id="calStatusChip" class="cal-status-chip cal-status-none">Calibration: None</div>
</div> </div>
<nav class="stepper"> <nav class="stepper">
@@ -68,12 +70,112 @@
<button type="button" id="wizardNotificationClose" class="notification-close" title="Dismiss notice"></button> <button type="button" id="wizardNotificationClose" class="notification-close" title="Dismiss notice"></button>
</div> </div>
<!-- Stage 0: Printer calibration (optional, parallel to the 5-stage wizard) -->
<section id="stage-cal" class="stage hidden">
<h2>Printer Calibration</h2>
<p>Linearize per-channel response and establish ink limits with Argyll <code>printcal</code> before building an ICC profile. Optional for RGB photo printers; strongly recommended for CMYK / RIP workflows.</p>
<div class="cal-status-banner cal-banner-active" data-cal-banner="always">
<span data-cal-banner-text>Calibration: None</span>
<label class="cal-apply-toggle" title="When enabled, subsequent Stage 2 printtarg runs use -K and Stage 4 applycal embeds the curves">
<input type="checkbox" id="calApplyToggleDash" data-cal-apply>
Apply Calibration
</label>
</div>
<div class="cal-dashboard-grid">
<div class="cal-card">
<h3>1. Calibration Chart</h3>
<div class="form-group has-tooltip">
<label>Colour Space</label>
<div class="radio-group">
<label><input type="radio" name="calColourSpace" value="rgb"> RGB (driver-managed)</label>
<label><input type="radio" name="calColourSpace" value="cmyk" checked> CMYK (RIP)</label>
</div>
<div class="tooltip-text">Calibration is most valuable on native CMYK devices. RGB printers already apply driver curves; results are often modest.</div>
</div>
<p id="calRgbHint" class="help-hint hidden">RGB driver-managed printers are typically already linearized. Calibration is optional here.</p>
<div class="input-row">
<div class="form-group has-tooltip">
<label for="calSteps">Steps per channel</label>
<input type="number" id="calSteps" min="11" max="51" value="21">
<div class="tooltip-text">Wedge density for each ink channel (1151). 21 is a good default; 33 for high-end inkjets.</div>
</div>
<div class="form-group has-tooltip">
<label for="calInkExplore">Ink-limit exploration (TAC %)</label>
<input type="number" id="calInkExplore" min="200" max="400" value="320">
<div class="tooltip-text">CMYK only. Total area coverage range passed to targen -l so printcal can recommend a TAC.</div>
</div>
</div>
<label class="checkbox-label" for="calNeutralEmphasis">
<input type="checkbox" id="calNeutralEmphasis">
Neutral-axis emphasis
</label>
<div class="stage-actions">
<button type="button" class="primary btn-lg" id="btnCalGenerate">Generate Calibration Target</button>
<button type="button" class="secondary btn-md" id="btnCalLayout">Create Layout & Print</button>
<button type="button" class="secondary btn-md" id="btnCalMeasure">Measure Chart</button>
</div>
</div>
<div class="cal-card">
<h3>2. Saved curves</h3>
<p class="help-hint" id="calCurrentFile">No .cal loaded</p>
<div class="btn-row wrap">
<button type="button" class="secondary btn-md" id="btnCalLoad">Load Existing…</button>
<button type="button" class="secondary btn-md" id="btnCalLibrary">Save to Library</button>
<button type="button" class="danger btn-md" id="btnCalClear">Clear</button>
</div>
<select id="calSavedSelect" style="width:100%; margin-top:10px;"></select>
<div class="stage-actions">
<button type="button" class="primary btn-lg" id="btnCalCompute">Compute Curves</button>
</div>
<p class="help-hint">Requires a measured <code>CAL_*.ti3</code> in the working directory. Existing <code>.cal</code> files are never overwritten without confirmation.</p>
</div>
</div>
<div class="cal-dashboard-grid">
<div class="cal-card">
<h3>Channel response</h3>
<svg id="calCurveSvg" class="cal-curve-svg" role="img" aria-label="Channel response curves"></svg>
<div id="calCurveLegend" class="cal-curve-legend"></div>
<p class="help-hint">Solid lines are post-linearization device response. Dashed line is identity (already linear).</p>
</div>
<div class="cal-card">
<h3>Ink limits</h3>
<div class="cal-tac-card">Total area coverage: <strong id="calTacValue"></strong></div>
<div class="form-group">
<label for="calTacOverride">Override TAC %</label>
<input type="number" id="calTacOverride" min="150" max="400" placeholder="use recommended">
</div>
<div id="calInkLimitControls"></div>
<p class="help-hint">Recommended power (targen -p): <strong id="calRecommendedPower"></strong>. Re-run Compute Curves after editing limits.</p>
</div>
</div>
<div class="stage-actions">
<button type="button" class="secondary btn-md" id="btnCalBackToWizard">← Back to profiling wizard</button>
</div>
<details class="log-container hidden" id="calLogContainer">
<summary>Process Output</summary>
<pre id="calLog"></pre>
</details>
</section>
<!-- Stage 1: targen --> <!-- Stage 1: targen -->
<section id="stage-1" class="stage active"> <section id="stage-1" class="stage active">
<h2 style="display:flex; justify-content:space-between; align-items:center;"> <h2 style="display:flex; justify-content:space-between; align-items:center;">
Define Target Define Target
<button type="button" id="btnToggleAllHelp" class="secondary btn-sm">Toggle Help Mode</button> <button type="button" id="btnToggleAllHelp" class="secondary btn-sm">Toggle Help Mode</button>
</h2> </h2>
<div id="calStage1Recommend" class="cal-status-banner cal-banner-stale hidden">
<span>No calibration applied — recommended for CMYK / RIP workflows.</span>
<button type="button" class="secondary btn-sm" id="btnCalRecalibrate">Calibrate Printer</button>
</div>
<div class="cal-status-banner hidden" data-cal-banner>
<span data-cal-banner-text>Calibration: None</span>
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
</div>
<div class="form-container" id="stage1FormContainer"> <div class="form-container" id="stage1FormContainer">
<!-- Basic Settings --> <!-- Basic Settings -->
@@ -319,6 +421,10 @@
<!-- Stage 2: printtarg --> <!-- Stage 2: printtarg -->
<section id="stage-2" class="stage hidden"> <section id="stage-2" class="stage hidden">
<h2>Print Layout</h2> <h2>Print Layout</h2>
<div class="cal-status-banner hidden" data-cal-banner>
<span data-cal-banner-text>Calibration: None</span>
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
</div>
<p>Configure the target layout for your instrument and paper size, then generate printable TIFF images.</p> <p>Configure the target layout for your instrument and paper size, then generate printable TIFF images.</p>
<!-- Colour management warning banner --> <!-- Colour management warning banner -->
@@ -634,6 +740,10 @@
<!-- Stage 4: colprof --> <!-- Stage 4: colprof -->
<section id="stage-4" class="stage hidden"> <section id="stage-4" class="stage hidden">
<h2>Create Profile</h2> <h2>Create Profile</h2>
<div class="cal-status-banner hidden" data-cal-banner>
<span data-cal-banner-text>Calibration: None</span>
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
</div>
<p>Calculate the ICC profile from your target measurement data.</p> <p>Calculate the ICC profile from your target measurement data.</p>
<div class="form-container"> <div class="form-container">
@@ -790,10 +900,15 @@
<!-- Stage 5: profcheck --> <!-- Stage 5: profcheck -->
<section id="stage-5" class="stage hidden"> <section id="stage-5" class="stage hidden">
<h2>Verify Profile</h2> <h2>Verify Profile</h2>
<div class="cal-status-banner hidden" data-cal-banner>
<span data-cal-banner-text>Calibration: None</span>
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
</div>
<p>Check the numerical accuracy of your profile against the original measurement data.</p> <p>Check the numerical accuracy of your profile against the original measurement data.</p>
<div class="stage-actions"> <div class="stage-actions">
<button class="primary btn-lg" id="btnVerify">Verify Profile Accuracy</button> <button class="primary btn-lg" id="btnVerify">Verify Profile Accuracy</button>
<button class="secondary btn-lg" id="btnInstallProfile" disabled title="Copies the generated ICC/ICM profile into the operating systems standard colour-profile directory so that print dialogs and colour-managed applications can discover it. Requires elevated privileges on some platforms.">Install Profile to System</button>
</div> </div>
<!-- Report Card --> <!-- Report Card -->
@@ -828,6 +943,7 @@
<summary>Verification History &amp; Drift Tracking</summary> <summary>Verification History &amp; Drift Tracking</summary>
<div class="notification-banner warning hidden" id="driftAlertCard"> <div class="notification-banner warning hidden" id="driftAlertCard">
<span id="driftAlertIcon">⚠️</span><span id="driftAlertText"></span> <span id="driftAlertIcon">⚠️</span><span id="driftAlertText"></span>
<button type="button" class="secondary btn-sm" id="btnDriftRecalibrate">Re-calibrate</button>
</div> </div>
<div class="drift-filter-row hidden" id="driftFilterRow"> <div class="drift-filter-row hidden" id="driftFilterRow">
<label for="driftPrinterFilter">Filter by Printer:</label> <label for="driftPrinterFilter">Filter by Printer:</label>
@@ -971,6 +1087,30 @@
<small id="deltaEThresholdError" class="help-hint" style="display:none; color: var(--error-color, #ff5f5f); margin-top: 6px;">Good threshold must be less than warning threshold.</small> <small id="deltaEThresholdError" class="help-hint" style="display:none; color: var(--error-color, #ff5f5f); margin-top: 6px;">Good threshold must be less than warning threshold.</small>
</div> </div>
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<label style="font-weight: 600;">Printer Calibration</label>
<div style="margin-top: 8px;">
<label for="calibrationStaleDays" class="sub-label">Warn when loaded .cal is older than (days)</label>
<input type="number" id="calibrationStaleDays" min="1" max="365" value="30">
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Also warns when the stored printer name no longer matches the current destination. Default 30 days.</small>
</div>
<div style="margin-top: 12px;">
<label for="defaultInstallLocation" class="sub-label">Default ICC install location</label>
<select id="defaultInstallLocation">
<option value="user" selected>User (no elevation)</option>
<option value="system">System-wide (may require admin)</option>
</select>
</div>
<label class="checkbox-label" for="askBeforeOverwriteProfile" style="display:flex; align-items:center; gap:8px; margin-top:10px; cursor:pointer;">
<input type="checkbox" id="askBeforeOverwriteProfile" checked>
<span>Ask before overwriting an existing system profile</span>
</label>
<label class="checkbox-label" for="openColorPanelAfterInstall" style="display:flex; align-items:center; gap:8px; margin-top:8px; cursor:pointer;">
<input type="checkbox" id="openColorPanelAfterInstall">
<span>Open the system Colour Management panel after install</span>
</label>
</div>
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);"> <div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<label style="font-weight: 600;">Diagnostics &amp; Logging</label> <label style="font-weight: 600;">Diagnostics &amp; Logging</label>
<div class="input-row" style="margin-top: 8px;"> <div class="input-row" style="margin-top: 8px;">
@@ -1000,6 +1140,32 @@
</div> </div>
</dialog> </dialog>
<dialog id="calCollisionDialog" class="settings-modal">
<div class="modal-content">
<h2>Calibration file exists</h2>
<p id="calCollisionMessage" style="white-space:pre-wrap;"></p>
<p class="help-hint">ICCery never overwrites a .cal without an explicit choice.</p>
<div class="modal-actions">
<button type="button" id="calOverwriteBtn" class="danger">Overwrite</button>
<button type="button" id="calRenameBtn" class="primary">Rename (timestamp)</button>
<button type="button" id="calCancelCollisionBtn" class="secondary">Cancel</button>
</div>
</div>
</dialog>
<dialog id="profileInstallCollisionDialog" class="settings-modal">
<div class="modal-content">
<h2>Profile already installed</h2>
<p id="profileInstallCollisionMessage" style="white-space:pre-wrap;"></p>
<p class="help-hint">The working-directory copy is never moved. Choose how to handle the existing system profile.</p>
<div class="modal-actions">
<button type="button" id="profileOverwriteBtn" class="danger">Overwrite</button>
<button type="button" id="profileRenameBtn" class="primary">Rename (timestamp)</button>
<button type="button" id="profileCancelCollisionBtn" class="secondary">Cancel</button>
</div>
</div>
</dialog>
<!-- About Dialog --> <!-- About Dialog -->
<dialog id="aboutDialog" class="settings-modal about-modal"> <dialog id="aboutDialog" class="settings-modal about-modal">
<div class="modal-content"> <div class="modal-content">
@@ -1007,7 +1173,7 @@
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" /> <img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
</div> </div>
<h2>About ICCery</h2> <h2>About ICCery</h2>
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.2</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p> <p><strong>Version:</strong> <span id="aboutVersion">v0.8.5</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
<p><strong>Copyright &copy; 2026 Gordon Bolton. All rights reserved.</strong></p> <p><strong>Copyright &copy; 2026 Gordon Bolton. All rights reserved.</strong></p>
<h3>Licences & EULA</h3> <h3>Licences & EULA</h3>
<div class="license-text-container"> <div class="license-text-container">
+60 -3
View File
@@ -4,14 +4,46 @@ import { initChartread } from './chartread.js';
import { initColprof } from './colprof.js'; import { initColprof } from './colprof.js';
import { initProfcheck } from './profcheck.js'; import { initProfcheck } from './profcheck.js';
import { initSettings } from './settings.js'; import { initSettings } from './settings.js';
import { initGamutViewer } from './gamut_viewer.js'; import { setGpuHints } from './gamut_viewer.js';
import { initPresets } from './presets.js'; import { initPresets } from './presets.js';
import { wizardState } from './state.js'; import { wizardState } from './state.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { CgatsInterop } from './cgats_interop.js'; import { CgatsInterop } from './cgats_interop.js';
import { initCalibration } from './calibration.js';
import { initProfileInstall } from './profile_install.js';
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
let mainWindowShown = false;
async function revealMainWindow() {
if (mainWindowShown) return;
mainWindowShown = true;
try {
await invoke('show_main_window');
} catch (e) {
mainWindowShown = false;
console.warn('[ICCery] show_main_window failed:', e);
}
}
function maybeShowConstrainedGpuNotice(info) {
if (!info || info.os !== 'macos') return;
const intel = info.arch === 'x86_64' || info.arch === 'x86';
const major = typeof info.macos_major === 'number' ? info.macos_major : null;
if (!intel || (major !== null && major >= 13)) return;
const key = 'iccery.macos-intel-webgl-notice';
try {
if (sessionStorage.getItem(key)) return;
sessionStorage.setItem(key, '1');
} catch (_) { /* private mode */ }
wizardState.showNotice(
'On this Mac the 3D gamut view may be unavailable. Profiling stages still work.',
'info',
8000
);
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Initialize interoperability handlers // Initialize interoperability handlers
new CgatsInterop(wizardState); new CgatsInterop(wizardState);
@@ -39,6 +71,13 @@ document.addEventListener('DOMContentLoaded', () => {
wizardState.updateGating(); wizardState.updateGating();
}); });
document.addEventListener('visibilitychange', () => {
logger.warn(`Frontend visibilitychange hidden=${document.hidden}`, 'WebView');
});
window.addEventListener('pagehide', () => {
logger.warn('Frontend pagehide', 'WebView');
});
// Initialize gating on load // Initialize gating on load
wizardState.updateGating(); wizardState.updateGating();
@@ -54,6 +93,12 @@ document.addEventListener('DOMContentLoaded', () => {
const buildDateEl = document.getElementById('aboutBuildDate'); const buildDateEl = document.getElementById('aboutBuildDate');
if (versionEl && info.version) versionEl.textContent = `v${info.version}`; if (versionEl && info.version) versionEl.textContent = `v${info.version}`;
if (buildDateEl && info.build_date) buildDateEl.textContent = info.build_date; if (buildDateEl && info.build_date) buildDateEl.textContent = info.build_date;
setGpuHints({
arch: info.arch,
os: info.os,
macosMajor: info.macos_major,
});
maybeShowConstrainedGpuNotice(info);
} catch (e) { } catch (e) {
console.warn('[ICCery] Could not load dynamic app info:', e); console.warn('[ICCery] Could not load dynamic app info:', e);
} }
@@ -82,13 +127,25 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}; };
// Initialize all stages & features safely // Initialize all stages & features safely.
// Gamut Viewer is deferred until Stage 5 is shown (eager WebGL on launch
// respawns WKWebView on Monterey Intel).
safeInit('Stage 1 (Targen)', initTargen); safeInit('Stage 1 (Targen)', initTargen);
safeInit('Stage 2 (Printtarg)', initPrinttarg); safeInit('Stage 2 (Printtarg)', initPrinttarg);
safeInit('Stage 3 (Chartread)', initChartread); safeInit('Stage 3 (Chartread)', initChartread);
safeInit('Stage 4 (Colprof)', initColprof); safeInit('Stage 4 (Colprof)', initColprof);
safeInit('Stage 5 (Profcheck)', initProfcheck); safeInit('Stage 5 (Profcheck)', initProfcheck);
safeInit('Settings', initSettings); safeInit('Settings', initSettings);
safeInit('Gamut Viewer', initGamutViewer);
safeInit('Presets', initPresets); safeInit('Presets', initPresets);
safeInit('Calibration', initCalibration);
safeInit('ProfileInstall', initProfileInstall);
// Double-rAF waits for layout + first paint of the dark CSS.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
revealMainWindow();
});
});
// Fallback so a JS exception cannot leave a permanently hidden window.
setTimeout(revealMainWindow, 1500);
}); });
+658
View File
@@ -0,0 +1,658 @@
/**
* Printer calibration (linearization & ink limits) via printcal / applycal (#224).
*
* Optional Stage 0 workflow. The 5-stage wizard is unchanged when calibration
* is skipped. Curves feed subsequent printtarg (-K) and colprof (applycal).
*/
import { wizardState } from './state.js';
import { logger } from './logger.js';
const invoke = (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke)
? window.__TAURI__.core.invoke.bind(window.__TAURI__.core)
: async () => { throw new Error('Tauri invoke unavailable'); };
const listen = (window.__TAURI__ && window.__TAURI__.event && window.__TAURI__.event.listen)
? window.__TAURI__.event.listen.bind(window.__TAURI__.event)
: async () => () => {};
export const CAL_PREFIX = 'CAL_';
export const DEFAULT_STALE_DAYS = 30;
export const CHANNEL_COLORS = {
C: '#00b4d8',
M: '#e63980',
Y: '#f4d35e',
K: '#c5c5c5',
R: '#e74c3c',
G: '#2ecc71',
B: '#3498db',
};
const calState = {
status: 'none', // none | active | stale
calPath: null,
filename: null,
created: null,
applyEnabled: true,
printerName: null,
colourSpace: null,
inkLimits: [],
totalInkLimit: null,
curves: [],
calBasename: null,
recommendedPower: null,
ageDays: 0,
staleDays: DEFAULT_STALE_DAYS,
};
export function makeCalibrationBasename(basename) {
const trimmed = String(basename || '').trim();
const base = trimmed.startsWith(CAL_PREFIX) ? trimmed.slice(CAL_PREFIX.length) : trimmed;
return `${CAL_PREFIX}${base || 'printer'}`;
}
export function isCalibrationBasename(basename) {
return String(basename || '').trim().startsWith(CAL_PREFIX);
}
export function isCalibrationStale(ageDays, staleDays = DEFAULT_STALE_DAYS) {
return Number(ageDays) > Math.max(1, Number(staleDays) || DEFAULT_STALE_DAYS);
}
export function totalAreaCoverage(limits) {
if (!Array.isArray(limits) || limits.length === 0) return 0;
return limits.reduce((sum, item) => sum + (Number(item.percent) || 0), 0);
}
export function classifyCalibrationStatus({
calPath,
applyEnabled,
ageDays,
staleDays,
printerName,
currentPrinter,
} = {}) {
if (!calPath) return 'none';
if (printerName && currentPrinter && printerName !== currentPrinter) return 'stale';
if (isCalibrationStale(ageDays || 0, staleDays)) return 'stale';
if (applyEnabled === false) return 'active';
return 'active';
}
export function downsampleCurve(points, maxPoints = 48) {
if (!Array.isArray(points) || points.length <= maxPoints) return points || [];
const out = [];
const last = points.length - 1;
for (let i = 0; i < maxPoints; i += 1) {
const idx = Math.round((i / (maxPoints - 1)) * last);
out.push(points[idx]);
}
return out;
}
export function buildCurvePolyline(points, width = 320, height = 160, padding = 18) {
const pts = downsampleCurve(points);
if (!pts.length) return '';
const innerW = width - padding * 2;
const innerH = height - padding * 2;
return pts.map((p, i) => {
const x = padding + (Number(p[0]) || 0) * innerW;
const y = padding + innerH - (Number(p[1]) || 0) * innerH;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
}
export function getActiveCalibration() {
return { ...calState };
}
export function getPrinttargCalibrationFields(basename) {
if (isCalibrationBasename(basename)) {
return { calibration_file: null, calibration_embed_only: false };
}
if (!calState.applyEnabled || !calState.calPath) {
return { calibration_file: null, calibration_embed_only: false };
}
return { calibration_file: calState.calPath, calibration_embed_only: false };
}
export async function applyCalibrationToProfile(profilePath) {
if (!calState.applyEnabled || !calState.calPath || !profilePath) return null;
try {
const result = await invoke('apply_calibration', {
config: {
cal_path: calState.calPath,
input_path: profilePath,
output_path: null,
unapply: false,
},
});
logger.info(`applycal: ${result.message}`, 'Calibration');
return result;
} catch (err) {
logger.error(`applycal failed: ${err}`, 'Calibration');
wizardState.showNotice(`Could not embed calibration curves into the profile: ${err}`, 'warning', 7000);
return null;
}
}
function statusLabel() {
if (calState.status === 'none') return 'Calibration: None';
const name = calState.filename || 'curves.cal';
if (calState.status === 'stale') return `Calibration: Stale (${name})`;
if (!calState.applyEnabled) return `Calibration: Loaded, not applied (${name})`;
return `Calibration: Active (${name})`;
}
function persistLocal() {
try {
localStorage.setItem('iccery.calibration', JSON.stringify({
calPath: calState.calPath,
applyEnabled: calState.applyEnabled,
printerName: calState.printerName,
colourSpace: calState.colourSpace,
created: calState.created,
calBasename: calState.calBasename,
}));
} catch (_) { /* private mode */ }
}
function restoreLocal() {
try {
const raw = localStorage.getItem('iccery.calibration');
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed && parsed.calPath) {
calState.calPath = parsed.calPath;
calState.applyEnabled = parsed.applyEnabled !== false;
calState.printerName = parsed.printerName || null;
calState.colourSpace = parsed.colourSpace || null;
calState.created = parsed.created || null;
calState.calBasename = parsed.calBasename || makeCalibrationBasename(wizardState.basename);
calState.filename = String(parsed.calPath).split(/[\\/]/).pop();
calState.status = 'active';
}
} catch (_) { /* ignore */ }
}
async function persistProject() {
if (!wizardState.cwd) return;
try {
await invoke('save_project_calibration', {
cwd: wizardState.cwd,
state: {
cal_path: calState.calPath,
apply_enabled: calState.applyEnabled,
printer_name: calState.printerName,
colour_space: calState.colourSpace,
created: calState.created,
cal_basename: calState.calBasename,
ink_limit_overrides: calState.inkLimits,
total_ink_override: calState.totalInkLimit,
},
});
} catch (err) {
logger.warn(`Could not persist project calibration: ${err}`, 'Calibration');
}
}
function refreshStatusFromMeta(meta, currentPrinter) {
if (!meta) {
calState.status = calState.calPath ? 'active' : 'none';
return;
}
calState.filename = meta.filename;
calState.created = meta.created;
calState.ageDays = meta.age_days || 0;
calState.curves = meta.curves || [];
if (meta.ink_limits && meta.ink_limits.length) calState.inkLimits = meta.ink_limits;
if (meta.total_ink_limit != null) calState.totalInkLimit = meta.total_ink_limit;
calState.status = classifyCalibrationStatus({
calPath: calState.calPath,
applyEnabled: calState.applyEnabled,
ageDays: calState.ageDays,
staleDays: calState.staleDays,
printerName: calState.printerName,
currentPrinter,
});
}
function renderBanners() {
const text = statusLabel();
document.querySelectorAll('[data-cal-banner-text]').forEach((el) => {
el.textContent = text;
});
document.querySelectorAll('[data-cal-banner]').forEach((el) => {
el.classList.toggle('hidden', calState.status === 'none' && el.dataset.calBanner !== 'always');
el.classList.toggle('cal-banner-stale', calState.status === 'stale');
el.classList.toggle('cal-banner-active', calState.status === 'active');
});
document.querySelectorAll('[data-cal-apply]').forEach((el) => {
el.checked = !!calState.applyEnabled && !!calState.calPath;
el.disabled = !calState.calPath;
});
const chip = document.getElementById('calStatusChip');
if (chip) {
chip.textContent = text;
chip.className = `cal-status-chip cal-status-${calState.status}`;
}
const rgbHint = document.getElementById('calRgbHint');
if (rgbHint) {
const cs = (document.querySelector('input[name="calColourSpace"]:checked') || {}).value
|| (document.querySelector('input[name="colourSpace"]:checked') || {}).value
|| 'rgb';
rgbHint.classList.toggle('hidden', cs !== 'rgb');
}
const stage1Banner = document.getElementById('calStage1Recommend');
if (stage1Banner) {
const cs = (document.querySelector('input[name="colourSpace"]:checked') || {}).value || 'rgb';
const show = calState.status === 'none' && cs === 'cmyk';
stage1Banner.classList.toggle('hidden', !show);
}
}
function renderPlots() {
const svg = document.getElementById('calCurveSvg');
const legend = document.getElementById('calCurveLegend');
if (!svg) return;
const width = 360;
const height = 180;
const padding = 22;
const grid = [0, 0.25, 0.5, 0.75, 1].map((t) => {
const x = padding + t * (width - padding * 2);
const y = padding + (1 - t) * (height - padding * 2);
return `<line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" class="cal-grid"/>`
+ `<line x1="${x}" y1="${padding}" x2="${x}" y2="${height - padding}" class="cal-grid"/>`;
}).join('');
const identity = buildCurvePolyline([[0, 0], [1, 1]], width, height, padding);
const paths = (calState.curves || []).map((curve) => {
const d = buildCurvePolyline(curve.points, width, height, padding);
const color = CHANNEL_COLORS[curve.channel] || '#7aa2f7';
return `<path d="${d}" fill="none" stroke="${color}" stroke-width="2"/>`;
}).join('');
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.innerHTML = `${grid}<path d="${identity}" fill="none" stroke="#555" stroke-dasharray="4 3" stroke-width="1"/>${paths}`
+ `<text x="${padding}" y="${height - 4}" class="cal-axis-label">Input</text>`
+ `<text x="4" y="${padding}" class="cal-axis-label">Out</text>`;
if (legend) {
legend.innerHTML = (calState.curves || []).map((c) => {
const color = CHANNEL_COLORS[c.channel] || '#7aa2f7';
return `<span class="cal-legend-item"><i style="background:${color}"></i>${c.channel}</span>`;
}).join('') || '<span class="help-hint">No curves yet — compute after measuring the calibration chart.</span>';
}
}
function renderInkLimits() {
const wrap = document.getElementById('calInkLimitControls');
const tacEl = document.getElementById('calTacValue');
if (tacEl) {
const tac = calState.totalInkLimit != null ? calState.totalInkLimit : totalAreaCoverage(calState.inkLimits);
tacEl.textContent = tac ? `${tac.toFixed(0)} %` : '—';
}
if (!wrap) return;
if (!calState.inkLimits.length) {
wrap.innerHTML = '<p class="help-hint">Ink-limit recommendations appear after printcal runs. Editable overrides can be re-computed.</p>';
return;
}
wrap.innerHTML = calState.inkLimits.map((lim) => {
const color = CHANNEL_COLORS[lim.channel] || '#888';
return `<label class="cal-ink-row"><span style="color:${color}">${lim.channel}</span>`
+ `<input type="range" min="50" max="100" step="0.5" value="${lim.percent}" data-cal-ink="${lim.channel}">`
+ `<input type="number" min="50" max="100" step="0.5" value="${Number(lim.percent).toFixed(1)}" data-cal-ink-num="${lim.channel}">`
+ `<span>%</span></label>`;
}).join('');
}
function renderDashboardMeta() {
const fileEl = document.getElementById('calCurrentFile');
if (fileEl) fileEl.textContent = calState.filename || 'No .cal loaded';
const powerEl = document.getElementById('calRecommendedPower');
if (powerEl) powerEl.textContent = calState.recommendedPower != null ? calState.recommendedPower.toFixed(2) : '—';
renderBanners();
renderPlots();
renderInkLimits();
}
async function loadCalPath(path) {
const meta = await invoke('parse_cal_file_cmd', { path });
calState.calPath = path;
calState.applyEnabled = true;
refreshStatusFromMeta(meta, wizardState.printerName);
if (!calState.calBasename) {
calState.calBasename = makeCalibrationBasename(wizardState.basename || 'printer');
}
persistLocal();
await persistProject();
renderDashboardMeta();
wizardState.showNotice(`Loaded calibration ${meta.filename}`, 'success', 4000);
}
async function enterCalibrationSession() {
const calBase = calState.calBasename || makeCalibrationBasename(wizardState.basename || 'printer');
calState.calBasename = calBase;
if (wizardState.basename && !isCalibrationBasename(wizardState.basename)) {
wizardState.profileBasename = wizardState.basename;
}
wizardState.sessionMode = 'calibration';
wizardState.basename = calBase;
wizardState.setTarget(calBase, wizardState.cwd);
const { setStage1Result } = await import('./printtarg.js');
const { setStage2Result } = await import('./chartread.js');
setStage1Result(calBase, wizardState.cwd);
setStage2Result(calBase, wizardState.cwd);
}
async function exitCalibrationSession() {
wizardState.sessionMode = 'profile';
if (wizardState.profileBasename) {
wizardState.basename = wizardState.profileBasename;
wizardState.setTarget(wizardState.profileBasename, wizardState.cwd);
const { setStage1Result } = await import('./printtarg.js');
setStage1Result(wizardState.profileBasename, wizardState.cwd);
}
}
async function generateTarget() {
const cwd = wizardState.cwd;
if (!cwd) {
wizardState.showNotice('Set a working directory in Stage 1 before generating a calibration chart.', 'warning');
return;
}
const cs = (document.querySelector('input[name="calColourSpace"]:checked') || {}).value
|| (document.querySelector('input[name="colourSpace"]:checked') || {}).value
|| 'rgb';
const steps = Math.max(11, Math.min(51, parseInt(document.getElementById('calSteps')?.value, 10) || 21));
const ink = parseInt(document.getElementById('calInkExplore')?.value, 10);
const basename = makeCalibrationBasename(wizardState.profileBasename || wizardState.basename || 'printer');
calState.calBasename = basename;
calState.colourSpace = cs;
const btn = document.getElementById('btnCalGenerate');
const logPre = document.getElementById('calLog');
const logBox = document.getElementById('calLogContainer');
if (logBox) logBox.classList.remove('hidden');
if (btn) btn.disabled = true;
const processId = `targen_${basename}`;
if (logPre) logPre.textContent = 'Starting targen (calibration chart)...\n';
try {
const unlistenStdout = await listen('process:stdout', (event) => {
if (event.payload.id === processId && event.payload.line && logPre) {
logPre.textContent += `${event.payload.line}\n`;
}
});
const unlistenStderr = await listen('process:stderr', (event) => {
if (event.payload.id === processId && event.payload.line && logPre) {
logPre.textContent += `ERR: ${event.payload.line}\n`;
}
});
const unlistenExit = await listen('process:exit', (event) => {
if (event.payload.id !== processId) return;
unlistenStdout();
unlistenStderr();
unlistenExit();
if (btn) btn.disabled = false;
if (event.payload.code === 0) {
if (logPre) logPre.textContent += '\n[SUCCESS] Calibration .ti1 generated.\n';
wizardState.showNotice(`Calibration chart ${basename}.ti1 is ready. Create a layout and print it uncalibrated.`, 'success', 6000);
} else if (logPre) {
logPre.textContent += `\n[ERROR] targen exited with code ${event.payload.code}.\n`;
}
});
await invoke('generate_calibration_target', {
config: {
colour_space: cs,
steps_per_channel: steps,
ink_limit_exploration: Number.isFinite(ink) ? ink : null,
channels: null,
white_patches: 4,
neutral_emphasis: !!(document.getElementById('calNeutralEmphasis') || {}).checked,
basename,
cwd,
},
});
} catch (err) {
if (btn) btn.disabled = false;
logger.error(`generate_calibration_target failed: ${err}`, 'Calibration');
wizardState.showNotice(`Could not generate calibration chart: ${err}`, 'error');
}
}
function collisionChoice(existingPath) {
return new Promise((resolve) => {
const dialog = document.getElementById('calCollisionDialog');
const msg = document.getElementById('calCollisionMessage');
if (msg) msg.textContent = `A calibration file already exists:\n${existingPath}`;
if (!dialog || typeof dialog.showModal !== 'function') {
const ok = window.confirm(`Overwrite existing calibration ${existingPath}?`);
resolve(ok ? 'overwrite' : 'cancel');
return;
}
const finish = (choice) => {
dialog.close();
resolve(choice);
};
document.getElementById('calOverwriteBtn')?.addEventListener('click', () => finish('overwrite'), { once: true });
document.getElementById('calRenameBtn')?.addEventListener('click', () => finish('rename'), { once: true });
document.getElementById('calCancelCollisionBtn')?.addEventListener('click', () => finish('cancel'), { once: true });
dialog.showModal();
});
}
async function computeCurves(forceOverwrite = false, outputName = null) {
const cwd = wizardState.cwd;
const basename = calState.calBasename || makeCalibrationBasename(wizardState.basename || 'printer');
if (!cwd) {
wizardState.showNotice('Working directory is not set.', 'warning');
return;
}
const btn = document.getElementById('btnCalCompute');
if (btn) {
btn.disabled = true;
btn.textContent = 'Computing…';
}
const channelLimits = [];
document.querySelectorAll('[data-cal-ink-num]').forEach((el) => {
channelLimits.push({ channel: el.getAttribute('data-cal-ink-num'), percent: parseFloat(el.value) });
});
const tac = parseFloat(document.getElementById('calTacOverride')?.value);
try {
const result = await invoke('compute_calibration_curves', {
config: {
ti3_basename: basename,
cwd,
output_cal: outputName,
previous_cal: calState.calPath,
force_overwrite: forceOverwrite,
no_ink_limit: false,
verify: false,
total_ink_limit: Number.isFinite(tac) ? tac : null,
channel_limits: channelLimits,
},
});
calState.calPath = result.cal_path;
calState.filename = String(result.cal_path).split(/[\\/]/).pop();
calState.inkLimits = result.ink_limits || [];
calState.totalInkLimit = result.total_ink_limit;
calState.recommendedPower = result.recommended_power;
calState.applyEnabled = true;
calState.printerName = wizardState.printerName || calState.printerName;
calState.created = new Date().toISOString();
refreshStatusFromMeta(result.metadata, wizardState.printerName);
persistLocal();
await persistProject();
renderDashboardMeta();
wizardState.showNotice(result.message, 'success', 6000);
} catch (err) {
const message = String(err);
if (/already exists/i.test(message) && !forceOverwrite) {
const choice = await collisionChoice(message);
if (choice === 'overwrite') {
await computeCurves(true, outputName);
} else if (choice === 'rename') {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
await computeCurves(true, `${basename}_${stamp}.cal`);
}
} else {
wizardState.showNotice(`printcal failed: ${err}`, 'error', 8000);
}
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = 'Compute Curves';
}
}
}
async function refreshSavedList() {
const select = document.getElementById('calSavedSelect');
if (!select) return;
try {
const list = await invoke('list_saved_calibrations', { cwd: wizardState.cwd || null });
const current = select.value;
select.innerHTML = '<option value="">Recent calibrations…</option>';
list.forEach((item) => {
const opt = document.createElement('option');
opt.value = item.path;
const stale = isCalibrationStale(item.age_days, calState.staleDays) ? ' (stale)' : '';
opt.textContent = `${item.filename}${stale}`;
select.appendChild(opt);
});
if (current) select.value = current;
} catch (err) {
logger.warn(`list_saved_calibrations: ${err}`, 'Calibration');
}
}
export function initCalibration() {
restoreLocal();
renderDashboardMeta();
const openBtn = document.getElementById('btnCalibratePrinter');
if (openBtn) {
openBtn.addEventListener('click', () => {
wizardState.navigateToStage(0);
refreshSavedList();
});
}
document.getElementById('btnCalBackToWizard')?.addEventListener('click', () => {
exitCalibrationSession();
wizardState.navigateToStage(1);
});
document.getElementById('btnCalGenerate')?.addEventListener('click', generateTarget);
document.getElementById('btnCalLayout')?.addEventListener('click', async () => {
await enterCalibrationSession();
wizardState.showNotice('Printing a calibration chart: color management stays bypassed and curves are not applied to this target.', 'info', 7000);
wizardState.navigateToStage(2);
});
document.getElementById('btnCalMeasure')?.addEventListener('click', async () => {
await enterCalibrationSession();
wizardState.showNotice('Measuring the calibration chart. After Finish, return here and compute curves.', 'info', 7000);
wizardState.navigateToStage(3);
});
document.getElementById('btnCalCompute')?.addEventListener('click', () => computeCurves(false, null));
document.getElementById('btnCalLoad')?.addEventListener('click', async () => {
try {
const picked = await invoke('select_cal_file', { defaultDir: wizardState.cwd || null });
if (picked) await loadCalPath(picked);
} catch (err) {
wizardState.showNotice(`Could not open .cal file: ${err}`, 'error');
}
});
document.getElementById('btnCalClear')?.addEventListener('click', async () => {
calState.status = 'none';
calState.calPath = null;
calState.filename = null;
calState.curves = [];
calState.inkLimits = [];
calState.totalInkLimit = null;
persistLocal();
await persistProject();
renderDashboardMeta();
wizardState.showNotice('Calibration cleared. Profiling will run without printcal curves.', 'info');
});
document.getElementById('btnCalLibrary')?.addEventListener('click', async () => {
if (!calState.calPath) return;
try {
const dest = await invoke('save_calibration_to_library', { calPath: calState.calPath });
wizardState.showNotice(`Copied to library: ${dest}`, 'success');
refreshSavedList();
} catch (err) {
wizardState.showNotice(`Library save failed: ${err}`, 'error');
}
});
document.getElementById('calSavedSelect')?.addEventListener('change', async (ev) => {
if (ev.target.value) await loadCalPath(ev.target.value);
});
document.getElementById('calApplyToggleDash')?.addEventListener('change', async (ev) => {
calState.applyEnabled = !!ev.target.checked;
persistLocal();
await persistProject();
renderBanners();
});
document.querySelectorAll('[data-cal-apply]').forEach((el) => {
el.addEventListener('change', async (ev) => {
calState.applyEnabled = !!ev.target.checked;
persistLocal();
await persistProject();
renderBanners();
});
});
document.getElementById('btnCalRecalibrate')?.addEventListener('click', () => {
wizardState.navigateToStage(0);
});
document.getElementById('btnDriftRecalibrate')?.addEventListener('click', () => {
wizardState.navigateToStage(0);
});
document.querySelectorAll('input[name="calColourSpace"]').forEach((el) => {
el.addEventListener('change', renderBanners);
});
document.querySelectorAll('input[name="colourSpace"]').forEach((el) => {
el.addEventListener('change', renderBanners);
});
document.getElementById('calInkLimitControls')?.addEventListener('input', (ev) => {
const ch = ev.target.getAttribute('data-cal-ink') || ev.target.getAttribute('data-cal-ink-num');
if (!ch) return;
const val = parseFloat(ev.target.value);
const range = document.querySelector(`[data-cal-ink="${ch}"]`);
const num = document.querySelector(`[data-cal-ink-num="${ch}"]`);
if (range && ev.target !== range) range.value = val;
if (num && ev.target !== num) num.value = val;
const item = calState.inkLimits.find((l) => l.channel === ch);
if (item) item.percent = val;
const tacEl = document.getElementById('calTacValue');
if (tacEl) tacEl.textContent = `${totalAreaCoverage(calState.inkLimits).toFixed(0)} %`;
});
window.addEventListener('stage-changed', (event) => {
if (event.detail && event.detail.stage !== 0 && event.detail.stage !== 2 && event.detail.stage !== 3) {
if (wizardState.sessionMode === 'calibration') {
exitCalibrationSession();
}
}
renderBanners();
});
window.addEventListener('settings-saved', (event) => {
const days = event.detail && event.detail.calibration_stale_days;
if (days) calState.staleDays = days;
refreshStatusFromMeta({
filename: calState.filename,
created: calState.created,
age_days: calState.ageDays,
curves: calState.curves,
ink_limits: calState.inkLimits,
total_ink_limit: calState.totalInkLimit,
}, wizardState.printerName);
renderBanners();
});
if (wizardState.cwd) {
invoke('load_project_calibration', { cwd: wizardState.cwd }).then(async (state) => {
if (state && state.cal_path) {
try { await loadCalPath(state.cal_path); } catch (_) { /* missing file */ }
calState.applyEnabled = state.apply_enabled !== false;
renderBanners();
}
}).catch(() => {});
}
}
+109
View File
@@ -0,0 +1,109 @@
// Unit tests for calibration helpers (#224).
// node src/js/calibration.test.js
if (typeof window === 'undefined') {
globalThis.window = {
__TAURI__: {
core: { invoke: () => Promise.resolve() },
event: { listen: () => Promise.resolve(() => {}) }
},
addEventListener: () => {},
dispatchEvent: () => {},
devicePixelRatio: 1
};
globalThis.document = {
getElementById: () => null,
querySelectorAll: () => [],
querySelector: () => null,
createElement: () => ({
style: {},
appendChild() {},
addEventListener() {},
textContent: '',
className: '',
setAttribute() {}
})
};
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {}
};
}
const {
makeCalibrationBasename,
isCalibrationBasename,
isCalibrationStale,
totalAreaCoverage,
classifyCalibrationStatus,
downsampleCurve,
buildCurvePolyline,
getPrinttargCalibrationFields,
CAL_PREFIX,
} = await import('./calibration.js');
let passed = 0;
let total = 0;
function assert(cond, name) {
total += 1;
if (cond) {
passed += 1;
console.log(` ok ${name}`);
} else {
console.error(` FAIL ${name}`);
}
}
export function runAll() {
passed = 0;
total = 0;
console.log('calibration.test.js');
assert(makeCalibrationBasename('photo') === 'CAL_photo', 'prefix profile basename');
assert(makeCalibrationBasename('CAL_photo') === 'CAL_photo', 'do not double-prefix');
assert(makeCalibrationBasename('') === 'CAL_printer', 'empty falls back to CAL_printer');
assert(isCalibrationBasename('CAL_x') === true, 'detects CAL_ prefix');
assert(isCalibrationBasename('photo') === false, 'profile basename is not cal');
assert(CAL_PREFIX === 'CAL_', 'prefix constant');
assert(isCalibrationStale(10, 30) === false, 'fresh cal is not stale');
assert(isCalibrationStale(30, 30) === false, 'equal to threshold is not stale');
assert(isCalibrationStale(31, 30) === true, 'older than threshold is stale');
assert(totalAreaCoverage([]) === 0, 'empty TAC');
assert(totalAreaCoverage([{ percent: 90 }, { percent: 80 }, { percent: 70 }, { percent: 60 }]) === 300, 'sum TAC');
assert(classifyCalibrationStatus({}) === 'none', 'no path => none');
assert(classifyCalibrationStatus({ calPath: '/a.cal', applyEnabled: true, ageDays: 2, staleDays: 30 }) === 'active', 'fresh active');
assert(classifyCalibrationStatus({ calPath: '/a.cal', ageDays: 40, staleDays: 30 }) === 'stale', 'age stale');
assert(
classifyCalibrationStatus({
calPath: '/a.cal',
ageDays: 1,
printerName: 'Epson',
currentPrinter: 'Canon',
}) === 'stale',
'printer mismatch is stale'
);
const long = Array.from({ length: 256 }, (_, i) => [i / 255, i / 255]);
const ds = downsampleCurve(long, 48);
assert(ds.length === 48, 'downsample length');
assert(ds[0][0] === 0, 'downsample starts at 0');
assert(Math.abs(ds[ds.length - 1][0] - 1) < 1e-9, 'downsample ends at 1');
const poly = buildCurvePolyline([[0, 0], [1, 1]], 100, 100, 10);
assert(poly.startsWith('M'), 'polyline starts with move');
assert(poly.includes('L'), 'polyline has line');
const skipped = getPrinttargCalibrationFields('CAL_photo');
assert(skipped.calibration_file == null, 'calibration charts do not apply -K to themselves');
console.log(`\n${passed}/${total} passed`);
if (passed !== total) process.exitCode = 1;
return { passed, total };
}
runAll();
+11
View File
@@ -4,6 +4,7 @@ import { setStage4Result } from './profcheck.js';
import { loadGamutMesh } from './gamut_viewer.js'; import { loadGamutMesh } from './gamut_viewer.js';
import { wizardState } from './state.js'; import { wizardState } from './state.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { applyCalibrationToProfile, getActiveCalibration } from './calibration.js';
let chartreadBasename = ""; let chartreadBasename = "";
let chartreadCwd = ""; let chartreadCwd = "";
@@ -159,6 +160,16 @@ export function initColprof() {
wizardState.setTarget(basename, cwd); wizardState.setTarget(basename, cwd);
setStage4Result(basename, cwd); setStage4Result(basename, cwd);
const cal = getActiveCalibration();
if (cal.applyEnabled && cal.calPath) {
logPre.textContent += `\nApplying calibration ${cal.filename || cal.calPath} via applycal...\n`;
const applied = await applyCalibrationToProfile(profilePath);
if (applied && applied.output_path) {
profilePath = applied.output_path;
logPre.textContent += `[SUCCESS] ${applied.message}\n`;
}
}
// Automatically extract gamut mesh for 3D visualization // Automatically extract gamut mesh for 3D visualization
triggerGamutExtraction(basename, cwd, profilePath); triggerGamutExtraction(basename, cwd, profilePath);
} else { } else {
+240 -9
View File
@@ -1,21 +1,210 @@
import { computeQuickHull } from "./vendor/quickhull.js"; import { computeQuickHull } from "./vendor/quickhull.js";
import { labToSrgb } from "./color_convert.js"; import { labToSrgb } from "./color_convert.js";
import { logger } from "./logger.js";
const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke ? window.__TAURI__.core.invoke : null; const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke ? window.__TAURI__.core.invoke : null;
export const WEBGL_UNAVAILABLE_MESSAGE =
"3D gamut viewer requires WebGL; the rest of ICCery still works.";
let scene, camera, renderer, labelRenderer, controls; let scene, camera, renderer, labelRenderer, controls;
let currentProfileMesh = null; let currentProfileMesh = null;
let sRgbGroup = null; let sRgbGroup = null;
let axisScaffoldGroup = null; let axisScaffoldGroup = null;
let resizeObserver = null;
let gamutViewerReady = false;
let gamutViewerInitStarted = false;
let gamutViewerUnavailable = false;
let animationRunning = false;
let contextLost = false;
let togglesWired = false;
let gpuHints = {};
export function setGpuHints(hints) {
gpuHints = { ...gpuHints, ...(hints || {}) };
}
export function isGamutViewerReady() {
return gamutViewerReady;
}
/**
* Feature-detect WebGL before constructing THREE.WebGLRenderer.
* @param {typeof document} [doc]
* @returns {boolean}
*/
export function webglAvailable(doc = (typeof document !== 'undefined' ? document : null)) {
if (!doc || typeof doc.createElement !== 'function') return false;
try {
const c = doc.createElement('canvas');
if (!c || typeof c.getContext !== 'function') return false;
return !!(c.getContext('webgl2') || c.getContext('webgl') || c.getContext('experimental-webgl'));
} catch {
return false;
}
}
/**
* Heuristic for Monterey Intel / Rosetta: drop antialias and cap DPR.
* Prefer backend `arch` from `get_app_info` so Apple Silicon is not treated as Intel
* (Safari still reports `navigator.platform === "MacIntel"` on ARM).
*
* @param {{ navigator?: Navigator, arch?: string, macosMajor?: number }} [hints]
* @returns {boolean}
*/
export function isLikelyConstrainedGpu(hints = {}) {
const merged = { ...gpuHints, ...hints };
const arch = String(merged.arch || '');
if (arch) {
return arch === 'x86_64' || arch === 'x86' || arch === 'ia32';
}
const nav = merged.navigator || (typeof navigator !== 'undefined' ? navigator : {});
const uaDataArch = nav.userAgentData && nav.userAgentData.architecture;
if (uaDataArch) {
return /x86/i.test(String(uaDataArch));
}
const ua = String(nav.userAgent || '');
const platform = String(nav.platform || '');
const looksMac = /Macintosh|Mac OS X|MacIntel/i.test(`${platform} ${ua}`);
if (!looksMac) return false;
if (/ARM|Apple Silicon|aarch64/i.test(ua)) return false;
// Intel Mac UA historically includes "Intel"; Apple Silicon UA often does not.
if (/Intel/i.test(ua) || /MacIntel/i.test(platform)) {
const major = merged.macosMajor;
if (typeof major === 'number') return major < 13;
return true;
}
return false;
}
function showGamutFallback(container, message, { reloadable = false } = {}) {
if (!container) return;
container.innerHTML = '';
const el = document.createElement('div');
el.className = 'gamut-webgl-fallback';
el.setAttribute('role', 'status');
const p = document.createElement('p');
p.textContent = message;
el.appendChild(p);
if (reloadable) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'secondary btn-md';
btn.textContent = 'Reload 3D view';
btn.addEventListener('click', () => {
disposeViewer();
ensureGamutViewer();
});
el.appendChild(btn);
}
container.appendChild(el);
}
function stopAnimate() {
animationRunning = false;
}
function startAnimate() {
if (!renderer || contextLost) return;
if (animationRunning) return;
animationRunning = true;
animate();
}
function disposeViewer() {
stopAnimate();
if (resizeObserver) {
try { resizeObserver.disconnect(); } catch (_) { /* ignore */ }
resizeObserver = null;
}
if (renderer) {
try {
renderer.dispose();
} catch (_) { /* ignore */ }
if (renderer.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
}
if (labelRenderer && labelRenderer.domElement && labelRenderer.domElement.parentNode) {
labelRenderer.domElement.parentNode.removeChild(labelRenderer.domElement);
}
renderer = null;
scene = null;
camera = null;
controls = null;
labelRenderer = null;
currentProfileMesh = null;
sRgbGroup = null;
axisScaffoldGroup = null;
gamutViewerReady = false;
gamutViewerInitStarted = false;
contextLost = false;
}
/**
* Create the Three.js renderer the first time Stage 5 is shown.
* Safe to call repeatedly; a second call does not leak a renderer.
*/
export function ensureGamutViewer() {
if (gamutViewerUnavailable) return;
if (gamutViewerReady) {
startAnimate();
return;
}
const stage5 = typeof document !== 'undefined' ? document.getElementById('stage-5') : null;
if (stage5 && stage5.classList.contains('hidden')) return;
const kick = () => {
if (gamutViewerUnavailable) return;
if (gamutViewerReady) {
startAnimate();
return;
}
const s = typeof document !== 'undefined' ? document.getElementById('stage-5') : null;
if (s && s.classList.contains('hidden')) return;
initGamutViewer();
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(kick);
} else {
kick();
}
}
export function pauseGamutViewer() {
stopAnimate();
}
export function resumeGamutViewer() {
if (gamutViewerReady && !contextLost) startAnimate();
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Public: initialise the gamut viewer // Public: initialise the gamut viewer
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export async function initGamutViewer() { export async function initGamutViewer() {
try { if (gamutViewerReady || gamutViewerInitStarted) return;
const container = document.getElementById('gamutViewerContainer'); gamutViewerInitStarted = true;
if (!container || typeof THREE === 'undefined') return;
const container = typeof document !== 'undefined'
? document.getElementById('gamutViewerContainer')
: null;
if (!container || typeof THREE === 'undefined') {
gamutViewerInitStarted = false;
return;
}
if (!webglAvailable()) {
gamutViewerUnavailable = true;
gamutViewerInitStarted = false;
logger.warn(WEBGL_UNAVAILABLE_MESSAGE, 'GamutViewer');
showGamutFallback(container, WEBGL_UNAVAILABLE_MESSAGE);
return;
}
try {
// Clear any existing contents if re-initialised // Clear any existing contents if re-initialised
container.innerHTML = ""; container.innerHTML = "";
@@ -25,17 +214,43 @@ export async function initGamutViewer() {
const width = container.clientWidth > 0 ? container.clientWidth : 500; const width = container.clientWidth > 0 ? container.clientWidth : 500;
const height = container.clientHeight > 0 ? container.clientHeight : 400; const height = container.clientHeight > 0 ? container.clientHeight : 400;
// ── WebGL renderer ──────────────────────────────────────────────────── const lowPower = isLikelyConstrainedGpu();
logger.info(
`Creating WebGLRenderer (lowPower=${lowPower}, arch=${gpuHints.arch || 'unknown'}, dpr=${window.devicePixelRatio || 1})`,
'GamutViewer'
);
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000); camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
camera.position.set(180, 120, 180); camera.position.set(180, 120, 180);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer = new THREE.WebGLRenderer({
antialias: !lowPower,
alpha: false,
powerPreference: lowPower ? 'low-power' : 'default',
failIfMajorPerformanceCaveat: false,
});
renderer.setSize(width, height); renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio || 1); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, lowPower ? 1 : 2));
renderer.domElement.style.touchAction = 'none'; renderer.domElement.style.touchAction = 'none';
renderer.domElement.style.display = 'block'; renderer.domElement.style.display = 'block';
container.appendChild(renderer.domElement); container.appendChild(renderer.domElement);
renderer.domElement.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
contextLost = true;
stopAnimate();
logger.error('WebGL context lost', 'GamutViewer');
showGamutFallback(container, '3D view lost its GPU context. Profiling stages still work.', {
reloadable: true,
});
});
renderer.domElement.addEventListener('webglcontextrestored', () => {
logger.warn('WebGL context restored — rebuilding viewer', 'GamutViewer');
contextLost = false;
disposeViewer();
ensureGamutViewer();
});
// ── CSS2D label renderer ────────────────────────────────────────────── // ── CSS2D label renderer ──────────────────────────────────────────────
if (typeof THREE.CSS2DRenderer !== 'undefined') { if (typeof THREE.CSS2DRenderer !== 'undefined') {
labelRenderer = new THREE.CSS2DRenderer(); labelRenderer = new THREE.CSS2DRenderer();
@@ -74,7 +289,8 @@ export async function initGamutViewer() {
buildAxisScaffold(); buildAxisScaffold();
// ── Resize handling ─────────────────────────────────────────────────── // ── Resize handling ───────────────────────────────────────────────────
const resizeObserver = new ResizeObserver((entries) => { if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
const w = entry.contentRect.width; const w = entry.contentRect.width;
const h = entry.contentRect.height; const h = entry.contentRect.height;
@@ -87,8 +303,10 @@ export async function initGamutViewer() {
} }
}); });
resizeObserver.observe(container); resizeObserver.observe(container);
}
animate(); gamutViewerReady = true;
startAnimate();
// ── Wire toggle controls ────────────────────────────────────────────── // ── Wire toggle controls ──────────────────────────────────────────────
_wireToggles(); _wireToggles();
@@ -97,7 +315,16 @@ export async function initGamutViewer() {
loadSrgbReferenceGamut(); loadSrgbReferenceGamut();
} catch (err) { } catch (err) {
console.warn("Gamut viewer initialisation notice:", err); logger.error(`Gamut viewer initialisation failed: ${err?.stack || err}`, 'GamutViewer');
gamutViewerInitStarted = false;
gamutViewerReady = false;
try {
if (renderer && renderer.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
} catch (_) { /* ignore */ }
renderer = null;
showGamutFallback(container, WEBGL_UNAVAILABLE_MESSAGE, { reloadable: true });
} }
} }
@@ -105,7 +332,9 @@ export async function initGamutViewer() {
// Animation loop // Animation loop
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function animate() { function animate() {
if (!animationRunning) return;
requestAnimationFrame(animate); requestAnimationFrame(animate);
if (contextLost || !renderer) return;
if (controls) controls.update(); if (controls) controls.update();
if (renderer && scene && camera) { if (renderer && scene && camera) {
renderer.render(scene, camera); renderer.render(scene, camera);
@@ -560,6 +789,8 @@ export function toggleAxes(visible) {
// Wire legend toggle checkboxes, opacity sliders, and reset button // Wire legend toggle checkboxes, opacity sliders, and reset button
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function _wireToggles() { function _wireToggles() {
if (togglesWired) return;
togglesWired = true;
const bindings = [ const bindings = [
['chkProfileGamut', toggleProfileGamut ], ['chkProfileGamut', toggleProfileGamut ],
['chkSrgbReference', toggleSrgbReference ], ['chkSrgbReference', toggleSrgbReference ],
+118 -3
View File
@@ -12,15 +12,37 @@ if (typeof window === 'undefined') {
event: { listen: () => Promise.resolve(() => {}) } event: { listen: () => Promise.resolve(() => {}) }
}, },
addEventListener: () => {}, addEventListener: () => {},
dispatchEvent: () => {} dispatchEvent: () => {},
devicePixelRatio: 1
}; };
globalThis.document = { globalThis.document = {
getElementById: () => null, getElementById: () => null,
querySelectorAll: () => [] querySelectorAll: () => [],
createElement: (tag) => {
if (tag === 'canvas') {
return { getContext: () => null };
}
return {
style: {},
appendChild() {},
addEventListener() {},
textContent: '',
className: '',
setAttribute() {}
};
}
}; };
} }
const { parseGamutFile } = await import('./gamut_viewer.js'); const {
parseGamutFile,
webglAvailable,
isLikelyConstrainedGpu,
ensureGamutViewer,
isGamutViewerReady,
WEBGL_UNAVAILABLE_MESSAGE,
setGpuHints,
} = await import('./gamut_viewer.js');
let passed = 0; let passed = 0;
let total = 0; let total = 0;
@@ -32,6 +54,13 @@ export function runAll() {
testParseGamutBasic(); testParseGamutBasic();
testParseGamutDualTable(); testParseGamutDualTable();
testParseGamutWithComments(); testParseGamutWithComments();
testWebglAvailableFalseWithoutContext();
testWebglAvailableTrueWithWebgl();
testConstrainedGpuPrefersBackendArch();
testConstrainedGpuIgnoresAppleSiliconUa();
testConstrainedGpuIntelMac();
testEnsureGamutViewerNoopsWithoutDom();
testFallbackMessage();
console.log(`\nResults: ${passed} / ${total} tests passed.`); console.log(`\nResults: ${passed} / ${total} tests passed.`);
console.groupEnd(); console.groupEnd();
@@ -52,6 +81,17 @@ function assertEqual(actual, expected, message) {
return ok; return ok;
} }
function assert(ok, message) {
total++;
if (ok) {
passed++;
console.log('PASS:', message);
} else {
console.error('FAIL:', message);
}
return ok;
}
function testParseGamutBasic() { function testParseGamutBasic() {
const text = `GAMUT file const text = `GAMUT file
BEGIN_DATA BEGIN_DATA
@@ -109,6 +149,81 @@ END_DATA`;
assertEqual(faces.length, 2, 'commented gamut face count'); assertEqual(faces.length, 2, 'commented gamut face count');
} }
function testWebglAvailableFalseWithoutContext() {
const doc = {
createElement: () => ({ getContext: () => null })
};
assertEqual(webglAvailable(doc), false, 'webglAvailable is false when no context');
assertEqual(webglAvailable(null), false, 'webglAvailable is false without document');
}
function testWebglAvailableTrueWithWebgl() {
const doc = {
createElement: () => ({
getContext: (type) => (type === 'webgl' ? {} : null)
})
};
assertEqual(webglAvailable(doc), true, 'webglAvailable is true with webgl context');
}
function testConstrainedGpuPrefersBackendArch() {
setGpuHints({});
assertEqual(
isLikelyConstrainedGpu({ arch: 'aarch64', navigator: { platform: 'MacIntel', userAgent: 'Macintosh; Intel Mac OS X 12_7' } }),
false,
'Apple Silicon arch is not constrained even if platform is MacIntel'
);
assertEqual(
isLikelyConstrainedGpu({ arch: 'x86_64', navigator: { platform: 'MacIntel' } }),
true,
'x86_64 arch is constrained'
);
}
function testConstrainedGpuIgnoresAppleSiliconUa() {
setGpuHints({});
assertEqual(
isLikelyConstrainedGpu({
navigator: { platform: 'MacIntel', userAgent: 'Macintosh; ARM Mac OS X 14_0 Apple Silicon' }
}),
false,
'ARM / Apple Silicon UA is not constrained'
);
}
function testConstrainedGpuIntelMac() {
setGpuHints({});
assertEqual(
isLikelyConstrainedGpu({
macosMajor: 12,
navigator: { platform: 'MacIntel', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_7_6)' }
}),
true,
'Monterey Intel UA is constrained'
);
assertEqual(
isLikelyConstrainedGpu({
arch: 'x86_64',
macosMajor: 14,
navigator: { platform: 'MacIntel', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' }
}),
true,
'Intel arch stays constrained on Ventura+'
);
}
function testEnsureGamutViewerNoopsWithoutDom() {
ensureGamutViewer();
assertEqual(isGamutViewerReady(), false, 'ensureGamutViewer does not create a renderer without Stage 5 DOM / THREE');
}
function testFallbackMessage() {
assert(
WEBGL_UNAVAILABLE_MESSAGE.includes('requires WebGL'),
'fallback copy mentions WebGL'
);
}
// Auto-run if executed in Node.js // Auto-run if executed in Node.js
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('gamut_viewer.test.js')) { if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('gamut_viewer.test.js')) {
runAll(); runAll();
+4
View File
@@ -1,5 +1,7 @@
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
import { getActiveCalibration } from './calibration.js';
let currentPresets = []; let currentPresets = [];
let activePresetId = "preset-std-rgb"; let activePresetId = "preset-std-rgb";
@@ -417,6 +419,8 @@ export async function initPresets() {
colprof_observer, colprof_observer,
colprof_input_viewing_cond, colprof_input_viewing_cond,
colprof_output_viewing_cond, colprof_output_viewing_cond,
calibration_file: getActiveCalibration().calPath || null,
apply_calibration: getActiveCalibration().applyEnabled,
}; };
} }
+2
View File
@@ -3,6 +3,7 @@ const { listen } = window.__TAURI__.event;
import { setStage2Result } from './chartread.js'; import { setStage2Result } from './chartread.js';
import { wizardState } from './state.js'; import { wizardState } from './state.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { getPrinttargCalibrationFields } from './calibration.js';
// Module-level state: set by Stage 1 when it completes // Module-level state: set by Stage 1 when it completes
let stage1Basename = ""; let stage1Basename = "";
@@ -505,6 +506,7 @@ export function initPrinttarg() {
no_randomize: noRandomize, no_randomize: noRandomize,
basename: stage1Basename, basename: stage1Basename,
cwd: stage1Cwd, cwd: stage1Cwd,
...getPrinttargCalibrationFields(stage1Basename),
}; };
const processId = `printtarg_${stage1Basename}`; const processId = `printtarg_${stage1Basename}`;
+3
View File
@@ -3,6 +3,7 @@ const { listen } = window.__TAURI__.event;
import { loadGamutMesh } from './gamut_viewer.js'; import { loadGamutMesh } from './gamut_viewer.js';
import { wizardState } from './state.js'; import { wizardState } from './state.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { setProfileInstallSource } from './profile_install.js';
let profileBasename = ""; let profileBasename = "";
let profileCwd = ""; let profileCwd = "";
@@ -535,6 +536,7 @@ export function initProfcheck() {
logContainer.classList.remove("hidden"); logContainer.classList.remove("hidden");
reportCard.classList.add("hidden"); reportCard.classList.add("hidden");
btnVerify.disabled = true; btnVerify.disabled = true;
setProfileInstallSource(iccPath, false);
const config = { const config = {
ti3_path: ti3Path, ti3_path: ti3Path,
@@ -571,6 +573,7 @@ export function initProfcheck() {
if (event.payload.code === 0) { if (event.payload.code === 0) {
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n"; logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
const report = parseProfcheckReport(stdoutAccumulator); const report = parseProfcheckReport(stdoutAccumulator);
setProfileInstallSource(iccPath, true);
reportCard.classList.remove("hidden"); reportCard.classList.remove("hidden");
if (report.warnings.length > 0) { if (report.warnings.length > 0) {
+142
View File
@@ -0,0 +1,142 @@
/**
* Stage 5 install a verified ICC/ICM profile into the OS colour store (#223).
*/
import { wizardState } from './state.js';
import { logger } from './logger.js';
import { getActiveCalibration } from './calibration.js';
const invoke = (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke)
? window.__TAURI__.core.invoke.bind(window.__TAURI__.core)
: async () => { throw new Error('Tauri invoke unavailable'); };
const installState = {
profilePath: null,
verified: false,
installedPath: null,
};
export function calibrationInstallNote() {
const cal = getActiveCalibration();
if (!cal.calPath || !cal.applyEnabled) return null;
const name = cal.filename || cal.calPath.split(/[\\/]/).pop();
return `Linearization curves from ${name} were applied when this profile was built.`;
}
export function setProfileInstallSource(profilePath, verified) {
installState.profilePath = profilePath || null;
installState.verified = !!verified;
if (!verified) installState.installedPath = null;
refreshInstallButton();
}
function refreshInstallButton() {
const btn = document.getElementById('btnInstallProfile');
if (!btn) return;
const ready = !!installState.profilePath && installState.verified;
const already = !!installState.installedPath;
btn.disabled = !ready || already;
if (already) {
btn.textContent = 'Installed ✓';
btn.title = `Already installed to ${installState.installedPath}`;
} else if (!ready) {
btn.textContent = 'Install Profile to System';
btn.title = 'Copies the generated ICC/ICM into the OS colour-profile directory so print dialogs and colour-managed applications can discover it. Requires a successful verification. Elevated privileges on some platforms.';
} else {
btn.textContent = 'Install Profile to System';
btn.title = 'Copies the generated ICC/ICM profile into the operating systems standard colour-profile directory so that print dialogs and colour-managed applications can discover it. Requires elevated privileges on some platforms.';
}
}
function collisionChoice(message) {
return new Promise((resolve) => {
const dialog = document.getElementById('profileInstallCollisionDialog');
const msg = document.getElementById('profileInstallCollisionMessage');
if (msg) msg.textContent = message;
if (!dialog || typeof dialog.showModal !== 'function') {
resolve(window.confirm(`${message}\n\nOverwrite?`) ? 'overwrite' : 'cancel');
return;
}
const finish = (choice) => {
dialog.close();
resolve(choice);
};
document.getElementById('profileOverwriteBtn')?.addEventListener('click', () => finish('overwrite'), { once: true });
document.getElementById('profileRenameBtn')?.addEventListener('click', () => finish('rename'), { once: true });
document.getElementById('profileCancelCollisionBtn')?.addEventListener('click', () => finish('cancel'), { once: true });
dialog.showModal();
});
}
async function loadInstallPrefs() {
try {
const settings = await invoke('load_settings');
return {
preferSystem: (settings.default_install_location || 'user') === 'system',
askOverwrite: settings.ask_before_overwrite_profile !== false,
openPanel: !!settings.open_color_panel_after_install,
};
} catch (_) {
return { preferSystem: false, askOverwrite: true, openPanel: false };
}
}
async function doInstall(policy = 'cancel') {
const btn = document.getElementById('btnInstallProfile');
const prefs = await loadInstallPrefs();
if (btn) {
btn.disabled = true;
btn.textContent = 'Installing…';
}
const options = {
force_overwrite: policy === 'overwrite',
prefer_system_wide: prefs.preferSystem,
register_with_os: true,
collision_policy: policy,
open_color_panel: prefs.openPanel,
calibration_note: calibrationInstallNote(),
};
try {
const result = await invoke('install_profile_to_system', {
profilePath: installState.profilePath,
options,
});
installState.installedPath = result.dest_path;
wizardState.showNotice(result.message, 'success', 8000);
logger.info(result.message, 'ProfileInstall');
const log = document.getElementById('profcheckLog');
if (log) log.textContent += `\n[INSTALL] ${result.message}\n`;
refreshInstallButton();
} catch (err) {
const message = String(err);
if (/already exists/i.test(message) && prefs.askOverwrite && policy === 'cancel') {
const choice = await collisionChoice(message);
if (choice !== 'cancel') {
await doInstall(choice);
return;
}
}
wizardState.showNotice(`Install failed: ${err}`, 'error', 9000);
logger.error(`install_profile_to_system: ${err}`, 'ProfileInstall');
if (btn) {
btn.disabled = false;
btn.textContent = 'Install Profile to System';
}
}
}
export function initProfileInstall() {
const btn = document.getElementById('btnInstallProfile');
if (btn) {
btn.addEventListener('click', () => doInstall('cancel'));
}
refreshInstallButton();
}
export function runProfileInstallTests() {
const noteNone = (() => {
// Pure helper coverage lives in calibrationInstallNote via getActiveCalibration.
return typeof calibrationInstallNote === 'function';
})();
return { helperExported: noteNone };
}
+43
View File
@@ -0,0 +1,43 @@
// node src/js/profile_install.test.js
if (typeof window === 'undefined') {
globalThis.window = {
__TAURI__: {
core: { invoke: () => Promise.resolve({}) },
event: { listen: () => Promise.resolve(() => {}) }
},
addEventListener: () => {},
dispatchEvent: () => {}
};
globalThis.document = {
getElementById: () => null,
querySelectorAll: () => [],
querySelector: () => null,
createElement: () => ({ style: {}, appendChild() {}, addEventListener() {}, setAttribute() {} })
};
globalThis.localStorage = { getItem: () => null, setItem: () => {} };
}
const { calibrationInstallNote, setProfileInstallSource } = await import('./profile_install.js');
let passed = 0;
let total = 0;
function assert(cond, name) {
total += 1;
if (cond) { passed += 1; console.log(` ok ${name}`); }
else console.error(` FAIL ${name}`);
}
export function runAll() {
passed = 0; total = 0;
console.log('profile_install.test.js');
assert(typeof calibrationInstallNote === 'function', 'exports calibrationInstallNote');
assert(calibrationInstallNote() === null, 'no note when calibration is inactive');
assert(typeof setProfileInstallSource === 'function', 'exports setProfileInstallSource');
setProfileInstallSource('/tmp/demo.icc', true);
setProfileInstallSource(null, false);
console.log(`\n${passed}/${total} passed`);
if (passed !== total) process.exitCode = 1;
}
runAll();
+18
View File
@@ -70,6 +70,18 @@ export async function initSettings() {
if (deltaEWarningMax) { if (deltaEWarningMax) {
deltaEWarningMax.value = Number(settings.delta_e_warning_max ?? 5.0).toFixed(1); deltaEWarningMax.value = Number(settings.delta_e_warning_max ?? 5.0).toFixed(1);
} }
const staleDays = document.getElementById('calibrationStaleDays');
if (staleDays) {
staleDays.value = Number(settings.calibration_stale_days ?? 30);
}
const installLoc = document.getElementById('defaultInstallLocation');
if (installLoc) {
installLoc.value = settings.default_install_location === 'system' ? 'system' : 'user';
}
const askOw = document.getElementById('askBeforeOverwriteProfile');
if (askOw) askOw.checked = settings.ask_before_overwrite_profile !== false;
const openPanel = document.getElementById('openColorPanelAfterInstall');
if (openPanel) openPanel.checked = !!settings.open_color_panel_after_install;
validateDeltaEThresholds(); validateDeltaEThresholds();
await refreshLogPath(); await refreshLogPath();
dialog.showModal(); dialog.showModal();
@@ -150,6 +162,12 @@ export async function initSettings() {
delta_e_good_max: getInputValueAsFloat('deltaEGoodMax', 2.0), delta_e_good_max: getInputValueAsFloat('deltaEGoodMax', 2.0),
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0), delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false, enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
calibration_stale_days: Math.max(1, parseInt(document.getElementById('calibrationStaleDays')?.value, 10) || 30),
default_install_location: document.getElementById('defaultInstallLocation')?.value === 'system' ? 'system' : 'user',
ask_before_overwrite_profile: document.getElementById('askBeforeOverwriteProfile')
? document.getElementById('askBeforeOverwriteProfile').checked : true,
open_color_panel_after_install: document.getElementById('openColorPanelAfterInstall')
? document.getElementById('openColorPanelAfterInstall').checked : false,
}; };
await invoke('save_settings', { settings }); await invoke('save_settings', { settings });
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings'); logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
+22 -2
View File
@@ -1,3 +1,5 @@
import { ensureGamutViewer, pauseGamutViewer } from './gamut_viewer.js';
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
export const wizardState = { export const wizardState = {
@@ -6,6 +8,8 @@ export const wizardState = {
cwd: "", cwd: "",
printerName: "", printerName: "",
noticeTimer: null, noticeTimer: null,
sessionMode: "profile",
profileBasename: "",
setTarget(basename, cwd) { setTarget(basename, cwd) {
if (basename) this.basename = basename; if (basename) this.basename = basename;
@@ -59,6 +63,10 @@ export const wizardState = {
const stages = document.querySelectorAll('.stage'); const stages = document.querySelectorAll('.stage');
steps.forEach(s => { steps.forEach(s => {
if (stageNumber === 0) {
s.classList.remove('active');
return;
}
if (s.getAttribute('data-step') === String(stageNumber)) { if (s.getAttribute('data-step') === String(stageNumber)) {
s.classList.remove('disabled'); s.classList.remove('disabled');
s.classList.add('active'); s.classList.add('active');
@@ -68,7 +76,7 @@ export const wizardState = {
}); });
stages.forEach(s => { stages.forEach(s => {
if (s.id === `stage-${stageNumber}`) { if (s.id === `stage-${stageNumber}` || (stageNumber === 0 && s.id === 'stage-cal')) {
s.classList.remove('hidden'); s.classList.remove('hidden');
s.classList.add('active'); s.classList.add('active');
} else { } else {
@@ -78,11 +86,23 @@ export const wizardState = {
}); });
window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } })); window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } }));
if (stageNumber === 5) {
ensureGamutViewer();
} else {
pauseGamutViewer();
}
}, },
async navigateToStage(stageNumber) { async navigateToStage(stageNumber) {
const targetNum = parseInt(stageNumber, 10); const targetNum = parseInt(stageNumber, 10);
if (isNaN(targetNum) || targetNum < 1 || targetNum > 5) return false; if (isNaN(targetNum) || targetNum < 0 || targetNum > 5) return false;
if (targetNum === 0) {
this.currentStage = 0;
this.applyStageDOM(0);
return true;
}
if (targetNum === 1) { if (targetNum === 1) {
this.currentStage = 1; this.currentStage = 1;
+136
View File
@@ -1491,6 +1491,25 @@ button.danger:hover {
width: 100% !important; width: 100% !important;
} }
.gamut-webgl-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
min-height: 280px;
padding: 28px 24px;
color: #c8c8d0;
background: #0e0e14;
text-align: center;
}
.gamut-webgl-fallback p {
margin: 0;
max-width: 28rem;
line-height: 1.5;
}
/* ── Legend / controls panel ─────────────────────────────────────────────── */ /* ── Legend / controls panel ─────────────────────────────────────────────── */
.gamut-controls-panel { .gamut-controls-panel {
@@ -1904,3 +1923,120 @@ button.danger:hover {
background: rgba(34, 197, 94, 0.2); background: rgba(34, 197, 94, 0.2);
color: #86efac; color: #86efac;
} }
/* Printer calibration dashboard (#224) */
.cal-status-chip {
margin-top: 8px;
font-size: 0.72rem;
line-height: 1.3;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border-color);
color: var(--text-muted, #9aa);
background: rgba(255, 255, 255, 0.03);
}
.cal-status-active {
color: #86efac;
border-color: rgba(34, 197, 94, 0.35);
}
.cal-status-stale {
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.4);
}
.cal-status-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin: 0 0 16px;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: rgba(0, 122, 204, 0.08);
font-size: 0.88rem;
}
.cal-banner-stale {
background: rgba(251, 191, 36, 0.08);
border-color: rgba(251, 191, 36, 0.35);
}
.cal-banner-active {
background: rgba(34, 197, 94, 0.08);
border-color: rgba(34, 197, 94, 0.3);
}
.cal-apply-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 0.82rem;
}
.cal-dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 1100px) {
.cal-dashboard-grid { grid-template-columns: 1fr; }
}
.cal-card {
background: var(--panel-color);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px 18px;
}
.cal-card h3 {
margin: 0 0 12px;
font-size: 1rem;
}
.cal-curve-svg {
width: 100%;
height: 180px;
background: #14141a;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.cal-grid {
stroke: #2a2a33;
stroke-width: 1;
}
.cal-axis-label {
fill: #888;
font-size: 10px;
font-family: system-ui, sans-serif;
}
.cal-curve-legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
font-size: 0.8rem;
}
.cal-legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.cal-legend-item i {
width: 10px;
height: 10px;
border-radius: 2px;
display: inline-block;
}
.cal-tac-card {
font-size: 0.95rem;
margin-bottom: 10px;
}
.cal-ink-row {
display: grid;
grid-template-columns: 1.4rem 1fr 4.5rem auto;
gap: 8px;
align-items: center;
margin: 6px 0;
font-weight: 600;
}
.cal-ink-row input[type="number"] {
width: 100%;
}