Author SHA1 Message Date
gbolton2008 87e8e65771 docs(macos): support matrix, log path, and Monterey troubleshooting (#225)
Build Windows Packages / Build Windows (push) Canceled after 25s
Build Linux Packages / Build Linux (push) Canceled after 27s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 10m39s
Build macOS Packages / Build macOS (Intel) (push) Successful in 10m50s
Document the 12.0+ policy, WebGL best-effort on Monterey Intel,
iccery.log location, and DiagnosticReports WebKit helpers.
2026-09-07 15:55:04 +00:00
gbolton2008 3cf2a0f441 fix(macos): hide window, dark backgroundColor, require 12.0 (#225)
visible:false avoids the first white WKWebView frame. backgroundColor
matches the dark theme. LSMinimumSystemVersion 12.0 stops Catalina
and Big Sur installing a binary that flash-loops.
2026-09-07 15:55:04 +00:00
gbolton2008 fb766c15d6 fix(window): show_main_window, dark WKWebView backing, os info (#225)
Keep the window hidden until the frontend signals first paint,
re-apply #1A1A22 NSWindow/WKWebView backing (drawsBackground=NO),
and expose os/arch/macos version for the Intel Monterey notice.
2026-09-07 15:55:04 +00:00
gbolton2008 26b2337a16 fix(host): log unexpected window destroy and Web Content death (#225)
Helper-process crashes do not produce an ICCery Crash Reporter
dialog. Record CloseRequested vs unexpected Destroyed, and detect
wry 'web content process terminated' strings for iccery.log.
2026-09-07 15:55:04 +00:00
gbolton2008 3e7ece746b fix(webgl): defer Stage 5 renderer and survive context loss (#225)
Eager THREE.WebGLRenderer + rAF during DOMContentLoaded respawns
WKWebView on Monterey Intel. Create the context only when Stage 5
is shown, feature-detect WebGL, pause the loop when hidden, and
leave a fallback UI if the GPU context is missing or lost.
2026-09-07 15:55:04 +00:00
gronod f47e65ee1f Merge pull request 'Main' (#221) from main into development
Reviewed-on: #221
2026-09-06 21:11:22 +01:00
gronod 193e492c10 Update .gitea/workflows/build-macos.yml
Build Windows Packages / Build Windows (push) Successful in 7m35s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 9m25s
Build Linux Packages / Build Linux (push) Successful in 10m52s
Build macOS Packages / Build macOS (Intel) (push) Successful in 7m14s
Re-enabled arm64
2026-09-06 20:16:52 +01:00
gronod 899718d896 Merge pull request 'Merge development into main' (#220) from development into main
Build Windows Packages / Build Windows (push) Successful in 7m44s
Build macOS Packages / Build macOS (Intel) (push) Successful in 8m18s
Build Linux Packages / Build Linux (push) Successful in 10m18s
2026-09-06 19:26:32 +01:00
gronod 7a9637b5d0 Merge pull request 'ci(macos): allow headless DMG bundling on Gitea runners' (#219) from ci/fix-macos-dmg-bundling into development 2026-09-06 19:26:24 +01:00
gronod 1af844c7e2 ci(macos): allow headless DMG bundling on Gitea runners 2026-09-06 19:24:30 +01:00
gronod 56f275a023 Merge branch 'ci/215-frontend-tests' into development (#215) 2026-09-06 16:40:43 +01:00
gronod 54792a8690 Merge branch 'fix/213-atomic-history-writes' into development (#213) 2026-09-06 16:40:41 +01:00
gronod 906b4f9104 ci: add npm test script and integrate frontend tests into CI workflows (#215) 2026-09-06 16:40:37 +01:00
gronod 5f5bf76594 fix(quality_store): atomic file writes for verification_history.json (#213) 2026-09-06 16:39:53 +01:00
gronod 9c7d563dbf chore: bump version to v0.8.4 2026-09-06 16:37:59 +01:00
gronod 9102c95468 ci(macos): remove redundant cargo test step from release workflow
Build Windows Packages / Build Windows (push) Successful in 7m13s
Build Linux Packages / Build Linux (push) Successful in 10m27s
Build macOS Packages / Build macOS (Intel) (push) Failing after 10m35s
Merge pull request 'ci(macos): remove redundant cargo test step from release workflow' (#218) from development into main
2026-09-06 16:23:09 +01:00
gronod 743ad86f58 ci(macos): remove redundant cargo test step from release workflow 2026-09-06 16:22:57 +01:00
gronod 11e796fd17 Development
Build Windows Packages / Build Windows (push) Successful in 7m39s
Build Linux Packages / Build Linux (push) Successful in 9m54s
Build macOS Packages / Build macOS (Intel) (push) Failing after 14m45s
Merge pull request 'Development' (#217) from development into main
2026-09-06 16:08:49 +01:00
gronod ebbdbd4f55 chore: disable Apple Silicon and Universal build targets in macOS workflow 2026-09-06 16:06:24 +01:00
gronod ef0d65453e chore: configure git permissions in CI workflows and restrict crate source inclusion and build re-run triggers 2026-09-06 16:04:34 +01:00
gronod 9f2ff015d7 fix(cgats): support dataset file selection, sync wizard target state, and bump to v0.8.3 (#211) 2026-09-06 14:57:11 +01:00
gronod 5a1ad654b8 fix(colprof): add select_spectrum_file command for custom sp browse (#210) 2026-09-06 12:51:52 +01:00
gronod ec7eb2fcb4 test(gamut): fix gamut_viewer.test.js node execution and guard window globals (#212) 2026-09-06 12:48:43 +01:00
24 changed files with 1098 additions and 106 deletions
+3
View File
@@ -64,6 +64,9 @@ jobs:
- name: Install Node dependencies - name: Install Node dependencies
run: npm ci run: npm ci
- name: Run frontend tests
run: npm test
- name: Set Release Environment - name: Set Release Environment
id: set_env id: set_env
shell: bash shell: bash
+21 -35
View File
@@ -9,6 +9,8 @@ jobs:
build-macos: build-macos:
name: Build macOS (${{ matrix.platform.name }}) name: Build macOS (${{ matrix.platform.name }})
runs-on: ${{ matrix.platform.os }} runs-on: ${{ matrix.platform.os }}
env:
XDG_CONFIG_HOME: ${{ runner.temp }}/.config
strategy: strategy:
matrix: matrix:
platform: platform:
@@ -22,16 +24,26 @@ jobs:
target: aarch64-apple-darwin target: aarch64-apple-darwin
binary_dir: macos-aarch64 binary_dir: macos-aarch64
arch: arm64 arch: arm64
- name: Universal (Intel + Apple Silicon) # - name: Universal (Intel + Apple Silicon)
os: macos # os: macos
target: universal-apple-darwin # target: universal-apple-darwin
binary_dir: macos-universal # binary_dir: macos-universal
arch: universal # arch: universal
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Ensure workspace and git permissions
run: |
mkdir -p "${{ runner.temp }}/.config/git"
touch "${{ runner.temp }}/.config/git/ignore"
mkdir -p .git/info
touch .git/info/exclude
chmod -R u+rwX .git || true
chmod 644 .git/info/exclude || true
git config --local core.excludesFile "${{ runner.temp }}/.config/git/ignore" || true
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -47,6 +59,9 @@ jobs:
- name: Install Node dependencies - name: Install Node dependencies
run: npm ci run: npm ci
- name: Run frontend tests
run: npm test
- name: Fetch ArgyllCMS binaries - name: Fetch ArgyllCMS binaries
env: env:
ARGYLL_SERVER_URL: ${{ github.server_url }} ARGYLL_SERVER_URL: ${{ github.server_url }}
@@ -59,21 +74,6 @@ jobs:
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/instlist" test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/instlist"
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/targen" test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/targen"
- name: Run Rust tests
shell: bash
env:
CARGO_INCREMENTAL: "0"
CARGO_TARGET_DIR: "${{ runner.temp }}/cargo-target"
run: |
cd src-tauri
if [ "${{ matrix.platform.target }}" = "universal-apple-darwin" ]; then
cargo test
elif [ "${{ matrix.platform.target }}" = "aarch64-apple-darwin" ] && [ "$(uname -m)" != "arm64" ]; then
cargo test --no-run --target ${{ matrix.platform.target }}
else
cargo test --target ${{ matrix.platform.target }}
fi
- name: Set Release Environment - name: Set Release Environment
id: set_env id: set_env
shell: bash shell: bash
@@ -84,23 +84,9 @@ jobs:
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
- name: Warm up macOS GUI for DMG layout
shell: bash
run: |
# Tauri's DMG bundler runs Finder AppleScript to set the background
# picture and icon positions. On CI Macs, Finder/Aqua may be idle,
# which causes AppleEvent timeouts (-1712). Wake them before build.
echo "WindowServer: $(pgrep -l WindowServer || echo 'NOT RUNNING')"
echo "console user: $(stat -f '%Su' /dev/console 2>/dev/null || echo unknown)"
echo "whoami: $(whoami)"
open -g -a Finder || true
open -g -a "System Events" || true
osascript -e 'tell application "Finder" to get name' || true
sleep 10
- name: Build Tauri App - name: Build Tauri App
env: env:
TAURI_BUNDLER_DMG_IGNORE_CI: "true" CI: "true"
run: npm run tauri build -- --target ${{ matrix.platform.target }} run: npm run tauri build -- --target ${{ matrix.platform.target }}
- name: Prepare Release Assets - name: Prepare Release Assets
+3
View File
@@ -41,6 +41,9 @@ jobs:
- name: Install Node dependencies - name: Install Node dependencies
run: npm ci run: npm ci
- name: Run frontend tests
run: npm test
- name: Fetch ArgyllCMS binaries - name: Fetch ArgyllCMS binaries
shell: powershell shell: powershell
env: env:
+10
View File
@@ -35,6 +35,16 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Ensure workspace and git permissions
run: |
mkdir -p "${{ runner.temp }}/.config/git"
touch "${{ runner.temp }}/.config/git/ignore"
mkdir -p .git/info
touch .git/info/exclude
chmod -R u+rwX .git || true
chmod 644 .git/info/exclude || true
git config --local core.excludesFile "${{ runner.temp }}/.config/git/ignore" || true
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
+7 -4
View File
@@ -10,6 +10,9 @@
## 3D Gamut Viewer ## 3D Gamut Viewer
- The viewer renders the measured/derived `.gam` volume and an optional sRGB reference wireframe in CIELAB. - The viewer renders the measured/derived `.gam` volume and an optional sRGB reference wireframe in CIELAB.
- **Do not** create `THREE.WebGLRenderer` during `DOMContentLoaded`. Call `ensureGamutViewer()` from `state.js` only when Stage 5 becomes visible. Eager WebGL on a hidden canvas respawns WKWebView on macOS Monterey Intel (#225).
- Feature-detect WebGL first; missing/lost context must leave a fallback message in `#gamutViewerContainer` and must not take down the app.
- Pause the rAF loop when leaving Stage 5 (`pauseGamutViewer`).
- Layer controls (profile, sRGB, axes) each have visibility toggles and opacity sliders. - Layer controls (profile, sRGB, axes) each have visibility toggles and opacity sliders.
- Click **Reset View** or press **R** to return the camera to its default position. - Click **Reset View** or press **R** to return the camera to its default position.
- Full JSDoc is provided on the public API in `src/js/gamut_viewer.js`. - Full JSDoc is provided on the public API in `src/js/gamut_viewer.js`.
@@ -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. - 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`. - 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`. - Release packaging workflows live under `.gitea/workflows/` (`build-macos.yml`, `build-linux.yml`, `build-windows.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)`). - Tag release builds focus exclusively on packaging via `npm run tauri build` without redundant debug-profile test compilations.
- Universal and native Intel matrix jobs run and execute full tests natively. - 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)`).
+36 -2
View File
@@ -2,7 +2,7 @@
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS. > Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
[![Release](https://img.shields.io/badge/version-v0.8.2-blue.svg)](https://git.i3omb.com/gronod/ICCery) [![Release](https://img.shields.io/badge/version-v0.8.4-blue.svg)](https://git.i3omb.com/gronod/ICCery)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery)
[![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app) [![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app)
[![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md) [![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md)
@@ -88,7 +88,7 @@ flowchart TD
- [Node.js](https://nodejs.org/) (v18 or newer) - [Node.js](https://nodejs.org/) (v18 or newer)
- [Rust](https://www.rust-lang.org/) (1.78+ stable) - [Rust](https://www.rust-lang.org/) (1.78+ stable)
- Operating system dependencies: - Operating system dependencies:
- **macOS**: macOS 11.0 (Big Sur) or newer, Xcode Command Line Tools (`xcode-select --install`). - **macOS**: macOS 12.0 (Monterey) or newer, Xcode Command Line Tools (`xcode-select --install`).
- **Windows**: Microsoft Visual Studio C++ Build Tools & WebView2 runtime. - **Windows**: Microsoft Visual Studio C++ Build Tools & WebView2 runtime.
- **Linux (Debian/Ubuntu)**: `libwebkit2gtk-4.1-dev`, `build-essential`, `curl`, `wget`, `file`, `libxdo-dev`, `libssl-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libcups2-dev`. - **Linux (Debian/Ubuntu)**: `libwebkit2gtk-4.1-dev`, `build-essential`, `curl`, `wget`, `file`, `libxdo-dev`, `libssl-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libcups2-dev`.
@@ -127,6 +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.012.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 14 remain usable if WebGL is missing or the GPU process is lost.
### macOS troubleshooting
If the window flashes white and disappears, this is almost always the WKWebView **Web Content** or **GPU** helper dying — Apple Crash Reporter will not attach to `ICCery.app`.
- Launch from Terminal to see `web content process terminated`:
```text
/Applications/ICCery.app/Contents/MacOS/ICCery
```
- Check `~/Library/Logs/DiagnosticReports` for `com.apple.WebKit.WebContent` or `com.apple.WebKit.GPU`.
- ICCery log file (rotated, last 5 segments kept):
```text
~/Library/Logs/com.gronod.iccery/iccery.log
```
- Custom ColorSync display profiles can crash toolkit UIs on Monterey. Testing with the default display profile (or Safe Mode) is a valid support question.
---
## Licence ## Licence
The ICCery GUI application is proprietary software licensed under the terms of the [EULA](LICENCE.md). ArgyllCMS binaries and source code are licensed under the GNU Affero General Public License (AGPLv3). The ICCery GUI application is proprietary software licensed under the terms of the [EULA](LICENCE.md). ArgyllCMS binaries and source code are licensed under the GNU Affero General Public License (AGPLv3).
+11 -1
View File
@@ -14,7 +14,7 @@ ICCery is a native, cross-platform desktop application built with:
- **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout. - **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout.
- **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons. - **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons.
- **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`). - **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`).
- **Current Version**: `v0.8.2` (Production release). - **Current Version**: `v0.8.4` (Production release).
--- ---
@@ -108,6 +108,16 @@ ICCery is a native, cross-platform desktop application built with:
- [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. - [x] **XY Automated Scanning Tables (#93)**: Full Stage 3 support for automated XY scanning tables (GretagMacbeth SpectroScan, X-Rite i1iO) with pure multi-line prompt classification, fiducial alignment prompts, 4-step sequence checklist, and graceful head parking on cancel.
- [ ] ~~**Multi-Language Localization (#96)**~~: *Closed — Won't Fix* (English UI retained as standard color-management terminology). - [ ] ~~**Multi-Language Localization (#96)**~~: *Closed — Won't Fix* (English UI retained as standard color-management terminology).
### Maintenance & Reliability Release (`v0.8.3`)
- [x] **Gamut Viewer Node Test Runner Support (#212)**: Guarded `window` and `window.__TAURI__` globals in `gamut_viewer.js` and added polyfill mock harness to `gamut_viewer.test.js` to enable automated headless test execution via `node src/js/gamut_viewer.test.js`.
- [x] **Custom Spectrum File Picker Dialog (#210)**: Implemented native `select_spectrum_file` command wrapping Tauri file dialog with `.sp` filter for custom FWA/OBA spectrum selection in Stage 4 profile generation.
- [x] **CGATS Dataset Import File Picker & State Synchronization (#211)**: Implemented native `select_dataset_file` command with `.ti3`, `.txt`, `.cgats`, and `.csv` filter, synchronized wizard target directory and basename upon import, and guarded against empty target states.
### Maintenance & Reliability Release (`v0.8.4`)
- [x] **Atomic Verification History Persistence (#213)**: Hardened `quality_store.rs` with atomic temporary file writes (`.tmp`), explicit flush/sync, and atomic rename to prevent historical drift data loss or corruption upon unexpected system crashes.
- [x] **Frontend Unit Testing & CI Integration (#215)**: Added standard `npm test` script executing the 3 frontend test suites (`profcheck`, `chartread`, and `gamut_viewer`) and integrated automated frontend test validation into macOS, Linux, and Windows CI workflows.
- [x] **macOS Monterey WKWebView survival (#225)**: Deferred Stage 5 WebGL until the gamut viewer is shown, hid the main window until first paint, painted a dark WKWebView backing, logged Web Content termination, and raised `minimumSystemVersion` to 12.0.
--- ---
## 3. Future Roadmap ## 3. Future Roadmap
+3 -2
View File
@@ -1,11 +1,12 @@
{ {
"name": "iccery", "name": "iccery",
"private": true, "private": true,
"version": "0.8.2", "version": "0.8.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"fetch-argyll": "node scripts/fetch-argyll.mjs", "fetch-argyll": "node scripts/fetch-argyll.mjs",
"tauri": "tauri" "tauri": "tauri",
"test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2"
+1 -1
View File
@@ -1423,7 +1423,7 @@ dependencies = [
[[package]] [[package]]
name = "iccery" name = "iccery"
version = "0.8.2" version = "0.8.4"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"image", "image",
+12 -1
View File
@@ -1,9 +1,20 @@
[package] [package]
name = "iccery" name = "iccery"
version = "0.8.2" version = "0.8.4"
description = "Modern Printer Profiling UI frontend for ArgyllCMS" description = "Modern Printer Profiling UI frontend for ArgyllCMS"
authors = ["Gordon"] authors = ["Gordon"]
edition = "2021" edition = "2021"
include = [
"src/**/*",
"build.rs",
"Cargo.toml",
"Cargo.lock",
"tauri.conf.json",
"capabilities/**/*",
"gen/**/*",
"icons/**/*",
"windows/**/*",
]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+6
View File
@@ -10,6 +10,12 @@ fn main() {
println!("cargo:rustc-env=BUILD_DATE={}", build_date); println!("cargo:rustc-env=BUILD_DATE={}", build_date);
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=tauri.conf.json");
println!("cargo:rerun-if-changed=Cargo.toml");
println!("cargo:rerun-if-changed=argyll");
// Validate that ArgyllCMS sidecar binaries are staged before building // Validate that ArgyllCMS sidecar binaries are staged before building
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
+165
View File
@@ -105,20 +105,101 @@ pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result<Strin
Ok(resource_path.to_string_lossy().to_string()) Ok(resource_path.to_string_lossy().to_string())
} }
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
pub struct OsInfo {
pub os: String,
pub arch: String,
pub family: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub macos_major: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub macos_minor: Option<u32>,
}
/// Parse `sw_vers -productVersion` output such as `"12.7.6"` or `"13.0"`.
pub fn parse_macos_product_version(version: &str) -> Option<(u32, u32, u32)> {
let mut parts = version.trim().split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().unwrap_or("0").parse().unwrap_or(0);
let patch = parts.next().unwrap_or("0").parse().unwrap_or(0);
Some((major, minor, patch))
}
fn macos_version_from_sw_vers() -> Option<(u32, u32, u32)> {
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("sw_vers")
.arg("-productVersion")
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_macos_product_version(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
pub fn collect_os_info() -> OsInfo {
let (macos_major, macos_minor) = match macos_version_from_sw_vers() {
Some((maj, min, _)) => (Some(maj), Some(min)),
None => (None, None),
};
OsInfo {
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
family: std::env::consts::FAMILY.to_string(),
macos_major,
macos_minor,
}
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct AppInfo { pub struct AppInfo {
pub version: String, pub version: String,
pub build_date: String, pub build_date: String,
pub os: String,
pub arch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub macos_major: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub macos_minor: Option<u32>,
}
#[tauri::command]
pub fn get_os_info() -> OsInfo {
collect_os_info()
} }
#[tauri::command] #[tauri::command]
pub fn get_app_info() -> AppInfo { pub fn get_app_info() -> AppInfo {
let os = collect_os_info();
AppInfo { AppInfo {
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
build_date: env!("BUILD_DATE").to_string(), build_date: env!("BUILD_DATE").to_string(),
os: os.os,
arch: os.arch,
macos_major: os.macos_major,
macos_minor: os.macos_minor,
} }
} }
#[tauri::command]
pub fn show_main_window(app: AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("main") {
crate::macos_webview::paint_dark_webview(&win);
win.show().map_err(|e| e.to_string())?;
let _ = win.set_focus();
log::info!("Main window shown after frontend ready");
} else {
log::warn!("show_main_window: window 'main' not found");
}
Ok(())
}
pub fn resolve_safe_cwd(app: &AppHandle, cwd_input: &str) -> Result<String, String> { pub fn resolve_safe_cwd(app: &AppHandle, cwd_input: &str) -> Result<String, String> {
if !cwd_input.trim().is_empty() { if !cwd_input.trim().is_empty() {
let p = std::path::Path::new(cwd_input.trim()); let p = std::path::Path::new(cwd_input.trim());
@@ -391,6 +472,61 @@ pub async fn select_profile_file(
rx.await.map_err(|e| format!("Dialog channel error: {}", e)) rx.await.map_err(|e| format!("Dialog channel error: {}", e))
} }
#[tauri::command]
pub async fn select_spectrum_file(
app: AppHandle,
default_dir: Option<String>,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let mut builder = app
.dialog()
.file()
.add_filter("Argyll Spectrum File (*.sp)", &["sp"]);
if let Some(ref dir) = default_dir {
if !dir.trim().is_empty() {
builder = builder.set_directory(std::path::PathBuf::from(dir));
}
}
let (tx, rx) = tokio::sync::oneshot::channel();
builder.pick_file(move |file_path| {
let res = file_path.map(|p| p.to_string());
let _ = tx.send(res);
});
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
}
#[tauri::command]
pub async fn select_dataset_file(
app: AppHandle,
default_dir: Option<String>,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let mut builder = app
.dialog()
.file()
.add_filter(
"Measurement Dataset (*.ti3, *.txt, *.cgats, *.csv)",
&["ti3", "txt", "cgats", "csv"],
);
if let Some(ref dir) = default_dir {
if !dir.trim().is_empty() {
builder = builder.set_directory(std::path::PathBuf::from(dir));
}
}
let (tx, rx) = tokio::sync::oneshot::channel();
builder.pick_file(move |file_path| {
let res = file_path.map(|p| p.to_string());
let _ = tx.send(res);
});
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
}
#[tauri::command] #[tauri::command]
pub async fn select_target_file( pub async fn select_target_file(
app: AppHandle, app: AppHandle,
@@ -2041,4 +2177,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&temp_dir); let _ = std::fs::remove_dir_all(&temp_dir);
} }
#[test]
fn test_parse_macos_product_version() {
assert_eq!(parse_macos_product_version("12.7.6"), Some((12, 7, 6)));
assert_eq!(parse_macos_product_version("13.0"), Some((13, 0, 0)));
assert_eq!(parse_macos_product_version(" 15.1.1\n"), Some((15, 1, 1)));
assert_eq!(parse_macos_product_version(""), None);
assert_eq!(parse_macos_product_version("ventura"), None);
}
#[test]
fn test_collect_os_info_has_host_os_and_arch() {
let info = collect_os_info();
assert_eq!(info.os, std::env::consts::OS);
assert_eq!(info.arch, std::env::consts::ARCH);
assert_eq!(info.family, std::env::consts::FAMILY);
if info.os != "macos" {
assert_eq!(info.macos_major, None);
assert_eq!(info.macos_minor, None);
}
}
#[test]
fn test_get_app_info_includes_os_arch() {
let info = get_app_info();
assert!(!info.version.is_empty());
assert_eq!(info.os, std::env::consts::OS);
assert_eq!(info.arch, std::env::consts::ARCH);
}
} }
+39 -9
View File
@@ -3,10 +3,12 @@ use tauri::Manager;
mod cgats; mod cgats;
mod commands; mod commands;
mod events; mod events;
mod macos_webview;
mod print; mod print;
mod process_manager; mod process_manager;
mod quality_store; mod quality_store;
mod settings; mod settings;
mod window_lifecycle;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -21,6 +23,10 @@ pub fn run() {
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview),
]) ])
// wry / tauri_runtime_wry log "web content process terminated" at info/debug.
// Surface those lines in iccery.log so Monterey GPU deaths are diagnosable.
.level_for("wry", log::LevelFilter::Info)
.level_for("tauri_runtime_wry", log::LevelFilter::Info)
.max_file_size(5 * 1024 * 1024) .max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll) .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.build(), .build(),
@@ -33,6 +39,12 @@ pub fn run() {
let filter = settings::parse_log_level_filter(settings.log_level.as_deref()); let filter = settings::parse_log_level_filter(settings.log_level.as_deref());
log::set_max_level(filter); log::set_max_level(filter);
log::info!("ICCery initialized. Effective log level: {:?}", filter); log::info!("ICCery initialized. Effective log level: {:?}", filter);
if let Some(win) = app.get_webview_window("main") {
// Dark backing before first paint. Do not show() here — that races
// the still-white WKWebView. Frontend invokes show_main_window.
macos_webview::paint_dark_webview(&win);
}
Ok(()) Ok(())
}) })
.manage(process_manager::ProcessManager::new()) .manage(process_manager::ProcessManager::new())
@@ -40,6 +52,8 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::spawn_process, commands::spawn_process,
commands::get_app_info, commands::get_app_info,
commands::get_os_info,
commands::show_main_window,
commands::get_default_working_dir, commands::get_default_working_dir,
commands::get_log_path, commands::get_log_path,
commands::get_recent_log_excerpt, commands::get_recent_log_excerpt,
@@ -51,6 +65,8 @@ pub fn run() {
commands::inspect_dataset_preview, commands::inspect_dataset_preview,
commands::select_existing_target, commands::select_existing_target,
commands::select_profile_file, commands::select_profile_file,
commands::select_spectrum_file,
commands::select_dataset_file,
commands::select_target_file, commands::select_target_file,
commands::select_directory, commands::select_directory,
commands::send_stdin, commands::send_stdin,
@@ -93,21 +109,35 @@ pub fn run() {
.run(|app_handle, event| { .run(|app_handle, event| {
match event { match event {
tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } => { tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } => {
window_lifecycle::mark_user_close_requested();
let pm = app_handle.state::<process_manager::ProcessManager>(); let pm = app_handle.state::<process_manager::ProcessManager>();
tauri::async_runtime::block_on(async { tauri::async_runtime::block_on(async {
pm.kill_all().await; pm.kill_all().await;
}); });
} }
tauri::RunEvent::WindowEvent { tauri::RunEvent::WindowEvent { label, event, .. } => {
event: tauri::WindowEvent::CloseRequested { .. }, match event {
.. tauri::WindowEvent::Destroyed => {
} => { window_lifecycle::on_window_destroyed(&label);
let pm = app_handle.state::<process_manager::ProcessManager>(); }
tauri::async_runtime::block_on(async { tauri::WindowEvent::CloseRequested { .. } => {
pm.kill_all().await; window_lifecycle::mark_user_close_requested();
}); let pm = app_handle.state::<process_manager::ProcessManager>();
tauri::async_runtime::block_on(async {
pm.kill_all().await;
});
}
other => {
log::debug!("WindowEvent on {label}: {other:?}");
}
}
}
other => {
let text = format!("{other:?}");
if window_lifecycle::is_web_content_termination_event(&text) {
log::error!("WebView lifecycle event: {text}");
}
} }
_ => {}
} }
}); });
} }
+84
View File
@@ -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));
}
}
+70 -4
View File
@@ -100,15 +100,21 @@ pub fn load_history_file(path: &Path) -> Vec<VerificationRecord> {
} }
let content = match fs::read_to_string(path) { let content = match fs::read_to_string(path) {
Ok(c) => c, Ok(c) => c,
Err(_) => return Vec::new(), Err(e) => {
log::warn!("Failed to read verification history at {}: {}", path.display(), e);
return Vec::new();
}
}; };
match serde_json::from_str::<VerificationHistoryStore>(&content) { match serde_json::from_str::<VerificationHistoryStore>(&content) {
Ok(store) => store.records, Ok(store) => store.records,
Err(_) => Vec::new(), Err(e) => {
log::error!("Failed to parse verification history at {}: {}", path.display(), e);
Vec::new()
}
} }
} }
/// Writes verification records to the specified JSON path. /// Writes verification records to the specified JSON path atomically.
/// Automatically creates parent directories if needed. /// Automatically creates parent directories if needed.
/// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000). /// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000).
pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> { pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> {
@@ -132,7 +138,33 @@ pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result
let json = serde_json::to_string_pretty(&store) let json = serde_json::to_string_pretty(&store)
.map_err(|e| format!("Failed to serialize verification history: {}", e))?; .map_err(|e| format!("Failed to serialize verification history: {}", e))?;
fs::write(path, json).map_err(|e| format!("Failed to write verification history: {}", e))?;
let mut tmp_path = path.as_os_str().to_os_string();
tmp_path.push(".tmp");
let tmp_path = PathBuf::from(tmp_path);
// Write to temporary file with explicit flush and sync
{
use std::io::Write;
let mut file = fs::File::create(&tmp_path)
.map_err(|e| format!("Failed to create temp history file: {}", e))?;
file.write_all(json.as_bytes())
.map_err(|e| {
let _ = fs::remove_file(&tmp_path);
format!("Failed to write temp history file: {}", e)
})?;
file.sync_all()
.map_err(|e| {
let _ = fs::remove_file(&tmp_path);
format!("Failed to sync temp history file: {}", e)
})?;
}
// Atomically replace destination file
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(format!("Failed to atomically replace verification history file: {}", e));
}
Ok(()) Ok(())
} }
@@ -418,4 +450,38 @@ mod tests {
assert_eq!(p2.extension().unwrap(), "csv"); assert_eq!(p2.extension().unwrap(), "csv");
assert_eq!(p2.to_string_lossy(), "/tmp/history.csv"); assert_eq!(p2.to_string_lossy(), "/tmp/history.csv");
} }
#[test]
fn test_atomic_write_preserves_data_and_cleans_tmp() {
let temp_dir = std::env::temp_dir().join("iccery_test_atomic_write");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let file_path = temp_dir.join("verification_history.json");
let mut tmp_file_path = file_path.as_os_str().to_os_string();
tmp_file_path.push(".tmp");
let tmp_file_path = std::path::PathBuf::from(tmp_file_path);
let rec = sample_record("vr-atom-1", "2026-09-06T12:00:00Z", 0.5);
write_history_file(&file_path, &[rec.clone()]).unwrap();
assert!(file_path.exists(), "Target file must exist");
assert!(!tmp_file_path.exists(), "Temporary file must not remain after successful atomic write");
let loaded = load_history_file(&file_path);
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0], rec);
// Overwrite with updated records to verify atomic replacement
let rec2 = sample_record("vr-atom-2", "2026-09-06T12:05:00Z", 1.2);
write_history_file(&file_path, &[rec.clone(), rec2.clone()]).unwrap();
assert!(!tmp_file_path.exists(), "Temporary file must not remain after overwrite");
let loaded2 = load_history_file(&file_path);
assert_eq!(loaded2.len(), 2);
assert_eq!(loaded2[0], rec);
assert_eq!(loaded2[1], rec2);
let _ = fs::remove_dir_all(&temp_dir);
}
} }
+88
View File
@@ -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);
}
}
+6 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery", "productName": "ICCery",
"version": "0.8.2", "version": "0.8.4",
"identifier": "com.gronod.iccery", "identifier": "com.gronod.iccery",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"
@@ -10,11 +10,14 @@
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [ "windows": [
{ {
"label": "main",
"title": "ICCery", "title": "ICCery",
"width": 1280, "width": 1280,
"height": 800, "height": 800,
"minWidth": 1100, "minWidth": 1100,
"minHeight": 700 "minHeight": 700,
"visible": false,
"backgroundColor": "#1A1A22"
} }
], ],
"security": { "security": {
@@ -36,7 +39,7 @@
"argyll/**/*" "argyll/**/*"
], ],
"macOS": { "macOS": {
"minimumSystemVersion": "10.15", "minimumSystemVersion": "12.0",
"dmg": { "dmg": {
"background": "icons/dmg-background.png", "background": "icons/dmg-background.png",
"windowSize": { "windowSize": {
+57 -4
View File
@@ -4,7 +4,7 @@ import { initChartread } from './chartread.js';
import { initColprof } from './colprof.js'; import { initColprof } from './colprof.js';
import { initProfcheck } from './profcheck.js'; import { initProfcheck } from './profcheck.js';
import { initSettings } from './settings.js'; import { initSettings } from './settings.js';
import { initGamutViewer } from './gamut_viewer.js'; import { setGpuHints } from './gamut_viewer.js';
import { initPresets } from './presets.js'; import { initPresets } from './presets.js';
import { wizardState } from './state.js'; import { wizardState } from './state.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -12,6 +12,36 @@ import { CgatsInterop } from './cgats_interop.js';
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
let mainWindowShown = false;
async function revealMainWindow() {
if (mainWindowShown) return;
mainWindowShown = true;
try {
await invoke('show_main_window');
} catch (e) {
mainWindowShown = false;
console.warn('[ICCery] show_main_window failed:', e);
}
}
function maybeShowConstrainedGpuNotice(info) {
if (!info || info.os !== 'macos') return;
const intel = info.arch === 'x86_64' || info.arch === 'x86';
const major = typeof info.macos_major === 'number' ? info.macos_major : null;
if (!intel || (major !== null && major >= 13)) return;
const key = 'iccery.macos-intel-webgl-notice';
try {
if (sessionStorage.getItem(key)) return;
sessionStorage.setItem(key, '1');
} catch (_) { /* private mode */ }
wizardState.showNotice(
'On this Mac the 3D gamut view may be unavailable. Profiling stages still work.',
'info',
8000
);
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Initialize interoperability handlers // Initialize interoperability handlers
new CgatsInterop(wizardState); new CgatsInterop(wizardState);
@@ -39,6 +69,13 @@ document.addEventListener('DOMContentLoaded', () => {
wizardState.updateGating(); wizardState.updateGating();
}); });
document.addEventListener('visibilitychange', () => {
logger.warn(`Frontend visibilitychange hidden=${document.hidden}`, 'WebView');
});
window.addEventListener('pagehide', () => {
logger.warn('Frontend pagehide', 'WebView');
});
// Initialize gating on load // Initialize gating on load
wizardState.updateGating(); wizardState.updateGating();
@@ -54,6 +91,12 @@ document.addEventListener('DOMContentLoaded', () => {
const buildDateEl = document.getElementById('aboutBuildDate'); const buildDateEl = document.getElementById('aboutBuildDate');
if (versionEl && info.version) versionEl.textContent = `v${info.version}`; if (versionEl && info.version) versionEl.textContent = `v${info.version}`;
if (buildDateEl && info.build_date) buildDateEl.textContent = info.build_date; if (buildDateEl && info.build_date) buildDateEl.textContent = info.build_date;
setGpuHints({
arch: info.arch,
os: info.os,
macosMajor: info.macos_major,
});
maybeShowConstrainedGpuNotice(info);
} catch (e) { } catch (e) {
console.warn('[ICCery] Could not load dynamic app info:', e); console.warn('[ICCery] Could not load dynamic app info:', e);
} }
@@ -82,13 +125,23 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}; };
// Initialize all stages & features safely // Initialize all stages & features safely.
// Gamut Viewer is deferred until Stage 5 is shown (eager WebGL on launch
// respawns WKWebView on Monterey Intel).
safeInit('Stage 1 (Targen)', initTargen); safeInit('Stage 1 (Targen)', initTargen);
safeInit('Stage 2 (Printtarg)', initPrinttarg); safeInit('Stage 2 (Printtarg)', initPrinttarg);
safeInit('Stage 3 (Chartread)', initChartread); safeInit('Stage 3 (Chartread)', initChartread);
safeInit('Stage 4 (Colprof)', initColprof); safeInit('Stage 4 (Colprof)', initColprof);
safeInit('Stage 5 (Profcheck)', initProfcheck); safeInit('Stage 5 (Profcheck)', initProfcheck);
safeInit('Settings', initSettings); safeInit('Settings', initSettings);
safeInit('Gamut Viewer', initGamutViewer);
safeInit('Presets', initPresets); safeInit('Presets', initPresets);
});
// Double-rAF waits for layout + first paint of the dark CSS.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
revealMainWindow();
});
});
// Fallback so a JS exception cannot leave a permanently hidden window.
setTimeout(revealMainWindow, 1500);
});
+40 -11
View File
@@ -1,4 +1,6 @@
const { invoke } = window.__TAURI__.core; const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke
? window.__TAURI__.core.invoke
: async () => {};
export class CgatsInterop { export class CgatsInterop {
constructor(appState) { constructor(appState) {
@@ -22,26 +24,53 @@ export class CgatsInterop {
async handleImport() { async handleImport() {
try { try {
const filePath = await invoke('select_target_file'); const filePath = await invoke('select_dataset_file', {
defaultDir: this.appState?.cwd || null,
});
if (!filePath) return; // User cancelled if (!filePath) return; // User cancelled
// Inspect first to show modal (optional, skipping for now, directly import) const isWindows = filePath.includes('\\');
// Since we are mocking the UI a bit for this branch, we will just import directly const sep = isWindows ? '\\' : '/';
const parts = filePath.split(sep);
const fileName = parts.pop();
const fileDir = parts.join(sep);
const fileStem = fileName.replace(/\.[^/.]+$/, '');
const targetCwd = this.appState?.cwd || fileDir;
const targetBasename = this.appState?.basename || fileStem;
const targetBasenameInput = document.getElementById('targetBasename');
if (targetBasenameInput && !targetBasenameInput.value.trim()) {
targetBasenameInput.value = targetBasename;
}
const selectedPathDisplay = document.getElementById('selectedPathDisplay');
if (selectedPathDisplay && (!selectedPathDisplay.textContent || selectedPathDisplay.textContent.includes('No directory') || !this.appState?.cwd)) {
selectedPathDisplay.textContent = `Directory: ${targetCwd}`;
}
if (this.appState?.setTarget) {
await this.appState.setTarget(targetBasename, targetCwd);
}
const summary = await invoke('import_measurement_dataset', { const summary = await invoke('import_measurement_dataset', {
filePath, filePath,
targetCwd: this.appState.cwd, targetCwd,
targetBasename: this.appState.basename, targetBasename,
}); });
this.appState.showNotice(`Successfully imported dataset (${summary.patch_count} patches)`, 'success'); this.appState?.showNotice?.(`Successfully imported dataset (${summary.patch_count} patches)`, 'success');
// Update state to jump to stage 4 // Update state to jump to stage 4
await this.appState.updateGating(); if (this.appState?.updateGating) {
this.appState.currentStage = 4; await this.appState.updateGating();
this.appState.applyStageDOM(4); }
if (this.appState) {
this.appState.currentStage = 4;
this.appState.applyStageDOM?.(4);
}
} catch (e) { } catch (e) {
this.appState.showNotice(`Failed to import dataset: ${e}`, 'error'); this.appState?.showNotice?.(`Failed to import dataset: ${e}`, 'error');
} }
} }
+2 -2
View File
@@ -54,8 +54,8 @@ export function initColprof() {
if (btnBrowseCustomSp && colprofCustomSpPath) { if (btnBrowseCustomSp && colprofCustomSpPath) {
btnBrowseCustomSp.addEventListener("click", async () => { btnBrowseCustomSp.addEventListener("click", async () => {
try { try {
const selected = await window.__TAURI__.dialog.open({ const selected = await invoke("select_spectrum_file", {
filters: [{ name: 'Spectrum', extensions: ['sp'] }] defaultDir: chartreadCwd || wizardState.cwd || null,
}); });
if (selected) { if (selected) {
colprofCustomSpPath.value = selected; colprofCustomSpPath.value = selected;
+253 -23
View File
@@ -1,22 +1,210 @@
import { computeQuickHull } from "./vendor/quickhull.js"; import { computeQuickHull } from "./vendor/quickhull.js";
import { labToSrgb } from "./color_convert.js"; import { labToSrgb } from "./color_convert.js";
import { logger } from "./logger.js";
const { invoke } = window.__TAURI__.core; const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke ? window.__TAURI__.core.invoke : null;
const { listen } = window.__TAURI__.event;
export const WEBGL_UNAVAILABLE_MESSAGE =
"3D gamut viewer requires WebGL; the rest of ICCery still works.";
let scene, camera, renderer, labelRenderer, controls; let scene, camera, renderer, labelRenderer, controls;
let currentProfileMesh = null; let currentProfileMesh = null;
let sRgbGroup = null; let sRgbGroup = null;
let axisScaffoldGroup = null; let axisScaffoldGroup = null;
let resizeObserver = null;
let gamutViewerReady = false;
let gamutViewerInitStarted = false;
let gamutViewerUnavailable = false;
let animationRunning = false;
let contextLost = false;
let togglesWired = false;
let gpuHints = {};
export function setGpuHints(hints) {
gpuHints = { ...gpuHints, ...(hints || {}) };
}
export function isGamutViewerReady() {
return gamutViewerReady;
}
/**
* Feature-detect WebGL before constructing THREE.WebGLRenderer.
* @param {typeof document} [doc]
* @returns {boolean}
*/
export function webglAvailable(doc = (typeof document !== 'undefined' ? document : null)) {
if (!doc || typeof doc.createElement !== 'function') return false;
try {
const c = doc.createElement('canvas');
if (!c || typeof c.getContext !== 'function') return false;
return !!(c.getContext('webgl2') || c.getContext('webgl') || c.getContext('experimental-webgl'));
} catch {
return false;
}
}
/**
* Heuristic for Monterey Intel / Rosetta: drop antialias and cap DPR.
* Prefer backend `arch` from `get_app_info` so Apple Silicon is not treated as Intel
* (Safari still reports `navigator.platform === "MacIntel"` on ARM).
*
* @param {{ navigator?: Navigator, arch?: string, macosMajor?: number }} [hints]
* @returns {boolean}
*/
export function isLikelyConstrainedGpu(hints = {}) {
const merged = { ...gpuHints, ...hints };
const arch = String(merged.arch || '');
if (arch) {
return arch === 'x86_64' || arch === 'x86' || arch === 'ia32';
}
const nav = merged.navigator || (typeof navigator !== 'undefined' ? navigator : {});
const uaDataArch = nav.userAgentData && nav.userAgentData.architecture;
if (uaDataArch) {
return /x86/i.test(String(uaDataArch));
}
const ua = String(nav.userAgent || '');
const platform = String(nav.platform || '');
const looksMac = /Macintosh|Mac OS X|MacIntel/i.test(`${platform} ${ua}`);
if (!looksMac) return false;
if (/ARM|Apple Silicon|aarch64/i.test(ua)) return false;
// Intel Mac UA historically includes "Intel"; Apple Silicon UA often does not.
if (/Intel/i.test(ua) || /MacIntel/i.test(platform)) {
const major = merged.macosMajor;
if (typeof major === 'number') return major < 13;
return true;
}
return false;
}
function showGamutFallback(container, message, { reloadable = false } = {}) {
if (!container) return;
container.innerHTML = '';
const el = document.createElement('div');
el.className = 'gamut-webgl-fallback';
el.setAttribute('role', 'status');
const p = document.createElement('p');
p.textContent = message;
el.appendChild(p);
if (reloadable) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'secondary btn-md';
btn.textContent = 'Reload 3D view';
btn.addEventListener('click', () => {
disposeViewer();
ensureGamutViewer();
});
el.appendChild(btn);
}
container.appendChild(el);
}
function stopAnimate() {
animationRunning = false;
}
function startAnimate() {
if (!renderer || contextLost) return;
if (animationRunning) return;
animationRunning = true;
animate();
}
function disposeViewer() {
stopAnimate();
if (resizeObserver) {
try { resizeObserver.disconnect(); } catch (_) { /* ignore */ }
resizeObserver = null;
}
if (renderer) {
try {
renderer.dispose();
} catch (_) { /* ignore */ }
if (renderer.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
}
if (labelRenderer && labelRenderer.domElement && labelRenderer.domElement.parentNode) {
labelRenderer.domElement.parentNode.removeChild(labelRenderer.domElement);
}
renderer = null;
scene = null;
camera = null;
controls = null;
labelRenderer = null;
currentProfileMesh = null;
sRgbGroup = null;
axisScaffoldGroup = null;
gamutViewerReady = false;
gamutViewerInitStarted = false;
contextLost = false;
}
/**
* Create the Three.js renderer the first time Stage 5 is shown.
* Safe to call repeatedly; a second call does not leak a renderer.
*/
export function ensureGamutViewer() {
if (gamutViewerUnavailable) return;
if (gamutViewerReady) {
startAnimate();
return;
}
const stage5 = typeof document !== 'undefined' ? document.getElementById('stage-5') : null;
if (stage5 && stage5.classList.contains('hidden')) return;
const kick = () => {
if (gamutViewerUnavailable) return;
if (gamutViewerReady) {
startAnimate();
return;
}
const s = typeof document !== 'undefined' ? document.getElementById('stage-5') : null;
if (s && s.classList.contains('hidden')) return;
initGamutViewer();
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(kick);
} else {
kick();
}
}
export function pauseGamutViewer() {
stopAnimate();
}
export function resumeGamutViewer() {
if (gamutViewerReady && !contextLost) startAnimate();
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Public: initialise the gamut viewer // Public: initialise the gamut viewer
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export async function initGamutViewer() { export async function initGamutViewer() {
try { if (gamutViewerReady || gamutViewerInitStarted) return;
const container = document.getElementById('gamutViewerContainer'); gamutViewerInitStarted = true;
if (!container || typeof THREE === 'undefined') return;
const container = typeof document !== 'undefined'
? document.getElementById('gamutViewerContainer')
: null;
if (!container || typeof THREE === 'undefined') {
gamutViewerInitStarted = false;
return;
}
if (!webglAvailable()) {
gamutViewerUnavailable = true;
gamutViewerInitStarted = false;
logger.warn(WEBGL_UNAVAILABLE_MESSAGE, 'GamutViewer');
showGamutFallback(container, WEBGL_UNAVAILABLE_MESSAGE);
return;
}
try {
// Clear any existing contents if re-initialised // Clear any existing contents if re-initialised
container.innerHTML = ""; container.innerHTML = "";
@@ -26,17 +214,43 @@ export async function initGamutViewer() {
const width = container.clientWidth > 0 ? container.clientWidth : 500; const width = container.clientWidth > 0 ? container.clientWidth : 500;
const height = container.clientHeight > 0 ? container.clientHeight : 400; const height = container.clientHeight > 0 ? container.clientHeight : 400;
// ── WebGL renderer ──────────────────────────────────────────────────── const lowPower = isLikelyConstrainedGpu();
logger.info(
`Creating WebGLRenderer (lowPower=${lowPower}, arch=${gpuHints.arch || 'unknown'}, dpr=${window.devicePixelRatio || 1})`,
'GamutViewer'
);
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000); camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
camera.position.set(180, 120, 180); camera.position.set(180, 120, 180);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer = new THREE.WebGLRenderer({
antialias: !lowPower,
alpha: false,
powerPreference: lowPower ? 'low-power' : 'default',
failIfMajorPerformanceCaveat: false,
});
renderer.setSize(width, height); renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio || 1); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, lowPower ? 1 : 2));
renderer.domElement.style.touchAction = 'none'; renderer.domElement.style.touchAction = 'none';
renderer.domElement.style.display = 'block'; renderer.domElement.style.display = 'block';
container.appendChild(renderer.domElement); container.appendChild(renderer.domElement);
renderer.domElement.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
contextLost = true;
stopAnimate();
logger.error('WebGL context lost', 'GamutViewer');
showGamutFallback(container, '3D view lost its GPU context. Profiling stages still work.', {
reloadable: true,
});
});
renderer.domElement.addEventListener('webglcontextrestored', () => {
logger.warn('WebGL context restored — rebuilding viewer', 'GamutViewer');
contextLost = false;
disposeViewer();
ensureGamutViewer();
});
// ── CSS2D label renderer ────────────────────────────────────────────── // ── CSS2D label renderer ──────────────────────────────────────────────
if (typeof THREE.CSS2DRenderer !== 'undefined') { if (typeof THREE.CSS2DRenderer !== 'undefined') {
labelRenderer = new THREE.CSS2DRenderer(); labelRenderer = new THREE.CSS2DRenderer();
@@ -75,21 +289,24 @@ export async function initGamutViewer() {
buildAxisScaffold(); buildAxisScaffold();
// ── Resize handling ─────────────────────────────────────────────────── // ── Resize handling ───────────────────────────────────────────────────
const resizeObserver = new ResizeObserver((entries) => { if (typeof ResizeObserver !== 'undefined') {
for (const entry of entries) { resizeObserver = new ResizeObserver((entries) => {
const w = entry.contentRect.width; for (const entry of entries) {
const h = entry.contentRect.height; const w = entry.contentRect.width;
if (w > 0 && h > 0 && renderer && camera) { const h = entry.contentRect.height;
camera.aspect = w / h; if (w > 0 && h > 0 && renderer && camera) {
camera.updateProjectionMatrix(); camera.aspect = w / h;
renderer.setSize(w, h); camera.updateProjectionMatrix();
if (labelRenderer) labelRenderer.setSize(w, h); renderer.setSize(w, h);
if (labelRenderer) labelRenderer.setSize(w, h);
}
} }
} });
}); resizeObserver.observe(container);
resizeObserver.observe(container); }
animate(); gamutViewerReady = true;
startAnimate();
// ── Wire toggle controls ────────────────────────────────────────────── // ── Wire toggle controls ──────────────────────────────────────────────
_wireToggles(); _wireToggles();
@@ -98,7 +315,16 @@ export async function initGamutViewer() {
loadSrgbReferenceGamut(); loadSrgbReferenceGamut();
} catch (err) { } catch (err) {
console.warn("Gamut viewer initialisation notice:", err); logger.error(`Gamut viewer initialisation failed: ${err?.stack || err}`, 'GamutViewer');
gamutViewerInitStarted = false;
gamutViewerReady = false;
try {
if (renderer && renderer.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
} catch (_) { /* ignore */ }
renderer = null;
showGamutFallback(container, WEBGL_UNAVAILABLE_MESSAGE, { reloadable: true });
} }
} }
@@ -106,7 +332,9 @@ export async function initGamutViewer() {
// Animation loop // Animation loop
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function animate() { function animate() {
if (!animationRunning) return;
requestAnimationFrame(animate); requestAnimationFrame(animate);
if (contextLost || !renderer) return;
if (controls) controls.update(); if (controls) controls.update();
if (renderer && scene && camera) { if (renderer && scene && camera) {
renderer.render(scene, camera); renderer.render(scene, camera);
@@ -561,6 +789,8 @@ export function toggleAxes(visible) {
// Wire legend toggle checkboxes, opacity sliders, and reset button // Wire legend toggle checkboxes, opacity sliders, and reset button
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function _wireToggles() { function _wireToggles() {
if (togglesWired) return;
togglesWired = true;
const bindings = [ const bindings = [
['chkProfileGamut', toggleProfileGamut ], ['chkProfileGamut', toggleProfileGamut ],
['chkSrgbReference', toggleSrgbReference ], ['chkSrgbReference', toggleSrgbReference ],
+154 -4
View File
@@ -1,20 +1,79 @@
// Manual / browser-console tests for gamut_viewer.js and profcheck.js parsing. // Unit & console tests for gamut_viewer.js parsing.
// Run in a browser/devtools console after the app has loaded: // Can be run in browser devtools console:
// import('./gamut_viewer.test.js').then(m => m.runAll()) // import('./gamut_viewer.test.js').then(m => m.runAll())
// Or in Node:
// node src/js/gamut_viewer.test.js
import { parseGamutFile } from './gamut_viewer.js'; // Node environment polyfill for browser globals
if (typeof window === 'undefined') {
globalThis.window = {
__TAURI__: {
core: { invoke: () => Promise.resolve() },
event: { listen: () => Promise.resolve(() => {}) }
},
addEventListener: () => {},
dispatchEvent: () => {},
devicePixelRatio: 1
};
globalThis.document = {
getElementById: () => null,
querySelectorAll: () => [],
createElement: (tag) => {
if (tag === 'canvas') {
return { getContext: () => null };
}
return {
style: {},
appendChild() {},
addEventListener() {},
textContent: '',
className: '',
setAttribute() {}
};
}
};
}
const {
parseGamutFile,
webglAvailable,
isLikelyConstrainedGpu,
ensureGamutViewer,
isGamutViewerReady,
WEBGL_UNAVAILABLE_MESSAGE,
setGpuHints,
} = await import('./gamut_viewer.js');
let passed = 0;
let total = 0;
export function runAll() { export function runAll() {
console.group('gamut/profcheck parser tests'); console.group('Gamut Viewer Parser Tests');
passed = 0;
total = 0;
testParseGamutBasic(); testParseGamutBasic();
testParseGamutDualTable(); testParseGamutDualTable();
testParseGamutWithComments(); testParseGamutWithComments();
testWebglAvailableFalseWithoutContext();
testWebglAvailableTrueWithWebgl();
testConstrainedGpuPrefersBackendArch();
testConstrainedGpuIgnoresAppleSiliconUa();
testConstrainedGpuIntelMac();
testEnsureGamutViewerNoopsWithoutDom();
testFallbackMessage();
console.log(`\nResults: ${passed} / ${total} tests passed.`);
console.groupEnd(); console.groupEnd();
if (passed !== total) {
throw new Error(`Gamut viewer parser tests failed: ${total - passed} failure(s)`);
}
} }
function assertEqual(actual, expected, message) { function assertEqual(actual, expected, message) {
total++;
const ok = JSON.stringify(actual) === JSON.stringify(expected); const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (ok) { if (ok) {
passed++;
console.log('PASS:', message); console.log('PASS:', message);
} else { } else {
console.error('FAIL:', message, 'expected', expected, 'got', actual); console.error('FAIL:', message, 'expected', expected, 'got', actual);
@@ -22,6 +81,17 @@ function assertEqual(actual, expected, message) {
return ok; return ok;
} }
function assert(ok, message) {
total++;
if (ok) {
passed++;
console.log('PASS:', message);
} else {
console.error('FAIL:', message);
}
return ok;
}
function testParseGamutBasic() { function testParseGamutBasic() {
const text = `GAMUT file const text = `GAMUT file
BEGIN_DATA BEGIN_DATA
@@ -78,3 +148,83 @@ END_DATA`;
assertEqual(vertices.length, 4, 'commented gamut vertex count'); assertEqual(vertices.length, 4, 'commented gamut vertex count');
assertEqual(faces.length, 2, 'commented gamut face count'); assertEqual(faces.length, 2, 'commented gamut face count');
} }
function testWebglAvailableFalseWithoutContext() {
const doc = {
createElement: () => ({ getContext: () => null })
};
assertEqual(webglAvailable(doc), false, 'webglAvailable is false when no context');
assertEqual(webglAvailable(null), false, 'webglAvailable is false without document');
}
function testWebglAvailableTrueWithWebgl() {
const doc = {
createElement: () => ({
getContext: (type) => (type === 'webgl' ? {} : null)
})
};
assertEqual(webglAvailable(doc), true, 'webglAvailable is true with webgl context');
}
function testConstrainedGpuPrefersBackendArch() {
setGpuHints({});
assertEqual(
isLikelyConstrainedGpu({ arch: 'aarch64', navigator: { platform: 'MacIntel', userAgent: 'Macintosh; Intel Mac OS X 12_7' } }),
false,
'Apple Silicon arch is not constrained even if platform is MacIntel'
);
assertEqual(
isLikelyConstrainedGpu({ arch: 'x86_64', navigator: { platform: 'MacIntel' } }),
true,
'x86_64 arch is constrained'
);
}
function testConstrainedGpuIgnoresAppleSiliconUa() {
setGpuHints({});
assertEqual(
isLikelyConstrainedGpu({
navigator: { platform: 'MacIntel', userAgent: 'Macintosh; ARM Mac OS X 14_0 Apple Silicon' }
}),
false,
'ARM / Apple Silicon UA is not constrained'
);
}
function testConstrainedGpuIntelMac() {
setGpuHints({});
assertEqual(
isLikelyConstrainedGpu({
macosMajor: 12,
navigator: { platform: 'MacIntel', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_7_6)' }
}),
true,
'Monterey Intel UA is constrained'
);
assertEqual(
isLikelyConstrainedGpu({
arch: 'x86_64',
macosMajor: 14,
navigator: { platform: 'MacIntel', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' }
}),
true,
'Intel arch stays constrained on Ventura+'
);
}
function testEnsureGamutViewerNoopsWithoutDom() {
ensureGamutViewer();
assertEqual(isGamutViewerReady(), false, 'ensureGamutViewer does not create a renderer without Stage 5 DOM / THREE');
}
function testFallbackMessage() {
assert(
WEBGL_UNAVAILABLE_MESSAGE.includes('requires WebGL'),
'fallback copy mentions WebGL'
);
}
// Auto-run if executed in Node.js
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('gamut_viewer.test.js')) {
runAll();
}
+8
View File
@@ -1,3 +1,5 @@
import { ensureGamutViewer, pauseGamutViewer } from './gamut_viewer.js';
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
export const wizardState = { export const wizardState = {
@@ -78,6 +80,12 @@ export const wizardState = {
}); });
window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } })); window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } }));
if (stageNumber === 5) {
ensureGamutViewer();
} else {
pauseGamutViewer();
}
}, },
async navigateToStage(stageNumber) { async navigateToStage(stageNumber) {
+19
View File
@@ -1491,6 +1491,25 @@ button.danger:hover {
width: 100% !important; width: 100% !important;
} }
.gamut-webgl-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
min-height: 280px;
padding: 28px 24px;
color: #c8c8d0;
background: #0e0e14;
text-align: center;
}
.gamut-webgl-fallback p {
margin: 0;
max-width: 28rem;
line-height: 1.5;
}
/* ── Legend / controls panel ─────────────────────────────────────────────── */ /* ── Legend / controls panel ─────────────────────────────────────────────── */
.gamut-controls-panel { .gamut-controls-panel {