diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml index ccf8360..ace2701 100644 --- a/.gitea/workflows/build-macos.yml +++ b/.gitea/workflows/build-macos.yml @@ -9,6 +9,8 @@ jobs: build-macos: name: Build macOS (${{ matrix.platform.name }}) runs-on: ${{ matrix.platform.os }} + env: + XDG_CONFIG_HOME: ${{ runner.temp }}/.config strategy: matrix: platform: @@ -17,21 +19,31 @@ jobs: target: x86_64-apple-darwin binary_dir: macos-x86_64 arch: x86_64 - - name: Apple Silicon - os: macos - target: aarch64-apple-darwin - binary_dir: macos-aarch64 - arch: arm64 - - name: Universal (Intel + Apple Silicon) - os: macos - target: universal-apple-darwin - binary_dir: macos-universal - arch: universal +# - name: Apple Silicon +# os: macos +# target: aarch64-apple-darwin +# binary_dir: macos-aarch64 +# arch: arm64 +# - name: Universal (Intel + Apple Silicon) +# os: macos +# target: universal-apple-darwin +# binary_dir: macos-universal +# arch: universal steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Ensure workspace and git permissions + run: | + mkdir -p "${{ runner.temp }}/.config/git" + touch "${{ runner.temp }}/.config/git/ignore" + mkdir -p .git/info + touch .git/info/exclude + chmod -R u+rwX .git || true + chmod 644 .git/info/exclude || true + git config --local core.excludesFile "${{ runner.temp }}/.config/git/ignore" || true + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index e0c89ab..02fc38f 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -35,6 +35,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Ensure workspace and git permissions + run: | + mkdir -p "${{ runner.temp }}/.config/git" + touch "${{ runner.temp }}/.config/git/ignore" + mkdir -p .git/info + touch .git/info/exclude + chmod -R u+rwX .git || true + chmod 644 .git/info/exclude || true + git config --local core.excludesFile "${{ runner.temp }}/.config/git/ignore" || true + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/README.md b/README.md index e46f791..d8fd441 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS. -[![Release](https://img.shields.io/badge/version-v0.8.2-blue.svg)](https://git.i3omb.com/gronod/ICCery) +[![Release](https://img.shields.io/badge/version-v0.8.3-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) diff --git a/ROADMAP.md b/ROADMAP.md index 2a3e238..6cfebbb 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.2` (Production release). +- **Current Version**: `v0.8.3` (Production release). --- @@ -108,6 +108,11 @@ ICCery is a native, cross-platform desktop application built with: - [x] **XY Automated Scanning Tables (#93)**: Full Stage 3 support for automated XY scanning tables (GretagMacbeth SpectroScan, X-Rite i1iO) with pure multi-line prompt classification, fiducial alignment prompts, 4-step sequence checklist, and graceful head parking on cancel. - [ ] ~~**Multi-Language Localization (#96)**~~: *Closed — Won't Fix* (English UI retained as standard color-management terminology). +### Maintenance & Reliability Release (`v0.8.3`) +- [x] **Gamut Viewer Node Test Runner Support (#212)**: Guarded `window` and `window.__TAURI__` globals in `gamut_viewer.js` and added polyfill mock harness to `gamut_viewer.test.js` to enable automated headless test execution via `node src/js/gamut_viewer.test.js`. +- [x] **Custom Spectrum File Picker Dialog (#210)**: Implemented native `select_spectrum_file` command wrapping Tauri file dialog with `.sp` filter for custom FWA/OBA spectrum selection in Stage 4 profile generation. +- [x] **CGATS Dataset Import File Picker & State Synchronization (#211)**: Implemented native `select_dataset_file` command with `.ti3`, `.txt`, `.cgats`, and `.csv` filter, synchronized wizard target directory and basename upon import, and guarded against empty target states. + --- ## 3. Future Roadmap diff --git a/package.json b/package.json index 4906920..4089efe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "iccery", "private": true, - "version": "0.8.2", + "version": "0.8.3", "type": "module", "scripts": { "fetch-argyll": "node scripts/fetch-argyll.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9a39611..fd301b4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "iccery" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 64209d7..8a4f594 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,9 +1,20 @@ [package] name = "iccery" -version = "0.8.2" +version = "0.8.3" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" +include = [ + "src/**/*", + "build.rs", + "Cargo.toml", + "Cargo.lock", + "tauri.conf.json", + "capabilities/**/*", + "gen/**/*", + "icons/**/*", + "windows/**/*", +] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src-tauri/build.rs b/src-tauri/build.rs index a9bb615..d54e76e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -10,6 +10,12 @@ fn main() { println!("cargo:rustc-env=BUILD_DATE={}", build_date); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=tauri.conf.json"); + println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-changed=argyll"); + // Validate that ArgyllCMS sidecar binaries are staged before building let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6ce29df..87fdb13 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -391,6 +391,61 @@ pub async fn select_profile_file( rx.await.map_err(|e| format!("Dialog channel error: {}", e)) } +#[tauri::command] +pub async fn select_spectrum_file( + app: AppHandle, + default_dir: Option, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let mut builder = app + .dialog() + .file() + .add_filter("Argyll Spectrum File (*.sp)", &["sp"]); + if let Some(ref dir) = default_dir { + if !dir.trim().is_empty() { + builder = builder.set_directory(std::path::PathBuf::from(dir)); + } + } + + let (tx, rx) = tokio::sync::oneshot::channel(); + builder.pick_file(move |file_path| { + let res = file_path.map(|p| p.to_string()); + let _ = tx.send(res); + }); + + rx.await.map_err(|e| format!("Dialog channel error: {}", e)) +} + +#[tauri::command] +pub async fn select_dataset_file( + app: AppHandle, + default_dir: Option, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let mut builder = app + .dialog() + .file() + .add_filter( + "Measurement Dataset (*.ti3, *.txt, *.cgats, *.csv)", + &["ti3", "txt", "cgats", "csv"], + ); + if let Some(ref dir) = default_dir { + if !dir.trim().is_empty() { + builder = builder.set_directory(std::path::PathBuf::from(dir)); + } + } + + let (tx, rx) = tokio::sync::oneshot::channel(); + builder.pick_file(move |file_path| { + let res = file_path.map(|p| p.to_string()); + let _ = tx.send(res); + }); + + rx.await.map_err(|e| format!("Dialog channel error: {}", e)) +} + #[tauri::command] pub async fn select_target_file( app: AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9a1e621..a97f780 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -51,6 +51,8 @@ pub fn run() { commands::inspect_dataset_preview, commands::select_existing_target, commands::select_profile_file, + commands::select_spectrum_file, + commands::select_dataset_file, commands::select_target_file, commands::select_directory, commands::send_stdin, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a1843df..32bafa1 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.2", + "version": "0.8.3", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src" diff --git a/src/js/cgats_interop.js b/src/js/cgats_interop.js index 46d0fcd..c3d6b89 100644 --- a/src/js/cgats_interop.js +++ b/src/js/cgats_interop.js @@ -1,4 +1,6 @@ -const { invoke } = window.__TAURI__.core; +const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke + ? window.__TAURI__.core.invoke + : async () => {}; export class CgatsInterop { constructor(appState) { @@ -22,26 +24,53 @@ export class CgatsInterop { async handleImport() { try { - const filePath = await invoke('select_target_file'); + const filePath = await invoke('select_dataset_file', { + defaultDir: this.appState?.cwd || null, + }); if (!filePath) return; // User cancelled - // Inspect first to show modal (optional, skipping for now, directly import) - // Since we are mocking the UI a bit for this branch, we will just import directly + const isWindows = filePath.includes('\\'); + const sep = isWindows ? '\\' : '/'; + const parts = filePath.split(sep); + const fileName = parts.pop(); + const fileDir = parts.join(sep); + const fileStem = fileName.replace(/\.[^/.]+$/, ''); + + const targetCwd = this.appState?.cwd || fileDir; + const targetBasename = this.appState?.basename || fileStem; + + const targetBasenameInput = document.getElementById('targetBasename'); + if (targetBasenameInput && !targetBasenameInput.value.trim()) { + targetBasenameInput.value = targetBasename; + } + const selectedPathDisplay = document.getElementById('selectedPathDisplay'); + if (selectedPathDisplay && (!selectedPathDisplay.textContent || selectedPathDisplay.textContent.includes('No directory') || !this.appState?.cwd)) { + selectedPathDisplay.textContent = `Directory: ${targetCwd}`; + } + + if (this.appState?.setTarget) { + await this.appState.setTarget(targetBasename, targetCwd); + } + const summary = await invoke('import_measurement_dataset', { filePath, - targetCwd: this.appState.cwd, - targetBasename: this.appState.basename, + targetCwd, + targetBasename, }); - this.appState.showNotice(`Successfully imported dataset (${summary.patch_count} patches)`, 'success'); + this.appState?.showNotice?.(`Successfully imported dataset (${summary.patch_count} patches)`, 'success'); // Update state to jump to stage 4 - await this.appState.updateGating(); - this.appState.currentStage = 4; - this.appState.applyStageDOM(4); + if (this.appState?.updateGating) { + await this.appState.updateGating(); + } + if (this.appState) { + this.appState.currentStage = 4; + this.appState.applyStageDOM?.(4); + } } catch (e) { - this.appState.showNotice(`Failed to import dataset: ${e}`, 'error'); + this.appState?.showNotice?.(`Failed to import dataset: ${e}`, 'error'); } } diff --git a/src/js/colprof.js b/src/js/colprof.js index bb70fca..0982466 100644 --- a/src/js/colprof.js +++ b/src/js/colprof.js @@ -54,8 +54,8 @@ export function initColprof() { if (btnBrowseCustomSp && colprofCustomSpPath) { btnBrowseCustomSp.addEventListener("click", async () => { try { - const selected = await window.__TAURI__.dialog.open({ - filters: [{ name: 'Spectrum', extensions: ['sp'] }] + const selected = await invoke("select_spectrum_file", { + defaultDir: chartreadCwd || wizardState.cwd || null, }); if (selected) { colprofCustomSpPath.value = selected; diff --git a/src/js/gamut_viewer.js b/src/js/gamut_viewer.js index 35f318f..40b6b9f 100644 --- a/src/js/gamut_viewer.js +++ b/src/js/gamut_viewer.js @@ -1,8 +1,7 @@ -import { computeQuickHull } from "./vendor/quickhull.js"; +import { computeQuickHull } from "./vendor/quickhull.js"; import { labToSrgb } from "./color_convert.js"; -const { invoke } = window.__TAURI__.core; -const { listen } = window.__TAURI__.event; +const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke ? window.__TAURI__.core.invoke : null; let scene, camera, renderer, labelRenderer, controls; let currentProfileMesh = null; diff --git a/src/js/gamut_viewer.test.js b/src/js/gamut_viewer.test.js index b57a40e..8fd8ed7 100644 --- a/src/js/gamut_viewer.test.js +++ b/src/js/gamut_viewer.test.js @@ -1,20 +1,50 @@ -// Manual / browser-console tests for gamut_viewer.js and profcheck.js parsing. -// Run in a browser/devtools console after the app has loaded: +// Unit & console tests for gamut_viewer.js parsing. +// Can be run in browser devtools console: // import('./gamut_viewer.test.js').then(m => m.runAll()) +// Or in Node: +// node src/js/gamut_viewer.test.js -import { parseGamutFile } from './gamut_viewer.js'; +// Node environment polyfill for browser globals +if (typeof window === 'undefined') { + globalThis.window = { + __TAURI__: { + core: { invoke: () => Promise.resolve() }, + event: { listen: () => Promise.resolve(() => {}) } + }, + addEventListener: () => {}, + dispatchEvent: () => {} + }; + globalThis.document = { + getElementById: () => null, + querySelectorAll: () => [] + }; +} + +const { parseGamutFile } = await import('./gamut_viewer.js'); + +let passed = 0; +let total = 0; export function runAll() { - console.group('gamut/profcheck parser tests'); + console.group('Gamut Viewer Parser Tests'); + passed = 0; + total = 0; testParseGamutBasic(); testParseGamutDualTable(); testParseGamutWithComments(); + console.log(`\nResults: ${passed} / ${total} tests passed.`); console.groupEnd(); + + if (passed !== total) { + throw new Error(`Gamut viewer parser tests failed: ${total - passed} failure(s)`); + } } function assertEqual(actual, expected, message) { + total++; const ok = JSON.stringify(actual) === JSON.stringify(expected); if (ok) { + passed++; console.log('PASS:', message); } else { console.error('FAIL:', message, 'expected', expected, 'got', actual); @@ -78,3 +108,8 @@ END_DATA`; assertEqual(vertices.length, 4, 'commented gamut vertex count'); assertEqual(faces.length, 2, 'commented gamut face count'); } + +// Auto-run if executed in Node.js +if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('gamut_viewer.test.js')) { + runAll(); +}