Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87e8e65771 | ||
|
|
3cf2a0f441 | ||
|
|
fb766c15d6 | ||
|
|
26b2337a16 | ||
|
|
3e7ece746b | ||
|
|
f47e65ee1f | ||
|
|
193e492c10 | ||
|
|
899718d896 | ||
|
|
7a9637b5d0 | ||
|
|
1af844c7e2 | ||
|
|
56f275a023 | ||
|
|
54792a8690 | ||
|
|
906b4f9104 | ||
|
|
5f5bf76594 | ||
|
|
9c7d563dbf | ||
|
|
9102c95468 | ||
|
|
743ad86f58 | ||
|
|
11e796fd17 | ||
|
|
ebbdbd4f55 | ||
|
|
ef0d65453e | ||
|
|
9f2ff015d7 | ||
|
|
a8c4025c01 | ||
|
|
5a1ad654b8 | ||
|
|
ec7eb2fcb4 | ||
|
|
c5ff3d1a49 | ||
|
|
8008d9bda4 | ||
|
|
ebcb3cdc2a | ||
|
|
2641edecc1 | ||
|
|
4621d734ff | ||
|
|
0b419702a1 |
@@ -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
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ jobs:
|
|||||||
build-macos:
|
build-macos:
|
||||||
name: Build macOS (${{ matrix.platform.name }})
|
name: Build macOS (${{ matrix.platform.name }})
|
||||||
runs-on: ${{ matrix.platform.os }}
|
runs-on: ${{ matrix.platform.os }}
|
||||||
|
env:
|
||||||
|
XDG_CONFIG_HOME: ${{ runner.temp }}/.config
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform:
|
platform:
|
||||||
@@ -22,16 +24,26 @@ jobs:
|
|||||||
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
|
||||||
binary_dir: macos-universal
|
# binary_dir: macos-universal
|
||||||
arch: universal
|
# arch: universal
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure workspace and git permissions
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ runner.temp }}/.config/git"
|
||||||
|
touch "${{ runner.temp }}/.config/git/ignore"
|
||||||
|
mkdir -p .git/info
|
||||||
|
touch .git/info/exclude
|
||||||
|
chmod -R u+rwX .git || true
|
||||||
|
chmod 644 .git/info/exclude || true
|
||||||
|
git config --local core.excludesFile "${{ runner.temp }}/.config/git/ignore" || true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -47,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 }}
|
||||||
@@ -59,21 +74,6 @@ jobs:
|
|||||||
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/instlist"
|
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/instlist"
|
||||||
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/targen"
|
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/targen"
|
||||||
|
|
||||||
- name: Run Rust tests
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
CARGO_INCREMENTAL: "0"
|
|
||||||
CARGO_TARGET_DIR: "${{ runner.temp }}/cargo-target"
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
if [ "${{ matrix.platform.target }}" = "universal-apple-darwin" ]; then
|
|
||||||
cargo test
|
|
||||||
elif [ "${{ matrix.platform.target }}" = "aarch64-apple-darwin" ] && [ "$(uname -m)" != "arm64" ]; then
|
|
||||||
cargo test --no-run --target ${{ matrix.platform.target }}
|
|
||||||
else
|
|
||||||
cargo test --target ${{ matrix.platform.target }}
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set Release Environment
|
- name: Set Release Environment
|
||||||
id: set_env
|
id: set_env
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -85,6 +85,8 @@ jobs:
|
|||||||
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: Build Tauri App
|
- name: Build Tauri App
|
||||||
|
env:
|
||||||
|
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
|
||||||
@@ -93,18 +95,11 @@ jobs:
|
|||||||
mkdir -p release-assets
|
mkdir -p release-assets
|
||||||
|
|
||||||
# Stage DMG package
|
# Stage DMG package
|
||||||
DMG_FILE=$(find src-tauri/target -type f -name "*.dmg" | head -n 1)
|
DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1)
|
||||||
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
|
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
|
||||||
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stage ZIP archive of .app bundle
|
|
||||||
APP_DIR=$(find src-tauri/target -type d -name "*.app" | head -n 1)
|
|
||||||
if [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then
|
|
||||||
APP_PARENT=$(dirname "$APP_DIR")
|
|
||||||
APP_NAME=$(basename "$APP_DIR")
|
|
||||||
(cd "$APP_PARENT" && zip -r "${GITHUB_WORKSPACE}/release-assets/${PREFIX}.zip" "$APP_NAME")
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Upload Artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure workspace and git permissions
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ runner.temp }}/.config/git"
|
||||||
|
touch "${{ runner.temp }}/.config/git/ignore"
|
||||||
|
mkdir -p .git/info
|
||||||
|
touch .git/info/exclude
|
||||||
|
chmod -R u+rwX .git || true
|
||||||
|
chmod 644 .git/info/exclude || true
|
||||||
|
git config --local core.excludesFile "${{ runner.temp }}/.config/git/ignore" || true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -103,13 +113,6 @@ jobs:
|
|||||||
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stage ZIP archive of .app bundle
|
|
||||||
APP_DIR=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1)
|
|
||||||
if [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then
|
|
||||||
APP_PARENT=$(dirname "$APP_DIR")
|
|
||||||
APP_NAME=$(basename "$APP_DIR")
|
|
||||||
(cd "$APP_PARENT" && zip -r "${GITHUB_WORKSPACE}/release-assets/${PREFIX}.zip" "$APP_NAME")
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Upload Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -10,6 +10,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`.
|
||||||
@@ -53,11 +56,17 @@ The frontend uses a tiered button sizing system defined in `src/styles/main.css`
|
|||||||
- Valid threshold values must be non-negative and `delta_e_good_max < delta_e_warning_max`; both the frontend and backend enforce this.
|
- Valid threshold values must be non-negative and `delta_e_good_max < delta_e_warning_max`; both the frontend and backend enforce this.
|
||||||
- Saving settings dispatches a `settings-saved` custom event so live components (e.g. the swatch grid) can re-classify on the fly.
|
- Saving settings dispatches a `settings-saved` custom event so live components (e.g. the swatch grid) can re-classify on the fly.
|
||||||
|
|
||||||
## Build Commands
|
## Build & Test Commands
|
||||||
|
|
||||||
- **Rust backend**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo check` (the project lives on a network filesystem that doesn't support file locking, so `CARGO_INCREMENTAL=0` is required)
|
- **Rust backend check**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo check` (the project lives on a network filesystem that doesn't support file locking, so `CARGO_INCREMENTAL=0` is required)
|
||||||
- **Rust tests**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo test`
|
- **Rust unit tests**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo test`
|
||||||
- **Frontend**: `cd src-tauri && npm run build` (or `npm run dev` for development)
|
- **Frontend test suites**:
|
||||||
|
- Verification & drift tests: `node src/js/profcheck.test.js` (21 tests)
|
||||||
|
- Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests)
|
||||||
|
- Gamut viewer tests: `node src/js/gamut_viewer.test.js`
|
||||||
|
- Browser devtools console: `import('./profcheck.test.js').then(m => m.runAll())`
|
||||||
|
- **Frontend development server**: `npm run tauri dev`
|
||||||
|
- **Production package build**: `npm run tauri build`
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
@@ -109,7 +118,7 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI
|
|||||||
- Record schema (`VerificationRecord` in `src-tauri/src/quality_store.rs`):
|
- Record schema (`VerificationRecord` in `src-tauri/src/quality_store.rs`):
|
||||||
- `id`: unique record identifier in the format `vr-<epoch_millis>-<seq>`.
|
- `id`: unique record identifier in the format `vr-<epoch_millis>-<seq>`.
|
||||||
- `profile_name`: target profile filename.
|
- `profile_name`: target profile filename.
|
||||||
- `printer`: device name captured at print spooling (`wizardState.printerName`), or "Unknown".
|
- `printer_name`: device name captured at print spooling (`wizardState.printerName`), or "Unknown".
|
||||||
- `avg_de`, `max_de`, `rms_de`: CIEDE2000 metrics from `profcheck` (using `-u` JSON summary).
|
- `avg_de`, `max_de`, `rms_de`: CIEDE2000 metrics from `profcheck` (using `-u` JSON summary).
|
||||||
- `patch_count`: number of test patches evaluated.
|
- `patch_count`: number of test patches evaluated.
|
||||||
- `status`: classified status using **ICCery verification bands (issue #95)**:
|
- `status`: classified status using **ICCery verification bands (issue #95)**:
|
||||||
@@ -118,7 +127,7 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI
|
|||||||
- `< 3.5`: "Acceptable" (`badge-acceptable`)
|
- `< 3.5`: "Acceptable" (`badge-acceptable`)
|
||||||
- `>= 3.5`: "Warning" (`badge-poor`)
|
- `>= 3.5`: "Warning" (`badge-poor`)
|
||||||
- `timestamp`: ISO-8601 UTC string.
|
- `timestamp`: ISO-8601 UTC string.
|
||||||
- Max capacity is 500 records; oldest records evicted on overflow.
|
- Max capacity is 1,000 records; oldest records evicted on overflow.
|
||||||
- Atomic file writes (`.tmp` write followed by `rename`) prevent data corruption.
|
- Atomic file writes (`.tmp` write followed by `rename`) prevent data corruption.
|
||||||
- Tauri IPC command casing:
|
- Tauri IPC command casing:
|
||||||
- Nested struct fields (`VerificationRecord`) serialize with `snake_case`.
|
- Nested struct fields (`VerificationRecord`) serialize with `snake_case`.
|
||||||
@@ -145,3 +154,21 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI
|
|||||||
- Testing:
|
- Testing:
|
||||||
- Pure line classification unit tests live in `src/js/chartread.test.js` (executable directly in Node or browser console).
|
- Pure line classification unit tests live in `src/js/chartread.test.js` (executable directly in Node or browser console).
|
||||||
- Unix/macOS mock script `src-tauri/argyll/mocks/chartread.mock` supports `--xy` flag (or `MOCK_XY_TABLE=1`) with blocking `read` calls simulating calibration, sheet placement, fiducial alignment, and scanning.
|
- Unix/macOS mock script `src-tauri/argyll/mocks/chartread.mock` supports `--xy` flag (or `MOCK_XY_TABLE=1`) with blocking `read` calls simulating calibration, sheet placement, fiducial alignment, and scanning.
|
||||||
|
|
||||||
|
## Stage 3 i1Pro 2 LED Status Feedback (#204)
|
||||||
|
|
||||||
|
- Supports the `-Y l` switch introduced in the ICCery ArgyllCMS fork to drive the dual RGB ring LEDs of the X-Rite i1Pro 2 (Rev E) for real-time visual status feedback during strip measurement:
|
||||||
|
- **Flashing White**: Awaiting baseline calibration on white tile.
|
||||||
|
- **Flashing Blue**: Ready for row swipe / awaiting strip read.
|
||||||
|
- **Flashing Red**: Strip scan error / misread.
|
||||||
|
- **Flashing Green**: Strip scan successfully captured.
|
||||||
|
- Controlled via `enable_i1pro2_leds: bool` in `AppSettings` (persisted in `settings.json`), exposed under Settings → Instrument & Measurement Preferences.
|
||||||
|
- Defaults to `false` ensuring 100% out-of-the-box compatibility with stock upstream ArgyllCMS binaries.
|
||||||
|
- Subprocess error diagnostics in `chartread.js` capture `lastStderrLine` from `process:stderr`, auto-expanding the Process Output `<details>` panel with the stderr explanation if an unpatched binary rejects `-Y l`.
|
||||||
|
|
||||||
|
## CI & Cross-Compilation
|
||||||
|
|
||||||
|
- Release packaging workflows live under `.gitea/workflows/` (`build-macos.yml`, `build-linux.yml`, `build-windows.yml`).
|
||||||
|
- Tag release builds focus exclusively on packaging via `npm run tauri build` without redundant debug-profile test compilations.
|
||||||
|
- Local/CI cross-compilation test execution for Apple Silicon (`aarch64-apple-darwin`) on Intel hosts must use `cargo test --no-run --target aarch64-apple-darwin` to avoid executing ARM64 binaries on an x86_64 CPU (`Bad CPU type in executable (os error 86)`).
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> 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)
|
||||||
|
|
||||||
@@ -14,16 +14,29 @@
|
|||||||
## 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, and neutral/grey axis boosting.
|
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.
|
||||||
2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint). View high-resolution downscaled TIFF previews and print directly using native OS raw unmanaged pathways (Windows GDI uncorrected / Linux CUPS `raw`).
|
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:
|
||||||
3. **Stage 3 — Interactive Measurement (`chartread`) & Averaging (`average`)**: Instrument auto-detection (`instlist`), real-time calibration prompts, interactive strip reading state machine, live swatch grid with CIEDE2000 ($\Delta E_{00}$) quality indicators and user-configurable traffic-light thresholds, diagonally split intended-vs-measured colour swatches, white reference patch preservation, and multi-pass sheet averaging for measurement noise reduction.
|
- **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).
|
||||||
4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, OBA/FWA compensation, illuminant/observer selection, viewing-condition transforms, custom ambient spectrum support, descriptions, and copyright tagging.
|
- **Windows**: GDI uncorrected raw printing and DEVMODE preferences.
|
||||||
5. **Stage 5 — Verification & 3D Gamut (`profcheck` + `iccgamut`)**: Comprehensive mathematical validation report (Peak, Average, RMS $\Delta E$) paired with an interactive 3D CIELAB convex hull color volume viewer, per-vertex true-colour rendering, layer opacity controls, camera reset, keyboard shortcut, touch controls, bundled sRGB reference wireframe comparison, and robust parsing of both JSON and legacy profcheck output formats.
|
- **Linux**: CUPS `raw` queue and PPD media option spooling.
|
||||||
|
3. **Stage 3 — Interactive Measurement (`chartread`) & Averaging (`average`)**:
|
||||||
|
- **Automated XY Scanning Tables (#93)**: Full automated sequence for GretagMacbeth SpectroScan and X-Rite i1iO tables with multi-line prompt classification, fiducial sight alignment prompts, 4-step sequence checklist UI, and graceful head parking (`q\n`).
|
||||||
|
- **Instrument Status Feedback (#204)**: Optional `-Y l` switch support driving the dual RGB ring LEDs of the X-Rite i1Pro 2 (flashing white for calibration, flashing blue for ready/swipe, flashing red for error, flashing green for capture).
|
||||||
|
- **Interactive Controls**: Dedicated `Done & Save .ti3` (`d\n`), `Undo Strip` (`u\n`), and `Skip Strip` (`s\n`) actions.
|
||||||
|
- **Live Swatch Grid**: 135° diagonally split intended-vs-measured colour patches with live CIEDE2000 ($\Delta E_{00}$) quality indicators, white reference patch preservation, and user-configurable good/warning traffic-light thresholds persisted across sessions.
|
||||||
|
- **Noise Reduction**: Multi-pass sheet averaging (`average`) to eliminate spectrophotometer noise.
|
||||||
|
4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, OBA/FWA compensation (`-f`), illuminant (`-i`) and observer (`-o`) overrides, viewing-condition transforms (`-c`, `-d`), custom ambient spectrum support, descriptions, and copyright tagging.
|
||||||
|
5. **Stage 5 — Verification, Drift Analytics & 3D Gamut (`profcheck` + `iccgamut`)**:
|
||||||
|
- **Longitudinal Printer Drift Analytics (#95)**: Historical verification logging persisted to `verification_history.json` (up to 1,000 records), an interactive dual-series SVG trend chart with shaded ICCery verification reference bands, consecutive-breach alert recommendation card (detecting drift across distinct dates or $\ge 1$ hour apart), and RFC-4180 compliant CSV export.
|
||||||
|
- **Mathematical Accuracy Report**: Peak, Average, and RMS CIEDE2000 metrics with robust parsing of both Argyll JSON summaries (`-u`) and legacy plain-text reports.
|
||||||
|
- **Interactive 3D Gamut Viewer**: CIELAB coordinate scaffold with crisp CSS2D labels, per-vertex true-colour profile gamut shading, layer visibility toggles and opacity sliders, camera reset (press **R**), touch controls, and bundled sRGB reference wireframe comparison.
|
||||||
|
- 📊 **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.
|
||||||
- 🐧 **glibc Compatibility**: Pre-built Linux packages compiled with Ubuntu 22.04 LTS compatibility for Debian/Ubuntu environments.
|
- 🍎 **macOS Universal Binary**: Native Apple Silicon (`arm64`) and Intel (`x86_64`) support with universal binary bundling and fallback resolution.
|
||||||
- 🛡️ **Disk Artefact Gating**: Stepper navigation strictly verifies generated artefacts on disk (`.ti1`, `.ti2`, `.ti3`, `.icc`/`.icm`), preventing out-of-order execution while preserving backward navigation.
|
- 🐧 **Linux glibc Compatibility**: Pre-built Linux packages compiled with Ubuntu 22.04 LTS compatibility for Debian/Ubuntu environments.
|
||||||
- 🌐 **Platform-Aware**: Automatic handling of platform profile conventions (`.icm` on Windows, `.icc` on Linux/macOS) and native OS printer subsystems.
|
- 🛡️ **Disk Artefact Gating**: Stepper navigation strictly verifies generated artefacts on disk (`.ti1` → `.ti2` → `.ti3` → `.icc`/`.icm`), preventing out-of-order execution while preserving backward navigation.
|
||||||
- 🎛️ **Consistent UI Controls**: Tiered button sizing (`btn-sm`/`btn-md`/`btn-lg`/`btn-icon-sq`) and standardised action row classes provide a uniform, polished interface across all wizard stages and dialogs.
|
- 🌐 **Platform-Aware**: Automatic handling of platform profile conventions (`.icm` on Windows, `.icc` on macOS/Linux) and native OS printer subsystems.
|
||||||
|
- 🎛️ **Standardised UI Design**: Tiered button sizing (`.btn-sm`, `.btn-md`, `.btn-lg`, `.btn-icon-sq`) and consistent action row layouts provide a uniform, responsive interface across all stages.
|
||||||
- ⚖️ **Clean AGPL Boundary**: Complete isolation of AGPLv3 binaries via asynchronous tokio IPC process pipelines.
|
- ⚖️ **Clean AGPL Boundary**: Complete isolation of AGPLv3 binaries via asynchronous tokio IPC process pipelines.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -38,13 +51,16 @@ flowchart TD
|
|||||||
UI[Wizard UI & Swatch Grid]
|
UI[Wizard UI & Swatch Grid]
|
||||||
ThreeJS[3D CIELAB Gamut Viewer]
|
ThreeJS[3D CIELAB Gamut Viewer]
|
||||||
State[Wizard State & Artefact Verifier]
|
State[Wizard State & Artefact Verifier]
|
||||||
PrintEngine["Raw Print Subsystem (GDI / CUPS)"]
|
QualityStore[Verification History & Drift Analytics]
|
||||||
|
PrintEngine["Raw Print Subsystem (GDI / CUPS / NSPrintPanel)"]
|
||||||
ProcMgr[Async Subprocess IPC Manager]
|
ProcMgr[Async Subprocess IPC Manager]
|
||||||
|
|
||||||
UI <--> State
|
UI <--> State
|
||||||
State <--> ProcMgr
|
State <--> ProcMgr
|
||||||
|
State <--> QualityStore
|
||||||
ProcMgr --> ThreeJS
|
ProcMgr --> ThreeJS
|
||||||
UI --> PrintEngine
|
UI --> PrintEngine
|
||||||
|
QualityStore --> UI
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Argyll ["ArgyllCMS Subprocesses (AGPLv3)"]
|
subgraph Argyll ["ArgyllCMS Subprocesses (AGPLv3)"]
|
||||||
@@ -72,6 +88,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 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`.
|
||||||
|
|
||||||
@@ -91,7 +108,7 @@ npm run fetch-argyll
|
|||||||
npm run tauri dev
|
npm run tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:**
|
> **Notes:**
|
||||||
> - Sidecars are not stored in git; `tauri build` / `tauri dev` will fail until `npm run fetch-argyll` has been run at least once.
|
> - Sidecars are not stored in git; `tauri build` / `tauri dev` will fail until `npm run fetch-argyll` has been run at least once.
|
||||||
> - You can override the downloaded ArgyllCMS release version using `ARGYLL_RELEASE_TAG=vX.Y.Z npm run fetch-argyll`.
|
> - You can override the downloaded ArgyllCMS release version using `ARGYLL_RELEASE_TAG=vX.Y.Z npm run fetch-argyll`.
|
||||||
> - On Windows, the NSIS installer package bundles the ArgyllCMS USB instrument driver suite and offers an optional driver setup step when run with administrative privileges.
|
> - On Windows, the NSIS installer package bundles the ArgyllCMS USB instrument driver suite and offers an optional driver setup step when run with administrative privileges.
|
||||||
@@ -101,12 +118,49 @@ npm run tauri dev
|
|||||||
# Download sidecars (if not already fetched)
|
# Download sidecars (if not already fetched)
|
||||||
npm run fetch-argyll
|
npm run fetch-argyll
|
||||||
|
|
||||||
# Build desktop packages (MSI/NSIS on Windows, DEB/AppImage on Linux)
|
# Build desktop packages
|
||||||
|
# - macOS: .dmg / .app bundle (Intel, Apple Silicon, or Universal with --target universal-apple-darwin)
|
||||||
|
# - Windows: .exe (NSIS) / .msi installer
|
||||||
|
# - Linux: .AppImage / .deb package
|
||||||
npm run tauri build
|
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).
|
||||||
|
|||||||
+33
-14
@@ -10,10 +10,11 @@ This document outlines the architectural roadmap, completed milestones, and upco
|
|||||||
## 1. Architecture Summary
|
## 1. Architecture Summary
|
||||||
|
|
||||||
ICCery is a native, cross-platform desktop application built with:
|
ICCery is a native, cross-platform desktop application built with:
|
||||||
- **Backend**: Rust + Tauri v2, managing asynchronous process pipes, native printer devmode configurations (Windows GDI & Linux CUPS), and filesystem operations.
|
- **Backend**: Rust + Tauri v2, managing asynchronous process pipes, native printer configurations (macOS Core Printing / `NSPrintPanel`, Windows GDI & DEVMODE, Linux CUPS), and filesystem operations.
|
||||||
- **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout.
|
- **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout.
|
||||||
- **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons.
|
- **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons.
|
||||||
- **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`).
|
- **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`).
|
||||||
|
- **Current Version**: `v0.8.4` (Production release).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -90,16 +91,7 @@ ICCery is a native, cross-platform desktop application built with:
|
|||||||
- [x] **Windows Authenticode Code Signing in Gitea CI** (`v0.6.4` – `v0.6.6`): Integrated Tauri bundle signing hooks via `sign.cmd` batch wrapper with PATH resolution, Gitea Actions secret-based PFX materialization, and ephemeral signing pipeline.
|
- [x] **Windows Authenticode Code Signing in Gitea CI** (`v0.6.4` – `v0.6.6`): Integrated Tauri bundle signing hooks via `sign.cmd` batch wrapper with PATH resolution, Gitea Actions secret-based PFX materialization, and ephemeral signing pipeline.
|
||||||
- [x] **Stage 3 Chartread Completion & Snapshot IPC Fix (#175)** (`v0.6.7` – `v0.6.8`): Added dedicated `Done & Save .ti3` action (`d\n`), `Undo Strip` action (`u\n`), automated completion state detection, and corrected Tauri IPC deserialization parameter (`passIndex`) in `snapshot_ti3`.
|
- [x] **Stage 3 Chartread Completion & Snapshot IPC Fix (#175)** (`v0.6.7` – `v0.6.8`): Added dedicated `Done & Save .ti3` action (`d\n`), `Undo Strip` action (`u\n`), automated completion state detection, and corrected Tauri IPC deserialization parameter (`passIndex`) in `snapshot_ti3`.
|
||||||
|
|
||||||
---
|
### Milestone 13 — UI/UX & Workflow Polish (`v0.7.0` – `v0.7.4`, `v0.8.0`)
|
||||||
|
|
||||||
## 3. Future Roadmap
|
|
||||||
|
|
||||||
### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0`)
|
|
||||||
- [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles with notarization.
|
|
||||||
- [ ] **macOS Raw Spooling**: Native CoreGraphics/CUPS raw print dialog bypass.
|
|
||||||
|
|
||||||
### Milestone 13 — UI/UX & Workflow Polish
|
|
||||||
|
|
||||||
- [x] **Global Button Standardization (#177)**: Enforce `.btn-sm`/`.btn-md`/`.btn-lg`/`.btn-icon-sq` tiers across all stages, remove inline button styles, and add CSS custom properties for button metrics.
|
- [x] **Global Button Standardization (#177)**: Enforce `.btn-sm`/`.btn-md`/`.btn-lg`/`.btn-icon-sq` tiers across all stages, remove inline button styles, and add CSS custom properties for button metrics.
|
||||||
- [x] **Swatch Grid White Patch & Orientation Polish (#178)**: Finalise `is_pad` guard documentation, diagonally split swatch tooltips, and validate `printtarg` row/column ordering.
|
- [x] **Swatch Grid White Patch & Orientation Polish (#178)**: Finalise `is_pad` guard documentation, diagonally split swatch tooltips, and validate `printtarg` row/column ordering.
|
||||||
- [x] **Configurable CIEDE2000 Thresholds (#184)**: User-configurable good/warning ΔE₀₀ upper bounds in Settings, persisted across sessions and applied to the Stage 3 swatch grid.
|
- [x] **Configurable CIEDE2000 Thresholds (#184)**: User-configurable good/warning ΔE₀₀ upper bounds in Settings, persisted across sessions and applied to the Stage 3 swatch grid.
|
||||||
@@ -107,7 +99,34 @@ ICCery is a native, cross-platform desktop application built with:
|
|||||||
- [x] **3D Gamut Viewer Controls (#185)**: Camera reset, opacity sliders, keyboard shortcut, and full public-API JSDoc.
|
- [x] **3D Gamut Viewer Controls (#185)**: Camera reset, opacity sliders, keyboard shortcut, and full public-API JSDoc.
|
||||||
- [x] **Gamut / Profcheck Hardening (#179)**: Validate `.gam` vertex/face parsing, improved `profcheck` regex fallbacks for legacy text output, and user-visible parser warnings.
|
- [x] **Gamut / Profcheck Hardening (#179)**: Validate `.gam` vertex/face parsing, improved `profcheck` regex fallbacks for legacy text output, and user-visible parser warnings.
|
||||||
|
|
||||||
### Milestone 12 — Future Workflow & Advanced Analytics
|
### Hardware Status Feedback Release (`v0.8.1`)
|
||||||
- [x] **Printer Drift Tracking & Verification Analytics (#95)**: Track longitudinal printer drift in Stage 5 over time with CIEDE2000 trend charts, breach alert banners, RFC-4180 CSV export, and history storage.
|
- [x] **i1Pro 2 LED Status Feedback (#204)**: Added optional `-Y l` switch support to `chartread` driving the dual RGB ring LEDs of the X-Rite i1Pro 2 for visual status feedback (calibration, swipe ready, misread, capture success).
|
||||||
- [x] **XY Automated Scanning Tables (#93)**: Full Stage 3 support for automated XY scanning tables (SpectroScan, i1iO) with multi-line prompt classification, fiducial alignment, and sheet placement checklist.
|
- [x] **macOS CI Cross-Compilation Testing**: Hardened `.gitea/workflows/build-macos.yml` by compiling Apple Silicon tests with `--no-run` on Intel runner hosts to avoid architecture execution mismatch.
|
||||||
|
|
||||||
|
### Milestone 12 — Future Workflow & Advanced Analytics (`v0.8.2`)
|
||||||
|
- [x] **Printer Drift Tracking & Verification Analytics (#95)**: Track longitudinal printer drift in Stage 5 over time with historical run logging in `verification_history.json` (1,000 records), interactive dual-series SVG trend chart with ICCery verification reference bands, consecutive-breach alert recommendation card, and RFC-4180 CSV export.
|
||||||
|
- [x] **XY Automated Scanning Tables (#93)**: Full Stage 3 support for automated XY scanning tables (GretagMacbeth SpectroScan, X-Rite i1iO) with pure multi-line prompt classification, fiducial alignment prompts, 4-step sequence checklist, and graceful head parking on cancel.
|
||||||
- [ ] ~~**Multi-Language Localization (#96)**~~: *Closed — Won't Fix* (English UI retained as standard color-management terminology).
|
- [ ] ~~**Multi-Language Localization (#96)**~~: *Closed — Won't Fix* (English UI retained as standard color-management terminology).
|
||||||
|
|
||||||
|
### Maintenance & Reliability Release (`v0.8.3`)
|
||||||
|
- [x] **Gamut Viewer Node Test Runner Support (#212)**: Guarded `window` and `window.__TAURI__` globals in `gamut_viewer.js` and added polyfill mock harness to `gamut_viewer.test.js` to enable automated headless test execution via `node src/js/gamut_viewer.test.js`.
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Future Roadmap
|
||||||
|
|
||||||
|
### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0` / Post-v0.8)
|
||||||
|
- [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles with Apple Developer ID notarization and stapling.
|
||||||
|
- [ ] **macOS Raw Spooling**: Native CoreGraphics/CUPS raw print dialog bypass.
|
||||||
|
|
||||||
|
### Future Architecture Strategy (Post-v0.8.2)
|
||||||
|
- **Multi-Device Drift Overlays**: Overlay drift curves from multiple printers/media types on a shared timeline in Stage 5.
|
||||||
|
- **Direct Remote Target Dispatch**: Send `.ti2` target jobs to network print spoolers or remote print labs with automated token callbacks.
|
||||||
|
- **Embedded ICC Profile Inspector**: Direct inspection of cLUT tags, chromatic adaptation matrices, tone reproduction curves (TRC), and profile metadata from saved `.icc`/`.icm` files.
|
||||||
|
|||||||
+18
-12
@@ -23,20 +23,26 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
|
|
||||||
## Delaunator
|
## quickhull3d
|
||||||
Copyright (c) 2017, Mapbox
|
MIT License
|
||||||
|
|
||||||
Permission to use, copy, modify, and/or distribute this software for any purpose
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
with or without fee is hereby granted, provided that the above copyright notice
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
and this permission notice appear in all copies.
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
The above copyright notice and this permission notice shall be included in
|
||||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
all copies or substantial portions of the Software.
|
||||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
||||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
## Tauri Framework & Rust Dependencies
|
## Tauri Framework & Rust Dependencies
|
||||||
Copyright (c) 2019-present, Tauri Programme within The Commons Conservancy.
|
Copyright (c) 2019-present, Tauri Programme within The Commons Conservancy.
|
||||||
|
|||||||
+3
-2
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "iccery",
|
"name": "iccery",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.2",
|
"version": "0.8.4",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"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.2"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"image",
|
"image",
|
||||||
|
|||||||
+12
-1
@@ -1,9 +1,20 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||||
authors = ["Gordon"]
|
authors = ["Gordon"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
include = [
|
||||||
|
"src/**/*",
|
||||||
|
"build.rs",
|
||||||
|
"Cargo.toml",
|
||||||
|
"Cargo.lock",
|
||||||
|
"tauri.conf.json",
|
||||||
|
"capabilities/**/*",
|
||||||
|
"gen/**/*",
|
||||||
|
"icons/**/*",
|
||||||
|
"windows/**/*",
|
||||||
|
]
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ fn main() {
|
|||||||
|
|
||||||
println!("cargo:rustc-env=BUILD_DATE={}", build_date);
|
println!("cargo:rustc-env=BUILD_DATE={}", build_date);
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
println!("cargo:rerun-if-changed=src");
|
||||||
|
println!("cargo:rerun-if-changed=tauri.conf.json");
|
||||||
|
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||||
|
println!("cargo:rerun-if-changed=argyll");
|
||||||
|
|
||||||
// Validate that ArgyllCMS sidecar binaries are staged before building
|
// Validate that ArgyllCMS sidecar binaries are staged before building
|
||||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||||
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -391,6 +472,61 @@ pub async fn select_profile_file(
|
|||||||
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn select_spectrum_file(
|
||||||
|
app: AppHandle,
|
||||||
|
default_dir: Option<String>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let mut builder = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter("Argyll Spectrum File (*.sp)", &["sp"]);
|
||||||
|
if let Some(ref dir) = default_dir {
|
||||||
|
if !dir.trim().is_empty() {
|
||||||
|
builder = builder.set_directory(std::path::PathBuf::from(dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
builder.pick_file(move |file_path| {
|
||||||
|
let res = file_path.map(|p| p.to_string());
|
||||||
|
let _ = tx.send(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn select_dataset_file(
|
||||||
|
app: AppHandle,
|
||||||
|
default_dir: Option<String>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let mut builder = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter(
|
||||||
|
"Measurement Dataset (*.ti3, *.txt, *.cgats, *.csv)",
|
||||||
|
&["ti3", "txt", "cgats", "csv"],
|
||||||
|
);
|
||||||
|
if let Some(ref dir) = default_dir {
|
||||||
|
if !dir.trim().is_empty() {
|
||||||
|
builder = builder.set_directory(std::path::PathBuf::from(dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
builder.pick_file(move |file_path| {
|
||||||
|
let res = file_path.map(|p| p.to_string());
|
||||||
|
let _ = tx.send(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn select_target_file(
|
pub async fn select_target_file(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -2041,4 +2177,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-9
@@ -3,10 +3,12 @@ use tauri::Manager;
|
|||||||
mod cgats;
|
mod cgats;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod events;
|
mod events;
|
||||||
|
mod macos_webview;
|
||||||
mod print;
|
mod print;
|
||||||
mod process_manager;
|
mod process_manager;
|
||||||
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 +23,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 +39,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 +52,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,
|
||||||
@@ -51,6 +65,8 @@ pub fn run() {
|
|||||||
commands::inspect_dataset_preview,
|
commands::inspect_dataset_preview,
|
||||||
commands::select_existing_target,
|
commands::select_existing_target,
|
||||||
commands::select_profile_file,
|
commands::select_profile_file,
|
||||||
|
commands::select_spectrum_file,
|
||||||
|
commands::select_dataset_file,
|
||||||
commands::select_target_file,
|
commands::select_target_file,
|
||||||
commands::select_directory,
|
commands::select_directory,
|
||||||
commands::send_stdin,
|
commands::send_stdin,
|
||||||
@@ -93,21 +109,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.2",
|
"version": "0.8.4",
|
||||||
"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": {
|
||||||
|
|||||||
+3
-3
@@ -1059,9 +1059,9 @@
|
|||||||
<p>Copyright (c) 2010-2023 three.js authors</p>
|
<p>Copyright (c) 2010-2023 three.js authors</p>
|
||||||
<p class="license-legal-block">Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:<br/><br/>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.<br/><br/>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
|
<p class="license-legal-block">Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:<br/><br/>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.<br/><br/>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
|
||||||
|
|
||||||
<h5>Delaunator</h5>
|
<h5>quickhull3d</h5>
|
||||||
<p>Copyright (c) 2017, Mapbox</p>
|
<p>MIT License</p>
|
||||||
<p class="license-legal-block">Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.<br/><br/>THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.</p>
|
<p class="license-legal-block">Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:<br/><br/>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.<br/><br/>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
|
||||||
|
|
||||||
<h5>Tauri Framework & Rust Dependencies</h5>
|
<h5>Tauri Framework & Rust Dependencies</h5>
|
||||||
<p>Copyright (c) 2019-present, Tauri Programme within The Commons Conservancy.</p>
|
<p>Copyright (c) 2019-present, Tauri Programme within The Commons Conservancy.</p>
|
||||||
|
|||||||
+56
-3
@@ -4,7 +4,7 @@ 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';
|
||||||
@@ -12,6 +12,36 @@ import { CgatsInterop } from './cgats_interop.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 +69,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 +91,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 +125,23 @@ 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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
});
|
});
|
||||||
+40
-11
@@ -1,4 +1,6 @@
|
|||||||
const { invoke } = window.__TAURI__.core;
|
const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke
|
||||||
|
? window.__TAURI__.core.invoke
|
||||||
|
: async () => {};
|
||||||
|
|
||||||
export class CgatsInterop {
|
export class CgatsInterop {
|
||||||
constructor(appState) {
|
constructor(appState) {
|
||||||
@@ -22,26 +24,53 @@ export class CgatsInterop {
|
|||||||
|
|
||||||
async handleImport() {
|
async handleImport() {
|
||||||
try {
|
try {
|
||||||
const filePath = await invoke('select_target_file');
|
const filePath = await invoke('select_dataset_file', {
|
||||||
|
defaultDir: this.appState?.cwd || null,
|
||||||
|
});
|
||||||
if (!filePath) return; // User cancelled
|
if (!filePath) return; // User cancelled
|
||||||
|
|
||||||
// Inspect first to show modal (optional, skipping for now, directly import)
|
const isWindows = filePath.includes('\\');
|
||||||
// Since we are mocking the UI a bit for this branch, we will just import directly
|
const sep = isWindows ? '\\' : '/';
|
||||||
|
const parts = filePath.split(sep);
|
||||||
|
const fileName = parts.pop();
|
||||||
|
const fileDir = parts.join(sep);
|
||||||
|
const fileStem = fileName.replace(/\.[^/.]+$/, '');
|
||||||
|
|
||||||
|
const targetCwd = this.appState?.cwd || fileDir;
|
||||||
|
const targetBasename = this.appState?.basename || fileStem;
|
||||||
|
|
||||||
|
const targetBasenameInput = document.getElementById('targetBasename');
|
||||||
|
if (targetBasenameInput && !targetBasenameInput.value.trim()) {
|
||||||
|
targetBasenameInput.value = targetBasename;
|
||||||
|
}
|
||||||
|
const selectedPathDisplay = document.getElementById('selectedPathDisplay');
|
||||||
|
if (selectedPathDisplay && (!selectedPathDisplay.textContent || selectedPathDisplay.textContent.includes('No directory') || !this.appState?.cwd)) {
|
||||||
|
selectedPathDisplay.textContent = `Directory: ${targetCwd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.appState?.setTarget) {
|
||||||
|
await this.appState.setTarget(targetBasename, targetCwd);
|
||||||
|
}
|
||||||
|
|
||||||
const summary = await invoke('import_measurement_dataset', {
|
const summary = await invoke('import_measurement_dataset', {
|
||||||
filePath,
|
filePath,
|
||||||
targetCwd: this.appState.cwd,
|
targetCwd,
|
||||||
targetBasename: this.appState.basename,
|
targetBasename,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.appState.showNotice(`Successfully imported dataset (${summary.patch_count} patches)`, 'success');
|
this.appState?.showNotice?.(`Successfully imported dataset (${summary.patch_count} patches)`, 'success');
|
||||||
|
|
||||||
// Update state to jump to stage 4
|
// Update state to jump to stage 4
|
||||||
await this.appState.updateGating();
|
if (this.appState?.updateGating) {
|
||||||
this.appState.currentStage = 4;
|
await this.appState.updateGating();
|
||||||
this.appState.applyStageDOM(4);
|
}
|
||||||
|
if (this.appState) {
|
||||||
|
this.appState.currentStage = 4;
|
||||||
|
this.appState.applyStageDOM?.(4);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.appState.showNotice(`Failed to import dataset: ${e}`, 'error');
|
this.appState?.showNotice?.(`Failed to import dataset: ${e}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -54,8 +54,8 @@ export function initColprof() {
|
|||||||
if (btnBrowseCustomSp && colprofCustomSpPath) {
|
if (btnBrowseCustomSp && colprofCustomSpPath) {
|
||||||
btnBrowseCustomSp.addEventListener("click", async () => {
|
btnBrowseCustomSp.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const selected = await window.__TAURI__.dialog.open({
|
const selected = await invoke("select_spectrum_file", {
|
||||||
filters: [{ name: 'Spectrum', extensions: ['sp'] }]
|
defaultDir: chartreadCwd || wizardState.cwd || null,
|
||||||
});
|
});
|
||||||
if (selected) {
|
if (selected) {
|
||||||
colprofCustomSpPath.value = selected;
|
colprofCustomSpPath.value = selected;
|
||||||
|
|||||||
+253
-23
@@ -1,22 +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 } = window.__TAURI__.core;
|
const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke ? window.__TAURI__.core.invoke : null;
|
||||||
const { listen } = window.__TAURI__.event;
|
|
||||||
|
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 = "";
|
||||||
|
|
||||||
@@ -26,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();
|
||||||
@@ -75,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();
|
||||||
@@ -98,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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,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);
|
||||||
@@ -561,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 ],
|
||||||
|
|||||||
+154
-4
@@ -1,20 +1,79 @@
|
|||||||
// Manual / browser-console tests for gamut_viewer.js and profcheck.js parsing.
|
// Unit & console tests for gamut_viewer.js parsing.
|
||||||
// Run in a browser/devtools console after the app has loaded:
|
// Can be run in browser devtools console:
|
||||||
// import('./gamut_viewer.test.js').then(m => m.runAll())
|
// import('./gamut_viewer.test.js').then(m => m.runAll())
|
||||||
|
// Or in Node:
|
||||||
|
// node src/js/gamut_viewer.test.js
|
||||||
|
|
||||||
import { parseGamutFile } from './gamut_viewer.js';
|
// Node environment polyfill for browser globals
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
globalThis.window = {
|
||||||
|
__TAURI__: {
|
||||||
|
core: { invoke: () => Promise.resolve() },
|
||||||
|
event: { listen: () => Promise.resolve(() => {}) }
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
dispatchEvent: () => {},
|
||||||
|
devicePixelRatio: 1
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: (tag) => {
|
||||||
|
if (tag === 'canvas') {
|
||||||
|
return { getContext: () => null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
style: {},
|
||||||
|
appendChild() {},
|
||||||
|
addEventListener() {},
|
||||||
|
textContent: '',
|
||||||
|
className: '',
|
||||||
|
setAttribute() {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
parseGamutFile,
|
||||||
|
webglAvailable,
|
||||||
|
isLikelyConstrainedGpu,
|
||||||
|
ensureGamutViewer,
|
||||||
|
isGamutViewerReady,
|
||||||
|
WEBGL_UNAVAILABLE_MESSAGE,
|
||||||
|
setGpuHints,
|
||||||
|
} = await import('./gamut_viewer.js');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
export function runAll() {
|
export function runAll() {
|
||||||
console.group('gamut/profcheck parser tests');
|
console.group('Gamut Viewer Parser Tests');
|
||||||
|
passed = 0;
|
||||||
|
total = 0;
|
||||||
testParseGamutBasic();
|
testParseGamutBasic();
|
||||||
testParseGamutDualTable();
|
testParseGamutDualTable();
|
||||||
testParseGamutWithComments();
|
testParseGamutWithComments();
|
||||||
|
testWebglAvailableFalseWithoutContext();
|
||||||
|
testWebglAvailableTrueWithWebgl();
|
||||||
|
testConstrainedGpuPrefersBackendArch();
|
||||||
|
testConstrainedGpuIgnoresAppleSiliconUa();
|
||||||
|
testConstrainedGpuIntelMac();
|
||||||
|
testEnsureGamutViewerNoopsWithoutDom();
|
||||||
|
testFallbackMessage();
|
||||||
|
console.log(`\nResults: ${passed} / ${total} tests passed.`);
|
||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
|
|
||||||
|
if (passed !== total) {
|
||||||
|
throw new Error(`Gamut viewer parser tests failed: ${total - passed} failure(s)`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertEqual(actual, expected, message) {
|
function assertEqual(actual, expected, message) {
|
||||||
|
total++;
|
||||||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
passed++;
|
||||||
console.log('PASS:', message);
|
console.log('PASS:', message);
|
||||||
} else {
|
} else {
|
||||||
console.error('FAIL:', message, 'expected', expected, 'got', actual);
|
console.error('FAIL:', message, 'expected', expected, 'got', actual);
|
||||||
@@ -22,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
|
||||||
@@ -78,3 +148,83 @@ END_DATA`;
|
|||||||
assertEqual(vertices.length, 4, 'commented gamut vertex count');
|
assertEqual(vertices.length, 4, 'commented gamut vertex count');
|
||||||
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
|
||||||
|
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('gamut_viewer.test.js')) {
|
||||||
|
runAll();
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
@@ -78,6 +80,12 @@ 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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user