eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 2480c1a5bcd74ac7f72f9a21ac3a8690db18bc0a parent 92a58b4
3 files changed, +93 −2
  • ios/LChatMac/LChatMacApp.swift +14 −2
  • ios/LChatMac/Sidebar.swift +30 −0
  • ios/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.