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>
5 files changed,
+240
−0
ios/LChatMac/Info.plist+6 −0ios/LChatMac/LChatMacApp.swift+10 −0ios/LChatMac/UpdaterUI.swift+57 −0ios/project.yml+35 −0ios/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…" |