Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87e8e65771 | ||
|
|
3cf2a0f441 | ||
|
|
fb766c15d6 | ||
|
|
26b2337a16 | ||
|
|
3e7ece746b | ||
|
|
f47e65ee1f | ||
|
|
193e492c10 | ||
|
|
899718d896 | ||
|
|
7a9637b5d0 | ||
|
|
1af844c7e2 | ||
|
|
56f275a023 | ||
|
|
54792a8690 | ||
|
|
906b4f9104 | ||
|
|
5f5bf76594 | ||
|
|
9c7d563dbf | ||
|
|
9102c95468 | ||
|
|
743ad86f58 | ||
|
|
11e796fd17 | ||
|
|
ebbdbd4f55 | ||
|
|
ef0d65453e | ||
|
|
9f2ff015d7 | ||
|
|
a8c4025c01 | ||
|
|
5a1ad654b8 | ||
|
|
ec7eb2fcb4 | ||
|
|
c5ff3d1a49 | ||
|
|
8008d9bda4 | ||
|
|
ebcb3cdc2a | ||
|
|
2641edecc1 | ||
|
|
4621d734ff |
@@ -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
|
||||
@@ -93,18 +95,11 @@ jobs:
|
||||
mkdir -p release-assets
|
||||
|
||||
# Stage DMG package
|
||||
DMG_FILE=$(find src-tauri/target -type f -name "*.dmg" | head -n 1)
|
||||
DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1)
|
||||
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
|
||||
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
||||
fi
|
||||
|
||||
# Stage ZIP archive of .app bundle
|
||||
APP_DIR=$(find src-tauri/target -type d -name "*.app" | head -n 1)
|
||||
if [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then
|
||||
APP_PARENT=$(dirname "$APP_DIR")
|
||||
APP_NAME=$(basename "$APP_DIR")
|
||||
(cd "$APP_PARENT" && zip -r "${GITHUB_WORKSPACE}/release-assets/${PREFIX}.zip" "$APP_NAME")
|
||||
fi
|
||||
|
||||
- name: Upload Artifacts
|
||||
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:
|
||||
@@ -103,13 +113,6 @@ jobs:
|
||||
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
||||
fi
|
||||
|
||||
# Stage ZIP archive of .app bundle
|
||||
APP_DIR=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1)
|
||||
if [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then
|
||||
APP_PARENT=$(dirname "$APP_DIR")
|
||||
APP_NAME=$(basename "$APP_DIR")
|
||||
(cd "$APP_PARENT" && zip -r "${GITHUB_WORKSPACE}/release-assets/${PREFIX}.zip" "$APP_NAME")
|
||||
fi
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -10,6 +10,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`.
|
||||
@@ -115,7 +118,7 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI
|
||||
- Record schema (`VerificationRecord` in `src-tauri/src/quality_store.rs`):
|
||||
- `id`: unique record identifier in the format `vr-<epoch_millis>-<seq>`.
|
||||
- `profile_name`: target profile filename.
|
||||
- `printer`: device name captured at print spooling (`wizardState.printerName`), or "Unknown".
|
||||
- `printer_name`: device name captured at print spooling (`wizardState.printerName`), or "Unknown".
|
||||
- `avg_de`, `max_de`, `rms_de`: CIEDE2000 metrics from `profcheck` (using `-u` JSON summary).
|
||||
- `patch_count`: number of test patches evaluated.
|
||||
- `status`: classified status using **ICCery verification bands (issue #95)**:
|
||||
@@ -124,7 +127,7 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI
|
||||
- `< 3.5`: "Acceptable" (`badge-acceptable`)
|
||||
- `>= 3.5`: "Warning" (`badge-poor`)
|
||||
- `timestamp`: ISO-8601 UTC string.
|
||||
- Max capacity is 500 records; oldest records evicted on overflow.
|
||||
- Max capacity is 1,000 records; oldest records evicted on overflow.
|
||||
- Atomic file writes (`.tmp` write followed by `rename`) prevent data corruption.
|
||||
- Tauri IPC command casing:
|
||||
- Nested struct fields (`VerificationRecord`) serialize with `snake_case`.
|
||||
@@ -163,9 +166,9 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI
|
||||
- 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 Testing
|
||||
## CI & Cross-Compilation
|
||||
|
||||
- macOS CI workflow lives in `.gitea/workflows/build-macos.yml`.
|
||||
- On Intel runner hosts, cross-compiling for Apple Silicon (`aarch64-apple-darwin`) must use `cargo test --no-run --target aarch64-apple-darwin`. This validates sidecar packaging, compilation, and link correctness without attempting to execute ARM64 binaries on an Intel CPU (`Bad CPU type in executable (os error 86)`).
|
||||
- Universal and native Intel matrix jobs run and execute full tests natively.
|
||||
- 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,7 +2,7 @@
|
||||
|
||||
> 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://tauri.app)
|
||||
[](LICENCE.md)
|
||||
@@ -27,7 +27,7 @@
|
||||
- **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 500 records), an interactive dual-series SVG trend chart with shaded ICCery verification reference bands, consecutive-breach alert recommendation card (detecting drift across distinct dates or $\ge 1$ hour apart), and RFC-4180 compliant CSV export.
|
||||
- **Longitudinal Printer Drift Analytics (#95)**: Historical verification logging persisted to `verification_history.json` (up to 1,000 records), an interactive dual-series SVG trend chart with shaded ICCery verification reference bands, consecutive-breach alert recommendation card (detecting drift across distinct dates or $\ge 1$ hour apart), and RFC-4180 compliant CSV export.
|
||||
- **Mathematical Accuracy Report**: Peak, Average, and RMS CIEDE2000 metrics with robust parsing of both Argyll JSON summaries (`-u`) and legacy plain-text reports.
|
||||
- **Interactive 3D Gamut Viewer**: CIELAB coordinate scaffold with crisp CSS2D labels, per-vertex true-colour profile gamut shading, layer visibility toggles and opacity sliders, camera reset (press **R**), touch controls, and bundled sRGB reference wireframe comparison.
|
||||
- 📊 **CGATS Dataset Interoperability (#94)**: Native parser for external CGATS and Argyll `.ti3` datasets with canonical normalization (0–255 scaling, field aliasing, metadata synthesis) and direct-jump workflows to Stage 4 (Profile Calculation) and Stage 5 (Verification).
|
||||
@@ -88,7 +88,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 11.0 (Big Sur) or newer, Xcode Command Line Tools (`xcode-select --install`).
|
||||
- **macOS**: macOS 12.0 (Monterey) or newer, Xcode Command Line Tools (`xcode-select --install`).
|
||||
- **Windows**: Microsoft Visual Studio C++ Build Tools & WebView2 runtime.
|
||||
- **Linux (Debian/Ubuntu)**: `libwebkit2gtk-4.1-dev`, `build-essential`, `curl`, `wget`, `file`, `libxdo-dev`, `libssl-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libcups2-dev`.
|
||||
|
||||
@@ -127,6 +127,40 @@ npm run tauri build
|
||||
|
||||
---
|
||||
|
||||
## Platform support
|
||||
|
||||
### macOS
|
||||
|
||||
The packaged app declares `LSMinimumSystemVersion = 12.0`. Installers refuse Catalina and Big Sur rather than launching into a WKWebView crash loop.
|
||||
|
||||
| macOS | Status |
|
||||
|---|---|
|
||||
| 13+ (Ventura and newer), Apple Silicon | Supported |
|
||||
| 13+, Intel | Supported |
|
||||
| 12.7.x Monterey, Apple Silicon | Supported, WebGL best-effort |
|
||||
| 12.0–12.6 Monterey, Intel | Best-effort; WebGL is deferred until Stage 5; known WKWebView GPU process crashes |
|
||||
| 11 Big Sur | Not supported (installer refuses) |
|
||||
| 10.15 Catalina | Not supported |
|
||||
|
||||
The 3D gamut viewer (Stage 5) creates a WebGL context only when that stage is shown. Stages 1–4 remain usable if WebGL is missing or the GPU process is lost.
|
||||
|
||||
### macOS troubleshooting
|
||||
|
||||
If the window flashes white and disappears, this is almost always the WKWebView **Web Content** or **GPU** helper dying — Apple Crash Reporter will not attach to `ICCery.app`.
|
||||
|
||||
- Launch from Terminal to see `web content process terminated`:
|
||||
```text
|
||||
/Applications/ICCery.app/Contents/MacOS/ICCery
|
||||
```
|
||||
- Check `~/Library/Logs/DiagnosticReports` for `com.apple.WebKit.WebContent` or `com.apple.WebKit.GPU`.
|
||||
- ICCery log file (rotated, last 5 segments kept):
|
||||
```text
|
||||
~/Library/Logs/com.gronod.iccery/iccery.log
|
||||
```
|
||||
- Custom ColorSync display profiles can crash toolkit UIs on Monterey. Testing with the default display profile (or Safe Mode) is a valid support question.
|
||||
|
||||
---
|
||||
|
||||
## Licence
|
||||
|
||||
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).
|
||||
|
||||
+12
-2
@@ -14,7 +14,7 @@ ICCery is a native, cross-platform desktop application built with:
|
||||
- **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout.
|
||||
- **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.2` (Production release).
|
||||
- **Current Version**: `v0.8.4` (Production release).
|
||||
|
||||
---
|
||||
|
||||
@@ -104,10 +104,20 @@ ICCery is a native, cross-platform desktop application built with:
|
||||
- [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` (500 records), interactive dual-series SVG trend chart with ICCery verification reference bands, consecutive-breach alert recommendation card, and RFC-4180 CSV export.
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
## 3. Future Roadmap
|
||||
|
||||
+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.2",
|
||||
"version": "0.8.4",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
|
||||
Generated
+1
-1
@@ -1423,7 +1423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iccery"
|
||||
version = "0.8.2"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
|
||||
+12
-1
@@ -1,9 +1,20 @@
|
||||
[package]
|
||||
name = "iccery"
|
||||
version = "0.8.2"
|
||||
version = "0.8.4"
|
||||
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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
@@ -2041,4 +2177,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);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-9
@@ -3,10 +3,12 @@ use tauri::Manager;
|
||||
mod cgats;
|
||||
mod commands;
|
||||
mod events;
|
||||
mod macos_webview;
|
||||
mod print;
|
||||
mod process_manager;
|
||||
mod quality_store;
|
||||
mod settings;
|
||||
mod window_lifecycle;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -21,6 +23,10 @@ pub fn run() {
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::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(),
|
||||
@@ -33,6 +39,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())
|
||||
@@ -40,6 +52,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,
|
||||
@@ -51,6 +65,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,
|
||||
@@ -93,21 +109,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 { .. },
|
||||
..
|
||||
} => {
|
||||
let pm = app_handle.state::<process_manager::ProcessManager>();
|
||||
tauri::async_runtime::block_on(async {
|
||||
pm.kill_all().await;
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -100,15 +100,21 @@ pub fn load_history_file(path: &Path) -> Vec<VerificationRecord> {
|
||||
}
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read verification history at {}: {}", path.display(), e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
match serde_json::from_str::<VerificationHistoryStore>(&content) {
|
||||
Ok(store) => store.records,
|
||||
Err(_) => Vec::new(),
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse verification history at {}: {}", path.display(), e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes verification records to the specified JSON path.
|
||||
/// Writes verification records to the specified JSON path atomically.
|
||||
/// Automatically creates parent directories if needed.
|
||||
/// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000).
|
||||
pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> {
|
||||
@@ -132,7 +138,33 @@ pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result
|
||||
|
||||
let json = serde_json::to_string_pretty(&store)
|
||||
.map_err(|e| format!("Failed to serialize verification history: {}", e))?;
|
||||
fs::write(path, json).map_err(|e| format!("Failed to write verification history: {}", e))?;
|
||||
|
||||
let mut tmp_path = path.as_os_str().to_os_string();
|
||||
tmp_path.push(".tmp");
|
||||
let tmp_path = PathBuf::from(tmp_path);
|
||||
|
||||
// Write to temporary file with explicit flush and sync
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = fs::File::create(&tmp_path)
|
||||
.map_err(|e| format!("Failed to create temp history file: {}", e))?;
|
||||
file.write_all(json.as_bytes())
|
||||
.map_err(|e| {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
format!("Failed to write temp history file: {}", e)
|
||||
})?;
|
||||
file.sync_all()
|
||||
.map_err(|e| {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
format!("Failed to sync temp history file: {}", e)
|
||||
})?;
|
||||
}
|
||||
|
||||
// Atomically replace destination file
|
||||
if let Err(e) = fs::rename(&tmp_path, path) {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
return Err(format!("Failed to atomically replace verification history file: {}", e));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -418,4 +450,38 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.2",
|
||||
"version": "0.8.4",
|
||||
"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": {
|
||||
|
||||
+3
-3
@@ -1059,9 +1059,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>
|
||||
|
||||
+57
-4
@@ -4,7 +4,7 @@ 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';
|
||||
@@ -12,6 +12,36 @@ import { CgatsInterop } from './cgats_interop.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 +69,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 +91,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 +125,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize all stages & features safely
|
||||
// Initialize all stages & features safely.
|
||||
// Gamut Viewer is deferred until Stage 5 is shown (eager WebGL on launch
|
||||
// respawns WKWebView on Monterey Intel).
|
||||
safeInit('Stage 1 (Targen)', initTargen);
|
||||
safeInit('Stage 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);
|
||||
});
|
||||
|
||||
// Double-rAF waits for layout + first paint of the dark CSS.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
revealMainWindow();
|
||||
});
|
||||
});
|
||||
// Fallback so a JS exception cannot leave a permanently hidden window.
|
||||
setTimeout(revealMainWindow, 1500);
|
||||
});
|
||||
+40
-11
@@ -1,4 +1,6 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke
|
||||
? window.__TAURI__.core.invoke
|
||||
: async () => {};
|
||||
|
||||
export class CgatsInterop {
|
||||
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
|
||||
await this.appState.updateGating();
|
||||
this.appState.currentStage = 4;
|
||||
this.appState.applyStageDOM(4);
|
||||
if (this.appState?.updateGating) {
|
||||
await this.appState.updateGating();
|
||||
}
|
||||
if (this.appState) {
|
||||
this.appState.currentStage = 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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -54,8 +54,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;
|
||||
|
||||
+253
-23
@@ -1,22 +1,210 @@
|
||||
import { computeQuickHull } from "./vendor/quickhull.js";
|
||||
import { computeQuickHull } from "./vendor/quickhull.js";
|
||||
import { labToSrgb } from "./color_convert.js";
|
||||
import { 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,21 +289,24 @@ export async function initGamutViewer() {
|
||||
buildAxisScaffold();
|
||||
|
||||
// ── Resize handling ───────────────────────────────────────────────────
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
const h = entry.contentRect.height;
|
||||
if (w > 0 && h > 0 && renderer && camera) {
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
if (labelRenderer) labelRenderer.setSize(w, h);
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
const h = entry.contentRect.height;
|
||||
if (w > 0 && h > 0 && renderer && camera) {
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
if (labelRenderer) labelRenderer.setSize(w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
});
|
||||
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,3 +1,5 @@
|
||||
import { ensureGamutViewer, pauseGamutViewer } from './gamut_viewer.js';
|
||||
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
export const wizardState = {
|
||||
@@ -78,6 +80,12 @@ export const wizardState = {
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } }));
|
||||
|
||||
if (stageNumber === 5) {
|
||||
ensureGamutViewer();
|
||||
} else {
|
||||
pauseGamutViewer();
|
||||
}
|
||||
},
|
||||
|
||||
async navigateToStage(stageNumber) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user