Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,310 changes: 1,310 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Resources/Localizable.xcstrings

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
public import Foundation

/// Restart to Update hands the app to Sparkle's installer, which swaps the bundle after the app has
/// quit. When the swap fails there is no app on screen to say so: Sparkle reopens the old build,
/// the update stages again and the card offers Restart to Update as if nothing happened (Leo
/// 2026-10-21: "If an installed update ever fails, the app must say why instead of offering
/// Restart again.").
///
/// Just before Sparkle relaunches, the updater records here which build it installs over which.
/// The next launch reads the record once (``consume(defaults:build:now:)``): still on the old
/// build, the install failed, and the staged update card says why (``UpdateInstallFailure``).
public nonisolated struct UpdateInstallCheck: Codable, Equatable, Sendable {
/// The running build when the install began (`CFBundleVersion`).
public var fromBuild: String
/// The build Sparkle installs.
public var targetBuild: String
public var recordedAt: Date

public init(fromBuild: String, targetBuild: String, recordedAt: Date) {
self.fromBuild = fromBuild
self.targetBuild = targetBuild
self.recordedAt = recordedAt
}

/// The defaults key of the record (the app's own domain, so each build channel keeps its own).
public static let key = "cmux.next.updates.installCheck"
/// A record older than this judges nothing: the launch it was written for is long past.
public static let maxAge: TimeInterval = 24 * 60 * 60

/// What this launch says about the install the record was written for.
public enum Outcome: Equatable, Sendable {
/// The relaunch runs the build Sparkle installed.
case installed
/// The relaunch runs the build the install was meant to replace.
case failed
}

/// The outcome for a launch of `build`, or nil when the record says nothing about it: stale,
/// written in the future, a no-op install, or a third build (one installed by hand).
public func outcome(runningBuild build: String, now: Date) -> Outcome? {
let age = now.timeIntervalSince(recordedAt)
guard age >= -60, age <= Self.maxAge, fromBuild != targetBuild else { return nil }
if build == targetBuild { return .installed }
if build == fromBuild { return .failed }
return nil
}

/// Writes `record`, or removes any record (nil: Sparkle did not name the build it installs).
public static func write(_ record: UpdateInstallCheck?, defaults: UserDefaults) {
guard let record, let data = try? JSONEncoder().encode(record) else {
defaults.removeObject(forKey: key)
return
}
defaults.set(data, forKey: key)
}

/// At launch: reads the record once and, when this launch is still the build it was meant to
/// replace, returns the build whose install failed and why (logged either way).
public static func launchFailure(defaults: UserDefaults, build: String, now: Date, bundle: URL = Bundle.main.bundleURL,
log: UpdateLogBuffer) -> (build: String, failure: UpdateInstallFailure)? {
guard let last = consume(defaults: defaults, build: build, now: now) else { return nil }
guard last.outcome == .failed else {
log.append("installed \(last.record.targetBuild) over \(last.record.fromBuild)")
return nil
}
// concurrency-allow: once per launch after a failed install: a path test, two access checks and one volume read.
let failure = UpdateInstallFailure.diagnose(bundle: bundle)
log.append("install of \(last.record.targetBuild) did not replace \(last.record.fromBuild): \(failure)")
return (last.record.targetBuild, failure)
}

/// Reads and clears the record at launch: the record and this launch's outcome, else nil (no
/// record, unreadable, or one that says nothing about this launch).
public static func consume(defaults: UserDefaults, build: String, now: Date = Date())
-> (record: UpdateInstallCheck, outcome: Outcome)? {
let data = defaults.data(forKey: key)
if data != nil { defaults.removeObject(forKey: key) }
guard let record = data.flatMap({ try? JSONDecoder().decode(UpdateInstallCheck.self, from: $0) }),
let outcome = record.outcome(runningBuild: build, now: now) else { return nil }
return (record, outcome)
}
}

/// Why an install left the old build running, read from the file system at the launch after it.
/// Sparkle's installer runs after the app quit and leaves no error the app can read, so these are
/// the causes the app can see for itself; anything else says the installer gave no reason.
public nonisolated enum UpdateInstallFailure: Equatable, Sendable {
/// macOS runs the app from a randomized read-only copy (App Translocation: the app was opened
/// from Downloads or a disk image without being moved), which no installer can replace.
case translocated
/// The app's bundle or its folder is not writable (path of the folder).
case folderNotWritable(String)
/// Free bytes on the app's volume, and the bytes an install needs.
case notEnoughSpace(free: Int64, needed: Int64)
/// None of the above: the installer left the old build and no cause shows.
case notReplaced

/// The cause for the app at `bundle`. Only the place checks of ``UpdateInstallPreflight``
/// apply: Sparkle removes its staged copy whether the install worked or not.
public static func diagnose(bundle: URL, files: FileManager = .default) -> UpdateInstallFailure {
if bundle.path.contains("/AppTranslocation/") { return .translocated }
let preflight = UpdateInstallPreflight(bundle: bundle, updateBytes: nil,
stagedInstallation: UpdateInstallPreflight.stagedInstallation(bundleIdentifier: nil))
switch preflight.placeFailure(files: files) {
case .folderNotWritable(let folder): return .folderNotWritable(folder)
case .notEnoughSpace(let free, let needed): return .notEnoughSpace(free: free, needed: needed)
case .updateMissing, nil: return .notReplaced
}
}

/// The reason the staged update card shows for `staged`: only the build whose install failed
/// carries it; a newer build is a new install, offered as usual.
public static func forStaged(_ staged: String?, failed: (build: String, failure: UpdateInstallFailure)?) -> UpdateInstallFailure? {
guard let failed, failed.build == staged else { return nil }
return failed.failure
}

/// The staged update card's one-line reason.
public var summary: String {
switch self {
case .translocated: UpdaterStrings.installFailedTranslocatedShort
case .folderNotWritable: UpdaterStrings.installFailedFolderShort
case .notEnoughSpace: UpdaterStrings.installFailedSpaceShort
case .notReplaced: UpdaterStrings.installFailedUnknownShort
}
}

/// The full reason and what to do (the card's popover and button help).
public var message: String {
switch self {
case .translocated: UpdaterStrings.installFailedTranslocated
case .folderNotWritable(let folder): UpdaterStrings.installFailedFolder(folder)
case .notEnoughSpace: UpdaterStrings.installFailedSpace
case .notReplaced: UpdaterStrings.installFailedUnknown
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ nonisolated public struct UpdateInstallPreflight: Sendable {

/// The first failed check, or nil when the install may start.
public func failure(files: FileManager = .default) -> Failure? {
if let failure = placeFailure(files: files) { return failure }
if let staged = try? files.contentsOfDirectory(atPath: stagedInstallation.path) {
return staged.contains(where: { !$0.hasPrefix(".") }) ? nil : .updateMissing
}
return files.fileExists(atPath: stagedInstallation.deletingLastPathComponent().path) ? .updateMissing : nil
}

/// The checks of where the app is: its folder is writable and its volume has room. They
/// also explain an install that failed after the app quit (``UpdateInstallFailure``).
public func placeFailure(files: FileManager = .default) -> Failure? {
let folder = bundle.deletingLastPathComponent()
guard files.isWritableFile(atPath: folder.path), files.isWritableFile(atPath: bundle.path) else {
return .folderNotWritable(folder.path)
Expand All @@ -55,10 +65,7 @@ nonisolated public struct UpdateInstallPreflight: Sendable {
let free = values.volumeAvailableCapacityForImportantUsage, free < needed {
return .notEnoughSpace(free: free, needed: needed)
}
if let staged = try? files.contentsOfDirectory(atPath: stagedInstallation.path) {
return staged.contains(where: { !$0.hasPrefix(".") }) ? nil : .updateMissing
}
return files.fileExists(atPath: stagedInstallation.deletingLastPathComponent().path) ? .updateMissing : nil
return nil
}

/// Whether the staged-update check could read Sparkle's folders (false:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ nonisolated public struct UpdateReadyCard: Equatable, Sendable {
public var automaticUpdates: Bool
public var notes: UpdateReadyNotes

/// Why the last install of this build left the old one running, when it did
/// (``UpdateInstallCheck``): the card says so instead of offering Restart to Update again.
public var failure: UpdateInstallFailure?

public init(version: String?, isInstalling: Bool, automaticUpdates: Bool, notes: UpdateReadyNotes,
changelog: UpdateChangelog? = nil) {
changelog: UpdateChangelog? = nil, failure: UpdateInstallFailure? = nil) {
// While the click installs, the card says so in its title too, not only on its disabled button.
title = isInstalling ? UpdaterStrings.installingUpdate : UpdaterStrings.readyToInstall
detail = (changelog ?? version.map { UpdateChangelog(version: $0, date: nil, lines: []) })?.cardDetail
Expand All @@ -34,6 +38,15 @@ nonisolated public struct UpdateReadyCard: Equatable, Sendable {
automaticUpdatesTitle = UpdaterStrings.automaticUpdates
self.automaticUpdates = automaticUpdates
self.notes = notes
// The install of this build failed: the title says it did not install, the detail line
// says why, and the popover (and the button's help) has the full reason and what to do.
self.failure = isInstalling ? nil : failure
if let failure = self.failure {
title = UpdaterStrings.installFailedTitle
detail = failure.summary
buttonTitle = UpdaterStrings.tryAgain
self.notes.headline = failure.message
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ extension UpdaterService: UpdateActionDelegate {
UpdateHarness.mark("will_relaunch")
#endif
recordRelaunchFocus()
UpdateInstallCheck.write(installingBuild.map { .init(fromBuild: identity.build, targetBuild: $0, recordedAt: now()) }, defaults: defaults)
willRelaunch?()
// Only a relaunch the person asked for (a click, or a click that took an automatic install
// over) takes the windows off the screen; its failure and stall paths bring them back. An
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,13 +214,13 @@ extension UpdaterService {
automaticInstallInFlight = false
automaticInstallFrontmost = nil
log.append("automatic install ended without a relaunch (not shown)")
installAbandoned?()
abandonInstall()
case .note where wasInstalling || (wasReady && clickedInstall):
// The install failed: no relaunch comes. The windows come back and the failure's
// sheet opens with Retry and Download Manually, so the click never ends in silence.
// A staged update that ends without a click (a background failure) opens nothing.
log.append("install ended without a relaunch")
installAbandoned?()
abandonInstall()
presentUpdateUI?()
#if DEBUG || CMUX_BENCH_HOOKS
UpdateHarness.mark("failure_sheet_presented")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ extension UpdaterService {
automaticInstallInFlight = false
automaticInstallFrontmost = nil
// The app keeps running: any window the relaunch took off the screen comes back.
installAbandoned?()
abandonInstall()
controller?.restageAfterDeclinedQuit()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,14 @@ extension UpdaterService {
guard !installGaveUp else { return }
installGaveUp = true
log.append("install: no relaunch after \(status.seconds) s; the windows come back")
installAbandoned?()
abandonInstall()
return
}
if status.seconds == Int(UpdateInstallStatus.slowAfter.components.seconds) {
log.append("install: still waiting after \(status.seconds) s")
}
scheduleInstallTick()
}
/// The install ended with this app running: its relaunch record goes (``UpdateInstallCheck``), the windows come back.
func abandonInstall() { UpdateInstallCheck.write(nil, defaults: defaults); installAbandoned?() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ extension UpdaterService {
updateSnooze.hides(version: version, at: now()) { return nil }
let notes = UpdateReadyNotes(version: stagedVersion, notes: stagedNotes, fullNotesURL: stagedReleaseNotesURL)
return UpdateReadyCard(version: stagedVersion, isInstalling: !pill.isEnabled, automaticUpdates: automaticUpdates, notes: notes,
changelog: stagedChangelog)
changelog: stagedChangelog, failure: UpdateInstallFailure.forStaged(stagedBuild(), failed: failedInstall))
}

/// The staged update's pill when the user asked to check while it waits.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ public final class UpdaterService {
public internal(set) var managedChannel: AppChannelSwitchTarget?
/// `MinimumVersion` from the managed policy, or nil.
public internal(set) var managedMinimumVersion: String?
/// Whether the circle shows the last probe's result (the user asked, in a
/// build without Sparkle).
/// Whether the circle shows the last probe's result (the user asked, in a build without Sparkle).
public internal(set) var showsProbeResult = false
/// A fixed circle phase for screenshots (`debug.update_indicator`).
public var debugIndicatorPhase: UpdateIndicatorPhase? {
Expand All @@ -63,8 +62,7 @@ public final class UpdaterService {
/// The app-owned per-PR kill-switch reader; retaining it cancels its observation with the updater.
/// ``startKillSwitchReader()`` stops the one before through this property.
@ObservationIgnored var killSwitchReader: KillSwitchReader?
/// The notice whose timeout is pending, and its one-shot deadline on the
/// injected clock (no `asyncAfter`).
/// The notice whose timeout is pending, and its one-shot deadline on the injected clock (no `asyncAfter`).
@ObservationIgnored var expiringCard: UpdateCard?
@ObservationIgnored let cardTimer: DemandTimer
/// When the install phase began (``installStatus``), nil outside it.
Expand All @@ -88,8 +86,7 @@ public final class UpdaterService {
@ObservationIgnored var releasePushRetry: DemandTimer
/// Opens the changelog page (set by the App; the What's New page's link).
@ObservationIgnored public var openChangelog: (() -> Bool)?
/// Opens the changelog page for the releases after `from` up to `to`
/// (set by the App).
/// Opens the changelog page for the releases after `from` up to `to` (set by the App).
@ObservationIgnored public var openChangelogSpan: ((_ from: String, _ to: String) -> Bool)?
/// Opens a web release notes page in a browser tab (set by the App;
/// "See all changes" when the staged build has no signed notes).
Expand All @@ -106,8 +103,7 @@ public final class UpdaterService {
/// What's New after an update (WHATS-NEW-AFTER-UPDATE): the bundled
/// documents and this feed's nightly digests. The App loads it at launch.
public let whatsNew: WhatsNewCenter
/// Where a staged update is recorded for the next build's What's New
/// (nil: not recorded).
/// Where a staged update is recorded for the next build's What's New (nil: not recorded).
@ObservationIgnored let lastUpdates: WhatsNewLastUpdateStore?
/// The record last written, so an unchanged one is not written again.
@ObservationIgnored var recordedUpdate: WhatsNewLastUpdate?
Expand Down Expand Up @@ -232,6 +228,8 @@ public final class UpdaterService {
@ObservationIgnored var automaticInstallInFlight = false
/// The build Sparkle said it installs (`updaterWillInstallUpdate`): the relaunch's focus record names it.
@ObservationIgnored var installingBuild: String?
/// The build whose install the last relaunch left undone, and why (``UpdateInstallCheck``).
public internal(set) var failedInstall: (build: String, failure: UpdateInstallFailure)?
/// The frontmost app when the automatic install in flight started (``UpdateRelaunchFocus``).
@ObservationIgnored var automaticInstallFrontmost: UpdateFrontmostApp?
/// Whether this app is the frontmost one, and the app that is (``UpdateRelaunchFocus``); AppKit in
Expand Down Expand Up @@ -341,6 +339,7 @@ public final class UpdaterService {
}
restorePinnedTestFeed()
restoreRollbackSkip()
failedInstall = UpdateInstallCheck.launchFailure(defaults: defaults, build: identity.build, now: now(), log: log)
releasePush.onNewerBuild = { [weak self] in self?.releasePushed($0) }
tipState = TipState(defaults: defaults)
// The app settled the install before making the updater (AppServices): an install that ran
Expand Down Expand Up @@ -453,8 +452,7 @@ public final class UpdaterService {
}
}

/// Reads the feed and records what it offers this Mac. Joins a probe
/// already in flight.
/// Reads the feed and records what it offers this Mac. Joins a probe already in flight.
@discardableResult
public func probe() -> Task<String?, Never> {
if let probeTask { return probeTask }
Expand Down Expand Up @@ -592,8 +590,7 @@ public final class UpdaterService {
}

extension UpdaterService {
/// ``switchChannel(to:)`` by name (`stable`, `nightly`); nil means the
/// counterpart of the running app.
/// ``switchChannel(to:)`` by name (`stable`, `nightly`); nil means the counterpart of the running app.
@discardableResult
public func switchChannel(named name: String?) throws -> Task<String?, Never> {
let requested = name.flatMap { AppChannelSwitchTarget(rawValue: $0.lowercased()) }
Expand All @@ -603,8 +600,7 @@ extension UpdaterService {
return try switchChannel(to: target)
}

/// Why switching channels is impossible here (DEV/RC builds have no
/// counterpart), or nil.
/// Why switching channels is impossible here (DEV/RC builds have no counterpart), or nil.
public var channelSwitchUnavailableReason: String? {
guard let target = identity.channelSwitchTarget else {
return UpdaterUnavailable.cannotSwitch(to: "stable/nightly", from: identity.track).description
Expand Down
Loading
Loading