Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95a19132fb | ||
|
|
520d0a2ab5 | ||
|
|
9cc87ca295 | ||
|
|
980b44bccb | ||
|
|
24318bca58 | ||
|
|
a147064722 | ||
|
|
87e8e65771 | ||
|
|
3cf2a0f441 | ||
|
|
fb766c15d6 | ||
|
|
26b2337a16 | ||
|
|
3e7ece746b | ||
|
|
6976ced610 | ||
|
|
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 | ||
|
|
d6373c243b | ||
|
|
dc7c8ada96 | ||
|
|
0741c961e6 | ||
|
|
9769d397b9 |
@@ -64,6 +64,9 @@ jobs:
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend tests
|
||||
run: npm test
|
||||
|
||||
- name: Set Release Environment
|
||||
id: set_env
|
||||
shell: bash
|
||||
|
||||
@@ -9,6 +9,8 @@ jobs:
|
||||
build-macos:
|
||||
name: Build macOS (${{ matrix.platform.name }})
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
env:
|
||||
XDG_CONFIG_HOME: ${{ runner.temp }}/.config
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
@@ -22,16 +24,26 @@ jobs:
|
||||
target: aarch64-apple-darwin
|
||||
binary_dir: macos-aarch64
|
||||
arch: arm64
|
||||
- name: Universal (Intel + Apple Silicon)
|
||||
os: macos
|
||||
target: universal-apple-darwin
|
||||
binary_dir: macos-universal
|
||||
arch: universal
|
||||
# - name: Universal (Intel + Apple Silicon)
|
||||
# os: macos
|
||||
# target: universal-apple-darwin
|
||||
# binary_dir: macos-universal
|
||||
# arch: universal
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -47,6 +59,9 @@ jobs:
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend tests
|
||||
run: npm test
|
||||
|
||||
- name: Fetch ArgyllCMS binaries
|
||||
env:
|
||||
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 }}/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
|
||||
id: set_env
|
||||
shell: bash
|
||||
@@ -85,6 +85,8 @@ jobs:
|
||||
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build Tauri App
|
||||
env:
|
||||
CI: "true"
|
||||
run: npm run tauri build -- --target ${{ matrix.platform.target }}
|
||||
|
||||
- name: Prepare Release Assets
|
||||
@@ -92,19 +94,23 @@ jobs:
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
|
||||
# Stage DMG package
|
||||
DMG_FILE=$(find src-tauri/target -type f -name "*.dmg" | head -n 1)
|
||||
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
|
||||
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
||||
APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1)
|
||||
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
|
||||
echo "Error: Could not locate built .app bundle in src-tauri/target"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found .app bundle: ${APP_PATH}"
|
||||
|
||||
# Setup isolated Python virtual environment for dmgbuild
|
||||
python3 -m venv "${{ runner.temp }}/dmgbuild-venv"
|
||||
"${{ runner.temp }}/dmgbuild-venv/bin/pip" install --quiet dmgbuild
|
||||
|
||||
# Build styled DMG with background art and custom icon locations
|
||||
"${{ runner.temp }}/dmgbuild-venv/bin/python" scripts/build-dmg.py \
|
||||
--app "${APP_PATH}" \
|
||||
--output "release-assets/${PREFIX}.dmg" \
|
||||
--volname "ICCery"
|
||||
|
||||
# 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
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -41,6 +41,9 @@ jobs:
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend tests
|
||||
run: npm test
|
||||
|
||||
- name: Fetch ArgyllCMS binaries
|
||||
shell: powershell
|
||||
env:
|
||||
|
||||
@@ -35,6 +35,16 @@ jobs:
|
||||
- name: Checkout repository
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -74,22 +84,9 @@ jobs:
|
||||
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
|
||||
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Warm up macOS GUI for DMG layout
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
# Tauri's DMG bundler runs an AppleScript that asks Finder to set the
|
||||
# background picture and icon positions. On CI runners, Finder or the
|
||||
# Aqua session may be idle, which can cause AppleEvent timeouts. Try to
|
||||
# start/awaken Finder and System Events before the real build.
|
||||
open -g -a Finder || true
|
||||
open -g -a "System Events" || true
|
||||
# Send a harmless probe to force Finder to initialise a scripting session.
|
||||
osascript -e 'tell application "Finder" to get name' || true
|
||||
sleep 10
|
||||
|
||||
- name: Build Tauri App
|
||||
env:
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||
CI: "true"
|
||||
run: npm run tauri build -- --target ${{ matrix.platform.target }}
|
||||
|
||||
- name: Prepare Release Assets
|
||||
@@ -97,19 +94,23 @@ jobs:
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
|
||||
# Stage DMG package
|
||||
DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1)
|
||||
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
|
||||
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
||||
APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1)
|
||||
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
|
||||
echo "Error: Could not locate built .app bundle in src-tauri/target"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found .app bundle: ${APP_PATH}"
|
||||
|
||||
# Setup isolated Python virtual environment for dmgbuild
|
||||
python3 -m venv "${{ runner.temp }}/dmgbuild-venv"
|
||||
"${{ runner.temp }}/dmgbuild-venv/bin/pip" install --quiet dmgbuild
|
||||
|
||||
# Build styled DMG with background art and custom icon locations
|
||||
"${{ runner.temp }}/dmgbuild-venv/bin/python" scripts/build-dmg.py \
|
||||
--app "${APP_PATH}" \
|
||||
--output "release-assets/${PREFIX}.dmg" \
|
||||
--volname "ICCery"
|
||||
|
||||
# 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
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# ICCery Agent Notes
|
||||
|
||||
## Printer Calibration (`printcal` / `applycal`) (#224)
|
||||
|
||||
- Optional Stage 0 dashboard, opened from **Calibrate Printer**. The 1–5 wizard is unchanged when calibration is skipped.
|
||||
- Calibration charts use a `CAL_` basename so they never collide with the profiling `.ti1`/`.ti2`/`.ti3`.
|
||||
- `printtarg -K file.cal` is applied only to **profiling** layouts, never to the calibration chart itself.
|
||||
- After Stage 4 `colprof`, `applycal` embeds the curves into the ICC/ICM when Apply Calibration is on.
|
||||
- `.cal` overwrite requires an explicit Overwrite / Rename / Cancel choice.
|
||||
- Warn when a loaded `.cal` is older than `calibration_stale_days` (default 30) or the stored printer name differs.
|
||||
- Tests: `src-tauri/src/calibration.rs` (arg builders + `.cal` parser) and `src/js/calibration.test.js`.
|
||||
|
||||
## Stage 5 System Profile Install (#223)
|
||||
|
||||
- `install_profile_to_system` copies the working-directory `.icc`/`.icm` into the OS colour store. It never moves or deletes the project artefact.
|
||||
- Destinations: Windows `%WINDIR%\System32\spool\drivers\color` (`.icm`); macOS `~/Library/ColorSync/Profiles` or `/Library/ColorSync/Profiles`; Linux `~/.local/share/icc` or `/usr/share/color/icc` (colormgr when present).
|
||||
- Collisions require Overwrite / Rename / Cancel. Permission errors must mention elevation.
|
||||
- If Apply Calibration is on, the success toast notes which `.cal` was embedded.
|
||||
- Tests: `src-tauri/src/profile_install.rs` and `src/js/profile_install.test.js`.
|
||||
|
||||
## Stage 5 Verification / Profcheck
|
||||
|
||||
- `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats.
|
||||
@@ -10,6 +28,9 @@
|
||||
## 3D Gamut Viewer
|
||||
|
||||
- The viewer renders the measured/derived `.gam` volume and an optional sRGB reference wireframe in CIELAB.
|
||||
- **Do not** create `THREE.WebGLRenderer` during `DOMContentLoaded`. Call `ensureGamutViewer()` from `state.js` only when Stage 5 becomes visible. Eager WebGL on a hidden canvas respawns WKWebView on macOS Monterey Intel (#225).
|
||||
- Feature-detect WebGL first; missing/lost context must leave a fallback message in `#gamutViewerContainer` and must not take down the app.
|
||||
- Pause the rAF loop when leaving Stage 5 (`pauseGamutViewer`).
|
||||
- Layer controls (profile, sRGB, axes) each have visibility toggles and opacity sliders.
|
||||
- Click **Reset View** or press **R** to return the camera to its default position.
|
||||
- Full JSDoc is provided on the public API in `src/js/gamut_viewer.js`.
|
||||
@@ -53,11 +74,19 @@ 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.
|
||||
- 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 tests**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo test`
|
||||
- **Frontend**: `cd src-tauri && npm run build` (or `npm run dev` for development)
|
||||
- **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 unit tests**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo test`
|
||||
- **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`
|
||||
- Calibration helpers: `node src/js/calibration.test.js`
|
||||
- Profile install helpers: `node src/js/profile_install.test.js`
|
||||
- Browser devtools console: `import('./profcheck.test.js').then(m => m.runAll())`
|
||||
- **Frontend development server**: `npm run tauri dev`
|
||||
- **Production package build**: `npm run tauri build`
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
@@ -102,3 +131,64 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI
|
||||
- Epson color bypass: `EPIJ_CMat=3` (Off / No Color Adjustment)
|
||||
- Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4`
|
||||
- Gutenprint: `StpColorCorrection=Uncorrected`
|
||||
|
||||
## Verification History & Printer Drift Tracking (#95)
|
||||
|
||||
- Historical verification runs are stored in `verification_history.json` in the app data directory.
|
||||
- Record schema (`VerificationRecord` in `src-tauri/src/quality_store.rs`):
|
||||
- `id`: unique record identifier in the format `vr-<epoch_millis>-<seq>`.
|
||||
- `profile_name`: target profile filename.
|
||||
- `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).
|
||||
- `patch_count`: number of test patches evaluated.
|
||||
- `status`: classified status using **ICCery verification bands (issue #95)**:
|
||||
- `< 1.0`: "Excellent" (`badge-excellent`)
|
||||
- `< 2.0`: "Good" (`badge-good`)
|
||||
- `< 3.5`: "Acceptable" (`badge-acceptable`)
|
||||
- `>= 3.5`: "Warning" (`badge-poor`)
|
||||
- `timestamp`: ISO-8601 UTC string.
|
||||
- Max capacity is 1,000 records; oldest records evicted on overflow.
|
||||
- Atomic file writes (`.tmp` write followed by `rename`) prevent data corruption.
|
||||
- Tauri IPC command casing:
|
||||
- Nested struct fields (`VerificationRecord`) serialize with `snake_case`.
|
||||
- Top-level Tauri command arguments use `camelCase` (e.g. `savePath`, `record`, `profileName`).
|
||||
- Drift history UI in Stage 5 features an interactive SVG trend chart with ICCery verification reference bands, consecutive-breach alert card (requires $\ge 2$ consecutive runs $\ge 3.5$ on distinct calendar days or $\ge 1$ hour apart), and RFC-4180 compliant CSV export.
|
||||
|
||||
## Stage 3 XY Automated Scanning Tables (#93)
|
||||
|
||||
- Supports automated XY scanning tables (GretagMacbeth SpectroScan, X-Rite i1iO) in Stage 3 `chartread`.
|
||||
- Hardware detection in `instlist` flags devices matching `/spectro\s?scan|i1io/i` with `data-xy="1"` and `· XY Table` label suffix.
|
||||
- Runtime auto-detection activates when any XY-specific prompt is classified from `chartread` stdout (supporting i1iO units reporting as i1Pro).
|
||||
- XY State Machine additions:
|
||||
- `STATE.TABLE_PLACE_SHEET`: Prompts user to place sheet on table; button displays "✓ Sheet Placed — Continue".
|
||||
- `STATE.TABLE_ALIGN`: Prompts user to align measurement head with target fiducial patches (`locate patch <ID> with sight`); button displays "✓ Aligned — Continue".
|
||||
- Two-line prompt handling & sticky state:
|
||||
- Argyll `chartread.c` splits XY prompts across two lines (prompt line followed by `hit return to continue...`).
|
||||
- While in `TABLE_PLACE_SHEET` or `TABLE_ALIGN`, subsequent continuation lines remain sticky in that table state, preserving the custom button label and preventing regression to generic `PROMPT_CONTINUE`.
|
||||
- Button behaviors:
|
||||
- `btnAccept`: in `TABLE_*` states, sends `\n` without forcing `STATE.READING`; the state machine advances naturally when Argyll emits the next prompt.
|
||||
- `btnCancel`: in `TABLE_*` states or when an XY table is active, sends `q\n` first to allow the hardware to park its measurement head gracefully before terminating the process.
|
||||
- Multi-sheet and final sheet notice:
|
||||
- Multi-sheet targets are measured within a single `chartread` process lifecycle; sheet changes transition through `TABLE_PLACE_SHEET` without opening the Stage 3 multi-pass averaging panel.
|
||||
- `Please remove last sheet from table` is emitted by Argyll right before writing `.ti3` and exiting; it is classified as an info-only notice (`isRemoveSheetNotice: true`) and does not prompt for user input.
|
||||
- Testing:
|
||||
- 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.
|
||||
|
||||
## 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,28 +2,43 @@
|
||||
|
||||
> 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)
|
||||
[](LICENCE.md)
|
||||
|
||||
**ICCery** is a native GUI frontend designed to make creating custom ICC/ICM printer profiles seamless, visual, and reliable. It wraps the powerful color management capabilities of [ArgyllCMS](https://www.argyllcms.com/) within an intuitive, artefact-gated 5-stage wizard.
|
||||
**ICCery** is a native GUI frontend designed to make creating custom ICC/ICM printer profiles seamless, visual, and reliable. It wraps the powerful color management capabilities of [ArgyllCMS](https://www.argyllcms.com/) within an intuitive, artefact-gated 5-stage wizard, with an optional printer calibration (linearization) workflow.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🪄 **Linear 5-Stage Wizard Workflow**:
|
||||
1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, and neutral/grey axis boosting.
|
||||
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`).
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
0. **Optional — Printer Calibration (`printcal` / `applycal`)** (#224): Per-channel linearization and ink-limit discovery before a full profile. Generate a short `CAL_` chart, print and measure it with the existing Stage 2/3 engines, compute `.cal` curves, inspect channel-response plots, and toggle **Apply Calibration** so subsequent `printtarg` (`-K`) and `colprof` (`applycal`) runs consume the curves. Skip entirely for simple RGB photo printers.
|
||||
1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, neutral/grey axis boosting, and 11 advanced generation parameters with contextual guidance tooltips. Supports direct-resume from existing `.ti2` target files to jump straight to measurement. CMYK / RIP workflows show a reminder when no calibration is applied.
|
||||
2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for handheld spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint) and automated XY tables (i1iO, SpectroScan). View high-resolution downscaled TIFF previews and print directly using native OS unmanaged pathways:
|
||||
- **macOS**: Native `NSPrintPanel` driver preferences with automatic ColorSync suppression (`AP_ColorMatchingMode=AP_ApplicationColorMatching`), CUPS media type selection, and driver-specific color adjustment bypass detection (Canon `CNIJIntent2`, Epson `ColorCorrection`, Gutenprint).
|
||||
- **Windows**: GDI uncorrected raw printing and DEVMODE preferences.
|
||||
- **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.
|
||||
- **Install Profile to System (#223)**: After a successful verification, copy the ICC/ICM into the OS colour-management store (Windows ICM Color folder, macOS ColorSync Profiles, Linux colord / `~/.local/share/icc`) without moving the working-directory artefact. Collisions prompt Overwrite / Rename / Cancel.
|
||||
- 📊 **CGATS Dataset Interoperability (#94)**: Native parser for external CGATS and Argyll `.ti3` datasets with canonical normalization (0–255 scaling, field aliasing, metadata synthesis) and direct-jump workflows to Stage 4 (Profile Calculation) and Stage 5 (Verification).
|
||||
- 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation.
|
||||
- 🐧 **glibc Compatibility**: Pre-built Linux packages compiled with Ubuntu 22.04 LTS compatibility for Debian/Ubuntu environments.
|
||||
- 🛡️ **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.
|
||||
- 🌐 **Platform-Aware**: Automatic handling of platform profile conventions (`.icm` on Windows, `.icc` on Linux/macOS) and native OS printer subsystems.
|
||||
- 🎛️ **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.
|
||||
- 🍎 **macOS Universal Binary**: Native Apple Silicon (`arm64`) and Intel (`x86_64`) support with universal binary bundling and fallback resolution.
|
||||
- 🐧 **Linux glibc Compatibility**: Pre-built Linux packages compiled with Ubuntu 22.04 LTS compatibility for Debian/Ubuntu environments.
|
||||
- 🛡️ **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.
|
||||
- 🌐 **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.
|
||||
|
||||
---
|
||||
@@ -38,19 +53,25 @@ flowchart TD
|
||||
UI[Wizard UI & Swatch Grid]
|
||||
ThreeJS[3D CIELAB Gamut Viewer]
|
||||
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]
|
||||
CalStore[Calibration .cal library]
|
||||
|
||||
UI <--> State
|
||||
State <--> ProcMgr
|
||||
State <--> QualityStore
|
||||
State <--> CalStore
|
||||
ProcMgr --> ThreeJS
|
||||
UI --> PrintEngine
|
||||
QualityStore --> UI
|
||||
end
|
||||
|
||||
subgraph Argyll ["ArgyllCMS Subprocesses (AGPLv3)"]
|
||||
BIN_TAR[targen]
|
||||
BIN_PRT[printtarg]
|
||||
BIN_CHR[chartread]
|
||||
BIN_CAL[printcal / applycal]
|
||||
BIN_COL[colprof]
|
||||
BIN_CHK[profcheck]
|
||||
BIN_GAM[iccgamut]
|
||||
@@ -59,6 +80,7 @@ flowchart TD
|
||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_TAR
|
||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_PRT
|
||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHR
|
||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CAL
|
||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_COL
|
||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHK
|
||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_GAM
|
||||
@@ -72,6 +94,7 @@ flowchart TD
|
||||
- [Node.js](https://nodejs.org/) (v18 or newer)
|
||||
- [Rust](https://www.rust-lang.org/) (1.78+ stable)
|
||||
- Operating system dependencies:
|
||||
- **macOS**: macOS 12.0 (Monterey) or newer, Xcode Command Line Tools (`xcode-select --install`).
|
||||
- **Windows**: Microsoft Visual Studio C++ Build Tools & WebView2 runtime.
|
||||
- **Linux (Debian/Ubuntu)**: `libwebkit2gtk-4.1-dev`, `build-essential`, `curl`, `wget`, `file`, `libxdo-dev`, `libssl-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libcups2-dev`.
|
||||
|
||||
@@ -91,7 +114,7 @@ npm run fetch-argyll
|
||||
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.
|
||||
> - 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.
|
||||
@@ -101,12 +124,49 @@ npm run tauri dev
|
||||
# Download sidecars (if not already fetched)
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform support
|
||||
|
||||
### macOS
|
||||
|
||||
The packaged app declares `LSMinimumSystemVersion = 12.0`. Installers refuse Catalina and Big Sur rather than launching into a WKWebView crash loop.
|
||||
|
||||
| macOS | Status |
|
||||
|---|---|
|
||||
| 13+ (Ventura and newer), Apple Silicon | Supported |
|
||||
| 13+, Intel | Supported |
|
||||
| 12.7.x Monterey, Apple Silicon | Supported, WebGL best-effort |
|
||||
| 12.0–12.6 Monterey, Intel | Best-effort; WebGL is deferred until Stage 5; known WKWebView GPU process crashes |
|
||||
| 11 Big Sur | Not supported (installer refuses) |
|
||||
| 10.15 Catalina | Not supported |
|
||||
|
||||
The 3D gamut viewer (Stage 5) creates a WebGL context only when that stage is shown. Stages 1–4 remain usable if WebGL is missing or the GPU process is lost.
|
||||
|
||||
### macOS troubleshooting
|
||||
|
||||
If the window flashes white and disappears, this is almost always the WKWebView **Web Content** or **GPU** helper dying — Apple Crash Reporter will not attach to `ICCery.app`.
|
||||
|
||||
- Launch from Terminal to see `web content process terminated`:
|
||||
```text
|
||||
/Applications/ICCery.app/Contents/MacOS/ICCery
|
||||
```
|
||||
- Check `~/Library/Logs/DiagnosticReports` for `com.apple.WebKit.WebContent` or `com.apple.WebKit.GPU`.
|
||||
- ICCery log file (rotated, last 5 segments kept):
|
||||
```text
|
||||
~/Library/Logs/com.gronod.iccery/iccery.log
|
||||
```
|
||||
- Custom ColorSync display profiles can crash toolkit UIs on Monterey. Testing with the default display profile (or Safe Mode) is a valid support question.
|
||||
|
||||
---
|
||||
|
||||
## Licence
|
||||
|
||||
The ICCery GUI application is proprietary software licensed under the terms of the [EULA](LICENCE.md). ArgyllCMS binaries and source code are licensed under the GNU Affero General Public License (AGPLv3).
|
||||
|
||||
+38
-15
@@ -10,10 +10,11 @@ This document outlines the architectural roadmap, completed milestones, and upco
|
||||
## 1. Architecture Summary
|
||||
|
||||
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.
|
||||
- **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons.
|
||||
- **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`).
|
||||
- **Current Version**: `v0.8.5` (Production release).
|
||||
|
||||
---
|
||||
|
||||
@@ -90,17 +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] **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`.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- [ ] **SpectroScan & Automated Table Support (#93)**: Support XY automated scanning tables (i1iO / SpectroScan) in `chartread` (deferred).
|
||||
|
||||
### Milestone 13 — UI/UX & Workflow Polish
|
||||
|
||||
### Milestone 13 — UI/UX & Workflow Polish (`v0.7.0` – `v0.7.4`, `v0.8.0`)
|
||||
- [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] **Configurable CIEDE2000 Thresholds (#184)**: User-configurable good/warning ΔE₀₀ upper bounds in Settings, persisted across sessions and applied to the Stage 3 swatch grid.
|
||||
@@ -108,6 +99,38 @@ 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] **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 (Deferred)
|
||||
- [ ] **Batch Verification & Drift Tracking (#95)**: Track printer drift over time by comparing periodic verification measurements against a baseline profile.
|
||||
- [ ] **Multi-Language Localization (#96)**: Full UI internationalization (English, German, French, Japanese).
|
||||
### Hardware Status Feedback Release (`v0.8.1`)
|
||||
- [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] **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).
|
||||
|
||||
### 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.
|
||||
|
||||
### Printer Calibration Release (`v0.8.5`)
|
||||
- [x] **Printer Calibration Curves (#224)**: Optional Stage 0 dashboard for `printcal` linearization and ink limits. `CAL_` artefacts, Apply Calibration toggle feeding `printtarg -K` and `applycal`, channel-response plots, stale-cal warnings, and project/library persistence.
|
||||
- [x] **System-Wide Profile Installation (#223)**: Stage 5 “Install Profile to System” copies the verified ICC/ICM into the platform colour store (user or system), with overwrite/rename/cancel, elevation guidance, and a note when printcal curves were applied.
|
||||
|
||||
---
|
||||
|
||||
## 3. Future Roadmap
|
||||
|
||||
### 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
|
||||
THE SOFTWARE.
|
||||
|
||||
## Delaunator
|
||||
Copyright (c) 2017, Mapbox
|
||||
## quickhull3d
|
||||
MIT License
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
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.
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
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.
|
||||
|
||||
## Tauri Framework & Rust Dependencies
|
||||
Copyright (c) 2019-present, Tauri Programme within The Commons Conservancy.
|
||||
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "iccery",
|
||||
"private": true,
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js && node src/js/calibration.test.js && node src/js/profile_install.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
|
||||
@@ -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()
|
||||
Generated
+1
-1
@@ -1423,7 +1423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iccery"
|
||||
version = "0.8.1"
|
||||
version = "0.8.5"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
|
||||
+13
-2
@@ -1,16 +1,27 @@
|
||||
[package]
|
||||
name = "iccery"
|
||||
version = "0.8.1"
|
||||
version = "0.8.5"
|
||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||
authors = ["Gordon"]
|
||||
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
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
# This is only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "iccery_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
|
||||
@@ -2,6 +2,51 @@
|
||||
# Mock script for chartread -u
|
||||
# This script simulates the behaviour of chartread for testing purposes.
|
||||
|
||||
# Check for --xy argument or MOCK_XY_TABLE environment variable
|
||||
IS_XY=0
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--xy" ]; then
|
||||
IS_XY=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
read -r _calib
|
||||
echo "Calibration successful."
|
||||
|
||||
echo "Please place sheet 1 of 1 on the table"
|
||||
echo "hit return to continue, Esc or 'q' to give up"
|
||||
read -r _sheet1
|
||||
|
||||
echo "locate patch A1 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read -r _fid1
|
||||
|
||||
echo "locate patch B24 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read -r _fid2
|
||||
|
||||
echo "Reading sheet 1..."
|
||||
sleep 0.5
|
||||
|
||||
# Emit mock JSON for strip A
|
||||
cat << 'EOF'
|
||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||
EOF
|
||||
|
||||
# Emit mock JSON for strip B
|
||||
cat << 'EOF'
|
||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||
EOF
|
||||
|
||||
echo "Sheet 1 of 1 read OK"
|
||||
echo "Please remove last sheet from table"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Handheld / strip reader simulation
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
|
||||
# We don't really wait for input, just wait 1 second
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Mock script for profcheck
|
||||
# Simulates profcheck verification output
|
||||
# Simulates real ArgyllCMS profcheck -v -k -s -u output
|
||||
|
||||
echo "profcheck: Checking profile accuracy..."
|
||||
echo "No of test patches = 52"
|
||||
sleep 1
|
||||
cat << 'EOF'
|
||||
{"event": "profcheck_complete", "avg_de": 0.85, "max_de": 2.41, "rms_de": 1.02}
|
||||
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||
EOF
|
||||
echo "Summary:"
|
||||
echo " avg. dE = 0.85"
|
||||
echo " max. dE = 2.41"
|
||||
echo " rms. dE = 1.02"
|
||||
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
|
||||
exit 0
|
||||
|
||||
@@ -10,6 +10,12 @@ fn main() {
|
||||
|
||||
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
|
||||
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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+268
-1
@@ -105,20 +105,101 @@ pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result<Strin
|
||||
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)]
|
||||
pub struct AppInfo {
|
||||
pub version: String,
|
||||
pub build_date: String,
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub macos_major: Option<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]
|
||||
pub fn get_app_info() -> AppInfo {
|
||||
let os = collect_os_info();
|
||||
AppInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
build_date: env!("BUILD_DATE").to_string(),
|
||||
os: os.os,
|
||||
arch: os.arch,
|
||||
macos_major: os.macos_major,
|
||||
macos_minor: os.macos_minor,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn show_main_window(app: AppHandle) -> Result<(), String> {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
crate::macos_webview::paint_dark_webview(&win);
|
||||
win.show().map_err(|e| e.to_string())?;
|
||||
let _ = win.set_focus();
|
||||
log::info!("Main window shown after frontend ready");
|
||||
} else {
|
||||
log::warn!("show_main_window: window 'main' not found");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resolve_safe_cwd(app: &AppHandle, cwd_input: &str) -> Result<String, String> {
|
||||
if !cwd_input.trim().is_empty() {
|
||||
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))
|
||||
}
|
||||
|
||||
#[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]
|
||||
pub async fn select_target_file(
|
||||
app: AppHandle,
|
||||
@@ -425,6 +561,34 @@ pub async fn select_target_file(
|
||||
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_csv_save_path(
|
||||
app: AppHandle,
|
||||
default_name: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let mut builder = app.dialog().file().add_filter("CSV", &["csv"]);
|
||||
if let Some(ref name) = default_name {
|
||||
if !name.trim().is_empty() {
|
||||
let filename = if name.to_lowercase().ends_with(".csv") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}.csv", name)
|
||||
};
|
||||
builder = builder.set_file_name(filename);
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
builder.save_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_directory(
|
||||
app: AppHandle,
|
||||
@@ -613,6 +777,12 @@ pub struct PrinttargConfig {
|
||||
pub no_randomize: bool, // If true, pass -r (raster layout / no randomization)
|
||||
pub basename: String, // Must match the .ti1 basename from Stage 1
|
||||
pub cwd: String, // Working directory where the .ti1 file resides
|
||||
/// Optional Argyll `.cal` applied via printtarg `-K` (or `-I` when embed-only).
|
||||
#[serde(default)]
|
||||
pub calibration_file: Option<String>,
|
||||
/// When true, embed the calibration (`-I`) without applying it to printed patches.
|
||||
#[serde(default)]
|
||||
pub calibration_embed_only: bool,
|
||||
}
|
||||
|
||||
pub fn build_targen_args(config: &TargenConfig) -> Vec<String> {
|
||||
@@ -763,6 +933,18 @@ pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec<String> {
|
||||
}
|
||||
args.push(config.dpi.to_string());
|
||||
|
||||
if let Some(ref cal) = config.calibration_file {
|
||||
let trimmed = cal.trim();
|
||||
if !trimmed.is_empty() {
|
||||
if config.calibration_embed_only {
|
||||
args.push("-I".to_string());
|
||||
} else {
|
||||
args.push("-K".to_string());
|
||||
}
|
||||
args.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
args.push(config.basename.clone());
|
||||
args
|
||||
}
|
||||
@@ -1084,6 +1266,7 @@ pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec<String> {
|
||||
"-v".to_string(),
|
||||
"-k".to_string(),
|
||||
"-s".to_string(),
|
||||
"-u".to_string(),
|
||||
config.ti3_path.clone(),
|
||||
config.icc_path.clone(),
|
||||
]
|
||||
@@ -1456,6 +1639,8 @@ mod tests {
|
||||
no_randomize: false,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
calibration_file: None,
|
||||
calibration_embed_only: false,
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "100", "my_profile"]);
|
||||
@@ -1473,6 +1658,8 @@ mod tests {
|
||||
no_randomize: false,
|
||||
basename: "cmyk_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
calibration_file: None,
|
||||
calibration_embed_only: false,
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-R", "1", "-T", "300", "cmyk_profile"]);
|
||||
@@ -1490,6 +1677,8 @@ mod tests {
|
||||
no_randomize: false,
|
||||
basename: "custom_target".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
calibration_file: None,
|
||||
calibration_embed_only: false,
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-R", "1", "-t", "150", "custom_target"]);
|
||||
@@ -1507,6 +1696,8 @@ mod tests {
|
||||
no_randomize: false,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
calibration_file: None,
|
||||
calibration_embed_only: false,
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(
|
||||
@@ -1541,6 +1732,8 @@ mod tests {
|
||||
no_randomize: false,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
calibration_file: None,
|
||||
calibration_embed_only: false,
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "42", "-t", "300", "my_profile"]);
|
||||
@@ -1558,11 +1751,56 @@ mod tests {
|
||||
no_randomize: true,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
calibration_file: None,
|
||||
calibration_embed_only: false,
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-r", "-t", "300", "my_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_printtarg_args_with_calibration_apply() {
|
||||
let config = PrinttargConfig {
|
||||
instrument: "i1".to_string(),
|
||||
page_size: "A4".to_string(),
|
||||
bit_depth: 8,
|
||||
dpi: 300,
|
||||
custom_label: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: false,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
calibration_file: Some("CAL_photo.cal".to_string()),
|
||||
calibration_embed_only: false,
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "300", "-K", "CAL_photo.cal", "my_profile"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_printtarg_args_with_calibration_embed_only() {
|
||||
let config = PrinttargConfig {
|
||||
instrument: "i1".to_string(),
|
||||
page_size: "A4".to_string(),
|
||||
bit_depth: 8,
|
||||
dpi: 300,
|
||||
custom_label: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: false,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
calibration_file: Some("lin.cal".to_string()),
|
||||
calibration_embed_only: true,
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert!(args.contains(&"-I".to_string()));
|
||||
assert!(!args.contains(&"-K".to_string()));
|
||||
assert!(args.contains(&"lin.cal".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chartread_args_auto() {
|
||||
let config = ChartreadConfig {
|
||||
@@ -1868,7 +2106,7 @@ mod tests {
|
||||
cwd: "/home/user".to_string(),
|
||||
};
|
||||
let args = build_profcheck_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-k", "-s", "my_profile.ti3", "my_profile.icc"]);
|
||||
assert_eq!(args, vec!["-v", "-k", "-s", "-u", "my_profile.ti3", "my_profile.icc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2012,4 +2250,33 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_macos_product_version() {
|
||||
assert_eq!(parse_macos_product_version("12.7.6"), Some((12, 7, 6)));
|
||||
assert_eq!(parse_macos_product_version("13.0"), Some((13, 0, 0)));
|
||||
assert_eq!(parse_macos_product_version(" 15.1.1\n"), Some((15, 1, 1)));
|
||||
assert_eq!(parse_macos_product_version(""), None);
|
||||
assert_eq!(parse_macos_product_version("ventura"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_os_info_has_host_os_and_arch() {
|
||||
let info = collect_os_info();
|
||||
assert_eq!(info.os, std::env::consts::OS);
|
||||
assert_eq!(info.arch, std::env::consts::ARCH);
|
||||
assert_eq!(info.family, std::env::consts::FAMILY);
|
||||
if info.os != "macos" {
|
||||
assert_eq!(info.macos_major, None);
|
||||
assert_eq!(info.macos_minor, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_app_info_includes_os_arch() {
|
||||
let info = get_app_info();
|
||||
assert!(!info.version.is_empty());
|
||||
assert_eq!(info.os, std::env::consts::OS);
|
||||
assert_eq!(info.arch, std::env::consts::ARCH);
|
||||
}
|
||||
}
|
||||
|
||||
+54
-5
@@ -2,10 +2,15 @@ use tauri::Manager;
|
||||
|
||||
mod cgats;
|
||||
mod commands;
|
||||
mod calibration;
|
||||
mod events;
|
||||
mod macos_webview;
|
||||
mod print;
|
||||
mod process_manager;
|
||||
mod profile_install;
|
||||
mod quality_store;
|
||||
mod settings;
|
||||
mod window_lifecycle;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -20,6 +25,10 @@ pub fn run() {
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview),
|
||||
])
|
||||
// wry / tauri_runtime_wry log "web content process terminated" at info/debug.
|
||||
// Surface those lines in iccery.log so Monterey GPU deaths are diagnosable.
|
||||
.level_for("wry", log::LevelFilter::Info)
|
||||
.level_for("tauri_runtime_wry", log::LevelFilter::Info)
|
||||
.max_file_size(5 * 1024 * 1024)
|
||||
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
|
||||
.build(),
|
||||
@@ -32,6 +41,12 @@ pub fn run() {
|
||||
let filter = settings::parse_log_level_filter(settings.log_level.as_deref());
|
||||
log::set_max_level(filter);
|
||||
log::info!("ICCery initialized. Effective log level: {:?}", filter);
|
||||
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
// Dark backing before first paint. Do not show() here — that races
|
||||
// the still-white WKWebView. Frontend invokes show_main_window.
|
||||
macos_webview::paint_dark_webview(&win);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.manage(process_manager::ProcessManager::new())
|
||||
@@ -39,6 +54,8 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::spawn_process,
|
||||
commands::get_app_info,
|
||||
commands::get_os_info,
|
||||
commands::show_main_window,
|
||||
commands::get_default_working_dir,
|
||||
commands::get_log_path,
|
||||
commands::get_recent_log_excerpt,
|
||||
@@ -50,6 +67,8 @@ pub fn run() {
|
||||
commands::inspect_dataset_preview,
|
||||
commands::select_existing_target,
|
||||
commands::select_profile_file,
|
||||
commands::select_spectrum_file,
|
||||
commands::select_dataset_file,
|
||||
commands::select_target_file,
|
||||
commands::select_directory,
|
||||
commands::send_stdin,
|
||||
@@ -74,6 +93,22 @@ pub fn run() {
|
||||
commands::get_printer_capabilities,
|
||||
commands::show_printer_properties,
|
||||
commands::print_target_native,
|
||||
commands::select_csv_save_path,
|
||||
calibration::generate_calibration_target,
|
||||
calibration::compute_calibration_curves,
|
||||
calibration::apply_calibration,
|
||||
calibration::parse_cal_file_cmd,
|
||||
calibration::list_saved_calibrations,
|
||||
calibration::save_calibration_to_library,
|
||||
calibration::select_cal_file,
|
||||
calibration::load_project_calibration,
|
||||
calibration::save_project_calibration,
|
||||
profile_install::install_profile_to_system,
|
||||
profile_install::get_profile_install_dir,
|
||||
quality_store::save_verification_record,
|
||||
quality_store::get_verification_history,
|
||||
quality_store::clear_verification_history,
|
||||
quality_store::export_verification_history_csv,
|
||||
settings::load_settings,
|
||||
settings::save_settings,
|
||||
settings::get_all_presets,
|
||||
@@ -87,21 +122,35 @@ pub fn run() {
|
||||
.run(|app_handle, event| {
|
||||
match event {
|
||||
tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } => {
|
||||
window_lifecycle::mark_user_close_requested();
|
||||
let pm = app_handle.state::<process_manager::ProcessManager>();
|
||||
tauri::async_runtime::block_on(async {
|
||||
pm.kill_all().await;
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::CloseRequested { .. },
|
||||
..
|
||||
} => {
|
||||
tauri::RunEvent::WindowEvent { label, event, .. } => {
|
||||
match event {
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
window_lifecycle::on_window_destroyed(&label);
|
||||
}
|
||||
tauri::WindowEvent::CloseRequested { .. } => {
|
||||
window_lifecycle::mark_user_close_requested();
|
||||
let pm = app_handle.state::<process_manager::ProcessManager>();
|
||||
tauri::async_runtime::block_on(async {
|
||||
pm.kill_all().await;
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
other => {
|
||||
log::debug!("WindowEvent on {label}: {other:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let text = format!("{other:?}");
|
||||
if window_lifecycle::is_web_content_termination_event(&text) {
|
||||
log::error!("WebView lifecycle event: {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Dark native window + WKWebView backing for macOS (#225).
|
||||
//!
|
||||
//! `backgroundColor` in `tauri.conf.json` paints the NSWindow, but WKWebView
|
||||
//! still composites an opaque white layer until `drawsBackground` is disabled
|
||||
//! and (on macOS 12+) `underPageBackgroundColor` is set. We do **not** set
|
||||
//! `transparent: true` — that changes hit-testing and titlebar compositing.
|
||||
|
||||
/// Theme fallback already used in `index.html` (`var(--bg-card, #1a1a22)`).
|
||||
pub const DARK_BG_U8: (u8, u8, u8, u8) = (0x1A, 0x1A, 0x22, 0xFF);
|
||||
|
||||
pub fn paint_dark_webview(window: &tauri::WebviewWindow) {
|
||||
let (r, g, b, a) = DARK_BG_U8;
|
||||
let _ = window.set_background_color(Some(tauri::window::Color(r, g, b, a)));
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = window.with_webview(|webview| unsafe {
|
||||
apply_native_dark_backing(&webview);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe fn apply_native_dark_backing(webview: &tauri::webview::PlatformWebview) {
|
||||
use objc2::runtime::{AnyClass, AnyObject};
|
||||
use objc2::{msg_send, sel};
|
||||
use objc2_foundation::ns_string;
|
||||
|
||||
let red = 26.0f64 / 255.0;
|
||||
let green = 26.0f64 / 255.0;
|
||||
let blue = 34.0f64 / 255.0;
|
||||
let alpha = 1.0f64;
|
||||
|
||||
let Some(nscolor_cls) = AnyClass::get(c"NSColor") else {
|
||||
log::warn!("NSColor class missing; skipping dark window backing");
|
||||
return;
|
||||
};
|
||||
|
||||
let color: *mut AnyObject = msg_send![
|
||||
nscolor_cls,
|
||||
colorWithSRGBRed: red,
|
||||
green: green,
|
||||
blue: blue,
|
||||
alpha: alpha
|
||||
];
|
||||
|
||||
let ns_window = webview.ns_window() as *mut AnyObject;
|
||||
if !ns_window.is_null() && !color.is_null() {
|
||||
let _: () = msg_send![ns_window, setBackgroundColor: color];
|
||||
}
|
||||
|
||||
let wk = webview.inner() as *mut AnyObject;
|
||||
if wk.is_null() {
|
||||
log::warn!("WKWebView inner handle is null; cannot disable white backing");
|
||||
return;
|
||||
}
|
||||
|
||||
// Private KVC key wry uses for transparency / backgroundColor on macOS.
|
||||
if let Some(nsnumber_cls) = AnyClass::get(c"NSNumber") {
|
||||
let no: *mut AnyObject = msg_send![nsnumber_cls, numberWithBool: false];
|
||||
if !no.is_null() {
|
||||
let _: () = msg_send![wk, setValue: no, forKey: ns_string!("drawsBackground")];
|
||||
}
|
||||
}
|
||||
|
||||
// Public API on macOS 12+: covers overscroll / unpainted page.
|
||||
let setter = sel!(setUnderPageBackgroundColor:);
|
||||
let responds: bool = msg_send![wk, respondsToSelector: setter];
|
||||
if responds && !color.is_null() {
|
||||
let _: () = msg_send![wk, setUnderPageBackgroundColor: color];
|
||||
}
|
||||
|
||||
log::info!("Applied dark WKWebView backing (#1A1A22)");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dark_bg_matches_theme_hex() {
|
||||
assert_eq!(DARK_BG_U8, (26, 26, 34, 255));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
//! System-wide ICC/ICM profile installation (#223).
|
||||
//!
|
||||
//! Copies a generated profile into the OS colour-management directory and,
|
||||
//! where available, registers it (Windows ICM, macOS ColorSync, Linux colord).
|
||||
//! The working-directory artefact is never moved.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
|
||||
const MIN_PROFILE_BYTES: u64 = 128;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct InstallOptions {
|
||||
#[serde(default)]
|
||||
pub force_overwrite: bool,
|
||||
#[serde(default)]
|
||||
pub prefer_system_wide: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub register_with_os: bool,
|
||||
/// `overwrite` | `rename` | `cancel` — used when the destination exists.
|
||||
#[serde(default = "default_collision")]
|
||||
pub collision_policy: String,
|
||||
#[serde(default)]
|
||||
pub open_color_panel: bool,
|
||||
#[serde(default)]
|
||||
pub calibration_note: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_collision() -> String {
|
||||
"cancel".to_string()
|
||||
}
|
||||
|
||||
impl Default for InstallOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
force_overwrite: false,
|
||||
prefer_system_wide: false,
|
||||
register_with_os: true,
|
||||
collision_policy: default_collision(),
|
||||
open_color_panel: false,
|
||||
calibration_note: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
pub struct InstallResult {
|
||||
pub dest_path: String,
|
||||
pub registered: bool,
|
||||
pub overwritten: bool,
|
||||
pub renamed: bool,
|
||||
pub opened_panel: bool,
|
||||
pub message: String,
|
||||
pub calibration_note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstallEnv {
|
||||
pub os: String,
|
||||
pub home: Option<PathBuf>,
|
||||
pub windir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl InstallEnv {
|
||||
pub fn from_process() -> Self {
|
||||
Self {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
home: std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(PathBuf::from),
|
||||
windir: std::env::var_os("WINDIR")
|
||||
.or_else(|| std::env::var_os("SystemRoot"))
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profile_extension_for_os(os: &str) -> &'static str {
|
||||
if os.eq_ignore_ascii_case("windows") {
|
||||
"icm"
|
||||
} else {
|
||||
"icc"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_profile_dir(env: &InstallEnv) -> Result<PathBuf, String> {
|
||||
match env.os.as_str() {
|
||||
"windows" => {
|
||||
let home = env.home.clone().ok_or_else(|| {
|
||||
"USERPROFILE is not set; cannot resolve the per-user Color directory.".to_string()
|
||||
})?;
|
||||
Ok(home.join("AppData").join("Local").join("Microsoft").join("Windows").join("Color"))
|
||||
}
|
||||
"macos" => {
|
||||
let home = env.home.clone().ok_or_else(|| {
|
||||
"HOME is not set; cannot resolve ~/Library/ColorSync/Profiles.".to_string()
|
||||
})?;
|
||||
Ok(home.join("Library").join("ColorSync").join("Profiles"))
|
||||
}
|
||||
_ => {
|
||||
let home = env.home.clone().ok_or_else(|| {
|
||||
"HOME is not set; cannot resolve ~/.local/share/icc.".to_string()
|
||||
})?;
|
||||
Ok(home.join(".local").join("share").join("icc"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_profile_dir(env: &InstallEnv) -> Result<PathBuf, String> {
|
||||
match env.os.as_str() {
|
||||
"windows" => {
|
||||
let windir = env.windir.clone().ok_or_else(|| {
|
||||
"WINDIR is not set; cannot resolve %WINDIR%\\System32\\spool\\drivers\\color.".to_string()
|
||||
})?;
|
||||
Ok(windir.join("System32").join("spool").join("drivers").join("color"))
|
||||
}
|
||||
"macos" => Ok(PathBuf::from("/Library/ColorSync/Profiles")),
|
||||
_ => Ok(PathBuf::from("/usr/share/color/icc")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target_profile_dir(env: &InstallEnv, prefer_system_wide: bool) -> Result<PathBuf, String> {
|
||||
if prefer_system_wide {
|
||||
system_profile_dir(env)
|
||||
} else {
|
||||
user_profile_dir(env)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dest_filename(source: &Path, os: &str) -> Result<String, String> {
|
||||
let stem = source
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| "profile filename is invalid".to_string())?;
|
||||
if stem.contains("..") || stem.contains('/') || stem.contains('\\') {
|
||||
return Err("profile filename is invalid".to_string());
|
||||
}
|
||||
Ok(format!("{stem}.{}", profile_extension_for_os(os)))
|
||||
}
|
||||
|
||||
pub fn timestamped_filename(name: &str) -> String {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) => (s, e),
|
||||
None => (name, "icc"),
|
||||
};
|
||||
format!("{stem}-{now}.{ext}")
|
||||
}
|
||||
|
||||
pub fn verify_source_profile(path: &Path) -> Result<u64, String> {
|
||||
if !path.is_file() {
|
||||
return Err(format!("Profile artefact not found: {}", path.display()));
|
||||
}
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if ext != "icc" && ext != "icm" {
|
||||
return Err("Source file must be an .icc or .icm profile.".to_string());
|
||||
}
|
||||
let len = fs::metadata(path)
|
||||
.map_err(|e| format!("Cannot stat profile: {e}"))?
|
||||
.len();
|
||||
if len < MIN_PROFILE_BYTES {
|
||||
return Err(format!(
|
||||
"Profile is too small ({len} bytes) to be a valid ICC header."
|
||||
));
|
||||
}
|
||||
Ok(len)
|
||||
}
|
||||
|
||||
pub fn resolve_destination(
|
||||
dest_dir: &Path,
|
||||
filename: &str,
|
||||
options: &InstallOptions,
|
||||
) -> Result<(PathBuf, bool, bool), String> {
|
||||
let dest = dest_dir.join(filename);
|
||||
if !dest.exists() {
|
||||
return Ok((dest, false, false));
|
||||
}
|
||||
let policy = options.collision_policy.to_ascii_lowercase();
|
||||
if options.force_overwrite || policy == "overwrite" {
|
||||
return Ok((dest, true, false));
|
||||
}
|
||||
if policy == "rename" {
|
||||
return Ok((dest_dir.join(timestamped_filename(filename)), false, true));
|
||||
}
|
||||
Err(format!(
|
||||
"A profile named {filename} already exists at {}. Choose Overwrite, Rename, or Cancel.",
|
||||
dest.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn copy_atomic(src: &Path, dest: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
permission_message(parent, &e)
|
||||
})?;
|
||||
}
|
||||
let tmp = dest.with_extension("iccery-install.tmp");
|
||||
fs::copy(src, &tmp).map_err(|e| permission_message(&tmp, &e))?;
|
||||
fs::rename(&tmp, dest).map_err(|e| {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
permission_message(dest, &e)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn permission_message(path: &Path, err: &std::io::Error) -> String {
|
||||
if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return format!(
|
||||
"Access denied writing {}. On Windows the system Color folder usually requires 'Run as Administrator'. Retry with elevation, or install to the per-user Color directory instead.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return format!(
|
||||
"Permission denied writing {}. Install to ~/Library/ColorSync/Profiles (no elevation) or authenticate to write /Library/ColorSync/Profiles.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
return format!(
|
||||
"Permission denied writing {}. Install to ~/.local/share/icc (no root) or use pkexec/sudo for /usr/share/color/icc.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
format!("Failed to write {}: {err}", path.display())
|
||||
}
|
||||
|
||||
fn register_profile(dest: &Path, os: &str) -> bool {
|
||||
match os {
|
||||
"windows" => register_windows(dest),
|
||||
"macos" => true, // ColorSync discovers files in the Profiles folders.
|
||||
_ => register_colord(dest),
|
||||
}
|
||||
}
|
||||
|
||||
fn register_windows(dest: &Path) -> bool {
|
||||
// Copy into the Color directory is sufficient for most apps. Try the
|
||||
// classic InstallColorProfile helper when present; ignore failure.
|
||||
let _ = Command::new("rundll32")
|
||||
.args([
|
||||
"mscms.dll,InstallColorProfileW",
|
||||
&dest.to_string_lossy(),
|
||||
])
|
||||
.status();
|
||||
true
|
||||
}
|
||||
|
||||
fn register_colord(dest: &Path) -> bool {
|
||||
match Command::new("colormgr")
|
||||
.args(["import-profile", &dest.to_string_lossy()])
|
||||
.output()
|
||||
{
|
||||
Ok(out) if out.status.success() => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_color_panel(os: &str) -> bool {
|
||||
let result = match os {
|
||||
"windows" => Command::new("colorcpl").status(),
|
||||
"macos" => Command::new("open").args(["-a", "ColorSync Utility"]).status(),
|
||||
_ => Command::new("colormgr")
|
||||
.arg("get-profiles")
|
||||
.status()
|
||||
.or_else(|_| Command::new("gnome-control-center").arg("color").status()),
|
||||
};
|
||||
result.map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn install_profile_with_env(
|
||||
profile_path: &str,
|
||||
options: &InstallOptions,
|
||||
env: &InstallEnv,
|
||||
) -> Result<InstallResult, String> {
|
||||
let src = PathBuf::from(profile_path.trim());
|
||||
let src_size = verify_source_profile(&src)?;
|
||||
let dir = target_profile_dir(env, options.prefer_system_wide)?;
|
||||
let filename = dest_filename(&src, &env.os)?;
|
||||
let (dest, overwritten, renamed) = resolve_destination(&dir, &filename, options)?;
|
||||
|
||||
copy_atomic(&src, &dest)?;
|
||||
|
||||
let dest_size = fs::metadata(&dest)
|
||||
.map_err(|e| format!("Installed file missing after copy: {e}"))?
|
||||
.len();
|
||||
if dest_size != src_size {
|
||||
return Err("Installed profile size does not match the working-directory artefact.".to_string());
|
||||
}
|
||||
|
||||
let registered = if options.register_with_os {
|
||||
register_profile(&dest, &env.os)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let opened = if options.open_color_panel {
|
||||
open_color_panel(&env.os)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let mut message = format!("Installed profile to {}", dest.display());
|
||||
if overwritten {
|
||||
message.push_str(" (replaced existing file)");
|
||||
} else if renamed {
|
||||
message.push_str(" (renamed to avoid collision)");
|
||||
}
|
||||
if let Some(ref note) = options.calibration_note {
|
||||
if !note.trim().is_empty() {
|
||||
message.push_str(". ");
|
||||
message.push_str(note.trim());
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(target: "profile_install", "{message}");
|
||||
|
||||
Ok(InstallResult {
|
||||
dest_path: dest.to_string_lossy().to_string(),
|
||||
registered,
|
||||
overwritten,
|
||||
renamed,
|
||||
opened_panel: opened,
|
||||
message,
|
||||
calibration_note: options.calibration_note.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_profile_install_dir(prefer_system_wide: Option<bool>) -> Result<String, String> {
|
||||
let env = InstallEnv::from_process();
|
||||
let dir = target_profile_dir(&env, prefer_system_wide.unwrap_or(false))?;
|
||||
Ok(dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn install_profile_to_system(
|
||||
_app: AppHandle,
|
||||
profile_path: String,
|
||||
options: Option<InstallOptions>,
|
||||
) -> Result<InstallResult, String> {
|
||||
let options = options.unwrap_or_default();
|
||||
install_profile_with_env(&profile_path, &options, &InstallEnv::from_process())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env_linux() -> InstallEnv {
|
||||
InstallEnv {
|
||||
os: "linux".to_string(),
|
||||
home: Some(PathBuf::from("/home/gordon")),
|
||||
windir: None,
|
||||
}
|
||||
}
|
||||
fn env_mac() -> InstallEnv {
|
||||
InstallEnv {
|
||||
os: "macos".to_string(),
|
||||
home: Some(PathBuf::from("/Users/gordon")),
|
||||
windir: None,
|
||||
}
|
||||
}
|
||||
fn env_win() -> InstallEnv {
|
||||
InstallEnv {
|
||||
os: "windows".to_string(),
|
||||
home: Some(PathBuf::from(r"C:\Users\gordon")),
|
||||
windir: Some(PathBuf::from(r"C:\Windows")),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_and_system_dirs_linux() {
|
||||
let env = env_linux();
|
||||
assert_eq!(
|
||||
user_profile_dir(&env).unwrap(),
|
||||
PathBuf::from("/home/gordon/.local/share/icc")
|
||||
);
|
||||
assert_eq!(
|
||||
system_profile_dir(&env).unwrap(),
|
||||
PathBuf::from("/usr/share/color/icc")
|
||||
);
|
||||
assert_eq!(
|
||||
target_profile_dir(&env, false).unwrap(),
|
||||
user_profile_dir(&env).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
target_profile_dir(&env, true).unwrap(),
|
||||
system_profile_dir(&env).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_and_system_dirs_macos() {
|
||||
let env = env_mac();
|
||||
assert_eq!(
|
||||
user_profile_dir(&env).unwrap(),
|
||||
PathBuf::from("/Users/gordon/Library/ColorSync/Profiles")
|
||||
);
|
||||
assert_eq!(
|
||||
system_profile_dir(&env).unwrap(),
|
||||
PathBuf::from("/Library/ColorSync/Profiles")
|
||||
);
|
||||
assert_eq!(profile_extension_for_os("macos"), "icc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_windows_system_color_dir_and_icm() {
|
||||
let env = env_win();
|
||||
assert_eq!(
|
||||
system_profile_dir(&env).unwrap(),
|
||||
PathBuf::from(r"C:\Windows\System32\spool\drivers\color")
|
||||
);
|
||||
assert_eq!(profile_extension_for_os("windows"), "icm");
|
||||
let dest = dest_filename(Path::new(r"C:\work\Press.icc"), "windows").unwrap();
|
||||
assert_eq!(dest, "Press.icm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dest_filename_unix_keeps_icc() {
|
||||
let name = dest_filename(Path::new("/tmp/photo.icm"), "linux").unwrap();
|
||||
assert_eq!(name, "photo.icc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dest_filename_rejects_invalid() {
|
||||
assert!(dest_filename(Path::new(""), "linux").is_err());
|
||||
assert!(dest_filename(Path::new(".."), "linux").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_destination_cancel() {
|
||||
let dir = std::env::temp_dir();
|
||||
let existing = dir.join("iccery-install-collision.icc");
|
||||
fs::write(&existing, vec![0u8; 200]).unwrap();
|
||||
let opts = InstallOptions {
|
||||
collision_policy: "cancel".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let err = resolve_destination(&dir, "iccery-install-collision.icc", &opts).unwrap_err();
|
||||
assert!(err.contains("already exists"));
|
||||
let _ = fs::remove_file(existing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_destination_rename_and_overwrite() {
|
||||
let dir = std::env::temp_dir();
|
||||
let filename = "iccery-install-exists.icc";
|
||||
let existing = dir.join(filename);
|
||||
fs::write(&existing, vec![0u8; 200]).unwrap();
|
||||
let rename = InstallOptions {
|
||||
collision_policy: "rename".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let (path, overwritten, renamed) = resolve_destination(&dir, filename, &rename).unwrap();
|
||||
assert!(!overwritten && renamed);
|
||||
assert_ne!(path, existing);
|
||||
let over = InstallOptions {
|
||||
collision_policy: "overwrite".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let (path2, overwritten2, renamed2) = resolve_destination(&dir, filename, &over).unwrap();
|
||||
assert!(overwritten2 && !renamed2);
|
||||
assert_eq!(path2, existing);
|
||||
let _ = fs::remove_file(existing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_source_profile_rejects_missing_and_tiny() {
|
||||
let missing = PathBuf::from("/tmp/does-not-exist-iccery.icc");
|
||||
assert!(verify_source_profile(&missing).is_err());
|
||||
let tiny = std::env::temp_dir().join("iccery-tiny.icc");
|
||||
fs::write(&tiny, b"short").unwrap();
|
||||
assert!(verify_source_profile(&tiny).is_err());
|
||||
let _ = fs::remove_file(tiny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamped_filename_preserves_extension() {
|
||||
let name = timestamped_filename("Press.icm");
|
||||
assert!(name.starts_with("Press-"));
|
||||
assert!(name.ends_with(".icm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_profile_copy_roundtrip() {
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"iccery-install-{}",
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis()
|
||||
));
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
let src = tmp.join("demo.icc");
|
||||
let bytes = vec![0u8; 256];
|
||||
fs::write(&src, &bytes).unwrap();
|
||||
let env = InstallEnv {
|
||||
os: "linux".to_string(),
|
||||
home: Some(tmp.join("home")),
|
||||
windir: None,
|
||||
};
|
||||
let result = install_profile_with_env(
|
||||
&src.to_string_lossy(),
|
||||
&InstallOptions {
|
||||
register_with_os: false,
|
||||
open_color_panel: false,
|
||||
calibration_note: Some("Curves from CAL_demo.cal were applied.".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
&env,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(Path::new(&result.dest_path).is_file());
|
||||
assert!(src.is_file(), "source artefact must remain");
|
||||
assert!(result.message.contains("Curves from CAL_demo.cal"));
|
||||
let _ = fs::remove_dir_all(tmp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
static SEQ_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
const HISTORY_CAP: usize = 1000;
|
||||
const STORE_VERSION: u32 = 1;
|
||||
const MAX_STRING_LEN: usize = 200;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct VerificationRecord {
|
||||
pub id: String, // backend-generated only: "vr-<epoch_millis>-<seq>"
|
||||
pub timestamp: String, // ISO 8601 string
|
||||
pub printer_name: String,
|
||||
pub profile_name: String,
|
||||
pub avg_de: f64,
|
||||
pub max_de: f64,
|
||||
pub rms_de: f64,
|
||||
pub patch_count: u32,
|
||||
pub status: String, // backend ALWAYS fills via classify_status
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct VerificationHistoryStore {
|
||||
pub version: u32,
|
||||
pub records: Vec<VerificationRecord>,
|
||||
}
|
||||
|
||||
impl Default for VerificationHistoryStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: STORE_VERSION,
|
||||
records: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a unique record identifier: "vr-<epoch_millis>-<seq>"
|
||||
pub fn generate_record_id() -> String {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let seq = SEQ_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
format!("vr-{}-{}", millis, seq)
|
||||
}
|
||||
|
||||
/// Classifies status into ICCery verification bands (issue #95):
|
||||
/// - < 1.0: "excellent"
|
||||
/// - < 2.0: "good"
|
||||
/// - < 3.5: "acceptable"
|
||||
/// - >= 3.5: "warning"
|
||||
pub fn classify_status(avg_de: f64) -> &'static str {
|
||||
if avg_de < 1.0 {
|
||||
"excellent"
|
||||
} else if avg_de < 2.0 {
|
||||
"good"
|
||||
} else if avg_de < 3.5 {
|
||||
"acceptable"
|
||||
} else {
|
||||
"warning"
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates record data constraints.
|
||||
pub fn validate_record(record: &VerificationRecord) -> Result<(), String> {
|
||||
if record.profile_name.trim().is_empty() {
|
||||
return Err("Profile name cannot be empty.".to_string());
|
||||
}
|
||||
if record.profile_name.len() > MAX_STRING_LEN {
|
||||
return Err(format!("Profile name exceeds maximum length of {} characters.", MAX_STRING_LEN));
|
||||
}
|
||||
if record.printer_name.len() > MAX_STRING_LEN {
|
||||
return Err(format!("Printer name exceeds maximum length of {} characters.", MAX_STRING_LEN));
|
||||
}
|
||||
if record.timestamp.trim().is_empty() {
|
||||
return Err("Timestamp cannot be empty.".to_string());
|
||||
}
|
||||
if !record.avg_de.is_finite() || record.avg_de < 0.0 {
|
||||
return Err("Average ΔE must be a finite non-negative number.".to_string());
|
||||
}
|
||||
if !record.max_de.is_finite() || record.max_de < 0.0 {
|
||||
return Err("Peak ΔE must be a finite non-negative number.".to_string());
|
||||
}
|
||||
if !record.rms_de.is_finite() || record.rms_de < 0.0 {
|
||||
return Err("RMS ΔE must be a finite non-negative number.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads verification history from a JSON file.
|
||||
/// Missing, unreadable, or corrupted files safely return an empty vector.
|
||||
pub fn load_history_file(path: &Path) -> Vec<VerificationRecord> {
|
||||
if !path.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read verification history at {}: {}", path.display(), e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
match serde_json::from_str::<VerificationHistoryStore>(&content) {
|
||||
Ok(store) => store.records,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse verification history at {}: {}", path.display(), e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes verification records to the specified JSON path atomically.
|
||||
/// Automatically creates parent directories if needed.
|
||||
/// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000).
|
||||
pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
}
|
||||
|
||||
let mut bounded_records: Vec<VerificationRecord> = records.to_vec();
|
||||
// Sort by timestamp ascending
|
||||
bounded_records.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
|
||||
|
||||
if bounded_records.len() > HISTORY_CAP {
|
||||
let excess = bounded_records.len() - HISTORY_CAP;
|
||||
bounded_records.drain(0..excess);
|
||||
}
|
||||
|
||||
let store = VerificationHistoryStore {
|
||||
version: STORE_VERSION,
|
||||
records: bounded_records,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&store)
|
||||
.map_err(|e| format!("Failed to serialize 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(())
|
||||
}
|
||||
|
||||
/// Helper to escape CSV text fields according to RFC-4180.
|
||||
fn escape_csv_field(val: &str) -> String {
|
||||
if val.contains('"') || val.contains(',') || val.contains('\n') || val.contains('\r') {
|
||||
format!("\"{}\"", val.replace('"', "\"\""))
|
||||
} else {
|
||||
val.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats records into standard RFC-4180 CSV string.
|
||||
pub fn to_csv(records: &[VerificationRecord]) -> String {
|
||||
let mut csv = String::from("id,timestamp,printer_name,profile_name,avg_de,max_de,rms_de,patch_count,status\r\n");
|
||||
for r in records {
|
||||
csv.push_str(&format!(
|
||||
"{},{},{},{},{:.4},{:.4},{:.4},{},{}\r\n",
|
||||
escape_csv_field(&r.id),
|
||||
escape_csv_field(&r.timestamp),
|
||||
escape_csv_field(&r.printer_name),
|
||||
escape_csv_field(&r.profile_name),
|
||||
r.avg_de,
|
||||
r.max_de,
|
||||
r.rms_de,
|
||||
r.patch_count,
|
||||
escape_csv_field(&r.status),
|
||||
));
|
||||
}
|
||||
csv
|
||||
}
|
||||
|
||||
/// Validates CSV destination file path.
|
||||
pub fn validate_csv_dest(dest: &str) -> Result<PathBuf, String> {
|
||||
let trimmed = dest.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Destination path cannot be empty.".to_string());
|
||||
}
|
||||
let mut path = PathBuf::from(trimmed);
|
||||
if path.extension().is_none() || path.extension().unwrap_or_default() != "csv" {
|
||||
path.set_extension("csv");
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn get_store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let app_data = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
Ok(app_data.join("verification_history.json"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_verification_record(
|
||||
app: AppHandle,
|
||||
mut record: VerificationRecord,
|
||||
) -> Result<VerificationRecord, String> {
|
||||
record.id = generate_record_id();
|
||||
record.status = classify_status(record.avg_de).to_string();
|
||||
validate_record(&record)?;
|
||||
|
||||
let path = get_store_path(&app)?;
|
||||
let mut records = load_history_file(&path);
|
||||
records.push(record.clone());
|
||||
write_history_file(&path, &records)?;
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_verification_history(
|
||||
app: AppHandle,
|
||||
profile_name: Option<String>,
|
||||
printer_name: Option<String>,
|
||||
) -> Result<Vec<VerificationRecord>, String> {
|
||||
let path = get_store_path(&app)?;
|
||||
let mut records = load_history_file(&path);
|
||||
|
||||
if let Some(ref prof) = profile_name {
|
||||
let prof_clean = prof.trim();
|
||||
if !prof_clean.is_empty() {
|
||||
records.retain(|r| r.profile_name == prof_clean);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref prn) = printer_name {
|
||||
let prn_clean = prn.trim();
|
||||
if !prn_clean.is_empty() {
|
||||
records.retain(|r| r.printer_name == prn_clean);
|
||||
}
|
||||
}
|
||||
|
||||
// Return chronological order (ascending timestamp)
|
||||
records.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_verification_history(app: AppHandle) -> Result<(), String> {
|
||||
let path = get_store_path(&app)?;
|
||||
write_history_file(&path, &[])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_verification_history_csv(
|
||||
app: AppHandle,
|
||||
dest_path: String,
|
||||
profile_name: Option<String>,
|
||||
printer_name: Option<String>,
|
||||
) -> Result<usize, String> {
|
||||
let validated_path = validate_csv_dest(&dest_path)?;
|
||||
let records = get_verification_history(app, profile_name, printer_name)?;
|
||||
let count = records.len();
|
||||
let csv_content = to_csv(&records);
|
||||
|
||||
if let Some(parent) = validated_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Failed to create CSV parent directory: {}", e))?;
|
||||
}
|
||||
fs::write(&validated_path, csv_content)
|
||||
.map_err(|e| format!("Failed to write CSV file: {}", e))?;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit Tests (Path-based, no AppHandle required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn sample_record(id: &str, ts: &str, avg: f64) -> VerificationRecord {
|
||||
VerificationRecord {
|
||||
id: id.to_string(),
|
||||
timestamp: ts.to_string(),
|
||||
printer_name: "Canon PRO-1000".to_string(),
|
||||
profile_name: "ProLustre_Photo".to_string(),
|
||||
avg_de: avg,
|
||||
max_de: avg * 2.0,
|
||||
rms_de: avg * 1.2,
|
||||
patch_count: 50,
|
||||
status: classify_status(avg).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_status_bands() {
|
||||
// Boundaries: <1.0 excellent, <2.0 good, <3.5 acceptable, >=3.5 warning
|
||||
assert_eq!(classify_status(0.0), "excellent");
|
||||
assert_eq!(classify_status(0.999), "excellent");
|
||||
assert_eq!(classify_status(1.0), "good");
|
||||
assert_eq!(classify_status(1.999), "good");
|
||||
assert_eq!(classify_status(2.0), "acceptable");
|
||||
assert_eq!(classify_status(3.499), "acceptable");
|
||||
assert_eq!(classify_status(3.5), "warning");
|
||||
assert_eq!(classify_status(5.2), "warning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_record_valid_and_invalid() {
|
||||
let valid = sample_record("vr-1", "2026-09-05T12:00:00Z", 0.85);
|
||||
assert!(validate_record(&valid).is_ok());
|
||||
|
||||
let mut empty_prof = valid.clone();
|
||||
empty_prof.profile_name = " ".to_string();
|
||||
assert!(validate_record(&empty_prof).is_err());
|
||||
|
||||
let mut empty_ts = valid.clone();
|
||||
empty_ts.timestamp = "".to_string();
|
||||
assert!(validate_record(&empty_ts).is_err());
|
||||
|
||||
let mut negative_de = valid.clone();
|
||||
negative_de.avg_de = -0.1;
|
||||
assert!(validate_record(&negative_de).is_err());
|
||||
|
||||
let mut nan_de = valid.clone();
|
||||
nan_de.max_de = f64::NAN;
|
||||
assert!(validate_record(&nan_de).is_err());
|
||||
|
||||
let mut inf_de = valid.clone();
|
||||
inf_de.rms_de = f64::INFINITY;
|
||||
assert!(validate_record(&inf_de).is_err());
|
||||
|
||||
let mut long_name = valid.clone();
|
||||
long_name.profile_name = "a".repeat(201);
|
||||
assert!(validate_record(&long_name).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_load_missing_and_corrupt() {
|
||||
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_corrupt");
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
let missing_path = temp_dir.join("nonexistent.json");
|
||||
let records = load_history_file(&missing_path);
|
||||
assert!(records.is_empty());
|
||||
|
||||
let corrupt_path = temp_dir.join("corrupt.json");
|
||||
fs::write(&corrupt_path, "{ broken json ... ").unwrap();
|
||||
let records_corrupt = load_history_file(&corrupt_path);
|
||||
assert!(records_corrupt.is_empty());
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_write_and_roundtrip() {
|
||||
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_roundtrip");
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
let file_path = temp_dir.join("verification_history.json");
|
||||
let rec1 = sample_record("vr-1", "2026-09-01T10:00:00Z", 0.75);
|
||||
let rec2 = sample_record("vr-2", "2026-09-02T10:00:00Z", 1.85);
|
||||
|
||||
write_history_file(&file_path, &[rec1.clone(), rec2.clone()]).unwrap();
|
||||
let loaded = load_history_file(&file_path);
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded[0], rec1);
|
||||
assert_eq!(loaded[1], rec2);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cap_eviction_drops_oldest_by_timestamp() {
|
||||
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_eviction");
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
let file_path = temp_dir.join("capped_history.json");
|
||||
|
||||
// Generate 1005 records with disordered timestamps
|
||||
let mut records = Vec::new();
|
||||
for i in 0..1005 {
|
||||
// ts ranges from 1000 to 2004
|
||||
let ts = format!("2026-01-01T{:04}Z", i);
|
||||
records.push(sample_record(&format!("vr-{}", i), &ts, 1.0));
|
||||
}
|
||||
|
||||
// Shuffle slightly so input is out of order
|
||||
records.swap(0, 500);
|
||||
|
||||
write_history_file(&file_path, &records).unwrap();
|
||||
let loaded = load_history_file(&file_path);
|
||||
|
||||
assert_eq!(loaded.len(), HISTORY_CAP); // 1000 records
|
||||
// Oldest 5 records (ts 0000..0004) should have been evicted
|
||||
assert_eq!(loaded[0].timestamp, "2026-01-01T0005Z");
|
||||
assert_eq!(loaded[loaded.len() - 1].timestamp, "2026-01-01T1004Z");
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_csv_escaping_and_formatting() {
|
||||
let mut rec1 = sample_record("vr-1", "2026-09-05T12:00:00Z", 0.85);
|
||||
rec1.printer_name = "Canon \"Pro\" 1000, Tray 1".to_string(); // quotes + comma
|
||||
rec1.profile_name = "FineArt, Velvet".to_string();
|
||||
|
||||
let csv = to_csv(&[rec1]);
|
||||
let lines: Vec<&str> = csv.split("\r\n").filter(|l| !l.is_empty()).collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[0], "id,timestamp,printer_name,profile_name,avg_de,max_de,rms_de,patch_count,status");
|
||||
assert!(lines[1].contains("\"Canon \"\"Pro\"\" 1000, Tray 1\""));
|
||||
assert!(lines[1].contains("\"FineArt, Velvet\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_csv_dest() {
|
||||
assert!(validate_csv_dest(" ").is_err());
|
||||
|
||||
let p1 = validate_csv_dest("/tmp/history.csv").unwrap();
|
||||
assert_eq!(p1.extension().unwrap(), "csv");
|
||||
|
||||
let p2 = validate_csv_dest("/tmp/history").unwrap();
|
||||
assert_eq!(p2.extension().unwrap(), "csv");
|
||||
assert_eq!(p2.to_string_lossy(), "/tmp/history.csv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atomic_write_preserves_data_and_cleans_tmp() {
|
||||
let temp_dir = std::env::temp_dir().join("iccery_test_atomic_write");
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
let file_path = temp_dir.join("verification_history.json");
|
||||
let mut tmp_file_path = file_path.as_os_str().to_os_string();
|
||||
tmp_file_path.push(".tmp");
|
||||
let tmp_file_path = std::path::PathBuf::from(tmp_file_path);
|
||||
|
||||
let rec = sample_record("vr-atom-1", "2026-09-06T12:00:00Z", 0.5);
|
||||
write_history_file(&file_path, &[rec.clone()]).unwrap();
|
||||
|
||||
assert!(file_path.exists(), "Target file must exist");
|
||||
assert!(!tmp_file_path.exists(), "Temporary file must not remain after successful atomic write");
|
||||
|
||||
let loaded = load_history_file(&file_path);
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0], rec);
|
||||
|
||||
// Overwrite with updated records to verify atomic replacement
|
||||
let rec2 = sample_record("vr-atom-2", "2026-09-06T12:05:00Z", 1.2);
|
||||
write_history_file(&file_path, &[rec.clone(), rec2.clone()]).unwrap();
|
||||
|
||||
assert!(!tmp_file_path.exists(), "Temporary file must not remain after overwrite");
|
||||
let loaded2 = load_history_file(&file_path);
|
||||
assert_eq!(loaded2.len(), 2);
|
||||
assert_eq!(loaded2[0], rec);
|
||||
assert_eq!(loaded2[1], rec2);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,17 @@ pub struct ProfilingPreset {
|
||||
pub random_seed: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub no_randomize: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub calibration_file: Option<String>,
|
||||
#[serde(default)]
|
||||
pub apply_calibration: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_delta_e_good_max() -> f64 { 2.0 }
|
||||
fn default_delta_e_warning_max() -> f64 { 5.0 }
|
||||
fn default_cal_stale_days() -> u32 { 30 }
|
||||
fn default_install_location() -> String { "user".to_string() }
|
||||
fn default_true_bool() -> bool { true }
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct AppSettings {
|
||||
@@ -76,6 +83,15 @@ pub struct AppSettings {
|
||||
pub custom_presets: Vec<ProfilingPreset>,
|
||||
#[serde(default)]
|
||||
pub enable_i1pro2_leds: bool,
|
||||
#[serde(default = "default_cal_stale_days")]
|
||||
pub calibration_stale_days: u32,
|
||||
/// `user` or `system` — default destination for Stage 5 profile install (#223).
|
||||
#[serde(default = "default_install_location")]
|
||||
pub default_install_location: String,
|
||||
#[serde(default = "default_true_bool")]
|
||||
pub ask_before_overwrite_profile: bool,
|
||||
#[serde(default)]
|
||||
pub open_color_panel_after_install: bool,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@@ -88,6 +104,10 @@ impl Default for AppSettings {
|
||||
delta_e_warning_max: default_delta_e_warning_max(),
|
||||
custom_presets: Vec::new(),
|
||||
enable_i1pro2_leds: false,
|
||||
calibration_stale_days: default_cal_stale_days(),
|
||||
default_install_location: default_install_location(),
|
||||
ask_before_overwrite_profile: true,
|
||||
open_color_panel_after_install: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +155,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||
device_power: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: Some(false),
|
||||
calibration_file: None,
|
||||
apply_calibration: None,
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
@@ -169,6 +191,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||
device_power: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: Some(false),
|
||||
calibration_file: None,
|
||||
apply_calibration: None,
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
@@ -203,6 +227,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||
device_power: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: Some(false),
|
||||
calibration_file: None,
|
||||
apply_calibration: None,
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
@@ -237,6 +263,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||
device_power: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: Some(false),
|
||||
calibration_file: None,
|
||||
apply_calibration: None,
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
@@ -405,6 +433,8 @@ mod tests {
|
||||
device_power: Some(1.2),
|
||||
random_seed: Some(42),
|
||||
no_randomize: Some(false),
|
||||
calibration_file: None,
|
||||
apply_calibration: None,
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
@@ -474,6 +504,10 @@ mod tests {
|
||||
fn test_default_enable_i1pro2_leds() {
|
||||
let settings = AppSettings::default();
|
||||
assert_eq!(settings.enable_i1pro2_leds, false);
|
||||
assert_eq!(settings.calibration_stale_days, 30);
|
||||
assert_eq!(settings.default_install_location, "user");
|
||||
assert!(settings.ask_before_overwrite_profile);
|
||||
assert!(!settings.open_color_panel_after_install);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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",
|
||||
"productName": "ICCery",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.5",
|
||||
"identifier": "com.gronod.iccery",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
@@ -10,11 +10,14 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "ICCery",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 1100,
|
||||
"minHeight": 700
|
||||
"minHeight": 700,
|
||||
"visible": false,
|
||||
"backgroundColor": "#1A1A22"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -36,7 +39,7 @@
|
||||
"argyll/**/*"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"minimumSystemVersion": "12.0",
|
||||
"dmg": {
|
||||
"background": "icons/dmg-background.png",
|
||||
"windowSize": {
|
||||
|
||||
+227
-4
@@ -49,6 +49,8 @@
|
||||
<select id="presetSelect" style="width: 100%; font-size: 0.85rem; padding: 6px 8px; border-radius: 6px; background: var(--bg-card, #1a1a22); color: var(--text-color, #eee); border: 1px solid var(--border-color, #333); cursor: pointer;">
|
||||
<option value="" disabled selected>Loading presets...</option>
|
||||
</select>
|
||||
<button type="button" id="btnCalibratePrinter" class="secondary btn-md" style="width:100%; margin-top:10px;" title="Optional printer linearization via printcal before profiling">Calibrate Printer</button>
|
||||
<div id="calStatusChip" class="cal-status-chip cal-status-none">Calibration: None</div>
|
||||
</div>
|
||||
|
||||
<nav class="stepper">
|
||||
@@ -68,12 +70,112 @@
|
||||
<button type="button" id="wizardNotificationClose" class="notification-close" title="Dismiss notice">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Stage 0: Printer calibration (optional, parallel to the 5-stage wizard) -->
|
||||
<section id="stage-cal" class="stage hidden">
|
||||
<h2>Printer Calibration</h2>
|
||||
<p>Linearize per-channel response and establish ink limits with Argyll <code>printcal</code> before building an ICC profile. Optional for RGB photo printers; strongly recommended for CMYK / RIP workflows.</p>
|
||||
|
||||
<div class="cal-status-banner cal-banner-active" data-cal-banner="always">
|
||||
<span data-cal-banner-text>Calibration: None</span>
|
||||
<label class="cal-apply-toggle" title="When enabled, subsequent Stage 2 printtarg runs use -K and Stage 4 applycal embeds the curves">
|
||||
<input type="checkbox" id="calApplyToggleDash" data-cal-apply>
|
||||
Apply Calibration
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cal-dashboard-grid">
|
||||
<div class="cal-card">
|
||||
<h3>1. Calibration Chart</h3>
|
||||
<div class="form-group has-tooltip">
|
||||
<label>Colour Space</label>
|
||||
<div class="radio-group">
|
||||
<label><input type="radio" name="calColourSpace" value="rgb"> RGB (driver-managed)</label>
|
||||
<label><input type="radio" name="calColourSpace" value="cmyk" checked> CMYK (RIP)</label>
|
||||
</div>
|
||||
<div class="tooltip-text">Calibration is most valuable on native CMYK devices. RGB printers already apply driver curves; results are often modest.</div>
|
||||
</div>
|
||||
<p id="calRgbHint" class="help-hint hidden">RGB driver-managed printers are typically already linearized. Calibration is optional here.</p>
|
||||
<div class="input-row">
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="calSteps">Steps per channel</label>
|
||||
<input type="number" id="calSteps" min="11" max="51" value="21">
|
||||
<div class="tooltip-text">Wedge density for each ink channel (11–51). 21 is a good default; 33 for high-end inkjets.</div>
|
||||
</div>
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="calInkExplore">Ink-limit exploration (TAC %)</label>
|
||||
<input type="number" id="calInkExplore" min="200" max="400" value="320">
|
||||
<div class="tooltip-text">CMYK only. Total area coverage range passed to targen -l so printcal can recommend a TAC.</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="checkbox-label" for="calNeutralEmphasis">
|
||||
<input type="checkbox" id="calNeutralEmphasis">
|
||||
Neutral-axis emphasis
|
||||
</label>
|
||||
<div class="stage-actions">
|
||||
<button type="button" class="primary btn-lg" id="btnCalGenerate">Generate Calibration Target</button>
|
||||
<button type="button" class="secondary btn-md" id="btnCalLayout">Create Layout & Print</button>
|
||||
<button type="button" class="secondary btn-md" id="btnCalMeasure">Measure Chart</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cal-card">
|
||||
<h3>2. Saved curves</h3>
|
||||
<p class="help-hint" id="calCurrentFile">No .cal loaded</p>
|
||||
<div class="btn-row wrap">
|
||||
<button type="button" class="secondary btn-md" id="btnCalLoad">Load Existing…</button>
|
||||
<button type="button" class="secondary btn-md" id="btnCalLibrary">Save to Library</button>
|
||||
<button type="button" class="danger btn-md" id="btnCalClear">Clear</button>
|
||||
</div>
|
||||
<select id="calSavedSelect" style="width:100%; margin-top:10px;"></select>
|
||||
<div class="stage-actions">
|
||||
<button type="button" class="primary btn-lg" id="btnCalCompute">Compute Curves</button>
|
||||
</div>
|
||||
<p class="help-hint">Requires a measured <code>CAL_*.ti3</code> in the working directory. Existing <code>.cal</code> files are never overwritten without confirmation.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cal-dashboard-grid">
|
||||
<div class="cal-card">
|
||||
<h3>Channel response</h3>
|
||||
<svg id="calCurveSvg" class="cal-curve-svg" role="img" aria-label="Channel response curves"></svg>
|
||||
<div id="calCurveLegend" class="cal-curve-legend"></div>
|
||||
<p class="help-hint">Solid lines are post-linearization device response. Dashed line is identity (already linear).</p>
|
||||
</div>
|
||||
<div class="cal-card">
|
||||
<h3>Ink limits</h3>
|
||||
<div class="cal-tac-card">Total area coverage: <strong id="calTacValue">—</strong></div>
|
||||
<div class="form-group">
|
||||
<label for="calTacOverride">Override TAC %</label>
|
||||
<input type="number" id="calTacOverride" min="150" max="400" placeholder="use recommended">
|
||||
</div>
|
||||
<div id="calInkLimitControls"></div>
|
||||
<p class="help-hint">Recommended power (targen -p): <strong id="calRecommendedPower">—</strong>. Re-run Compute Curves after editing limits.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-actions">
|
||||
<button type="button" class="secondary btn-md" id="btnCalBackToWizard">← Back to profiling wizard</button>
|
||||
</div>
|
||||
<details class="log-container hidden" id="calLogContainer">
|
||||
<summary>Process Output</summary>
|
||||
<pre id="calLog"></pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- Stage 1: targen -->
|
||||
<section id="stage-1" class="stage active">
|
||||
<h2 style="display:flex; justify-content:space-between; align-items:center;">
|
||||
Define Target
|
||||
<button type="button" id="btnToggleAllHelp" class="secondary btn-sm">Toggle Help Mode</button>
|
||||
</h2>
|
||||
<div id="calStage1Recommend" class="cal-status-banner cal-banner-stale hidden">
|
||||
<span>No calibration applied — recommended for CMYK / RIP workflows.</span>
|
||||
<button type="button" class="secondary btn-sm" id="btnCalRecalibrate">Calibrate Printer</button>
|
||||
</div>
|
||||
<div class="cal-status-banner hidden" data-cal-banner>
|
||||
<span data-cal-banner-text>Calibration: None</span>
|
||||
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
|
||||
</div>
|
||||
|
||||
<div class="form-container" id="stage1FormContainer">
|
||||
<!-- Basic Settings -->
|
||||
@@ -319,6 +421,10 @@
|
||||
<!-- Stage 2: printtarg -->
|
||||
<section id="stage-2" class="stage hidden">
|
||||
<h2>Print Layout</h2>
|
||||
<div class="cal-status-banner hidden" data-cal-banner>
|
||||
<span data-cal-banner-text>Calibration: None</span>
|
||||
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
|
||||
</div>
|
||||
<p>Configure the target layout for your instrument and paper size, then generate printable TIFF images.</p>
|
||||
|
||||
<!-- Colour management warning banner -->
|
||||
@@ -559,6 +665,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- XY Scanning Table Panel -->
|
||||
<div id="xyTableHint" class="notification-banner info hidden">
|
||||
<span>ℹ️</span>
|
||||
<span>SpectroScan → use the SS layout in Stage 2; i1iO → use i1/i1Pro2 layout — the table is auto-detected when chartread starts.</span>
|
||||
</div>
|
||||
|
||||
<div id="xyTablePanel" class="xy-table-panel hidden">
|
||||
<div class="xy-table-header">
|
||||
<h4>XY Automated Scanning Table Sequence</h4>
|
||||
<span id="xyTableActiveStepBadge" class="status-badge badge-idle">Standby</span>
|
||||
</div>
|
||||
<ol class="xy-steps-list">
|
||||
<li id="xyStepPlace" class="xy-step">Place sheet on table</li>
|
||||
<li id="xyStepAlign" class="xy-step">Align reference patches with sight</li>
|
||||
<li id="xyStepScan" class="xy-step">Automated scan in progress</li>
|
||||
<li id="xyStepRemove" class="xy-step">Remove sheet from table</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- State & Prompt Area -->
|
||||
<div class="chartread-status">
|
||||
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
|
||||
@@ -615,6 +740,10 @@
|
||||
<!-- Stage 4: colprof -->
|
||||
<section id="stage-4" class="stage hidden">
|
||||
<h2>Create Profile</h2>
|
||||
<div class="cal-status-banner hidden" data-cal-banner>
|
||||
<span data-cal-banner-text>Calibration: None</span>
|
||||
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
|
||||
</div>
|
||||
<p>Calculate the ICC profile from your target measurement data.</p>
|
||||
|
||||
<div class="form-container">
|
||||
@@ -771,10 +900,15 @@
|
||||
<!-- Stage 5: profcheck -->
|
||||
<section id="stage-5" class="stage hidden">
|
||||
<h2>Verify Profile</h2>
|
||||
<div class="cal-status-banner hidden" data-cal-banner>
|
||||
<span data-cal-banner-text>Calibration: None</span>
|
||||
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
|
||||
</div>
|
||||
<p>Check the numerical accuracy of your profile against the original measurement data.</p>
|
||||
|
||||
<div class="stage-actions">
|
||||
<button class="primary btn-lg" id="btnVerify">Verify Profile Accuracy</button>
|
||||
<button class="secondary btn-lg" id="btnInstallProfile" disabled title="Copies the generated ICC/ICM profile into the operating system’s standard colour-profile directory so that print dialogs and colour-managed applications can discover it. Requires elevated privileges on some platforms.">Install Profile to System</button>
|
||||
</div>
|
||||
|
||||
<!-- Report Card -->
|
||||
@@ -804,6 +938,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification History & Drift Tracking -->
|
||||
<details class="log-container drift-history" id="driftHistorySection">
|
||||
<summary>Verification History & Drift Tracking</summary>
|
||||
<div class="notification-banner warning hidden" id="driftAlertCard">
|
||||
<span id="driftAlertIcon">⚠️</span><span id="driftAlertText"></span>
|
||||
<button type="button" class="secondary btn-sm" id="btnDriftRecalibrate">Re-calibrate</button>
|
||||
</div>
|
||||
<div class="drift-filter-row hidden" id="driftFilterRow">
|
||||
<label for="driftPrinterFilter">Filter by Printer:</label>
|
||||
<select id="driftPrinterFilter">
|
||||
<option value="">All Printers</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="driftChartWrap" class="drift-chart-wrap">
|
||||
<svg id="driftTrendChart" role="img" aria-label="Printer Drift Verification History"></svg>
|
||||
</div>
|
||||
<p id="driftEmptyState" class="help-hint">No verification history yet for this profile.</p>
|
||||
<div class="drift-table-wrap">
|
||||
<table id="verificationHistoryTable" class="drift-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Printer</th>
|
||||
<th>Avg ΔE₀₀</th>
|
||||
<th>Peak ΔE₀₀</th>
|
||||
<th>RMS ΔE₀₀</th>
|
||||
<th>Patches</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="verificationHistoryTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="button" class="secondary" id="btnExportHistoryCsv" disabled>Export CSV…</button>
|
||||
<button type="button" class="danger" id="btnClearHistory" disabled>Clear History</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 3D Gamut Viewer -->
|
||||
<div class="gamut-viewer-section">
|
||||
<div class="gamut-viewer-header">
|
||||
@@ -914,6 +1087,30 @@
|
||||
<small id="deltaEThresholdError" class="help-hint" style="display:none; color: var(--error-color, #ff5f5f); margin-top: 6px;">Good threshold must be less than warning threshold.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||
<label style="font-weight: 600;">Printer Calibration</label>
|
||||
<div style="margin-top: 8px;">
|
||||
<label for="calibrationStaleDays" class="sub-label">Warn when loaded .cal is older than (days)</label>
|
||||
<input type="number" id="calibrationStaleDays" min="1" max="365" value="30">
|
||||
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Also warns when the stored printer name no longer matches the current destination. Default 30 days.</small>
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<label for="defaultInstallLocation" class="sub-label">Default ICC install location</label>
|
||||
<select id="defaultInstallLocation">
|
||||
<option value="user" selected>User (no elevation)</option>
|
||||
<option value="system">System-wide (may require admin)</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="checkbox-label" for="askBeforeOverwriteProfile" style="display:flex; align-items:center; gap:8px; margin-top:10px; cursor:pointer;">
|
||||
<input type="checkbox" id="askBeforeOverwriteProfile" checked>
|
||||
<span>Ask before overwriting an existing system profile</span>
|
||||
</label>
|
||||
<label class="checkbox-label" for="openColorPanelAfterInstall" style="display:flex; align-items:center; gap:8px; margin-top:8px; cursor:pointer;">
|
||||
<input type="checkbox" id="openColorPanelAfterInstall">
|
||||
<span>Open the system Colour Management panel after install</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||
<label style="font-weight: 600;">Diagnostics & Logging</label>
|
||||
<div class="input-row" style="margin-top: 8px;">
|
||||
@@ -943,6 +1140,32 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="calCollisionDialog" class="settings-modal">
|
||||
<div class="modal-content">
|
||||
<h2>Calibration file exists</h2>
|
||||
<p id="calCollisionMessage" style="white-space:pre-wrap;"></p>
|
||||
<p class="help-hint">ICCery never overwrites a .cal without an explicit choice.</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="calOverwriteBtn" class="danger">Overwrite</button>
|
||||
<button type="button" id="calRenameBtn" class="primary">Rename (timestamp)</button>
|
||||
<button type="button" id="calCancelCollisionBtn" class="secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="profileInstallCollisionDialog" class="settings-modal">
|
||||
<div class="modal-content">
|
||||
<h2>Profile already installed</h2>
|
||||
<p id="profileInstallCollisionMessage" style="white-space:pre-wrap;"></p>
|
||||
<p class="help-hint">The working-directory copy is never moved. Choose how to handle the existing system profile.</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="profileOverwriteBtn" class="danger">Overwrite</button>
|
||||
<button type="button" id="profileRenameBtn" class="primary">Rename (timestamp)</button>
|
||||
<button type="button" id="profileCancelCollisionBtn" class="secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- About Dialog -->
|
||||
<dialog id="aboutDialog" class="settings-modal about-modal">
|
||||
<div class="modal-content">
|
||||
@@ -950,7 +1173,7 @@
|
||||
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
|
||||
</div>
|
||||
<h2>About ICCery</h2>
|
||||
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.1</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.5</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
||||
<h3>Licences & EULA</h3>
|
||||
<div class="license-text-container">
|
||||
@@ -1002,9 +1225,9 @@
|
||||
<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>
|
||||
|
||||
<h5>Delaunator</h5>
|
||||
<p>Copyright (c) 2017, Mapbox</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>
|
||||
<h5>quickhull3d</h5>
|
||||
<p>MIT License</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>
|
||||
<p>Copyright (c) 2019-present, Tauri Programme within The Commons Conservancy.</p>
|
||||
|
||||
+60
-3
@@ -4,14 +4,46 @@ import { initChartread } from './chartread.js';
|
||||
import { initColprof } from './colprof.js';
|
||||
import { initProfcheck } from './profcheck.js';
|
||||
import { initSettings } from './settings.js';
|
||||
import { initGamutViewer } from './gamut_viewer.js';
|
||||
import { setGpuHints } from './gamut_viewer.js';
|
||||
import { initPresets } from './presets.js';
|
||||
import { wizardState } from './state.js';
|
||||
import { logger } from './logger.js';
|
||||
import { CgatsInterop } from './cgats_interop.js';
|
||||
import { initCalibration } from './calibration.js';
|
||||
import { initProfileInstall } from './profile_install.js';
|
||||
|
||||
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', () => {
|
||||
// Initialize interoperability handlers
|
||||
new CgatsInterop(wizardState);
|
||||
@@ -39,6 +71,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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
|
||||
wizardState.updateGating();
|
||||
|
||||
@@ -54,6 +93,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const buildDateEl = document.getElementById('aboutBuildDate');
|
||||
if (versionEl && info.version) versionEl.textContent = `v${info.version}`;
|
||||
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) {
|
||||
console.warn('[ICCery] Could not load dynamic app info:', e);
|
||||
}
|
||||
@@ -82,13 +127,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize all stages & features safely
|
||||
// Initialize all stages & features safely.
|
||||
// Gamut Viewer is deferred until Stage 5 is shown (eager WebGL on launch
|
||||
// respawns WKWebView on Monterey Intel).
|
||||
safeInit('Stage 1 (Targen)', initTargen);
|
||||
safeInit('Stage 2 (Printtarg)', initPrinttarg);
|
||||
safeInit('Stage 3 (Chartread)', initChartread);
|
||||
safeInit('Stage 4 (Colprof)', initColprof);
|
||||
safeInit('Stage 5 (Profcheck)', initProfcheck);
|
||||
safeInit('Settings', initSettings);
|
||||
safeInit('Gamut Viewer', initGamutViewer);
|
||||
safeInit('Presets', initPresets);
|
||||
safeInit('Calibration', initCalibration);
|
||||
safeInit('ProfileInstall', initProfileInstall);
|
||||
|
||||
// Double-rAF waits for layout + first paint of the dark CSS.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
revealMainWindow();
|
||||
});
|
||||
});
|
||||
// Fallback so a JS exception cannot leave a permanently hidden window.
|
||||
setTimeout(revealMainWindow, 1500);
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* Printer calibration (linearization & ink limits) via printcal / applycal (#224).
|
||||
*
|
||||
* Optional Stage 0 workflow. The 5-stage wizard is unchanged when calibration
|
||||
* is skipped. Curves feed subsequent printtarg (-K) and colprof (applycal).
|
||||
*/
|
||||
|
||||
import { wizardState } from './state.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const invoke = (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke)
|
||||
? window.__TAURI__.core.invoke.bind(window.__TAURI__.core)
|
||||
: async () => { throw new Error('Tauri invoke unavailable'); };
|
||||
const listen = (window.__TAURI__ && window.__TAURI__.event && window.__TAURI__.event.listen)
|
||||
? window.__TAURI__.event.listen.bind(window.__TAURI__.event)
|
||||
: async () => () => {};
|
||||
|
||||
export const CAL_PREFIX = 'CAL_';
|
||||
export const DEFAULT_STALE_DAYS = 30;
|
||||
export const CHANNEL_COLORS = {
|
||||
C: '#00b4d8',
|
||||
M: '#e63980',
|
||||
Y: '#f4d35e',
|
||||
K: '#c5c5c5',
|
||||
R: '#e74c3c',
|
||||
G: '#2ecc71',
|
||||
B: '#3498db',
|
||||
};
|
||||
|
||||
const calState = {
|
||||
status: 'none', // none | active | stale
|
||||
calPath: null,
|
||||
filename: null,
|
||||
created: null,
|
||||
applyEnabled: true,
|
||||
printerName: null,
|
||||
colourSpace: null,
|
||||
inkLimits: [],
|
||||
totalInkLimit: null,
|
||||
curves: [],
|
||||
calBasename: null,
|
||||
recommendedPower: null,
|
||||
ageDays: 0,
|
||||
staleDays: DEFAULT_STALE_DAYS,
|
||||
};
|
||||
|
||||
export function makeCalibrationBasename(basename) {
|
||||
const trimmed = String(basename || '').trim();
|
||||
const base = trimmed.startsWith(CAL_PREFIX) ? trimmed.slice(CAL_PREFIX.length) : trimmed;
|
||||
return `${CAL_PREFIX}${base || 'printer'}`;
|
||||
}
|
||||
|
||||
export function isCalibrationBasename(basename) {
|
||||
return String(basename || '').trim().startsWith(CAL_PREFIX);
|
||||
}
|
||||
|
||||
export function isCalibrationStale(ageDays, staleDays = DEFAULT_STALE_DAYS) {
|
||||
return Number(ageDays) > Math.max(1, Number(staleDays) || DEFAULT_STALE_DAYS);
|
||||
}
|
||||
|
||||
export function totalAreaCoverage(limits) {
|
||||
if (!Array.isArray(limits) || limits.length === 0) return 0;
|
||||
return limits.reduce((sum, item) => sum + (Number(item.percent) || 0), 0);
|
||||
}
|
||||
|
||||
export function classifyCalibrationStatus({
|
||||
calPath,
|
||||
applyEnabled,
|
||||
ageDays,
|
||||
staleDays,
|
||||
printerName,
|
||||
currentPrinter,
|
||||
} = {}) {
|
||||
if (!calPath) return 'none';
|
||||
if (printerName && currentPrinter && printerName !== currentPrinter) return 'stale';
|
||||
if (isCalibrationStale(ageDays || 0, staleDays)) return 'stale';
|
||||
if (applyEnabled === false) return 'active';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
export function downsampleCurve(points, maxPoints = 48) {
|
||||
if (!Array.isArray(points) || points.length <= maxPoints) return points || [];
|
||||
const out = [];
|
||||
const last = points.length - 1;
|
||||
for (let i = 0; i < maxPoints; i += 1) {
|
||||
const idx = Math.round((i / (maxPoints - 1)) * last);
|
||||
out.push(points[idx]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildCurvePolyline(points, width = 320, height = 160, padding = 18) {
|
||||
const pts = downsampleCurve(points);
|
||||
if (!pts.length) return '';
|
||||
const innerW = width - padding * 2;
|
||||
const innerH = height - padding * 2;
|
||||
return pts.map((p, i) => {
|
||||
const x = padding + (Number(p[0]) || 0) * innerW;
|
||||
const y = padding + innerH - (Number(p[1]) || 0) * innerH;
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
export function getActiveCalibration() {
|
||||
return { ...calState };
|
||||
}
|
||||
|
||||
export function getPrinttargCalibrationFields(basename) {
|
||||
if (isCalibrationBasename(basename)) {
|
||||
return { calibration_file: null, calibration_embed_only: false };
|
||||
}
|
||||
if (!calState.applyEnabled || !calState.calPath) {
|
||||
return { calibration_file: null, calibration_embed_only: false };
|
||||
}
|
||||
return { calibration_file: calState.calPath, calibration_embed_only: false };
|
||||
}
|
||||
|
||||
export async function applyCalibrationToProfile(profilePath) {
|
||||
if (!calState.applyEnabled || !calState.calPath || !profilePath) return null;
|
||||
try {
|
||||
const result = await invoke('apply_calibration', {
|
||||
config: {
|
||||
cal_path: calState.calPath,
|
||||
input_path: profilePath,
|
||||
output_path: null,
|
||||
unapply: false,
|
||||
},
|
||||
});
|
||||
logger.info(`applycal: ${result.message}`, 'Calibration');
|
||||
return result;
|
||||
} catch (err) {
|
||||
logger.error(`applycal failed: ${err}`, 'Calibration');
|
||||
wizardState.showNotice(`Could not embed calibration curves into the profile: ${err}`, 'warning', 7000);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel() {
|
||||
if (calState.status === 'none') return 'Calibration: None';
|
||||
const name = calState.filename || 'curves.cal';
|
||||
if (calState.status === 'stale') return `Calibration: Stale (${name})`;
|
||||
if (!calState.applyEnabled) return `Calibration: Loaded, not applied (${name})`;
|
||||
return `Calibration: Active (${name})`;
|
||||
}
|
||||
|
||||
function persistLocal() {
|
||||
try {
|
||||
localStorage.setItem('iccery.calibration', JSON.stringify({
|
||||
calPath: calState.calPath,
|
||||
applyEnabled: calState.applyEnabled,
|
||||
printerName: calState.printerName,
|
||||
colourSpace: calState.colourSpace,
|
||||
created: calState.created,
|
||||
calBasename: calState.calBasename,
|
||||
}));
|
||||
} catch (_) { /* private mode */ }
|
||||
}
|
||||
|
||||
function restoreLocal() {
|
||||
try {
|
||||
const raw = localStorage.getItem('iccery.calibration');
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.calPath) {
|
||||
calState.calPath = parsed.calPath;
|
||||
calState.applyEnabled = parsed.applyEnabled !== false;
|
||||
calState.printerName = parsed.printerName || null;
|
||||
calState.colourSpace = parsed.colourSpace || null;
|
||||
calState.created = parsed.created || null;
|
||||
calState.calBasename = parsed.calBasename || makeCalibrationBasename(wizardState.basename);
|
||||
calState.filename = String(parsed.calPath).split(/[\\/]/).pop();
|
||||
calState.status = 'active';
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function persistProject() {
|
||||
if (!wizardState.cwd) return;
|
||||
try {
|
||||
await invoke('save_project_calibration', {
|
||||
cwd: wizardState.cwd,
|
||||
state: {
|
||||
cal_path: calState.calPath,
|
||||
apply_enabled: calState.applyEnabled,
|
||||
printer_name: calState.printerName,
|
||||
colour_space: calState.colourSpace,
|
||||
created: calState.created,
|
||||
cal_basename: calState.calBasename,
|
||||
ink_limit_overrides: calState.inkLimits,
|
||||
total_ink_override: calState.totalInkLimit,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`Could not persist project calibration: ${err}`, 'Calibration');
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStatusFromMeta(meta, currentPrinter) {
|
||||
if (!meta) {
|
||||
calState.status = calState.calPath ? 'active' : 'none';
|
||||
return;
|
||||
}
|
||||
calState.filename = meta.filename;
|
||||
calState.created = meta.created;
|
||||
calState.ageDays = meta.age_days || 0;
|
||||
calState.curves = meta.curves || [];
|
||||
if (meta.ink_limits && meta.ink_limits.length) calState.inkLimits = meta.ink_limits;
|
||||
if (meta.total_ink_limit != null) calState.totalInkLimit = meta.total_ink_limit;
|
||||
calState.status = classifyCalibrationStatus({
|
||||
calPath: calState.calPath,
|
||||
applyEnabled: calState.applyEnabled,
|
||||
ageDays: calState.ageDays,
|
||||
staleDays: calState.staleDays,
|
||||
printerName: calState.printerName,
|
||||
currentPrinter,
|
||||
});
|
||||
}
|
||||
|
||||
function renderBanners() {
|
||||
const text = statusLabel();
|
||||
document.querySelectorAll('[data-cal-banner-text]').forEach((el) => {
|
||||
el.textContent = text;
|
||||
});
|
||||
document.querySelectorAll('[data-cal-banner]').forEach((el) => {
|
||||
el.classList.toggle('hidden', calState.status === 'none' && el.dataset.calBanner !== 'always');
|
||||
el.classList.toggle('cal-banner-stale', calState.status === 'stale');
|
||||
el.classList.toggle('cal-banner-active', calState.status === 'active');
|
||||
});
|
||||
document.querySelectorAll('[data-cal-apply]').forEach((el) => {
|
||||
el.checked = !!calState.applyEnabled && !!calState.calPath;
|
||||
el.disabled = !calState.calPath;
|
||||
});
|
||||
const chip = document.getElementById('calStatusChip');
|
||||
if (chip) {
|
||||
chip.textContent = text;
|
||||
chip.className = `cal-status-chip cal-status-${calState.status}`;
|
||||
}
|
||||
const rgbHint = document.getElementById('calRgbHint');
|
||||
if (rgbHint) {
|
||||
const cs = (document.querySelector('input[name="calColourSpace"]:checked') || {}).value
|
||||
|| (document.querySelector('input[name="colourSpace"]:checked') || {}).value
|
||||
|| 'rgb';
|
||||
rgbHint.classList.toggle('hidden', cs !== 'rgb');
|
||||
}
|
||||
const stage1Banner = document.getElementById('calStage1Recommend');
|
||||
if (stage1Banner) {
|
||||
const cs = (document.querySelector('input[name="colourSpace"]:checked') || {}).value || 'rgb';
|
||||
const show = calState.status === 'none' && cs === 'cmyk';
|
||||
stage1Banner.classList.toggle('hidden', !show);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlots() {
|
||||
const svg = document.getElementById('calCurveSvg');
|
||||
const legend = document.getElementById('calCurveLegend');
|
||||
if (!svg) return;
|
||||
const width = 360;
|
||||
const height = 180;
|
||||
const padding = 22;
|
||||
const grid = [0, 0.25, 0.5, 0.75, 1].map((t) => {
|
||||
const x = padding + t * (width - padding * 2);
|
||||
const y = padding + (1 - t) * (height - padding * 2);
|
||||
return `<line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" class="cal-grid"/>`
|
||||
+ `<line x1="${x}" y1="${padding}" x2="${x}" y2="${height - padding}" class="cal-grid"/>`;
|
||||
}).join('');
|
||||
const identity = buildCurvePolyline([[0, 0], [1, 1]], width, height, padding);
|
||||
const paths = (calState.curves || []).map((curve) => {
|
||||
const d = buildCurvePolyline(curve.points, width, height, padding);
|
||||
const color = CHANNEL_COLORS[curve.channel] || '#7aa2f7';
|
||||
return `<path d="${d}" fill="none" stroke="${color}" stroke-width="2"/>`;
|
||||
}).join('');
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
svg.innerHTML = `${grid}<path d="${identity}" fill="none" stroke="#555" stroke-dasharray="4 3" stroke-width="1"/>${paths}`
|
||||
+ `<text x="${padding}" y="${height - 4}" class="cal-axis-label">Input</text>`
|
||||
+ `<text x="4" y="${padding}" class="cal-axis-label">Out</text>`;
|
||||
if (legend) {
|
||||
legend.innerHTML = (calState.curves || []).map((c) => {
|
||||
const color = CHANNEL_COLORS[c.channel] || '#7aa2f7';
|
||||
return `<span class="cal-legend-item"><i style="background:${color}"></i>${c.channel}</span>`;
|
||||
}).join('') || '<span class="help-hint">No curves yet — compute after measuring the calibration chart.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderInkLimits() {
|
||||
const wrap = document.getElementById('calInkLimitControls');
|
||||
const tacEl = document.getElementById('calTacValue');
|
||||
if (tacEl) {
|
||||
const tac = calState.totalInkLimit != null ? calState.totalInkLimit : totalAreaCoverage(calState.inkLimits);
|
||||
tacEl.textContent = tac ? `${tac.toFixed(0)} %` : '—';
|
||||
}
|
||||
if (!wrap) return;
|
||||
if (!calState.inkLimits.length) {
|
||||
wrap.innerHTML = '<p class="help-hint">Ink-limit recommendations appear after printcal runs. Editable overrides can be re-computed.</p>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = calState.inkLimits.map((lim) => {
|
||||
const color = CHANNEL_COLORS[lim.channel] || '#888';
|
||||
return `<label class="cal-ink-row"><span style="color:${color}">${lim.channel}</span>`
|
||||
+ `<input type="range" min="50" max="100" step="0.5" value="${lim.percent}" data-cal-ink="${lim.channel}">`
|
||||
+ `<input type="number" min="50" max="100" step="0.5" value="${Number(lim.percent).toFixed(1)}" data-cal-ink-num="${lim.channel}">`
|
||||
+ `<span>%</span></label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderDashboardMeta() {
|
||||
const fileEl = document.getElementById('calCurrentFile');
|
||||
if (fileEl) fileEl.textContent = calState.filename || 'No .cal loaded';
|
||||
const powerEl = document.getElementById('calRecommendedPower');
|
||||
if (powerEl) powerEl.textContent = calState.recommendedPower != null ? calState.recommendedPower.toFixed(2) : '—';
|
||||
renderBanners();
|
||||
renderPlots();
|
||||
renderInkLimits();
|
||||
}
|
||||
|
||||
async function loadCalPath(path) {
|
||||
const meta = await invoke('parse_cal_file_cmd', { path });
|
||||
calState.calPath = path;
|
||||
calState.applyEnabled = true;
|
||||
refreshStatusFromMeta(meta, wizardState.printerName);
|
||||
if (!calState.calBasename) {
|
||||
calState.calBasename = makeCalibrationBasename(wizardState.basename || 'printer');
|
||||
}
|
||||
persistLocal();
|
||||
await persistProject();
|
||||
renderDashboardMeta();
|
||||
wizardState.showNotice(`Loaded calibration ${meta.filename}`, 'success', 4000);
|
||||
}
|
||||
|
||||
async function enterCalibrationSession() {
|
||||
const calBase = calState.calBasename || makeCalibrationBasename(wizardState.basename || 'printer');
|
||||
calState.calBasename = calBase;
|
||||
if (wizardState.basename && !isCalibrationBasename(wizardState.basename)) {
|
||||
wizardState.profileBasename = wizardState.basename;
|
||||
}
|
||||
wizardState.sessionMode = 'calibration';
|
||||
wizardState.basename = calBase;
|
||||
wizardState.setTarget(calBase, wizardState.cwd);
|
||||
const { setStage1Result } = await import('./printtarg.js');
|
||||
const { setStage2Result } = await import('./chartread.js');
|
||||
setStage1Result(calBase, wizardState.cwd);
|
||||
setStage2Result(calBase, wizardState.cwd);
|
||||
}
|
||||
|
||||
async function exitCalibrationSession() {
|
||||
wizardState.sessionMode = 'profile';
|
||||
if (wizardState.profileBasename) {
|
||||
wizardState.basename = wizardState.profileBasename;
|
||||
wizardState.setTarget(wizardState.profileBasename, wizardState.cwd);
|
||||
const { setStage1Result } = await import('./printtarg.js');
|
||||
setStage1Result(wizardState.profileBasename, wizardState.cwd);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateTarget() {
|
||||
const cwd = wizardState.cwd;
|
||||
if (!cwd) {
|
||||
wizardState.showNotice('Set a working directory in Stage 1 before generating a calibration chart.', 'warning');
|
||||
return;
|
||||
}
|
||||
const cs = (document.querySelector('input[name="calColourSpace"]:checked') || {}).value
|
||||
|| (document.querySelector('input[name="colourSpace"]:checked') || {}).value
|
||||
|| 'rgb';
|
||||
const steps = Math.max(11, Math.min(51, parseInt(document.getElementById('calSteps')?.value, 10) || 21));
|
||||
const ink = parseInt(document.getElementById('calInkExplore')?.value, 10);
|
||||
const basename = makeCalibrationBasename(wizardState.profileBasename || wizardState.basename || 'printer');
|
||||
calState.calBasename = basename;
|
||||
calState.colourSpace = cs;
|
||||
const btn = document.getElementById('btnCalGenerate');
|
||||
const logPre = document.getElementById('calLog');
|
||||
const logBox = document.getElementById('calLogContainer');
|
||||
if (logBox) logBox.classList.remove('hidden');
|
||||
if (btn) btn.disabled = true;
|
||||
const processId = `targen_${basename}`;
|
||||
if (logPre) logPre.textContent = 'Starting targen (calibration chart)...\n';
|
||||
try {
|
||||
const unlistenStdout = await listen('process:stdout', (event) => {
|
||||
if (event.payload.id === processId && event.payload.line && logPre) {
|
||||
logPre.textContent += `${event.payload.line}\n`;
|
||||
}
|
||||
});
|
||||
const unlistenStderr = await listen('process:stderr', (event) => {
|
||||
if (event.payload.id === processId && event.payload.line && logPre) {
|
||||
logPre.textContent += `ERR: ${event.payload.line}\n`;
|
||||
}
|
||||
});
|
||||
const unlistenExit = await listen('process:exit', (event) => {
|
||||
if (event.payload.id !== processId) return;
|
||||
unlistenStdout();
|
||||
unlistenStderr();
|
||||
unlistenExit();
|
||||
if (btn) btn.disabled = false;
|
||||
if (event.payload.code === 0) {
|
||||
if (logPre) logPre.textContent += '\n[SUCCESS] Calibration .ti1 generated.\n';
|
||||
wizardState.showNotice(`Calibration chart ${basename}.ti1 is ready. Create a layout and print it uncalibrated.`, 'success', 6000);
|
||||
} else if (logPre) {
|
||||
logPre.textContent += `\n[ERROR] targen exited with code ${event.payload.code}.\n`;
|
||||
}
|
||||
});
|
||||
await invoke('generate_calibration_target', {
|
||||
config: {
|
||||
colour_space: cs,
|
||||
steps_per_channel: steps,
|
||||
ink_limit_exploration: Number.isFinite(ink) ? ink : null,
|
||||
channels: null,
|
||||
white_patches: 4,
|
||||
neutral_emphasis: !!(document.getElementById('calNeutralEmphasis') || {}).checked,
|
||||
basename,
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (btn) btn.disabled = false;
|
||||
logger.error(`generate_calibration_target failed: ${err}`, 'Calibration');
|
||||
wizardState.showNotice(`Could not generate calibration chart: ${err}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function collisionChoice(existingPath) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.getElementById('calCollisionDialog');
|
||||
const msg = document.getElementById('calCollisionMessage');
|
||||
if (msg) msg.textContent = `A calibration file already exists:\n${existingPath}`;
|
||||
if (!dialog || typeof dialog.showModal !== 'function') {
|
||||
const ok = window.confirm(`Overwrite existing calibration ${existingPath}?`);
|
||||
resolve(ok ? 'overwrite' : 'cancel');
|
||||
return;
|
||||
}
|
||||
const finish = (choice) => {
|
||||
dialog.close();
|
||||
resolve(choice);
|
||||
};
|
||||
document.getElementById('calOverwriteBtn')?.addEventListener('click', () => finish('overwrite'), { once: true });
|
||||
document.getElementById('calRenameBtn')?.addEventListener('click', () => finish('rename'), { once: true });
|
||||
document.getElementById('calCancelCollisionBtn')?.addEventListener('click', () => finish('cancel'), { once: true });
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
async function computeCurves(forceOverwrite = false, outputName = null) {
|
||||
const cwd = wizardState.cwd;
|
||||
const basename = calState.calBasename || makeCalibrationBasename(wizardState.basename || 'printer');
|
||||
if (!cwd) {
|
||||
wizardState.showNotice('Working directory is not set.', 'warning');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('btnCalCompute');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Computing…';
|
||||
}
|
||||
const channelLimits = [];
|
||||
document.querySelectorAll('[data-cal-ink-num]').forEach((el) => {
|
||||
channelLimits.push({ channel: el.getAttribute('data-cal-ink-num'), percent: parseFloat(el.value) });
|
||||
});
|
||||
const tac = parseFloat(document.getElementById('calTacOverride')?.value);
|
||||
try {
|
||||
const result = await invoke('compute_calibration_curves', {
|
||||
config: {
|
||||
ti3_basename: basename,
|
||||
cwd,
|
||||
output_cal: outputName,
|
||||
previous_cal: calState.calPath,
|
||||
force_overwrite: forceOverwrite,
|
||||
no_ink_limit: false,
|
||||
verify: false,
|
||||
total_ink_limit: Number.isFinite(tac) ? tac : null,
|
||||
channel_limits: channelLimits,
|
||||
},
|
||||
});
|
||||
calState.calPath = result.cal_path;
|
||||
calState.filename = String(result.cal_path).split(/[\\/]/).pop();
|
||||
calState.inkLimits = result.ink_limits || [];
|
||||
calState.totalInkLimit = result.total_ink_limit;
|
||||
calState.recommendedPower = result.recommended_power;
|
||||
calState.applyEnabled = true;
|
||||
calState.printerName = wizardState.printerName || calState.printerName;
|
||||
calState.created = new Date().toISOString();
|
||||
refreshStatusFromMeta(result.metadata, wizardState.printerName);
|
||||
persistLocal();
|
||||
await persistProject();
|
||||
renderDashboardMeta();
|
||||
wizardState.showNotice(result.message, 'success', 6000);
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
if (/already exists/i.test(message) && !forceOverwrite) {
|
||||
const choice = await collisionChoice(message);
|
||||
if (choice === 'overwrite') {
|
||||
await computeCurves(true, outputName);
|
||||
} else if (choice === 'rename') {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
await computeCurves(true, `${basename}_${stamp}.cal`);
|
||||
}
|
||||
} else {
|
||||
wizardState.showNotice(`printcal failed: ${err}`, 'error', 8000);
|
||||
}
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Compute Curves';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSavedList() {
|
||||
const select = document.getElementById('calSavedSelect');
|
||||
if (!select) return;
|
||||
try {
|
||||
const list = await invoke('list_saved_calibrations', { cwd: wizardState.cwd || null });
|
||||
const current = select.value;
|
||||
select.innerHTML = '<option value="">Recent calibrations…</option>';
|
||||
list.forEach((item) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.path;
|
||||
const stale = isCalibrationStale(item.age_days, calState.staleDays) ? ' (stale)' : '';
|
||||
opt.textContent = `${item.filename}${stale}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (current) select.value = current;
|
||||
} catch (err) {
|
||||
logger.warn(`list_saved_calibrations: ${err}`, 'Calibration');
|
||||
}
|
||||
}
|
||||
|
||||
export function initCalibration() {
|
||||
restoreLocal();
|
||||
renderDashboardMeta();
|
||||
|
||||
const openBtn = document.getElementById('btnCalibratePrinter');
|
||||
if (openBtn) {
|
||||
openBtn.addEventListener('click', () => {
|
||||
wizardState.navigateToStage(0);
|
||||
refreshSavedList();
|
||||
});
|
||||
}
|
||||
document.getElementById('btnCalBackToWizard')?.addEventListener('click', () => {
|
||||
exitCalibrationSession();
|
||||
wizardState.navigateToStage(1);
|
||||
});
|
||||
document.getElementById('btnCalGenerate')?.addEventListener('click', generateTarget);
|
||||
document.getElementById('btnCalLayout')?.addEventListener('click', async () => {
|
||||
await enterCalibrationSession();
|
||||
wizardState.showNotice('Printing a calibration chart: color management stays bypassed and curves are not applied to this target.', 'info', 7000);
|
||||
wizardState.navigateToStage(2);
|
||||
});
|
||||
document.getElementById('btnCalMeasure')?.addEventListener('click', async () => {
|
||||
await enterCalibrationSession();
|
||||
wizardState.showNotice('Measuring the calibration chart. After Finish, return here and compute curves.', 'info', 7000);
|
||||
wizardState.navigateToStage(3);
|
||||
});
|
||||
document.getElementById('btnCalCompute')?.addEventListener('click', () => computeCurves(false, null));
|
||||
document.getElementById('btnCalLoad')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const picked = await invoke('select_cal_file', { defaultDir: wizardState.cwd || null });
|
||||
if (picked) await loadCalPath(picked);
|
||||
} catch (err) {
|
||||
wizardState.showNotice(`Could not open .cal file: ${err}`, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('btnCalClear')?.addEventListener('click', async () => {
|
||||
calState.status = 'none';
|
||||
calState.calPath = null;
|
||||
calState.filename = null;
|
||||
calState.curves = [];
|
||||
calState.inkLimits = [];
|
||||
calState.totalInkLimit = null;
|
||||
persistLocal();
|
||||
await persistProject();
|
||||
renderDashboardMeta();
|
||||
wizardState.showNotice('Calibration cleared. Profiling will run without printcal curves.', 'info');
|
||||
});
|
||||
document.getElementById('btnCalLibrary')?.addEventListener('click', async () => {
|
||||
if (!calState.calPath) return;
|
||||
try {
|
||||
const dest = await invoke('save_calibration_to_library', { calPath: calState.calPath });
|
||||
wizardState.showNotice(`Copied to library: ${dest}`, 'success');
|
||||
refreshSavedList();
|
||||
} catch (err) {
|
||||
wizardState.showNotice(`Library save failed: ${err}`, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('calSavedSelect')?.addEventListener('change', async (ev) => {
|
||||
if (ev.target.value) await loadCalPath(ev.target.value);
|
||||
});
|
||||
document.getElementById('calApplyToggleDash')?.addEventListener('change', async (ev) => {
|
||||
calState.applyEnabled = !!ev.target.checked;
|
||||
persistLocal();
|
||||
await persistProject();
|
||||
renderBanners();
|
||||
});
|
||||
document.querySelectorAll('[data-cal-apply]').forEach((el) => {
|
||||
el.addEventListener('change', async (ev) => {
|
||||
calState.applyEnabled = !!ev.target.checked;
|
||||
persistLocal();
|
||||
await persistProject();
|
||||
renderBanners();
|
||||
});
|
||||
});
|
||||
document.getElementById('btnCalRecalibrate')?.addEventListener('click', () => {
|
||||
wizardState.navigateToStage(0);
|
||||
});
|
||||
document.getElementById('btnDriftRecalibrate')?.addEventListener('click', () => {
|
||||
wizardState.navigateToStage(0);
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="calColourSpace"]').forEach((el) => {
|
||||
el.addEventListener('change', renderBanners);
|
||||
});
|
||||
document.querySelectorAll('input[name="colourSpace"]').forEach((el) => {
|
||||
el.addEventListener('change', renderBanners);
|
||||
});
|
||||
|
||||
document.getElementById('calInkLimitControls')?.addEventListener('input', (ev) => {
|
||||
const ch = ev.target.getAttribute('data-cal-ink') || ev.target.getAttribute('data-cal-ink-num');
|
||||
if (!ch) return;
|
||||
const val = parseFloat(ev.target.value);
|
||||
const range = document.querySelector(`[data-cal-ink="${ch}"]`);
|
||||
const num = document.querySelector(`[data-cal-ink-num="${ch}"]`);
|
||||
if (range && ev.target !== range) range.value = val;
|
||||
if (num && ev.target !== num) num.value = val;
|
||||
const item = calState.inkLimits.find((l) => l.channel === ch);
|
||||
if (item) item.percent = val;
|
||||
const tacEl = document.getElementById('calTacValue');
|
||||
if (tacEl) tacEl.textContent = `${totalAreaCoverage(calState.inkLimits).toFixed(0)} %`;
|
||||
});
|
||||
|
||||
window.addEventListener('stage-changed', (event) => {
|
||||
if (event.detail && event.detail.stage !== 0 && event.detail.stage !== 2 && event.detail.stage !== 3) {
|
||||
if (wizardState.sessionMode === 'calibration') {
|
||||
exitCalibrationSession();
|
||||
}
|
||||
}
|
||||
renderBanners();
|
||||
});
|
||||
|
||||
window.addEventListener('settings-saved', (event) => {
|
||||
const days = event.detail && event.detail.calibration_stale_days;
|
||||
if (days) calState.staleDays = days;
|
||||
refreshStatusFromMeta({
|
||||
filename: calState.filename,
|
||||
created: calState.created,
|
||||
age_days: calState.ageDays,
|
||||
curves: calState.curves,
|
||||
ink_limits: calState.inkLimits,
|
||||
total_ink_limit: calState.totalInkLimit,
|
||||
}, wizardState.printerName);
|
||||
renderBanners();
|
||||
});
|
||||
|
||||
if (wizardState.cwd) {
|
||||
invoke('load_project_calibration', { cwd: wizardState.cwd }).then(async (state) => {
|
||||
if (state && state.cal_path) {
|
||||
try { await loadCalPath(state.cal_path); } catch (_) { /* missing file */ }
|
||||
calState.applyEnabled = state.apply_enabled !== false;
|
||||
renderBanners();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Unit tests for calibration helpers (#224).
|
||||
// node src/js/calibration.test.js
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
globalThis.window = {
|
||||
__TAURI__: {
|
||||
core: { invoke: () => Promise.resolve() },
|
||||
event: { listen: () => Promise.resolve(() => {}) }
|
||||
},
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
devicePixelRatio: 1
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById: () => null,
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
createElement: () => ({
|
||||
style: {},
|
||||
appendChild() {},
|
||||
addEventListener() {},
|
||||
textContent: '',
|
||||
className: '',
|
||||
setAttribute() {}
|
||||
})
|
||||
};
|
||||
globalThis.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
makeCalibrationBasename,
|
||||
isCalibrationBasename,
|
||||
isCalibrationStale,
|
||||
totalAreaCoverage,
|
||||
classifyCalibrationStatus,
|
||||
downsampleCurve,
|
||||
buildCurvePolyline,
|
||||
getPrinttargCalibrationFields,
|
||||
CAL_PREFIX,
|
||||
} = await import('./calibration.js');
|
||||
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
|
||||
function assert(cond, name) {
|
||||
total += 1;
|
||||
if (cond) {
|
||||
passed += 1;
|
||||
console.log(` ok ${name}`);
|
||||
} else {
|
||||
console.error(` FAIL ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function runAll() {
|
||||
passed = 0;
|
||||
total = 0;
|
||||
console.log('calibration.test.js');
|
||||
|
||||
assert(makeCalibrationBasename('photo') === 'CAL_photo', 'prefix profile basename');
|
||||
assert(makeCalibrationBasename('CAL_photo') === 'CAL_photo', 'do not double-prefix');
|
||||
assert(makeCalibrationBasename('') === 'CAL_printer', 'empty falls back to CAL_printer');
|
||||
assert(isCalibrationBasename('CAL_x') === true, 'detects CAL_ prefix');
|
||||
assert(isCalibrationBasename('photo') === false, 'profile basename is not cal');
|
||||
assert(CAL_PREFIX === 'CAL_', 'prefix constant');
|
||||
|
||||
assert(isCalibrationStale(10, 30) === false, 'fresh cal is not stale');
|
||||
assert(isCalibrationStale(30, 30) === false, 'equal to threshold is not stale');
|
||||
assert(isCalibrationStale(31, 30) === true, 'older than threshold is stale');
|
||||
|
||||
assert(totalAreaCoverage([]) === 0, 'empty TAC');
|
||||
assert(totalAreaCoverage([{ percent: 90 }, { percent: 80 }, { percent: 70 }, { percent: 60 }]) === 300, 'sum TAC');
|
||||
|
||||
assert(classifyCalibrationStatus({}) === 'none', 'no path => none');
|
||||
assert(classifyCalibrationStatus({ calPath: '/a.cal', applyEnabled: true, ageDays: 2, staleDays: 30 }) === 'active', 'fresh active');
|
||||
assert(classifyCalibrationStatus({ calPath: '/a.cal', ageDays: 40, staleDays: 30 }) === 'stale', 'age stale');
|
||||
assert(
|
||||
classifyCalibrationStatus({
|
||||
calPath: '/a.cal',
|
||||
ageDays: 1,
|
||||
printerName: 'Epson',
|
||||
currentPrinter: 'Canon',
|
||||
}) === 'stale',
|
||||
'printer mismatch is stale'
|
||||
);
|
||||
|
||||
const long = Array.from({ length: 256 }, (_, i) => [i / 255, i / 255]);
|
||||
const ds = downsampleCurve(long, 48);
|
||||
assert(ds.length === 48, 'downsample length');
|
||||
assert(ds[0][0] === 0, 'downsample starts at 0');
|
||||
assert(Math.abs(ds[ds.length - 1][0] - 1) < 1e-9, 'downsample ends at 1');
|
||||
|
||||
const poly = buildCurvePolyline([[0, 0], [1, 1]], 100, 100, 10);
|
||||
assert(poly.startsWith('M'), 'polyline starts with move');
|
||||
assert(poly.includes('L'), 'polyline has line');
|
||||
|
||||
const skipped = getPrinttargCalibrationFields('CAL_photo');
|
||||
assert(skipped.calibration_file == null, 'calibration charts do not apply -K to themselves');
|
||||
|
||||
console.log(`\n${passed}/${total} passed`);
|
||||
if (passed !== total) process.exitCode = 1;
|
||||
return { passed, total };
|
||||
}
|
||||
|
||||
runAll();
|
||||
+38
-9
@@ -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 {
|
||||
constructor(appState) {
|
||||
@@ -22,26 +24,53 @@ export class CgatsInterop {
|
||||
|
||||
async handleImport() {
|
||||
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
|
||||
|
||||
// Inspect first to show modal (optional, skipping for now, directly import)
|
||||
// Since we are mocking the UI a bit for this branch, we will just import directly
|
||||
const isWindows = filePath.includes('\\');
|
||||
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', {
|
||||
filePath,
|
||||
targetCwd: this.appState.cwd,
|
||||
targetBasename: this.appState.basename,
|
||||
targetCwd,
|
||||
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
|
||||
if (this.appState?.updateGating) {
|
||||
await this.appState.updateGating();
|
||||
}
|
||||
if (this.appState) {
|
||||
this.appState.currentStage = 4;
|
||||
this.appState.applyStageDOM(4);
|
||||
this.appState.applyStageDOM?.(4);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
this.appState.showNotice(`Failed to import dataset: ${e}`, 'error');
|
||||
this.appState?.showNotice?.(`Failed to import dataset: ${e}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+423
-50
@@ -45,7 +45,7 @@ export function populateStage3TargetContext(metadata) {
|
||||
}
|
||||
|
||||
// State machine states
|
||||
const STATE = {
|
||||
export const STATE = {
|
||||
IDLE: "IDLE",
|
||||
CALIBRATING: "CALIBRATING",
|
||||
AWAITING_STRIP: "AWAITING_STRIP",
|
||||
@@ -53,13 +53,242 @@ const STATE = {
|
||||
ALL_STRIPS_READ: "ALL_STRIPS_READ",
|
||||
WARNING: "WARNING",
|
||||
PROMPT_CONTINUE: "PROMPT_CONTINUE",
|
||||
TABLE_PLACE_SHEET: "TABLE_PLACE_SHEET",
|
||||
TABLE_ALIGN: "TABLE_ALIGN",
|
||||
ERROR: "ERROR",
|
||||
FINISHED: "FINISHED",
|
||||
};
|
||||
|
||||
/**
|
||||
* Classifies a line of stdout from chartread into a state transition, prompt, and metadata.
|
||||
* Pure function with no side-effects or DOM interaction.
|
||||
*
|
||||
* @param {string} line - Raw stdout line
|
||||
* @param {string} currentState - The current STATE value
|
||||
* @returns {{ state: string, prompt: string, matched: boolean, meta?: object }}
|
||||
*/
|
||||
export function classifyChartreadLine(line, currentState = STATE.IDLE) {
|
||||
if (typeof line !== "string") {
|
||||
return { state: currentState, prompt: "", matched: false };
|
||||
}
|
||||
|
||||
const lineTrim = line.trim();
|
||||
const lineLower = lineTrim.toLowerCase();
|
||||
|
||||
if (!lineTrim) {
|
||||
return { state: currentState, prompt: "", matched: false };
|
||||
}
|
||||
|
||||
// 1. Info-only: Remove last sheet notice (emitted by Argyll before writing .ti3 and exiting)
|
||||
if (lineLower.includes("remove last sheet from table") || lineLower.includes("remove last sheet")) {
|
||||
return {
|
||||
state: currentState,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
meta: { isRemoveSheetNotice: true },
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Info-only: Sheet read OK
|
||||
const sheetOkMatch = lineTrim.match(/sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok/i);
|
||||
if (sheetOkMatch) {
|
||||
return {
|
||||
state: currentState,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
meta: {
|
||||
sheetOk: true,
|
||||
sheet: parseInt(sheetOkMatch[1], 10),
|
||||
totalSheets: parseInt(sheetOkMatch[2], 10),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Fiducial alignment prompt (XY table)
|
||||
// e.g. "locate patch A1 with the sight," or "locate patch 1 with the sight"
|
||||
const alignMatch = lineTrim.match(/locate\s+patch\s+([A-Za-z0-9_]+)\s+with\s+(?:the\s+)?sight/i);
|
||||
if (alignMatch) {
|
||||
return {
|
||||
state: STATE.TABLE_ALIGN,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
meta: { patch: alignMatch[1] },
|
||||
};
|
||||
}
|
||||
if (lineLower.includes("locate patch") && lineLower.includes("sight")) {
|
||||
const fallbackMatch = lineTrim.match(/locate\s+patch\s+([^\s,]+)/i);
|
||||
return {
|
||||
state: STATE.TABLE_ALIGN,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
meta: { patch: fallbackMatch ? fallbackMatch[1] : "" },
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Sheet placement prompt (XY table)
|
||||
// e.g. "Please place sheet 1 of 1 on the table" or "Please remove previous sheet and place sheet 2 of 2 on the table"
|
||||
const placeMatch = lineTrim.match(/place\s+sheet\s+(\d+)\s+of\s+(\d+)/i);
|
||||
if (placeMatch) {
|
||||
return {
|
||||
state: STATE.TABLE_PLACE_SHEET,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
meta: {
|
||||
sheet: parseInt(placeMatch[1], 10),
|
||||
totalSheets: parseInt(placeMatch[2], 10),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (lineLower.includes("place sheet") || lineLower.includes("remove previous sheet")) {
|
||||
return {
|
||||
state: STATE.TABLE_PLACE_SHEET,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
meta: {},
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Continuation lines ("hit return to continue...", etc.)
|
||||
// Real Argyll XY prompts are two lines:
|
||||
// Line 1: "locate patch A1 with the sight,"
|
||||
// Line 2: "then hit return to continue"
|
||||
// When line 2 arrives, if we are in TABLE_PLACE_SHEET or TABLE_ALIGN, we MUST remain sticky in that table state!
|
||||
const isContinuePrompt =
|
||||
(lineLower.includes("hit return to continue") ||
|
||||
lineLower.includes("then hit return to continue") ||
|
||||
lineLower.includes("hit return to continue, esc or 'q' to give up")) &&
|
||||
!lineLower.includes("use it anyway");
|
||||
|
||||
if (isContinuePrompt) {
|
||||
if (currentState === STATE.TABLE_PLACE_SHEET) {
|
||||
return {
|
||||
state: STATE.TABLE_PLACE_SHEET,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
meta: { isContinuation: true },
|
||||
};
|
||||
}
|
||||
if (currentState === STATE.TABLE_ALIGN) {
|
||||
return {
|
||||
state: STATE.TABLE_ALIGN,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
meta: { isContinuation: true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: STATE.PROMPT_CONTINUE,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Strip / measurement completed signals
|
||||
if (
|
||||
lineLower.includes("'d' if done") ||
|
||||
lineLower.includes("'d' when done") ||
|
||||
lineLower.includes("d if done") ||
|
||||
lineLower.includes("d when done") ||
|
||||
lineLower.includes("d to finish") ||
|
||||
lineLower.includes("d to save") ||
|
||||
lineLower.includes("all strips read") ||
|
||||
lineLower.includes("all patches read") ||
|
||||
lineLower.includes("done reading")
|
||||
) {
|
||||
return {
|
||||
state: STATE.ALL_STRIPS_READ,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 7. Warning prompts (e.g. unexpected response, use it anyway)
|
||||
if (
|
||||
lineLower.includes("(warning)") ||
|
||||
lineLower.includes("use it anyway") ||
|
||||
lineLower.includes("seem to have read strip pass") ||
|
||||
lineLower.includes("unexpected response")
|
||||
) {
|
||||
return {
|
||||
state: STATE.WARNING,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 8. Calibration prompts
|
||||
if (
|
||||
((lineLower.includes("place") &&
|
||||
(lineLower.includes("reference") ||
|
||||
lineLower.includes("white") ||
|
||||
lineLower.includes("calibrat") ||
|
||||
lineLower.includes("standard"))) ||
|
||||
lineLower.includes("hit any key to continue") ||
|
||||
lineLower.includes("calibration")) &&
|
||||
!lineLower.includes("place sheet") &&
|
||||
!lineLower.includes("locate patch")
|
||||
) {
|
||||
return {
|
||||
state: STATE.CALIBRATING,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 9. Ready to read / Strip trigger (handheld / strip readers)
|
||||
if (
|
||||
((lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) ||
|
||||
lineLower.includes("ready to read") ||
|
||||
(lineLower.includes("read") && lineLower.includes("strip") && lineLower.includes("key"))) &&
|
||||
!lineLower.includes("all strips read")
|
||||
) {
|
||||
return {
|
||||
state: STATE.AWAITING_STRIP,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 10. Reading / Scanning
|
||||
if (
|
||||
lineLower.includes("reading strip") ||
|
||||
lineLower.includes("processing") ||
|
||||
lineLower.includes("scanning") ||
|
||||
lineLower.includes("reading sheet")
|
||||
) {
|
||||
return {
|
||||
state: STATE.READING,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 11. Errors
|
||||
if (
|
||||
lineLower.includes("error") ||
|
||||
lineLower.includes("too fast") ||
|
||||
lineLower.includes("too slow") ||
|
||||
lineLower.includes("misread") ||
|
||||
lineLower.includes("failed to read")
|
||||
) {
|
||||
return {
|
||||
state: STATE.ERROR,
|
||||
prompt: lineTrim,
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: currentState,
|
||||
prompt: lineTrim,
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
let currentState = STATE.IDLE;
|
||||
let currentProcessId = "";
|
||||
let measurementInProgress = false;
|
||||
let xyTableDetected = false;
|
||||
let currentPassIndex = 0;
|
||||
const recordedPasses = [];
|
||||
|
||||
@@ -83,6 +312,82 @@ export function initChartread() {
|
||||
const passCounterBadge = document.getElementById("passCounterBadge");
|
||||
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
|
||||
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
|
||||
const xyTableHint = document.getElementById("xyTableHint");
|
||||
const xyTablePanel = document.getElementById("xyTablePanel");
|
||||
const xyTableActiveStepBadge = document.getElementById("xyTableActiveStepBadge");
|
||||
const xyStepPlace = document.getElementById("xyStepPlace");
|
||||
const xyStepAlign = document.getElementById("xyStepAlign");
|
||||
const xyStepScan = document.getElementById("xyStepScan");
|
||||
const xyStepRemove = document.getElementById("xyStepRemove");
|
||||
|
||||
function setXyTableVisible(visible) {
|
||||
if (xyTableHint) {
|
||||
if (visible) xyTableHint.classList.remove("hidden");
|
||||
else xyTableHint.classList.add("hidden");
|
||||
}
|
||||
if (xyTablePanel) {
|
||||
if (visible) xyTablePanel.classList.remove("hidden");
|
||||
else xyTablePanel.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function updateXyTableSequence(phase) {
|
||||
if (!xyTablePanel) return;
|
||||
|
||||
const steps = [
|
||||
{ el: xyStepPlace, name: "place" },
|
||||
{ el: xyStepAlign, name: "align" },
|
||||
{ el: xyStepScan, name: "scan" },
|
||||
{ el: xyStepRemove, name: "remove" },
|
||||
];
|
||||
|
||||
const phaseOrder = ["place", "align", "scan", "remove", "done"];
|
||||
const targetIndex = phaseOrder.indexOf(phase);
|
||||
|
||||
steps.forEach((step, idx) => {
|
||||
if (!step.el) return;
|
||||
step.el.classList.remove("active", "completed");
|
||||
if (targetIndex >= 0) {
|
||||
if (idx < targetIndex) {
|
||||
step.el.classList.add("completed");
|
||||
} else if (idx === targetIndex && phase !== "done") {
|
||||
step.el.classList.add("active");
|
||||
} else if (phase === "done") {
|
||||
step.el.classList.add("completed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (xyTableActiveStepBadge) {
|
||||
xyTableActiveStepBadge.className = "status-badge";
|
||||
switch (phase) {
|
||||
case "place":
|
||||
xyTableActiveStepBadge.textContent = "Step 1: Place Sheet";
|
||||
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||
break;
|
||||
case "align":
|
||||
xyTableActiveStepBadge.textContent = "Step 2: Align Patches";
|
||||
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||
break;
|
||||
case "scan":
|
||||
xyTableActiveStepBadge.textContent = "Step 3: Scanning";
|
||||
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||
break;
|
||||
case "remove":
|
||||
xyTableActiveStepBadge.textContent = "Step 4: Remove Sheet";
|
||||
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||
break;
|
||||
case "done":
|
||||
xyTableActiveStepBadge.textContent = "Complete";
|
||||
xyTableActiveStepBadge.classList.add("badge-good");
|
||||
break;
|
||||
default:
|
||||
xyTableActiveStepBadge.textContent = "Standby";
|
||||
xyTableActiveStepBadge.classList.add("badge-idle");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setMeasurementBusy(busy) {
|
||||
measurementInProgress = busy;
|
||||
@@ -182,7 +487,13 @@ export function initChartread() {
|
||||
const opt = document.createElement("option");
|
||||
// Port value for -c switch. If port is 1 or auto, empty string leaves -c omitted for default port
|
||||
opt.value = inst.port && inst.port !== "1" ? inst.port : "";
|
||||
const isXy = /spectro\s?scan|i1io/i.test(inst.name) || /spectro\s?scan|i1io/i.test(inst.type);
|
||||
if (isXy) {
|
||||
opt.dataset.xy = "1";
|
||||
opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""} · XY Table`;
|
||||
} else {
|
||||
opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""}`;
|
||||
}
|
||||
instrumentSelect.appendChild(opt);
|
||||
});
|
||||
instrumentSelect.value = "";
|
||||
@@ -202,6 +513,21 @@ export function initChartread() {
|
||||
});
|
||||
}
|
||||
|
||||
if (instrumentSelect) {
|
||||
instrumentSelect.addEventListener("change", () => {
|
||||
const selectedOpt = instrumentSelect.selectedOptions && instrumentSelect.selectedOptions[0];
|
||||
const isXy = Boolean(selectedOpt && selectedOpt.dataset && selectedOpt.dataset.xy === "1");
|
||||
if (isXy) {
|
||||
xyTableDetected = true;
|
||||
setXyTableVisible(true);
|
||||
updateXyTableSequence("standby");
|
||||
} else if (!measurementInProgress) {
|
||||
xyTableDetected = false;
|
||||
setXyTableVisible(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setState(newState) {
|
||||
currentState = newState;
|
||||
if (stateLabel) stateLabel.textContent = newState;
|
||||
@@ -287,6 +613,22 @@ export function initChartread() {
|
||||
}
|
||||
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.TABLE_PLACE_SHEET:
|
||||
if (btnAccept) {
|
||||
btnAccept.disabled = false;
|
||||
btnAccept.textContent = "✓ Sheet Placed — Continue";
|
||||
btnAccept.classList.remove("hidden");
|
||||
}
|
||||
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.TABLE_ALIGN:
|
||||
if (btnAccept) {
|
||||
btnAccept.disabled = false;
|
||||
btnAccept.textContent = "✓ Aligned — Continue";
|
||||
btnAccept.classList.remove("hidden");
|
||||
}
|
||||
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.ERROR:
|
||||
if (btnRetry) {
|
||||
btnRetry.disabled = false;
|
||||
@@ -339,6 +681,15 @@ export function initChartread() {
|
||||
setState(STATE.CALIBRATING);
|
||||
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
|
||||
|
||||
const selectedOpt = instrumentSelect && instrumentSelect.selectedOptions && instrumentSelect.selectedOptions[0];
|
||||
if (selectedOpt && selectedOpt.dataset && selectedOpt.dataset.xy === "1") {
|
||||
xyTableDetected = true;
|
||||
setXyTableVisible(true);
|
||||
updateXyTableSequence("standby");
|
||||
} else {
|
||||
xyTableDetected = false;
|
||||
}
|
||||
|
||||
const selectedPort = instrumentSelect && instrumentSelect.value ? instrumentSelect.value : null;
|
||||
|
||||
const config = {
|
||||
@@ -365,64 +716,66 @@ export function initChartread() {
|
||||
logPre.textContent += line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
|
||||
// Parse prompts for state transitions
|
||||
const lineLower = line.toLowerCase();
|
||||
const classified = classifyChartreadLine(line, currentState);
|
||||
|
||||
if (classified.matched) {
|
||||
if (
|
||||
lineLower.includes("'d' if done") ||
|
||||
lineLower.includes("'d' when done") ||
|
||||
lineLower.includes("d if done") ||
|
||||
lineLower.includes("d when done") ||
|
||||
lineLower.includes("d to finish") ||
|
||||
lineLower.includes("d to save") ||
|
||||
lineLower.includes("all strips read") ||
|
||||
lineLower.includes("all patches read") ||
|
||||
lineLower.includes("done reading")
|
||||
) {
|
||||
setState(STATE.ALL_STRIPS_READ);
|
||||
setPrompt(`🎉 ${line.trim()} — Click 'Done & Save .ti3' to save.`);
|
||||
} else if (
|
||||
lineLower.includes("(warning)") ||
|
||||
lineLower.includes("use it anyway") ||
|
||||
lineLower.includes("seem to have read strip pass") ||
|
||||
lineLower.includes("unexpected response") ||
|
||||
lineLower.includes("hit return to use it anyway")
|
||||
classified.state === STATE.TABLE_PLACE_SHEET ||
|
||||
classified.state === STATE.TABLE_ALIGN ||
|
||||
(classified.meta && (classified.meta.isRemoveSheetNotice || classified.meta.sheetOk))
|
||||
) {
|
||||
if (!xyTableDetected) {
|
||||
xyTableDetected = true;
|
||||
setXyTableVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (xyTableDetected) {
|
||||
if (classified.state === STATE.TABLE_PLACE_SHEET) {
|
||||
updateXyTableSequence("place");
|
||||
} else if (classified.state === STATE.TABLE_ALIGN) {
|
||||
updateXyTableSequence("align");
|
||||
} else if (classified.state === STATE.READING) {
|
||||
updateXyTableSequence("scan");
|
||||
} else if (classified.meta && classified.meta.isRemoveSheetNotice) {
|
||||
updateXyTableSequence("remove");
|
||||
}
|
||||
}
|
||||
|
||||
if (classified.state !== currentState) {
|
||||
setState(classified.state);
|
||||
}
|
||||
|
||||
if (classified.state === STATE.TABLE_PLACE_SHEET) {
|
||||
const sheetInfo = (classified.meta && classified.meta.sheet && classified.meta.totalSheets)
|
||||
? ` (Sheet ${classified.meta.sheet} of ${classified.meta.totalSheets})`
|
||||
: "";
|
||||
setPrompt(`📋 ${classified.prompt}${sheetInfo}`);
|
||||
} else if (classified.state === STATE.TABLE_ALIGN) {
|
||||
const patchInfo = (classified.meta && classified.meta.patch)
|
||||
? ` [Patch ${classified.meta.patch}]`
|
||||
: "";
|
||||
setPrompt(`🎯 ${classified.prompt}${patchInfo}`);
|
||||
} else if (classified.meta && classified.meta.isRemoveSheetNotice) {
|
||||
setPrompt(`ℹ️ ${classified.prompt}`);
|
||||
} else if (classified.meta && classified.meta.sheetOk) {
|
||||
setPrompt(`✅ ${classified.prompt}`);
|
||||
} else if (classified.state === STATE.ALL_STRIPS_READ) {
|
||||
setPrompt(`🎉 ${classified.prompt} — Click 'Done & Save .ti3' to save.`);
|
||||
} else if (classified.state === STATE.WARNING) {
|
||||
const previousPrompt = promptText ? promptText.textContent.trim() : "";
|
||||
const lineLower = line.toLowerCase();
|
||||
const isContinuationPrompt = lineLower.includes("hit return to use it anyway") || lineLower.includes("use it anyway");
|
||||
setState(STATE.WARNING);
|
||||
if (currentState === STATE.WARNING && isContinuationPrompt && previousPrompt && !previousPrompt.includes(line.trim())) {
|
||||
setPrompt(`${previousPrompt}\n${line.trim()}`);
|
||||
} else {
|
||||
setPrompt(line.trim());
|
||||
setPrompt(classified.prompt);
|
||||
}
|
||||
} else if (classified.state === STATE.ERROR) {
|
||||
setPrompt("⚠️ " + classified.prompt);
|
||||
} else {
|
||||
setPrompt(classified.prompt);
|
||||
}
|
||||
} else if (
|
||||
lineLower.includes("place sheet") ||
|
||||
lineLower.includes("remove previous sheet") ||
|
||||
(lineLower.includes("hit return to continue") && !lineLower.includes("use it anyway"))
|
||||
) {
|
||||
setState(STATE.PROMPT_CONTINUE);
|
||||
setPrompt(line.trim());
|
||||
} else if (
|
||||
(lineLower.includes("place") && (lineLower.includes("reference") || lineLower.includes("white") || lineLower.includes("calibrat") || lineLower.includes("standard"))) ||
|
||||
lineLower.includes("hit any key to continue") ||
|
||||
lineLower.includes("calibration")
|
||||
) {
|
||||
setState(STATE.CALIBRATING);
|
||||
setPrompt(line.trim());
|
||||
} else if (
|
||||
(lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) ||
|
||||
lineLower.includes("ready to read") ||
|
||||
(lineLower.includes("read") && lineLower.includes("strip") && lineLower.includes("key"))
|
||||
) {
|
||||
setState(STATE.AWAITING_STRIP);
|
||||
setPrompt(line.trim());
|
||||
} else if (lineLower.includes("reading strip") || lineLower.includes("processing")) {
|
||||
setState(STATE.READING);
|
||||
setPrompt(line.trim());
|
||||
} else if (lineLower.includes("error") || lineLower.includes("too fast") || lineLower.includes("too slow") || lineLower.includes("misread") || lineLower.includes("failed to read")) {
|
||||
setState(STATE.ERROR);
|
||||
setPrompt("⚠️ " + line.trim());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -444,6 +797,9 @@ export function initChartread() {
|
||||
stopSwatchListener();
|
||||
|
||||
if (event.payload.code === 0) {
|
||||
if (xyTableDetected) {
|
||||
updateXyTableSequence("done");
|
||||
}
|
||||
try {
|
||||
const passIndex = currentPassIndex + 1;
|
||||
const filename = await invoke("snapshot_ti3", {
|
||||
@@ -615,8 +971,12 @@ export function initChartread() {
|
||||
try {
|
||||
btnAccept.disabled = true;
|
||||
await invoke("send_stdin", { id: currentProcessId, input: "\n" });
|
||||
if (currentState === STATE.TABLE_PLACE_SHEET || currentState === STATE.TABLE_ALIGN) {
|
||||
setPrompt("Continuing XY table sequence...");
|
||||
} else {
|
||||
setState(STATE.READING);
|
||||
setPrompt("Accepted. Processing...");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("send_stdin error:", e);
|
||||
btnAccept.disabled = false;
|
||||
@@ -682,14 +1042,27 @@ export function initChartread() {
|
||||
if (btnCancel) {
|
||||
btnCancel.addEventListener("click", async () => {
|
||||
try {
|
||||
btnCancel.disabled = true;
|
||||
if (currentState === STATE.TABLE_PLACE_SHEET || currentState === STATE.TABLE_ALIGN || xyTableDetected) {
|
||||
// For XY table states, send 'q\n' first to allow the table to park its measurement head gracefully
|
||||
try {
|
||||
await invoke("send_stdin", { id: currentProcessId, input: "q\n" });
|
||||
} catch (_) {}
|
||||
// Brief pause before kill to allow graceful parking
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
await invoke("kill_process", { id: currentProcessId });
|
||||
stopSwatchListener();
|
||||
setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE);
|
||||
setPrompt("Measurement cancelled.");
|
||||
if (xyTableDetected) {
|
||||
updateXyTableSequence("standby");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("kill_process error:", e);
|
||||
setPrompt(`Cancel failed: ${e}`);
|
||||
} finally {
|
||||
btnCancel.disabled = false;
|
||||
setMeasurementBusy(false);
|
||||
stopSwatchListener();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Unit & console tests for chartread.js stdout classifier and state machine transitions.
|
||||
// Can be run in browser devtools console:
|
||||
// import('./chartread.test.js').then(m => m.runAll())
|
||||
// Or in Node:
|
||||
// node src/js/chartread.test.js
|
||||
|
||||
// Node environment polyfill for browser globals
|
||||
if (typeof window === 'undefined') {
|
||||
globalThis.window = {
|
||||
__TAURI__: {
|
||||
core: { invoke: () => Promise.resolve() },
|
||||
event: {
|
||||
listen: () => Promise.resolve(() => {}),
|
||||
emit: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById: () => null,
|
||||
querySelectorAll: () => [],
|
||||
createElement: () => ({
|
||||
classList: { add: () => {}, remove: () => {} },
|
||||
style: {},
|
||||
appendChild: () => {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const { STATE, classifyChartreadLine } = await import('./chartread.js');
|
||||
|
||||
export function runAll() {
|
||||
console.group('Chartread Classifier & XY Table Tests');
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
|
||||
function assert(actual, expected, message) {
|
||||
total++;
|
||||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||
if (ok) {
|
||||
console.log('PASS:', message);
|
||||
passed++;
|
||||
} else {
|
||||
console.error('FAIL:', message, '\nExpected:', expected, '\nGot:', actual);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// 1. XY Sheet Placement prompts
|
||||
const place1 = classifyChartreadLine("Please place sheet 1 of 1 on the table", STATE.CALIBRATING);
|
||||
assert(place1.state, STATE.TABLE_PLACE_SHEET, 'sheet 1 of 1 state TABLE_PLACE_SHEET');
|
||||
assert(place1.matched, true, 'sheet 1 of 1 matched');
|
||||
assert(place1.meta?.sheet, 1, 'sheet 1 of 1 sheet number 1');
|
||||
assert(place1.meta?.totalSheets, 1, 'sheet 1 of 1 total sheets 1');
|
||||
|
||||
const place2 = classifyChartreadLine("Please remove previous sheet and place sheet 2 of 2 on the table", STATE.READING);
|
||||
assert(place2.state, STATE.TABLE_PLACE_SHEET, 'sheet 2 of 2 state TABLE_PLACE_SHEET');
|
||||
assert(place2.meta?.sheet, 2, 'sheet 2 of 2 sheet number 2');
|
||||
assert(place2.meta?.totalSheets, 2, 'sheet 2 of 2 total sheets 2');
|
||||
|
||||
const placeGeneric = classifyChartreadLine("place sheet on table", STATE.IDLE);
|
||||
assert(placeGeneric.state, STATE.TABLE_PLACE_SHEET, 'generic place sheet state TABLE_PLACE_SHEET');
|
||||
|
||||
// 2. XY Fiducial patch alignment prompts
|
||||
const fid1 = classifyChartreadLine("locate patch A1 with the sight,", STATE.TABLE_PLACE_SHEET);
|
||||
assert(fid1.state, STATE.TABLE_ALIGN, 'locate patch A1 state TABLE_ALIGN');
|
||||
assert(fid1.matched, true, 'locate patch A1 matched');
|
||||
assert(fid1.meta?.patch, "A1", 'locate patch A1 meta patch');
|
||||
|
||||
const fid2 = classifyChartreadLine("locate patch B24 with the sight", STATE.TABLE_ALIGN);
|
||||
assert(fid2.state, STATE.TABLE_ALIGN, 'locate patch B24 state TABLE_ALIGN');
|
||||
assert(fid2.meta?.patch, "B24", 'locate patch B24 meta patch');
|
||||
|
||||
const fid3 = classifyChartreadLine("locate patch 1 with sight", STATE.TABLE_ALIGN);
|
||||
assert(fid3.state, STATE.TABLE_ALIGN, 'locate patch 1 state TABLE_ALIGN');
|
||||
assert(fid3.meta?.patch, "1", 'locate patch 1 meta patch');
|
||||
|
||||
// 3. Two-line prompt sticky transitions
|
||||
// A continuation line arriving while in TABLE_PLACE_SHEET must remain in TABLE_PLACE_SHEET
|
||||
const contSheet1 = classifyChartreadLine("hit return to continue, Esc or 'q' to give up", STATE.TABLE_PLACE_SHEET);
|
||||
assert(contSheet1.state, STATE.TABLE_PLACE_SHEET, 'sheet continuation remains in TABLE_PLACE_SHEET');
|
||||
assert(contSheet1.meta?.isContinuation, true, 'sheet continuation flag set');
|
||||
|
||||
const contSheet2 = classifyChartreadLine("then hit return to continue", STATE.TABLE_PLACE_SHEET);
|
||||
assert(contSheet2.state, STATE.TABLE_PLACE_SHEET, 'then hit return remains in TABLE_PLACE_SHEET');
|
||||
|
||||
// A continuation line arriving while in TABLE_ALIGN must remain in TABLE_ALIGN
|
||||
const contAlign1 = classifyChartreadLine("then hit return to continue", STATE.TABLE_ALIGN);
|
||||
assert(contAlign1.state, STATE.TABLE_ALIGN, 'align continuation remains in TABLE_ALIGN');
|
||||
assert(contAlign1.meta?.isContinuation, true, 'align continuation flag set');
|
||||
|
||||
const contAlign2 = classifyChartreadLine("hit return to continue, Esc or 'q' to give up", STATE.TABLE_ALIGN);
|
||||
assert(contAlign2.state, STATE.TABLE_ALIGN, 'align esc/q continuation remains in TABLE_ALIGN');
|
||||
|
||||
// Continuation prompt in strip mode becomes generic PROMPT_CONTINUE
|
||||
const contStrip = classifyChartreadLine("hit return to continue", STATE.READING);
|
||||
assert(contStrip.state, STATE.PROMPT_CONTINUE, 'strip continue transitions to PROMPT_CONTINUE');
|
||||
|
||||
// 4. Final sheet removal notice (Info-only, does not change state or trigger stdin prompt)
|
||||
const removeSheet = classifyChartreadLine("Please remove last sheet from table", STATE.READING);
|
||||
assert(removeSheet.state, STATE.READING, 'remove last sheet notice preserves currentState');
|
||||
assert(removeSheet.matched, true, 'remove last sheet notice is matched');
|
||||
assert(removeSheet.meta?.isRemoveSheetNotice, true, 'remove last sheet notice flag set');
|
||||
|
||||
// 5. Sheet read OK notices
|
||||
const sheetOk = classifyChartreadLine("Sheet 1 of 1 read OK", STATE.READING);
|
||||
assert(sheetOk.matched, true, 'sheet read OK matched');
|
||||
assert(sheetOk.meta?.sheetOk, true, 'sheet read OK flag');
|
||||
assert(sheetOk.meta?.sheet, 1, 'sheet read OK sheet 1');
|
||||
assert(sheetOk.meta?.totalSheets, 1, 'sheet read OK totalSheets 1');
|
||||
|
||||
// 6. Strip mode regressions
|
||||
const calib = classifyChartreadLine("Place instrument on calibration tile and hit [Space] to calibrate.", STATE.IDLE);
|
||||
assert(calib.state, STATE.CALIBRATING, 'strip calibration prompt transitions to CALIBRATING');
|
||||
|
||||
const awaitStrip = classifyChartreadLine("Hit [Space] to read strip A (or 's' to skip).", STATE.CALIBRATING);
|
||||
assert(awaitStrip.state, STATE.AWAITING_STRIP, 'strip prompt transitions to AWAITING_STRIP');
|
||||
|
||||
const readingStrip = classifyChartreadLine("Reading strip A...", STATE.AWAITING_STRIP);
|
||||
assert(readingStrip.state, STATE.READING, 'reading strip transitions to READING');
|
||||
|
||||
const readingSheet = classifyChartreadLine("Reading sheet 1...", STATE.TABLE_ALIGN);
|
||||
assert(readingSheet.state, STATE.READING, 'reading sheet transitions to READING');
|
||||
|
||||
const warning = classifyChartreadLine("Warning: unexpected response from instrument", STATE.READING);
|
||||
assert(warning.state, STATE.WARNING, 'unexpected response transitions to WARNING');
|
||||
|
||||
const warnAnyway = classifyChartreadLine("Hit return to use it anyway", STATE.WARNING);
|
||||
assert(warnAnyway.state, STATE.WARNING, 'use it anyway stays in WARNING');
|
||||
|
||||
const allStripsDone = classifyChartreadLine("All strips read. Hit 'd' when done", STATE.READING);
|
||||
assert(allStripsDone.state, STATE.ALL_STRIPS_READ, 'all strips read transitions to ALL_STRIPS_READ');
|
||||
|
||||
const err = classifyChartreadLine("Fatal error: instrument communication failed", STATE.READING);
|
||||
assert(err.state, STATE.ERROR, 'instrument communication failed transitions to ERROR');
|
||||
|
||||
const unrecognized = classifyChartreadLine("some debug log output [1234]", STATE.READING);
|
||||
assert(unrecognized.state, STATE.READING, 'unrecognized line preserves currentState');
|
||||
assert(unrecognized.matched, false, 'unrecognized line matched is false');
|
||||
|
||||
console.log(`\nResults: ${passed} / ${total} tests passed.`);
|
||||
console.groupEnd();
|
||||
|
||||
if (passed !== total) {
|
||||
throw new Error(`Chartread tests failed: ${total - passed} failure(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-run if executed in Node.js
|
||||
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('chartread.test.js')) {
|
||||
runAll();
|
||||
}
|
||||
+13
-2
@@ -4,6 +4,7 @@ import { setStage4Result } from './profcheck.js';
|
||||
import { loadGamutMesh } from './gamut_viewer.js';
|
||||
import { wizardState } from './state.js';
|
||||
import { logger } from './logger.js';
|
||||
import { applyCalibrationToProfile, getActiveCalibration } from './calibration.js';
|
||||
|
||||
let chartreadBasename = "";
|
||||
let chartreadCwd = "";
|
||||
@@ -54,8 +55,8 @@ export function initColprof() {
|
||||
if (btnBrowseCustomSp && colprofCustomSpPath) {
|
||||
btnBrowseCustomSp.addEventListener("click", async () => {
|
||||
try {
|
||||
const selected = await window.__TAURI__.dialog.open({
|
||||
filters: [{ name: 'Spectrum', extensions: ['sp'] }]
|
||||
const selected = await invoke("select_spectrum_file", {
|
||||
defaultDir: chartreadCwd || wizardState.cwd || null,
|
||||
});
|
||||
if (selected) {
|
||||
colprofCustomSpPath.value = selected;
|
||||
@@ -159,6 +160,16 @@ export function initColprof() {
|
||||
wizardState.setTarget(basename, cwd);
|
||||
setStage4Result(basename, cwd);
|
||||
|
||||
const cal = getActiveCalibration();
|
||||
if (cal.applyEnabled && cal.calPath) {
|
||||
logPre.textContent += `\nApplying calibration ${cal.filename || cal.calPath} via applycal...\n`;
|
||||
const applied = await applyCalibrationToProfile(profilePath);
|
||||
if (applied && applied.output_path) {
|
||||
profilePath = applied.output_path;
|
||||
logPre.textContent += `[SUCCESS] ${applied.message}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically extract gamut mesh for 3D visualization
|
||||
triggerGamutExtraction(basename, cwd, profilePath);
|
||||
} else {
|
||||
|
||||
+242
-12
@@ -1,22 +1,210 @@
|
||||
import { computeQuickHull } from "./vendor/quickhull.js";
|
||||
import { computeQuickHull } from "./vendor/quickhull.js";
|
||||
import { labToSrgb } from "./color_convert.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke ? window.__TAURI__.core.invoke : null;
|
||||
|
||||
export const WEBGL_UNAVAILABLE_MESSAGE =
|
||||
"3D gamut viewer requires WebGL; the rest of ICCery still works.";
|
||||
|
||||
let scene, camera, renderer, labelRenderer, controls;
|
||||
let currentProfileMesh = null;
|
||||
let sRgbGroup = 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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function initGamutViewer() {
|
||||
try {
|
||||
const container = document.getElementById('gamutViewerContainer');
|
||||
if (!container || typeof THREE === 'undefined') return;
|
||||
if (gamutViewerReady || gamutViewerInitStarted) return;
|
||||
gamutViewerInitStarted = true;
|
||||
|
||||
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
|
||||
container.innerHTML = "";
|
||||
|
||||
@@ -26,17 +214,43 @@ export async function initGamutViewer() {
|
||||
const width = container.clientWidth > 0 ? container.clientWidth : 500;
|
||||
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.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.setPixelRatio(window.devicePixelRatio || 1);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, lowPower ? 1 : 2));
|
||||
renderer.domElement.style.touchAction = 'none';
|
||||
renderer.domElement.style.display = 'block';
|
||||
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 ──────────────────────────────────────────────
|
||||
if (typeof THREE.CSS2DRenderer !== 'undefined') {
|
||||
labelRenderer = new THREE.CSS2DRenderer();
|
||||
@@ -75,7 +289,8 @@ export async function initGamutViewer() {
|
||||
buildAxisScaffold();
|
||||
|
||||
// ── Resize handling ───────────────────────────────────────────────────
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
const h = entry.contentRect.height;
|
||||
@@ -88,8 +303,10 @@ export async function initGamutViewer() {
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
animate();
|
||||
gamutViewerReady = true;
|
||||
startAnimate();
|
||||
|
||||
// ── Wire toggle controls ──────────────────────────────────────────────
|
||||
_wireToggles();
|
||||
@@ -98,7 +315,16 @@ export async function initGamutViewer() {
|
||||
loadSrgbReferenceGamut();
|
||||
|
||||
} 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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function animate() {
|
||||
if (!animationRunning) return;
|
||||
requestAnimationFrame(animate);
|
||||
if (contextLost || !renderer) return;
|
||||
if (controls) controls.update();
|
||||
if (renderer && scene && camera) {
|
||||
renderer.render(scene, camera);
|
||||
@@ -561,6 +789,8 @@ export function toggleAxes(visible) {
|
||||
// Wire legend toggle checkboxes, opacity sliders, and reset button
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _wireToggles() {
|
||||
if (togglesWired) return;
|
||||
togglesWired = true;
|
||||
const bindings = [
|
||||
['chkProfileGamut', toggleProfileGamut ],
|
||||
['chkSrgbReference', toggleSrgbReference ],
|
||||
|
||||
+154
-4
@@ -1,20 +1,79 @@
|
||||
// Manual / browser-console tests for gamut_viewer.js and profcheck.js parsing.
|
||||
// Run in a browser/devtools console after the app has loaded:
|
||||
// Unit & console tests for gamut_viewer.js parsing.
|
||||
// Can be run in browser devtools console:
|
||||
// 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() {
|
||||
console.group('gamut/profcheck parser tests');
|
||||
console.group('Gamut Viewer Parser Tests');
|
||||
passed = 0;
|
||||
total = 0;
|
||||
testParseGamutBasic();
|
||||
testParseGamutDualTable();
|
||||
testParseGamutWithComments();
|
||||
testWebglAvailableFalseWithoutContext();
|
||||
testWebglAvailableTrueWithWebgl();
|
||||
testConstrainedGpuPrefersBackendArch();
|
||||
testConstrainedGpuIgnoresAppleSiliconUa();
|
||||
testConstrainedGpuIntelMac();
|
||||
testEnsureGamutViewerNoopsWithoutDom();
|
||||
testFallbackMessage();
|
||||
console.log(`\nResults: ${passed} / ${total} tests passed.`);
|
||||
console.groupEnd();
|
||||
|
||||
if (passed !== total) {
|
||||
throw new Error(`Gamut viewer parser tests failed: ${total - passed} failure(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, message) {
|
||||
total++;
|
||||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||
if (ok) {
|
||||
passed++;
|
||||
console.log('PASS:', message);
|
||||
} else {
|
||||
console.error('FAIL:', message, 'expected', expected, 'got', actual);
|
||||
@@ -22,6 +81,17 @@ function assertEqual(actual, expected, message) {
|
||||
return ok;
|
||||
}
|
||||
|
||||
function assert(ok, message) {
|
||||
total++;
|
||||
if (ok) {
|
||||
passed++;
|
||||
console.log('PASS:', message);
|
||||
} else {
|
||||
console.error('FAIL:', message);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function testParseGamutBasic() {
|
||||
const text = `GAMUT file
|
||||
BEGIN_DATA
|
||||
@@ -78,3 +148,83 @@ END_DATA`;
|
||||
assertEqual(vertices.length, 4, 'commented gamut vertex 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,5 +1,7 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
import { getActiveCalibration } from './calibration.js';
|
||||
|
||||
let currentPresets = [];
|
||||
let activePresetId = "preset-std-rgb";
|
||||
|
||||
@@ -417,6 +419,8 @@ export async function initPresets() {
|
||||
colprof_observer,
|
||||
colprof_input_viewing_cond,
|
||||
colprof_output_viewing_cond,
|
||||
calibration_file: getActiveCalibration().calPath || null,
|
||||
apply_calibration: getActiveCalibration().applyEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const { listen } = window.__TAURI__.event;
|
||||
import { setStage2Result } from './chartread.js';
|
||||
import { wizardState } from './state.js';
|
||||
import { logger } from './logger.js';
|
||||
import { getPrinttargCalibrationFields } from './calibration.js';
|
||||
|
||||
// Module-level state: set by Stage 1 when it completes
|
||||
let stage1Basename = "";
|
||||
@@ -505,6 +506,7 @@ export function initPrinttarg() {
|
||||
no_randomize: noRandomize,
|
||||
basename: stage1Basename,
|
||||
cwd: stage1Cwd,
|
||||
...getPrinttargCalibrationFields(stage1Basename),
|
||||
};
|
||||
|
||||
const processId = `printtarg_${stage1Basename}`;
|
||||
@@ -574,6 +576,7 @@ export function initPrinttarg() {
|
||||
showNotification("error", "Please select a destination printer first.");
|
||||
return;
|
||||
}
|
||||
wizardState.printerName = printerName;
|
||||
|
||||
const options = getSelectedPrintOptions();
|
||||
const origBtnContent = triggeringButton ? triggeringButton.innerHTML : "";
|
||||
@@ -619,6 +622,7 @@ export function initPrinttarg() {
|
||||
showNotification("error", "Please select a destination printer first.");
|
||||
return;
|
||||
}
|
||||
wizardState.printerName = printerName;
|
||||
|
||||
const options = getSelectedPrintOptions();
|
||||
const cwd = stage1Cwd;
|
||||
|
||||
+528
-107
@@ -3,6 +3,7 @@ const { listen } = window.__TAURI__.event;
|
||||
import { loadGamutMesh } from './gamut_viewer.js';
|
||||
import { wizardState } from './state.js';
|
||||
import { logger } from './logger.js';
|
||||
import { setProfileInstallSource } from './profile_install.js';
|
||||
|
||||
let profileBasename = "";
|
||||
let profileCwd = "";
|
||||
@@ -14,6 +15,423 @@ export function setStage4Result(basename, cwd) {
|
||||
profileBasename = basename || wizardState.basename;
|
||||
profileCwd = cwd || wizardState.cwd;
|
||||
wizardState.setTarget(profileBasename, profileCwd);
|
||||
loadVerificationHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse profcheck output for Average, Peak, and RMS delta-E values and patch count.
|
||||
* Supports Argyll's -u JSON summary object, text summary line, and legacy plain-text output.
|
||||
* @param {string} stdout - Full profcheck stdout.
|
||||
* @returns {{ avgDe: number, maxDe: number, rmsDe: number, patchCount: number, warnings: string[] }}
|
||||
*/
|
||||
export function parseProfcheckReport(stdout) {
|
||||
let avgDe = 0.0;
|
||||
let maxDe = 0.0;
|
||||
let rmsDe = 0.0;
|
||||
let patchCount = 0;
|
||||
const warnings = [];
|
||||
|
||||
if (!stdout || typeof stdout !== 'string') {
|
||||
return { avgDe, maxDe, rmsDe, patchCount, warnings: ['No stdout received from profcheck.'] };
|
||||
}
|
||||
|
||||
// Parse patch count from "No of test patches = (\d+)"
|
||||
const patchMatch = stdout.match(/No\s+of\s+test\s+patches\s*=\s*(\d+)/i);
|
||||
if (patchMatch) {
|
||||
patchCount = parseInt(patchMatch[1], 10);
|
||||
}
|
||||
|
||||
// Argyll's JSON output can appear either as a compact object on a single
|
||||
// line or embedded inside larger text. Accept objects with event === "report"
|
||||
// or containing any of avg_de, avg_de2000, peak_de, peak_de2000, rms, rms_de.
|
||||
const jsonObjects = [];
|
||||
const re = /\{[\s\S]*?\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(stdout)) !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(m[0]);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
if (
|
||||
parsed.event === 'report' ||
|
||||
'avg_de' in parsed ||
|
||||
'avg_de2000' in parsed ||
|
||||
'peak_de' in parsed ||
|
||||
'peak_de2000' in parsed ||
|
||||
'rms' in parsed ||
|
||||
'rms_de' in parsed
|
||||
) {
|
||||
jsonObjects.push(parsed);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a valid JSON object, ignore.
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonObjects.length > 0) {
|
||||
// When several report objects exist (de2000, de94, de), prefer *de2000 object matching -k
|
||||
const de2000Obj = jsonObjects.find(o => 'avg_de2000' in o || 'peak_de2000' in o);
|
||||
const targetJson = de2000Obj || jsonObjects[jsonObjects.length - 1];
|
||||
|
||||
avgDe = typeof targetJson.avg_de2000 === 'number' ? targetJson.avg_de2000 :
|
||||
(typeof targetJson.avg_de === 'number' ? targetJson.avg_de : 0);
|
||||
maxDe = typeof targetJson.peak_de2000 === 'number' ? targetJson.peak_de2000 :
|
||||
(typeof targetJson.max_de === 'number' ? targetJson.max_de :
|
||||
(typeof targetJson.peak_de === 'number' ? targetJson.peak_de : 0));
|
||||
rmsDe = typeof targetJson.rms === 'number' ? targetJson.rms :
|
||||
(typeof targetJson.rms_de === 'number' ? targetJson.rms_de : 0);
|
||||
} else {
|
||||
// Check for standard Argyll text summary line:
|
||||
// Profile check complete, errors...: max. = %f, avg. = %f, RMS = %f
|
||||
const summaryMatch = stdout.match(/Profile check complete,\s*errors[^\:]*:\s*max\.\s*=\s*([\d\.]+),\s*avg\.\s*=\s*([\d\.]+),\s*RMS\s*=\s*([\d\.]+)/i);
|
||||
if (summaryMatch) {
|
||||
maxDe = parseFloat(summaryMatch[1]);
|
||||
avgDe = parseFloat(summaryMatch[2]);
|
||||
rmsDe = parseFloat(summaryMatch[3]);
|
||||
} else {
|
||||
// Regex fallbacks for standard profcheck text output
|
||||
const avgPatterns = [
|
||||
/avg(?:\.?|erage)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||
/average\s+(?:dE\s*)?([\d\.]+)/i,
|
||||
/mean\s+(?:dE\s*)?([\d\.]+)/i,
|
||||
/dE\s+average[^\d]*([\d\.]+)/i,
|
||||
];
|
||||
const maxPatterns = [
|
||||
/max(?:\.?|imum)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||
/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||
/worst\s*(?:dE\s*)?([\d\.]+)/i,
|
||||
/dE\s+max[^\d]*([\d\.]+)/i,
|
||||
];
|
||||
const rmsPatterns = [
|
||||
/RMS(?:\.?)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||
/rms(?:\.?)\s*(?:dE\s*)?([\d\.]+)/i,
|
||||
/root\s+mean\s+sq(?:uare)?\s*(?:dE\s*)?([\d\.]+)/i,
|
||||
];
|
||||
|
||||
const find = (patterns) => {
|
||||
for (const p of patterns) {
|
||||
const match = stdout.match(p);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const avgMatch = find(avgPatterns);
|
||||
const maxMatch = find(maxPatterns);
|
||||
const rmsMatch = find(rmsPatterns);
|
||||
|
||||
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
|
||||
else warnings.push('Could not detect Average ΔE in profcheck output.');
|
||||
|
||||
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
|
||||
else warnings.push('Could not detect Peak ΔE in profcheck output.');
|
||||
|
||||
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
|
||||
else warnings.push('Could not detect RMS ΔE in profcheck output.');
|
||||
|
||||
if (!avgMatch && !maxMatch && !rmsMatch) {
|
||||
warnings.push('No delta-E values were found in profcheck output.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { avgDe, maxDe, rmsDe, patchCount, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for a printer drift breach condition:
|
||||
* Returns an alert string if the last >=2 consecutive records have avg_de >= 3.5
|
||||
* and span distinct calendar dates (or are >= 1 hour apart).
|
||||
* @param {Array<object>} records - Array of verification records sorted ascending by timestamp.
|
||||
* @returns {string|null} Alert text or null if no breach.
|
||||
*/
|
||||
export function checkBreachAlert(records) {
|
||||
if (!records || records.length < 2) return null;
|
||||
|
||||
let count = 0;
|
||||
let firstBreach = null;
|
||||
let latestBreach = null;
|
||||
|
||||
for (let i = records.length - 1; i >= 0; i--) {
|
||||
if (records[i].avg_de >= 3.5) {
|
||||
count++;
|
||||
if (!latestBreach) latestBreach = records[i];
|
||||
firstBreach = records[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (count >= 2 && firstBreach && latestBreach) {
|
||||
const tFirst = new Date(firstBreach.timestamp).getTime();
|
||||
const tLatest = new Date(latestBreach.timestamp).getTime();
|
||||
const diffHours = (tLatest - tFirst) / (1000 * 60 * 60);
|
||||
const dFirst = firstBreach.timestamp.slice(0, 10);
|
||||
const dLatest = latestBreach.timestamp.slice(0, 10);
|
||||
|
||||
if (dFirst !== dLatest || diffHours >= 1.0) {
|
||||
const fmt = (ts) => {
|
||||
try {
|
||||
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch (_) {
|
||||
return ts;
|
||||
}
|
||||
};
|
||||
return `⚠️ Re-profiling Recommended — ${count} consecutive verifications out of tolerance (first: ${fmt(firstBreach.timestamp)}, latest: ${fmt(latestBreach.timestamp)})`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads verification history records and updates the Stage 5 drift analytics UI.
|
||||
*/
|
||||
export async function loadVerificationHistory(selectedPrinter = "") {
|
||||
const profileName = profileBasename || wizardState.basename || "";
|
||||
const driftSection = document.getElementById("driftHistorySection");
|
||||
if (!driftSection) return;
|
||||
|
||||
const alertCard = document.getElementById("driftAlertCard");
|
||||
const alertText = document.getElementById("driftAlertText");
|
||||
const emptyState = document.getElementById("driftEmptyState");
|
||||
const chartWrap = document.getElementById("driftChartWrap");
|
||||
const chartSvg = document.getElementById("driftTrendChart");
|
||||
const tbody = document.getElementById("verificationHistoryTbody");
|
||||
const btnExport = document.getElementById("btnExportHistoryCsv");
|
||||
const btnClear = document.getElementById("btnClearHistory");
|
||||
const printerFilterSelect = document.getElementById("driftPrinterFilter");
|
||||
const filterRow = document.getElementById("driftFilterRow");
|
||||
|
||||
try {
|
||||
// 1. Fetch all records for current profile to populate printer options
|
||||
const allProfileRecords = await invoke("get_verification_history", {
|
||||
profileName: profileName || null,
|
||||
printerName: null,
|
||||
});
|
||||
|
||||
if (printerFilterSelect) {
|
||||
const distinctPrinters = Array.from(new Set(allProfileRecords.map(r => r.printer_name).filter(Boolean)));
|
||||
printerFilterSelect.innerHTML = `<option value="">All Printers</option>`;
|
||||
distinctPrinters.forEach(p => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p;
|
||||
opt.textContent = p;
|
||||
if (p === selectedPrinter) opt.selected = true;
|
||||
printerFilterSelect.appendChild(opt);
|
||||
});
|
||||
if (filterRow) {
|
||||
filterRow.classList.toggle("hidden", distinctPrinters.length <= 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch filtered records
|
||||
const records = await invoke("get_verification_history", {
|
||||
profileName: profileName || null,
|
||||
printerName: selectedPrinter || null,
|
||||
});
|
||||
|
||||
// 3. Breach Alert check (evaluated against profile records chronologically)
|
||||
const breachMessage = checkBreachAlert(records);
|
||||
if (alertCard && alertText) {
|
||||
if (breachMessage) {
|
||||
alertText.textContent = breachMessage;
|
||||
alertCard.classList.remove("hidden");
|
||||
} else {
|
||||
alertCard.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
const hasRecords = records.length > 0;
|
||||
if (btnExport) btnExport.disabled = !hasRecords;
|
||||
if (btnClear) btnClear.disabled = allProfileRecords.length === 0;
|
||||
|
||||
if (!hasRecords) {
|
||||
if (emptyState) emptyState.classList.remove("hidden");
|
||||
if (chartWrap) chartWrap.classList.add("hidden");
|
||||
if (tbody) tbody.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (emptyState) emptyState.classList.add("hidden");
|
||||
if (chartWrap) chartWrap.classList.remove("hidden");
|
||||
|
||||
// 4. Render Hand-rolled SVG Trend Chart
|
||||
if (chartSvg) {
|
||||
renderDriftTrendChart(chartSvg, records);
|
||||
}
|
||||
|
||||
// 5. Render Run-Log Table (newest first)
|
||||
if (tbody) {
|
||||
tbody.innerHTML = "";
|
||||
const reversed = [...records].reverse();
|
||||
reversed.forEach(r => {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
const tdDate = document.createElement("td");
|
||||
try {
|
||||
tdDate.textContent = new Date(r.timestamp).toLocaleString();
|
||||
} catch (_) {
|
||||
tdDate.textContent = r.timestamp;
|
||||
}
|
||||
|
||||
const tdPrinter = document.createElement("td");
|
||||
tdPrinter.textContent = r.printer_name || "Unknown";
|
||||
|
||||
const tdAvg = document.createElement("td");
|
||||
tdAvg.textContent = r.avg_de.toFixed(2);
|
||||
|
||||
const tdMax = document.createElement("td");
|
||||
tdMax.textContent = r.max_de.toFixed(2);
|
||||
|
||||
const tdRms = document.createElement("td");
|
||||
tdRms.textContent = r.rms_de.toFixed(2);
|
||||
|
||||
const tdPatches = document.createElement("td");
|
||||
tdPatches.textContent = r.patch_count || "-";
|
||||
|
||||
const tdStatus = document.createElement("td");
|
||||
const badge = document.createElement("span");
|
||||
const statusClass = r.status === 'excellent' ? 'badge-excellent' :
|
||||
r.status === 'good' ? 'badge-good' :
|
||||
r.status === 'acceptable' ? 'badge-acceptable' : 'badge-poor';
|
||||
badge.className = `status-badge ${statusClass}`;
|
||||
badge.textContent = r.status ? r.status.toUpperCase() : "UNKNOWN";
|
||||
tdStatus.appendChild(badge);
|
||||
|
||||
tr.appendChild(tdDate);
|
||||
tr.appendChild(tdPrinter);
|
||||
tr.appendChild(tdAvg);
|
||||
tr.appendChild(tdMax);
|
||||
tr.appendChild(tdRms);
|
||||
tr.appendChild(tdPatches);
|
||||
tr.appendChild(tdStatus);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to load verification history: ${e}`, 'Stage5-Profcheck');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled SVG line chart rendering verification drift trends with ICCery threshold bands.
|
||||
*/
|
||||
function renderDriftTrendChart(svg, records) {
|
||||
// Downsample to the last 50 points if necessary
|
||||
const data = records.length > 50 ? records.slice(records.length - 50) : records;
|
||||
|
||||
const width = 640;
|
||||
const height = 220;
|
||||
const padLeft = 45;
|
||||
const padRight = 30;
|
||||
const padTop = 20;
|
||||
const padBottom = 28;
|
||||
|
||||
const plotW = width - padLeft - padRight;
|
||||
const plotH = height - padTop - padBottom;
|
||||
|
||||
// Compute max Y (minimum 4.0 for all threshold bands)
|
||||
let maxY = 4.0;
|
||||
data.forEach(d => {
|
||||
if (d.max_de > maxY) maxY = d.max_de;
|
||||
if (d.avg_de > maxY) maxY = d.avg_de;
|
||||
});
|
||||
maxY = Math.ceil(maxY * 1.1);
|
||||
|
||||
const getY = (val) => padTop + plotH - (val / maxY) * plotH;
|
||||
const getX = (idx) => {
|
||||
if (data.length <= 1) return padLeft + plotW / 2;
|
||||
return padLeft + (idx / (data.length - 1)) * plotW;
|
||||
};
|
||||
|
||||
let elements = [];
|
||||
|
||||
// Threshold background bands
|
||||
// Bands at: 0-1.0 (Excellent), 1.0-2.0 (Good), 2.0-3.5 (Acceptable), 3.5-maxY (Warning)
|
||||
const bands = [
|
||||
{ from: 0.0, to: 1.0, color: "rgba(34, 197, 94, 0.08)", label: "Excellent (< 1.0)" },
|
||||
{ from: 1.0, to: 2.0, color: "rgba(59, 130, 246, 0.08)", label: "Good (< 2.0)" },
|
||||
{ from: 2.0, to: 3.5, color: "rgba(245, 158, 11, 0.08)", label: "Acceptable (< 3.5)" },
|
||||
{ from: 3.5, to: maxY, color: "rgba(239, 68, 68, 0.08)", label: "Warning (≥ 3.5)" },
|
||||
];
|
||||
|
||||
bands.forEach(b => {
|
||||
const yTop = getY(Math.min(b.to, maxY));
|
||||
const yBot = getY(b.from);
|
||||
const bandH = Math.max(0, yBot - yTop);
|
||||
elements.push(`<rect x="${padLeft}" y="${yTop}" width="${plotW}" height="${bandH}" fill="${b.color}" />`);
|
||||
});
|
||||
|
||||
// Threshold lines
|
||||
[1.0, 2.0, 3.5].forEach(thresh => {
|
||||
if (thresh <= maxY) {
|
||||
const y = getY(thresh);
|
||||
elements.push(`<line x1="${padLeft}" y1="${y}" x2="${padLeft + plotW}" y2="${y}" stroke="rgba(255,255,255,0.15)" stroke-dasharray="3,3" />`);
|
||||
elements.push(`<text x="${padLeft + plotW - 4}" y="${y - 3}" class="drift-band-label">${thresh.toFixed(1)} ΔE</text>`);
|
||||
}
|
||||
});
|
||||
|
||||
// Verification bands caption
|
||||
elements.push(`<text x="${padLeft + 6}" y="${padTop + 12}" fill="rgba(255,255,255,0.35)" font-size="9px" font-family="sans-serif">ICCery verification bands</text>`);
|
||||
|
||||
// Y Axis ticks
|
||||
const ySteps = [0, 1, 2, 3.5];
|
||||
if (maxY > 5) ySteps.push(Math.floor(maxY));
|
||||
ySteps.forEach(val => {
|
||||
const y = getY(val);
|
||||
elements.push(`<line x1="${padLeft - 4}" y1="${y}" x2="${padLeft}" y2="${y}" stroke="rgba(255,255,255,0.3)" />`);
|
||||
elements.push(`<text x="${padLeft - 8}" y="${y + 3}" text-anchor="end" class="drift-chart-text">${val.toFixed(1)}</text>`);
|
||||
});
|
||||
|
||||
// X Axis baseline
|
||||
elements.push(`<line x1="${padLeft}" y1="${padTop + plotH}" x2="${padLeft + plotW}" y2="${padTop + plotH}" stroke="rgba(255,255,255,0.3)" />`);
|
||||
|
||||
// Series points & paths
|
||||
if (data.length === 1) {
|
||||
const x = getX(0);
|
||||
const yAvg = getY(data[0].avg_de);
|
||||
const yMax = getY(data[0].max_de);
|
||||
|
||||
// Dashed horizontal line across plot for single point
|
||||
elements.push(`<line x1="${padLeft}" y1="${yAvg}" x2="${padLeft + plotW}" y2="${yAvg}" stroke="#3b82f6" stroke-dasharray="4,4" stroke-opacity="0.5" />`);
|
||||
elements.push(`<circle cx="${x}" cy="${yAvg}" r="5" class="drift-dot-avg"><title>Avg ΔE: ${data[0].avg_de.toFixed(2)} (${data[0].timestamp})</title></circle>`);
|
||||
elements.push(`<circle cx="${x}" cy="${yMax}" r="4" class="drift-dot-max"><title>Peak ΔE: ${data[0].max_de.toFixed(2)}</title></circle>`);
|
||||
} else {
|
||||
// Polylines
|
||||
let ptsAvg = [];
|
||||
let ptsMax = [];
|
||||
data.forEach((d, idx) => {
|
||||
const x = getX(idx);
|
||||
const yA = getY(d.avg_de);
|
||||
const yM = getY(d.max_de);
|
||||
ptsAvg.push(`${x.toFixed(1)},${yA.toFixed(1)}`);
|
||||
ptsMax.push(`${x.toFixed(1)},${yM.toFixed(1)}`);
|
||||
});
|
||||
|
||||
elements.push(`<polyline points="${ptsMax.join(' ')}" class="drift-line-max" />`);
|
||||
elements.push(`<polyline points="${ptsAvg.join(' ')}" class="drift-line-avg" />`);
|
||||
|
||||
// Draw dots
|
||||
data.forEach((d, idx) => {
|
||||
const x = getX(idx);
|
||||
const yA = getY(d.avg_de);
|
||||
const yM = getY(d.max_de);
|
||||
const dateStr = d.timestamp.slice(0, 10);
|
||||
elements.push(`<circle cx="${x.toFixed(1)}" cy="${yA.toFixed(1)}" r="4" class="drift-dot-avg"><title>Avg: ${d.avg_de.toFixed(2)} (${dateStr})</title></circle>`);
|
||||
elements.push(`<circle cx="${x.toFixed(1)}" cy="${yM.toFixed(1)}" r="3" class="drift-dot-max"><title>Peak: ${d.max_de.toFixed(2)} (${dateStr})</title></circle>`);
|
||||
});
|
||||
|
||||
// Start and End date labels on X axis
|
||||
const startStr = data[0].timestamp.slice(5, 10);
|
||||
const endStr = data[data.length - 1].timestamp.slice(5, 10);
|
||||
elements.push(`<text x="${padLeft}" y="${height - 8}" text-anchor="start" class="drift-chart-text">${startStr}</text>`);
|
||||
elements.push(`<text x="${padLeft + plotW}" y="${height - 8}" text-anchor="end" class="drift-chart-text">${endStr}</text>`);
|
||||
}
|
||||
|
||||
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
svg.innerHTML = elements.join("\n");
|
||||
}
|
||||
|
||||
export function initProfcheck() {
|
||||
@@ -25,9 +443,68 @@ export function initProfcheck() {
|
||||
const maxDeEl = document.getElementById("profcheckMaxDe");
|
||||
const rmsDeEl = document.getElementById("profcheckRmsDe");
|
||||
const badgeEl = document.getElementById("profcheckBadge");
|
||||
const btnExport = document.getElementById("btnExportHistoryCsv");
|
||||
const btnClear = document.getElementById("btnClearHistory");
|
||||
const printerFilterSelect = document.getElementById("driftPrinterFilter");
|
||||
|
||||
if (!btnVerify) return;
|
||||
|
||||
// Listen for printer filter changes in drift history
|
||||
if (printerFilterSelect) {
|
||||
printerFilterSelect.addEventListener("change", () => {
|
||||
loadVerificationHistory(printerFilterSelect.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Export CSV button handler
|
||||
if (btnExport) {
|
||||
btnExport.addEventListener("click", async () => {
|
||||
try {
|
||||
const profileName = profileBasename || wizardState.basename || "verification";
|
||||
const defaultName = `${profileName}_history.csv`;
|
||||
const chosenPath = await invoke("select_csv_save_path", { defaultName });
|
||||
if (chosenPath) {
|
||||
const printerFilter = printerFilterSelect ? printerFilterSelect.value : "";
|
||||
const count = await invoke("export_verification_history_csv", {
|
||||
destPath: chosenPath,
|
||||
profileName: profileName || null,
|
||||
printerName: printerFilter || null,
|
||||
});
|
||||
wizardState.showNotice(`✓ Exported ${count} verification records to ${chosenPath}`, "success");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`CSV Export failed: ${err}`, 'Stage5-Profcheck');
|
||||
wizardState.showNotice(`Failed to export CSV: ${err}`, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear History button handler
|
||||
if (btnClear) {
|
||||
btnClear.addEventListener("click", async () => {
|
||||
if (confirm("Are you sure you want to clear all verification history records? This cannot be undone.")) {
|
||||
try {
|
||||
await invoke("clear_verification_history");
|
||||
await loadVerificationHistory();
|
||||
wizardState.showNotice("Verification history cleared.", "info");
|
||||
} catch (err) {
|
||||
logger.error(`Clear history failed: ${err}`, 'Stage5-Profcheck');
|
||||
wizardState.showNotice(`Failed to clear history: ${err}`, "error");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for Stage 5 navigation to load history
|
||||
window.addEventListener("stage-changed", (event) => {
|
||||
if (event.detail && event.detail.stage === 5) {
|
||||
loadVerificationHistory();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load
|
||||
loadVerificationHistory();
|
||||
|
||||
btnVerify.addEventListener("click", async () => {
|
||||
const basename = profileBasename || wizardState.basename;
|
||||
const cwd = profileCwd || wizardState.cwd;
|
||||
@@ -59,6 +536,7 @@ export function initProfcheck() {
|
||||
logContainer.classList.remove("hidden");
|
||||
reportCard.classList.add("hidden");
|
||||
btnVerify.disabled = true;
|
||||
setProfileInstallSource(iccPath, false);
|
||||
|
||||
const config = {
|
||||
ti3_path: ti3Path,
|
||||
@@ -94,7 +572,56 @@ export function initProfcheck() {
|
||||
|
||||
if (event.payload.code === 0) {
|
||||
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
|
||||
parseAndRenderReport(stdoutAccumulator);
|
||||
const report = parseProfcheckReport(stdoutAccumulator);
|
||||
setProfileInstallSource(iccPath, true);
|
||||
|
||||
reportCard.classList.remove("hidden");
|
||||
if (report.warnings.length > 0) {
|
||||
logPre.textContent += `\n[WARN] ${report.warnings.join(' ')}\n`;
|
||||
}
|
||||
|
||||
avgDeEl.textContent = report.avgDe.toFixed(2);
|
||||
maxDeEl.textContent = report.maxDe.toFixed(2);
|
||||
rmsDeEl.textContent = report.rmsDe.toFixed(2);
|
||||
|
||||
// Quality verdict
|
||||
badgeEl.className = "report-badge";
|
||||
if (report.avgDe < 1.0) {
|
||||
badgeEl.textContent = "EXCELLENT";
|
||||
badgeEl.classList.add("badge-excellent");
|
||||
} else if (report.avgDe < 2.0) {
|
||||
badgeEl.textContent = "GOOD";
|
||||
badgeEl.classList.add("badge-good");
|
||||
} else if (report.avgDe < 3.5) {
|
||||
badgeEl.textContent = "ACCEPTABLE";
|
||||
badgeEl.classList.add("badge-acceptable");
|
||||
} else {
|
||||
badgeEl.textContent = "POOR";
|
||||
badgeEl.classList.add("badge-poor");
|
||||
}
|
||||
|
||||
// Auto-save record to verification history
|
||||
const record = {
|
||||
id: "",
|
||||
timestamp: new Date().toISOString(),
|
||||
printer_name: wizardState.printerName || "Unknown",
|
||||
profile_name: profileBasename || wizardState.basename || "Unknown",
|
||||
avg_de: report.avgDe,
|
||||
max_de: report.maxDe,
|
||||
rms_de: report.rmsDe,
|
||||
patch_count: report.patchCount,
|
||||
status: "",
|
||||
};
|
||||
|
||||
try {
|
||||
await invoke("save_verification_record", { record });
|
||||
} catch (saveErr) {
|
||||
logger.warn(`Could not auto-save verification record: ${saveErr}`, 'Stage5-Profcheck');
|
||||
logPre.textContent += `\n[WARN] Could not auto-save verification record: ${saveErr}\n`;
|
||||
}
|
||||
|
||||
// Refresh verification history display
|
||||
await loadVerificationHistory();
|
||||
|
||||
// Ensure gamut mesh is loaded into 3D viewer
|
||||
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
|
||||
@@ -119,110 +646,4 @@ export function initProfcheck() {
|
||||
btnVerify.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse profcheck output for Average, Peak, and RMS delta-E values.
|
||||
* Supports both Argyll's JSON-style summary and plain-text legacy output.
|
||||
* @param {string} stdout - Full profcheck stdout.
|
||||
*/
|
||||
function parseAndRenderReport(stdout) {
|
||||
reportCard.classList.remove("hidden");
|
||||
|
||||
let avgDe = 0.0;
|
||||
let maxDe = 0.0;
|
||||
let rmsDe = 0.0;
|
||||
let parserWarnings = [];
|
||||
|
||||
// Argyll's JSON output can appear either as a compact object on a single
|
||||
// line or embedded inside larger text. Try to find and parse the LAST valid
|
||||
// JSON object in the output, which is most likely the summary.
|
||||
const jsonObjects = [];
|
||||
const re = /\{[\s\S]*?\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(stdout)) !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(m[0]);
|
||||
if (typeof parsed === 'object' && parsed !== null && ('avg_de' in parsed || 'peak_de' in parsed || 'rms_de' in parsed)) {
|
||||
jsonObjects.push(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a valid JSON object, ignore.
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonObjects.length > 0) {
|
||||
const json = jsonObjects[jsonObjects.length - 1];
|
||||
avgDe = typeof json.avg_de === 'number' ? json.avg_de : 0;
|
||||
maxDe = typeof json.max_de === 'number' ? json.max_de : (typeof json.peak_de === 'number' ? json.peak_de : 0);
|
||||
rmsDe = typeof json.rms_de === 'number' ? json.rms_de : 0;
|
||||
} else {
|
||||
// Regex fallbacks for standard profcheck text output
|
||||
const avgPatterns = [
|
||||
/avg(?:\.?|erage)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||
/average\s+(?:dE\s*)?([\d\.]+)/i,
|
||||
/mean\s+(?:dE\s*)?([\d\.]+)/i,
|
||||
/dE\s+average[^\d]*([\d\.]+)/i,
|
||||
];
|
||||
const maxPatterns = [
|
||||
/max(?:\.?|imum)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||
/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||
/worst\s*(?:dE\s*)?([\d\.]+)/i,
|
||||
/dE\s+max[^\d]*([\d\.]+)/i,
|
||||
];
|
||||
const rmsPatterns = [
|
||||
/RMS\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||
/rms\s*(?:dE\s*)?([\d\.]+)/i,
|
||||
/root\s+mean\s+sq(?:uare)?\s*(?:dE\s*)?([\d\.]+)/i,
|
||||
];
|
||||
|
||||
const find = (patterns) => {
|
||||
for (const p of patterns) {
|
||||
const match = stdout.match(p);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const avgMatch = find(avgPatterns);
|
||||
const maxMatch = find(maxPatterns);
|
||||
const rmsMatch = find(rmsPatterns);
|
||||
|
||||
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
|
||||
else parserWarnings.push('Could not detect Average ΔE in profcheck output.');
|
||||
|
||||
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
|
||||
else parserWarnings.push('Could not detect Peak ΔE in profcheck output.');
|
||||
|
||||
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
|
||||
else parserWarnings.push('Could not detect RMS ΔE in profcheck output.');
|
||||
|
||||
if (!avgMatch && !maxMatch && !rmsMatch) {
|
||||
parserWarnings.push('No delta-E values were found in profcheck output.');
|
||||
}
|
||||
}
|
||||
|
||||
if (parserWarnings.length > 0) {
|
||||
logPre.textContent += `\n[WARN] ${parserWarnings.join(' ')}\n`;
|
||||
}
|
||||
|
||||
avgDeEl.textContent = avgDe.toFixed(2);
|
||||
maxDeEl.textContent = maxDe.toFixed(2);
|
||||
rmsDeEl.textContent = rmsDe.toFixed(2);
|
||||
|
||||
// Quality verdict
|
||||
badgeEl.className = "report-badge";
|
||||
if (avgDe < 1.0) {
|
||||
badgeEl.textContent = "EXCELLENT";
|
||||
badgeEl.classList.add("badge-excellent");
|
||||
} else if (avgDe < 2.0) {
|
||||
badgeEl.textContent = "GOOD";
|
||||
badgeEl.classList.add("badge-good");
|
||||
} else if (avgDe < 4.0) {
|
||||
badgeEl.textContent = "ACCEPTABLE";
|
||||
badgeEl.classList.add("badge-acceptable");
|
||||
} else {
|
||||
badgeEl.textContent = "POOR";
|
||||
badgeEl.classList.add("badge-poor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Unit & console tests for profcheck.js parsing and breach alert logic.
|
||||
// Can be run in browser devtools console:
|
||||
// import('./profcheck.test.js').then(m => m.runAll())
|
||||
// Or in Node:
|
||||
// node src/js/profcheck.test.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 { parseProfcheckReport, checkBreachAlert } = await import('./profcheck.js');
|
||||
|
||||
export function runAll() {
|
||||
console.group('Profcheck Report Parser & Drift Tests');
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
|
||||
function assert(actual, expected, message) {
|
||||
total++;
|
||||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||
if (ok) {
|
||||
console.log('PASS:', message);
|
||||
passed++;
|
||||
} else {
|
||||
console.error('FAIL:', message, '\nExpected:', expected, '\nGot:', actual);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// 1. Real Argyll -u JSON payload
|
||||
const realUOutput = `
|
||||
profcheck: Checking profile accuracy...
|
||||
No of test patches = 52
|
||||
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||
Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02
|
||||
`;
|
||||
const res1 = parseProfcheckReport(realUOutput);
|
||||
assert(res1.avgDe, 0.85, 'real -u JSON avgDe');
|
||||
assert(res1.maxDe, 2.41, 'real -u JSON maxDe');
|
||||
assert(res1.rmsDe, 1.02, 'real -u JSON rmsDe');
|
||||
assert(res1.patchCount, 52, 'real -u patchCount');
|
||||
assert(res1.warnings.length, 0, 'real -u no warnings');
|
||||
|
||||
// 2. Preference for *de2000 object when multiple JSON objects appear
|
||||
const multiJsonOutput = `
|
||||
{"event": "report", "peak_de": 3.10, "avg_de": 1.20, "rms": 1.50}
|
||||
{"event": "report", "peak_de2000": 2.15, "avg_de2000": 0.72, "rms": 0.95}
|
||||
`;
|
||||
const res2 = parseProfcheckReport(multiJsonOutput);
|
||||
assert(res2.avgDe, 0.72, 'prefers *de2000 avg');
|
||||
assert(res2.maxDe, 2.15, 'prefers *de2000 peak');
|
||||
assert(res2.rmsDe, 0.95, 'prefers *de2000 rms');
|
||||
|
||||
// 3. Standard text summary line without JSON
|
||||
const textSummaryOutput = `
|
||||
Header information...
|
||||
No of test patches = 120
|
||||
Profile check complete, errors(CIEDE2000): max. = 1.95, avg. = 0.65, RMS = 0.88
|
||||
Done.
|
||||
`;
|
||||
const res3 = parseProfcheckReport(textSummaryOutput);
|
||||
assert(res3.avgDe, 0.65, 'text summary line avgDe');
|
||||
assert(res3.maxDe, 1.95, 'text summary line maxDe');
|
||||
assert(res3.rmsDe, 0.88, 'text summary line rmsDe');
|
||||
assert(res3.patchCount, 120, 'text summary patchCount');
|
||||
|
||||
// 4. Legacy regex fallbacks
|
||||
const legacyOutput = `
|
||||
Summary:
|
||||
avg. dE = 1.15
|
||||
max. dE = 3.42
|
||||
rms. dE = 1.65
|
||||
`;
|
||||
const res4 = parseProfcheckReport(legacyOutput);
|
||||
assert(res4.avgDe, 1.15, 'legacy text avgDe');
|
||||
assert(res4.maxDe, 3.42, 'legacy text maxDe');
|
||||
assert(res4.rmsDe, 1.65, 'legacy text rmsDe');
|
||||
|
||||
// 5. checkBreachAlert tests
|
||||
assert(checkBreachAlert([]), null, 'empty records returns null');
|
||||
assert(checkBreachAlert([{ avg_de: 4.0, timestamp: '2026-09-01T10:00:00Z' }]), null, 'single record returns null');
|
||||
|
||||
// Two records in same minute >= 3.5 -> no alert
|
||||
const sameTimeRecords = [
|
||||
{ avg_de: 3.8, timestamp: '2026-09-05T12:00:10Z' },
|
||||
{ avg_de: 3.9, timestamp: '2026-09-05T12:00:45Z' },
|
||||
];
|
||||
assert(checkBreachAlert(sameTimeRecords), null, 'two records in same minute do not fire alert');
|
||||
|
||||
// Two records on distinct calendar dates >= 3.5 -> alert fires
|
||||
const distinctDateRecords = [
|
||||
{ avg_de: 1.0, timestamp: '2026-08-15T10:00:00Z' },
|
||||
{ avg_de: 3.6, timestamp: '2026-09-01T10:00:00Z' },
|
||||
{ avg_de: 3.8, timestamp: '2026-09-05T10:00:00Z' },
|
||||
];
|
||||
const alert = checkBreachAlert(distinctDateRecords);
|
||||
assert(typeof alert === 'string' && alert.includes('Re-profiling Recommended') && alert.includes('2 consecutive'), true, 'distinct dates breach alert fires');
|
||||
|
||||
// Two records >= 1 hour apart on same day -> alert fires
|
||||
const hourApartRecords = [
|
||||
{ avg_de: 3.7, timestamp: '2026-09-05T10:00:00Z' },
|
||||
{ avg_de: 3.9, timestamp: '2026-09-05T12:30:00Z' },
|
||||
];
|
||||
const alert2 = checkBreachAlert(hourApartRecords);
|
||||
assert(typeof alert2 === 'string' && alert2.includes('Re-profiling Recommended'), true, 'records >=1 hr apart breach alert fires');
|
||||
|
||||
// Trailing record is good -> no alert
|
||||
const recoveredRecords = [
|
||||
{ avg_de: 3.7, timestamp: '2026-09-01T10:00:00Z' },
|
||||
{ avg_de: 3.9, timestamp: '2026-09-02T10:00:00Z' },
|
||||
{ avg_de: 0.9, timestamp: '2026-09-03T10:00:00Z' },
|
||||
];
|
||||
assert(checkBreachAlert(recoveredRecords), null, 'recovered profile returns null');
|
||||
|
||||
console.log(`Profcheck tests complete: ${passed}/${total} passed`);
|
||||
console.groupEnd();
|
||||
return passed === total;
|
||||
}
|
||||
|
||||
// Auto-run if executed directly in Node
|
||||
if (typeof process !== 'undefined' && process.argv && process.argv[1] && process.argv[1].endsWith('profcheck.test.js')) {
|
||||
const ok = runAll();
|
||||
if (!ok) process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Stage 5 — install a verified ICC/ICM profile into the OS colour store (#223).
|
||||
*/
|
||||
|
||||
import { wizardState } from './state.js';
|
||||
import { logger } from './logger.js';
|
||||
import { getActiveCalibration } from './calibration.js';
|
||||
|
||||
const invoke = (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke)
|
||||
? window.__TAURI__.core.invoke.bind(window.__TAURI__.core)
|
||||
: async () => { throw new Error('Tauri invoke unavailable'); };
|
||||
|
||||
const installState = {
|
||||
profilePath: null,
|
||||
verified: false,
|
||||
installedPath: null,
|
||||
};
|
||||
|
||||
export function calibrationInstallNote() {
|
||||
const cal = getActiveCalibration();
|
||||
if (!cal.calPath || !cal.applyEnabled) return null;
|
||||
const name = cal.filename || cal.calPath.split(/[\\/]/).pop();
|
||||
return `Linearization curves from ${name} were applied when this profile was built.`;
|
||||
}
|
||||
|
||||
export function setProfileInstallSource(profilePath, verified) {
|
||||
installState.profilePath = profilePath || null;
|
||||
installState.verified = !!verified;
|
||||
if (!verified) installState.installedPath = null;
|
||||
refreshInstallButton();
|
||||
}
|
||||
|
||||
function refreshInstallButton() {
|
||||
const btn = document.getElementById('btnInstallProfile');
|
||||
if (!btn) return;
|
||||
const ready = !!installState.profilePath && installState.verified;
|
||||
const already = !!installState.installedPath;
|
||||
btn.disabled = !ready || already;
|
||||
if (already) {
|
||||
btn.textContent = 'Installed ✓';
|
||||
btn.title = `Already installed to ${installState.installedPath}`;
|
||||
} else if (!ready) {
|
||||
btn.textContent = 'Install Profile to System';
|
||||
btn.title = 'Copies the generated ICC/ICM into the OS colour-profile directory so print dialogs and colour-managed applications can discover it. Requires a successful verification. Elevated privileges on some platforms.';
|
||||
} else {
|
||||
btn.textContent = 'Install Profile to System';
|
||||
btn.title = 'Copies the generated ICC/ICM profile into the operating system’s standard colour-profile directory so that print dialogs and colour-managed applications can discover it. Requires elevated privileges on some platforms.';
|
||||
}
|
||||
}
|
||||
|
||||
function collisionChoice(message) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.getElementById('profileInstallCollisionDialog');
|
||||
const msg = document.getElementById('profileInstallCollisionMessage');
|
||||
if (msg) msg.textContent = message;
|
||||
if (!dialog || typeof dialog.showModal !== 'function') {
|
||||
resolve(window.confirm(`${message}\n\nOverwrite?`) ? 'overwrite' : 'cancel');
|
||||
return;
|
||||
}
|
||||
const finish = (choice) => {
|
||||
dialog.close();
|
||||
resolve(choice);
|
||||
};
|
||||
document.getElementById('profileOverwriteBtn')?.addEventListener('click', () => finish('overwrite'), { once: true });
|
||||
document.getElementById('profileRenameBtn')?.addEventListener('click', () => finish('rename'), { once: true });
|
||||
document.getElementById('profileCancelCollisionBtn')?.addEventListener('click', () => finish('cancel'), { once: true });
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInstallPrefs() {
|
||||
try {
|
||||
const settings = await invoke('load_settings');
|
||||
return {
|
||||
preferSystem: (settings.default_install_location || 'user') === 'system',
|
||||
askOverwrite: settings.ask_before_overwrite_profile !== false,
|
||||
openPanel: !!settings.open_color_panel_after_install,
|
||||
};
|
||||
} catch (_) {
|
||||
return { preferSystem: false, askOverwrite: true, openPanel: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function doInstall(policy = 'cancel') {
|
||||
const btn = document.getElementById('btnInstallProfile');
|
||||
const prefs = await loadInstallPrefs();
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Installing…';
|
||||
}
|
||||
const options = {
|
||||
force_overwrite: policy === 'overwrite',
|
||||
prefer_system_wide: prefs.preferSystem,
|
||||
register_with_os: true,
|
||||
collision_policy: policy,
|
||||
open_color_panel: prefs.openPanel,
|
||||
calibration_note: calibrationInstallNote(),
|
||||
};
|
||||
try {
|
||||
const result = await invoke('install_profile_to_system', {
|
||||
profilePath: installState.profilePath,
|
||||
options,
|
||||
});
|
||||
installState.installedPath = result.dest_path;
|
||||
wizardState.showNotice(result.message, 'success', 8000);
|
||||
logger.info(result.message, 'ProfileInstall');
|
||||
const log = document.getElementById('profcheckLog');
|
||||
if (log) log.textContent += `\n[INSTALL] ${result.message}\n`;
|
||||
refreshInstallButton();
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
if (/already exists/i.test(message) && prefs.askOverwrite && policy === 'cancel') {
|
||||
const choice = await collisionChoice(message);
|
||||
if (choice !== 'cancel') {
|
||||
await doInstall(choice);
|
||||
return;
|
||||
}
|
||||
}
|
||||
wizardState.showNotice(`Install failed: ${err}`, 'error', 9000);
|
||||
logger.error(`install_profile_to_system: ${err}`, 'ProfileInstall');
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Install Profile to System';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initProfileInstall() {
|
||||
const btn = document.getElementById('btnInstallProfile');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => doInstall('cancel'));
|
||||
}
|
||||
refreshInstallButton();
|
||||
}
|
||||
|
||||
export function runProfileInstallTests() {
|
||||
const noteNone = (() => {
|
||||
// Pure helper coverage lives in calibrationInstallNote via getActiveCalibration.
|
||||
return typeof calibrationInstallNote === 'function';
|
||||
})();
|
||||
return { helperExported: noteNone };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// node src/js/profile_install.test.js
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
globalThis.window = {
|
||||
__TAURI__: {
|
||||
core: { invoke: () => Promise.resolve({}) },
|
||||
event: { listen: () => Promise.resolve(() => {}) }
|
||||
},
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => {}
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById: () => null,
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
createElement: () => ({ style: {}, appendChild() {}, addEventListener() {}, setAttribute() {} })
|
||||
};
|
||||
globalThis.localStorage = { getItem: () => null, setItem: () => {} };
|
||||
}
|
||||
|
||||
const { calibrationInstallNote, setProfileInstallSource } = await import('./profile_install.js');
|
||||
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
function assert(cond, name) {
|
||||
total += 1;
|
||||
if (cond) { passed += 1; console.log(` ok ${name}`); }
|
||||
else console.error(` FAIL ${name}`);
|
||||
}
|
||||
|
||||
export function runAll() {
|
||||
passed = 0; total = 0;
|
||||
console.log('profile_install.test.js');
|
||||
assert(typeof calibrationInstallNote === 'function', 'exports calibrationInstallNote');
|
||||
assert(calibrationInstallNote() === null, 'no note when calibration is inactive');
|
||||
assert(typeof setProfileInstallSource === 'function', 'exports setProfileInstallSource');
|
||||
setProfileInstallSource('/tmp/demo.icc', true);
|
||||
setProfileInstallSource(null, false);
|
||||
console.log(`\n${passed}/${total} passed`);
|
||||
if (passed !== total) process.exitCode = 1;
|
||||
}
|
||||
|
||||
runAll();
|
||||
@@ -70,6 +70,18 @@ export async function initSettings() {
|
||||
if (deltaEWarningMax) {
|
||||
deltaEWarningMax.value = Number(settings.delta_e_warning_max ?? 5.0).toFixed(1);
|
||||
}
|
||||
const staleDays = document.getElementById('calibrationStaleDays');
|
||||
if (staleDays) {
|
||||
staleDays.value = Number(settings.calibration_stale_days ?? 30);
|
||||
}
|
||||
const installLoc = document.getElementById('defaultInstallLocation');
|
||||
if (installLoc) {
|
||||
installLoc.value = settings.default_install_location === 'system' ? 'system' : 'user';
|
||||
}
|
||||
const askOw = document.getElementById('askBeforeOverwriteProfile');
|
||||
if (askOw) askOw.checked = settings.ask_before_overwrite_profile !== false;
|
||||
const openPanel = document.getElementById('openColorPanelAfterInstall');
|
||||
if (openPanel) openPanel.checked = !!settings.open_color_panel_after_install;
|
||||
validateDeltaEThresholds();
|
||||
await refreshLogPath();
|
||||
dialog.showModal();
|
||||
@@ -150,6 +162,12 @@ export async function initSettings() {
|
||||
delta_e_good_max: getInputValueAsFloat('deltaEGoodMax', 2.0),
|
||||
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
|
||||
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
|
||||
calibration_stale_days: Math.max(1, parseInt(document.getElementById('calibrationStaleDays')?.value, 10) || 30),
|
||||
default_install_location: document.getElementById('defaultInstallLocation')?.value === 'system' ? 'system' : 'user',
|
||||
ask_before_overwrite_profile: document.getElementById('askBeforeOverwriteProfile')
|
||||
? document.getElementById('askBeforeOverwriteProfile').checked : true,
|
||||
open_color_panel_after_install: document.getElementById('openColorPanelAfterInstall')
|
||||
? document.getElementById('openColorPanelAfterInstall').checked : false,
|
||||
};
|
||||
await invoke('save_settings', { settings });
|
||||
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
|
||||
|
||||
+25
-2
@@ -1,10 +1,15 @@
|
||||
import { ensureGamutViewer, pauseGamutViewer } from './gamut_viewer.js';
|
||||
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
export const wizardState = {
|
||||
currentStage: 1,
|
||||
basename: "",
|
||||
cwd: "",
|
||||
printerName: "",
|
||||
noticeTimer: null,
|
||||
sessionMode: "profile",
|
||||
profileBasename: "",
|
||||
|
||||
setTarget(basename, cwd) {
|
||||
if (basename) this.basename = basename;
|
||||
@@ -58,6 +63,10 @@ export const wizardState = {
|
||||
const stages = document.querySelectorAll('.stage');
|
||||
|
||||
steps.forEach(s => {
|
||||
if (stageNumber === 0) {
|
||||
s.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
if (s.getAttribute('data-step') === String(stageNumber)) {
|
||||
s.classList.remove('disabled');
|
||||
s.classList.add('active');
|
||||
@@ -67,7 +76,7 @@ export const wizardState = {
|
||||
});
|
||||
|
||||
stages.forEach(s => {
|
||||
if (s.id === `stage-${stageNumber}`) {
|
||||
if (s.id === `stage-${stageNumber}` || (stageNumber === 0 && s.id === 'stage-cal')) {
|
||||
s.classList.remove('hidden');
|
||||
s.classList.add('active');
|
||||
} else {
|
||||
@@ -75,11 +84,25 @@ export const wizardState = {
|
||||
s.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } }));
|
||||
|
||||
if (stageNumber === 5) {
|
||||
ensureGamutViewer();
|
||||
} else {
|
||||
pauseGamutViewer();
|
||||
}
|
||||
},
|
||||
|
||||
async navigateToStage(stageNumber) {
|
||||
const targetNum = parseInt(stageNumber, 10);
|
||||
if (isNaN(targetNum) || targetNum < 1 || targetNum > 5) return false;
|
||||
if (isNaN(targetNum) || targetNum < 0 || targetNum > 5) return false;
|
||||
|
||||
if (targetNum === 0) {
|
||||
this.currentStage = 0;
|
||||
this.applyStageDOM(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetNum === 1) {
|
||||
this.currentStage = 1;
|
||||
|
||||
+368
-1
@@ -1158,7 +1158,7 @@ button.danger:hover {
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.badge-printing {
|
||||
.badge-printing, .badge-primary {
|
||||
background: rgba(33, 150, 243, 0.15);
|
||||
color: #64b5f6;
|
||||
border: 1px solid rgba(33, 150, 243, 0.3);
|
||||
@@ -1491,6 +1491,25 @@ button.danger:hover {
|
||||
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 ─────────────────────────────────────────────── */
|
||||
|
||||
.gamut-controls-panel {
|
||||
@@ -1671,5 +1690,353 @@ button.danger:hover {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Longitudinal Printer Drift Tracking & Verification History — Stage 5
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.drift-history {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.drift-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.drift-filter-row select {
|
||||
max-width: 240px;
|
||||
padding: 4px 8px;
|
||||
height: 30px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.drift-chart-wrap {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 14px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#driftTrendChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.drift-chart-grid line {
|
||||
stroke: rgba(255, 255, 255, 0.08);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.drift-chart-axis line,
|
||||
.drift-chart-axis path {
|
||||
stroke: rgba(255, 255, 255, 0.2);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.drift-chart-text {
|
||||
fill: #888;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.drift-band-label {
|
||||
fill: rgba(255, 255, 255, 0.45);
|
||||
font-size: 9px;
|
||||
text-anchor: end;
|
||||
}
|
||||
|
||||
.drift-line-avg {
|
||||
fill: none;
|
||||
stroke: #3b82f6;
|
||||
stroke-width: 2.5;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.drift-line-max {
|
||||
fill: none;
|
||||
stroke: #f59e0b;
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 4, 3;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.drift-dot-avg {
|
||||
fill: #3b82f6;
|
||||
stroke: #1e293b;
|
||||
stroke-width: 2;
|
||||
cursor: pointer;
|
||||
transition: r 0.15s ease, fill 0.15s ease;
|
||||
}
|
||||
|
||||
.drift-dot-avg:hover {
|
||||
r: 6;
|
||||
fill: #60a5fa;
|
||||
}
|
||||
|
||||
.drift-dot-max {
|
||||
fill: #f59e0b;
|
||||
stroke: #1e293b;
|
||||
stroke-width: 1.5;
|
||||
cursor: pointer;
|
||||
transition: r 0.15s ease, fill 0.15s ease;
|
||||
}
|
||||
|
||||
.drift-dot-max:hover {
|
||||
r: 5;
|
||||
fill: #fbbf24;
|
||||
}
|
||||
|
||||
.drift-table-wrap {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.drift-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.drift-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-surface, #1e1e1e);
|
||||
color: var(--text-muted, #aaa);
|
||||
font-weight: 600;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.drift-table td {
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.drift-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.drift-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
XY Automated Scanning Table Sequence — Stage 3
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.xy-table-panel {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xy-table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xy-table-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #eee);
|
||||
}
|
||||
|
||||
.xy-steps-list {
|
||||
list-style: none;
|
||||
counter-reset: xy-step-counter;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xy-step {
|
||||
counter-increment: xy-step-counter;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #888);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xy-step::before {
|
||||
content: counter(xy-step-counter);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ccc;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xy-step.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #93c5fd;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xy-step.active::before {
|
||||
background: var(--accent-color, #3b82f6);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.xy-step.completed {
|
||||
color: #86efac;
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.xy-step.completed::before {
|
||||
content: "✓";
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
/* Printer calibration dashboard (#224) */
|
||||
.cal-status-chip {
|
||||
margin-top: 8px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.3;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted, #9aa);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.cal-status-active {
|
||||
color: #86efac;
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
.cal-status-stale {
|
||||
color: #fbbf24;
|
||||
border-color: rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
.cal-status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(0, 122, 204, 0.08);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.cal-banner-stale {
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
.cal-banner-active {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.cal-apply-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.cal-dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.cal-dashboard-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.cal-card {
|
||||
background: var(--panel-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.cal-card h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.cal-curve-svg {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background: #14141a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.cal-grid {
|
||||
stroke: #2a2a33;
|
||||
stroke-width: 1;
|
||||
}
|
||||
.cal-axis-label {
|
||||
fill: #888;
|
||||
font-size: 10px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.cal-curve-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.cal-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.cal-legend-item i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
.cal-tac-card {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cal-ink-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4rem 1fr 4.5rem auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cal-ink-row input[type="number"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user