mac: gentle update reminders — a rail indicator instead of Sparkle's dialog (#1461)
A scheduled background check that finds an update no longer pops the update dialog over someone's conversation (the stock SPUStandardUserDriver behaviour). The find publishes to a GentleUpdateReminder (SPUStandardUserDriverDelegate, supportsGentleScheduledUpdateReminders) and the rail shows a quiet tinted circle in its own flow — click it when convenient and Sparkle's standard flow runs as user-initiated. The app-menu Check for Updates… is unchanged. iOS unscoped: TestFlight/App Store own updates there — no Sparkle. Changelog held for the next Mac mint per convention. Claude-Session: https://claude.ai/code/session_014mt8aQ3bwLs1kcqeP598xr
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+93
−2
ios/LChatMac/LChatMacApp.swift+14 −2ios/LChatMac/Sidebar.swift+30 −0ios/LChatMac/UpdaterUI.swift+49 −0
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift| index 4121aee..0ed876e 100644 |
| --- a/ios/LChatMac/LChatMacApp.swift |
| +++ b/ios/LChatMac/LChatMacApp.swift |
| @@ -52,6 +52,7 @@ struct LChatMacApp: App { |
| Window("Eleven", id: "main") { |
| RootView() |
| .environmentObject(app) |
| + .environmentObject(appDelegate.gentleReminder) |
| .frame(minWidth: 820, minHeight: 560) |
| .onAppear { appDelegate.appState = app } |
| .onOpenURL { url in |
| @@ -199,8 +200,19 @@ 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) |
| + // Gentle update reminders (UpdaterUI): scheduled checks publish into this |
| + // instead of popping Sparkle's dialog; the rail shows the indicator. |
| + let gentleReminder: GentleUpdateReminder |
| + let updaterController: SPUStandardUpdaterController |
| + |
| + override init() { |
| + let reminder = GentleUpdateReminder() |
| + gentleReminder = reminder |
| + updaterController = SPUStandardUpdaterController( |
| + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: reminder) |
| + reminder.updater = updaterController.updater |
| + super.init() |
| + } |
| // Every activation pulls the vault — switching back from the browser or |
| // another Mac is exactly when a home-page change is freshest. |
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift| index 25c62e3..134fa40 100644 |
| --- a/ios/LChatMac/Sidebar.swift |
| +++ b/ios/LChatMac/Sidebar.swift |
| @@ -21,6 +21,9 @@ struct Sidebar: View { |
| // this Mac is signed in with at least one space — the web's gate). The |
| // takeover itself is RootView's (app.requestCreateSpace bumps the tick). |
| @State private var spacesOffered = false |
| + // Gentle update reminders (UpdaterUI): a scheduled Sparkle check that |
| + // found an update publishes here instead of popping its dialog. |
| + @EnvironmentObject var updates: GentleUpdateReminder |
| // The rail shows favourites ONLY when any exist; otherwise up to nine |
| // spaces. Everything else lives in the "…" menu — archived spaces at its |
| @@ -52,6 +55,12 @@ struct Sidebar: View { |
| if spacesOffered && signOut.sessionExists && !app.instances.isEmpty { |
| createButton |
| } |
| + // The unobtrusive update indicator (the Rogue Amoeba shape): |
| + // sits in the rail's flow like any other control — never a |
| + // dialog, never floating — until clicked or the update ships. |
| + if let version = updates.availableVersion { |
| + updateButton(version) |
| + } |
| } |
| // Top inset clears the floating traffic-light controls (title bar hidden). |
| .padding(.top, 34) |
| @@ -135,6 +144,27 @@ struct Sidebar: View { |
| .railTooltip("Create a New Space") |
| } |
| + // The rail's update indicator: same quiet-circle chrome as "+" and "…", |
| + // tinted so it reads as news rather than another verb — but the tint is |
| + // never the only signal (the arrow glyph + tooltip carry it too). Click |
| + // hands over to Sparkle's standard, now user-initiated, flow. |
| + private func updateButton(_ version: String) -> some View { |
| + Button { |
| + updates.install() |
| + } label: { |
| + Image(systemName: "arrow.down.circle.fill") |
| + .font(.callout.weight(.semibold)) |
| + .foregroundStyle(Color.accentColor) |
| + .frame(width: 34, height: 34) |
| + .background(Circle().fill(Color.accentColor.opacity(0.15))) |
| + .frame(maxWidth: .infinity) |
| + .contentShape(Circle()) |
| + } |
| + .buttonStyle(.plain) |
| + .railTooltip("Update Available — Eleven \(version)") |
| + .accessibilityLabel("Update available: Eleven \(version)") |
| + } |
| + |
| // Everything not on the rail, behind a quiet ellipsis circle (Discord's |
| // "more servers" idiom) — as a MENU, not an inline fold: click a space to |
| // open it, or use its submenu to star/archive; archived spaces group at |
diff --git a/ios/LChatMac/UpdaterUI.swift b/ios/LChatMac/UpdaterUI.swift| index a9eaafd..f51e52b 100644 |
| --- a/ios/LChatMac/UpdaterUI.swift |
| +++ b/ios/LChatMac/UpdaterUI.swift |
| @@ -1,6 +1,55 @@ |
| import SwiftUI |
| import Sparkle |
| +// Sparkle's "gentle scheduled update reminders": a background check that finds |
| +// an update must never pop the update dialog over someone's conversation (the |
| +// stock driver's behaviour — the exact interruption Rogue Amoeba's apps moved |
| +// away from). Instead the find is published here and the rail paints a quiet |
| +// inline indicator; clicking it re-runs the check as USER-initiated, which |
| +// shows Sparkle's standard flow at a moment the member chose. The app-menu |
| +// "Check for Updates…" is user-initiated already and unchanged. |
| +final class GentleUpdateReminder: NSObject, ObservableObject, SPUStandardUserDriverDelegate { |
| + // The version string the rail shows; nil = no indicator. Delegate |
| + // callbacks arrive on the main queue, so plain @Published is safe. |
| + @Published var availableVersion: String? |
| + |
| + // Set right after the controller is built (AppDelegate) — the click's |
| + // route back into Sparkle. Weak: the controller owns the updater. |
| + weak var updater: SPUUpdater? |
| + |
| + var supportsGentleScheduledUpdateReminders: Bool { true } |
| + |
| + // Never let a scheduled check take the window — even the "immediate |
| + // focus" case (an update found moments after launch) waits for a click. |
| + func standardUserDriverShouldHandleShowingScheduledUpdate( |
| + _ update: SUAppcastItem, andInImmediateFocus immediateFocus: Bool) -> Bool { |
| + false |
| + } |
| + |
| + func standardUserDriverWillHandleShowingUpdate( |
| + _ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, |
| + state: SPUUserUpdateState) { |
| + guard !state.userInitiated else { return } |
| + availableVersion = update.displayVersionString |
| + } |
| + |
| + // The member engaged with Sparkle's own UI (via our indicator or the |
| + // menu) — the reminder has done its job either way. |
| + func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { |
| + availableVersion = nil |
| + } |
| + |
| + func standardUserDriverWillFinishUpdateSession() { |
| + availableVersion = nil |
| + } |
| + |
| + // The indicator's click: a user-initiated check re-finds the update and |
| + // presents the standard flow. |
| + func install() { |
| + updater?.checkForUpdates() |
| + } |
| +} |
| + |
| // 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. |