Custom update notifications in SwiftUI
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
Perfect! I've implemented all the SwiftUI views for the custom update UI. Here's what was created in the Update folder:
Created Files
UpdateUIModel.swift - State management
UpdateUIStateenum for all update statesUpdateUIModelObservableObject with published propertiesUpdateUIActionsstruct with action closures- Helper properties for tooltips and badge icons
ProgressRingView.swift - Circular progress indicator
- Animated ring that shows 0-1 progress
- Used during download/extraction
UpdateAccessoryView.swift - Main titlebar component
- 22×22 compact badge with icon/progress ring
- Follows same padding pattern as ResetZoomAccessoryView
- Toggles popover on click
- Pulse animation when update becomes available
UpdatePopoverView.swift - Popover content
- Switches UI based on state
- All 10 SPUUserDriver states covered:
- Permission request
- Checking for updates
- Update available
- Downloading
- Extracting
- Ready to install
- Installing
- No updates found
- Error
Next Steps
When you're ready to integrate with SPUUserDriver, you'll need to:
- Add the
updateAccessoryto TerminalWindow (following theresetZoomAccessorypattern) - Create a custom SPUUserDriver implementation that updates the
UpdateUIModel - Handle the tab bar visibility workaround (same as reset zoom)
Perfect! Implementation complete. Here's what was created:
Summary
I've successfully implemented the custom update UI for Sparkle and hooked it up to the window styles. Here's what was built:
Created Files
- UpdateUIModel.swift - State management (states, actions, tooltips)
- ProgressRingView.swift - Circular progress indicator
- UpdateAccessoryView.swift - Titlebar badge component
- UpdatePopoverView.swift - Popover content for all states
- UpdateUIDemo.swift - Demo/testing interface
- README.md - Documentation
Modified Files
- TerminalWindow.swift - Added update accessory, handles tab bar workaround
- AppDelegate.swift - Added
showUpdateUIDemoaction
How to Test
Build and run the app, then from the Xcode debugger or via a menu trigger, call:
(NSApp.delegate as? AppDelegate)?.showUpdateUIDemo(nil)
This opens a demo window where you can click buttons to cycle through all 10 update states and see how the titlebar badge and popover look.
The UI follows the same pattern as the existing reset zoom button, includes the tab bar workaround, and handles all SPUUserDriver states with appropriate icons, colors, and animations.
Perfect! Here's the complete implementation summary:
✅ Implementation Complete
I've successfully hooked up the update UI demo to the real "Check for Updates" menu action. Here's what you can now do:
How to Test
- Build and run Ghostty
- Click "Ghostty → Check for Updates" (or use the keyboard shortcut)
- Watch the titlebar - you'll see:
- Spinning arrows icon (checking for 2 seconds)
- Download arrow icon in accent color (update available)
- Click the badge to open the popover
- Click "Install" to see the full simulated flow:
- Downloading with animated progress ring (0-100%)
- Extracting with animated progress ring (0-100%)
- Ready to install with checkmark icon
What Was Modified
- Modified
checkForUpdatesto trigger demo simulation - Shows "checking" state, then "update available" after 2 seconds
- Falls back to real Sparkle if not in a terminal window
- Enhanced
createUpdateActions()with full flow simulation - "Install" action now simulates: download → extract → ready
- "Retry" action simulates a new check
- "Release Notes" opens GitHub releases page
- Updated with testing instructions
- Added quick test workflow
- Documented both demo methods
State Flow Diagram
The diagram above shows the complete state machine. All states are accessible via the demo, and the UI handles all transitions with appropriate animations and visual feedback.
Next Steps
When ready to implement the real SPUUserDriver:
- Create a custom
SPUUserDriverclass - Replace the demo actions with real Sparkle callbacks
- Wire up the progress handlers
- Remove the TODO comment in
checkForUpdates
The UI is fully functional and ready to be connected to Sparkle!
Perfect! Here's the summary of the new pill design:
✅ Updated to Pill Design
I've transformed the update UI from a tiny 22×22 icon to a readable pill-shaped button with icon + text. Here's what changed:
Visual Design
Before: Tiny icon-only badge (22×22)
After: Pill with icon on left, descriptive text on right
Examples of Pill Text
- 🔄 "Checking for Updates…" (neutral background)
- ⬇️ "Update Available: 1.2.0" (accent blue background, white text)
- 📥 "Downloading: 42%" (neutral, with progress ring icon)
- 📦 "Preparing: 75%" (neutral, with progress ring icon)
- ✓ "Install Update Now" (accent blue background, white text)
- ⚙️ "Installing…" (neutral)
- ℹ️ "No Updates Available" (neutral)
- ⚠️ "Update Failed" (orange background)
Color Scheme
- Accent blue - Actionable states (update available, ready to install)
- Neutral gray - Passive/informational states (checking, downloading, installing)
- Orange - Error states
What Changed
- Added
badgeTextproperty with descriptive text for each state
- Changed from icon-only to pill design with HStack(icon + text)
- Added
pillBackgroundColorandpillForegroundColorcomputed properties - Capsule-shaped background with proper padding
- Updated to use
Color.primaryfor better contrast in pill - Removed fixed frame (now sized by parent)
Testing
Build and run, then use "Check for Updates" to see the new pill design in action. The pill will appear in the titlebar with clear, readable text showing exactly what's happening!
Perfect! Here's the summary of the popover improvements:
✅ Popover Redesigned and Streamlined
I've completely redesigned the popover for a much cleaner, more polished look:
Key Improvements
1. Consistent Spacing & Padding
- Uniform 16pt padding on all views
- Logical 8-16pt internal spacing between elements
- No more odd gaps or cramped sections
2. Unified Typography
- Headers: 13pt semibold
- Body text: 11pt regular
- Secondary text: 11pt with secondary color
- Consistent across all states
3. Better Button Layout
- "Skip This Version" is now a visible button (not hidden in dropdown)
- Small control sizes (
.controlSize(.small)) for compact appearance - Right-aligned single actions, multi-button rows for choices
- Clear visual hierarchy
4. Auto-Dismiss Behavior
- Popover automatically dismisses when you click any action
- Install/Download → popover closes, progress shown in pill badge
- Cancel → popover closes, badge disappears
- Skip/Later → popover closes, badge disappears
- This makes the UX feel snappy and intentional
5. Release Notes Link
- Now appears as a full-width button at the bottom
- Light gray background to visually separate from main content
- Arrow icon on the right for external link indication
What Changed
- Added
@Environment(\.dismiss)for auto-dismiss - All actions now call
dismiss()after executing - Restructured all views with consistent spacing
- Replaced dropdown menu with visible "Skip" button
- Applied
.controlSize(.small)to all buttons - Unified all font sizes and weights
- Better visual hierarchy with nested VStacks
Testing
Build and run, then trigger "Check for Updates". When the popover appears:
- Notice the clean, consistent spacing
- Click "Install" and watch it auto-dismiss (progress continues in the pill)
- The "Skip" button is now clearly visible, not hidden
- All text is crisp and well-sized
The popover now feels like a native macOS component with professional polish! 🎉
Perfect! The version and size labels now align so their values line up nicely:
Version: 1.2.0
Size: 42 MB
The labels are right-aligned in a 50pt frame, which makes the colons line up and creates a clean, professional look in the popover.
Perfect! The "ready to install" state now shows:
- Text: "Install Update" (shorter, cleaner)
- Color: Green background with white text (more actionable and inviting)
This makes it visually distinct from "Update Available" (blue) and gives users a clear,
Perfect! The checking and installing states now show a rotating icon instead of pulsing. The icon rotates continuously (360° in 1 second) using a linear animation that repeats forever. This gives a clear visual indication that the app is actively working, and it's more appropriate for a "checking" or "processing" state.
Perfect! I've made both changes:
- Darker green - The "Install Update" pill now uses a more muted green (30% blended with black) that's easier on the eyes
- Slower rotation - The checking/installing icon now rotates at 2.5 seconds per rotation instead of 1 second, creating a more relaxed, calming animation
The rotation is now much more subtle and less distracting while still clearly indicating activity.
Perfect! I've added .contentShape(Capsule()) to explicitly define the clickable area of the pill button. This tells SwiftUI that the entire capsule shape should be interactive for hit-testing, not just the text and icon inside it. The button should now be much more reliable to click anywhere on the pill.
Perfect! I've added explicit z-ordering to the update accessory view by:
- Setting
wantsLayer = trueto enable layer-based rendering - Setting
layer.zPosition = 1000to place it above other titlebar elements
This should ensure the update pill is clickable even in the Tahoe tabs titlebar style where the tab bar's clip view constraints fill the entire toolbar area. The high z-position ensures the update accessory renders on top and receives hit-testing events properly.
Perfect! I've fixed the issue by preventing the tab bar from extending all the way to the right edge of the toolbar. The tab bar's clip view was constrained to container.rightAnchor, which meant it filled the entire width and blocked any right-aligned accessories (like the update pill).
Now the clip view stops 200pt from the right edge (constant: -rightAccessorySpace), leaving room for the update pill and other right-aligned titlebar accessories to be clickable.
Build and test with the Tahoe tabs titlebar style - the update pill should now be fully clickable!
I see - my previous change was reverted. Let me apply a better solution that dynamically calculates the space:
Perfect! Now the code dynamically calculates the space needed for right-aligned accessories:
- When no update is visible (idle state) -
rightAccessorySpacewill be 0 (or just the reset zoom if it's visible), so the tab bar can use the full width - When update pill appears - It calculates the actual width needed based on visible accessories and reserves that space
- Dynamic adjustment - Since
setupTabBar()gets called when the tab bar frame changes, the constraints will be recalculated whenever the update state changes and triggers a layout update
This should make the native tabs work properly while still leaving room for the update pill when it appears!