fix(settings): restore automatic updates and bump Sparkle to 2.10.0 - #2865
Merged
Merged
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.plistdeclaredSUAutomaticallyUpdatebut notSUEnableAutomaticChecks. 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-227reads the absent key asNO:314-317then makesallowsAutomaticUpdatesNO, becauseSUAllowsAutomaticUpdatesis unset:325-328computesNO && YES = NO, soSUAutomaticallyUpdate: truenever took effect:330-334returns early, so the second toggle was inert, not merely dimmedSparkle 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 = truewrite thatSPUUpdater.h:198-201forbids, 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:
automaticallyChecksForUpdatesallowsAutomaticUpdatesautomaticallyDownloadsUpdatesfalsefalsefalsetruetruetruescripts/check-sparkle-update-defaults.shis 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
SUEnableAutomaticChecksis already written, so Info.plist is never consulted.2. The updater could never start at all
applicationDidFinishLaunchingnever touched it.UpdaterBridgewas a lazy singleton whoseinitbuiltSPUStandardUpdaterController(startingUpdater: true), so the update cycle began as a side effect of whichever view read.sharedfirst.The only launch-adjacent reader was the usage heartbeat, and
AnalyticsHeartbeatService.swift:85returns beforebuildPayload()at:95when analytics are off.LaunchEnvironment.swift:97-101with Reopen Last Session opens no welcome window, soWelcomeActionsPanel.swift:16is never built either.Turning off the usage heartbeat turned off automatic updates.
SoftwareUpdater.start()now runs fromAppDelegate.runPostLaunchActivationIfNeeded(), beside the five other deferred services, which already carries theisIsolatedguard the updater was duplicating.3. A withdrawn release came back on its own
build.ymlsnapshottedorigin/main:appcast.xmlbefore the build andcp'd the merged result over main afterwards. Apull-release.pywithdrawal 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.xmlmain 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 whateverscripts/cimain 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 apull-release.py 0.74.0withdrawal pushed between the base fetch and the commit:mainadvertises afterwards0.72.0 0.73.0 0.74.0 0.75.0, the withdrawn build offered again, no error anywhere0.72.0 0.73.0 0.75.0, the withdrawal holdsAlso in this change
scripts/ci/check-sparkle-version.pyfails Repo Hygiene when they drift;project.yml'smajorVersionisupToNextMajor, so a resolve could move the framework alone.RectangleandIceare the reference implementations.nil, so a failed check was silent to the app.SUScheduledCheckIntervalis declared in Info.plist, which is whereSPUUpdater.h:215says 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.Linkto the changelog URL: Stage releases, name what may interrupt, and show highlights in a What's New window #2815 moved the other two ontoWhatsNewWindowControllerand 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.SUAppcastDriver.bestItemFromAppcastItemspicks 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.pyrefuses to push off main. It wasgit push origin HEADwithcapture_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 whileSUFeedURLkept serving it from main.gh release listcalls lost their|| true, which turned two release gates into silent passes on an API hiccup.check-sparkle-version.pyalso refuses a macOS deployment target at or above 27.0:generate_appcaststops emittingsparkle:hardwareRequirementsthere (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.pyis still needed.generate_appcast'sFeedXML.swiftis 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 theif createNewItemguard.appcast_feed.pyis 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 (1245sparkle:occurrences on both sides); that sentence is corrected.What
item_spanscould 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 intest_appcast_feed.pyagainst the old scanner.Architecture
UpdaterBridgebecomesSoftwareUpdater, with the Sparkle delegates on a separateNSObjectso the app's model is not also bound to an Objective-C protocol.UpdaterSettingsWritingis deleted: it had one conformer, no test double, andGeneralSettingsViewtook the concrete type anyway.TeamLibrarySyncCoordinatoris the shape this follows. The dead.environment(UpdaterBridge.shared)inSettingsWindowControlleris gone, andresetUpdatePreferences()moves intoAppSettingsManager.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.
SPUUpdaterisNS_SWIFT_UI_ACTOR, butSUHost.m:88observesNSUserDefaults, so a managed preference written from outside the process arrives on whatever thread delivers it. Reading the updater there would be off-actor.Verification
verify.sh buildverify.sh test SparkleUpdatePreferencesTestsverify.sh teston the six neighbour suitesverify.sh uitest SoftwareUpdateSettingsUITestsverify.sh linton the changed Swift filesverify.sh docstest_appcast_feed.pytest_merge_appcast.pytest_pull_release.pycheck-sparkle-version.pycheck-sparkle-update-defaults.shEnd to end on the built bundle: the app carries all four Sparkle keys,
SUScheduledImpatientCheckIntervalis correctly absent, the embedded framework reports2.10.0, and all six delegate selectors are inTablePro.debug.dylib, so neither delegate is silently unwired.SparkleUpdatePreferencesTests.everyRequiredKeyIsDeclaredis the test that would have caught defect 1.No screenshots, and why
Driving Settings headlessly with
osascriptopened 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:The frequency picker that sat between the second switch and the button is gone; the
last-update-check-labelrow 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 asReviewer 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:
newest_before=$(… | grep -o …)would abort the step undererrexitwhen the feed is empty. It would, underpipefail, which this workflow does not set:build.ymldeclares noshell:ordefaults:key anywhere, so GitHub runs the step asbash -e, where the pipeline takeshead's status. The guard that skips an empty value is reached, and both shells were run to confirm it.check_baserefusing 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