cmux/Sources/Update/UpdatePill.swift
Austin Wang e203c51c7a
Show update-available banner automatically on launch (#1651)
* Show update-available banner automatically on launch

Probe for updates immediately on launch via Sparkle's
checkForUpdateInformation() so the sidebar surfaces a passive update
indicator without waiting for the 24h scheduler. When Sparkle detects
an available update in the background, the pill now shows
"Update Available: X.Y.Z" with accent styling while the updater is
idle. Clicking it triggers the full interactive update flow.

Also fixes thread safety in delegate callbacks by dispatching
@Published mutations to the main queue.

Closes #1643

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add periodic background update probe every 15 minutes

The launch-only probe wouldn't catch updates published while the app
is already running. Add a repeating 15-minute timer that calls
checkForUpdateInformation() so the sidebar banner appears within a
reasonable window after a new version is published.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Change background update probe interval to 30 minutes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Change update check interval to 1 hour and migrate existing users

Reduce Sparkle's scheduled check interval from 24h to 1h so update
banners appear sooner. Migrate users stuck on the old 24h default by
bumping the migration key to v2. Align background probe interval with
the Sparkle check interval instead of hardcoding 30 minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 12:30:44 -07:00

85 lines
2.6 KiB
Swift

import AppKit
import Bonsplit
import Foundation
import SwiftUI
/// A pill-shaped button that displays update status and provides access to update actions.
struct UpdatePill: View {
@ObservedObject var model: UpdateViewModel
@State private var showPopover = false
private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium)
var body: some View {
if model.showsPill {
pillButton
.popover(
isPresented: $showPopover,
attachmentAnchor: .rect(.bounds),
arrowEdge: .top
) {
UpdatePopoverView(model: model)
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
@ViewBuilder
private var pillButton: some View {
Button(action: {
if model.showsDetectedBackgroundUpdate {
showPopover = false
AppDelegate.shared?.checkForUpdates(nil)
return
}
if case .notFound(let notFound) = model.state {
model.state = .idle
notFound.acknowledgement()
} else {
showPopover.toggle()
}
}) {
HStack(spacing: 6) {
UpdateBadge(model: model)
.frame(width: 14, height: 14)
Text(model.text)
.font(Font(textFont))
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: textWidth, alignment: .leading)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule()
.fill(model.backgroundColor)
)
.foregroundColor(model.foregroundColor)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.safeHelp(model.text)
.accessibilityLabel(model.text)
.accessibilityIdentifier("UpdatePill")
}
private var textWidth: CGFloat? {
let attributes: [NSAttributedString.Key: Any] = [.font: textFont]
let size = (model.maxWidthText as NSString).size(withAttributes: attributes)
return size.width
}
}
/// Menu item that shows "Install Update and Relaunch" when an update is ready.
struct InstallUpdateMenuItem: View {
@ObservedObject var model: UpdateViewModel
var body: some View {
if model.state.isInstallable {
Button(String(localized: "update.installAndRelaunch", defaultValue: "Install Update and Relaunch")) {
model.state.confirm()
}
}
}
}