feat(stage5): install generated ICC/ICM profiles into the OS (#223) #228

Merged
gronod merged 1 commits from feat/223-systemic-profile-installation into development 2026-09-07 18:31:26 +01:00
13 changed files with 795 additions and 1 deletions
+9
View File
@@ -10,6 +10,14 @@
- 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 System Profile Install (#223)
- `install_profile_to_system` copies the working-directory `.icc`/`.icm` into the OS colour store. It never moves or deletes the project artefact.
- Destinations: Windows `%WINDIR%\System32\spool\drivers\color` (`.icm`); macOS `~/Library/ColorSync/Profiles` or `/Library/ColorSync/Profiles`; Linux `~/.local/share/icc` or `/usr/share/color/icc` (colormgr when present).
- Collisions require Overwrite / Rename / Cancel. Permission errors must mention elevation.
- If Apply Calibration is on, the success toast notes which `.cal` was embedded.
- Tests: `src-tauri/src/profile_install.rs` and `src/js/profile_install.test.js`.
## Stage 5 Verification / Profcheck
- `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats.
@@ -75,6 +83,7 @@ The frontend uses a tiered button sizing system defined in `src/styles/main.css`
- 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`
- Profile install helpers: `node src/js/profile_install.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`
+1
View File
@@ -31,6 +31,7 @@
- **Longitudinal Printer Drift Analytics (#95)**: Historical verification logging persisted to `verification_history.json` (up to 1,000 records), an interactive dual-series SVG trend chart with shaded ICCery verification reference bands, consecutive-breach alert recommendation card (detecting drift across distinct dates or $\ge 1$ hour apart), and RFC-4180 compliant CSV export.
- **Mathematical Accuracy Report**: Peak, Average, and RMS CIEDE2000 metrics with robust parsing of both Argyll JSON summaries (`-u`) and legacy plain-text reports.
- **Interactive 3D Gamut Viewer**: CIELAB coordinate scaffold with crisp CSS2D labels, per-vertex true-colour profile gamut shading, layer visibility toggles and opacity sliders, camera reset (press **R**), touch controls, and bundled sRGB reference wireframe comparison.
- **Install Profile to System (#223)**: After a successful verification, copy the ICC/ICM into the OS colour-management store (Windows ICM Color folder, macOS ColorSync Profiles, Linux colord / `~/.local/share/icc`) without moving the working-directory artefact. Collisions prompt Overwrite / Rename / Cancel.
- 📊 **CGATS Dataset Interoperability (#94)**: Native parser for external CGATS and Argyll `.ti3` datasets with canonical normalization (0255 scaling, field aliasing, metadata synthesis) and direct-jump workflows to Stage 4 (Profile Calculation) and Stage 5 (Verification).
- 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation.
- 🍎 **macOS Universal Binary**: Native Apple Silicon (`arm64`) and Intel (`x86_64`) support with universal binary bundling and fallback resolution.
+1
View File
@@ -120,6 +120,7 @@ ICCery is a native, cross-platform desktop application built with:
### 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.
- [x] **System-Wide Profile Installation (#223)**: Stage 5 “Install Profile to System” copies the verified ICC/ICM into the platform colour store (user or system), with overwrite/rename/cancel, elevation guidance, and a note when printcal curves were applied.
---
+1 -1
View File
@@ -6,7 +6,7 @@
"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 && node src/js/calibration.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 && node src/js/profile_install.test.js"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
+3
View File
@@ -7,6 +7,7 @@ mod events;
mod macos_webview;
mod print;
mod process_manager;
mod profile_install;
mod quality_store;
mod settings;
mod window_lifecycle;
@@ -102,6 +103,8 @@ pub fn run() {
calibration::select_cal_file,
calibration::load_project_calibration,
calibration::save_project_calibration,
profile_install::install_profile_to_system,
profile_install::get_profile_install_dir,
quality_store::save_verification_record,
quality_store::get_verification_history,
quality_store::clear_verification_history,
+533
View File
@@ -0,0 +1,533 @@
//! System-wide ICC/ICM profile installation (#223).
//!
//! Copies a generated profile into the OS colour-management directory and,
//! where available, registers it (Windows ICM, macOS ColorSync, Linux colord).
//! The working-directory artefact is never moved.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::AppHandle;
const MIN_PROFILE_BYTES: u64 = 128;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct InstallOptions {
#[serde(default)]
pub force_overwrite: bool,
#[serde(default)]
pub prefer_system_wide: bool,
#[serde(default = "default_true")]
pub register_with_os: bool,
/// `overwrite` | `rename` | `cancel` — used when the destination exists.
#[serde(default = "default_collision")]
pub collision_policy: String,
#[serde(default)]
pub open_color_panel: bool,
#[serde(default)]
pub calibration_note: Option<String>,
}
fn default_true() -> bool {
true
}
fn default_collision() -> String {
"cancel".to_string()
}
impl Default for InstallOptions {
fn default() -> Self {
Self {
force_overwrite: false,
prefer_system_wide: false,
register_with_os: true,
collision_policy: default_collision(),
open_color_panel: false,
calibration_note: None,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct InstallResult {
pub dest_path: String,
pub registered: bool,
pub overwritten: bool,
pub renamed: bool,
pub opened_panel: bool,
pub message: String,
pub calibration_note: Option<String>,
}
#[derive(Debug, Clone)]
pub struct InstallEnv {
pub os: String,
pub home: Option<PathBuf>,
pub windir: Option<PathBuf>,
}
impl InstallEnv {
pub fn from_process() -> Self {
Self {
os: std::env::consts::OS.to_string(),
home: std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from),
windir: std::env::var_os("WINDIR")
.or_else(|| std::env::var_os("SystemRoot"))
.map(PathBuf::from),
}
}
}
pub fn profile_extension_for_os(os: &str) -> &'static str {
if os.eq_ignore_ascii_case("windows") {
"icm"
} else {
"icc"
}
}
pub fn user_profile_dir(env: &InstallEnv) -> Result<PathBuf, String> {
match env.os.as_str() {
"windows" => {
let home = env.home.clone().ok_or_else(|| {
"USERPROFILE is not set; cannot resolve the per-user Color directory.".to_string()
})?;
Ok(home.join("AppData").join("Local").join("Microsoft").join("Windows").join("Color"))
}
"macos" => {
let home = env.home.clone().ok_or_else(|| {
"HOME is not set; cannot resolve ~/Library/ColorSync/Profiles.".to_string()
})?;
Ok(home.join("Library").join("ColorSync").join("Profiles"))
}
_ => {
let home = env.home.clone().ok_or_else(|| {
"HOME is not set; cannot resolve ~/.local/share/icc.".to_string()
})?;
Ok(home.join(".local").join("share").join("icc"))
}
}
}
pub fn system_profile_dir(env: &InstallEnv) -> Result<PathBuf, String> {
match env.os.as_str() {
"windows" => {
let windir = env.windir.clone().ok_or_else(|| {
"WINDIR is not set; cannot resolve %WINDIR%\\System32\\spool\\drivers\\color.".to_string()
})?;
Ok(windir.join("System32").join("spool").join("drivers").join("color"))
}
"macos" => Ok(PathBuf::from("/Library/ColorSync/Profiles")),
_ => Ok(PathBuf::from("/usr/share/color/icc")),
}
}
pub fn target_profile_dir(env: &InstallEnv, prefer_system_wide: bool) -> Result<PathBuf, String> {
if prefer_system_wide {
system_profile_dir(env)
} else {
user_profile_dir(env)
}
}
pub fn dest_filename(source: &Path, os: &str) -> Result<String, String> {
let stem = source
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| "profile filename is invalid".to_string())?;
if stem.contains("..") || stem.contains('/') || stem.contains('\\') {
return Err("profile filename is invalid".to_string());
}
Ok(format!("{stem}.{}", profile_extension_for_os(os)))
}
pub fn timestamped_filename(name: &str) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) => (s, e),
None => (name, "icc"),
};
format!("{stem}-{now}.{ext}")
}
pub fn verify_source_profile(path: &Path) -> Result<u64, String> {
if !path.is_file() {
return Err(format!("Profile artefact not found: {}", path.display()));
}
let ext = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if ext != "icc" && ext != "icm" {
return Err("Source file must be an .icc or .icm profile.".to_string());
}
let len = fs::metadata(path)
.map_err(|e| format!("Cannot stat profile: {e}"))?
.len();
if len < MIN_PROFILE_BYTES {
return Err(format!(
"Profile is too small ({len} bytes) to be a valid ICC header."
));
}
Ok(len)
}
pub fn resolve_destination(
dest_dir: &Path,
filename: &str,
options: &InstallOptions,
) -> Result<(PathBuf, bool, bool), String> {
let dest = dest_dir.join(filename);
if !dest.exists() {
return Ok((dest, false, false));
}
let policy = options.collision_policy.to_ascii_lowercase();
if options.force_overwrite || policy == "overwrite" {
return Ok((dest, true, false));
}
if policy == "rename" {
return Ok((dest_dir.join(timestamped_filename(filename)), false, true));
}
Err(format!(
"A profile named {filename} already exists at {}. Choose Overwrite, Rename, or Cancel.",
dest.display()
))
}
fn copy_atomic(src: &Path, dest: &Path) -> Result<(), String> {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| {
permission_message(parent, &e)
})?;
}
let tmp = dest.with_extension("iccery-install.tmp");
fs::copy(src, &tmp).map_err(|e| permission_message(&tmp, &e))?;
fs::rename(&tmp, dest).map_err(|e| {
let _ = fs::remove_file(&tmp);
permission_message(dest, &e)
})?;
Ok(())
}
fn permission_message(path: &Path, err: &std::io::Error) -> String {
if err.kind() == std::io::ErrorKind::PermissionDenied {
#[cfg(target_os = "windows")]
{
return format!(
"Access denied writing {}. On Windows the system Color folder usually requires 'Run as Administrator'. Retry with elevation, or install to the per-user Color directory instead.",
path.display()
);
}
#[cfg(target_os = "macos")]
{
return format!(
"Permission denied writing {}. Install to ~/Library/ColorSync/Profiles (no elevation) or authenticate to write /Library/ColorSync/Profiles.",
path.display()
);
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
return format!(
"Permission denied writing {}. Install to ~/.local/share/icc (no root) or use pkexec/sudo for /usr/share/color/icc.",
path.display()
);
}
}
format!("Failed to write {}: {err}", path.display())
}
fn register_profile(dest: &Path, os: &str) -> bool {
match os {
"windows" => register_windows(dest),
"macos" => true, // ColorSync discovers files in the Profiles folders.
_ => register_colord(dest),
}
}
fn register_windows(dest: &Path) -> bool {
// Copy into the Color directory is sufficient for most apps. Try the
// classic InstallColorProfile helper when present; ignore failure.
let _ = Command::new("rundll32")
.args([
"mscms.dll,InstallColorProfileW",
&dest.to_string_lossy(),
])
.status();
true
}
fn register_colord(dest: &Path) -> bool {
match Command::new("colormgr")
.args(["import-profile", &dest.to_string_lossy()])
.output()
{
Ok(out) if out.status.success() => true,
_ => false,
}
}
fn open_color_panel(os: &str) -> bool {
let result = match os {
"windows" => Command::new("colorcpl").status(),
"macos" => Command::new("open").args(["-a", "ColorSync Utility"]).status(),
_ => Command::new("colormgr")
.arg("get-profiles")
.status()
.or_else(|_| Command::new("gnome-control-center").arg("color").status()),
};
result.map(|s| s.success()).unwrap_or(false)
}
pub fn install_profile_with_env(
profile_path: &str,
options: &InstallOptions,
env: &InstallEnv,
) -> Result<InstallResult, String> {
let src = PathBuf::from(profile_path.trim());
let src_size = verify_source_profile(&src)?;
let dir = target_profile_dir(env, options.prefer_system_wide)?;
let filename = dest_filename(&src, &env.os)?;
let (dest, overwritten, renamed) = resolve_destination(&dir, &filename, options)?;
copy_atomic(&src, &dest)?;
let dest_size = fs::metadata(&dest)
.map_err(|e| format!("Installed file missing after copy: {e}"))?
.len();
if dest_size != src_size {
return Err("Installed profile size does not match the working-directory artefact.".to_string());
}
let registered = if options.register_with_os {
register_profile(&dest, &env.os)
} else {
false
};
let opened = if options.open_color_panel {
open_color_panel(&env.os)
} else {
false
};
let mut message = format!("Installed profile to {}", dest.display());
if overwritten {
message.push_str(" (replaced existing file)");
} else if renamed {
message.push_str(" (renamed to avoid collision)");
}
if let Some(ref note) = options.calibration_note {
if !note.trim().is_empty() {
message.push_str(". ");
message.push_str(note.trim());
}
}
log::info!(target: "profile_install", "{message}");
Ok(InstallResult {
dest_path: dest.to_string_lossy().to_string(),
registered,
overwritten,
renamed,
opened_panel: opened,
message,
calibration_note: options.calibration_note.clone(),
})
}
#[tauri::command]
pub fn get_profile_install_dir(prefer_system_wide: Option<bool>) -> Result<String, String> {
let env = InstallEnv::from_process();
let dir = target_profile_dir(&env, prefer_system_wide.unwrap_or(false))?;
Ok(dir.to_string_lossy().to_string())
}
#[tauri::command]
pub fn install_profile_to_system(
_app: AppHandle,
profile_path: String,
options: Option<InstallOptions>,
) -> Result<InstallResult, String> {
let options = options.unwrap_or_default();
install_profile_with_env(&profile_path, &options, &InstallEnv::from_process())
}
#[cfg(test)]
mod tests {
use super::*;
fn env_linux() -> InstallEnv {
InstallEnv {
os: "linux".to_string(),
home: Some(PathBuf::from("/home/gordon")),
windir: None,
}
}
fn env_mac() -> InstallEnv {
InstallEnv {
os: "macos".to_string(),
home: Some(PathBuf::from("/Users/gordon")),
windir: None,
}
}
fn env_win() -> InstallEnv {
InstallEnv {
os: "windows".to_string(),
home: Some(PathBuf::from(r"C:\Users\gordon")),
windir: Some(PathBuf::from(r"C:\Windows")),
}
}
#[test]
fn test_user_and_system_dirs_linux() {
let env = env_linux();
assert_eq!(
user_profile_dir(&env).unwrap(),
PathBuf::from("/home/gordon/.local/share/icc")
);
assert_eq!(
system_profile_dir(&env).unwrap(),
PathBuf::from("/usr/share/color/icc")
);
assert_eq!(
target_profile_dir(&env, false).unwrap(),
user_profile_dir(&env).unwrap()
);
assert_eq!(
target_profile_dir(&env, true).unwrap(),
system_profile_dir(&env).unwrap()
);
}
#[test]
fn test_user_and_system_dirs_macos() {
let env = env_mac();
assert_eq!(
user_profile_dir(&env).unwrap(),
PathBuf::from("/Users/gordon/Library/ColorSync/Profiles")
);
assert_eq!(
system_profile_dir(&env).unwrap(),
PathBuf::from("/Library/ColorSync/Profiles")
);
assert_eq!(profile_extension_for_os("macos"), "icc");
}
#[test]
fn test_windows_system_color_dir_and_icm() {
let env = env_win();
assert_eq!(
system_profile_dir(&env).unwrap(),
PathBuf::from(r"C:\Windows\System32\spool\drivers\color")
);
assert_eq!(profile_extension_for_os("windows"), "icm");
let dest = dest_filename(Path::new(r"C:\work\Press.icc"), "windows").unwrap();
assert_eq!(dest, "Press.icm");
}
#[test]
fn test_dest_filename_unix_keeps_icc() {
let name = dest_filename(Path::new("/tmp/photo.icm"), "linux").unwrap();
assert_eq!(name, "photo.icc");
}
#[test]
fn test_dest_filename_rejects_invalid() {
assert!(dest_filename(Path::new(""), "linux").is_err());
assert!(dest_filename(Path::new(".."), "linux").is_err());
}
#[test]
fn test_resolve_destination_cancel() {
let dir = std::env::temp_dir();
let existing = dir.join("iccery-install-collision.icc");
fs::write(&existing, vec![0u8; 200]).unwrap();
let opts = InstallOptions {
collision_policy: "cancel".to_string(),
..Default::default()
};
let err = resolve_destination(&dir, "iccery-install-collision.icc", &opts).unwrap_err();
assert!(err.contains("already exists"));
let _ = fs::remove_file(existing);
}
#[test]
fn test_resolve_destination_rename_and_overwrite() {
let dir = std::env::temp_dir();
let filename = "iccery-install-exists.icc";
let existing = dir.join(filename);
fs::write(&existing, vec![0u8; 200]).unwrap();
let rename = InstallOptions {
collision_policy: "rename".to_string(),
..Default::default()
};
let (path, overwritten, renamed) = resolve_destination(&dir, filename, &rename).unwrap();
assert!(!overwritten && renamed);
assert_ne!(path, existing);
let over = InstallOptions {
collision_policy: "overwrite".to_string(),
..Default::default()
};
let (path2, overwritten2, renamed2) = resolve_destination(&dir, filename, &over).unwrap();
assert!(overwritten2 && !renamed2);
assert_eq!(path2, existing);
let _ = fs::remove_file(existing);
}
#[test]
fn test_verify_source_profile_rejects_missing_and_tiny() {
let missing = PathBuf::from("/tmp/does-not-exist-iccery.icc");
assert!(verify_source_profile(&missing).is_err());
let tiny = std::env::temp_dir().join("iccery-tiny.icc");
fs::write(&tiny, b"short").unwrap();
assert!(verify_source_profile(&tiny).is_err());
let _ = fs::remove_file(tiny);
}
#[test]
fn test_timestamped_filename_preserves_extension() {
let name = timestamped_filename("Press.icm");
assert!(name.starts_with("Press-"));
assert!(name.ends_with(".icm"));
}
#[test]
fn test_install_profile_copy_roundtrip() {
let tmp = std::env::temp_dir().join(format!(
"iccery-install-{}",
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis()
));
fs::create_dir_all(&tmp).unwrap();
let src = tmp.join("demo.icc");
let bytes = vec![0u8; 256];
fs::write(&src, &bytes).unwrap();
let env = InstallEnv {
os: "linux".to_string(),
home: Some(tmp.join("home")),
windir: None,
};
let result = install_profile_with_env(
&src.to_string_lossy(),
&InstallOptions {
register_with_os: false,
open_color_panel: false,
calibration_note: Some("Curves from CAL_demo.cal were applied.".to_string()),
..Default::default()
},
&env,
)
.unwrap();
assert!(Path::new(&result.dest_path).is_file());
assert!(src.is_file(), "source artefact must remain");
assert!(result.message.contains("Curves from CAL_demo.cal"));
let _ = fs::remove_dir_all(tmp);
}
}
+15
View File
@@ -67,6 +67,8 @@ pub struct ProfilingPreset {
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 }
fn default_install_location() -> String { "user".to_string() }
fn default_true_bool() -> bool { true }
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AppSettings {
@@ -83,6 +85,13 @@ pub struct AppSettings {
pub enable_i1pro2_leds: bool,
#[serde(default = "default_cal_stale_days")]
pub calibration_stale_days: u32,
/// `user` or `system` — default destination for Stage 5 profile install (#223).
#[serde(default = "default_install_location")]
pub default_install_location: String,
#[serde(default = "default_true_bool")]
pub ask_before_overwrite_profile: bool,
#[serde(default)]
pub open_color_panel_after_install: bool,
}
impl Default for AppSettings {
@@ -96,6 +105,9 @@ impl Default for AppSettings {
custom_presets: Vec::new(),
enable_i1pro2_leds: false,
calibration_stale_days: default_cal_stale_days(),
default_install_location: default_install_location(),
ask_before_overwrite_profile: true,
open_color_panel_after_install: false,
}
}
}
@@ -493,6 +505,9 @@ mod tests {
let settings = AppSettings::default();
assert_eq!(settings.enable_i1pro2_leds, false);
assert_eq!(settings.calibration_stale_days, 30);
assert_eq!(settings.default_install_location, "user");
assert!(settings.ask_before_overwrite_profile);
assert!(!settings.open_color_panel_after_install);
}
#[test]
+29
View File
@@ -908,6 +908,7 @@
<div class="stage-actions">
<button class="primary btn-lg" id="btnVerify">Verify Profile Accuracy</button>
<button class="secondary btn-lg" id="btnInstallProfile" disabled title="Copies the generated ICC/ICM profile into the operating systems standard colour-profile directory so that print dialogs and colour-managed applications can discover it. Requires elevated privileges on some platforms.">Install Profile to System</button>
</div>
<!-- Report Card -->
@@ -1093,6 +1094,21 @@
<input type="number" id="calibrationStaleDays" min="1" max="365" value="30">
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Also warns when the stored printer name no longer matches the current destination. Default 30 days.</small>
</div>
<div style="margin-top: 12px;">
<label for="defaultInstallLocation" class="sub-label">Default ICC install location</label>
<select id="defaultInstallLocation">
<option value="user" selected>User (no elevation)</option>
<option value="system">System-wide (may require admin)</option>
</select>
</div>
<label class="checkbox-label" for="askBeforeOverwriteProfile" style="display:flex; align-items:center; gap:8px; margin-top:10px; cursor:pointer;">
<input type="checkbox" id="askBeforeOverwriteProfile" checked>
<span>Ask before overwriting an existing system profile</span>
</label>
<label class="checkbox-label" for="openColorPanelAfterInstall" style="display:flex; align-items:center; gap:8px; margin-top:8px; cursor:pointer;">
<input type="checkbox" id="openColorPanelAfterInstall">
<span>Open the system Colour Management panel after install</span>
</label>
</div>
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
@@ -1137,6 +1153,19 @@
</div>
</dialog>
<dialog id="profileInstallCollisionDialog" class="settings-modal">
<div class="modal-content">
<h2>Profile already installed</h2>
<p id="profileInstallCollisionMessage" style="white-space:pre-wrap;"></p>
<p class="help-hint">The working-directory copy is never moved. Choose how to handle the existing system profile.</p>
<div class="modal-actions">
<button type="button" id="profileOverwriteBtn" class="danger">Overwrite</button>
<button type="button" id="profileRenameBtn" class="primary">Rename (timestamp)</button>
<button type="button" id="profileCancelCollisionBtn" class="secondary">Cancel</button>
</div>
</div>
</dialog>
<!-- About Dialog -->
<dialog id="aboutDialog" class="settings-modal about-modal">
<div class="modal-content">
+2
View File
@@ -10,6 +10,7 @@ import { wizardState } from './state.js';
import { logger } from './logger.js';
import { CgatsInterop } from './cgats_interop.js';
import { initCalibration } from './calibration.js';
import { initProfileInstall } from './profile_install.js';
const { invoke } = window.__TAURI__.core;
@@ -137,6 +138,7 @@ document.addEventListener('DOMContentLoaded', () => {
safeInit('Settings', initSettings);
safeInit('Presets', initPresets);
safeInit('Calibration', initCalibration);
safeInit('ProfileInstall', initProfileInstall);
// Double-rAF waits for layout + first paint of the dark CSS.
requestAnimationFrame(() => {
+3
View File
@@ -3,6 +3,7 @@ const { listen } = window.__TAURI__.event;
import { loadGamutMesh } from './gamut_viewer.js';
import { wizardState } from './state.js';
import { logger } from './logger.js';
import { setProfileInstallSource } from './profile_install.js';
let profileBasename = "";
let profileCwd = "";
@@ -535,6 +536,7 @@ export function initProfcheck() {
logContainer.classList.remove("hidden");
reportCard.classList.add("hidden");
btnVerify.disabled = true;
setProfileInstallSource(iccPath, false);
const config = {
ti3_path: ti3Path,
@@ -571,6 +573,7 @@ export function initProfcheck() {
if (event.payload.code === 0) {
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
const report = parseProfcheckReport(stdoutAccumulator);
setProfileInstallSource(iccPath, true);
reportCard.classList.remove("hidden");
if (report.warnings.length > 0) {
+142
View File
@@ -0,0 +1,142 @@
/**
* Stage 5 — install a verified ICC/ICM profile into the OS colour store (#223).
*/
import { wizardState } from './state.js';
import { logger } from './logger.js';
import { getActiveCalibration } from './calibration.js';
const invoke = (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke)
? window.__TAURI__.core.invoke.bind(window.__TAURI__.core)
: async () => { throw new Error('Tauri invoke unavailable'); };
const installState = {
profilePath: null,
verified: false,
installedPath: null,
};
export function calibrationInstallNote() {
const cal = getActiveCalibration();
if (!cal.calPath || !cal.applyEnabled) return null;
const name = cal.filename || cal.calPath.split(/[\\/]/).pop();
return `Linearization curves from ${name} were applied when this profile was built.`;
}
export function setProfileInstallSource(profilePath, verified) {
installState.profilePath = profilePath || null;
installState.verified = !!verified;
if (!verified) installState.installedPath = null;
refreshInstallButton();
}
function refreshInstallButton() {
const btn = document.getElementById('btnInstallProfile');
if (!btn) return;
const ready = !!installState.profilePath && installState.verified;
const already = !!installState.installedPath;
btn.disabled = !ready || already;
if (already) {
btn.textContent = 'Installed ✓';
btn.title = `Already installed to ${installState.installedPath}`;
} else if (!ready) {
btn.textContent = 'Install Profile to System';
btn.title = 'Copies the generated ICC/ICM into the OS colour-profile directory so print dialogs and colour-managed applications can discover it. Requires a successful verification. Elevated privileges on some platforms.';
} else {
btn.textContent = 'Install Profile to System';
btn.title = 'Copies the generated ICC/ICM profile into the operating systems standard colour-profile directory so that print dialogs and colour-managed applications can discover it. Requires elevated privileges on some platforms.';
}
}
function collisionChoice(message) {
return new Promise((resolve) => {
const dialog = document.getElementById('profileInstallCollisionDialog');
const msg = document.getElementById('profileInstallCollisionMessage');
if (msg) msg.textContent = message;
if (!dialog || typeof dialog.showModal !== 'function') {
resolve(window.confirm(`${message}\n\nOverwrite?`) ? 'overwrite' : 'cancel');
return;
}
const finish = (choice) => {
dialog.close();
resolve(choice);
};
document.getElementById('profileOverwriteBtn')?.addEventListener('click', () => finish('overwrite'), { once: true });
document.getElementById('profileRenameBtn')?.addEventListener('click', () => finish('rename'), { once: true });
document.getElementById('profileCancelCollisionBtn')?.addEventListener('click', () => finish('cancel'), { once: true });
dialog.showModal();
});
}
async function loadInstallPrefs() {
try {
const settings = await invoke('load_settings');
return {
preferSystem: (settings.default_install_location || 'user') === 'system',
askOverwrite: settings.ask_before_overwrite_profile !== false,
openPanel: !!settings.open_color_panel_after_install,
};
} catch (_) {
return { preferSystem: false, askOverwrite: true, openPanel: false };
}
}
async function doInstall(policy = 'cancel') {
const btn = document.getElementById('btnInstallProfile');
const prefs = await loadInstallPrefs();
if (btn) {
btn.disabled = true;
btn.textContent = 'Installing…';
}
const options = {
force_overwrite: policy === 'overwrite',
prefer_system_wide: prefs.preferSystem,
register_with_os: true,
collision_policy: policy,
open_color_panel: prefs.openPanel,
calibration_note: calibrationInstallNote(),
};
try {
const result = await invoke('install_profile_to_system', {
profilePath: installState.profilePath,
options,
});
installState.installedPath = result.dest_path;
wizardState.showNotice(result.message, 'success', 8000);
logger.info(result.message, 'ProfileInstall');
const log = document.getElementById('profcheckLog');
if (log) log.textContent += `\n[INSTALL] ${result.message}\n`;
refreshInstallButton();
} catch (err) {
const message = String(err);
if (/already exists/i.test(message) && prefs.askOverwrite && policy === 'cancel') {
const choice = await collisionChoice(message);
if (choice !== 'cancel') {
await doInstall(choice);
return;
}
}
wizardState.showNotice(`Install failed: ${err}`, 'error', 9000);
logger.error(`install_profile_to_system: ${err}`, 'ProfileInstall');
if (btn) {
btn.disabled = false;
btn.textContent = 'Install Profile to System';
}
}
}
export function initProfileInstall() {
const btn = document.getElementById('btnInstallProfile');
if (btn) {
btn.addEventListener('click', () => doInstall('cancel'));
}
refreshInstallButton();
}
export function runProfileInstallTests() {
const noteNone = (() => {
// Pure helper coverage lives in calibrationInstallNote via getActiveCalibration.
return typeof calibrationInstallNote === 'function';
})();
return { helperExported: noteNone };
}
+43
View File
@@ -0,0 +1,43 @@
// node src/js/profile_install.test.js
if (typeof window === 'undefined') {
globalThis.window = {
__TAURI__: {
core: { invoke: () => Promise.resolve({}) },
event: { listen: () => Promise.resolve(() => {}) }
},
addEventListener: () => {},
dispatchEvent: () => {}
};
globalThis.document = {
getElementById: () => null,
querySelectorAll: () => [],
querySelector: () => null,
createElement: () => ({ style: {}, appendChild() {}, addEventListener() {}, setAttribute() {} })
};
globalThis.localStorage = { getItem: () => null, setItem: () => {} };
}
const { calibrationInstallNote, setProfileInstallSource } = await import('./profile_install.js');
let passed = 0;
let total = 0;
function assert(cond, name) {
total += 1;
if (cond) { passed += 1; console.log(` ok ${name}`); }
else console.error(` FAIL ${name}`);
}
export function runAll() {
passed = 0; total = 0;
console.log('profile_install.test.js');
assert(typeof calibrationInstallNote === 'function', 'exports calibrationInstallNote');
assert(calibrationInstallNote() === null, 'no note when calibration is inactive');
assert(typeof setProfileInstallSource === 'function', 'exports setProfileInstallSource');
setProfileInstallSource('/tmp/demo.icc', true);
setProfileInstallSource(null, false);
console.log(`\n${passed}/${total} passed`);
if (passed !== total) process.exitCode = 1;
}
runAll();
+13
View File
@@ -74,6 +74,14 @@ export async function initSettings() {
if (staleDays) {
staleDays.value = Number(settings.calibration_stale_days ?? 30);
}
const installLoc = document.getElementById('defaultInstallLocation');
if (installLoc) {
installLoc.value = settings.default_install_location === 'system' ? 'system' : 'user';
}
const askOw = document.getElementById('askBeforeOverwriteProfile');
if (askOw) askOw.checked = settings.ask_before_overwrite_profile !== false;
const openPanel = document.getElementById('openColorPanelAfterInstall');
if (openPanel) openPanel.checked = !!settings.open_color_panel_after_install;
validateDeltaEThresholds();
await refreshLogPath();
dialog.showModal();
@@ -155,6 +163,11 @@ export async function initSettings() {
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
calibration_stale_days: Math.max(1, parseInt(document.getElementById('calibrationStaleDays')?.value, 10) || 30),
default_install_location: document.getElementById('defaultInstallLocation')?.value === 'system' ? 'system' : 'user',
ask_before_overwrite_profile: document.getElementById('askBeforeOverwriteProfile')
? document.getElementById('askBeforeOverwriteProfile').checked : true,
open_color_panel_after_install: document.getElementById('openColorPanelAfterInstall')
? document.getElementById('openColorPanelAfterInstall').checked : false,
};
await invoke('save_settings', { settings });
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');