Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87e8e65771 | ||
|
|
3cf2a0f441 | ||
|
|
fb766c15d6 | ||
|
|
26b2337a16 | ||
|
|
3e7ece746b | ||
|
|
f47e65ee1f | ||
|
|
193e492c10 | ||
|
|
899718d896 | ||
|
|
7a9637b5d0 | ||
|
|
1af844c7e2 | ||
|
|
56f275a023 | ||
|
|
54792a8690 | ||
|
|
5f5bf76594 | ||
|
|
9102c95468 | ||
|
|
11e796fd17 | ||
|
|
a8c4025c01 |
@@ -19,11 +19,11 @@ jobs:
|
|||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
binary_dir: macos-x86_64
|
binary_dir: macos-x86_64
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
# - name: Apple Silicon
|
- name: Apple Silicon
|
||||||
# os: macos
|
os: macos
|
||||||
# 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.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
|
## 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).
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ ICCery is a native, cross-platform desktop application built with:
|
|||||||
### Maintenance & Reliability Release (`v0.8.4`)
|
### 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] **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] **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -2096,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-5
@@ -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,
|
||||||
@@ -95,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);
|
||||||
|
}
|
||||||
|
tauri::WindowEvent::CloseRequested { .. } => {
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {}
|
other => {
|
||||||
|
log::debug!("WindowEvent on {label}: {other:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let text = format!("{other:?}");
|
||||||
|
if window_lifecycle::is_web_content_termination_event(&text) {
|
||||||
|
log::error!("WebView lifecycle event: {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Dark native window + WKWebView backing for macOS (#225).
|
||||||
|
//!
|
||||||
|
//! `backgroundColor` in `tauri.conf.json` paints the NSWindow, but WKWebView
|
||||||
|
//! still composites an opaque white layer until `drawsBackground` is disabled
|
||||||
|
//! and (on macOS 12+) `underPageBackgroundColor` is set. We do **not** set
|
||||||
|
//! `transparent: true` — that changes hit-testing and titlebar compositing.
|
||||||
|
|
||||||
|
/// Theme fallback already used in `index.html` (`var(--bg-card, #1a1a22)`).
|
||||||
|
pub const DARK_BG_U8: (u8, u8, u8, u8) = (0x1A, 0x1A, 0x22, 0xFF);
|
||||||
|
|
||||||
|
pub fn paint_dark_webview(window: &tauri::WebviewWindow) {
|
||||||
|
let (r, g, b, a) = DARK_BG_U8;
|
||||||
|
let _ = window.set_background_color(Some(tauri::window::Color(r, g, b, a)));
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let _ = window.with_webview(|webview| unsafe {
|
||||||
|
apply_native_dark_backing(&webview);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
unsafe fn apply_native_dark_backing(webview: &tauri::webview::PlatformWebview) {
|
||||||
|
use objc2::runtime::{AnyClass, AnyObject};
|
||||||
|
use objc2::{msg_send, sel};
|
||||||
|
use objc2_foundation::ns_string;
|
||||||
|
|
||||||
|
let red = 26.0f64 / 255.0;
|
||||||
|
let green = 26.0f64 / 255.0;
|
||||||
|
let blue = 34.0f64 / 255.0;
|
||||||
|
let alpha = 1.0f64;
|
||||||
|
|
||||||
|
let Some(nscolor_cls) = AnyClass::get(c"NSColor") else {
|
||||||
|
log::warn!("NSColor class missing; skipping dark window backing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let color: *mut AnyObject = msg_send![
|
||||||
|
nscolor_cls,
|
||||||
|
colorWithSRGBRed: red,
|
||||||
|
green: green,
|
||||||
|
blue: blue,
|
||||||
|
alpha: alpha
|
||||||
|
];
|
||||||
|
|
||||||
|
let ns_window = webview.ns_window() as *mut AnyObject;
|
||||||
|
if !ns_window.is_null() && !color.is_null() {
|
||||||
|
let _: () = msg_send![ns_window, setBackgroundColor: color];
|
||||||
|
}
|
||||||
|
|
||||||
|
let wk = webview.inner() as *mut AnyObject;
|
||||||
|
if wk.is_null() {
|
||||||
|
log::warn!("WKWebView inner handle is null; cannot disable white backing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private KVC key wry uses for transparency / backgroundColor on macOS.
|
||||||
|
if let Some(nsnumber_cls) = AnyClass::get(c"NSNumber") {
|
||||||
|
let no: *mut AnyObject = msg_send![nsnumber_cls, numberWithBool: false];
|
||||||
|
if !no.is_null() {
|
||||||
|
let _: () = msg_send![wk, setValue: no, forKey: ns_string!("drawsBackground")];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API on macOS 12+: covers overscroll / unpainted page.
|
||||||
|
let setter = sel!(setUnderPageBackgroundColor:);
|
||||||
|
let responds: bool = msg_send![wk, respondsToSelector: setter];
|
||||||
|
if responds && !color.is_null() {
|
||||||
|
let _: () = msg_send![wk, setUnderPageBackgroundColor: color];
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Applied dark WKWebView backing (#1A1A22)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dark_bg_matches_theme_hex() {
|
||||||
|
assert_eq!(DARK_BG_U8, (26, 26, 34, 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,15 +100,21 @@ pub fn load_history_file(path: &Path) -> Vec<VerificationRecord> {
|
|||||||
}
|
}
|
||||||
let content = match fs::read_to_string(path) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
+56
-3
@@ -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);
|
||||||
});
|
});
|
||||||
+240
-9
@@ -1,21 +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 = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke ? window.__TAURI__.core.invoke : null;
|
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 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 = "";
|
||||||
|
|
||||||
@@ -25,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();
|
||||||
@@ -74,7 +289,8 @@ export async function initGamutViewer() {
|
|||||||
buildAxisScaffold();
|
buildAxisScaffold();
|
||||||
|
|
||||||
// ── Resize handling ───────────────────────────────────────────────────
|
// ── Resize handling ───────────────────────────────────────────────────
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const w = entry.contentRect.width;
|
const w = entry.contentRect.width;
|
||||||
const h = entry.contentRect.height;
|
const h = entry.contentRect.height;
|
||||||
@@ -87,8 +303,10 @@ export async function initGamutViewer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
resizeObserver.observe(container);
|
resizeObserver.observe(container);
|
||||||
|
}
|
||||||
|
|
||||||
animate();
|
gamutViewerReady = true;
|
||||||
|
startAnimate();
|
||||||
|
|
||||||
// ── Wire toggle controls ──────────────────────────────────────────────
|
// ── Wire toggle controls ──────────────────────────────────────────────
|
||||||
_wireToggles();
|
_wireToggles();
|
||||||
@@ -97,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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,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);
|
||||||
@@ -560,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 ],
|
||||||
|
|||||||
+118
-3
@@ -12,15 +12,37 @@ if (typeof window === 'undefined') {
|
|||||||
event: { listen: () => Promise.resolve(() => {}) }
|
event: { listen: () => Promise.resolve(() => {}) }
|
||||||
},
|
},
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
dispatchEvent: () => {}
|
dispatchEvent: () => {},
|
||||||
|
devicePixelRatio: 1
|
||||||
};
|
};
|
||||||
globalThis.document = {
|
globalThis.document = {
|
||||||
getElementById: () => null,
|
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 passed = 0;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -32,6 +54,13 @@ export function runAll() {
|
|||||||
testParseGamutBasic();
|
testParseGamutBasic();
|
||||||
testParseGamutDualTable();
|
testParseGamutDualTable();
|
||||||
testParseGamutWithComments();
|
testParseGamutWithComments();
|
||||||
|
testWebglAvailableFalseWithoutContext();
|
||||||
|
testWebglAvailableTrueWithWebgl();
|
||||||
|
testConstrainedGpuPrefersBackendArch();
|
||||||
|
testConstrainedGpuIgnoresAppleSiliconUa();
|
||||||
|
testConstrainedGpuIntelMac();
|
||||||
|
testEnsureGamutViewerNoopsWithoutDom();
|
||||||
|
testFallbackMessage();
|
||||||
console.log(`\nResults: ${passed} / ${total} tests passed.`);
|
console.log(`\nResults: ${passed} / ${total} tests passed.`);
|
||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
|
|
||||||
@@ -52,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
|
||||||
@@ -109,6 +149,81 @@ END_DATA`;
|
|||||||
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
|
// Auto-run if executed in Node.js
|
||||||
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('gamut_viewer.test.js')) {
|
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('gamut_viewer.test.js')) {
|
||||||
runAll();
|
runAll();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user