cmux/Sources/Update/UpdateDelegate.swift
Lawrence Chen 707be44aaf
Separate cmux NIGHTLY as standalone app with its own bundle ID (#164)
The nightly build is now a distinct app called "cmux NIGHTLY" with
bundle ID com.cmuxterm.app.nightly, allowing side-by-side installation
with the stable release. The nightly appcast URL is baked into the
app's Info.plist by CI, so no in-app channel switching is needed.

- Nightly workflow: rename app to "cmux NIGHTLY", set bundle ID to
  com.cmuxterm.app.nightly, hardcode nightly Sparkle feed URL, publish
  DMG as cmux-nightly-macos.dmg
- Remove "Receive Nightly Builds" toggle from settings
- Remove UpdateChannelSettings enum and simplify feed URL resolution
  to just use SUFeedURL from Info.plist
- Remove UpdateChannelSettingsTests (no longer applicable)
2026-02-20 03:54:07 -08:00

97 lines
4.1 KiB
Swift

import Sparkle
import Cocoa
extension UpdateDriver: SPUUpdaterDelegate {
func feedURLString(for updater: SPUUpdater) -> String? {
#if DEBUG
let env = ProcessInfo.processInfo.environment
if let override = env["CMUX_UI_TEST_FEED_URL"], !override.isEmpty {
UpdateTestURLProtocol.registerIfNeeded()
recordFeedURLString(override, usedFallback: false)
return override
}
#endif
// The feed URL is baked into Info.plist at build time:
// - Stable releases use the stable appcast URL
// - cmux NIGHTLY has the nightly appcast URL injected by CI
let feedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
let isNightly = feedURL?.contains("/nightly/") == true
UpdateLogStore.shared.append("update channel: \(isNightly ? "nightly" : "stable")")
let usedFallback = feedURL == nil || feedURL?.isEmpty == true
recordFeedURLString(feedURL ?? "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml", usedFallback: usedFallback)
return feedURL
}
/// 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.state = .installing(.init(
isAutoUpdate: true,
retryTerminatingApplication: immediateInstallHandler,
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}
))
return true
}
func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
let count = appcast.items.count
let firstVersion = appcast.items.first?.displayVersionString ?? ""
if firstVersion.isEmpty {
UpdateLogStore.shared.append("appcast loaded (items=\(count))")
} else {
UpdateLogStore.shared.append("appcast loaded (items=\(count), first=\(firstVersion))")
}
}
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
let version = item.displayVersionString
let fileURL = item.fileURL?.absoluteString ?? ""
if fileURL.isEmpty {
UpdateLogStore.shared.append("valid update found: \(version)")
} else {
UpdateLogStore.shared.append("valid update found: \(version) (\(fileURL))")
}
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
let nsError = error as NSError
let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue
let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil
let reasonText = reason.map(describeNoUpdateFoundReason) ?? "unknown"
let userInitiated = (nsError.userInfo[SPUNoUpdateFoundUserInitiatedKey] as? NSNumber)?.boolValue ?? false
let latestItem = nsError.userInfo[SPULatestAppcastItemFoundKey] as? SUAppcastItem
let latestVersion = latestItem?.displayVersionString ?? ""
if latestVersion.isEmpty {
UpdateLogStore.shared.append("no update found (reason=\(reasonText), userInitiated=\(userInitiated))")
} else {
UpdateLogStore.shared.append("no update found (reason=\(reasonText), userInitiated=\(userInitiated), latest=\(latestVersion))")
}
}
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
}
}
}
private func describeNoUpdateFoundReason(_ reason: SPUNoUpdateFoundReason) -> String {
switch reason {
case .unknown:
return "unknown"
case .onLatestVersion:
return "onLatestVersion"
case .onNewerThanLatestVersion:
return "onNewerThanLatestVersion"
case .systemIsTooOld:
return "systemIsTooOld"
case .systemIsTooNew:
return "systemIsTooNew"
@unknown default:
return "unknown"
}
}