Custom update notifications in SwiftUI
- 1 I want to enable custom, unobtrusive update notifications and installs by customizing SPUUserDriver (see https://sparkle-project.org/documentation/api-reference/Protocols/SPUUserDriver.html). Let's start by planning the custom UI we'll need. We'll ONLY work on UI. Create a plan for creating SwiftUI views that can show the various states that are required by SPUUserDriver. I think the best place for these to show up is in the macOS window titlebars on the top-right. Create a plan to put it there.
- 2 Let's implement this. Put the update-specific views in the `Update` folder that already exists.
- 3 Hook it up to the window styles. Create demo code so we can view the various states without actually using the SPUUserDriver
- 4 Hook up the demo to the real `checkForUpdates` ibaction.
- 5 Okay! This is looking really good. Instead of the really tiny 22x22 icon, let's change the visual style to be a pill with an icon on the left and text on the right. It should say state like "Update Available" and "Downloading:" and "Install Update Now" etc.
- 6 This looks really good. The PopOver is now the weak link. It has some odd padding and spacing. The "Skip this version" is hidden behind a single dropdown. Let's streamline this whole design to look a lot better. Additionally, when clicking "Install/Download", we should hide the popover since the progress is shown now in the badge.
- 7 On the update available popover, let's align the version/size labels so their values align on the left edge.
- 8 Let's change "Install Update Now" to just say "Install Update" and be green.
- 9 The "check for update" sync icon should rotate not pulse.
- 10 The green for "install update" is a bit too bright. And the rotation speed for the update check needs to slow down and be more relaxing.
- 11 I sometimes can't click the "update available" pill. It is almost like the hit isn't regisering.
- 12 I think the hit testing issue specifically affects our "tabs" titlebar style on Tahoe. It might be due to the layout/constraints that we setup there. This is only not working with that style
- 13 It's no longer showing up at all.
- 14 The new issue we're running into is that this doesn't play nicely with the macOS native tabs at all. Can we fix that.
The user wants to:
- Create custom update notifications for Sparkle (SPUUserDriver)
- Make them unobtrusive
- Plan SwiftUI views for the various states
- Place them in the macOS window titlebars on the top-right
- Consult the oracle for planning
I should:
- First understand the SPUUserDriver protocol to know what states we need to handle
- Read the Sparkle documentation URL provided
- Look at the current macOS app structure to understand how to integrate this
- Consult the oracle with the relevant context about planning this UI implementation
Let me start by reading the web page about SPUUserDriver and then finding relevant files in the macOS app directory.
- TL;DR
- Add a single compact “Update badge” SwiftUI accessory in the window titlebar (top-right) via NSTitlebarAccessoryViewController. It shows an icon/progress ring and opens a small popover for details/actions.
- Map all SPUUserDriver states to unobtrusive icon/pill variants; keep text out of the titlebar and put details in the popover. Reuse the reset-zoom accessory pattern (padding, layout, tab-bar workaround).
- Recommended approach (simple path)
A) SwiftUI views to create
- UpdateAccessoryView: The titlebar-hosted, ultra-compact badge view (22×22 target hit-area), with subtle background only when needed. Contains:
- BadgeButton (icon-only; toggles popover)
- InlineProgressRing (when progress states)
- Optional mini-pill for “Install” when ready
- Tooltip for all states
- .popover for details and actionable controls
- UpdatePopoverView: Popover content; a small, focused panel that varies by state, with actions wired via closures to be implemented later by SPUUserDriver integration.
- Reusable small components:
- ProgressRingView: Circular progress (0–1) with subtle animation
- UpdatePill: Minimal pill with icon + short label (e.g., “Install”)
- ErrorInlineView: Warning icon + short message line (used in popover)
- ReleaseNotesPreview (optional): Simple text view area; initially a placeholder with “View release notes…” action
B) Titlebar appearance and UX
- Titlebar badge (top-right):
- Default hidden (idle)
- 22×22 icon button with .plain style; accent-colored glyph for high-signal states (update available, error); neutral/secondary color for passive states (checking)
- Small circular progress ring replaces the icon (or overlays it) for downloading/extracting
- When “Ready to install,” the badge shows a checkmark or arrow.triangle.2.circlepath.circle with a short “Install” pill on hover, else icon-only to stay unobtrusive
- Tooltip reflects current state: “Checking for updates…”, “Update available”, “Downloading 42%…”, “Ready to install”, “Update failed”, etc.
- On click, popover anchored to the badge; popover includes readable text and all actions
- Popover content (by state):
- Permission request: Title “Enable automatic updates?” with “Allow” / “Not now,” plus small help text
- User-initiated check: “Checking for updates…” with activity indicator and “Cancel”
- Update found: Version info, “Install”, “Skip this version”, “Remind me later”, “Release notes…”
- Download progress: Progress bar with percent, “Pause/Resume” (if you intend to support later) or “Cancel”
- Extraction progress: Progress bar and message “Preparing…”
- Ready to install: “Install and Relaunch” primary, “Later” secondary
- Installing: Activity indicator + “Installing…”
- Errors: brief message and “Retry”, plus “Details…” if needed
- Visuals/spacing:
- Match ResetZoomAccessoryView padding strategy:
- .padding(.top, hasToolbar ? 9–10 : 4–5) depending on OS
- .padding(.trailing, 10)
- Keep pill text minimal; most details live in the popover
- Match ResetZoomAccessoryView padding strategy:
C) State management (view model)
- Define a single ObservableObject used by the accessory:
- class UpdateUIModel: ObservableObject
- @Published var state: UpdateUIState
- @Published var progress: Double? (0…1) // used in download/extract/check
- @Published var details: UpdateDetails? // version, notes summary, etc.
- @Published var error: UpdateErrorInfo? // message, code
- enum UpdateUIState {
case idle
case permissionRequest
case checking // user-initiated check
case updateAvailable
case downloading
case extracting
case readyToInstall
case installing
case notFound // “No updates found”
case error
} - struct UpdateDetails { version: String, build: String?, size: String?, date: Date?, notesSummary: String? }
- struct UpdateErrorInfo { title: String, message: String }
- class UpdateUIModel: ObservableObject
- Actions protocol (closures injected; SPUUserDriver will implement later):
- struct UpdateUIActions {
let allowAutoChecks: () -> Void
let denyAutoChecks: () -> Void
let cancel: () -> Void
let install: () -> Void
let remindLater: () -> Void
let skipThisVersion: () -> Void
let showReleaseNotes: () -> Void
let retry: () -> Void
}
- struct UpdateUIActions {
- The SwiftUI views never talk to Sparkle directly; they only mutate via UpdateUIModel and call actions
D) Integration with NSTitlebarAccessoryViewController
- In TerminalWindow:
- Add a property: private let updateAccessory = NSTitlebarAccessoryViewController()
- Add a window-scoped UpdateUIModel (similar to existing ViewModel storage)
- In awakeFromNib (after styleMask.contains(.titled)):
- updateAccessory.layoutAttribute = .right
- updateAccessory.view = NSHostingView(rootView:
UpdateAccessoryView(
hasToolbar: viewModel.$hasToolbar,
model: updateUIModel,
actions: injectedActions // placeholder now
)
) - addTitlebarAccessoryViewController(updateAccessory)
- updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false
- Respect the existing tab bar workaround:
- On tabBarDidAppear: remove the SwiftUI accessory (same as reset zoom) to avoid the scaling bug
- On tabBarDidDisappear: re-add it if missing
- Ordering with reset zoom:
- Use insertTitlebarAccessoryViewController(_:at:) to keep update badge as the right-most item; ensure consistent index relative to resetZoomAccessory
- Fullscreen and toolbar presence:
- Share the same hasToolbar signal to align padding with ResetZoomAccessoryView
E) Animations/transitions (unobtrusive)
- Badge enters/exits with .opacity + slight .scale (0.95→1.0) over 0.15–0.2s
- Progress rings animate changes with .easeInOut(duration: 0.2)
- When update becomes available, do a single gentle pulse of the badge (e.g., opacity 0.8→1.0) to draw attention once, then remain steady
- Popover appears with default system animation; keep it small and focused
F) Example skeleton (minimal)
- enum UpdateUIState { … } and UpdateUIModel as above
- UpdateAccessoryView:
- Takes hasToolbar: Binding, model: ObservedObject, actions: UpdateUIActions
- Computes top padding as in ResetZoomAccessoryView
- Renders:
- if model.state == .idle: EmptyView()
- else: VStack { BadgeButton or ProgressRingView; Spacer() }
.padding(.top, topPadding)
.padding(.trailing, 10)
.popover(isPresented: $showPopover) { UpdatePopoverView(model: model, actions: actions) }
- ProgressRingView:
- ZStack { Circle trimmed according to progress; background circle; overlay icon when appropriate }
- UpdatePopoverView:
- Switch on model.state and render small controls; buttons just call actions.* closures
G) Mapping SPUUserDriver states → UI states
- Permission request → .permissionRequest (badge with questionmark.circle; popover asks “Enable automatic updates?”)
- User-initiated check (with progress) → .checking (spinner in badge; cancel in popover)
- Update found → .updateAvailable (badge with arrow.down.circle.fill; popover with Install/Skip/Remind/Notes)
- Download progress → .downloading(progress)
- Extraction progress → .extracting(progress)
- Ready to install → .readyToInstall (badge shows checkmark/“Install” pill; one-click installs; popover offers Later)
- Installing → .installing (spinner; disabled actions)
- Errors / not found → .error or .notFound (warning or info icon; popover shows message and Retry/OK)
Effort/scope
- S (0.5–1h): Define state model, actions protocol, and the basic UpdateAccessoryView + popover placeholders
- M (1–3h): Implement compact badge UI, progress ring, icons, popover skeletons per state
- L (1–2d): Polish visuals, accessibility, refine states, QA across toolbar/no-toolbar, fullscreen, and tabbed windows
- Rationale and trade-offs
- Single compact badge keeps the titlebar clean and unobtrusive while still discoverable; details live in a popover to preserve space.
- Reusing NSTitlebarAccessoryViewController + NSHostingView mirrors the existing reset-zoom pattern, minimizing new infra and reducing risk.
- A single model + enum keeps logic simple and testable; wiring to SPUUserDriver later is straightforward via closures.
Alternative (only if needed)
- If you must show update UI while the tab bar is visible (where SwiftUI accessories cause scaling issues), replace the titlebar accessory with a lightweight NSView-based accessory for that condition only, or place a minimal AppKit button in tab.accessoryView. This deviates from the “SwiftUI-only” ask, so treat it as a fallback.
- Risks and guardrails
- Known SwiftUI accessory + tab bar scaling bug: we will hide the accessory when the tab bar is visible (matching ResetZoom). Guardrail: keep menu- and command-initiated update flows functional; consider a transient NSAlert for critical errors if the badge is hidden.
- Touch targets: ensure 22×22 minimum; test on different titlebar heights with and without toolbars.
- Fullscreen/toolbar layout differences: use the existing hasToolbar signal to align correctly.
- Popover anchoring in titlebar: test in light/dark modes and different window sizes; ensure it doesn’t cover traffic lights.
- When to consider the advanced path
- You need the badge visible even when a tab bar is present (user may miss notifications).
- You want richer content (inline release notes, longer text) in the titlebar itself.
- You need cross-window coordination (global update state across multiple windows).
- Optional advanced path (brief)
- Build an UpdateUIHost that renders:
- Titlebar SwiftUI accessory when no tab bar is present
- AppKit NSView accessory (or tab.accessoryView item) when tab bar is present
- A fallback floating HUD anchored to NSTitlebarView for critical states
- Add a shared UpdateCenter (singleton ObservableObject) to synchronize state across windows; each window hosts a small proxy model derived from the shared state.
I'm going to ask the oracle for advice on planning custom UI for Sparkle update notifications in the titlebar.
Based on the oracle's analysis and the existing codebase patterns, here's the plan for creating unobtrusive SwiftUI update notification UI:
Custom SPUUserDriver UI Plan
1. SwiftUI Views Architecture
Core Views:
UpdateAccessoryView- Main titlebar component (22×22 compact badge)- Shows icon/progress ring based on state
- Toggles popover on click
- Hidden when idle
- Positioned using
.layoutAttribute = .rightviaNSTitlebarAccessoryViewController
UpdatePopoverView- Detailed content for each state- Switches UI based on current update state
- Contains all actions (Install, Skip, Cancel, etc.)
- Shows release notes, progress details, error messages
Supporting Components:
ProgressRingView- Circular progress indicator (0-1)UpdatePill- Minimal pill with icon + labelErrorInlineView- Warning icon + messageReleaseNotesPreview- Text preview area
2. Titlebar Design & UX
Badge Appearance:
- 22×22 icon button in top-right corner
- Hidden when no update activity
- Color scheme:
- Accent color for actionable states (update available)
- Secondary color for passive states (checking)
- Warning color for errors
- Progress ring overlays icon during download/extraction
- Tooltip shows current state
Popover States:
- Permission request → "Enable automatic updates?" with Allow/Not now
- Checking → Spinner with Cancel
- Update available → Version info, Install/Skip/Later buttons, Release notes link
- Downloading → Progress bar with percentage
- Extracting → Progress bar with "Preparing…"
- Ready to install → "Install and Relaunch" primary button
- Installing → Spinner with message
- Errors → Error message with Retry/OK
3. State Management
class UpdateUIModel: ObservableObject {
@Published var state: UpdateUIState
@Published var progress: Double? // 0-1
@Published var details: UpdateDetails?
@Published var error: UpdateErrorInfo?
}
enum UpdateUIState {
case idle
case permissionRequest
case checking
case updateAvailable
case downloading
case extracting
case readyToInstall
case installing
case notFound
case error
}
struct UpdateUIActions {
let allowAutoChecks: () -> Void
let denyAutoChecks: () -> Void
let cancel: () -> Void
let install: () -> Void
let remindLater: () -> Void
let skipThisVersion: () -> Void
let showReleaseNotes: () -> Void
let retry: () -> Void
}
4. Integration with NSTitlebarAccessoryViewController
Following the existing resetZoomAccessory pattern:
- Add
updateAccessoryproperty toTerminalWindow - Create in
awakeFromNibwithlayoutAttribute = .right - Handle tab bar visibility workaround (remove on
tabBarDidAppear, restore ontabBarDidDisappear) - Match padding:
.padding(.top, hasToolbar ? 9-10 : 4-5)+.padding(.trailing, 10)
5. Animations & Transitions
- Badge enter/exit:
.opacity+.scale(0.95→1.0)over 0.15-0.2s - Single gentle pulse when update becomes available
- Progress changes:
.easeInOut(duration: 0.2) - Popover uses system default animations
6. Key Implementation Details
- Views never talk to Sparkle directly - only mutate via
UpdateUIModeland call action closures - Respect existing tab bar workaround (same SwiftUI + tab bar scaling bug)
- Position as rightmost accessory, ordered relative to
resetZoomAccessory - 22×22 minimum touch target
- Test across toolbar/no-toolbar, fullscreen, tabbed windows
The user wants me to implement the custom SPUUserDriver UI based on the plan I just created. They want the update-specific views placed in the existing Update folder at /Users/mitchellh/Documents/ghostty/macos/Sources/Features/Update.
Let me start by creating the implementation. I should:
- Create a TODO list to track the work
- Create the state management models
- Create the SwiftUI views
- All files should go in the Update folder
Let me break this down:
- UpdateUIModel.swift - State management
- UpdateAccessoryView.swift - Main titlebar component
- UpdatePopoverView.swift - Popover content
- Supporting components like ProgressRingView, UpdatePill, etc.
I'll create these files in a logical order.
@@ -1,0 +1,120 @@
import Foundation
import SwiftUI
enum UpdateUIState: Equatable {
case idle
case permissionRequest
case checking
case updateAvailable
case downloading
case extracting
case readyToInstall
case installing
case notFound
case error
}
struct UpdateDetails: Equatable {
let version: String
let build: String?
let size: String?
let date: Date?
let notesSummary: String?
}
struct UpdateErrorInfo: Equatable {
let title: String
let message: String
}
struct UpdateUIActions {
let allowAutoChecks: () -> Void
let denyAutoChecks: () -> Void
let cancel: () -> Void
let install: () -> Void
let remindLater: () -> Void
let skipThisVersion: () -> Void
let showReleaseNotes: () -> Void
let retry: () -> Void
}
class UpdateUIModel: ObservableObject {
@Published var state: UpdateUIState = .idle
@Published var progress: Double? = nil
@Published var details: UpdateDetails? = nil
@Published var error: UpdateErrorInfo? = nil
var stateTooltip: String {
switch state {
case .idle:
return ""
case .permissionRequest:
return "Update permission required"
case .checking:
return "Checking for updates…"
case .updateAvailable:
if let details {
return "Update available: \(details.version)"
}
return "Update available"
case .downloading:
if let progress {
return String(format: "Downloading %.0f%%…", progress * 100)
}
return "Downloading…"
case .extracting:
if let progress {
return String(format: "Preparing %.0f%%…", progress * 100)
}
return "Preparing…"
case .readyToInstall:
return "Ready to install"
case .installing:
return "Installing…"
case .notFound:
return "No updates found"
case .error:
return error?.title ?? "Update failed"
}
}
var badgeIconName: String {
switch state {
case .idle:
return ""
case .permissionRequest:
return "questionmark.circle"
case .checking:
return "arrow.triangle.2.circlepath"
case .updateAvailable:
return "arrow.down.circle.fill"
case .downloading, .extracting:
return "" // Progress ring instead
case .readyToInstall:
return "checkmark.circle.fill"
case .installing:
return "gear"
case .notFound:
return "info.circle"
case .error:
return "exclamationmark.triangle.fill"
}
}
var badgeIconColor: Color {
switch state {
case .idle:
return .secondary
case .permissionRequest, .checking:
return .secondary
case .updateAvailable, .readyToInstall:
return .accentColor
case .downloading, .extracting, .installing:
return .secondary
case .notFound:
return .secondary
case .error:
return .orange
}
}
}