Skip to content

Add an in-app updater with Sparkle 2 #257

Closed

Description

@sudoHG

Goal

An Ask Key installed at /Applications/Ask Key.app checks a signed appcast and, after the user confirms, installs a notarized update with Sparkle 2; Debug, Local and E2E builds never run the updater.

Context

  • docs/adr/0006-release-process.md (Publication; Not included) and docs/adr/0005-distribution-and-client-integration.md. AGENTS.md says not to add Sparkle unless an issue asks for it: this issue asks for it and approves Sparkle as a new third-party dependency.
  • docs/features.md, row "Sparkle and in-app update checks" (currently Remove). The removed Lokalite-era SoftwareUpdater is in sudoHG/AskKey-legacy at legacy-final; read it only as a reference (see issue-workflow.md → Working in the legacy code).
  • scripts/build-app.sh: Info.plist generation, the Local mode comment ("It does not enable software updates"), and signing with codesign --force --deep.
  • AskKeyApp.entitlements: the app is not sandboxed and must stay that way.
  • Sources/AskKeyApp/AskKeyAppEntry.swift (production entry), Sources/AskKeyAppKit/App/AppDelegate+StatusItem.swift (right-click menu), Sources/AskKeyAppKit/Views/Settings/FrozenSettingsPage.swift (General section), Sources/AskKeyBroker/OfficialInstallTopology.swift.
  • Sparkle documentation: https://sparkle-project.org/documentation/, Customization, Publishing.

Decisions (record them in a new ADR 0011)

  1. Dependency. Sparkle 2 through SwiftPM, pinned with exact: to a 2.x release that supports signed feeds (2.9 or later). Only the production AskKeyApp executable target depends on it. AskKeyAppKit defines the updater interface and the pure policy types, so swift test, the E2E app and the askkey helper never link Sparkle.
  2. Feed. SUFeedURL is https://askkey.sudohg.com/appcast.xml, on the maintainer's own domain. The website answers it with a temporary (302) redirect to https://github.com/sudoHG/AskKey/releases/latest/download/appcast.xml (set up in Publish a signed appcast with each release #259). The feed is therefore a GitHub Release asset: it goes live when a release is published, which already needs the maintainer's approval, and releases need no website deploy. Every installed copy keeps requesting the URL on our domain, so treat it as a product identifier and add it to AGENTS.md → Product identifiers. Because the domain is ours, a later change can serve the feed directly or from a mirror for users in mainland China without a new release. Enclosures use versioned GitHub Release URLs (https://github.com/sudoHG/AskKey/releases/download/v<version>/AskKey-<version>.dmg), and a later appcast can point them elsewhere. Feeds and archives must be EdDSA-signed, so neither host is trusted for integrity: SUVerifyUpdateBeforeExtraction and SURequireSignedFeed are YES. SUSignedFeedFailureExpirationInterval is 0, so a feed that fails validation is never accepted later (Sparkle otherwise falls back after 20 days; added in review of Add an in-app updater with Sparkle 2 #260). SUPublicEDKey comes from the committed file scripts/sparkle-public-key.txt (added by Create the Sparkle update-signing key #258). Until that file lands, Release builds fail closed; this is intended, so no release ships an updater without a key.
  3. No silent installs. SUAllowsAutomaticUpdates and SUAutomaticallyUpdate are NO, so every install needs the user's click. SUEnableAutomaticChecks stays unset, so Sparkle asks on the second launch whether to check automatically; Settings can change it later. SUEnableSystemProfiling is NO. Keep the default check interval (one day). Scheduled updates use Sparkle's gentle reminders: Sparkle still shows the alert without stealing focus, and, when notifications are already allowed, Ask Key also posts one notification that opens the update (added in review of Add an in-app updater with Sparkle 2 #260).
  4. Where it runs. An updater is created only when all of these hold: the Info.plist has SUFeedURL, which only Release mode writes; the bundle ID is com.sudohg.askkey.app; none of AskKeyLocalTesting, AskKeyE2ETesting or AskKeyRequiresDebugRunDirectory is set; and OfficialInstallTopology allows the official runtime. Otherwise no Sparkle object exists and no update controls are shown.
  5. Relaunch only when idle. Implement updater:shouldPostponeRelaunchForUpdate:untilInvokingBlock: to postpone while an approval prompt is pending, a delivery, run or write session is in progress, or management authentication is active, and invoke the handler once the app is idle. An update never cancels, denies or orphans a pending request. Timed allowances do not postpone it, because they can last hours. Instead the Settings row states what a relaunch ends: Ask Key locks again and timed allowances held by the running broker end. Verify that wording against current behaviour.
  6. Running agents. The install path, helper path and broker protocol version stay unchanged, so client configurations need no change, and helper processes that are already running keep working against the new broker (helper trust compares Team IDs). Tools added in a new version appear only after the agent restarts its MCP server. Say so in the ADR and the README.
  7. Downgrades and migrations. Sparkle only offers newer versions, and pulling a bad update means shipping a fixed, higher version. If automatic installs are ever enabled, every release that adds a library migration must set sparkle:minimumAutoupdateVersion to its own version.
  8. Network and privacy. The update check is Ask Key's only outbound network request. Record exactly what a check sends: the feed URL and Sparkle's request headers, including its User-Agent, confirmed from the pinned Sparkle source rather than by launching the app. A check goes to askkey.sudohg.com through Cloudflare and follows the redirect to GitHub, and an update download goes to GitHub; both see the IP address. Document it in README.md, README.zh-CN.md and SECURITY.md.
  9. Known limitation. Sparkle's standard windows follow the macOS language, not Ask Key's in-app language setting. Accept this for now and note it in the ADR.

Scope

May change:

  • Package.swift, Package.resolved: add Sparkle only
  • Sources/AskKeyApp/: the Sparkle adapter (SPUStandardUpdaterController and SPUUpdaterDelegate)
  • Sources/AskKeyAppKit/: the updater interface, the enable and relaunch policy types, the Settings General rows, the status item menu, and Localizable.xcstrings (English and Simplified Chinese)
  • scripts/build-app.sh; scripts/check_module_deps.py and scripts/check_release_symbols.sh only if they must accept the new dependency edge
  • Tests/AskKeyAppTests/, Tests/Automation/
  • AGENTS.md: only add the feed URL to Product identifiers
  • docs/adr/0011-in-app-updates.md (new); in docs/adr/0005-distribution-and-client-integration.md and docs/adr/0006-release-process.md, only the sentences that 0011 supersedes; docs/features.md; docs/architecture.md; README.md; README.zh-CN.md; SECURITY.md; CHANGELOG.md (Unreleased)

Must not touch:

Steps

  1. Write ADR 0011 with the decisions above. Mark the 0006 sentences "There is no updater" and "Sparkle or any in-app updater … need their own issue" as superseded by 0011, and change the features.md row to Keep, citing 0011.
  2. Add Sparkle to Package.swift as a dependency of AskKeyApp only.
  3. In AskKeyAppKit, add two pure types: one decides whether the updater is enabled (from the Info.plist values and the topology decision), the other decides whether a relaunch must wait (from the busy states in decision 5). Add a small protocol the UI uses for "check now", the automatic-check setting and availability.
  4. In AskKeyApp, implement that protocol with Sparkle. Create it only when the policy enables it, and wire the delegate's postpone method to the relaunch policy.
  5. UI, shown only when the updater is enabled:
    • Settings → General gets a "Check for Updates Automatically" switch, a "Check for Updates…" button, and a subtitle explaining what a relaunch ends.
    • The status item's right-click menu gets "Check for Updates…" above "Quit Ask Key".
    • Simplified Chinese strings use the catalog's existing terms (凭证, Agent).
  6. scripts/build-app.sh, in order:
    • Copy Sparkle.framework into Contents/Frameworks in every mode, so the binary always launches.
    • Remove the framework's XPCServices (unneeded outside the sandbox) and make sure the executable's rpath includes @executable_path/../Frameworks.
    • Sign nested code inside out with the existing options (Sparkle's Autoupdate, Updater.app, the framework, the helper, then the app), without --deep when signing. Keep --deep for verification.
    • In Release mode only, write the Info.plist keys from decisions 2 and 3. Fail if scripts/sparkle-public-key.txt is missing or is not a base64 32-byte key.
    • Debug, Local and E2E modes write no Sparkle keys.
  7. Tests:
    • The enable policy covers every flag, the bundle-ID and topology case.
    • The relaunch policy covers each busy state, idle, and a request that arrives while postponed.
    • Automation tests check the Debug build-app.sh output: no Sparkle keys, the framework embedded and no XPC services.
  8. Update the docs in Scope.

Acceptance

swift build                                                    # succeeds
swift test --filter 'SoftwareUpdate'                           # new suites run; 0 failed (adjust the filter to the suite names you add and list them in the receipt)
python3 -m unittest discover -s Tests/Automation -v            # 0 failed
python3 scripts/check_hygiene.py                               # passes
python3 scripts/check_module_deps.py                           # passes
bash scripts/check_release_symbols.sh                          # passes
git grep -n 'import Sparkle' -- Sources | grep -v '^Sources/AskKeyApp/'   # no output
bash scripts/build-app.sh Debug                                # succeeds (needs 80 GiB free; do not launch the app)
/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' .build/AskKeyApp.app/Contents/Info.plist   # fails: key absent
test -d .build/AskKeyApp.app/Contents/Frameworks/Sparkle.framework                       # succeeds
find .build/AskKeyApp.app -name '*.xpc' | wc -l                                          # 0
codesign --verify --strict --deep .build/AskKeyApp.app                                   # succeeds
otool -l .build/AskKeyApp.app/Contents/MacOS/AskKeyApp | grep -A2 LC_RPATH | grep -c '@executable_path/../Frameworks'   # 1 or more

Both CI jobs are green, including test-dev-launch.sh (the Debug app still launches) and basic-ui-flows. The receipt lists the headers from decision 8 and the Sparkle source lines they come from.

Before the merge, the planner renders Settings → General and the status menu in English and Simplified Chinese offscreen. This is not an executor step.

Blocked by

None

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified; an agent may claim itrisk:securityTouches broker, approval, delivery, crypto or keychain; requires line-by-line review

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions