eleven / messenger Public

mac: fix release-mac.sh notarytool auth + ship a polished install DMG (#193)

release-mac.sh reused xcodebuild's -authenticationKey* flags for notarytool,
which takes --key/--key-id/--issuer instead — so the first `notarytool submit`
failed ("... must be a valid UUID") and no release could be cut. Split out a
NOTARY_AUTH array for the two notarize calls; -exportArchive keeps ASC_AUTH.

Also replace the bare two-icon hdiutil window with a laid-out install window
built by dmgbuild: the app icon + an Applications drop-link either side of a
small "1 … 10 →" count on a logo-purple background (assets + generator in
ios/dmg/). dmgbuild writes the .DS_Store layout directly, so no Finder
scripting. Adds a dmgbuild preflight check + prereq docs.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell dcebf1ad25ed3c5259d670f9d2289d90644e0f9f parent accccc8
7 files changed, +144 −9
  • changelog/2026-07-05-mac-app.md +5 −0
  • ios/dmg/background.png binary
  • ios/dmg/background.tiff binary
  • ios/dmg/background@2x.png binary
  • ios/dmg/dmg-settings.py +30 −0
  • ios/dmg/genbg.py +88 −0
  • ios/release-mac.sh +21 −9
diff --git a/changelog/2026-07-05-mac-app.md b/changelog/2026-07-05-mac-app.md
new file mode 100644
index 0000000..85db8b6
--- /dev/null
+++ b/changelog/2026-07-05-mac-app.md
@@ -0,0 +1,5 @@
+Eleven now has a proper Mac app. Download it, drag it into your Applications
+folder, and it opens straight away — no "unidentified developer" warnings,
+because it's signed and notarized by Apple. From then on it keeps itself up to
+date quietly in the background, so you're always on the latest version without
+having to think about it.
diff --git a/ios/dmg/background.png b/ios/dmg/background.png
new file mode 100644
index 0000000..2383a64
Binary files /dev/null and b/ios/dmg/background.png differ
diff --git a/ios/dmg/background.tiff b/ios/dmg/background.tiff
new file mode 100644
index 0000000..94c80cc
Binary files /dev/null and b/ios/dmg/background.tiff differ
diff --git a/ios/dmg/background@2x.png b/ios/dmg/background@2x.png
new file mode 100644
index 0000000..65a8aa4
Binary files /dev/null and b/ios/dmg/background@2x.png differ
diff --git a/ios/dmg/dmg-settings.py b/ios/dmg/dmg-settings.py
new file mode 100644
index 0000000..72879dc
--- /dev/null
+++ b/ios/dmg/dmg-settings.py
@@ -0,0 +1,30 @@
+# dmgbuild settings for the Eleven install DMG. Icon coordinates match
+# background.tiff (see genbg.py): the app icon and Applications sit either side
+# of the "1 … 10 →" count, on the same line at y=208. APP_PATH and BG_TIFF are
+# exported by ios/release-mac.sh.
+import os
+
+app = os.environ["APP_PATH"]
+appname = os.path.basename(app) # "Eleven.app"
+
+format = "UDZO" # compressed, read-only
+files = [app]
+symlinks = {"Applications": "/Applications"}
+background = os.environ["BG_TIFF"]
+
+# ((window x, y), (width, height)) — width/height match the background.
+window_rect = ((240, 140), (640, 400))
+default_view = "icon-view"
+icon_size = 128
+text_size = 13
+icon_locations = {
+ appname: (160, 208),
+ "Applications": (480, 208),
+}
+
+# Clean, chrome-free install window.
+show_status_bar = False
+show_tab_view = False
+show_toolbar = False
+show_pathbar = False
+show_sidebar = False
diff --git a/ios/dmg/genbg.py b/ios/dmg/genbg.py
new file mode 100644
index 0000000..ec54019
--- /dev/null
+++ b/ios/dmg/genbg.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+# Generate the install-window background for the Eleven DMG.
+# Renders at 2x (retina) then downsamples for the 1x rep, so both are crisp.
+import os
+from PIL import Image, ImageDraw, ImageFont
+
+OUT = os.path.dirname(os.path.abspath(__file__))
+S = 2 # supersample / retina factor
+W, H = 640 * S, 400 * S
+
+# Purple sampled from the app icon (AppIcon icon_1024, core of the violet
+# gradient) so the "Eleven" wordmark matches the logo exactly.
+PURPLE = (101, 64, 208) # #6540D0 — wordmark
+GREY = (150, 155, 168) # the count numbers — light grey, regular weight
+DOT_GREY = (200, 203, 214) # faint separators / arrow-lighter
+DARK = (28, 30, 42)
+MUTED = (122, 126, 140)
+GRAD_TOP = (252, 252, 255)
+GRAD_BOT = (235, 237, 249)
+
+
+def font(size, bold=False):
+ size = int(size * S)
+ # Prefer SF Pro (variable font — pick the Bold instance); fall back to Arial.
+ try:
+ f = ImageFont.truetype("/System/Library/Fonts/SFNS.ttf", size)
+ if bold:
+ try:
+ f.set_variation_by_name("Bold")
+ except Exception:
+ pass
+ return f
+ except Exception:
+ p = "/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold \
+ else "/System/Library/Fonts/Supplemental/Arial.ttf"
+ return ImageFont.truetype(p, size)
+
+
+# --- base gradient ---
+img = Image.new("RGB", (W, H), GRAD_TOP)
+d = ImageDraw.Draw(img)
+for y in range(H):
+ t = y / (H - 1)
+ d.line([(0, y), (W, y)], fill=tuple(
+ int(a + (b - a) * t) for a, b in zip(GRAD_TOP, GRAD_BOT)))
+d = ImageDraw.Draw(img)
+
+# --- the count: 1 · 2 · … · 10 → (light grey, regular, on the icon line) ---
+row_y = 208 * S
+num_font = font(14.5) # match the "Drag the app…" subtitle — one body size
+arrow_font = num_font # the → is the same size as the numbers (no size-mixing)
+inter = 2 * S # tight spacing between tokens
+arrow_gap = 7 * S # a little breathing room before the arrow
+
+parts = [] # (text, font, colour)
+for i in range(1, 11):
+ parts.append((str(i), num_font, GREY))
+ if i < 10:
+ parts.append(("·", num_font, DOT_GREY))
+widths = [d.textlength(t, font=f) for (t, f, _) in parts]
+arrow_w = d.textlength("→", font=arrow_font)
+total = sum(widths) + inter * (len(parts) - 1) + arrow_gap + arrow_w
+x = W / 2 - total / 2
+for (t, f, c), w in zip(parts, widths):
+ d.text((x, row_y), t, font=f, fill=c, anchor="lm")
+ x += w + inter
+x = x - inter + arrow_gap
+d.text((x, row_y), "→", font=arrow_font, fill=GREY, anchor="lm")
+
+# --- title ---
+title_r = font(27, bold=True)
+ty = 62 * S
+seg1, seg2 = "Install ", "Eleven"
+w1 = d.textlength(seg1, font=title_r)
+w2 = d.textlength(seg2, font=title_r)
+sx = (W - (w1 + w2)) / 2
+d.text((sx, ty), seg1, font=title_r, fill=DARK, anchor="lm")
+d.text((sx + w1, ty), seg2, font=title_r, fill=PURPLE, anchor="lm")
+
+# --- subtitle ---
+sub = font(14.5)
+d.text((W / 2, 98 * S), "Drag the app into your Applications folder",
+ font=sub, fill=MUTED, anchor="mm")
+
+img.save(os.path.join(OUT, "background@2x.png"))
+img.resize((W // S, H // S), Image.LANCZOS).save(os.path.join(OUT, "background.png"))
+print("wrote background.png (%dx%d) + background@2x.png (%dx%d)" %
+ (W // S, H // S, W, H))
diff --git a/ios/release-mac.sh b/ios/release-mac.sh
index 178b8a5..11235d2 100755
--- a/ios/release-mac.sh
+++ b/ios/release-mac.sh
@@ -21,6 +21,14 @@
# before running this script (never committed, never passed on argv).
# Optional env:
# LCHAT_RELEASES_REPO Default elevenmessenger/releases.
+#
+# Host tools: xcodebuild + notarytool/stapler (Xcode), and dmgbuild
+# (`pip install dmgbuild`), which lays out the install window from
+# ios/dmg/dmg-settings.py over ios/dmg/background.tiff. After editing the
+# window design, regenerate that background:
+# pip install pillow && python3 ios/dmg/genbg.py && \
+# tiffutil -cathidpicheck ios/dmg/background.png ios/dmg/background@2x.png \
+# -out ios/dmg/background.tiff
set -euo pipefail
VERSION="${1:?usage: release-mac.sh <marketing-version> <build-number>}"
@@ -35,6 +43,10 @@ APP_NAME="Eleven"
# Keep in sync with the `packages.Sparkle.exactVersion` pin in ios/project.yml
# — generate_appcast's flags are matched against this exact release's --help.
SPARKLE_VERSION="2.9.4"
+command -v dmgbuild >/dev/null || {
+ echo "error: dmgbuild not found — install it with: pip install dmgbuild" >&2
+ exit 1
+}
REPO="$(cd "$(dirname "$0")/.." && pwd)"
IOS="$REPO/ios"
@@ -42,6 +54,8 @@ TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
ASC_AUTH=(-authenticationKeyPath "$LCHAT_ASC_KEY_PATH" -authenticationKeyID "$LCHAT_ASC_KEY_ID" -authenticationKeyIssuerID "$LCHAT_ASC_ISSUER_ID")
+# notarytool takes different flag names than xcodebuild's -authenticationKey* set.
+NOTARY_AUTH=(--key "$LCHAT_ASC_KEY_PATH" --key-id "$LCHAT_ASC_KEY_ID" --issuer "$LCHAT_ASC_ISSUER_ID")
echo "Building $APP_NAME $VERSION ($BUILD) — Developer ID / notarized"
@@ -80,24 +94,22 @@ APP="$TMP/export/$APP_NAME.app"
ditto -c -k --keepParent "$APP" "$TMP/$APP_NAME.zip"
echo "Notarizing $APP_NAME.app…"
-xcrun notarytool submit "$TMP/$APP_NAME.zip" "${ASC_AUTH[@]}" --wait
+xcrun notarytool submit "$TMP/$APP_NAME.zip" "${NOTARY_AUTH[@]}" --wait
xcrun stapler staple "$APP"
-# Stage the DMG contents: the stapled .app + an /Applications symlink is all
-# that's needed for Finder's native drag-to-install.
-STAGE="$TMP/dmg-stage"
-mkdir -p "$STAGE"
-cp -R "$APP" "$STAGE/"
-ln -s /Applications "$STAGE/Applications"
+# Build the install window with dmgbuild: the app icon, an Applications drop
+# link, and a "1 … 10 →" count on a branded background (ios/dmg/). dmgbuild
+# writes the .DS_Store layout directly, so no flaky Finder scripting is involved.
DMG="$TMP/$APP_NAME-$VERSION.dmg"
-hdiutil create -volname "$APP_NAME" -srcfolder "$STAGE" -ov -format UDZO "$DMG"
+APP_PATH="$APP" BG_TIFF="$IOS/dmg/background.tiff" \
+ dmgbuild -s "$IOS/dmg/dmg-settings.py" "$APP_NAME" "$DMG"
# Notarize + staple the DMG too, not just the .app inside it: once a member
# drags the app to /Applications and ejects the DMG, that copy carries no
# staple of its own unless the DMG's staple already covered it — without
# this, first launch falls back to an online Gatekeeper check.
echo "Notarizing $APP_NAME-$VERSION.dmg…"
-xcrun notarytool submit "$DMG" "${ASC_AUTH[@]}" --wait
+xcrun notarytool submit "$DMG" "${NOTARY_AUTH[@]}" --wait
xcrun stapler staple "$DMG"
# generate_appcast only needs the previous appcast.xml (it preserves entries