feat(stage5): install generated ICC/ICM profiles into the OS (#223) #228
@@ -10,6 +10,14 @@
|
|||||||
- Warn when a loaded `.cal` is older than `calibration_stale_days` (default 30) or the stored printer name differs.
|
- 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`.
|
- 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
|
## Stage 5 Verification / Profcheck
|
||||||
|
|
||||||
- `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats.
|
- `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)
|
- Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests)
|
||||||
- Gamut viewer tests: `node src/js/gamut_viewer.test.js`
|
- Gamut viewer tests: `node src/js/gamut_viewer.test.js`
|
||||||
- Calibration helpers: `node src/js/calibration.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())`
|
- Browser devtools console: `import('./profcheck.test.js').then(m => m.runAll())`
|
||||||
- **Frontend development server**: `npm run tauri dev`
|
- **Frontend development server**: `npm run tauri dev`
|
||||||
- **Production package build**: `npm run tauri build`
|
- **Production package build**: `npm run tauri build`
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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 (0–255 scaling, field aliasing, metadata synthesis) and direct-jump workflows to Stage 4 (Profile Calculation) and Stage 5 (Verification).
|
- 📊 **CGATS Dataset Interoperability (#94)**: Native parser for external CGATS and Argyll `.ti3` datasets with canonical normalization (0–255 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.
|
- 📋 **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.
|
- 🍎 **macOS Universal Binary**: Native Apple Silicon (`arm64`) and Intel (`x86_64`) support with universal binary bundling and fallback resolution.
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ ICCery is a native, cross-platform desktop application built with:
|
|||||||
|
|
||||||
### Printer Calibration Release (`v0.8.5`)
|
### 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] **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
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
||||||
"tauri": "tauri",
|
"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": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod events;
|
|||||||
mod macos_webview;
|
mod macos_webview;
|
||||||
mod print;
|
mod print;
|
||||||
mod process_manager;
|
mod process_manager;
|
||||||
|
mod profile_install;
|
||||||
mod quality_store;
|
mod quality_store;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod window_lifecycle;
|
mod window_lifecycle;
|
||||||
@@ -102,6 +103,8 @@ pub fn run() {
|
|||||||
calibration::select_cal_file,
|
calibration::select_cal_file,
|
||||||
calibration::load_project_calibration,
|
calibration::load_project_calibration,
|
||||||
calibration::save_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::save_verification_record,
|
||||||
quality_store::get_verification_history,
|
quality_store::get_verification_history,
|
||||||
quality_store::clear_verification_history,
|
quality_store::clear_verification_history,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ pub struct ProfilingPreset {
|
|||||||
fn default_delta_e_good_max() -> f64 { 2.0 }
|
fn default_delta_e_good_max() -> f64 { 2.0 }
|
||||||
fn default_delta_e_warning_max() -> f64 { 5.0 }
|
fn default_delta_e_warning_max() -> f64 { 5.0 }
|
||||||
fn default_cal_stale_days() -> u32 { 30 }
|
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)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
@@ -83,6 +85,13 @@ pub struct AppSettings {
|
|||||||
pub enable_i1pro2_leds: bool,
|
pub enable_i1pro2_leds: bool,
|
||||||
#[serde(default = "default_cal_stale_days")]
|
#[serde(default = "default_cal_stale_days")]
|
||||||
pub calibration_stale_days: u32,
|
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 {
|
impl Default for AppSettings {
|
||||||
@@ -96,6 +105,9 @@ impl Default for AppSettings {
|
|||||||
custom_presets: Vec::new(),
|
custom_presets: Vec::new(),
|
||||||
enable_i1pro2_leds: false,
|
enable_i1pro2_leds: false,
|
||||||
calibration_stale_days: default_cal_stale_days(),
|
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();
|
let settings = AppSettings::default();
|
||||||
assert_eq!(settings.enable_i1pro2_leds, false);
|
assert_eq!(settings.enable_i1pro2_leds, false);
|
||||||
assert_eq!(settings.calibration_stale_days, 30);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -908,6 +908,7 @@
|
|||||||
|
|
||||||
<div class="stage-actions">
|
<div class="stage-actions">
|
||||||
<button class="primary btn-lg" id="btnVerify">Verify Profile Accuracy</button>
|
<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 system’s 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>
|
</div>
|
||||||
|
|
||||||
<!-- Report Card -->
|
<!-- Report Card -->
|
||||||
@@ -1093,6 +1094,21 @@
|
|||||||
<input type="number" id="calibrationStaleDays" min="1" max="365" value="30">
|
<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>
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||||
@@ -1137,6 +1153,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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 -->
|
<!-- About Dialog -->
|
||||||
<dialog id="aboutDialog" class="settings-modal about-modal">
|
<dialog id="aboutDialog" class="settings-modal about-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { wizardState } from './state.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { CgatsInterop } from './cgats_interop.js';
|
import { CgatsInterop } from './cgats_interop.js';
|
||||||
import { initCalibration } from './calibration.js';
|
import { initCalibration } from './calibration.js';
|
||||||
|
import { initProfileInstall } from './profile_install.js';
|
||||||
|
|
||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
safeInit('Settings', initSettings);
|
safeInit('Settings', initSettings);
|
||||||
safeInit('Presets', initPresets);
|
safeInit('Presets', initPresets);
|
||||||
safeInit('Calibration', initCalibration);
|
safeInit('Calibration', initCalibration);
|
||||||
|
safeInit('ProfileInstall', initProfileInstall);
|
||||||
|
|
||||||
// Double-rAF waits for layout + first paint of the dark CSS.
|
// Double-rAF waits for layout + first paint of the dark CSS.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { listen } = window.__TAURI__.event;
|
|||||||
import { loadGamutMesh } from './gamut_viewer.js';
|
import { loadGamutMesh } from './gamut_viewer.js';
|
||||||
import { wizardState } from './state.js';
|
import { wizardState } from './state.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { setProfileInstallSource } from './profile_install.js';
|
||||||
|
|
||||||
let profileBasename = "";
|
let profileBasename = "";
|
||||||
let profileCwd = "";
|
let profileCwd = "";
|
||||||
@@ -535,6 +536,7 @@ export function initProfcheck() {
|
|||||||
logContainer.classList.remove("hidden");
|
logContainer.classList.remove("hidden");
|
||||||
reportCard.classList.add("hidden");
|
reportCard.classList.add("hidden");
|
||||||
btnVerify.disabled = true;
|
btnVerify.disabled = true;
|
||||||
|
setProfileInstallSource(iccPath, false);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
ti3_path: ti3Path,
|
ti3_path: ti3Path,
|
||||||
@@ -571,6 +573,7 @@ export function initProfcheck() {
|
|||||||
if (event.payload.code === 0) {
|
if (event.payload.code === 0) {
|
||||||
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
|
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
|
||||||
const report = parseProfcheckReport(stdoutAccumulator);
|
const report = parseProfcheckReport(stdoutAccumulator);
|
||||||
|
setProfileInstallSource(iccPath, true);
|
||||||
|
|
||||||
reportCard.classList.remove("hidden");
|
reportCard.classList.remove("hidden");
|
||||||
if (report.warnings.length > 0) {
|
if (report.warnings.length > 0) {
|
||||||
|
|||||||
@@ -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 system’s 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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -74,6 +74,14 @@ export async function initSettings() {
|
|||||||
if (staleDays) {
|
if (staleDays) {
|
||||||
staleDays.value = Number(settings.calibration_stale_days ?? 30);
|
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();
|
validateDeltaEThresholds();
|
||||||
await refreshLogPath();
|
await refreshLogPath();
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
@@ -155,6 +163,11 @@ export async function initSettings() {
|
|||||||
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
|
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
|
||||||
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
|
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
|
||||||
calibration_stale_days: Math.max(1, parseInt(document.getElementById('calibrationStaleDays')?.value, 10) || 30),
|
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 });
|
await invoke('save_settings', { settings });
|
||||||
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
|
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
|
||||||
|
|||||||
Reference in New Issue
Block a user