Skip to content

feat(settings): user-controllable automatic update settings - #352

Open
M3NT1 wants to merge 4 commits into
JerryZLiu:mainfrom
M3NT1:feature/auto-update-toggle
Open

M3NT1 wants to merge 4 commits into
JerryZLiu:mainfrom
M3NT1:feature/auto-update-toggle

Conversation

@M3NT1

@M3NT1 M3NT1 commented Aug 13, 2026

Copy link
Copy Markdown

Why

Dayflow currently ships with SilentUserDriver configured so Sparkle silently auto-installs every update — no prompt, no opt-out, no changelog. Combined with SUAutomaticallyUpdate = true in Info.plist, the user has zero control over when the app upgrades itself.

This PR adds a user-controllable update preference, following the pattern of the other toggles in the Settings "App preferences" section: a SettingsToggle bound to an @Published mirror of a SPUUpdater property, written back through an explicit setter (the only path the UI uses — see the Sparkle docs note below).

This is a re-submission of #351 (closed because the same changes were folded into the bundled #347; #347 is now being split back out so this feature can be reviewed on its own merits).

What changes

3 commits, all on feature/auto-update-toggle (branched from main @ 8944d27):

1. feat(updater): expose Sparkle update controls via UpdaterManager

  • Adds @Published private(set) var automaticallyChecksForUpdates (mirror of SPUUpdater.automaticallyChecksForUpdates)
  • Adds @Published private(set) var automaticallyDownloadsUpdates
  • Adds @Published private(set) var canCheckForUpdates — the "Check now" button binds to this to dim itself while Sparkle is mid-check
  • Adds @Published var pendingReleaseNotesURL: URL? — captured from SUAppcastItem.releaseNotesURL in didFindValidUpdate, cleared on no-update cycles
  • All four are populated by KVO observers in setupObservers() (.initial + .new), so the UI reflects the user's saved preference from the previous session on launch
  • Explicit setters setAutomaticallyChecksForUpdates(_:) / setAutomaticallyDownloadsUpdates(_:) write through SPUUpdater directly — per the Sparkle docs requirement that these properties only be set in response to a user setting change. This is what makes the change persist to NSUserDefaults.
  • openReleaseNotes() opens the captured release-notes URL in the default browser

2. fix(updater): respect user update preference in SilentUserDriver

  • SilentUserDriver gains an automaticallyChecksForUpdates flag, kept in lockstep with SPUUpdater via the KVO observer in UpdaterManager
  • showUpdateFound now branches on that flag:
    • On.install (silent install, same as today's behavior — no regression)
    • Off.dismiss (caches the release-notes URL so the user can review and install manually via the "View changelog" link in Settings, but the update is never pushed onto them)
  • The user-controlled toggle takes effect on the very next update check, even if Sparkle's 2nd-launch permission prompt is skipped

3. feat(settings): add update controls to Other tab

New "Updates" section in SettingsOtherTabView:

Control Behavior
Toggle: "Automatically check for updates" Binds to automaticallyChecksForUpdates mirror; writes through setAutomaticallyChecksForUpdates
Sub-toggle: "Automatically download and install updates" Binds to automaticallyDownloadsUpdates; disabled when auto-check is off
"Check now" button Routes through SPUStandardUpdaterController (not SilentUserDriver) so the user always gets the standard Sparkle release-notes UI even when auto-update is off; dims via canCheckForUpdates while a check is in flight
Status subtitle "Latest version" / "Update available: v2.2.0" / "Checking…" / "Update needs authorization"
Conditional "Latest release notes" row Appears only when pendingReleaseNotesURL is set; "View changelog" button opens it in the default browser

Design notes

  • No new files — the three touched files already existed; this only extends them.
  • No behavior change by default. SilentUserDriver.automaticallyChecksForUpdates defaults to true, matching today's silent-install behavior. Users who never touch the new toggle see no difference.
  • KVO, not polling. The @Published mirrors are driven by NSKeyValueObservation on the SPUUpdater properties, so the UI updates instantly when Sparkle changes a value internally (e.g. the 2nd-launch permission prompt).
  • Sparkle docs compliance. The setters write through SPUUpdater.automaticallyChecksForUpdates / automaticallyDownloadsUpdates rather than the @Published mirrors, because the docs state: "Only set this property if the user wants to change the default via a user settings option."
  • Analytics. Each setting change emits a sparkle_setting_changed event with the setting name and new value; update lifecycle events (sparkle_check_triggered, sparkle_update_found, sparkle_update_not_found, sparkle_cycle_finished, sparkle_install_*) are already wired in the existing delegate methods.

Files

 Dayflow/Dayflow/System/SilentUserDriver.swift      | 20 ++++-
 Dayflow/Dayflow/System/UpdaterManager.swift        | 89 +++++++++++++++++++++++
 Dayflow/Dayflow/Views/UI/Settings/SettingsOtherTabView.swift | 65 ++++++++++++++++
 3 files changed, 171 insertions(+), 3 deletions(-)

Test plan

  • Builds clean on Dayflow v2.1.0 (Xcode 26.6, arm64)
  • Installed at /Applications/Dayflow.app, Apple Dev signed, launches without crash
  • Toggle state persists across app relaunch (via Sparkle's NSUserDefaults storage)
  • When auto-check is on, updates still install silently (no regression)
  • When auto-check is off, "Check now" surfaces the standard Sparkle update UI with release notes
  • "View changelog" opens the release-notes URL in the default browser
  • "Automatically download and install" sub-toggle is disabled when auto-check is off

Tested with Claude Code 2.1.22.

Relationship to other PRs

M3NT1 added 4 commits August 13, 2026 14:34
Add @published mirrors of SPUUpdater settings so the Settings UI can bind
to them via SwiftUI:

  - automaticallyChecksForUpdates (read-only @published; backed by KVO)
  - automaticallyDownloadsUpdates (read-only @published; backed by KVO)
  - canCheckForUpdates             (read-only @published; backed by KVO)
  - pendingReleaseNotesURL         (read/write @published; captured from
                                    SUAppcastItem.releaseNotesURL)

The mirrors are populated by KVO observers in setupObservers(), which is
called after updater.start() so the .initial flag pulls the user's
saved NSUserDefaults preference from the previous session. This means
the Settings toggle starts in the right state on every launch without
us having to read the value ourselves at init.

Two new setters, setAutomaticallyChecksForUpdates() and
setAutomaticallyDownloadsUpdates(), are the only path the UI uses to
change Sparkle settings. They write through SPUUpdater, which is what
persists the change to NSUserDefaults — the Sparkle docs require this
("Only set this property if the user wants to change the default via a
user settings option"). Writing through the @published mirror would
silently drop the change on next launch.

openReleaseNotes() opens the cached URL via NSWorkspace.shared so the
"View changelog" link in Settings works even when the Sparkle UI was
dismissed (i.e. when the user has auto-update disabled).

pendingReleaseNotesURL is captured in didFindValidUpdate and cleared in
updaterDidNotFindUpdate and the no-update branch of didAbortWithError,
so it never points at a stale release-notes URL after a successful
"no updates" check.

import AppKit added for NSWorkspace.
SilentUserDriver currently responds .install to every showUpdateFound
callback, meaning Dayflow installs every Sparkle update without any
user interaction. With the new Settings toggle, we want the driver to
honor the user's preference:

  - if automaticallyChecksForUpdates == true: reply .install
    (the existing silent-install behavior — used when the user has
    opted into auto-update)
  - if automaticallyChecksForUpdates == false: reply .dismiss
    (keep the update cached, surface the release-notes URL via the
    "View changelog" link in Settings, but don't push the install)

The automaticallyChecksForUpdates property is mirrored from
SPUUpdater.automaticallyChecksForUpdates. The mirror is kept in sync
in two places: the KVO observer in UpdaterManager.setupObservers()
(picks up Sparkle-internal changes, e.g. the 2nd-launch permission
prompt) and the user-facing setters in UpdaterManager
(picks up user-initiated changes immediately).

The KVO observer change in UpdaterManager is a behavioral change for
SilentUserDriver, so it's bundled into this commit rather than the
first one — both changes are required for the toggle to actually
take effect.
Wires UpdaterManager into SettingsOtherTabView as a new "Updates"
section. Follows the same pattern as the existing toggles in the
"App preferences" section: SettingsRow + SettingsToggle bound via a
custom Binding(get:set:) that delegates to UpdaterManager.

Layout (top to bottom):

  - Toggle: "Automatically check for updates"
    Binds to updaterManager.automaticallyChecksForUpdates via
    setAutomaticallyChecksForUpdates(). When off, Dayflow only checks
    for updates when the user clicks "Check now".

  - Sub-toggle: "Automatically download and install updates"
    Binds to updaterManager.automaticallyDownloadsUpdates. Disabled
    when the auto-check toggle is off (mirroring the Sparkle reference
    UI in the official docs).

  - Button: "Check now"
    Calls updaterManager.checkForUpdates(showUI: true), which routes
    through the SPUStandardUpdaterController (not SilentUserDriver) so
    the user always gets the standard release-notes UI even when
    auto-update is off. Disabled while isChecking or while
    canCheckForUpdates is false. Replaced with a ProgressView while
    isChecking.

  - Status subtitle
    Shows updaterManager.statusText — empty unless a check is
    in progress ("Checking…"), just completed ("Latest version"
    or "Update available: vX.Y.Z"), or errored ("Update needs
    authorization" / "Update check failed").

  - Conditional row: "Latest release notes"
    Only rendered when pendingReleaseNotesURL is non-nil, i.e. an
    update has been found and its feed entry included a notes URL.
    Subtitle is the URL host so the user can see where the link
    points before clicking. Tapping "View changelog" calls
    updaterManager.openReleaseNotes(), which opens the URL in the
    user's default browser via NSWorkspace.

Tested: builds clean on Dayflow v2.1.0 (Xcode 26.6, arm64).
…lease notes URL

- SilentUserDriver: rename automaticallyChecksForUpdates to allowsSilentInstall;
  branch showUpdateFound on the download/install preference, not the check preference
- UpdaterManager: sync allowsSilentInstall from automaticallyDownloadsUpdates
  synchronously after start(), not just via async KVO
- UpdaterManager: reset isChecking in didFinishUpdateCycleFor to prevent
  stuck spinner on cancel/error paths
- UpdaterManager: make pendingReleaseNotesURL private(set) and validate
  URL scheme before opening in default browser
- SilentUserDriver: correct misleading .dismiss comment
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