diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml index deb7402..b2dc66f 100644 --- a/.gitea/workflows/build-macos.yml +++ b/.gitea/workflows/build-macos.yml @@ -94,11 +94,22 @@ jobs: run: | mkdir -p release-assets - # Stage DMG package - DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1) - if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then - cp "$DMG_FILE" "release-assets/${PREFIX}.dmg" + APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1) + if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then + echo "Error: Could not locate built .app bundle in src-tauri/target" + exit 1 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 diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 02fc38f..641c694 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -84,22 +84,9 @@ jobs: echo "SHORT_SHA=${SHORT_SHA}" >> $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 env: - TAURI_BUNDLER_DMG_IGNORE_CI: "true" + CI: "true" run: npm run tauri build -- --target ${{ matrix.platform.target }} - name: Prepare Release Assets @@ -107,11 +94,22 @@ jobs: run: | mkdir -p release-assets - # Stage DMG package - DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1) - if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then - cp "$DMG_FILE" "release-assets/${PREFIX}.dmg" + APP_PATH=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1) + if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then + echo "Error: Could not locate built .app bundle in src-tauri/target" + exit 1 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 diff --git a/scripts/build-dmg.py b/scripts/build-dmg.py new file mode 100644 index 0000000..0ede7c8 --- /dev/null +++ b/scripts/build-dmg.py @@ -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()