Merge origin/main into feat-hidden-titlebar-minimalism-reset

This commit is contained in:
Lawrence Chen 2026-03-17 17:37:28 -07:00
commit 90e573b68f
No known key found for this signature in database
116 changed files with 36455 additions and 3357 deletions

View file

@ -3,6 +3,47 @@ import Cocoa
import Combine
import SwiftUI
enum UpdateSettings {
static let automaticChecksKey = "SUEnableAutomaticChecks"
static let automaticallyUpdateKey = "SUAutomaticallyUpdate"
static let scheduledCheckIntervalKey = "SUScheduledCheckInterval"
static let sendProfileInfoKey = "SUSendProfileInfo"
static let migrationKey = "cmux.sparkle.automaticChecksMigration.v1"
static let scheduledCheckInterval: TimeInterval = 60 * 60 * 24
static func apply(to defaults: UserDefaults) {
defaults.register(defaults: [
automaticChecksKey: true,
automaticallyUpdateKey: false,
scheduledCheckIntervalKey: scheduledCheckInterval,
sendProfileInfoKey: false,
])
guard !defaults.bool(forKey: migrationKey) else { return }
// Repair older installs that may have ended up with automatic checks disabled
// before the updater defaults were embedded in Info.plist.
defaults.set(true, forKey: automaticChecksKey)
if let interval = defaults.object(forKey: scheduledCheckIntervalKey) as? NSNumber {
if interval.doubleValue <= 0 {
defaults.set(scheduledCheckInterval, forKey: scheduledCheckIntervalKey)
}
} else {
defaults.set(scheduledCheckInterval, forKey: scheduledCheckIntervalKey)
}
if defaults.object(forKey: automaticallyUpdateKey) == nil {
defaults.set(false, forKey: automaticallyUpdateKey)
}
if defaults.object(forKey: sendProfileInfoKey) == nil {
defaults.set(false, forKey: sendProfileInfoKey)
}
defaults.set(true, forKey: migrationKey)
}
}
/// Controller for managing Sparkle updates in cmux.
class UpdateController {
private(set) var updater: SPUUpdater
@ -27,13 +68,8 @@ class UpdateController {
}
init() {
// Default to manual update checks. This also prevents Sparkle from prompting at startup.
let defaults = UserDefaults.standard
defaults.register(defaults: [
"SUEnableAutomaticChecks": false,
"SUSendProfileInfo": false,
"SUAutomaticallyUpdate": false,
])
UpdateSettings.apply(to: defaults)
let hostBundle = Bundle.main
self.userDriver = UpdateDriver(viewModel: .init(), hostBundle: hostBundle)
@ -59,27 +95,26 @@ class UpdateController {
guard !didStartUpdater else { return }
ensureSparkleInstallationCache()
#if DEBUG
// UI tests need to exercise Sparkle's permission request deterministically.
// Clearing these defaults causes Sparkle to re-request permission on next start.
// Keep the permission-related defaults resettable for UI tests even though the
// delegate now suppresses Sparkle's permission UI entirely.
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_RESET_SPARKLE_PERMISSION"] == "1" {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "SUEnableAutomaticChecks")
defaults.removeObject(forKey: "SUSendProfileInfo")
defaults.removeObject(forKey: "SUAutomaticallyUpdate")
defaults.removeObject(forKey: UpdateSettings.automaticChecksKey)
defaults.removeObject(forKey: UpdateSettings.automaticallyUpdateKey)
defaults.removeObject(forKey: UpdateSettings.scheduledCheckIntervalKey)
defaults.removeObject(forKey: UpdateSettings.sendProfileInfoKey)
defaults.removeObject(forKey: UpdateSettings.migrationKey)
defaults.synchronize()
UpdateLogStore.shared.append("reset sparkle permission defaults (ui test)")
}
#endif
do {
// cmux never enables automatic update checks; we rely on the in-app update pill.
// Sparkle reads these from defaults, but set them explicitly before starting.
let defaults = UserDefaults.standard
defaults.set(false, forKey: "SUEnableAutomaticChecks")
defaults.set(false, forKey: "SUSendProfileInfo")
defaults.set(false, forKey: "SUAutomaticallyUpdate")
try updater.start()
didStartUpdater = true
let interval = Int(updater.updateCheckInterval.rounded())
UpdateLogStore.shared.append(
"updater started (autoChecks=\(updater.automaticallyChecksForUpdates), interval=\(interval)s, autoDownloads=\(updater.automaticallyDownloadsUpdates))"
)
} catch {
userDriver.viewModel.state = .error(.init(
error: error,
@ -201,7 +236,7 @@ class UpdateController {
/// Validate the check for updates menu item.
func validateMenuItem(_ item: NSMenuItem) -> Bool {
if item.action == #selector(checkForUpdates) {
// Always allow user-initiated checks; we start Sparkle lazily on first use.
// Always allow user-initiated checks; Sparkle can safely surface current progress.
return true
}
return true

View file

@ -13,6 +13,10 @@ enum UpdateFeedResolver {
}
extension UpdateDriver: SPUUpdaterDelegate {
func updaterShouldPromptForPermissionToCheck(forUpdates _: SPUUpdater) -> Bool {
false
}
func feedURLString(for updater: SPUUpdater) -> String? {
#if DEBUG
let env = ProcessInfo.processInfo.environment
@ -29,12 +33,21 @@ extension UpdateDriver: SPUUpdaterDelegate {
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeedURL)
UpdateLogStore.shared.append("update channel: \(resolved.isNightly ? "nightly" : "stable")")
recordFeedURLString(resolved.url, usedFallback: resolved.usedFallback)
return infoFeedURL
return resolved.url
}
func updater(_ updater: SPUUpdater, willScheduleUpdateCheckAfterDelay delay: TimeInterval) {
UpdateLogStore.shared.append("next update check scheduled in \(Int(delay.rounded()))s")
}
func updaterWillNotScheduleUpdateCheck(_ updater: SPUUpdater) {
UpdateLogStore.shared.append("automatic update checks disabled; no scheduled check")
}
/// Called when an update is scheduled to install silently,
/// which occurs when automatic download is enabled.
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
viewModel.clearDetectedUpdate()
viewModel.state = .installing(.init(
isAutoUpdate: true,
retryTerminatingApplication: immediateInstallHandler,
@ -56,6 +69,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
viewModel.recordDetectedUpdate(item)
let version = item.displayVersionString
let fileURL = item.fileURL?.absoluteString ?? ""
if fileURL.isEmpty {
@ -66,6 +80,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
viewModel.clearDetectedUpdate()
let nsError = error as NSError
let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue
let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil
@ -80,13 +95,18 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
}
@MainActor
func updater(_ updater: SPUUpdater, userDidMake _: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state _: SPUUserUpdateState) {
viewModel.clearDetectedUpdate()
}
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
Task { @MainActor in
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
}
}
}
}

View file

@ -27,11 +27,11 @@ class UpdateDriver: NSObject, SPUUserDriver {
return
}
#endif
// Never show Sparkle's permission UI. cmux relies on its in-app update pill instead,
// and defaults to manual update checks unless explicitly enabled elsewhere.
UpdateLogStore.shared.append("auto-deny update permission (no UI)")
// Never show Sparkle's permission UI. cmux always enables scheduled checks and keeps
// automatic downloads disabled so installs remain user-driven.
UpdateLogStore.shared.append("auto-allow update permission (no UI)")
DispatchQueue.main.async {
reply(SUUpdatePermissionResponse(automaticUpdateChecks: false, sendSystemProfile: false))
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
}
}

View file

@ -6,6 +6,14 @@ enum UpdateTestSupport {
static func applyIfNeeded(to viewModel: UpdateViewModel) {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_MODE"] == "1" else { return }
if let detectedVersion = env["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"],
!detectedVersion.isEmpty {
DispatchQueue.main.async {
viewModel.detectedUpdateVersion = UpdateViewModel.normalizedDetectedUpdateVersion(from: detectedVersion)
}
}
guard let state = env["CMUX_UI_TEST_UPDATE_STATE"] else { return }
DispatchQueue.main.async {

View file

@ -6,6 +6,7 @@ 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
@ -14,6 +15,14 @@ class UpdateViewModel: ObservableObject {
overrideState ?? state
}
func recordDetectedUpdate(_ item: SUAppcastItem) {
detectedUpdateVersion = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString)
}
func clearDetectedUpdate() {
detectedUpdateVersion = nil
}
var text: String {
#if DEBUG
if let debugOverrideText { return debugOverrideText }
@ -334,6 +343,11 @@ class UpdateViewModel: ObservableObject {
return nil
}
}
static func normalizedDetectedUpdateVersion(from version: String) -> String? {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
enum UpdateState: Equatable {