diff --git a/AGENTS.md b/AGENTS.md index 13d3a8a..d30ba69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md index ef6ef2f..acad0c2 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/ROADMAP.md b/ROADMAP.md index 7d0c931..cba00f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -116,6 +116,7 @@ ICCery is a native, cross-platform desktop application built with: ### 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. --- diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 87fdb13..acbcaba 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -105,20 +105,101 @@ pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result, + #[serde(skip_serializing_if = "Option::is_none")] + pub macos_minor: Option, +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub macos_minor: Option, +} + +#[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 { if !cwd_input.trim().is_empty() { let p = std::path::Path::new(cwd_input.trim()); @@ -2096,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); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a97f780..f908b40 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, @@ -95,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::(); tauri::async_runtime::block_on(async { pm.kill_all().await; }); } - tauri::RunEvent::WindowEvent { - event: tauri::WindowEvent::CloseRequested { .. }, - .. - } => { - let pm = app_handle.state::(); - 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::(); + 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}"); + } } - _ => {} } }); } diff --git a/src-tauri/src/macos_webview.rs b/src-tauri/src/macos_webview.rs new file mode 100644 index 0000000..0e2e886 --- /dev/null +++ b/src-tauri/src/macos_webview.rs @@ -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)); + } +} diff --git a/src-tauri/src/window_lifecycle.rs b/src-tauri/src/window_lifecycle.rs new file mode 100644 index 0000000..8b02617 --- /dev/null +++ b/src-tauri/src/window_lifecycle.rs @@ -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); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 54ae769..e3e2181 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": { diff --git a/src/js/app.js b/src/js/app.js index 646130a..42c157c 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -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); +}); \ No newline at end of file diff --git a/src/js/gamut_viewer.js b/src/js/gamut_viewer.js index 40b6b9f..5ae8769 100644 --- a/src/js/gamut_viewer.js +++ b/src/js/gamut_viewer.js @@ -1,21 +1,210 @@ import { computeQuickHull } from "./vendor/quickhull.js"; import { labToSrgb } from "./color_convert.js"; +import { logger } from "./logger.js"; 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 = ""; @@ -25,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(); @@ -74,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(); @@ -97,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 }); } } @@ -105,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); @@ -560,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 ], diff --git a/src/js/gamut_viewer.test.js b/src/js/gamut_viewer.test.js index 8fd8ed7..06c835d 100644 --- a/src/js/gamut_viewer.test.js +++ b/src/js/gamut_viewer.test.js @@ -12,15 +12,37 @@ if (typeof window === 'undefined') { event: { listen: () => Promise.resolve(() => {}) } }, addEventListener: () => {}, - dispatchEvent: () => {} + dispatchEvent: () => {}, + devicePixelRatio: 1 }; globalThis.document = { getElementById: () => null, - querySelectorAll: () => [] + querySelectorAll: () => [], + createElement: (tag) => { + if (tag === 'canvas') { + return { getContext: () => null }; + } + return { + style: {}, + appendChild() {}, + addEventListener() {}, + textContent: '', + className: '', + setAttribute() {} + }; + } }; } -const { parseGamutFile } = await import('./gamut_viewer.js'); +const { + parseGamutFile, + webglAvailable, + isLikelyConstrainedGpu, + ensureGamutViewer, + isGamutViewerReady, + WEBGL_UNAVAILABLE_MESSAGE, + setGpuHints, +} = await import('./gamut_viewer.js'); let passed = 0; let total = 0; @@ -32,6 +54,13 @@ export function runAll() { testParseGamutBasic(); testParseGamutDualTable(); testParseGamutWithComments(); + testWebglAvailableFalseWithoutContext(); + testWebglAvailableTrueWithWebgl(); + testConstrainedGpuPrefersBackendArch(); + testConstrainedGpuIgnoresAppleSiliconUa(); + testConstrainedGpuIntelMac(); + testEnsureGamutViewerNoopsWithoutDom(); + testFallbackMessage(); console.log(`\nResults: ${passed} / ${total} tests passed.`); console.groupEnd(); @@ -52,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 @@ -109,7 +149,82 @@ END_DATA`; 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(); -} +} \ No newline at end of file diff --git a/src/js/state.js b/src/js/state.js index 9f05a55..ecebd17 100644 --- a/src/js/state.js +++ b/src/js/state.js @@ -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) { diff --git a/src/styles/main.css b/src/styles/main.css index 5af936b..81c85c6 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -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 {