11 Commits
Author SHA1 Message Date
gronod 6976ced610 feat(ci): build styled macOS DMG headlessly via dmgbuild (#189)
Build Windows Packages / Build Windows (push) Canceled after 10s
Build Linux Packages / Build Linux (push) Canceled after 14s
Build macOS Packages / Build macOS (Intel) (push) Successful in 10m34s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 10m43s
- Add scripts/build-dmg.py using dmgbuild to generate DMGs with custom background art and icon locations without Finder AppleScript.
- Update .gitea/workflows/build-macos.yml and .github/workflows/build-macos.yml to package the .app bundle using dmgbuild in a headless virtual environment.
- Resolves missing DMG background in CI while preventing AppleScript GUI automation timeouts.
2026-09-06 22:04:36 +01:00
gronod f47e65ee1f Merge pull request 'Main' (#221) from main into development
Reviewed-on: #221
2026-09-06 21:11:22 +01:00
gronod 193e492c10 Update .gitea/workflows/build-macos.yml
Build Windows Packages / Build Windows (push) Successful in 7m35s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 9m25s
Build Linux Packages / Build Linux (push) Successful in 10m52s
Build macOS Packages / Build macOS (Intel) (push) Successful in 7m14s
Re-enabled arm64
2026-09-06 20:16:52 +01:00
gronod 899718d896 Merge pull request 'Merge development into main' (#220) from development into main
Build Windows Packages / Build Windows (push) Successful in 7m44s
Build macOS Packages / Build macOS (Intel) (push) Successful in 8m18s
Build Linux Packages / Build Linux (push) Successful in 10m18s
2026-09-06 19:26:32 +01:00
gronod 7a9637b5d0 Merge pull request 'ci(macos): allow headless DMG bundling on Gitea runners' (#219) from ci/fix-macos-dmg-bundling into development 2026-09-06 19:26:24 +01:00
gronod 1af844c7e2 ci(macos): allow headless DMG bundling on Gitea runners 2026-09-06 19:24:30 +01:00
gronod 56f275a023 Merge branch 'ci/215-frontend-tests' into development (#215) 2026-09-06 16:40:43 +01:00
gronod 54792a8690 Merge branch 'fix/213-atomic-history-writes' into development (#213) 2026-09-06 16:40:41 +01:00
gronod 906b4f9104 ci: add npm test script and integrate frontend tests into CI workflows (#215) 2026-09-06 16:40:37 +01:00
gronod 5f5bf76594 fix(quality_store): atomic file writes for verification_history.json (#213) 2026-09-06 16:39:53 +01:00
gronod 9c7d563dbf chore: bump version to v0.8.4 2026-09-06 16:37:59 +01:00
12 changed files with 274 additions and 53 deletions
+3
View File
@@ -64,6 +64,9 @@ jobs:
- name: Install Node dependencies - name: Install Node dependencies
run: npm ci run: npm ci
- name: Run frontend tests
run: npm test
- name: Set Release Environment - name: Set Release Environment
id: set_env id: set_env
shell: bash shell: bash
+24 -24
View File
@@ -19,11 +19,11 @@ jobs:
target: x86_64-apple-darwin target: x86_64-apple-darwin
binary_dir: macos-x86_64 binary_dir: macos-x86_64
arch: x86_64 arch: x86_64
# - name: Apple Silicon - name: Apple Silicon
# os: macos os: macos
# target: aarch64-apple-darwin target: aarch64-apple-darwin
# binary_dir: macos-aarch64 binary_dir: macos-aarch64
# arch: arm64 arch: arm64
# - name: Universal (Intel + Apple Silicon) # - name: Universal (Intel + Apple Silicon)
# os: macos # os: macos
# target: universal-apple-darwin # target: universal-apple-darwin
@@ -59,6 +59,9 @@ jobs:
- name: Install Node dependencies - name: Install Node dependencies
run: npm ci run: npm ci
- name: Run frontend tests
run: npm test
- name: Fetch ArgyllCMS binaries - name: Fetch ArgyllCMS binaries
env: env:
ARGYLL_SERVER_URL: ${{ github.server_url }} ARGYLL_SERVER_URL: ${{ github.server_url }}
@@ -81,23 +84,9 @@ jobs:
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
- name: Warm up macOS GUI for DMG layout
shell: bash
run: |
# Tauri's DMG bundler runs Finder AppleScript to set the background
# picture and icon positions. On CI Macs, Finder/Aqua may be idle,
# which causes AppleEvent timeouts (-1712). Wake them before build.
echo "WindowServer: $(pgrep -l WindowServer || echo 'NOT RUNNING')"
echo "console user: $(stat -f '%Su' /dev/console 2>/dev/null || echo unknown)"
echo "whoami: $(whoami)"
open -g -a Finder || true
open -g -a "System Events" || true
osascript -e 'tell application "Finder" to get name' || true
sleep 10
- name: Build Tauri App - name: Build Tauri App
env: env:
TAURI_BUNDLER_DMG_IGNORE_CI: "true" CI: "true"
run: npm run tauri build -- --target ${{ matrix.platform.target }} run: npm run tauri build -- --target ${{ matrix.platform.target }}
- name: Prepare Release Assets - name: Prepare Release Assets
@@ -105,11 +94,22 @@ jobs:
run: | run: |
mkdir -p release-assets mkdir -p release-assets
# Stage DMG package APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1)
DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1) if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then echo "Error: Could not locate built .app bundle in src-tauri/target"
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg" exit 1
fi fi
echo "Found .app bundle: ${APP_PATH}"
# Setup isolated Python virtual environment for dmgbuild
python3 -m venv "${{ runner.temp }}/dmgbuild-venv"
"${{ runner.temp }}/dmgbuild-venv/bin/pip" install --quiet dmgbuild
# Build styled DMG with background art and custom icon locations
"${{ runner.temp }}/dmgbuild-venv/bin/python" scripts/build-dmg.py \
--app "${APP_PATH}" \
--output "release-assets/${PREFIX}.dmg" \
--volname "ICCery"
- name: Upload Artifacts - name: Upload Artifacts
+3
View File
@@ -41,6 +41,9 @@ jobs:
- name: Install Node dependencies - name: Install Node dependencies
run: npm ci run: npm ci
- name: Run frontend tests
run: npm test
- name: Fetch ArgyllCMS binaries - name: Fetch ArgyllCMS binaries
shell: powershell shell: powershell
env: env:
+16 -18
View File
@@ -84,22 +84,9 @@ jobs:
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
- name: Warm up macOS GUI for DMG layout
if: runner.os == 'macOS'
run: |
# Tauri's DMG bundler runs an AppleScript that asks Finder to set the
# background picture and icon positions. On CI runners, Finder or the
# Aqua session may be idle, which can cause AppleEvent timeouts. Try to
# start/awaken Finder and System Events before the real build.
open -g -a Finder || true
open -g -a "System Events" || true
# Send a harmless probe to force Finder to initialise a scripting session.
osascript -e 'tell application "Finder" to get name' || true
sleep 10
- name: Build Tauri App - name: Build Tauri App
env: env:
TAURI_BUNDLER_DMG_IGNORE_CI: "true" CI: "true"
run: npm run tauri build -- --target ${{ matrix.platform.target }} run: npm run tauri build -- --target ${{ matrix.platform.target }}
- name: Prepare Release Assets - name: Prepare Release Assets
@@ -107,11 +94,22 @@ jobs:
run: | run: |
mkdir -p release-assets mkdir -p release-assets
# Stage DMG package APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1)
DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1) if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then echo "Error: Could not locate built .app bundle in src-tauri/target"
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg" exit 1
fi fi
echo "Found .app bundle: ${APP_PATH}"
# Setup isolated Python virtual environment for dmgbuild
python3 -m venv "${{ runner.temp }}/dmgbuild-venv"
"${{ runner.temp }}/dmgbuild-venv/bin/pip" install --quiet dmgbuild
# Build styled DMG with background art and custom icon locations
"${{ runner.temp }}/dmgbuild-venv/bin/python" scripts/build-dmg.py \
--app "${APP_PATH}" \
--output "release-assets/${PREFIX}.dmg" \
--volname "ICCery"
- name: Upload Artifacts - name: Upload Artifacts
+1 -1
View File
@@ -2,7 +2,7 @@
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS. > Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
[![Release](https://img.shields.io/badge/version-v0.8.3-blue.svg)](https://git.i3omb.com/gronod/ICCery) [![Release](https://img.shields.io/badge/version-v0.8.4-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) [![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) [![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) [![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md)
+5 -1
View File
@@ -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. - **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. - **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`). - **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`).
- **Current Version**: `v0.8.3` (Production release). - **Current Version**: `v0.8.4` (Production release).
--- ---
@@ -113,6 +113,10 @@ ICCery is a native, cross-platform desktop application built with:
- [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] **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. - [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.
### Maintenance & Reliability Release (`v0.8.4`)
- [x] **Atomic Verification History Persistence (#213)**: Hardened `quality_store.rs` with atomic temporary file writes (`.tmp`), explicit flush/sync, and atomic rename to prevent historical drift data loss or corruption upon unexpected system crashes.
- [x] **Frontend Unit Testing & CI Integration (#215)**: Added standard `npm test` script executing the 3 frontend test suites (`profcheck`, `chartread`, and `gamut_viewer`) and integrated automated frontend test validation into macOS, Linux, and Windows CI workflows.
--- ---
## 3. Future Roadmap ## 3. Future Roadmap
+3 -2
View File
@@ -1,11 +1,12 @@
{ {
"name": "iccery", "name": "iccery",
"private": true, "private": true,
"version": "0.8.3", "version": "0.8.4",
"type": "module", "type": "module",
"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"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2"
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Headless macOS DMG Builder for ICCery.
Uses `dmgbuild` to package the .app bundle into a styled DMG with custom
background art, window bounds, and icon locations without requiring Finder
AppleScript automation or an interactive display session.
"""
import argparse
import os
import sys
def parse_args():
parser = argparse.ArgumentParser(description="Build styled macOS DMG installer")
parser.add_argument(
"--app",
required=True,
help="Path to the .app application bundle",
)
parser.add_argument(
"--output",
required=True,
help="Path to the output .dmg file",
)
parser.add_argument(
"--volname",
default="ICCery",
help="Volume name for the mounted disk image (default: ICCery)",
)
parser.add_argument(
"--background",
default="src-tauri/icons/dmg-background.png",
help="Path to background image (default: src-tauri/icons/dmg-background.png)",
)
parser.add_argument(
"--icon",
default="src-tauri/icons/icon.icns",
help="Path to volume icon .icns (default: src-tauri/icons/icon.icns)",
)
parser.add_argument(
"--window-size",
nargs=2,
type=int,
default=[660, 400],
metavar=("WIDTH", "HEIGHT"),
help="Window width and height in points (default: 660 400)",
)
parser.add_argument(
"--icon-size",
type=int,
default=100,
help="Icon size in points (default: 100)",
)
parser.add_argument(
"--app-pos",
nargs=2,
type=int,
default=[180, 220],
metavar=("X", "Y"),
help="App icon location (default: 180 220)",
)
parser.add_argument(
"--apps-link-pos",
nargs=2,
type=int,
default=[480, 220],
metavar=("X", "Y"),
help="Applications symlink location (default: 480 220)",
)
return parser.parse_args()
def main():
args = parse_args()
app_path = os.path.abspath(args.app)
output_path = os.path.abspath(args.output)
bg_path = os.path.abspath(args.background) if args.background else None
icon_path = os.path.abspath(args.icon) if args.icon else None
if not os.path.isdir(app_path):
print(f"Error: Application bundle does not exist at '{app_path}'", file=sys.stderr)
sys.exit(1)
if bg_path and not os.path.isfile(bg_path):
print(f"Error: Background image not found at '{bg_path}'", file=sys.stderr)
sys.exit(1)
try:
import dmgbuild
except ImportError:
print("Error: 'dmgbuild' is required. Install via: pip install dmgbuild", file=sys.stderr)
sys.exit(1)
app_name = os.path.basename(app_path)
output_dir = os.path.dirname(output_path)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
if os.path.exists(output_path):
os.remove(output_path)
win_w, win_h = args.window_size
app_x, app_y = args.app_pos
apps_x, apps_y = args.apps_link_pos
settings = {
"files": [app_path],
"symlinks": {"Applications": "/Applications"},
"background": bg_path,
"icon": icon_path if icon_path and os.path.isfile(icon_path) else None,
"icon_size": args.icon_size,
"window_rect": ((100, 100), (win_w, win_h)),
"icon_locations": {
app_name: (app_x, app_y),
"Applications": (apps_x, apps_y),
},
"format": "UDZO",
}
print(f"Building DMG for {app_name}...")
print(f" Volume Name: {args.volname}")
print(f" App Bundle: {app_path}")
print(f" Background: {bg_path}")
print(f" Window Size: {win_w}x{win_h}")
print(f" App Position: ({app_x}, {app_y})")
print(f" Apps Position: ({apps_x}, {apps_y})")
print(f" Output Path: {output_path}")
try:
dmgbuild.build_dmg(output_path, args.volname, settings=settings)
except Exception as e:
print(f"Error: dmgbuild failed: {e}", file=sys.stderr)
sys.exit(1)
if not os.path.isfile(output_path):
print(f"Error: Expected output DMG file '{output_path}' was not created", file=sys.stderr)
sys.exit(1)
size_mb = os.path.getsize(output_path) / (1024 * 1024)
print(f"Successfully generated DMG ({size_mb:.2f} MB): {output_path}")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1423,7 +1423,7 @@ dependencies = [
[[package]] [[package]]
name = "iccery" name = "iccery"
version = "0.8.3" version = "0.8.4"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"image", "image",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "iccery" name = "iccery"
version = "0.8.3" version = "0.8.4"
description = "Modern Printer Profiling UI frontend for ArgyllCMS" description = "Modern Printer Profiling UI frontend for ArgyllCMS"
authors = ["Gordon"] authors = ["Gordon"]
edition = "2021" edition = "2021"
+70 -4
View File
@@ -100,15 +100,21 @@ pub fn load_history_file(path: &Path) -> Vec<VerificationRecord> {
} }
let content = match fs::read_to_string(path) { let content = match fs::read_to_string(path) {
Ok(c) => c, Ok(c) => c,
Err(_) => return Vec::new(), Err(e) => {
log::warn!("Failed to read verification history at {}: {}", path.display(), e);
return Vec::new();
}
}; };
match serde_json::from_str::<VerificationHistoryStore>(&content) { match serde_json::from_str::<VerificationHistoryStore>(&content) {
Ok(store) => store.records, Ok(store) => store.records,
Err(_) => Vec::new(), Err(e) => {
log::error!("Failed to parse verification history at {}: {}", path.display(), e);
Vec::new()
}
} }
} }
/// Writes verification records to the specified JSON path. /// Writes verification records to the specified JSON path atomically.
/// Automatically creates parent directories if needed. /// Automatically creates parent directories if needed.
/// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000). /// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000).
pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> { pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> {
@@ -132,7 +138,33 @@ pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result
let json = serde_json::to_string_pretty(&store) let json = serde_json::to_string_pretty(&store)
.map_err(|e| format!("Failed to serialize verification history: {}", e))?; .map_err(|e| format!("Failed to serialize verification history: {}", e))?;
fs::write(path, json).map_err(|e| format!("Failed to write verification history: {}", e))?;
let mut tmp_path = path.as_os_str().to_os_string();
tmp_path.push(".tmp");
let tmp_path = PathBuf::from(tmp_path);
// Write to temporary file with explicit flush and sync
{
use std::io::Write;
let mut file = fs::File::create(&tmp_path)
.map_err(|e| format!("Failed to create temp history file: {}", e))?;
file.write_all(json.as_bytes())
.map_err(|e| {
let _ = fs::remove_file(&tmp_path);
format!("Failed to write temp history file: {}", e)
})?;
file.sync_all()
.map_err(|e| {
let _ = fs::remove_file(&tmp_path);
format!("Failed to sync temp history file: {}", e)
})?;
}
// Atomically replace destination file
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(format!("Failed to atomically replace verification history file: {}", e));
}
Ok(()) Ok(())
} }
@@ -418,4 +450,38 @@ mod tests {
assert_eq!(p2.extension().unwrap(), "csv"); assert_eq!(p2.extension().unwrap(), "csv");
assert_eq!(p2.to_string_lossy(), "/tmp/history.csv"); assert_eq!(p2.to_string_lossy(), "/tmp/history.csv");
} }
#[test]
fn test_atomic_write_preserves_data_and_cleans_tmp() {
let temp_dir = std::env::temp_dir().join("iccery_test_atomic_write");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let file_path = temp_dir.join("verification_history.json");
let mut tmp_file_path = file_path.as_os_str().to_os_string();
tmp_file_path.push(".tmp");
let tmp_file_path = std::path::PathBuf::from(tmp_file_path);
let rec = sample_record("vr-atom-1", "2026-09-06T12:00:00Z", 0.5);
write_history_file(&file_path, &[rec.clone()]).unwrap();
assert!(file_path.exists(), "Target file must exist");
assert!(!tmp_file_path.exists(), "Temporary file must not remain after successful atomic write");
let loaded = load_history_file(&file_path);
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0], rec);
// Overwrite with updated records to verify atomic replacement
let rec2 = sample_record("vr-atom-2", "2026-09-06T12:05:00Z", 1.2);
write_history_file(&file_path, &[rec.clone(), rec2.clone()]).unwrap();
assert!(!tmp_file_path.exists(), "Temporary file must not remain after overwrite");
let loaded2 = load_history_file(&file_path);
assert_eq!(loaded2.len(), 2);
assert_eq!(loaded2[0], rec);
assert_eq!(loaded2[1], rec2);
let _ = fs::remove_dir_all(&temp_dir);
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery", "productName": "ICCery",
"version": "0.8.3", "version": "0.8.4",
"identifier": "com.gronod.iccery", "identifier": "com.gronod.iccery",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"