eleven / ocho Public

mac: add Sparkle auto-update (Developer ID signing + notarized releases) (#134)

LChatMac had no way to update once installed. Wires in Sparkle for
background update checks/downloads (with a Settings toggle to opt out and
a manual "Check for Updates..." menu item), and adds ios/release-mac.sh to
build, sign (Developer ID), notarize, package as a DMG, and publish
releases + the Sparkle appcast to the public elevenmessenger/releases
repo -- messenger is private, so its own GitHub Releases aren't fetchable
by Sparkle's background check.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Paul Campbell d0756b32c2d1b386e8c870594275ef23be2260af parent 52efb29
5 files changed, +240 −0
  • ios/LChatMac/Info.plist +6 −0
  • ios/LChatMac/LChatMacApp.swift +10 −0
  • ios/LChatMac/UpdaterUI.swift +57 −0
  • ios/project.yml +35 −0
  • ios/release-mac.sh +132 −0
diff --git a/ios/LChatMac/Info.plist b/ios/LChatMac/Info.plist
index 7905eba..7ccfc01 100644
--- a/ios/LChatMac/Info.plist
+++ b/ios/LChatMac/Info.plist
@@ -38,5 +38,11 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
+ <key>SUEnableAutomaticChecks</key>
+ <true/>
+ <key>SUFeedURL</key>
+ <string>https://github.com/elevenmessenger/releases/releases/latest/download/appcast.xml</string>
+ <key>SUPublicEDKey</key>
+ <string>TkXBJrY0JvTeeCaHbEZlT5v5apJaOsG1op8uq7n1TsM=</string>
</dict>
</plist>
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 0552dae..d9133d0 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -2,6 +2,7 @@ import SwiftUI
import AppKit
import WebKit
import UserNotifications
+import Sparkle
// A genuinely native macOS app — NOT Mac Catalyst. The window, sidebar, toolbar
// and menu bar are real AppKit-backed SwiftUI, so scrolling, the title bar and the
@@ -55,6 +56,13 @@ struct LChatMacApp: App {
.keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command)
}
}
+ // Sits right below "About Eleven" in the app menu.
+ CommandGroup(after: .appInfo) {
+ CheckForUpdatesView(updater: appDelegate.updaterController.updater)
+ }
+ }
+ Settings {
+ GeneralSettingsView(updater: appDelegate.updaterController.updater)
}
}
}
@@ -64,6 +72,8 @@ struct LChatMacApp: App {
// we display it, and a tap jumps to that instance.
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
weak var appState: MacAppState?
+ let updaterController = SPUStandardUpdaterController(
+ startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
func applicationDidFinishLaunching(_ notification: Notification) {
let center = UNUserNotificationCenter.current()
diff --git a/ios/LChatMac/UpdaterUI.swift b/ios/LChatMac/UpdaterUI.swift
new file mode 100644
index 0000000..647b519
--- /dev/null
+++ b/ios/LChatMac/UpdaterUI.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+import Sparkle
+
+// Sparkle's own documented SwiftUI idiom for a menu item that's disabled while
+// a check is already in flight (canCheckForUpdates), rather than a plain
+// always-enabled button.
+final class CheckForUpdatesViewModel: ObservableObject {
+ @Published var canCheckForUpdates = false
+
+ init(updater: SPUUpdater) {
+ updater.publisher(for: \.canCheckForUpdates).assign(to: &$canCheckForUpdates)
+ }
+}
+
+struct CheckForUpdatesView: View {
+ @ObservedObject private var model: CheckForUpdatesViewModel
+ private let updater: SPUUpdater
+
+ init(updater: SPUUpdater) {
+ self.updater = updater
+ self.model = CheckForUpdatesViewModel(updater: updater)
+ }
+
+ var body: some View {
+ Button("Check for Updates…", action: updater.checkForUpdates)
+ .disabled(!model.canCheckForUpdates)
+ }
+}
+
+// The Settings window's General pane: a single on/off switch for background
+// updates (rather than separate "check" and "download" toggles) — "Check for
+// Updates…" in the app menu always works for a manual kick-off regardless of
+// this setting.
+struct GeneralSettingsView: View {
+ let updater: SPUUpdater
+ @State private var automaticUpdates: Bool
+
+ init(updater: SPUUpdater) {
+ self.updater = updater
+ _automaticUpdates = State(initialValue: updater.automaticallyDownloadsUpdates)
+ }
+
+ var body: some View {
+ Form {
+ Toggle("Automatically download and install updates", isOn: $automaticUpdates)
+ .onChange(of: automaticUpdates) { _, enabled in
+ // Downloading automatically requires checking automatically;
+ // turning the switch off falls back to fully manual
+ // (menu-triggered) checks only.
+ updater.automaticallyChecksForUpdates = enabled
+ updater.automaticallyDownloadsUpdates = enabled
+ }
+ }
+ .padding(20)
+ .frame(width: 360)
+ }
+}
diff --git a/ios/project.yml b/ios/project.yml
index 72223ee..9d1ca2b 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -17,6 +17,14 @@ settings:
# override (e.g. LCHAT_APP_BUNDLE_ID=com.example.mine on the xcodebuild
# line for device signing) can never collide the two bundle ids.
LCHAT_APP_BUNDLE_ID: com.elevenmessenger.messenger
+packages:
+ # Mac-only auto-update framework (LChatMac). Pin a specific release rather
+ # than floating on `from:` so `xcodegen generate` is reproducible; bump
+ # deliberately and re-check ios/release-mac.sh still matches the CLI tool
+ # flags for that version.
+ Sparkle:
+ url: https://github.com/sparkle-project/Sparkle
+ exactVersion: 2.9.4
targets:
LChat:
type: application
@@ -80,6 +88,8 @@ targets:
- path: LChat/Models.swift
- path: LChat/LChatAPI.swift
- path: LChat/InviteLink.swift
+ dependencies:
+ - package: Sparkle
settings:
base:
PRODUCT_NAME: Eleven
@@ -87,6 +97,10 @@ targets:
CODE_SIGN_STYLE: Automatic
CODE_SIGN_ENTITLEMENTS: LChatMac/LChatMac.entitlements
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ # Required for notarization (Developer ID distribution, see
+ # ios/release-mac.sh). Sparkle's bundled XPC helpers need no entitlement
+ # exceptions on top of this for a non-sandboxed app.
+ ENABLE_HARDENED_RUNTIME: YES
info:
path: LChatMac/Info.plist
properties:
@@ -104,6 +118,18 @@ targets:
- elevenmessenger
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
+ # Sparkle auto-update feed, published by ios/release-mac.sh to the
+ # separate public elevenmessenger/releases repo (this repo is private,
+ # so GitHub Releases here aren't publicly fetchable). The "latest"
+ # alias always resolves to whatever release-mac.sh most recently
+ # published. SUPublicEDKey is generated once via Sparkle's
+ # generate_keys tool — see ios/release-mac.sh for the matching private
+ # key usage.
+ SUFeedURL: https://github.com/elevenmessenger/releases/releases/latest/download/appcast.xml
+ SUPublicEDKey: "TkXBJrY0JvTeeCaHbEZlT5v5apJaOsG1op8uq7n1TsM="
+ # Skips Sparkle's own first-run "should Eleven check for updates
+ # automatically?" prompt — background checking is the default we want.
+ SUEnableAutomaticChecks: true
# Notification service extension: decrypts pushed ciphertext on-device (the
# E2EE half of APNs). Shares the crypto/keychain sources with the app.
LChatNSE:
@@ -150,3 +176,12 @@ schemes:
config: Debug
targets:
- LChatUITests
+ # Needed to archive/export the Mac app at all (see ios/release-mac.sh).
+ LChatMac:
+ build:
+ targets:
+ LChatMac: all
+ run:
+ config: Debug
+ archive:
+ config: Release
diff --git a/ios/release-mac.sh b/ios/release-mac.sh
new file mode 100755
index 0000000..178b8a5
--- /dev/null
+++ b/ios/release-mac.sh
@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+# Build, sign, notarize, and publish a Developer ID release of the native Mac
+# app (LChatMac), then regenerate and publish the Sparkle appcast that
+# SUFeedURL (ios/LChatMac/Info.plist, via project.yml) points members at.
+#
+# messenger is a PRIVATE repo, so its own GitHub Releases aren't publicly
+# downloadable — Sparkle's background update check runs unauthenticated on
+# members' Macs. The DMG + appcast.xml are published to a separate PUBLIC
+# repo instead (default elevenmessenger/releases); see CLAUDE.local.md.
+#
+# Usage: ios/release-mac.sh <marketing-version> <build-number>
+# ios/release-mac.sh 1.1 2
+#
+# Required env:
+# LCHAT_ASC_KEY_PATH / LCHAT_ASC_KEY_ID / LCHAT_ASC_ISSUER_ID
+# The App Store Connect API key already used for TestFlight uploads
+# (see CLAUDE.local.md) — notarytool accepts the same API-key auth.
+# LCHAT_SPARKLE_KEY_FILE
+# Path to the Sparkle EdDSA private key, exported once via
+# `generate_keys -x` and stored in 1Password — export it to a local file
+# before running this script (never committed, never passed on argv).
+# Optional env:
+# LCHAT_RELEASES_REPO Default elevenmessenger/releases.
+set -euo pipefail
+
+VERSION="${1:?usage: release-mac.sh <marketing-version> <build-number>}"
+BUILD="${2:?usage: release-mac.sh <marketing-version> <build-number>}"
+: "${LCHAT_ASC_KEY_PATH:?LCHAT_ASC_KEY_PATH is required (ASC API key .p8 path)}"
+: "${LCHAT_ASC_KEY_ID:?LCHAT_ASC_KEY_ID is required}"
+: "${LCHAT_ASC_ISSUER_ID:?LCHAT_ASC_ISSUER_ID is required}"
+: "${LCHAT_SPARKLE_KEY_FILE:?LCHAT_SPARKLE_KEY_FILE is required (exported Sparkle EdDSA private key)}"
+RELEASES_REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}"
+TEAM_ID="J5NDF5PCD8"
+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"
+
+REPO="$(cd "$(dirname "$0")/.." && pwd)"
+IOS="$REPO/ios"
+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")
+
+echo "Building $APP_NAME $VERSION ($BUILD) — Developer ID / notarized"
+
+cd "$IOS"
+xcodegen generate
+
+xcodebuild archive \
+ -project LChat.xcodeproj -scheme LChatMac -destination 'generic/platform=macOS' \
+ -archivePath "$TMP/LChatMac.xcarchive" -allowProvisioningUpdates \
+ CODE_SIGN_STYLE=Automatic MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD"
+
+# -exportOptionsPlist has no CLI flag form, so template a throwaway one.
+cat > "$TMP/export-options.plist" <<PLIST
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>method</key>
+ <string>developer-id</string>
+ <key>teamID</key>
+ <string>$TEAM_ID</string>
+ <key>signingStyle</key>
+ <string>automatic</string>
+</dict>
+</plist>
+PLIST
+
+# The ASC key flags are passed defensively here even though Developer ID
+# signing itself needs no ASC auth — CLAUDE.local.md notes a stale Xcode
+# account session can otherwise break -exportArchive outright (hit this on
+# the TestFlight flow; same landmine could apply here).
+xcodebuild -exportArchive -archivePath "$TMP/LChatMac.xcarchive" -exportPath "$TMP/export" \
+ -exportOptionsPlist "$TMP/export-options.plist" -allowProvisioningUpdates "${ASC_AUTH[@]}"
+
+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 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"
+DMG="$TMP/$APP_NAME-$VERSION.dmg"
+hdiutil create -volname "$APP_NAME" -srcfolder "$STAGE" -ov -format UDZO "$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 stapler staple "$DMG"
+
+# generate_appcast only needs the previous appcast.xml (it preserves entries
+# it can't find a local archive for) plus the newly built DMG — not every
+# historical release's DMG.
+APPCAST_DIR="$TMP/appcast"
+mkdir -p "$APPCAST_DIR"
+gh release download -R "$RELEASES_REPO" -p appcast.xml -D "$APPCAST_DIR" 2>/dev/null || \
+ echo "No previous release found on $RELEASES_REPO — generating the first appcast."
+cp "$DMG" "$APPCAST_DIR/"
+
+echo "Fetching Sparkle $SPARKLE_VERSION command-line tools…"
+curl -sL -o "$TMP/sparkle-tools.tar.xz" \
+ "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz"
+mkdir -p "$TMP/sparkle-tools"
+tar -xJf "$TMP/sparkle-tools.tar.xz" -C "$TMP/sparkle-tools" ./bin/generate_appcast
+chmod +x "$TMP/sparkle-tools/bin/generate_appcast"
+
+"$TMP/sparkle-tools/bin/generate_appcast" \
+ --ed-key-file "$LCHAT_SPARKLE_KEY_FILE" \
+ --download-url-prefix "https://github.com/$RELEASES_REPO/releases/download/v$VERSION/" \
+ --maximum-deltas 0 \
+ "$APPCAST_DIR"
+
+gh release create "v$VERSION" -R "$RELEASES_REPO" \
+ --title "$APP_NAME $VERSION" --notes "$APP_NAME $VERSION for macOS." \
+ "$APPCAST_DIR/$APP_NAME-$VERSION.dmg" "$APPCAST_DIR/appcast.xml"
+
+echo
+echo "✅ Published $APP_NAME $VERSION ($BUILD) to $RELEASES_REPO"
+echo " Members on an older build pick this up next automatic check, or"
+echo " immediately via Eleven → Check for Updates…"