* 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>
578 lines
22 KiB
Swift
578 lines
22 KiB
Swift
import Foundation
|
||
import AppKit
|
||
import SwiftUI
|
||
import Sparkle
|
||
|
||
class UpdateViewModel: ObservableObject {
|
||
@Published var state: UpdateState = .idle
|
||
@Published var overrideState: UpdateState?
|
||
@Published var detectedUpdateVersion: String?
|
||
#if DEBUG
|
||
@Published var debugOverrideText: String?
|
||
#endif
|
||
|
||
var effectiveState: UpdateState {
|
||
overrideState ?? state
|
||
}
|
||
|
||
var showsDetectedBackgroundUpdate: Bool {
|
||
effectiveState.isIdle && detectedUpdateVersion != nil
|
||
}
|
||
|
||
var showsPill: Bool {
|
||
!effectiveState.isIdle || showsDetectedBackgroundUpdate
|
||
}
|
||
|
||
func recordDetectedUpdate(_ item: SUAppcastItem) {
|
||
detectedUpdateVersion = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString)
|
||
}
|
||
|
||
func clearDetectedUpdate() {
|
||
detectedUpdateVersion = nil
|
||
}
|
||
|
||
var text: String {
|
||
#if DEBUG
|
||
if let debugOverrideText { return debugOverrideText }
|
||
#endif
|
||
if let detectedText = detectedUpdateText {
|
||
return detectedText
|
||
}
|
||
switch effectiveState {
|
||
case .idle:
|
||
return ""
|
||
case .permissionRequest:
|
||
return String(localized: "update.permissionRequest.text", defaultValue: "Enable Automatic Updates?")
|
||
case .checking:
|
||
return String(localized: "update.checking", defaultValue: "Checking for Updates…")
|
||
case .updateAvailable(let update):
|
||
let version = update.appcastItem.displayVersionString
|
||
if !version.isEmpty {
|
||
return String(localized: "update.available.withVersion", defaultValue: "Update Available: \(version)")
|
||
}
|
||
return String(localized: "update.available.short", defaultValue: "Update Available")
|
||
case .downloading(let download):
|
||
if let expectedLength = download.expectedLength, expectedLength > 0 {
|
||
let progress = Double(download.progress) / Double(expectedLength)
|
||
let percent = String(format: "%.0f%%", progress * 100)
|
||
return String(localized: "update.downloading.progress", defaultValue: "Downloading: \(percent)")
|
||
}
|
||
return String(localized: "update.downloading.status", defaultValue: "Downloading…")
|
||
case .extracting(let extracting):
|
||
let percent = String(format: "%.0f%%", extracting.progress * 100)
|
||
return String(localized: "update.extracting.progress", defaultValue: "Preparing: \(percent)")
|
||
case .installing(let install):
|
||
return install.isAutoUpdate ? String(localized: "update.restartToComplete", defaultValue: "Restart to Complete Update") : String(localized: "update.installing.status", defaultValue: "Installing…")
|
||
case .notFound:
|
||
return String(localized: "update.noUpdates.title", defaultValue: "No Updates Available")
|
||
case .error(let err):
|
||
return Self.userFacingErrorTitle(for: err.error)
|
||
}
|
||
}
|
||
|
||
var maxWidthText: String {
|
||
if let detectedText = detectedUpdateText {
|
||
return detectedText
|
||
}
|
||
switch effectiveState {
|
||
case .downloading:
|
||
return "Downloading: 100%"
|
||
case .extracting:
|
||
return "Preparing: 100%"
|
||
default:
|
||
return text
|
||
}
|
||
}
|
||
|
||
var iconName: String? {
|
||
if showsDetectedBackgroundUpdate {
|
||
return "shippingbox.fill"
|
||
}
|
||
switch effectiveState {
|
||
case .idle:
|
||
return nil
|
||
case .permissionRequest:
|
||
return "questionmark.circle"
|
||
case .checking:
|
||
return "arrow.triangle.2.circlepath"
|
||
case .updateAvailable:
|
||
return "shippingbox.fill"
|
||
case .downloading:
|
||
return "arrow.down.circle"
|
||
case .extracting:
|
||
return "shippingbox"
|
||
case .installing:
|
||
return "power.circle"
|
||
case .notFound:
|
||
return "info.circle"
|
||
case .error:
|
||
return "exclamationmark.triangle.fill"
|
||
}
|
||
}
|
||
|
||
var description: String {
|
||
switch effectiveState {
|
||
case .idle:
|
||
return ""
|
||
case .permissionRequest:
|
||
return String(localized: "update.configureAutoUpdates", defaultValue: "Configure automatic update preferences")
|
||
case .checking:
|
||
return String(localized: "update.pleaseWait", defaultValue: "Please wait while we check for available updates")
|
||
case .updateAvailable(let update):
|
||
return update.releaseNotes?.label ?? String(localized: "update.downloadAndInstall", defaultValue: "Download and install the latest version")
|
||
case .downloading:
|
||
return String(localized: "update.downloadingPackage", defaultValue: "Downloading the update package")
|
||
case .extracting:
|
||
return String(localized: "update.preparingUpdate", defaultValue: "Extracting and preparing the update")
|
||
case let .installing(install):
|
||
return install.isAutoUpdate ? String(localized: "update.restartToComplete", defaultValue: "Restart to Complete Update") : String(localized: "update.installingAndRestarting", defaultValue: "Installing update and preparing to restart")
|
||
case .notFound:
|
||
return String(localized: "update.noUpdates.message", defaultValue: "You are running the latest version")
|
||
case .error(let err):
|
||
return Self.userFacingErrorMessage(for: err.error)
|
||
}
|
||
}
|
||
|
||
var badge: String? {
|
||
switch effectiveState {
|
||
case .updateAvailable(let update):
|
||
let version = update.appcastItem.displayVersionString
|
||
return version.isEmpty ? nil : version
|
||
case .downloading(let download):
|
||
if let expectedLength = download.expectedLength, expectedLength > 0 {
|
||
let percentage = Double(download.progress) / Double(expectedLength) * 100
|
||
return String(format: "%.0f%%", percentage)
|
||
}
|
||
return nil
|
||
case .extracting(let extracting):
|
||
return String(format: "%.0f%%", extracting.progress * 100)
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
var iconColor: Color {
|
||
if showsDetectedBackgroundUpdate {
|
||
return cmuxAccentColor()
|
||
}
|
||
switch effectiveState {
|
||
case .idle:
|
||
return .secondary
|
||
case .permissionRequest:
|
||
return .white
|
||
case .checking:
|
||
return .secondary
|
||
case .updateAvailable:
|
||
return cmuxAccentColor()
|
||
case .downloading, .extracting, .installing:
|
||
return .secondary
|
||
case .notFound:
|
||
return .secondary
|
||
case .error:
|
||
return .orange
|
||
}
|
||
}
|
||
|
||
var backgroundColor: Color {
|
||
if showsDetectedBackgroundUpdate {
|
||
return cmuxAccentColor()
|
||
}
|
||
switch effectiveState {
|
||
case .permissionRequest:
|
||
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue)
|
||
case .updateAvailable:
|
||
return cmuxAccentColor()
|
||
case .notFound:
|
||
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue)
|
||
case .error:
|
||
return .orange.opacity(0.2)
|
||
default:
|
||
return Color(nsColor: .controlBackgroundColor)
|
||
}
|
||
}
|
||
|
||
var foregroundColor: Color {
|
||
if showsDetectedBackgroundUpdate {
|
||
return .white
|
||
}
|
||
switch effectiveState {
|
||
case .permissionRequest:
|
||
return .white
|
||
case .updateAvailable:
|
||
return .white
|
||
case .notFound:
|
||
return .white
|
||
case .error:
|
||
return .orange
|
||
default:
|
||
return .primary
|
||
}
|
||
}
|
||
|
||
static func userFacingErrorTitle(for error: Swift.Error) -> String {
|
||
let nsError = error as NSError
|
||
if let networkError = networkError(from: nsError) {
|
||
switch networkError.code {
|
||
case NSURLErrorNotConnectedToInternet:
|
||
return String(localized: "update.error.noInternet.title", defaultValue: "No Internet Connection")
|
||
case NSURLErrorTimedOut:
|
||
return String(localized: "update.error.timedOut.title", defaultValue: "Update Timed Out")
|
||
case NSURLErrorCannotFindHost:
|
||
return String(localized: "update.error.serverNotFound.title", defaultValue: "Server Not Found")
|
||
case NSURLErrorCannotConnectToHost:
|
||
return String(localized: "update.error.serverUnreachable.title", defaultValue: "Server Unreachable")
|
||
case NSURLErrorNetworkConnectionLost:
|
||
return String(localized: "update.error.connectionLost.title", defaultValue: "Connection Lost")
|
||
case NSURLErrorSecureConnectionFailed,
|
||
NSURLErrorServerCertificateUntrusted,
|
||
NSURLErrorServerCertificateHasBadDate,
|
||
NSURLErrorServerCertificateHasUnknownRoot,
|
||
NSURLErrorServerCertificateNotYetValid:
|
||
return String(localized: "update.error.secureConnectionFailed.title", defaultValue: "Secure Connection Failed")
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
if nsError.domain == SUSparkleErrorDomain {
|
||
switch nsError.code {
|
||
case 4005:
|
||
return String(localized: "update.error.permissionError.title", defaultValue: "Updater Permission Error")
|
||
case 2001:
|
||
return String(localized: "update.error.downloadFailed.title", defaultValue: "Couldn't Download Update")
|
||
case 1000, 1002:
|
||
return String(localized: "update.error.feedError.title", defaultValue: "Update Feed Error")
|
||
case 4:
|
||
return String(localized: "update.error.invalidFeed.title", defaultValue: "Invalid Update Feed")
|
||
case 3:
|
||
return String(localized: "update.error.insecureFeed.title", defaultValue: "Insecure Update Feed")
|
||
case 1, 2, 3001, 3002:
|
||
return String(localized: "update.error.signatureError.title", defaultValue: "Update Signature Error")
|
||
case 1003, 1005:
|
||
return String(localized: "update.error.appLocation.title", defaultValue: "App Location Issue")
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
return String(localized: "update.error.failed.title", defaultValue: "Update Failed")
|
||
}
|
||
|
||
static func userFacingErrorMessage(for error: Swift.Error) -> String {
|
||
let nsError = error as NSError
|
||
if let networkError = networkError(from: nsError) {
|
||
switch networkError.code {
|
||
case NSURLErrorNotConnectedToInternet:
|
||
return String(localized: "update.error.noInternet.message", defaultValue: "cmux can’t reach the update server. Check your internet connection and try again.")
|
||
case NSURLErrorTimedOut:
|
||
return String(localized: "update.error.timedOut.message", defaultValue: "The update server took too long to respond. Try again in a moment.")
|
||
case NSURLErrorCannotFindHost:
|
||
return String(localized: "update.error.serverNotFound.message", defaultValue: "The update server can’t be found. Check your connection or try again later.")
|
||
case NSURLErrorCannotConnectToHost:
|
||
return String(localized: "update.error.serverUnreachable.message", defaultValue: "cmux couldn’t connect to the update server. Check your connection or try again later.")
|
||
case NSURLErrorNetworkConnectionLost:
|
||
return String(localized: "update.error.connectionLost.message", defaultValue: "The network connection was lost while checking for updates. Try again.")
|
||
case NSURLErrorSecureConnectionFailed,
|
||
NSURLErrorServerCertificateUntrusted,
|
||
NSURLErrorServerCertificateHasBadDate,
|
||
NSURLErrorServerCertificateHasUnknownRoot,
|
||
NSURLErrorServerCertificateNotYetValid:
|
||
return String(localized: "update.error.secureConnectionFailed.message", defaultValue: "A secure connection to the update server couldn’t be established. Try again later.")
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
if nsError.domain == SUSparkleErrorDomain {
|
||
switch nsError.code {
|
||
case 2001:
|
||
return String(localized: "update.error.feedDownload.message", defaultValue: "cmux couldn't download the update feed. Check your connection and try again.")
|
||
case 1000, 1002:
|
||
return String(localized: "update.error.feedRead.message", defaultValue: "The update feed could not be read. Please try again later.")
|
||
case 4:
|
||
return String(localized: "update.error.invalidFeed.message", defaultValue: "The update feed URL is invalid. Please contact support.")
|
||
case 3:
|
||
return String(localized: "update.error.insecureFeed.message", defaultValue: "The update feed is insecure. Please contact support.")
|
||
case 1, 2, 3001, 3002:
|
||
return String(localized: "update.error.signatureError.message", defaultValue: "The update's signature could not be verified. Please try again later.")
|
||
case 1003, 1005, 4005:
|
||
return String(localized: "update.error.permissionError.message", defaultValue: "Move cmux into Applications and relaunch to enable updates.")
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
return nsError.localizedDescription
|
||
}
|
||
|
||
static func errorDetails(for error: Swift.Error, technicalDetails: String?, feedURLString: String?) -> String {
|
||
let nsError = error as NSError
|
||
var lines: [String] = []
|
||
lines.append("Message: \(nsError.localizedDescription)")
|
||
lines.append("Domain: \(nsError.domain)")
|
||
if nsError.domain == SUSparkleErrorDomain,
|
||
let sparkleName = sparkleErrorCodeName(for: nsError.code) {
|
||
lines.append("Code: \(sparkleName) (\(nsError.code))")
|
||
} else {
|
||
lines.append("Code: \(nsError.code)")
|
||
}
|
||
|
||
if let url = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL {
|
||
lines.append("URL: \(url.absoluteString)")
|
||
} else if let urlString = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String {
|
||
lines.append("URL: \(urlString)")
|
||
}
|
||
|
||
if let failure = nsError.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
|
||
!failure.isEmpty {
|
||
lines.append("Failure: \(failure)")
|
||
}
|
||
if let recovery = nsError.userInfo[NSLocalizedRecoverySuggestionErrorKey] as? String,
|
||
!recovery.isEmpty {
|
||
lines.append("Recovery: \(recovery)")
|
||
}
|
||
|
||
if let feedURLString, !feedURLString.isEmpty {
|
||
lines.append("Feed: \(feedURLString)")
|
||
}
|
||
|
||
if let technicalDetails, !technicalDetails.isEmpty {
|
||
lines.append("Debug: \(technicalDetails)")
|
||
}
|
||
|
||
lines.append("Log: \(UpdateLogStore.shared.logPath())")
|
||
return lines.joined(separator: "\n")
|
||
}
|
||
|
||
private static func networkError(from error: NSError) -> NSError? {
|
||
if error.domain == NSURLErrorDomain {
|
||
return error
|
||
}
|
||
if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError,
|
||
underlying.domain == NSURLErrorDomain {
|
||
return underlying
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static func sparkleErrorCodeName(for code: Int) -> String? {
|
||
switch code {
|
||
case 1: return "SUNoPublicDSAFoundError"
|
||
case 2: return "SUInsufficientSigningError"
|
||
case 3: return "SUInsecureFeedURLError"
|
||
case 4: return "SUInvalidFeedURLError"
|
||
case 1000: return "SUAppcastParseError"
|
||
case 1001: return "SUNoUpdateError"
|
||
case 1002: return "SUAppcastError"
|
||
case 1003: return "SURunningFromDiskImageError"
|
||
case 1005: return "SURunningTranslocated"
|
||
case 2001: return "SUDownloadError"
|
||
case 3001: return "SUSignatureError"
|
||
case 3002: return "SUValidationError"
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
static func normalizedDetectedUpdateVersion(from version: String) -> String? {
|
||
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return trimmed.isEmpty ? nil : trimmed
|
||
}
|
||
|
||
private var detectedUpdateText: String? {
|
||
guard showsDetectedBackgroundUpdate, let version = detectedUpdateVersion else { return nil }
|
||
return String(localized: "update.available.withVersion", defaultValue: "Update Available: \(version)")
|
||
}
|
||
}
|
||
|
||
enum UpdateState: Equatable {
|
||
case idle
|
||
case permissionRequest(PermissionRequest)
|
||
case checking(Checking)
|
||
case updateAvailable(UpdateAvailable)
|
||
case notFound(NotFound)
|
||
case error(Error)
|
||
case downloading(Downloading)
|
||
case extracting(Extracting)
|
||
case installing(Installing)
|
||
|
||
var isIdle: Bool {
|
||
if case .idle = self { return true }
|
||
return false
|
||
}
|
||
|
||
var isInstallable: Bool {
|
||
switch self {
|
||
case .checking,
|
||
.updateAvailable,
|
||
.downloading,
|
||
.extracting,
|
||
.installing:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func cancel() {
|
||
switch self {
|
||
case .checking(let checking):
|
||
checking.cancel()
|
||
case .updateAvailable(let available):
|
||
available.reply(.dismiss)
|
||
case .downloading(let downloading):
|
||
downloading.cancel()
|
||
case .notFound(let notFound):
|
||
notFound.acknowledgement()
|
||
case .error(let err):
|
||
err.dismiss()
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
func confirm() {
|
||
switch self {
|
||
case .updateAvailable(let available):
|
||
available.reply(.install)
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
static func == (lhs: UpdateState, rhs: UpdateState) -> Bool {
|
||
switch (lhs, rhs) {
|
||
case (.idle, .idle):
|
||
return true
|
||
case (.permissionRequest, .permissionRequest):
|
||
return true
|
||
case (.checking, .checking):
|
||
return true
|
||
case (.updateAvailable(let lUpdate), .updateAvailable(let rUpdate)):
|
||
return lUpdate.appcastItem.displayVersionString == rUpdate.appcastItem.displayVersionString
|
||
case (.notFound, .notFound):
|
||
return true
|
||
case (.error(let lErr), .error(let rErr)):
|
||
return lErr.error.localizedDescription == rErr.error.localizedDescription
|
||
case (.downloading(let lDown), .downloading(let rDown)):
|
||
return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength
|
||
case (.extracting(let lExt), .extracting(let rExt)):
|
||
return lExt.progress == rExt.progress
|
||
case (.installing(let lInstall), .installing(let rInstall)):
|
||
return lInstall.isAutoUpdate == rInstall.isAutoUpdate
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
struct NotFound {
|
||
let acknowledgement: () -> Void
|
||
}
|
||
|
||
struct PermissionRequest {
|
||
let request: SPUUpdatePermissionRequest
|
||
let reply: @Sendable (SUUpdatePermissionResponse) -> Void
|
||
}
|
||
|
||
struct Checking {
|
||
let cancel: () -> Void
|
||
}
|
||
|
||
struct UpdateAvailable {
|
||
let appcastItem: SUAppcastItem
|
||
let reply: @Sendable (SPUUserUpdateChoice) -> Void
|
||
|
||
var releaseNotes: ReleaseNotes? {
|
||
ReleaseNotes(displayVersionString: appcastItem.displayVersionString)
|
||
}
|
||
}
|
||
|
||
enum ReleaseNotes {
|
||
case commit(URL)
|
||
case tagged(URL)
|
||
|
||
init?(displayVersionString: String) {
|
||
let version = displayVersionString
|
||
|
||
if let semver = Self.extractSemanticVersion(from: version) {
|
||
let tag = semver.hasPrefix("v") ? semver : "v\(semver)"
|
||
if let url = URL(string: "https://github.com/manaflow-ai/cmux/releases/tag/\(tag)") {
|
||
self = .tagged(url)
|
||
return
|
||
}
|
||
}
|
||
|
||
guard let newHash = Self.extractGitHash(from: version) else {
|
||
return nil
|
||
}
|
||
|
||
if let url = URL(string: "https://github.com/manaflow-ai/cmux/commit/\(newHash)") {
|
||
self = .commit(url)
|
||
} else {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private static func extractSemanticVersion(from version: String) -> String? {
|
||
let pattern = #"v?\d+\.\d+\.\d+"#
|
||
if let range = version.range(of: pattern, options: .regularExpression) {
|
||
return String(version[range])
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static func extractGitHash(from version: String) -> String? {
|
||
let pattern = #"[0-9a-f]{7,40}"#
|
||
if let range = version.range(of: pattern, options: .regularExpression) {
|
||
return String(version[range])
|
||
}
|
||
return nil
|
||
}
|
||
|
||
var url: URL {
|
||
switch self {
|
||
case .commit(let url): return url
|
||
case .tagged(let url): return url
|
||
}
|
||
}
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .commit: return String(localized: "update.viewGitHubCommit", defaultValue: "View GitHub Commit")
|
||
case .tagged: return String(localized: "update.viewReleaseNotes", defaultValue: "View Release Notes")
|
||
}
|
||
}
|
||
}
|
||
|
||
struct Error {
|
||
let error: any Swift.Error
|
||
let retry: () -> Void
|
||
let dismiss: () -> Void
|
||
let technicalDetails: String?
|
||
let feedURLString: String?
|
||
|
||
init(error: any Swift.Error,
|
||
retry: @escaping () -> Void,
|
||
dismiss: @escaping () -> Void,
|
||
technicalDetails: String? = nil,
|
||
feedURLString: String? = nil) {
|
||
self.error = error
|
||
self.retry = retry
|
||
self.dismiss = dismiss
|
||
self.technicalDetails = technicalDetails
|
||
self.feedURLString = feedURLString
|
||
}
|
||
}
|
||
|
||
struct Downloading {
|
||
let cancel: () -> Void
|
||
let expectedLength: UInt64?
|
||
let progress: UInt64
|
||
}
|
||
|
||
struct Extracting {
|
||
let progress: Double
|
||
}
|
||
|
||
struct Installing {
|
||
var isAutoUpdate = false
|
||
let retryTerminatingApplication: () -> Void
|
||
let dismiss: () -> Void
|
||
}
|
||
}
|