Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6976ced610 | ||
|
|
f47e65ee1f | ||
|
|
193e492c10 | ||
|
|
899718d896 | ||
|
|
7a9637b5d0 | ||
|
|
1af844c7e2 | ||
|
|
56f275a023 | ||
|
|
54792a8690 | ||
|
|
5f5bf76594 | ||
|
|
9102c95468 | ||
|
|
11e796fd17 | ||
|
|
a8c4025c01 |
@@ -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
|
||||||
@@ -84,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
|
||||||
@@ -108,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user