Merge origin/main into feat-hidden-titlebar-minimalism-reset
This commit is contained in:
commit
90e573b68f
116 changed files with 36455 additions and 3357 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue