From 6976ced6106a74b5cce3b4f3c9b6f3636b08e06e Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 6 Sep 2026 22:04:36 +0100 Subject: [PATCH 1/8] feat(ci): build styled macOS DMG headlessly via dmgbuild (#189) - Add scripts/build-dmg.py using dmgbuild to generate DMGs with custom background art and icon locations without Finder AppleScript. - Update .gitea/workflows/build-macos.yml and .github/workflows/build-macos.yml to package the .app bundle using dmgbuild in a headless virtual environment. - Resolves missing DMG background in CI while preventing AppleScript GUI automation timeouts. --- .gitea/workflows/build-macos.yml | 19 +++- .github/workflows/build-macos.yml | 34 ++++--- scripts/build-dmg.py | 146 ++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 22 deletions(-) create mode 100644 scripts/build-dmg.py diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml index deb7402..b2dc66f 100644 --- a/.gitea/workflows/build-macos.yml +++ b/.gitea/workflows/build-macos.yml @@ -94,11 +94,22 @@ jobs: run: | mkdir -p release-assets - # Stage DMG package - DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1) - if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then - cp "$DMG_FILE" "release-assets/${PREFIX}.dmg" + APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1) + if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then + echo "Error: Could not locate built .app bundle in src-tauri/target" + exit 1 fi + echo "Found .app bundle: ${APP_PATH}" + + # Setup isolated Python virtual environment for dmgbuild + python3 -m venv "${{ runner.temp }}/dmgbuild-venv" + "${{ runner.temp }}/dmgbuild-venv/bin/pip" install --quiet dmgbuild + + # Build styled DMG with background art and custom icon locations + "${{ runner.temp }}/dmgbuild-venv/bin/python" scripts/build-dmg.py \ + --app "${APP_PATH}" \ + --output "release-assets/${PREFIX}.dmg" \ + --volname "ICCery" - name: Upload Artifacts diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 02fc38f..641c694 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -84,22 +84,9 @@ jobs: echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV - - name: Warm up macOS GUI for DMG layout - if: runner.os == 'macOS' - run: | - # Tauri's DMG bundler runs an AppleScript that asks Finder to set the - # background picture and icon positions. On CI runners, Finder or the - # Aqua session may be idle, which can cause AppleEvent timeouts. Try to - # start/awaken Finder and System Events before the real build. - open -g -a Finder || true - open -g -a "System Events" || true - # Send a harmless probe to force Finder to initialise a scripting session. - osascript -e 'tell application "Finder" to get name' || true - sleep 10 - - name: Build Tauri App env: - TAURI_BUNDLER_DMG_IGNORE_CI: "true" + CI: "true" run: npm run tauri build -- --target ${{ matrix.platform.target }} - name: Prepare Release Assets @@ -107,11 +94,22 @@ jobs: run: | mkdir -p release-assets - # Stage DMG package - DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1) - if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then - cp "$DMG_FILE" "release-assets/${PREFIX}.dmg" + APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1) + if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then + echo "Error: Could not locate built .app bundle in src-tauri/target" + exit 1 fi + echo "Found .app bundle: ${APP_PATH}" + + # Setup isolated Python virtual environment for dmgbuild + python3 -m venv "${{ runner.temp }}/dmgbuild-venv" + "${{ runner.temp }}/dmgbuild-venv/bin/pip" install --quiet dmgbuild + + # Build styled DMG with background art and custom icon locations + "${{ runner.temp }}/dmgbuild-venv/bin/python" scripts/build-dmg.py \ + --app "${APP_PATH}" \ + --output "release-assets/${PREFIX}.dmg" \ + --volname "ICCery" - name: Upload Artifacts diff --git a/scripts/build-dmg.py b/scripts/build-dmg.py new file mode 100644 index 0000000..0ede7c8 --- /dev/null +++ b/scripts/build-dmg.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Headless macOS DMG Builder for ICCery. + +Uses `dmgbuild` to package the .app bundle into a styled DMG with custom +background art, window bounds, and icon locations without requiring Finder +AppleScript automation or an interactive display session. +""" + +import argparse +import os +import sys + +def parse_args(): + parser = argparse.ArgumentParser(description="Build styled macOS DMG installer") + parser.add_argument( + "--app", + required=True, + help="Path to the .app application bundle", + ) + parser.add_argument( + "--output", + required=True, + help="Path to the output .dmg file", + ) + parser.add_argument( + "--volname", + default="ICCery", + help="Volume name for the mounted disk image (default: ICCery)", + ) + parser.add_argument( + "--background", + default="src-tauri/icons/dmg-background.png", + help="Path to background image (default: src-tauri/icons/dmg-background.png)", + ) + parser.add_argument( + "--icon", + default="src-tauri/icons/icon.icns", + help="Path to volume icon .icns (default: src-tauri/icons/icon.icns)", + ) + parser.add_argument( + "--window-size", + nargs=2, + type=int, + default=[660, 400], + metavar=("WIDTH", "HEIGHT"), + help="Window width and height in points (default: 660 400)", + ) + parser.add_argument( + "--icon-size", + type=int, + default=100, + help="Icon size in points (default: 100)", + ) + parser.add_argument( + "--app-pos", + nargs=2, + type=int, + default=[180, 220], + metavar=("X", "Y"), + help="App icon location (default: 180 220)", + ) + parser.add_argument( + "--apps-link-pos", + nargs=2, + type=int, + default=[480, 220], + metavar=("X", "Y"), + help="Applications symlink location (default: 480 220)", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + + app_path = os.path.abspath(args.app) + output_path = os.path.abspath(args.output) + bg_path = os.path.abspath(args.background) if args.background else None + icon_path = os.path.abspath(args.icon) if args.icon else None + + if not os.path.isdir(app_path): + print(f"Error: Application bundle does not exist at '{app_path}'", file=sys.stderr) + sys.exit(1) + + if bg_path and not os.path.isfile(bg_path): + print(f"Error: Background image not found at '{bg_path}'", file=sys.stderr) + sys.exit(1) + + try: + import dmgbuild + except ImportError: + print("Error: 'dmgbuild' is required. Install via: pip install dmgbuild", file=sys.stderr) + sys.exit(1) + + app_name = os.path.basename(app_path) + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + if os.path.exists(output_path): + os.remove(output_path) + + win_w, win_h = args.window_size + app_x, app_y = args.app_pos + apps_x, apps_y = args.apps_link_pos + + settings = { + "files": [app_path], + "symlinks": {"Applications": "/Applications"}, + "background": bg_path, + "icon": icon_path if icon_path and os.path.isfile(icon_path) else None, + "icon_size": args.icon_size, + "window_rect": ((100, 100), (win_w, win_h)), + "icon_locations": { + app_name: (app_x, app_y), + "Applications": (apps_x, apps_y), + }, + "format": "UDZO", + } + + print(f"Building DMG for {app_name}...") + print(f" Volume Name: {args.volname}") + print(f" App Bundle: {app_path}") + print(f" Background: {bg_path}") + print(f" Window Size: {win_w}x{win_h}") + print(f" App Position: ({app_x}, {app_y})") + print(f" Apps Position: ({apps_x}, {apps_y})") + print(f" Output Path: {output_path}") + + try: + dmgbuild.build_dmg(output_path, args.volname, settings=settings) + except Exception as e: + print(f"Error: dmgbuild failed: {e}", file=sys.stderr) + sys.exit(1) + + if not os.path.isfile(output_path): + print(f"Error: Expected output DMG file '{output_path}' was not created", file=sys.stderr) + sys.exit(1) + + size_mb = os.path.getsize(output_path) / (1024 * 1024) + print(f"Successfully generated DMG ({size_mb:.2f} MB): {output_path}") + + +if __name__ == "__main__": + main() -- 2.39.5 From 3e7ece746b2de710843b85e266a713ba50209d8c Mon Sep 17 00:00:00 2001 From: gbolton2008 Date: Mon, 7 Sep 2026 15:35:04 +0000 Subject: [PATCH 2/8] fix(webgl): defer Stage 5 renderer and survive context loss (#225) Eager THREE.WebGLRenderer + rAF during DOMContentLoaded respawns WKWebView on Monterey Intel. Create the context only when Stage 5 is shown, feature-detect WebGL, pause the loop when hidden, and leave a fallback UI if the GPU context is missing or lost. --- AGENTS.md | 3 + src/js/app.js | 61 +++++++- src/js/gamut_viewer.js | 271 +++++++++++++++++++++++++++++++++--- src/js/gamut_viewer.test.js | 123 +++++++++++++++- src/js/state.js | 8 ++ src/styles/main.css | 19 +++ 6 files changed, 457 insertions(+), 28 deletions(-) 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/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 { -- 2.39.5 From 26b2337a16ff15be139590abd237036b1e34fd11 Mon Sep 17 00:00:00 2001 From: gbolton2008 Date: Mon, 7 Sep 2026 15:35:14 +0000 Subject: [PATCH 3/8] fix(host): log unexpected window destroy and Web Content death (#225) Helper-process crashes do not produce an ICCery Crash Reporter dialog. Record CloseRequested vs unexpected Destroyed, and detect wry 'web content process terminated' strings for iccery.log. --- src-tauri/src/window_lifecycle.rs | 88 +++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src-tauri/src/window_lifecycle.rs 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); + } +} -- 2.39.5 From fb766c15d6690d344ce9923e274e4c27ee057662 Mon Sep 17 00:00:00 2001 From: gbolton2008 Date: Mon, 7 Sep 2026 15:35:14 +0000 Subject: [PATCH 4/8] fix(window): show_main_window, dark WKWebView backing, os info (#225) Keep the window hidden until the frontend signals first paint, re-apply #1A1A22 NSWindow/WKWebView backing (drawsBackground=NO), and expose os/arch/macos version for the Intel Monterey notice. --- src-tauri/src/commands.rs | 110 +++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 46 +++++++++++--- src-tauri/src/macos_webview.rs | 84 +++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/macos_webview.rs 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)); + } +} -- 2.39.5 From 3cf2a0f4411bf280f57108cdd4ded64dc483a5ca Mon Sep 17 00:00:00 2001 From: gbolton2008 Date: Mon, 7 Sep 2026 15:35:14 +0000 Subject: [PATCH 5/8] fix(macos): hide window, dark backgroundColor, require 12.0 (#225) visible:false avoids the first white WKWebView frame. backgroundColor matches the dark theme. LSMinimumSystemVersion 12.0 stops Catalina and Big Sur installing a binary that flash-loops. --- src-tauri/tauri.conf.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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": { -- 2.39.5 From 87e8e65771e9b62949041c20998d20a09aea6e21 Mon Sep 17 00:00:00 2001 From: gbolton2008 Date: Mon, 7 Sep 2026 15:35:14 +0000 Subject: [PATCH 6/8] docs(macos): support matrix, log path, and Monterey troubleshooting (#225) Document the 12.0+ policy, WebGL best-effort on Monterey Intel, iccery.log location, and DiagnosticReports WebKit helpers. --- README.md | 36 +++++++++++++++++++++++++++++++++++- ROADMAP.md | 1 + 2 files changed, 36 insertions(+), 1 deletion(-) 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. --- -- 2.39.5 From 24318bca580e3b45185033def53fb4a11fa660f0 Mon Sep 17 00:00:00 2001 From: gronod <1+gronod@noreply@i3omb.com> Date: Mon, 7 Sep 2026 16:59:35 +0000 Subject: [PATCH 7/8] feat(cal): printer linearization via printcal / applycal (#224) Add an optional Stage 0 calibration dashboard so CMYK / RIP workflows can linearize and set ink limits before a full profile. CAL_ artefacts keep the 5-stage wizard unchanged when calibration is skipped. printtarg receives -K only for profiling layouts; colprof embeds curves with applycal. Existing .cal files require Overwrite / Rename / Cancel. Bump version to v0.8.5. --- AGENTS.md | 11 + README.md | 11 +- ROADMAP.md | 5 +- package.json | 4 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 4 +- src-tauri/src/calibration.rs | 1083 ++++++++++++++++++++++++++++++++++ src-tauri/src/commands.rs | 73 +++ src-tauri/src/lib.rs | 10 + src-tauri/src/settings.rs | 19 + src-tauri/tauri.conf.json | 2 +- src/index.html | 139 ++++- src/js/app.js | 2 + src/js/calibration.js | 658 +++++++++++++++++++++ src/js/calibration.test.js | 109 ++++ src/js/colprof.js | 11 + src/js/presets.js | 4 + src/js/printtarg.js | 2 + src/js/settings.js | 5 + src/js/state.js | 16 +- src/styles/main.css | 117 ++++ 21 files changed, 2274 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/calibration.rs create mode 100644 src/js/calibration.js create mode 100644 src/js/calibration.test.js diff --git a/AGENTS.md b/AGENTS.md index d30ba69..435eb69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,15 @@ # ICCery Agent Notes +## Printer Calibration (`printcal` / `applycal`) (#224) + +- Optional Stage 0 dashboard, opened from **Calibrate Printer**. The 1–5 wizard is unchanged when calibration is skipped. +- Calibration charts use a `CAL_` basename so they never collide with the profiling `.ti1`/`.ti2`/`.ti3`. +- `printtarg -K file.cal` is applied only to **profiling** layouts, never to the calibration chart itself. +- After Stage 4 `colprof`, `applycal` embeds the curves into the ICC/ICM when Apply Calibration is on. +- `.cal` overwrite requires an explicit Overwrite / Rename / Cancel choice. +- Warn when a loaded `.cal` is older than `calibration_stale_days` (default 30) or the stored printer name differs. +- Tests: `src-tauri/src/calibration.rs` (arg builders + `.cal` parser) and `src/js/calibration.test.js`. + ## Stage 5 Verification / Profcheck - `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats. @@ -64,6 +74,7 @@ The frontend uses a tiered button sizing system defined in `src/styles/main.css` - Verification & drift tests: `node src/js/profcheck.test.js` (21 tests) - Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests) - Gamut viewer tests: `node src/js/gamut_viewer.test.js` + - Calibration helpers: `node src/js/calibration.test.js` - Browser devtools console: `import('./profcheck.test.js').then(m => m.runAll())` - **Frontend development server**: `npm run tauri dev` - **Production package build**: `npm run tauri build` diff --git a/README.md b/README.md index acad0c2..a5c01c9 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,20 @@ > Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS. -[![Release](https://img.shields.io/badge/version-v0.8.4-blue.svg)](https://git.i3omb.com/gronod/ICCery) +[![Release](https://img.shields.io/badge/version-v0.8.5-blue.svg)](https://git.i3omb.com/gronod/ICCery) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery) [![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app) [![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md) -**ICCery** is a native GUI frontend designed to make creating custom ICC/ICM printer profiles seamless, visual, and reliable. It wraps the powerful color management capabilities of [ArgyllCMS](https://www.argyllcms.com/) within an intuitive, artefact-gated 5-stage wizard. +**ICCery** is a native GUI frontend designed to make creating custom ICC/ICM printer profiles seamless, visual, and reliable. It wraps the powerful color management capabilities of [ArgyllCMS](https://www.argyllcms.com/) within an intuitive, artefact-gated 5-stage wizard, with an optional printer calibration (linearization) workflow. --- ## Key Features - 🪄 **Linear 5-Stage Wizard Workflow**: - 1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, neutral/grey axis boosting, and 11 advanced generation parameters with contextual guidance tooltips. Supports direct-resume from existing `.ti2` target files to jump straight to measurement. + 0. **Optional — Printer Calibration (`printcal` / `applycal`)** (#224): Per-channel linearization and ink-limit discovery before a full profile. Generate a short `CAL_` chart, print and measure it with the existing Stage 2/3 engines, compute `.cal` curves, inspect channel-response plots, and toggle **Apply Calibration** so subsequent `printtarg` (`-K`) and `colprof` (`applycal`) runs consume the curves. Skip entirely for simple RGB photo printers. + 1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, neutral/grey axis boosting, and 11 advanced generation parameters with contextual guidance tooltips. Supports direct-resume from existing `.ti2` target files to jump straight to measurement. CMYK / RIP workflows show a reminder when no calibration is applied. 2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for handheld spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint) and automated XY tables (i1iO, SpectroScan). View high-resolution downscaled TIFF previews and print directly using native OS unmanaged pathways: - **macOS**: Native `NSPrintPanel` driver preferences with automatic ColorSync suppression (`AP_ColorMatchingMode=AP_ApplicationColorMatching`), CUPS media type selection, and driver-specific color adjustment bypass detection (Canon `CNIJIntent2`, Epson `ColorCorrection`, Gutenprint). - **Windows**: GDI uncorrected raw printing and DEVMODE preferences. @@ -54,10 +55,12 @@ flowchart TD QualityStore[Verification History & Drift Analytics] PrintEngine["Raw Print Subsystem (GDI / CUPS / NSPrintPanel)"] ProcMgr[Async Subprocess IPC Manager] + CalStore[Calibration .cal library] UI <--> State State <--> ProcMgr State <--> QualityStore + State <--> CalStore ProcMgr --> ThreeJS UI --> PrintEngine QualityStore --> UI @@ -67,6 +70,7 @@ flowchart TD BIN_TAR[targen] BIN_PRT[printtarg] BIN_CHR[chartread] + BIN_CAL[printcal / applycal] BIN_COL[colprof] BIN_CHK[profcheck] BIN_GAM[iccgamut] @@ -75,6 +79,7 @@ flowchart TD ProcMgr -- stdin/stdout/stderr pipes --> BIN_TAR ProcMgr -- stdin/stdout/stderr pipes --> BIN_PRT ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHR + ProcMgr -- stdin/stdout/stderr pipes --> BIN_CAL ProcMgr -- stdin/stdout/stderr pipes --> BIN_COL ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHK ProcMgr -- stdin/stdout/stderr pipes --> BIN_GAM diff --git a/ROADMAP.md b/ROADMAP.md index cba00f3..1bcb82a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ ICCery is a native, cross-platform desktop application built with: - **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout. - **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons. - **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`). -- **Current Version**: `v0.8.4` (Production release). +- **Current Version**: `v0.8.5` (Production release). --- @@ -118,6 +118,9 @@ ICCery is a native, cross-platform desktop application built with: - [x] **Frontend Unit Testing & CI Integration (#215)**: Added standard `npm test` script executing the 3 frontend test suites (`profcheck`, `chartread`, and `gamut_viewer`) and integrated automated frontend test validation into macOS, Linux, and Windows CI workflows. - [x] **macOS Monterey WKWebView survival (#225)**: Deferred Stage 5 WebGL until the gamut viewer is shown, hid the main window until first paint, painted a dark WKWebView backing, logged Web Content termination, and raised `minimumSystemVersion` to 12.0. +### Printer Calibration Release (`v0.8.5`) +- [x] **Printer Calibration Curves (#224)**: Optional Stage 0 dashboard for `printcal` linearization and ink limits. `CAL_` artefacts, Apply Calibration toggle feeding `printtarg -K` and `applycal`, channel-response plots, stale-cal warnings, and project/library persistence. + --- ## 3. Future Roadmap diff --git a/package.json b/package.json index d8e26bc..6715262 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "iccery", "private": true, - "version": "0.8.4", + "version": "0.8.5", "type": "module", "scripts": { "fetch-argyll": "node scripts/fetch-argyll.mjs", "tauri": "tauri", - "test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js" + "test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js && node src/js/calibration.test.js" }, "devDependencies": { "@tauri-apps/cli": "^2" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 63cde58..11eaf7b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "iccery" -version = "0.8.4" +version = "0.8.5" dependencies = [ "base64 0.22.1", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bdc0735..ed60ad8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iccery" -version = "0.8.4" +version = "0.8.5" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" @@ -21,7 +21,7 @@ include = [ [lib] # The `_lib` suffix may seem redundant but it is necessary # to make the lib name unique and wouldn't conflict with the bin name. -# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +# This is only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 name = "iccery_lib" crate-type = ["staticlib", "cdylib", "rlib"] diff --git a/src-tauri/src/calibration.rs b/src-tauri/src/calibration.rs new file mode 100644 index 0000000..f014e04 --- /dev/null +++ b/src-tauri/src/calibration.rs @@ -0,0 +1,1083 @@ +//! Printer linearization via ArgyllCMS `printcal` / `applycal` / `targen` (#224). +//! +//! All orchestration stays in the proprietary host. Argyll binaries are invoked +//! as isolated subprocesses — never linked. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager, State}; + +use crate::commands::{resolve_binary, resolve_safe_cwd}; +use crate::process_manager::ProcessManager; + +pub const CAL_PREFIX: &str = "CAL_"; +pub const DEFAULT_STEPS: u32 = 21; +pub const MIN_STEPS: u32 = 11; +pub const MAX_STEPS: u32 = 51; +pub const DEFAULT_STALE_DAYS: u32 = 30; +pub const PROJECT_STATE_FILENAME: &str = "iccery-calibration.json"; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PrintcalTargetConfig { + pub colour_space: String, + #[serde(default = "default_steps")] + pub steps_per_channel: u32, + pub ink_limit_exploration: Option, + pub channels: Option, + #[serde(default)] + pub white_patches: Option, + #[serde(default)] + pub neutral_emphasis: bool, + pub basename: String, + pub cwd: String, +} + +fn default_steps() -> u32 { + DEFAULT_STEPS +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PrintcalConfig { + pub ti3_basename: String, + pub cwd: String, + pub output_cal: Option, + pub previous_cal: Option, + #[serde(default)] + pub force_overwrite: bool, + #[serde(default)] + pub no_ink_limit: bool, + #[serde(default)] + pub verify: bool, + pub total_ink_limit: Option, + #[serde(default)] + pub channel_limits: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct ChannelLimit { + pub channel: String, + pub percent: f64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ApplycalConfig { + pub cal_path: String, + pub input_path: String, + pub output_path: Option, + #[serde(default)] + pub unapply: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct CalCurve { + pub channel: String, + pub points: Vec<[f64; 2]>, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct CalMetadata { + pub path: String, + pub filename: String, + pub color_rep: Option, + pub created: Option, + pub description: Option, + pub modified_ms: u128, + pub age_days: f64, + pub ink_limits: Vec, + pub total_ink_limit: Option, + pub curves: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct CalResult { + pub cal_path: String, + pub stdout: String, + pub stderr: String, + pub ink_limits: Vec, + pub total_ink_limit: Option, + pub recommended_power: Option, + pub metadata: Option, + pub message: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct ProjectCalibrationState { + pub cal_path: Option, + pub apply_enabled: bool, + pub printer_name: Option, + pub colour_space: Option, + pub created: Option, + pub cal_basename: Option, + pub ink_limit_overrides: Vec, + pub total_ink_override: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ApplycalResult { + pub output_path: String, + pub stdout: String, + pub message: String, +} + +pub fn clamp_steps(steps: u32) -> u32 { + steps.clamp(MIN_STEPS, MAX_STEPS) +} + +pub fn calibration_basename(profile_basename: &str) -> String { + let trimmed = profile_basename.trim(); + let base = trimmed + .strip_prefix(CAL_PREFIX) + .unwrap_or(trimmed) + .trim(); + let base = if base.is_empty() { "printer" } else { base }; + format!("{CAL_PREFIX}{base}") +} + +pub fn is_calibration_basename(basename: &str) -> bool { + basename.trim().starts_with(CAL_PREFIX) +} + +/// Build `targen` arguments for a short per-channel calibration chart. +/// +/// RGB: `-d2 -s{N} -g{N}` (and optional `-e` white patches). +/// CMYK: `-d4 -s{N} -g{N}` plus optional `-l` TAC exploration. +pub fn build_calibration_targen_args(config: &PrintcalTargetConfig) -> Result, String> { + let basename = sanitize_cal_basename(&config.basename)?; + let steps = clamp_steps(config.steps_per_channel); + let is_cmyk = config.colour_space.eq_ignore_ascii_case("cmyk"); + + let mut args = vec!["-v".to_string(), "-d".to_string()]; + args.push(if is_cmyk { "4".to_string() } else { "2".to_string() }); + + args.push("-s".to_string()); + args.push(steps.to_string()); + args.push("-g".to_string()); + args.push(steps.to_string()); + + if config.neutral_emphasis { + args.push("-n".to_string()); + args.push(steps.to_string()); + } + + if let Some(white) = config.white_patches { + if white > 0 { + args.push("-e".to_string()); + args.push(white.to_string()); + } + } else { + args.push("-e".to_string()); + args.push("4".to_string()); + } + + if is_cmyk { + if let Some(limit) = config.ink_limit_exploration { + if (200..=400).contains(&limit) { + args.push("-l".to_string()); + args.push(limit.to_string()); + } + } + } + + // Full-spread patches are not useful on a calibration wedge. + args.push("-f".to_string()); + args.push("0".to_string()); + + args.push(basename); + Ok(args) +} + +pub fn build_printcal_args(config: &PrintcalConfig) -> Result, String> { + let basename = sanitize_cal_basename(&config.ti3_basename)?; + let mut args = vec!["-v".to_string(), "-e".to_string()]; + + if config.no_ink_limit { + args.push("-I".to_string()); + } + if config.verify { + args.push("-z".to_string()); + } + if let Some(ref prev) = config.previous_cal { + let trimmed = prev.trim(); + if !trimmed.is_empty() { + args.push("-a".to_string()); + args.push(trimmed.to_string()); + } + } + if let Some(tac) = config.total_ink_limit { + if tac > 0.0 { + args.push("-m".to_string()); + args.push(format!("{tac:.1}")); + } + } + for limit in &config.channel_limits { + let ch = limit.channel.trim(); + if ch.is_empty() { + continue; + } + let flag = format!("-x{}", ch.chars().next().unwrap_or('C')); + args.push(flag); + args.push(format!("{:.1}", limit.percent)); + } + + let output = config + .output_cal + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("{basename}.cal")); + args.push("-o".to_string()); + args.push(output); + args.push(basename); + Ok(args) +} + +pub fn build_applycal_args(config: &ApplycalConfig) -> Result, String> { + let cal = config.cal_path.trim(); + let input = config.input_path.trim(); + if cal.is_empty() { + return Err("calibration file path is empty".to_string()); + } + if input.is_empty() { + return Err("input profile path is empty".to_string()); + } + let mut args = vec!["-v".to_string()]; + if config.unapply { + args.push("-u".to_string()); + } else { + args.push("-a".to_string()); + } + args.push(cal.to_string()); + args.push(input.to_string()); + if let Some(ref out) = config.output_path { + let trimmed = out.trim(); + if !trimmed.is_empty() { + args.push(trimmed.to_string()); + } + } + Ok(args) +} + +pub fn sanitize_cal_basename(basename: &str) -> Result { + let name = basename.trim(); + if name.is_empty() { + return Err("basename is empty".to_string()); + } + if name.contains('/') || name.contains('\\') || name.contains("..") { + return Err("basename must not contain path separators".to_string()); + } + Ok(name.to_string()) +} + +pub fn parse_printcal_stdout(stdout: &str) -> (Vec, Option, Option) { + let mut limits = Vec::new(); + let mut total = None; + let mut power = None; + + for raw in stdout.lines() { + let line = raw.trim(); + let lower = line.to_ascii_lowercase(); + + if lower.contains("ideal power") || lower.contains("device power") || lower.contains("power value") { + if let Some(v) = first_number(line) { + power = Some(v); + } + } + + if lower.contains("total") && (lower.contains("ink") || lower.contains("tac") || lower.contains("limit")) { + if let Some(v) = first_number(line) { + total = Some(v); + } + continue; + } + + if let Some(ch) = channel_from_limit_line(line) { + if let Some(v) = first_number(line) { + limits.push(ChannelLimit { + channel: ch, + percent: v, + }); + } + } + } + + (limits, total, power) +} + +fn channel_from_limit_line(line: &str) -> Option { + let t = line.trim(); + let letters = ["Cyan", "Magenta", "Yellow", "Black", "Red", "Green", "Blue"]; + let shorts = ["C", "M", "Y", "K", "R", "G", "B"]; + for (full, short) in letters.iter().zip(shorts) { + if t.starts_with(full) || t.starts_with(&format!("{full}:")) || t.starts_with(&format!("{short}:")) || t.starts_with(&format!("{short} ")) { + return Some((*short).to_string()); + } + } + None +} + +fn first_number(line: &str) -> Option { + let mut buf = String::new(); + let mut seen_digit = false; + for ch in line.chars() { + if ch.is_ascii_digit() || (ch == '.' && seen_digit && !buf.contains('.')) { + buf.push(ch); + seen_digit = true; + } else if seen_digit { + break; + } + } + if buf.is_empty() { + None + } else { + buf.parse().ok() + } +} + +pub fn parse_cal_file(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + parse_cal_contents(path, &content) +} + +pub fn parse_cal_contents(path: &Path, content: &str) -> Result { + let mut color_rep = None; + let mut created = None; + let mut description = None; + let mut total_ink_limit = None; + let mut format_fields: Vec = Vec::new(); + let mut in_format = false; + let mut in_data = false; + let mut rows: Vec> = Vec::new(); + let mut ink_limits: Vec = Vec::new(); + + for raw in content.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.eq_ignore_ascii_case("BEGIN_DATA_FORMAT") { + in_format = true; + continue; + } + if line.eq_ignore_ascii_case("END_DATA_FORMAT") { + in_format = false; + continue; + } + if line.eq_ignore_ascii_case("BEGIN_DATA") { + in_data = true; + continue; + } + if line.eq_ignore_ascii_case("END_DATA") { + in_data = false; + continue; + } + if in_format { + format_fields.extend( + line.split_whitespace() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + ); + continue; + } + if in_data { + let nums: Vec = line + .split_whitespace() + .filter_map(|t| t.parse().ok()) + .collect(); + if !nums.is_empty() { + rows.push(nums); + } + continue; + } + + let (key, value) = split_cgats_kv(line); + match key.to_ascii_uppercase().as_str() { + "COLOR_REP" | "COLORANT_COLOURSPACE" => color_rep = Some(unquote(&value)), + "CREATED" => created = Some(unquote(&value)), + "DESCRIPTOR" | "DESCRIPTION" => description = Some(unquote(&value)), + "MAX_TAC" | "TOTAL_INK_LIMIT" | "INK_LIMIT" => { + total_ink_limit = unquote(&value).parse().ok(); + } + other if other.starts_with("INK_LIMIT_") => { + let ch = other.rsplit('_').next().unwrap_or("").to_string(); + if let Ok(percent) = unquote(&value).parse::() { + ink_limits.push(ChannelLimit { channel: ch, percent }); + } + } + _ => {} + } + } + + let curves = curves_from_rows(&format_fields, &rows); + if ink_limits.is_empty() { + ink_limits = infer_channel_limits_from_curves(&curves); + } + + let modified_ms = fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis()) + .unwrap_or(0); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(modified_ms); + let age_days = if modified_ms == 0 { + 0.0 + } else { + (now_ms.saturating_sub(modified_ms) as f64) / 86_400_000.0 + }; + + Ok(CalMetadata { + path: path.to_string_lossy().to_string(), + filename: path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown.cal".to_string()), + color_rep, + created, + description, + modified_ms, + age_days, + ink_limits, + total_ink_limit, + curves, + }) +} + +fn split_cgats_kv(line: &str) -> (String, String) { + let mut parts = line.splitn(2, char::is_whitespace); + let key = parts.next().unwrap_or("").to_string(); + let value = parts.next().unwrap_or("").trim().to_string(); + (key, value) +} + +fn unquote(s: &str) -> String { + s.trim().trim_matches('"').to_string() +} + +fn curves_from_rows(fields: &[String], rows: &[Vec]) -> Vec { + if fields.is_empty() || rows.is_empty() { + return Vec::new(); + } + let input_idx = fields.iter().position(|f| { + let u = f.to_ascii_uppercase(); + u.ends_with("_I") || u == "RGB_I" || u == "CMYK_I" || u == "GRAY_I" + }); + let Some(input_idx) = input_idx else { + return Vec::new(); + }; + + let mut curves = Vec::new(); + for (idx, field) in fields.iter().enumerate() { + if idx == input_idx { + continue; + } + let channel = channel_from_field(field); + if channel.is_empty() { + continue; + } + let mut points = Vec::new(); + for row in rows { + if row.len() > input_idx && row.len() > idx { + points.push([row[input_idx], row[idx]]); + } + } + if !points.is_empty() { + curves.push(CalCurve { channel, points }); + } + } + curves +} + +fn channel_from_field(field: &str) -> String { + let u = field.to_ascii_uppercase(); + if let Some(rest) = u.strip_prefix("RGB_") { + return rest.to_string(); + } + if let Some(rest) = u.strip_prefix("CMYK_") { + return rest.to_string(); + } + if u.contains("CYAN") || u.ends_with("_C") { + return "C".to_string(); + } + if u.contains("MAGENTA") || u.ends_with("_M") { + return "M".to_string(); + } + if u.contains("YELLOW") || u.ends_with("_Y") { + return "Y".to_string(); + } + if u.contains("BLACK") || u.ends_with("_K") { + return "K".to_string(); + } + field.to_string() +} + +fn infer_channel_limits_from_curves(curves: &[CalCurve]) -> Vec { + curves + .iter() + .filter_map(|c| { + let max_out = c.points.iter().map(|p| p[1]).fold(0.0_f64, f64::max); + if max_out <= 0.0 { + None + } else { + Some(ChannelLimit { + channel: c.channel.clone(), + percent: (max_out * 100.0).clamp(0.0, 100.0), + }) + } + }) + .collect() +} + +pub fn is_cal_stale(age_days: f64, stale_days: u32) -> bool { + age_days > f64::from(stale_days.max(1)) +} + +pub fn list_cal_files_in_dir(dir: &Path) -> Vec { + let mut out = Vec::new(); + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return out, + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let ext = path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if ext != "cal" { + continue; + } + if let Ok(meta) = parse_cal_file(&path) { + out.push(meta); + } + } + out.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms)); + out +} + +pub fn project_state_path(cwd: &Path) -> PathBuf { + cwd.join(PROJECT_STATE_FILENAME) +} + +pub fn load_project_state(cwd: &Path) -> ProjectCalibrationState { + let path = project_state_path(cwd); + fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +pub fn save_project_state(cwd: &Path, state: &ProjectCalibrationState) -> Result<(), String> { + fs::create_dir_all(cwd).map_err(|e| e.to_string())?; + let path = project_state_path(cwd); + let tmp = cwd.join(format!("{PROJECT_STATE_FILENAME}.tmp")); + let json = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?; + fs::write(&tmp, json).map_err(|e| e.to_string())?; + fs::rename(&tmp, path).map_err(|e| e.to_string()) +} + +fn library_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("calibrations"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +#[tauri::command] +pub async fn generate_calibration_target( + app: AppHandle, + state: State<'_, ProcessManager>, + config: PrintcalTargetConfig, +) -> Result { + let basename = calibration_basename(&config.basename); + let mut cfg = config; + cfg.basename = basename.clone(); + let args = build_calibration_targen_args(&cfg)?; + let binary = resolve_binary(app.clone(), "targen".to_string()).await?; + let cwd = Some(resolve_safe_cwd(&app, &cfg.cwd)?); + let id = format!("targen_{basename}"); + state.spawn(app, id, binary, args, cwd).await?; + Ok(basename) +} + +#[tauri::command] +pub async fn compute_calibration_curves( + app: AppHandle, + config: PrintcalConfig, +) -> Result { + let cwd = resolve_safe_cwd(&app, &config.cwd)?; + let basename = sanitize_cal_basename(&config.ti3_basename)?; + let ti3 = Path::new(&cwd).join(format!("{basename}.ti3")); + if !ti3.is_file() { + return Err(format!( + "Measurement file not found: {}. Measure the calibration chart before computing curves.", + ti3.display() + )); + } + + let output_name = config + .output_cal + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + if s.to_ascii_lowercase().ends_with(".cal") { + s.to_string() + } else { + format!("{s}.cal") + } + }) + .unwrap_or_else(|| format!("{basename}.cal")); + if output_name.contains('/') || output_name.contains('\\') || output_name.contains("..") { + return Err("output calibration filename is invalid".to_string()); + } + let dest = Path::new(&cwd).join(&output_name); + if dest.exists() && !config.force_overwrite { + return Err(format!( + "Calibration file already exists: {}. Choose Overwrite, Rename, or Cancel.", + dest.display() + )); + } + + let mut run_cfg = config.clone(); + run_cfg.output_cal = Some(output_name.clone()); + let args = build_printcal_args(&run_cfg)?; + let binary = resolve_binary(app.clone(), "printcal".to_string()).await?; + let (code, stdout, stderr) = run_captured(&binary, &args, &cwd).await?; + if code != 0 { + return Err(format!( + "printcal exited with code {code}. {}", + stderr.lines().last().unwrap_or("No stderr.") + )); + } + if !dest.is_file() { + return Err(format!( + "printcal reported success but {} was not created.", + dest.display() + )); + } + + let (mut ink_limits, mut total, power) = parse_printcal_stdout(&stdout); + let metadata = parse_cal_file(&dest).ok(); + if let Some(ref meta) = metadata { + if ink_limits.is_empty() { + ink_limits = meta.ink_limits.clone(); + } + if total.is_none() { + total = meta.total_ink_limit; + } + } + + log::info!( + target: "calibration", + "Computed calibration {} (limits: {:?}, TAC: {:?})", + dest.display(), + ink_limits, + total + ); + + Ok(CalResult { + cal_path: dest.to_string_lossy().to_string(), + stdout, + stderr, + ink_limits, + total_ink_limit: total, + recommended_power: power, + metadata, + message: format!("Saved calibration curves to {}", dest.display()), + }) +} + +#[tauri::command] +pub async fn apply_calibration(app: AppHandle, config: ApplycalConfig) -> Result { + let cal = PathBuf::from(config.cal_path.trim()); + if !cal.is_file() { + return Err(format!("Calibration file not found: {}", cal.display())); + } + let input = PathBuf::from(config.input_path.trim()); + if !input.is_file() { + return Err(format!("Input file not found: {}", input.display())); + } + + let output = match config.output_path.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(p) => PathBuf::from(p), + None => input.clone(), + }; + + let tmp = if output == input { + let mut t = input.clone(); + t.set_extension("applycal.tmp"); + t + } else { + output.clone() + }; + + let run_cfg = ApplycalConfig { + cal_path: cal.to_string_lossy().to_string(), + input_path: input.to_string_lossy().to_string(), + output_path: Some(tmp.to_string_lossy().to_string()), + unapply: config.unapply, + }; + let args = build_applycal_args(&run_cfg)?; + let binary = resolve_binary(app.clone(), "applycal".to_string()).await?; + let cwd = input + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + let (code, stdout, stderr) = run_captured(&binary, &args, &cwd).await?; + if code != 0 { + let _ = fs::remove_file(&tmp); + return Err(format!( + "applycal exited with code {code}. {}", + stderr.lines().last().unwrap_or("No stderr.") + )); + } + if tmp != output { + fs::rename(&tmp, &output).map_err(|e| format!("Failed to replace profile with calibrated copy: {e}"))?; + } + log::info!( + target: "calibration", + "Applied {} to {} -> {}", + cal.display(), + input.display(), + output.display() + ); + Ok(ApplycalResult { + output_path: output.to_string_lossy().to_string(), + stdout, + message: format!("Applied calibration to {}", output.display()), + }) +} + +#[tauri::command] +pub fn parse_cal_file_cmd(path: String) -> Result { + parse_cal_file(Path::new(&path)) +} + +#[tauri::command] +pub fn list_saved_calibrations(app: AppHandle, cwd: Option) -> Result, String> { + let mut all = Vec::new(); + if let Some(dir) = cwd { + if !dir.trim().is_empty() { + all.extend(list_cal_files_in_dir(Path::new(&dir))); + } + } + if let Ok(lib) = library_dir(&app) { + for meta in list_cal_files_in_dir(&lib) { + if !all.iter().any(|m| m.path == meta.path) { + all.push(meta); + } + } + } + all.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms)); + Ok(all) +} + +#[tauri::command] +pub fn save_calibration_to_library(app: AppHandle, cal_path: String) -> Result { + let src = Path::new(&cal_path); + if !src.is_file() { + return Err(format!("Calibration file not found: {cal_path}")); + } + let lib = library_dir(&app)?; + let name = src + .file_name() + .ok_or_else(|| "invalid calibration filename".to_string())?; + let dest = lib.join(name); + fs::copy(src, &dest).map_err(|e| format!("Failed to copy into library: {e}"))?; + Ok(dest.to_string_lossy().to_string()) +} + +#[tauri::command] +pub async fn select_cal_file( + app: AppHandle, + default_dir: Option, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let mut builder = app + .dialog() + .file() + .add_filter("Argyll calibration (*.cal)", &["cal"]); + if let Some(ref dir) = default_dir { + if !dir.trim().is_empty() { + builder = builder.set_directory(PathBuf::from(dir)); + } + } + let (tx, rx) = tokio::sync::oneshot::channel(); + builder.pick_file(move |file_path| { + let res = file_path.map(|p| p.to_string()); + let _ = tx.send(res); + }); + rx.await.map_err(|e| format!("Dialog channel error: {e}")) +} + +#[tauri::command] +pub fn load_project_calibration(cwd: String) -> Result { + if cwd.trim().is_empty() { + return Ok(ProjectCalibrationState::default()); + } + Ok(load_project_state(Path::new(&cwd))) +} + +#[tauri::command] +pub fn save_project_calibration(cwd: String, state: ProjectCalibrationState) -> Result<(), String> { + if cwd.trim().is_empty() { + return Err("working directory is empty".to_string()); + } + save_project_state(Path::new(&cwd), &state) +} + +async fn run_captured(binary: &str, args: &[String], cwd: &str) -> Result<(i32, String, String), String> { + let mut cmd = tokio::process::Command::new(binary); + cmd.args(args) + .current_dir(cwd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .env("ARGYLL_NOT_INTERACTIVE", "1"); + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + log::info!( + target: "subprocess", + "Running captured {} {:?}", + crate::process_manager::sanitize_arg_for_logging(binary), + crate::process_manager::sanitize_args_for_logging(args) + ); + let output = cmd + .output() + .await + .map_err(|e| format!("Failed to launch {binary}: {e}"))?; + let code = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Ok((code, stdout, stderr)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rgb_target() -> PrintcalTargetConfig { + PrintcalTargetConfig { + colour_space: "rgb".to_string(), + steps_per_channel: 21, + ink_limit_exploration: Some(320), + channels: None, + white_patches: None, + neutral_emphasis: false, + basename: "photo".to_string(), + cwd: "/tmp".to_string(), + } + } + + #[test] + fn test_calibration_basename_prefix() { + assert_eq!(calibration_basename("photo"), "CAL_photo"); + assert_eq!(calibration_basename("CAL_photo"), "CAL_photo"); + assert_eq!(calibration_basename(" "), "CAL_printer"); + assert!(is_calibration_basename("CAL_photo")); + assert!(!is_calibration_basename("photo")); + } + + #[test] + fn test_clamp_steps() { + assert_eq!(clamp_steps(5), 11); + assert_eq!(clamp_steps(21), 21); + assert_eq!(clamp_steps(99), 51); + } + + #[test] + fn test_build_calibration_targen_rgb() { + let args = build_calibration_targen_args(&rgb_target()).unwrap(); + assert!(args.contains(&"-d".to_string())); + assert!(args.contains(&"2".to_string())); + assert!(args.contains(&"-s".to_string())); + assert!(args.contains(&"21".to_string())); + assert!(args.contains(&"-g".to_string())); + assert!(args.contains(&"-f".to_string())); + assert!(args.contains(&"0".to_string())); + assert_eq!(args.last().unwrap(), "photo"); + assert!(!args.contains(&"-l".to_string()), "RGB must not pass -l"); + } + + #[test] + fn test_build_calibration_targen_cmyk_ink_limit() { + let mut cfg = rgb_target(); + cfg.colour_space = "cmyk".to_string(); + cfg.basename = "CAL_press".to_string(); + cfg.neutral_emphasis = true; + let args = build_calibration_targen_args(&cfg).unwrap(); + assert!(args.contains(&"4".to_string())); + assert!(args.contains(&"-l".to_string())); + assert!(args.contains(&"320".to_string())); + assert!(args.contains(&"-n".to_string())); + assert_eq!(args.last().unwrap(), "CAL_press"); + } + + #[test] + fn test_build_calibration_targen_rejects_path() { + let mut cfg = rgb_target(); + cfg.basename = "../escape".to_string(); + assert!(build_calibration_targen_args(&cfg).is_err()); + } + + #[test] + fn test_build_printcal_args_defaults() { + let cfg = PrintcalConfig { + ti3_basename: "CAL_photo".to_string(), + cwd: "/tmp".to_string(), + output_cal: None, + previous_cal: None, + force_overwrite: false, + no_ink_limit: false, + verify: false, + total_ink_limit: None, + channel_limits: vec![], + }; + let args = build_printcal_args(&cfg).unwrap(); + assert_eq!( + args, + vec!["-v", "-e", "-o", "CAL_photo.cal", "CAL_photo"] + ); + } + + #[test] + fn test_build_printcal_args_overrides_and_prev() { + let cfg = PrintcalConfig { + ti3_basename: "CAL_press".to_string(), + cwd: "/tmp".to_string(), + output_cal: Some("press_lin.cal".to_string()), + previous_cal: Some("old.cal".to_string()), + force_overwrite: true, + no_ink_limit: true, + verify: true, + total_ink_limit: Some(280.0), + channel_limits: vec![ChannelLimit { + channel: "C".to_string(), + percent: 95.0, + }], + }; + let args = build_printcal_args(&cfg).unwrap(); + assert!(args.contains(&"-I".to_string())); + assert!(args.contains(&"-z".to_string())); + assert!(args.contains(&"-a".to_string())); + assert!(args.contains(&"old.cal".to_string())); + assert!(args.contains(&"-m".to_string())); + assert!(args.contains(&"280.0".to_string())); + assert!(args.contains(&"-xC".to_string())); + assert!(args.contains(&"95.0".to_string())); + assert!(args.contains(&"press_lin.cal".to_string())); + } + + #[test] + fn test_build_applycal_args() { + let cfg = ApplycalConfig { + cal_path: "lin.cal".to_string(), + input_path: "out.icc".to_string(), + output_path: Some("out_cal.icc".to_string()), + unapply: false, + }; + let args = build_applycal_args(&cfg).unwrap(); + assert_eq!(args, vec!["-v", "-a", "lin.cal", "out.icc", "out_cal.icc"]); + } + + #[test] + fn test_build_applycal_unapply_and_empty() { + let cfg = ApplycalConfig { + cal_path: "".to_string(), + input_path: "out.icc".to_string(), + output_path: None, + unapply: true, + }; + assert!(build_applycal_args(&cfg).is_err()); + let cfg = ApplycalConfig { + cal_path: "lin.cal".to_string(), + input_path: "out.icc".to_string(), + output_path: None, + unapply: true, + }; + let args = build_applycal_args(&cfg).unwrap(); + assert_eq!(args, vec!["-v", "-u", "lin.cal", "out.icc"]); + } + + #[test] + fn test_parse_printcal_stdout_limits() { + let stdout = r#" +printcal: Creating calibration +Ideal power value to apply to the test chart = 1.35 +Ink limits: + Cyan: 96.4% + Magenta: 94.1% + Yellow: 98.0% + Black: 90.2% + Total ink limit: 280.0% +"#; + let (limits, total, power) = parse_printcal_stdout(stdout); + assert_eq!(power, Some(1.35)); + assert_eq!(total, Some(280.0)); + assert_eq!(limits.len(), 4); + assert_eq!(limits[0].channel, "C"); + assert!((limits[0].percent - 96.4).abs() < 0.01); + } + + #[test] + fn test_parse_cal_contents_cmyk_curves() { + let cal = r#" +CAL +DESCRIPTOR "Argyll Device Calibration File" +CREATED "Mon Sep 7 17:00:00 2026" +KEYWORD "COLOR_REP" +COLOR_REP "CMYK" +KEYWORD "MAX_TAC" +MAX_TAC "280.000000" +KEYWORD "NUMBER_OF_FIELDS" +NUMBER_OF_FIELDS 5 +BEGIN_DATA_FORMAT +CMYK_I CMYK_C CMYK_M CMYK_Y CMYK_K +END_DATA_FORMAT +NUMBER_OF_SETS 3 +BEGIN_DATA +0.0 0.00 0.00 0.00 0.00 +0.5 0.42 0.40 0.45 0.38 +1.0 0.95 0.92 0.98 0.90 +END_DATA +"#; + let meta = parse_cal_contents(Path::new("/tmp/demo.cal"), cal).unwrap(); + assert_eq!(meta.color_rep.as_deref(), Some("CMYK")); + assert_eq!(meta.total_ink_limit, Some(280.0)); + assert_eq!(meta.curves.len(), 4); + assert_eq!(meta.curves[0].channel, "C"); + assert_eq!(meta.curves[0].points.len(), 3); + assert!((meta.ink_limits.iter().find(|l| l.channel == "C").unwrap().percent - 95.0).abs() < 0.01); + } + + #[test] + fn test_is_cal_stale() { + assert!(!is_cal_stale(10.0, 30)); + assert!(!is_cal_stale(30.0, 30)); + assert!(is_cal_stale(31.0, 30)); + } + + #[test] + fn test_missing_measurement_error_path_message() { + // The command itself needs a Tauri app; the path construction is covered + // by sanitize + ti3 join used in compute_calibration_curves. + let basename = sanitize_cal_basename("CAL_x").unwrap(); + let ti3 = Path::new("/tmp").join(format!("{basename}.ti3")); + assert_eq!(ti3, Path::new("/tmp/CAL_x.ti3")); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index acbcaba..77cf843 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -777,6 +777,12 @@ pub struct PrinttargConfig { pub no_randomize: bool, // If true, pass -r (raster layout / no randomization) pub basename: String, // Must match the .ti1 basename from Stage 1 pub cwd: String, // Working directory where the .ti1 file resides + /// Optional Argyll `.cal` applied via printtarg `-K` (or `-I` when embed-only). + #[serde(default)] + pub calibration_file: Option, + /// When true, embed the calibration (`-I`) without applying it to printed patches. + #[serde(default)] + pub calibration_embed_only: bool, } pub fn build_targen_args(config: &TargenConfig) -> Vec { @@ -927,6 +933,18 @@ pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec { } args.push(config.dpi.to_string()); + if let Some(ref cal) = config.calibration_file { + let trimmed = cal.trim(); + if !trimmed.is_empty() { + if config.calibration_embed_only { + args.push("-I".to_string()); + } else { + args.push("-K".to_string()); + } + args.push(trimmed.to_string()); + } + } + args.push(config.basename.clone()); args } @@ -1621,6 +1639,8 @@ mod tests { no_randomize: false, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "100", "my_profile"]); @@ -1638,6 +1658,8 @@ mod tests { no_randomize: false, basename: "cmyk_profile".to_string(), cwd: "/home/user".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-R", "1", "-T", "300", "cmyk_profile"]); @@ -1655,6 +1677,8 @@ mod tests { no_randomize: false, basename: "custom_target".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-R", "1", "-t", "150", "custom_target"]); @@ -1672,6 +1696,8 @@ mod tests { no_randomize: false, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!( @@ -1706,6 +1732,8 @@ mod tests { no_randomize: false, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "42", "-t", "300", "my_profile"]); @@ -1723,11 +1751,56 @@ mod tests { no_randomize: true, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-r", "-t", "300", "my_profile"]); } + #[test] + fn test_build_printtarg_args_with_calibration_apply() { + let config = PrinttargConfig { + instrument: "i1".to_string(), + page_size: "A4".to_string(), + bit_depth: 8, + dpi: 300, + custom_label: None, + random_seed: Some(1), + no_randomize: false, + basename: "my_profile".to_string(), + cwd: "/tmp".to_string(), + calibration_file: Some("CAL_photo.cal".to_string()), + calibration_embed_only: false, + }; + let args = build_printtarg_args(&config); + assert_eq!( + args, + vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "300", "-K", "CAL_photo.cal", "my_profile"] + ); + } + + #[test] + fn test_build_printtarg_args_with_calibration_embed_only() { + let config = PrinttargConfig { + instrument: "i1".to_string(), + page_size: "A4".to_string(), + bit_depth: 8, + dpi: 300, + custom_label: None, + random_seed: Some(1), + no_randomize: false, + basename: "my_profile".to_string(), + cwd: "/tmp".to_string(), + calibration_file: Some("lin.cal".to_string()), + calibration_embed_only: true, + }; + let args = build_printtarg_args(&config); + assert!(args.contains(&"-I".to_string())); + assert!(!args.contains(&"-K".to_string())); + assert!(args.contains(&"lin.cal".to_string())); + } + #[test] fn test_build_chartread_args_auto() { let config = ChartreadConfig { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f908b40..0963df5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ use tauri::Manager; mod cgats; mod commands; +mod calibration; mod events; mod macos_webview; mod print; @@ -92,6 +93,15 @@ pub fn run() { commands::show_printer_properties, commands::print_target_native, commands::select_csv_save_path, + calibration::generate_calibration_target, + calibration::compute_calibration_curves, + calibration::apply_calibration, + calibration::parse_cal_file_cmd, + calibration::list_saved_calibrations, + calibration::save_calibration_to_library, + calibration::select_cal_file, + calibration::load_project_calibration, + calibration::save_project_calibration, quality_store::save_verification_record, quality_store::get_verification_history, quality_store::clear_verification_history, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 192a8e6..69442a9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -58,10 +58,15 @@ pub struct ProfilingPreset { pub random_seed: Option, #[serde(default)] pub no_randomize: Option, + #[serde(default)] + pub calibration_file: Option, + #[serde(default)] + pub apply_calibration: Option, } fn default_delta_e_good_max() -> f64 { 2.0 } fn default_delta_e_warning_max() -> f64 { 5.0 } +fn default_cal_stale_days() -> u32 { 30 } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct AppSettings { @@ -76,6 +81,8 @@ pub struct AppSettings { pub custom_presets: Vec, #[serde(default)] pub enable_i1pro2_leds: bool, + #[serde(default = "default_cal_stale_days")] + pub calibration_stale_days: u32, } impl Default for AppSettings { @@ -88,6 +95,7 @@ impl Default for AppSettings { delta_e_warning_max: default_delta_e_warning_max(), custom_presets: Vec::new(), enable_i1pro2_leds: false, + calibration_stale_days: default_cal_stale_days(), } } } @@ -135,6 +143,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -169,6 +179,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -203,6 +215,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -237,6 +251,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -405,6 +421,8 @@ mod tests { device_power: Some(1.2), random_seed: Some(42), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -474,6 +492,7 @@ mod tests { fn test_default_enable_i1pro2_leds() { let settings = AppSettings::default(); assert_eq!(settings.enable_i1pro2_leds, false); + assert_eq!(settings.calibration_stale_days, 30); } #[test] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e3e2181..59ae2a1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ICCery", - "version": "0.8.4", + "version": "0.8.5", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src" diff --git a/src/index.html b/src/index.html index 9e4adc1..649d17a 100644 --- a/src/index.html +++ b/src/index.html @@ -49,6 +49,8 @@ + +
Calibration: None