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>
7 files changed,
+144
−9
changelog/2026-07-05-mac-app.md+5 −0ios/dmg/background.pngbinaryios/dmg/background.tiffbinaryios/dmg/background@2x.pngbinaryios/dmg/dmg-settings.py+30 −0ios/dmg/genbg.py+88 −0ios/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 |