Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(settings): restore automatic updates and bump Sparkle to 2.10.0 - #2865

Merged
datlechin merged 2 commits into
mainfrom
refactor/sparkle-updater
Sep 15, 2026
Merged

datlechin merged 2 commits into
mainfrom
refactor/sparkle-updater

Conversation

@datlechin

Copy link
Copy Markdown
Member

Rewrites the whole Sparkle path: the app-side updater, the Info.plist defaults, the framework pin, and the release scripts that sign and publish appcast.xml.

Three defects turned up on the way. The first two each mean a user gets no automatic updates at all; both arrived with #2813 and are still unreleased, so this catches them before 0.75.0 rather than after. The third is in the release pipeline and has been there for every release so far.

1. A fresh install had automatic updates off, and every control was inert

TablePro/Info.plist declared SUAutomaticallyUpdate but not SUEnableAutomaticChecks. Sparkle resolves a key as user defaults first and Info.plist second (SUHost.m:400-404), and the three settings are chained:

  • SPUUpdaterSettings.m:220-227 reads the absent key as NO
  • :314-317 then makes allowsAutomaticUpdates NO, because SUAllowsAutomaticUpdates is unset
  • :325-328 computes NO && YES = NO, so SUAutomaticallyUpdate: true never took effect
  • :330-334 returns early, so the second toggle was inert, not merely dimmed

Sparkle then asked for permission on the second launch instead, and docs/ said the opposite on three pages.

No developer machine could see this: PR #2813 removed a per-launch automaticallyChecksForUpdates = true write that SPUUpdater.h:198-201 forbids, and that write had already put the key in every existing install's defaults, where it still wins over Info.plist.

Measured against the real Sparkle 2.10.0 with a fresh defaults domain:

automaticallyChecksForUpdates allowsAutomaticUpdates automaticallyDownloadsUpdates
before false false false
after true true true

scripts/check-sparkle-update-defaults.sh is that probe, committed so the next framework bump re-asks rather than trusting a transcription. It fails on the old plist and passes on the new one.

An existing install is unaffected: its SUEnableAutomaticChecks is already written, so Info.plist is never consulted.

2. The updater could never start at all

applicationDidFinishLaunching never touched it. UpdaterBridge was a lazy singleton whose init built SPUStandardUpdaterController(startingUpdater: true), so the update cycle began as a side effect of whichever view read .shared first.

The only launch-adjacent reader was the usage heartbeat, and AnalyticsHeartbeatService.swift:85 returns before buildPayload() at :95 when analytics are off. LaunchEnvironment.swift:97-101 with Reopen Last Session opens no welcome window, so WelcomeActionsPanel.swift:16 is never built either.

Turning off the usage heartbeat turned off automatic updates. SoftwareUpdater.start() now runs from AppDelegate.runPostLaunchActivationIfNeeded(), beside the five other deferred services, which already carries the isIsolated guard the updater was duplicating.

3. A withdrawn release came back on its own

build.yml snapshotted origin/main:appcast.xml before the build and cp'd the merged result over main afterwards. A pull-release.py withdrawal pushed inside that window was reverted, and the rewind guard could not see it because a restoration adds versions rather than dropping them.

The merge now runs inside the commit step against the appcast.xml main holds at that moment, in a loop that re-fetches and re-merges when the push is rejected. Rebasing was not an option: the merge rewrites the file wholesale, so every retry conflicted. The scripts are copied to a temp directory before the branch switch, so a release still publishes with the code its own tag was tested with rather than whatever scripts/ci main happens to hold.

Both versions of the step were run against the same scenario in a scratch repo, on a runner shaped like the real one (fresh tag checkout, no local main), with a pull-release.py 0.74.0 withdrawal pushed between the base fetch and the commit:

main advertises afterwards
before 0.72.0 0.73.0 0.74.0 0.75.0, the withdrawn build offered again, no error anywhere
after 0.72.0 0.73.0 0.75.0, the withdrawal holds

Also in this change

  • Sparkle 2.9.5 to 2.10.0 in all five places that name it, which picks up 2.9.6's installer fixes for a symlink attack and a privilege escalation under root. scripts/ci/check-sparkle-version.py fails Repo Hygiene when they drift; project.yml's majorVersion is upToNextMajor, so a resolve could move the framework alone.
  • Gentle scheduled reminders, the shape Sparkle documents for an app with a Dock icon. A scheduled update no longer takes the front; the app menu item and the settings button both retitle to Update Available… until the user comes back. Rectangle and Ice are the reference implementations.
  • Update failures are logged. Both delegates were nil, so a failed check was silent to the app.
  • The Daily/Weekly picker is gone, and SUScheduledCheckInterval is declared in Info.plist, which is where SPUUpdater.h:215 says the value belongs. macOS System Settings, TablePlus, Rectangle and Ice ship no such control; Postico removed its update pane outright. It reversed nothing, it only slowed a security fix down. It never shipped, so nobody loses a setting.
  • A "Last checked" row, which every comparable app has and this pane did not.
  • Settings > General's "What's New" opens the in-app window, the same action as Help > What's New and the welcome window. It was still a Link to the changelog URL: Stage releases, name what may interrupt, and show highlights in a What's New window #2815 moved the other two onto WhatsNewWindowController and rewrote the docs sentence, but never touched the settings pane, so one label did two things depending on where it was clicked. Found by the review pass, not by me.
  • The feed is pruned to the newest 5 releases: 695,350 bytes and 155 items today, growing by two per release forever, 84% of it embedded release-note CDATA, all of it downloaded by every install on every daily check. Dropping old items is safe because SUAppcastDriver.bestItemFromAppcastItems picks the highest version present and never looks for the host's own. Pruning also removes a duplicate 0.27.4 item already in the feed whose length and signature do not match its asset.
  • pull-release.py refuses to push off main. It was git push origin HEAD with capture_output=True, so run from any of this repo's worktrees it pushed that branch, exited 0, and printed that the build was no longer offered while SUFeedURL kept serving it from main.
  • Both gh release list calls lost their || true, which turned two release gates into silent passes on an API hiccup.
  • check-sparkle-version.py also refuses a macOS deployment target at or above 27.0: generate_appcast stops emitting sparkle:hardwareRequirements there (ArchiveItem.swift:255-262, unchanged in 2.10.0), which would fail the release after both notarizations with the tag already pushed.

Two things that look like hacks and are not

Both were measured before touching them, and both stay:

  • merge-appcast.py is still needed. generate_appcast's FeedXML.swift is byte-identical between 2.9.5 and 2.10.0. Line 603 rewrites an existing item's enclosure and 522-525 drops its <description>, both outside the if createNewItem guard.
  • The text splicing in appcast_feed.py is still needed. An ElementTree round trip of the real feed takes 7 ms and is semantically identical, but destroys all 155 CDATA sections and grows the file 9%. Its docstring also claimed a round trip rewrites namespace prefixes, which is false (1245 sparkle: occurrences on both sides); that sentence is corrected.

What item_spans could not survive was fixed: an XML comment holding <item> swallowed the next item, a comment holding </item> truncated one, and <itemCount> matched as an item. All three are reproduced in test_appcast_feed.py against the old scanner.

Architecture

UpdaterBridge becomes SoftwareUpdater, with the Sparkle delegates on a separate NSObject so the app's model is not also bound to an Objective-C protocol. UpdaterSettingsWriting is deleted: it had one conformer, no test double, and GeneralSettingsView took the concrete type anyway. TeamLibrarySyncCoordinator is the shape this follows. The dead .environment(UpdaterBridge.shared) in SettingsWindowController is gone, and resetUpdatePreferences() moves into AppSettingsManager.resetToDefaults() so the alert's promise about every section is one contract rather than one caller's convention.

The main-actor hop in the KVO handler is deliberate and documented. SPUUpdater is NS_SWIFT_UI_ACTOR, but SUHost.m:88 observes NSUserDefaults, so a managed preference written from outside the process arrives on whatever thread delivers it. Reading the updater there would be off-actor.

Verification

Step Result
verify.sh build PASS, no warnings on the new files
verify.sh test SparkleUpdatePreferencesTests PASS, 7 of 7
verify.sh test on the six neighbour suites PASS, 25 of 25
verify.sh uitest SoftwareUpdateSettingsUITests PASS, 10 of 10
verify.sh lint on the changed Swift files PASS, 0 violations
verify.sh docs PASS
test_appcast_feed.py 16 tests
test_merge_appcast.py 23 tests
test_pull_release.py 12 tests
check-sparkle-version.py passes, and fails on a 27.0 target
check-sparkle-update-defaults.sh passes, and fails on the old plist

End to end on the built bundle: the app carries all four Sparkle keys, SUScheduledImpatientCheckInterval is correctly absent, the embedded framework reports 2.10.0, and all six delegate selectors are in TablePro.debug.dylib, so neither delegate is silently unwired.

SparkleUpdatePreferencesTests.everyRequiredKeyIsDeclared is the test that would have caught defect 1.

No screenshots, and why

Driving Settings headlessly with osascript opened Integrations instead, and the only recording available is the UI test's, which captures the whole developer desktop and cannot be published. The rendered pane is instead pinned by the accessibility tree the passing UI test dumps:

StaticText  value: Automatically chec...
Switch      identifier: 'automatic-update-check-toggle', value: 0
StaticText  value: Download and insta..., Disabled
Switch      identifier: 'automatic-update-install-toggle', value: 0, Disabled
Group
  StaticText identifier: 'last-update-check-label', value: Last checked: 14 S...
  Button     identifier: 'check-for-updates-button', label: 'Check for Updates…', Disabled
Link        identifier: 'whats-new-link', label: 'What's New'

The frequency picker that sat between the second switch and the button is gone; the last-update-check-label row is new.

Reviewer

Codex could not read this diff: it is out of usage credits until 19 September, which its job record reports as You've hit your usage limit, surfacing as Reviewer failed to output a response. Two multi-agent passes read it instead, each verifying its own findings adversarially.

A security pass over three lenses (supply chain, feed parsing, client) returned nothing. A correctness pass over three lenses (Swift, pipeline, claim consistency) raised three findings: the What's New inconsistency above, which is real and fixed here, and two that were refuted by measurement and are worth recording because they look right:

  • That newest_before=$(… | grep -o …) would abort the step under errexit when the feed is empty. It would, under pipefail, which this workflow does not set: build.yml declares no shell: or defaults: key anywhere, so GitHub runs the step as bash -e, where the pipeline takes head's status. The guard that skips an empty value is reached, and both shells were run to confirm it.
  • That check_base refusing a base that already advertises the version makes the loop's idempotent exit unreachable. The mechanism is right and the behaviour is deliberate: re-cutting a release at the same build number is legitimate here, so a feed already carrying the version can hold enclosures signed for replaced archives, and exiting 0 over it would publish something no updater can install. A red job with an accurate message is the better outcome.

https://claude.ai/code/session_01GY434Ns4K7v3FuMogDbU6K

@mintlify

mintlify Bot commented Sep 15, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 15, 2026, 1:34 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

Resolves the welcome panel onto main's rebuilt layout from #2849, which inlined
the version and update lines this branch had edited in their old helpers.

Claude-Session: https://claude.ai/code/session_01GY434Ns4K7v3FuMogDbU6K
@datlechin
datlechin merged commit 751b198 into main Sep 15, 2026
7 of 8 checks passed
@datlechin
datlechin deleted the refactor/sparkle-updater branch September 15, 2026 01:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant