Compare commits
18
Commits
v0.8.3
...
v0.8.5-test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
980b44bccb | ||
|
|
24318bca58 | ||
|
|
a147064722 | ||
|
|
87e8e65771 | ||
|
|
3cf2a0f441 | ||
|
|
fb766c15d6 | ||
|
|
26b2337a16 | ||
|
|
3e7ece746b | ||
|
|
f47e65ee1f | ||
|
|
193e492c10 | ||
|
|
899718d896 | ||
|
|
7a9637b5d0 | ||
|
|
1af844c7e2 | ||
|
|
56f275a023 | ||
|
|
54792a8690 | ||
|
|
906b4f9104 | ||
|
|
5f5bf76594 | ||
|
|
9c7d563dbf |
@@ -64,6 +64,9 @@ jobs:
|
|||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Set Release Environment
|
- name: Set Release Environment
|
||||||
id: set_env
|
id: set_env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ jobs:
|
|||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
binary_dir: macos-x86_64
|
binary_dir: macos-x86_64
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
# - name: Apple Silicon
|
- name: Apple Silicon
|
||||||
# os: macos
|
os: macos
|
||||||
# target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
# binary_dir: macos-aarch64
|
binary_dir: macos-aarch64
|
||||||
# arch: arm64
|
arch: arm64
|
||||||
# - name: Universal (Intel + Apple Silicon)
|
# - name: Universal (Intel + Apple Silicon)
|
||||||
# os: macos
|
# os: macos
|
||||||
# target: universal-apple-darwin
|
# target: universal-apple-darwin
|
||||||
@@ -59,6 +59,9 @@ jobs:
|
|||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Fetch ArgyllCMS binaries
|
- name: Fetch ArgyllCMS binaries
|
||||||
env:
|
env:
|
||||||
ARGYLL_SERVER_URL: ${{ github.server_url }}
|
ARGYLL_SERVER_URL: ${{ github.server_url }}
|
||||||
@@ -81,23 +84,9 @@ jobs:
|
|||||||
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
|
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
|
||||||
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
|
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Warm up macOS GUI for DMG layout
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Tauri's DMG bundler runs Finder AppleScript to set the background
|
|
||||||
# picture and icon positions. On CI Macs, Finder/Aqua may be idle,
|
|
||||||
# which causes AppleEvent timeouts (-1712). Wake them before build.
|
|
||||||
echo "WindowServer: $(pgrep -l WindowServer || echo 'NOT RUNNING')"
|
|
||||||
echo "console user: $(stat -f '%Su' /dev/console 2>/dev/null || echo unknown)"
|
|
||||||
echo "whoami: $(whoami)"
|
|
||||||
open -g -a Finder || true
|
|
||||||
open -g -a "System Events" || true
|
|
||||||
osascript -e 'tell application "Finder" to get name' || true
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
- name: Build Tauri App
|
- name: Build Tauri App
|
||||||
env:
|
env:
|
||||||
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
CI: "true"
|
||||||
run: npm run tauri build -- --target ${{ matrix.platform.target }}
|
run: npm run tauri build -- --target ${{ matrix.platform.target }}
|
||||||
|
|
||||||
- name: Prepare Release Assets
|
- name: Prepare Release Assets
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ jobs:
|
|||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Fetch ArgyllCMS binaries
|
- name: Fetch ArgyllCMS binaries
|
||||||
shell: powershell
|
shell: powershell
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# ICCery Agent Notes
|
# ICCery Agent Notes
|
||||||
|
|
||||||
|
## Printer Calibration (`printcal` / `applycal`) (#224)
|
||||||
|
|
||||||
|
- Optional Stage 0 dashboard, opened from **Calibrate Printer**. The 1–5 wizard is unchanged when calibration is skipped.
|
||||||
|
- Calibration charts use a `CAL_` basename so they never collide with the profiling `.ti1`/`.ti2`/`.ti3`.
|
||||||
|
- `printtarg -K file.cal` is applied only to **profiling** layouts, never to the calibration chart itself.
|
||||||
|
- After Stage 4 `colprof`, `applycal` embeds the curves into the ICC/ICM when Apply Calibration is on.
|
||||||
|
- `.cal` overwrite requires an explicit Overwrite / Rename / Cancel choice.
|
||||||
|
- Warn when a loaded `.cal` is older than `calibration_stale_days` (default 30) or the stored printer name differs.
|
||||||
|
- Tests: `src-tauri/src/calibration.rs` (arg builders + `.cal` parser) and `src/js/calibration.test.js`.
|
||||||
|
|
||||||
|
## Stage 5 System Profile Install (#223)
|
||||||
|
|
||||||
|
- `install_profile_to_system` copies the working-directory `.icc`/`.icm` into the OS colour store. It never moves or deletes the project artefact.
|
||||||
|
- Destinations: Windows `%WINDIR%\System32\spool\drivers\color` (`.icm`); macOS `~/Library/ColorSync/Profiles` or `/Library/ColorSync/Profiles`; Linux `~/.local/share/icc` or `/usr/share/color/icc` (colormgr when present).
|
||||||
|
- Collisions require Overwrite / Rename / Cancel. Permission errors must mention elevation.
|
||||||
|
- If Apply Calibration is on, the success toast notes which `.cal` was embedded.
|
||||||
|
- Tests: `src-tauri/src/profile_install.rs` and `src/js/profile_install.test.js`.
|
||||||
|
|
||||||
## Stage 5 Verification / Profcheck
|
## 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`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
[](https://git.i3omb.com/gronod/ICCery)
|
[](https://git.i3omb.com/gronod/ICCery)
|
||||||
[](https://git.i3omb.com/gronod/ICCery)
|
[](https://git.i3omb.com/gronod/ICCery)
|
||||||
[](https://tauri.app)
|
[](https://tauri.app)
|
||||||
[](LICENCE.md)
|
[](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 (0–255 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 (0–255 scaling, field aliasing, metadata synthesis) and direct-jump workflows to Stage 4 (Profile Calculation) and Stage 5 (Verification).
|
||||||
- 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation.
|
- 📋 **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.0–12.6 Monterey, Intel | Best-effort; WebGL is deferred until Stage 5; known WKWebView GPU process crashes |
|
||||||
|
| 11 Big Sur | Not supported (installer refuses) |
|
||||||
|
| 10.15 Catalina | Not supported |
|
||||||
|
|
||||||
|
The 3D gamut viewer (Stage 5) creates a WebGL context only when that stage is shown. Stages 1–4 remain usable if WebGL is missing or the GPU process is lost.
|
||||||
|
|
||||||
|
### macOS troubleshooting
|
||||||
|
|
||||||
|
If the window flashes white and disappears, this is almost always the WKWebView **Web Content** or **GPU** helper dying — Apple Crash Reporter will not attach to `ICCery.app`.
|
||||||
|
|
||||||
|
- Launch from Terminal to see `web content process terminated`:
|
||||||
|
```text
|
||||||
|
/Applications/ICCery.app/Contents/MacOS/ICCery
|
||||||
|
```
|
||||||
|
- Check `~/Library/Logs/DiagnosticReports` for `com.apple.WebKit.WebContent` or `com.apple.WebKit.GPU`.
|
||||||
|
- ICCery log file (rotated, last 5 segments kept):
|
||||||
|
```text
|
||||||
|
~/Library/Logs/com.gronod.iccery/iccery.log
|
||||||
|
```
|
||||||
|
- Custom ColorSync display profiles can crash toolkit UIs on Monterey. Testing with the default display profile (or Safe Mode) is a valid support question.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Licence
|
## 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).
|
||||||
|
|||||||
+10
-1
@@ -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.3` (Production release).
|
- **Current Version**: `v0.8.5` (Production release).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -113,6 +113,15 @@ ICCery is a native, cross-platform desktop application built with:
|
|||||||
- [x] **Custom Spectrum File Picker Dialog (#210)**: Implemented native `select_spectrum_file` command wrapping Tauri file dialog with `.sp` filter for custom FWA/OBA spectrum selection in Stage 4 profile generation.
|
- [x] **Custom Spectrum File Picker Dialog (#210)**: Implemented native `select_spectrum_file` command wrapping Tauri file dialog with `.sp` filter for custom FWA/OBA spectrum selection in Stage 4 profile generation.
|
||||||
- [x] **CGATS Dataset Import File Picker & State Synchronization (#211)**: Implemented native `select_dataset_file` command with `.ti3`, `.txt`, `.cgats`, and `.csv` filter, synchronized wizard target directory and basename upon import, and guarded against empty target states.
|
- [x] **CGATS Dataset Import File Picker & State Synchronization (#211)**: Implemented native `select_dataset_file` command with `.ti3`, `.txt`, `.cgats`, and `.csv` filter, synchronized wizard target directory and basename upon import, and guarded against empty target states.
|
||||||
|
|
||||||
|
### Maintenance & Reliability Release (`v0.8.4`)
|
||||||
|
- [x] **Atomic Verification History Persistence (#213)**: Hardened `quality_store.rs` with atomic temporary file writes (`.tmp`), explicit flush/sync, and atomic rename to prevent historical drift data loss or corruption upon unexpected system crashes.
|
||||||
|
- [x] **Frontend Unit Testing & CI Integration (#215)**: Added standard `npm test` script executing the 3 frontend test suites (`profcheck`, `chartread`, and `gamut_viewer`) and integrated automated frontend test validation into macOS, Linux, and Windows CI workflows.
|
||||||
|
- [x] **macOS Monterey WKWebView survival (#225)**: Deferred Stage 5 WebGL until the gamut viewer is shown, hid the main window until first paint, painted a dark WKWebView backing, logged Web Content termination, and raised `minimumSystemVersion` to 12.0.
|
||||||
|
|
||||||
|
### Printer Calibration Release (`v0.8.5`)
|
||||||
|
- [x] **Printer Calibration Curves (#224)**: Optional Stage 0 dashboard for `printcal` linearization and ink limits. `CAL_` artefacts, Apply Calibration toggle feeding `printtarg -K` and `applycal`, channel-response plots, stale-cal warnings, and project/library persistence.
|
||||||
|
- [x] **System-Wide Profile Installation (#223)**: Stage 5 “Install Profile to System” copies the verified ICC/ICM into the platform colour store (user or system), with overwrite/rename/cancel, elevation guidance, and a note when printcal curves were applied.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Future Roadmap
|
## 3. Future Roadmap
|
||||||
|
|||||||
+3
-2
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "iccery",
|
"name": "iccery",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.3",
|
"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 && node src/js/calibration.test.js && node src/js/profile_install.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2"
|
||||||
|
|||||||
Generated
+1
-1
@@ -1423,7 +1423,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.8.3"
|
version = "0.8.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"image",
|
"image",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.8.3"
|
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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-9
@@ -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);
|
||||||
let pm = app_handle.state::<process_manager::ProcessManager>();
|
}
|
||||||
tauri::async_runtime::block_on(async {
|
tauri::WindowEvent::CloseRequested { .. } => {
|
||||||
pm.kill_all().await;
|
window_lifecycle::mark_user_close_requested();
|
||||||
});
|
let pm = app_handle.state::<process_manager::ProcessManager>();
|
||||||
|
tauri::async_runtime::block_on(async {
|
||||||
|
pm.kill_all().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
log::debug!("WindowEvent on {label}: {other:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let text = format!("{other:?}");
|
||||||
|
if window_lifecycle::is_web_content_termination_event(&text) {
|
||||||
|
log::error!("WebView lifecycle event: {text}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,15 +100,21 @@ pub fn load_history_file(path: &Path) -> Vec<VerificationRecord> {
|
|||||||
}
|
}
|
||||||
let content = match fs::read_to_string(path) {
|
let content = match fs::read_to_string(path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return Vec::new(),
|
Err(e) => {
|
||||||
|
log::warn!("Failed to read verification history at {}: {}", path.display(), e);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match serde_json::from_str::<VerificationHistoryStore>(&content) {
|
match serde_json::from_str::<VerificationHistoryStore>(&content) {
|
||||||
Ok(store) => store.records,
|
Ok(store) => store.records,
|
||||||
Err(_) => Vec::new(),
|
Err(e) => {
|
||||||
|
log::error!("Failed to parse verification history at {}: {}", path.display(), e);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes verification records to the specified JSON path.
|
/// Writes verification records to the specified JSON path atomically.
|
||||||
/// Automatically creates parent directories if needed.
|
/// Automatically creates parent directories if needed.
|
||||||
/// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000).
|
/// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000).
|
||||||
pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> {
|
pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> {
|
||||||
@@ -132,7 +138,33 @@ pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result
|
|||||||
|
|
||||||
let json = serde_json::to_string_pretty(&store)
|
let json = serde_json::to_string_pretty(&store)
|
||||||
.map_err(|e| format!("Failed to serialize verification history: {}", e))?;
|
.map_err(|e| format!("Failed to serialize verification history: {}", e))?;
|
||||||
fs::write(path, json).map_err(|e| format!("Failed to write verification history: {}", e))?;
|
|
||||||
|
let mut tmp_path = path.as_os_str().to_os_string();
|
||||||
|
tmp_path.push(".tmp");
|
||||||
|
let tmp_path = PathBuf::from(tmp_path);
|
||||||
|
|
||||||
|
// Write to temporary file with explicit flush and sync
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = fs::File::create(&tmp_path)
|
||||||
|
.map_err(|e| format!("Failed to create temp history file: {}", e))?;
|
||||||
|
file.write_all(json.as_bytes())
|
||||||
|
.map_err(|e| {
|
||||||
|
let _ = fs::remove_file(&tmp_path);
|
||||||
|
format!("Failed to write temp history file: {}", e)
|
||||||
|
})?;
|
||||||
|
file.sync_all()
|
||||||
|
.map_err(|e| {
|
||||||
|
let _ = fs::remove_file(&tmp_path);
|
||||||
|
format!("Failed to sync temp history file: {}", e)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically replace destination file
|
||||||
|
if let Err(e) = fs::rename(&tmp_path, path) {
|
||||||
|
let _ = fs::remove_file(&tmp_path);
|
||||||
|
return Err(format!("Failed to atomically replace verification history file: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -418,4 +450,38 @@ mod tests {
|
|||||||
assert_eq!(p2.extension().unwrap(), "csv");
|
assert_eq!(p2.extension().unwrap(), "csv");
|
||||||
assert_eq!(p2.to_string_lossy(), "/tmp/history.csv");
|
assert_eq!(p2.to_string_lossy(), "/tmp/history.csv");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_atomic_write_preserves_data_and_cleans_tmp() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("iccery_test_atomic_write");
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let file_path = temp_dir.join("verification_history.json");
|
||||||
|
let mut tmp_file_path = file_path.as_os_str().to_os_string();
|
||||||
|
tmp_file_path.push(".tmp");
|
||||||
|
let tmp_file_path = std::path::PathBuf::from(tmp_file_path);
|
||||||
|
|
||||||
|
let rec = sample_record("vr-atom-1", "2026-09-06T12:00:00Z", 0.5);
|
||||||
|
write_history_file(&file_path, &[rec.clone()]).unwrap();
|
||||||
|
|
||||||
|
assert!(file_path.exists(), "Target file must exist");
|
||||||
|
assert!(!tmp_file_path.exists(), "Temporary file must not remain after successful atomic write");
|
||||||
|
|
||||||
|
let loaded = load_history_file(&file_path);
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert_eq!(loaded[0], rec);
|
||||||
|
|
||||||
|
// Overwrite with updated records to verify atomic replacement
|
||||||
|
let rec2 = sample_record("vr-atom-2", "2026-09-06T12:05:00Z", 1.2);
|
||||||
|
write_history_file(&file_path, &[rec.clone(), rec2.clone()]).unwrap();
|
||||||
|
|
||||||
|
assert!(!tmp_file_path.exists(), "Temporary file must not remain after overwrite");
|
||||||
|
let loaded2 = load_history_file(&file_path);
|
||||||
|
assert_eq!(loaded2.len(), 2);
|
||||||
|
assert_eq!(loaded2[0], rec);
|
||||||
|
assert_eq!(loaded2[1], rec2);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.3",
|
"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
@@ -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 (11–51). 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 system’s 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 & Drift Tracking</summary>
|
<summary>Verification History & 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 & Logging</label>
|
<label style="font-weight: 600;">Diagnostics & 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> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.5</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||||
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
<p><strong>Copyright © 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
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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 {
|
||||||
|
|||||||
+251
-20
@@ -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,21 +289,24 @@ export async function initGamutViewer() {
|
|||||||
buildAxisScaffold();
|
buildAxisScaffold();
|
||||||
|
|
||||||
// ── Resize handling ───────────────────────────────────────────────────
|
// ── Resize handling ───────────────────────────────────────────────────
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
for (const entry of entries) {
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
const w = entry.contentRect.width;
|
for (const entry of entries) {
|
||||||
const h = entry.contentRect.height;
|
const w = entry.contentRect.width;
|
||||||
if (w > 0 && h > 0 && renderer && camera) {
|
const h = entry.contentRect.height;
|
||||||
camera.aspect = w / h;
|
if (w > 0 && h > 0 && renderer && camera) {
|
||||||
camera.updateProjectionMatrix();
|
camera.aspect = w / h;
|
||||||
renderer.setSize(w, h);
|
camera.updateProjectionMatrix();
|
||||||
if (labelRenderer) labelRenderer.setSize(w, h);
|
renderer.setSize(w, h);
|
||||||
|
if (labelRenderer) labelRenderer.setSize(w, h);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
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
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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) {
|
||||||
|
|||||||
@@ -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 system’s 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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user