diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 833e7bba..65bf5207 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -203,6 +203,14 @@ jobs:
rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH
+ - name: Set up Python for the DMG layout
+ if: matrix.platform == 'macos-latest'
+ id: python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 #v7.0.0
+ with:
+ python-version: "3.13"
+ update-environment: false
+
- name: Build Tauri app
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f #v1.0.0
env:
@@ -228,6 +236,29 @@ jobs:
prerelease: false
args: ${{ matrix.args }}
+ # Tauri's DMG has no layout in CI: its Finder script needs a desktop
+ # session. Rebuild the DMG from the notarized app with a layout written
+ # directly, sign it, and replace the asset that tauri-action uploaded.
+ - name: Rebuild macOS DMG with the install layout
+ if: matrix.platform == 'macos-latest'
+ shell: bash
+ env:
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PYTHON: ${{ steps.python.outputs.python-path }}
+ TAG: ${{ github.ref_name }}
+ run: |
+ BUNDLE="src-tauri/target/${{ matrix.target }}/release/bundle"
+ shopt -s nullglob
+ DMGS=("$BUNDLE"/dmg/*.dmg)
+ if [ "${#DMGS[@]}" -ne 1 ]; then
+ echo "Expected one DMG in $BUNDLE/dmg, found ${#DMGS[@]}"
+ exit 1
+ fi
+ xcrun stapler validate "$BUNDLE/macos/Donut.app"
+ scripts/build-macos-dmg.sh "$BUNDLE/macos/Donut.app" "${DMGS[0]}"
+ gh release upload "$TAG" "${DMGS[0]}" --clobber --repo "$GITHUB_REPOSITORY"
+
- name: Create portable Windows ZIP
if: matrix.platform == 'windows-latest'
shell: bash
diff --git a/.github/workflows/rolling-release.yml b/.github/workflows/rolling-release.yml
index 4e5a7ac1..21160044 100644
--- a/.github/workflows/rolling-release.yml
+++ b/.github/workflows/rolling-release.yml
@@ -232,6 +232,14 @@ jobs:
rm -f "$CERT_PATH" "$KEY_PATH" "$PEM_PATH" "$P12_PATH"
+ - name: Set up Python for the DMG layout
+ if: matrix.platform == 'macos-latest'
+ id: python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 #v7.0.0
+ with:
+ python-version: "3.13"
+ update-environment: false
+
- name: Build Tauri app
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f #v1.0.0
env:
@@ -256,6 +264,29 @@ jobs:
prerelease: true
args: ${{ matrix.args }}
+ # Tauri's DMG has no layout in CI: its Finder script needs a desktop
+ # session. Rebuild the DMG from the notarized app with a layout written
+ # directly, sign it, and replace the asset that tauri-action uploaded.
+ - name: Rebuild macOS DMG with the install layout
+ if: matrix.platform == 'macos-latest'
+ shell: bash
+ env:
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PYTHON: ${{ steps.python.outputs.python-path }}
+ TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
+ run: |
+ BUNDLE="src-tauri/target/${{ matrix.target }}/release/bundle"
+ shopt -s nullglob
+ DMGS=("$BUNDLE"/dmg/*.dmg)
+ if [ "${#DMGS[@]}" -ne 1 ]; then
+ echo "Expected one DMG in $BUNDLE/dmg, found ${#DMGS[@]}"
+ exit 1
+ fi
+ xcrun stapler validate "$BUNDLE/macos/Donut.app"
+ scripts/build-macos-dmg.sh "$BUNDLE/macos/Donut.app" "${DMGS[0]}"
+ gh release upload "$TAG" "${DMGS[0]}" --clobber --repo "$GITHUB_REPOSITORY"
+
- name: Create portable Windows ZIP
if: matrix.platform == 'windows-latest'
shell: bash
diff --git a/packaging/macos/dmg-background.png b/packaging/macos/dmg-background.png
new file mode 100644
index 00000000..e65607d0
Binary files /dev/null and b/packaging/macos/dmg-background.png differ
diff --git a/packaging/macos/dmg-background.svg b/packaging/macos/dmg-background.svg
new file mode 100644
index 00000000..9a738b2d
--- /dev/null
+++ b/packaging/macos/dmg-background.svg
@@ -0,0 +1,25 @@
+
diff --git a/packaging/macos/dmg-background@2x.png b/packaging/macos/dmg-background@2x.png
new file mode 100644
index 00000000..69d51268
Binary files /dev/null and b/packaging/macos/dmg-background@2x.png differ
diff --git a/packaging/macos/dmg-requirements.txt b/packaging/macos/dmg-requirements.txt
new file mode 100644
index 00000000..f9f36423
--- /dev/null
+++ b/packaging/macos/dmg-requirements.txt
@@ -0,0 +1,3 @@
+dmgbuild==1.6.7 --hash=sha256:37ee5771c377beb3203d9164aae8046ffed8531c06edf9227f5788b3c599b1bf
+ds-store==1.3.3 --hash=sha256:b92a371efbf1b4ccce2a04d1ed13fceacc4736c81ba09cf5aefb74c088160a35
+mac-alias==2.2.3 --hash=sha256:7362b521d2132ef92f606a37abfed5fcd849ceb2f28b6f9743e014b02af92f0d
diff --git a/packaging/macos/dmg-settings.py b/packaging/macos/dmg-settings.py
new file mode 100644
index 00000000..f3eada48
--- /dev/null
+++ b/packaging/macos/dmg-settings.py
@@ -0,0 +1,52 @@
+# dmgbuild settings for the macOS disk image. scripts/build-macos-dmg.sh runs
+# dmgbuild with them. dmgbuild writes the .DS_Store directly, so the layout
+# needs no Finder session and is the same on every build.
+#
+# Icon locations are icon centres, in points from the top-left of the window
+# content. The window origin is measured from the bottom-left of the screen,
+# and its size includes the title bar. Finder on macOS 26 also shows a tab
+# bar, which leaves about 362 points of content height. The background is
+# drawn for this layout: change dmg-background.svg with it and render it
+# again at 1x and 2x.
+
+import os.path
+
+app = defines["app"]
+app_name = os.path.basename(app.rstrip("/"))
+
+format = "UDZO"
+compression_level = 9
+filesystem = "HFS+"
+
+files = [app]
+symlinks = {"Applications": "/Applications"}
+# No hide_extensions: its Finder flag on the bundle fails codesign --strict.
+
+icon = defines["volume_icon"]
+# dmgbuild joins dmg-background@2x.png with it into one HiDPI TIFF.
+background = defines["background"]
+
+window_rect = ((400, 400), (660, 430))
+default_view = "icon-view"
+show_toolbar = False
+show_tab_view = False
+show_status_bar = False
+show_pathbar = False
+show_sidebar = False
+
+arrange_by = None
+icon_size = 128
+text_size = 13
+label_pos = "bottom"
+
+icon_locations = {
+ app_name: (170, 222),
+ "Applications": (490, 222),
+}
+
+# Items that Finder shows when hidden files are visible. They sit to the right
+# of the window, so they stay out of view.
+for i, name in enumerate(
+ (".background.tiff", ".VolumeIcon.icns", ".fseventsd", ".Trashes")
+):
+ icon_locations[name] = (900 + 160 * i, 222)
diff --git a/scripts/build-macos-dmg.sh b/scripts/build-macos-dmg.sh
new file mode 100755
index 00000000..27eda58c
--- /dev/null
+++ b/scripts/build-macos-dmg.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Builds the macOS disk image from a built (and, in CI, notarized) Donut.app,
+# with the background and the Finder layout in packaging/macos.
+#
+# scripts/build-macos-dmg.sh
+#
+# When APPLE_SIGNING_IDENTITY is set, the image is signed with it.
+# PYTHON selects the interpreter (3.10 or newer); the default is python3.
+set -euo pipefail
+
+if [[ $# -ne 2 ]]; then
+ echo "usage: $0 " >&2
+ exit 2
+fi
+
+app="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
+out="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"
+root="$(cd "$(dirname "$0")/.." && pwd)"
+assets="$root/packaging/macos"
+python="${PYTHON:-python3}"
+
+[[ -d "$app" ]] || { echo "app bundle not found: $app" >&2; exit 1; }
+"$python" -c 'import sys; sys.exit(sys.version_info < (3, 10))' ||
+ { echo "dmgbuild needs Python 3.10 or newer; set PYTHON" >&2; exit 1; }
+
+work="$(mktemp -d)"
+trap 'rm -rf "$work"' EXIT
+dmg="$work/$(basename "$out")"
+
+"$python" -m venv "$work/venv"
+PIP_DISABLE_PIP_VERSION_CHECK=1 "$work/venv/bin/pip" install --quiet \
+ --require-hashes --no-deps --only-binary :all: -r "$assets/dmg-requirements.txt"
+
+"$work/venv/bin/dmgbuild" \
+ -s "$assets/dmg-settings.py" \
+ -D app="$app" \
+ -D background="$assets/dmg-background.png" \
+ -D volume_icon="$root/src-tauri/icons/icon.icns" \
+ "Donut" "$dmg"
+
+if [[ -n "${APPLE_SIGNING_IDENTITY:-}" ]]; then
+ codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg"
+ codesign --verify --strict "$dmg"
+fi
+hdiutil verify -quiet "$dmg"
+
+mv -f "$dmg" "$out"
+echo "Built $out"