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
2 changes: 2 additions & 0 deletions macos/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,7 @@
"Download and Install" = "Download and Install";
"Downloaded %@ · %@/s" = "Downloaded %@ · %@/s";
"Downloaded (%lld of %lld enabled)" = "Downloaded (%lld of %lld enabled)";
"Downloaded data was kept. Try again to resume from where it stopped." = "Downloaded data was kept. Try again to resume from where it stopped.";
"Downloading %@…" = "Downloading %@…";
"Downloading full stable release…" = "Downloading full stable release…";
"Downloading packages…" = "Downloading packages…";
Expand Down Expand Up @@ -5889,6 +5890,7 @@
"native.downloadPHPSupportFromTheOfficialPluginRelease" = "Download PHP Support from the official plugin release.";
"native.downloadPHPSupportFromTheOfficialSignedPluginPackage" = "Download PHP Support from the official signed plugin package.";
"native.downloadStableRelease" = "Download Stable Release";
"native.downloadedDataWasKeptTryAgainToResumeFromWhereItStopped" = "Downloaded data was kept. Try again to resume from where it stopped.";
"native.downloadedLldOfLldEnabled" = "Downloaded (%1$lld of %2$lld enabled)";
"native.downloadedS" = "Downloaded %1$@ · %2$@/s";
"native.downloading" = "Downloading %1$@…";
Expand Down
2 changes: 2 additions & 0 deletions macos/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,7 @@
"Download and Install" = "下载并安装";
"Downloaded %@ · %@/s" = "已下载 %@ · %@/秒";
"Downloaded (%lld of %lld enabled)" = "已下载(%lld 个,%lld 个已启用)";
"Downloaded data was kept. Try again to resume from where it stopped." = "已下载的部分已保留,重试时会从中断处继续下载。";
"Downloading %@…" = "正在下载 %@…";
"Downloading full stable release…" = "正在下载正式版完整安装包…";
"Downloading packages…" = "正在下载软件包…";
Expand Down Expand Up @@ -5889,6 +5890,7 @@
"native.downloadPHPSupportFromTheOfficialPluginRelease" = "从官方插件版本下载 PHP 支持。";
"native.downloadPHPSupportFromTheOfficialSignedPluginPackage" = "从官方签名的插件包下载 PHP 支持。";
"native.downloadStableRelease" = "下载正式版";
"native.downloadedDataWasKeptTryAgainToResumeFromWhereItStopped" = "已下载的部分已保留,重试时会从中断处继续下载。";
"native.downloadedLldOfLldEnabled" = "已下载(%1$lld 个,%2$lld 个已启用)";
"native.downloadedS" = "已下载 %1$@ · %2$@/秒";
"native.downloading" = "正在下载 %1$@…";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,41 @@ struct StableRollbackPackage: Sendable {
guard (200..<300).contains(response.statusCode) else { throw UpdateCheckError.httpStatus(response.statusCode) }
}

/// Removes staging folders the replacement script left beside `target`.
///
/// A folder holding only `previous.app` belongs to a finished replacement:
/// the running app is its successor, so the recovery copy has served its
/// purpose. A folder still holding `new.app` was never installed and goes once
/// it is a day old. Folders of another app, or with a disk image possibly
/// still mounted, are left alone.
@discardableResult
static func removeLeftoverStaging(beside target: URL, identifier: String,
now: Date = Date()) -> [URL] {
let manager = FileManager.default
let parent = target.resolvingSymlinksInPath().deletingLastPathComponent()
guard let entries = try? manager.contentsOfDirectory(at: parent,
includingPropertiesForKeys: [.contentModificationDateKey], options: []) else { return [] }
var removed: [URL] = []
for root in entries where root.lastPathComponent.hasPrefix(".lithe-stable-") {
let mount = root.appendingPathComponent("mount")
if let contents = try? manager.contentsOfDirectory(atPath: mount.path), !contents.isEmpty { continue }
let previous = root.appendingPathComponent("previous.app")
let staged = root.appendingPathComponent("new.app")
let candidate = manager.fileExists(atPath: previous.path) ? previous : staged
guard let info = NSDictionary(contentsOf: candidate.appendingPathComponent("Contents/Info.plist")),
info["CFBundleIdentifier"] as? String == identifier else { continue }
if candidate == staged {
let modified = (try? root.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
guard let modified, now.timeIntervalSince(modified) > 24 * 60 * 60 else { continue }
}
do {
try manager.removeItem(at: root)
removed.append(root)
} catch { NSLog("Stable rollback leftover cleanup failed: %@", error.localizedDescription) }
}
return removed
}

func discard() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
DispatchQueue.global(qos: .utility).async {
Expand Down Expand Up @@ -341,6 +376,7 @@ struct StableRollbackPackage: Sendable {
"$opener" "$target" || true
exit 1
fi
# Retain previous.app and the log for recovery; user data is never removed.
# Retain previous.app and the log for recovery until the replacement app
# starts; it removes them on launch. User data is never removed.
"""#
}
59 changes: 55 additions & 4 deletions macos/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,25 +181,39 @@ final class UpdateChecker: NSObject, ObservableObject, SPUUpdaterDelegate {
private var userDriver: LitheSparkleUserDriver?
@Published private(set) var updateInfo: UpdateInfo?
private var started = false
/// Sparkle downloads archives through this loopback relay so interrupted
/// downloads resume and failures keep their network cause.
private let downloadRelay: UpdateDownloadRelay?
static let releasePageURL = AppUpdateChannel.stableReleaseURL

init(bundle: Bundle = .main, preferences: UserDefaults = .standard,
diagnosticSink: @escaping @Sendable (String) -> Void = { NSLog("%@", $0) },
sessionFactory: @escaping @Sendable (URLSessionConfiguration) -> MacNetworkSessionLease = {
MacNetworkSessionProvider.makeSession(configuration: $0, plan: nil)
}) {
},
downloadRelay: UpdateDownloadRelay? = nil) {
self.preferences = preferences
selectedChannel = AppUpdateChannel.saved(in: preferences,
defaultChannel: MacAppIdentity(bundleIdentifier: bundle.bundleIdentifier).isDevelopment ? .autobuild : .stable)
// Only the rollback downloader goes through our own session; the
// Sparkle updater keeps its internal network stack, which always
// follows the system proxy.
// Only the rollback downloader goes through our own session. Sparkle and
// its archive relay use the default configuration, which always follows
// the system proxy.
let storage = MacFileStorage(identity: MacAppIdentity(bundleIdentifier: bundle.bundleIdentifier))
self.downloadRelay = downloadRelay ?? UpdateDownloadRelay(store: UpdateDownloadPartialStore(
directory: storage.cacheDirectory().appendingPathComponent("Lithe/UpdateDownloads", isDirectory: true)),
diagnosticSink: diagnosticSink)
stableRollback = MacStableRollback(diagnosticSink: diagnosticSink, sessionFactory: sessionFactory)
self.bundle = bundle
buildIdentity = UpdateBuildIdentity(info: bundle.infoDictionary ?? [:])
currentVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
super.init()
rollbackObservation = stableRollback.objectWillChange.sink { [weak self] in self?.objectWillChange.send() }
if bundle.bundleURL.pathExtension == "app", let identifier = bundle.bundleIdentifier {
let target = bundle.bundleURL
DispatchQueue.global(qos: .utility).async {
StableRollbackPackage.removeLeftoverStaging(beside: target, identifier: identifier)
}
}
}

/// The caller confirms the rolling-build warning before committing consent.
Expand Down Expand Up @@ -256,6 +270,9 @@ final class UpdateChecker: NSObject, ObservableObject, SPUUpdaterDelegate {
driver.installationWaiting = { [weak self] waiting in
self?.installationWaitingForTermination(waiting)
}
driver.downloadFailureMessage = { [weak self] error in
self?.downloadFailure(for: error).map(Self.downloadFailureMessage)
}
driver.downloadProgress = { [weak self] progress in
guard let self, let info = self.updateInfo else { return }
self.status = .downloading(version: info.targetVersion, progress: progress)
Expand Down Expand Up @@ -390,6 +407,24 @@ final class UpdateChecker: NSObject, ObservableObject, SPUUpdaterDelegate {
isChecking = false
isInstalling = true
status = .downloading(version: item.displayVersionString, progress: .initial)
if let url = request.url, let relayed = downloadRelay?.relayURL(for: url) { request.url = relayed }
}

/// The network cause of a failed archive download. The relay's record wins
/// because Sparkle only observed the loopback connection.
func downloadFailure(for error: Error) -> UpdateDownloadFailure? {
let error = error as NSError
guard error.domain == SUSparkleErrorDomain, error.code == Int(SUError.downloadError.rawValue) else { return nil }
if let failure = downloadRelay?.lastFailure { return failure }
guard let underlying = error.userInfo[NSUnderlyingErrorKey] as? Error,
let host = (underlying as? URLError)?.failingURL?.host,
!UpdateEndpointConfiguration.isLoopbackHost(host) else { return nil }
return UpdateDownloadFailure(underlying)
}

static func downloadFailureMessage(_ failure: UpdateDownloadFailure) -> String {
failure.checkError.userMessage + " "
+ L10n.string("Downloaded data was kept. Try again to resume from where it stopped.")
}

func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) {
Expand Down Expand Up @@ -428,6 +463,8 @@ final class UpdateChecker: NSObject, ObservableObject, SPUUpdaterDelegate {
} else if nsError.domain == SUSparkleErrorDomain &&
[SUError.installationCanceledError.rawValue, SUError.installationAuthorizeLaterError.rawValue].contains(Int32(nsError.code)) {
status = .idle
} else if let failure = downloadFailure(for: error) {
status = .failed(code: failure.checkError.code, message: Self.downloadFailureMessage(failure))
} else {
status = .failed(code: .installFailed, message: error.localizedDescription)
}
Expand All @@ -445,6 +482,8 @@ final class LitheSparkleUserDriver: SPUStandardUserDriver {
var presentUpdate: ((SUAppcastItem) -> Void)?
var downloadProgress: ((UpdateDownloadProgress) -> Void)?
var installationWaiting: ((Bool) -> Void)?
/// Replaces Sparkle's generic download error with the observed cause.
var downloadFailureMessage: ((Error) -> String?)?
private var updateReply: ((SPUUserUpdateChoice) -> Void)?
var hasPendingReply: Bool { updateReply != nil }
private var receivedBytes: Int64 = 0
Expand All @@ -463,6 +502,18 @@ final class LitheSparkleUserDriver: SPUStandardUserDriver {
installationWaiting?(!applicationTerminated)
}

override func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) {
guard let message = downloadFailureMessage?(error) else {
return super.showUpdaterError(error, acknowledgement: acknowledgement)
}
let original = error as NSError
let specific = NSError(domain: original.domain, code: original.code, userInfo: [
NSLocalizedDescriptionKey: message,
NSUnderlyingErrorKey: original,
])
super.showUpdaterError(specific, acknowledgement: acknowledgement)
}

override func showDownloadInitiated(cancellation: @escaping () -> Void) {
receivedBytes = 0
expectedBytes = nil
Expand Down
Loading
Loading