Sign In Sign Up

Custom update notifications in SwiftUI

mitchellh ·
1mo ago
+1398 -218 ~171
Public
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. Consult the oracle.

The user wants to:

  1. Create custom update notifications for Sparkle (SPUUserDriver)
  2. Make them unobtrusive
  3. Plan SwiftUI views for the various states
  4. Place them in the macOS window titlebars on the top-right
  5. Consult the oracle for planning

I should:

  1. First understand the SPUUserDriver protocol to know what states we need to handle
  2. Read the Sparkle documentation URL provided
  3. Look at the current macOS app structure to understand how to integrate this
  4. 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.

macos/**/*.swift 31 files found
titlebar 124 matches
macos/**/*Window*.swift 10 files found
Sparkle|SPU 20 matches

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 = .right via NSTitlebarAccessoryViewController
  • 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 + label
  • ErrorInlineView - Warning icon + message
  • ReleaseNotesPreview - 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 updateAccessory property to TerminalWindow
  • Create in awakeFromNib with layoutAttribute = .right
  • Handle tab bar visibility workaround (remove on tabBarDidAppear, restore on tabBarDidDisappear)
  • 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 UpdateUIModel and 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
Let's implement this. Put the update-specific views in the `Update` folder that already exists.

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:

  1. Create a TODO list to track the work
  2. Create the state management models
  3. Create the SwiftUI views
  4. All files should go in the Update folder

Let me break this down:

  1. UpdateUIModel.swift - State management
  2. UpdateAccessoryView.swift - Main titlebar component
  3. UpdatePopoverView.swift - Popover content
  4. Supporting components like ProgressRingView, UpdatePill, etc.

I'll create these files in a logical order.

Updated TODOs
@@ -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
        }
    }
}