26 Commits
Author SHA1 Message Date
gronod 6976ced610 feat(ci): build styled macOS DMG headlessly via dmgbuild (#189)
Build Windows Packages / Build Windows (push) Canceled after 10s
Build Linux Packages / Build Linux (push) Canceled after 14s
Build macOS Packages / Build macOS (Intel) (push) Successful in 10m34s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 10m43s
- Add scripts/build-dmg.py using dmgbuild to generate DMGs with custom background art and icon locations without Finder AppleScript.
- Update .gitea/workflows/build-macos.yml and .github/workflows/build-macos.yml to package the .app bundle using dmgbuild in a headless virtual environment.
- Resolves missing DMG background in CI while preventing AppleScript GUI automation timeouts.
2026-09-06 22:04:36 +01:00
gronod f47e65ee1f Merge pull request 'Main' (#221) from main into development
Reviewed-on: #221
2026-09-06 21:11:22 +01:00
gronod 193e492c10 Update .gitea/workflows/build-macos.yml
Build Windows Packages / Build Windows (push) Successful in 7m35s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 9m25s
Build Linux Packages / Build Linux (push) Successful in 10m52s
Build macOS Packages / Build macOS (Intel) (push) Successful in 7m14s
Re-enabled arm64
2026-09-06 20:16:52 +01:00
gronod 899718d896 Merge pull request 'Merge development into main' (#220) from development into main
Build Windows Packages / Build Windows (push) Successful in 7m44s
Build macOS Packages / Build macOS (Intel) (push) Successful in 8m18s
Build Linux Packages / Build Linux (push) Successful in 10m18s
2026-09-06 19:26:32 +01:00
gronod 7a9637b5d0 Merge pull request 'ci(macos): allow headless DMG bundling on Gitea runners' (#219) from ci/fix-macos-dmg-bundling into development 2026-09-06 19:26:24 +01:00
gronod 1af844c7e2 ci(macos): allow headless DMG bundling on Gitea runners 2026-09-06 19:24:30 +01:00
gronod 56f275a023 Merge branch 'ci/215-frontend-tests' into development (#215) 2026-09-06 16:40:43 +01:00
gronod 54792a8690 Merge branch 'fix/213-atomic-history-writes' into development (#213) 2026-09-06 16:40:41 +01:00
gronod 906b4f9104 ci: add npm test script and integrate frontend tests into CI workflows (#215) 2026-09-06 16:40:37 +01:00
gronod 5f5bf76594 fix(quality_store): atomic file writes for verification_history.json (#213) 2026-09-06 16:39:53 +01:00
gronod 9c7d563dbf chore: bump version to v0.8.4 2026-09-06 16:37:59 +01:00
gronod 9102c95468 ci(macos): remove redundant cargo test step from release workflow
Build Windows Packages / Build Windows (push) Successful in 7m13s
Build Linux Packages / Build Linux (push) Successful in 10m27s
Build macOS Packages / Build macOS (Intel) (push) Failing after 10m35s
Merge pull request 'ci(macos): remove redundant cargo test step from release workflow' (#218) from development into main
2026-09-06 16:23:09 +01:00
gronod 743ad86f58 ci(macos): remove redundant cargo test step from release workflow 2026-09-06 16:22:57 +01:00
gronod 11e796fd17 Development
Build Windows Packages / Build Windows (push) Successful in 7m39s
Build Linux Packages / Build Linux (push) Successful in 9m54s
Build macOS Packages / Build macOS (Intel) (push) Failing after 14m45s
Merge pull request 'Development' (#217) from development into main
2026-09-06 16:08:49 +01:00
gronod ebbdbd4f55 chore: disable Apple Silicon and Universal build targets in macOS workflow 2026-09-06 16:06:24 +01:00
gronod ef0d65453e chore: configure git permissions in CI workflows and restrict crate source inclusion and build re-run triggers 2026-09-06 16:04:34 +01:00
gronod 9f2ff015d7 fix(cgats): support dataset file selection, sync wizard target state, and bump to v0.8.3 (#211) 2026-09-06 14:57:11 +01:00
gronod a8c4025c01 Merge pull request 'Development' (#216) from development into main
Build Windows Packages / Build Windows (push) Successful in 7m38s
Build Linux Packages / Build Linux (push) Successful in 8m59s
Build macOS Packages / Build macOS (Apple Silicon) (push) Failing after 1m13s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Failing after 1m28s
Build macOS Packages / Build macOS (Intel) (push) Failing after 51s
Reviewed-on: #216
2026-09-06 13:56:17 +01:00
gronod 5a1ad654b8 fix(colprof): add select_spectrum_file command for custom sp browse (#210) 2026-09-06 12:51:52 +01:00
gronod ec7eb2fcb4 test(gamut): fix gamut_viewer.test.js node execution and guard window globals (#212) 2026-09-06 12:48:43 +01:00
gronod c5ff3d1a49 docs: resolve documentation and license inaccuracies (#214) 2026-09-06 12:41:32 +01:00
gronod 8008d9bda4 ci(macos): drop redundant .zip archive from macOS release assets (#209) 2026-09-06 12:23:14 +01:00
gronod ebcb3cdc2a ci(macos): drop redundant .zip archive from macOS release assets 2026-09-06 12:22:37 +01:00
gronod 2641edecc1 Merge pull request 'ci(macos): enable DMG AppleScript background on Gitea runners (#189)' (#208) from fix/gitea-dmg-background into development
Build Windows Packages / Build Windows (push) Successful in 7m37s
Build Linux Packages / Build Linux (push) Successful in 8m56s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 18m6s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Failing after 49s
Build macOS Packages / Build macOS (Intel) (push) Failing after 1m3s
2026-09-06 09:40:37 +01:00
gronod 4621d734ff ci(macos): enable DMG AppleScript background on Gitea runners (#189)
Port the GitHub runner fix (d51bae7) to .gitea/workflows/build-macos.yml:
warm Finder/System Events before tauri build, and set
TAURI_BUNDLER_DMG_IGNORE_CI=true so create-dmg does not skip
AppleScript under CI=true. Tighten DMG/.app discovery to the
release bundle paths.

Closes #189 for the Gitea workflow.
2026-09-06 09:40:18 +01:00
gronod 0b419702a1 docs: align README, ROADMAP, and AGENTS for v0.8.2 and Milestones 12/13/14
- Update README.md version to v0.8.2 and platforms to Windows | macOS | Linux
- Document XY automated scanning tables (#93) and i1Pro 2 LED feedback (#204)
- Document longitudinal printer drift tracking (#95) and CGATS interop (#94)
- Update Mermaid architecture diagram and macOS build prerequisites
- Move Milestones 12 and 13 and Release v0.8.1 to Completed Milestones in ROADMAP.md
- Add post-v0.8.2 future architecture considerations in ROADMAP.md
- Document i1Pro 2 LED switch, frontend test suites, and CI cross-compilation in AGENTS.md
2026-09-06 09:17:33 +01:00
22 changed files with 565 additions and 134 deletions
+3
View File
@@ -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
+37 -31
View File
@@ -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
@@ -92,19 +94,23 @@ jobs:
run: | run: |
mkdir -p release-assets mkdir -p release-assets
# Stage DMG package APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1)
DMG_FILE=$(find src-tauri/target -type f -name "*.dmg" | head -n 1) if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then echo "Error: Could not locate built .app bundle in src-tauri/target"
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg" exit 1
fi fi
echo "Found .app bundle: ${APP_PATH}"
# Setup isolated Python virtual environment for dmgbuild
python3 -m venv "${{ runner.temp }}/dmgbuild-venv"
"${{ runner.temp }}/dmgbuild-venv/bin/pip" install --quiet dmgbuild
# Build styled DMG with background art and custom icon locations
"${{ runner.temp }}/dmgbuild-venv/bin/python" scripts/build-dmg.py \
--app "${APP_PATH}" \
--output "release-assets/${PREFIX}.dmg" \
--volname "ICCery"
# 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
+3
View File
@@ -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:
+26 -25
View File
@@ -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:
@@ -74,22 +84,9 @@ jobs:
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
- name: Warm up macOS GUI for DMG layout
if: runner.os == 'macOS'
run: |
# Tauri's DMG bundler runs an AppleScript that asks Finder to set the
# background picture and icon positions. On CI runners, Finder or the
# Aqua session may be idle, which can cause AppleEvent timeouts. Try to
# start/awaken Finder and System Events before the real build.
open -g -a Finder || true
open -g -a "System Events" || true
# Send a harmless probe to force Finder to initialise a scripting session.
osascript -e 'tell application "Finder" to get name' || true
sleep 10
- name: Build Tauri App - name: Build Tauri App
env: env:
TAURI_BUNDLER_DMG_IGNORE_CI: "true" CI: "true"
run: npm run tauri build -- --target ${{ matrix.platform.target }} run: npm run tauri build -- --target ${{ matrix.platform.target }}
- name: Prepare Release Assets - name: Prepare Release Assets
@@ -97,19 +94,23 @@ jobs:
run: | run: |
mkdir -p release-assets mkdir -p release-assets
# Stage DMG package APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1)
DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1) if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then echo "Error: Could not locate built .app bundle in src-tauri/target"
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg" exit 1
fi fi
echo "Found .app bundle: ${APP_PATH}"
# Setup isolated Python virtual environment for dmgbuild
python3 -m venv "${{ runner.temp }}/dmgbuild-venv"
"${{ runner.temp }}/dmgbuild-venv/bin/pip" install --quiet dmgbuild
# Build styled DMG with background art and custom icon locations
"${{ runner.temp }}/dmgbuild-venv/bin/python" scripts/build-dmg.py \
--app "${APP_PATH}" \
--output "release-assets/${PREFIX}.dmg" \
--volname "ICCery"
# 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
+30 -6
View File
@@ -53,11 +53,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 +115,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 +124,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 +151,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)`).
+34 -14
View File
@@ -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.
[![Release](https://img.shields.io/badge/version-v0.7.2-blue.svg)](https://git.i3omb.com/gronod/ICCery) [![Release](https://img.shields.io/badge/version-v0.8.4-blue.svg)](https://git.i3omb.com/gronod/ICCery)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery)
[![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app) [![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app)
[![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md) [![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md)
@@ -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 (0255 scaling, field aliasing, metadata synthesis) and direct-jump workflows to Stage 4 (Profile Calculation) and Stage 5 (Verification).
- 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation. - 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation.
- 🐧 **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 11.0 (Big Sur) 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,7 +118,10 @@ 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
``` ```
+32 -14
View File
@@ -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,33 @@ 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.
---
## 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
View File
@@ -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
View File
@@ -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"
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Headless macOS DMG Builder for ICCery.
Uses `dmgbuild` to package the .app bundle into a styled DMG with custom
background art, window bounds, and icon locations without requiring Finder
AppleScript automation or an interactive display session.
"""
import argparse
import os
import sys
def parse_args():
parser = argparse.ArgumentParser(description="Build styled macOS DMG installer")
parser.add_argument(
"--app",
required=True,
help="Path to the .app application bundle",
)
parser.add_argument(
"--output",
required=True,
help="Path to the output .dmg file",
)
parser.add_argument(
"--volname",
default="ICCery",
help="Volume name for the mounted disk image (default: ICCery)",
)
parser.add_argument(
"--background",
default="src-tauri/icons/dmg-background.png",
help="Path to background image (default: src-tauri/icons/dmg-background.png)",
)
parser.add_argument(
"--icon",
default="src-tauri/icons/icon.icns",
help="Path to volume icon .icns (default: src-tauri/icons/icon.icns)",
)
parser.add_argument(
"--window-size",
nargs=2,
type=int,
default=[660, 400],
metavar=("WIDTH", "HEIGHT"),
help="Window width and height in points (default: 660 400)",
)
parser.add_argument(
"--icon-size",
type=int,
default=100,
help="Icon size in points (default: 100)",
)
parser.add_argument(
"--app-pos",
nargs=2,
type=int,
default=[180, 220],
metavar=("X", "Y"),
help="App icon location (default: 180 220)",
)
parser.add_argument(
"--apps-link-pos",
nargs=2,
type=int,
default=[480, 220],
metavar=("X", "Y"),
help="Applications symlink location (default: 480 220)",
)
return parser.parse_args()
def main():
args = parse_args()
app_path = os.path.abspath(args.app)
output_path = os.path.abspath(args.output)
bg_path = os.path.abspath(args.background) if args.background else None
icon_path = os.path.abspath(args.icon) if args.icon else None
if not os.path.isdir(app_path):
print(f"Error: Application bundle does not exist at '{app_path}'", file=sys.stderr)
sys.exit(1)
if bg_path and not os.path.isfile(bg_path):
print(f"Error: Background image not found at '{bg_path}'", file=sys.stderr)
sys.exit(1)
try:
import dmgbuild
except ImportError:
print("Error: 'dmgbuild' is required. Install via: pip install dmgbuild", file=sys.stderr)
sys.exit(1)
app_name = os.path.basename(app_path)
output_dir = os.path.dirname(output_path)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
if os.path.exists(output_path):
os.remove(output_path)
win_w, win_h = args.window_size
app_x, app_y = args.app_pos
apps_x, apps_y = args.apps_link_pos
settings = {
"files": [app_path],
"symlinks": {"Applications": "/Applications"},
"background": bg_path,
"icon": icon_path if icon_path and os.path.isfile(icon_path) else None,
"icon_size": args.icon_size,
"window_rect": ((100, 100), (win_w, win_h)),
"icon_locations": {
app_name: (app_x, app_y),
"Applications": (apps_x, apps_y),
},
"format": "UDZO",
}
print(f"Building DMG for {app_name}...")
print(f" Volume Name: {args.volname}")
print(f" App Bundle: {app_path}")
print(f" Background: {bg_path}")
print(f" Window Size: {win_w}x{win_h}")
print(f" App Position: ({app_x}, {app_y})")
print(f" Apps Position: ({apps_x}, {apps_y})")
print(f" Output Path: {output_path}")
try:
dmgbuild.build_dmg(output_path, args.volname, settings=settings)
except Exception as e:
print(f"Error: dmgbuild failed: {e}", file=sys.stderr)
sys.exit(1)
if not os.path.isfile(output_path):
print(f"Error: Expected output DMG file '{output_path}' was not created", file=sys.stderr)
sys.exit(1)
size_mb = os.path.getsize(output_path) / (1024 * 1024)
print(f"Successfully generated DMG ({size_mb:.2f} MB): {output_path}")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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();
+55
View File
@@ -391,6 +391,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,
+2
View File
@@ -51,6 +51,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,
+70 -4
View File
@@ -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);
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery", "productName": "ICCery",
"version": "0.8.2", "version": "0.8.4",
"identifier": "com.gronod.iccery", "identifier": "com.gronod.iccery",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"
+3 -3
View File
@@ -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 &amp; Rust Dependencies</h5> <h5>Tauri Framework &amp; 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>
+38 -9
View File
@@ -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
if (this.appState?.updateGating) {
await this.appState.updateGating(); await this.appState.updateGating();
}
if (this.appState) {
this.appState.currentStage = 4; this.appState.currentStage = 4;
this.appState.applyStageDOM(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
View File
@@ -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;
+2 -3
View File
@@ -1,8 +1,7 @@
import { computeQuickHull } from "./vendor/quickhull.js"; import { computeQuickHull } from "./vendor/quickhull.js";
import { labToSrgb } from "./color_convert.js"; import { labToSrgb } from "./color_convert.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;
let scene, camera, renderer, labelRenderer, controls; let scene, camera, renderer, labelRenderer, controls;
let currentProfileMesh = null; let currentProfileMesh = null;
+39 -4
View File
@@ -1,20 +1,50 @@
// 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: () => {}
};
globalThis.document = {
getElementById: () => null,
querySelectorAll: () => []
};
}
const { parseGamutFile } = 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();
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);
@@ -78,3 +108,8 @@ 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');
} }
// Auto-run if executed in Node.js
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('gamut_viewer.test.js')) {
runAll();
}