diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 0c27994274..10b25f47f8 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -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…"; @@ -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$@…"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 0ab83aa705..4e07123d2e 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -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…" = "正在下载软件包…"; @@ -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$@…"; diff --git a/macos/Sources/Lithe/Platform/MacOS/Updates/MacStableRollback.swift b/macos/Sources/Lithe/Platform/MacOS/Updates/MacStableRollback.swift index 77e46cb936..95dd6cd5a4 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Updates/MacStableRollback.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Updates/MacStableRollback.swift @@ -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) in DispatchQueue.global(qos: .utility).async { @@ -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. """# } diff --git a/macos/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift b/macos/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift index b8743a5f1d..e8cf706de3 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift @@ -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. @@ -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) @@ -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) { @@ -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) } @@ -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 @@ -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 diff --git a/macos/Sources/Lithe/Platform/MacOS/Updates/UpdateDownloadRelay.swift b/macos/Sources/Lithe/Platform/MacOS/Updates/UpdateDownloadRelay.swift new file mode 100644 index 0000000000..a66e847000 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Updates/UpdateDownloadRelay.swift @@ -0,0 +1,666 @@ +import CryptoKit +import Darwin +import Foundation + +/// Why the upstream update download stopped. Sparkle only sees the loopback +/// connection, so the relay keeps the real cause for the error message. +enum UpdateDownloadFailure: Equatable, Sendable { + case timedOut + case connectionFailed + case tlsOrProxyFailure + case httpStatus(Int) + case invalidResponse + + init?(_ error: Error) { + guard let error = error as? URLError else { return nil } + switch error.code { + case .timedOut: + self = .timedOut + case .secureConnectionFailed, .serverCertificateHasBadDate, .serverCertificateUntrusted, + .serverCertificateHasUnknownRoot, .serverCertificateNotYetValid, + .clientCertificateRejected, .clientCertificateRequired: + self = .tlsOrProxyFailure + case .cannotFindHost, .cannotConnectToHost, .networkConnectionLost, .dnsLookupFailed, + .notConnectedToInternet, .internationalRoamingOff, .dataNotAllowed, + .cannotLoadFromNetwork, .callIsActive: + self = .connectionFailed + default: + return nil + } + } + + var checkError: UpdateCheckError { + switch self { + case .timedOut: return .timedOut + case .connectionFailed: return .connectionFailed + case .tlsOrProxyFailure: return .tlsOrProxyFailure + case .httpStatus(let status): return .httpStatus(status) + case .invalidResponse: return .invalidResponse + } + } + + /// Server errors and rate limits are usually temporary; other client + /// errors (404, 403) will not change by asking again. + var isRetryable: Bool { + switch self { + case .httpStatus(let status): return status == 408 || status == 429 || status >= 500 + case .invalidResponse: return false + default: return true + } + } +} + +/// Partial update archives kept between attempts. +/// +/// Lifecycle: one `.part` file plus `.json` validator per upstream URL under the +/// platform cache directory (`/Lithe/UpdateDownloads`). A completed +/// transfer deletes its pair; registering a new download prunes entries older +/// than `maximumAge` and keeps at most `maximumEntries`. Nothing is written into +/// the app bundle, so code signing and Sparkle delta baselines are unaffected. +struct UpdateDownloadPartialStore: Sendable { + struct Metadata: Codable, Equatable, Sendable { + var url: String + var validator: String? + var totalBytes: Int64? + var contentType: String? + var contentDisposition: String? + } + + struct Entry: Sendable { + let data: URL + let metadata: URL + } + + let directory: URL + var maximumAge: TimeInterval = 7 * 24 * 60 * 60 + var maximumEntries = 3 + + static func key(for url: URL) -> String { + SHA256.hash(data: Data(url.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined() + } + + func entry(for url: URL) -> Entry { + let key = Self.key(for: url) + return Entry(data: directory.appendingPathComponent("\(key).part"), + metadata: directory.appendingPathComponent("\(key).json")) + } + + func prepareDirectory() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + } + + /// Removes abandoned archives, never the entry about to be downloaded. + func prune(keeping url: URL, now: Date = Date()) { + let manager = FileManager.default + guard let files = try? manager.contentsOfDirectory(at: directory, + includingPropertiesForKeys: [.contentModificationDateKey], options: .skipsHiddenFiles) else { return } + let keep = Self.key(for: url) + var groups: [String: Date] = [:] + for file in files where ["part", "json"].contains(file.pathExtension) { + let key = file.deletingPathExtension().lastPathComponent + guard key != keep else { continue } + let modified = (try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast + groups[key] = max(groups[key] ?? .distantPast, modified) + } + let ordered = groups.sorted { $0.value > $1.value } + for (index, group) in ordered.enumerated() + where index >= maximumEntries - 1 || now.timeIntervalSince(group.value) > maximumAge { + try? manager.removeItem(at: directory.appendingPathComponent("\(group.key).part")) + try? manager.removeItem(at: directory.appendingPathComponent("\(group.key).json")) + } + } +} + +/// Destination of relayed bytes: the loopback socket in production, a buffer in tests. +protocol UpdateRelayClient: AnyObject, Sendable { + /// Returns false once the reader is gone. + func send(_ data: Data) -> Bool + func close() +} + +/// One loopback request: streams the stored prefix, then resumes the upstream +/// download with `Range`/`If-Range`, retrying transient failures while the +/// client is still inside its own idle timeout. +/// +/// All mutable state is confined to `queue`; URLSession delivers delegate +/// callbacks on the same queue. +final class UpdateDownloadTransfer: NSObject, URLSessionDataDelegate, @unchecked Sendable { + struct Policy: Sendable { + /// Sparkle's loopback request fails after 60 seconds without data; give + /// up first so the next attempt starts promptly and resumes. + var noProgressBudget: TimeInterval = 45 + var retryDelays: [TimeInterval] = [1, 2, 4, 8] + var requestTimeout: TimeInterval = 15 + } + + enum Outcome: Equatable, Sendable { + case completed + case failed(UpdateDownloadFailure?) + case cancelled + } + + typealias Scheduler = @Sendable (TimeInterval, DispatchQueue, @escaping @Sendable () -> Void) -> Void + + static let defaultScheduler: Scheduler = { delay, queue, work in + queue.asyncAfter(deadline: .now() + delay, execute: work) + } + + private let upstream: URL + private let userAgent: String? + private let entry: UpdateDownloadPartialStore.Entry + private let client: UpdateRelayClient + private let policy: Policy + private let now: @Sendable () -> TimeInterval + private let schedule: Scheduler + private let finished: @Sendable (Outcome, UpdateDownloadFailure?) -> Void + let queue = DispatchQueue(label: "app.lithe.updates.relay.transfer") + private var session: URLSession? + private var task: URLSessionDataTask? + private var file: FileHandle? + private var metadata: UpdateDownloadPartialStore.Metadata + private var storedBytes: Int64 = 0 + private var sentBytes: Int64 = 0 + private var headersSent = false + private var chunkedResponse = false + private var failures = 0 + private var lastProgress: TimeInterval = 0 + private var lastFailure: UpdateDownloadFailure? + private var done = false + private var afterCancel: (() -> Void)? + + init(upstream: URL, userAgent: String?, entry: UpdateDownloadPartialStore.Entry, + client: UpdateRelayClient, configuration: URLSessionConfiguration, policy: Policy = Policy(), + now: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + schedule: @escaping Scheduler = UpdateDownloadTransfer.defaultScheduler, + finished: @escaping @Sendable (Outcome, UpdateDownloadFailure?) -> Void) { + self.upstream = upstream + self.userAgent = userAgent + self.entry = entry + self.client = client + self.policy = policy + self.now = now + self.schedule = schedule + self.finished = finished + metadata = .init(url: upstream.absoluteString) + super.init() + let operations = OperationQueue() + operations.maxConcurrentOperationCount = 1 + operations.underlyingQueue = queue + let configuration = configuration.copy() as! URLSessionConfiguration + configuration.timeoutIntervalForRequest = policy.requestTimeout + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + session = URLSession(configuration: configuration, delegate: self, delegateQueue: operations) + } + + func start() { + queue.async { [self] in + lastProgress = now() + do { + if let data = try? Data(contentsOf: entry.metadata), + let saved = try? JSONDecoder().decode(UpdateDownloadPartialStore.Metadata.self, from: data), + saved.url == upstream.absoluteString, saved.validator != nil { + metadata = saved + } else { + try? FileManager.default.removeItem(at: entry.data) + } + if !FileManager.default.fileExists(atPath: entry.data.path) { + FileManager.default.createFile(atPath: entry.data.path, contents: nil, + attributes: [.posixPermissions: 0o600]) + } + let handle = try FileHandle(forUpdating: entry.data) + storedBytes = Int64(try handle.seekToEnd()) + if let total = metadata.totalBytes, storedBytes > total { try truncate(handle) } + file = handle + } catch { + finish(.failed(nil)) + return + } + request() + } + } + + /// Stops the transfer and keeps the partial file. Safe from any queue except `queue`. + func cancel() { + queue.sync { if !done { finish(.cancelled) } } + } + + private func request() { + guard !done else { return } + var request = URLRequest(url: upstream, cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: policy.requestTimeout) + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + if let userAgent { request.setValue(userAgent, forHTTPHeaderField: "User-Agent") } + if storedBytes > 0 { + guard let validator = metadata.validator else { + guard sentBytes == 0, (try? file.map(truncate)) != nil else { return finish(.failed(lastFailure)) } + return self.request() + } + request.setValue("bytes=\(storedBytes)-", forHTTPHeaderField: "Range") + request.setValue(validator, forHTTPHeaderField: "If-Range") + } + task = session?.dataTask(with: request) + task?.resume() + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void) { + guard !done, dataTask === task else { return completionHandler(.cancel) } + guard let http = response as? HTTPURLResponse else { + lastFailure = .invalidResponse + return cancelTask(completionHandler) { self.finish(.failed(self.lastFailure)) } + } + if let encoding = http.value(forHTTPHeaderField: "Content-Encoding"), + encoding.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != "identity" { + lastFailure = .invalidResponse + return cancelTask(completionHandler) { self.finish(.failed(self.lastFailure)) } + } + var total: Int64? + switch http.statusCode { + case 206: + guard let range = Self.contentRange(http.value(forHTTPHeaderField: "Content-Range")), + range.start == storedBytes else { + // The server ignored our offset; restart only if nothing was sent yet. + return cancelTask(completionHandler) { self.restartFromZero() } + } + // Some CDNs (GitHub release assets among them) answer 206 even when If-Range + // no longer matches, so a re-uploaded archive must be caught here. + if Self.validator(of: http) != metadata.validator { + guard sentBytes == 0 else { + try? file.map(truncate) + lastFailure = .invalidResponse + return cancelTask(completionHandler) { self.finish(.failed(self.lastFailure)) } + } + return cancelTask(completionHandler) { self.restartFromZero() } + } + total = range.total + case 200..<300: + if storedBytes > 0 { + // A full response means the validator no longer matches. + guard sentBytes == 0, (try? file.map(truncate)) != nil else { + try? file.map(truncate) + lastFailure = .invalidResponse + return cancelTask(completionHandler) { self.finish(.failed(self.lastFailure)) } + } + } + total = http.expectedContentLength >= 0 ? storedBytes + http.expectedContentLength : nil + case 416: + return cancelTask(completionHandler) { self.restartFromZero() } + default: + let failure = UpdateDownloadFailure.httpStatus(http.statusCode) + lastFailure = failure + return cancelTask(completionHandler) { + if failure.isRetryable { self.retry() } else { self.finish(.failed(failure)) } + } + } + if headersSent, let previousTotal = metadata.totalBytes, total != previousTotal { + lastFailure = .invalidResponse + return cancelTask(completionHandler) { self.finish(.failed(self.lastFailure)) } + } + metadata.validator = Self.validator(of: http) + metadata.totalBytes = total + if let type = http.value(forHTTPHeaderField: "Content-Type") { metadata.contentType = type } + if let disposition = http.value(forHTTPHeaderField: "Content-Disposition") { metadata.contentDisposition = disposition } + if let data = try? JSONEncoder().encode(metadata) { try? data.write(to: entry.metadata, options: .atomic) } + if !headersSent { + guard sendHeaders(), replayStoredBytes() else { + return cancelTask(completionHandler) { self.finish(.cancelled) } + } + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard !done, dataTask === task, let file else { return } + do { try file.write(contentsOf: data) } catch { + dataTask.cancel() + return finish(.failed(nil)) + } + storedBytes += Int64(data.count) + lastProgress = now() + failures = 0 + guard sendBody(data) else { + dataTask.cancel() + return finish(.cancelled) + } + sentBytes += Int64(data.count) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard !done, task === self.task else { return } + self.task = nil + if let afterCancel { + self.afterCancel = nil + return afterCancel() + } + if error == nil { + if let total = metadata.totalBytes, storedBytes < total { + lastFailure = .connectionFailed + return retry() + } + return finish(.completed) + } + let failure = error.flatMap(UpdateDownloadFailure.init) ?? .connectionFailed + lastFailure = failure + failure.isRetryable ? retry() : finish(.failed(failure)) + } + + private func cancelTask(_ completionHandler: @Sendable (URLSession.ResponseDisposition) -> Void, + then next: @escaping () -> Void) { + afterCancel = next + completionHandler(.cancel) + } + + private func restartFromZero() { + guard sentBytes == 0, (try? file.map(truncate)) != nil else { + lastFailure = .invalidResponse + return finish(.failed(lastFailure)) + } + failures += 1 + failures > policy.retryDelays.count ? finish(.failed(lastFailure ?? .invalidResponse)) : request() + } + + private func retry() { + failures += 1 + let delay = policy.retryDelays[min(failures, policy.retryDelays.count) - 1] + guard failures <= policy.retryDelays.count, + now() - lastProgress + delay + policy.requestTimeout <= policy.noProgressBudget else { + return finish(.failed(lastFailure)) + } + schedule(delay, queue) { [self] in request() } + } + + private func truncate(_ handle: FileHandle) throws { + try handle.truncate(atOffset: 0) + storedBytes = 0 + metadata = .init(url: upstream.absoluteString) + try? FileManager.default.removeItem(at: entry.metadata) + } + + private func sendHeaders() -> Bool { + var lines = ["HTTP/1.1 200 OK", "Connection: close", "Cache-Control: no-store"] + if let total = metadata.totalBytes { lines.append("Content-Length: \(total)") } + else { chunkedResponse = true; lines.append("Transfer-Encoding: chunked") } + lines.append("Content-Type: \(metadata.contentType ?? "application/octet-stream")") + if let disposition = metadata.contentDisposition { lines.append("Content-Disposition: \(disposition)") } + headersSent = true + return client.send(Data((lines.joined(separator: "\r\n") + "\r\n\r\n").utf8)) + } + + private func sendBody(_ data: Data) -> Bool { + guard chunkedResponse else { return client.send(data) } + guard !data.isEmpty else { return true } + return client.send(Data("\(String(data.count, radix: 16))\r\n".utf8)) + && client.send(data) && client.send(Data("\r\n".utf8)) + } + + private func replayStoredBytes() -> Bool { + guard storedBytes > 0, let file else { return true } + do { + try file.seek(toOffset: 0) + while sentBytes < storedBytes { + let count = Int(min(storedBytes - sentBytes, 1 << 18)) + guard let chunk = try file.read(upToCount: count), !chunk.isEmpty else { return false } + guard sendBody(chunk) else { return false } + sentBytes += Int64(chunk.count) + } + try file.seekToEnd() + return true + } catch { return false } + } + + private func finish(_ outcome: Outcome) { + guard !done else { return } + done = true + task?.cancel() + task = nil + try? file?.close() + file = nil + if outcome == .completed { + if chunkedResponse { _ = client.send(Data("0\r\n\r\n".utf8)) } + try? FileManager.default.removeItem(at: entry.data) + try? FileManager.default.removeItem(at: entry.metadata) + } + session?.invalidateAndCancel() + session = nil + // Record the cause before the client observes the closed connection. + finished(outcome, outcome == .completed ? nil : lastFailure) + client.close() + } + + static func validator(of response: HTTPURLResponse) -> String? { + response.value(forHTTPHeaderField: "ETag") ?? response.value(forHTTPHeaderField: "Last-Modified") + } + + static func contentRange(_ value: String?) -> (start: Int64, total: Int64?)? { + guard let value, value.lowercased().hasPrefix("bytes ") else { return nil } + let spec = value.dropFirst(6).split(separator: "/", maxSplits: 1) + guard spec.count == 2, let start = spec[0].split(separator: "-").first.flatMap({ Int64($0) }) else { return nil } + return (start, Int64(spec[1])) + } +} + +/// Serves Sparkle's update archive downloads from 127.0.0.1 so interrupted +/// downloads resume instead of restarting, and so failures keep their cause. +/// +/// Each registration gets an unguessable path token; the listener binds only to +/// the loopback interface and answers unknown paths with 404. +final class UpdateDownloadRelay: @unchecked Sendable { + private let store: UpdateDownloadPartialStore + private let configuration: URLSessionConfiguration + private let policy: UpdateDownloadTransfer.Policy + private let diagnosticSink: @Sendable (String) -> Void + private let lock = NSLock() + private let acceptQueue = DispatchQueue(label: "app.lithe.updates.relay.accept") + private var listener: Int32 = -1 + private var port: UInt16 = 0 + private var acceptSource: DispatchSourceRead? + private var routes: [(token: String, upstream: URL)] = [] + private var transfers: [String: (id: UUID, transfer: UpdateDownloadTransfer)] = [:] + private var failure: UpdateDownloadFailure? + + init(store: UpdateDownloadPartialStore, configuration: URLSessionConfiguration = .default, + policy: UpdateDownloadTransfer.Policy = .init(), + diagnosticSink: @escaping @Sendable (String) -> Void = { NSLog("%@", $0) }) { + self.store = store + self.configuration = configuration + self.policy = policy + self.diagnosticSink = diagnosticSink + } + + deinit { stop() } + + /// The cause of the most recent unfinished relayed download, if any. + var lastFailure: UpdateDownloadFailure? { lock.withLock { failure } } + + /// Returns the loopback URL Sparkle should download instead, or nil when + /// the relay cannot listen and Sparkle should download directly. + func relayURL(for upstream: URL) -> URL? { + guard ["http", "https"].contains(upstream.scheme?.lowercased() ?? "") else { return nil } + do { + try store.prepareDirectory() + store.prune(keeping: upstream) + let port = try listen() + var bytes = [UInt8](repeating: 0, count: 16) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { return nil } + let token = bytes.map { String(format: "%02x", $0) }.joined() + lock.withLock { + failure = nil + routes.append((token, upstream)) + if routes.count > 8 { routes.removeFirst(routes.count - 8) } + } + let name = upstream.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "update" + return URL(string: "http://127.0.0.1:\(port)/\(token)/\(name)") + } catch { + diagnosticSink("Update download relay unavailable: \(error.localizedDescription)\n") + return nil + } + } + + func stop() { + let (source, active) = lock.withLock { () -> (DispatchSourceRead?, [UpdateDownloadTransfer]) in + let source = acceptSource + acceptSource = nil + listener = -1 + port = 0 + defer { transfers.removeAll() } + return (source, transfers.values.map(\.transfer)) + } + source?.cancel() + active.forEach { $0.cancel() } + } + + private func listen() throws -> UInt16 { + try lock.withLock { + if listener >= 0 { return port } + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } + var enable: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enable, socklen_t(MemoryLayout.size)) + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr.s_addr = inet_addr("127.0.0.1") + var length = socklen_t(MemoryLayout.size) + let bound = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { pointer -> Bool in + bind(fd, pointer, length) == 0 && Darwin.listen(fd, 8) == 0 + && getsockname(fd, pointer, &length) == 0 + } + } + guard bound else { + let code = errno + Darwin.close(fd) + throw POSIXError(.init(rawValue: code) ?? .EIO) + } + _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: acceptQueue) + source.setEventHandler { [weak self] in self?.acceptPending(fd) } + source.setCancelHandler { Darwin.close(fd) } + source.resume() + listener = fd + port = UInt16(bigEndian: address.sin_port) + acceptSource = source + return port + } + } + + private func acceptPending(_ fd: Int32) { + while true { + let client = accept(fd, nil, nil) + guard client >= 0 else { return } + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let self else { Darwin.close(client); return } + self.serve(UpdateRelaySocket(client)) + } + } + } + + private func serve(_ socket: UpdateRelaySocket) { + guard let head = socket.readRequestHead(), + let request = Self.parseRequest(head), + request.method == "GET", + let upstream = lock.withLock({ routes.first { $0.token == request.token }?.upstream }) else { + _ = socket.send(Data("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8)) + return socket.close() + } + let key = UpdateDownloadPartialStore.key(for: upstream) + // One writer per partial file: a new request replaces an abandoned one. + lock.withLock { transfers.removeValue(forKey: key) }?.transfer.cancel() + let id = UUID() + let transfer = UpdateDownloadTransfer(upstream: upstream, userAgent: request.userAgent, + entry: store.entry(for: upstream), client: socket, configuration: configuration, policy: policy) { + [weak self] outcome, failure in + guard let self else { return } + self.lock.withLock { + if outcome != .cancelled { self.failure = failure } + if self.transfers[key]?.id == id { self.transfers.removeValue(forKey: key) } + } + if case .failed = outcome { + self.diagnosticSink("Update download stopped; partial data kept for resume: \(String(describing: failure))\n") + } + } + lock.withLock { transfers[key] = (id, transfer) } + transfer.start() + } + + struct Request: Equatable { + let method: String + let token: String + let userAgent: String? + } + + static func parseRequest(_ head: Data) -> Request? { + guard let text = String(data: head, encoding: .utf8) else { return nil } + let lines = text.components(separatedBy: "\r\n") + let parts = lines.first?.split(separator: " ") ?? [] + guard parts.count == 3, parts[2].hasPrefix("HTTP/1.") else { return nil } + let segments = parts[1].split(separator: "/", omittingEmptySubsequences: true) + guard let token = segments.first, token.count == 32, + token.allSatisfy({ $0.isHexDigit }) else { return nil } + let agent = lines.dropFirst().first { $0.lowercased().hasPrefix("user-agent:") } + .map { String($0.dropFirst("user-agent:".count)).trimmingCharacters(in: .whitespaces) } + return Request(method: String(parts[0]), token: String(token), userAgent: agent) + } +} + +/// Blocking loopback connection. Send and receive timeouts bound every call, +/// so a reader that stops consuming cannot hold a transfer forever. +final class UpdateRelaySocket: UpdateRelayClient, @unchecked Sendable { + private let fd: Int32 + private let lock = NSLock() + private var closed = false + + init(_ fd: Int32) { + self.fd = fd + var enable: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enable, socklen_t(MemoryLayout.size)) + var timeout = timeval(tv_sec: 30, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + timeout = timeval(tv_sec: 10, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) & ~O_NONBLOCK) + } + + deinit { close() } + + func readRequestHead(limit: Int = 16 * 1024) -> Data? { + var head = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + let terminator = Data("\r\n\r\n".utf8) + while head.count < limit { + let count = recv(fd, &buffer, buffer.count, 0) + if count < 0, errno == EINTR { continue } + guard count > 0 else { return nil } + head.append(contentsOf: buffer[0.. Bool { + data.withUnsafeBytes { raw -> Bool in + guard let base = raw.baseAddress else { return true } + var offset = 0 + while offset < raw.count { + let count = Darwin.send(fd, base + offset, raw.count - offset, 0) + if count < 0, errno == EINTR { continue } + guard count > 0 else { return false } + offset += count + } + return true + } + } + + func close() { + let shouldClose = lock.withLock { () -> Bool in + defer { closed = true } + return !closed + } + guard shouldClose else { return } + shutdown(fd, SHUT_RDWR) + Darwin.close(fd) + } +} diff --git a/macos/Tests/LitheTests/SparkleRelayEndToEndTests.swift b/macos/Tests/LitheTests/SparkleRelayEndToEndTests.swift new file mode 100644 index 0000000000..d6d592cb16 --- /dev/null +++ b/macos/Tests/LitheTests/SparkleRelayEndToEndTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Sparkle +import Testing +@testable import Lithe + +/// Opt-in end-to-end update through the real Sparkle engine and the loopback relay. +/// +/// Requires a local feed prepared outside the test: `LITHE_E2E_APP` is a signed +/// fixture bundle at the old version, `LITHE_E2E_FEED` the appcast URL, and +/// `LITHE_E2E_EXPECTED_BUILD` the build the bundle must carry after installation. +@Suite("Sparkle relay end-to-end") +struct SparkleRelayEndToEndTests { + static let environment = ProcessInfo.processInfo.environment + static var enabled: Bool { environment["LITHE_E2E_APP"] != nil } + + @Test(.enabled(if: enabled), .timeLimit(.minutes(2))) @MainActor + func updateDownloadsThroughRelayAndInstalls() async throws { + let environment = Self.environment + let app = URL(fileURLWithPath: try #require(environment["LITHE_E2E_APP"])) + let feed = try #require(environment["LITHE_E2E_FEED"]) + let expectedBuild = try #require(environment["LITHE_E2E_EXPECTED_BUILD"]) + let partials = URL(fileURLWithPath: try #require(environment["LITHE_E2E_PARTIALS"])) + let bundle = try #require(Bundle(url: app)) + + let relay = UpdateDownloadRelay(store: UpdateDownloadPartialStore(directory: partials)) + defer { relay.stop() } + let driver = HeadlessUserDriver() + let delegate = RelayingDelegate(feed: feed, relay: relay) + let updater = SPUUpdater(hostBundle: bundle, applicationBundle: bundle, userDriver: driver, delegate: delegate) + try updater.start() + updater.checkForUpdates() + + let installed = await poll(seconds: 90) { + driver.error != nil || Self.build(of: app) == expectedBuild + } + if environment["LITHE_E2E_EXPECT_FAILURE"] != nil { + let failure = try #require(relay.lastFailure) + print("E2E failure=\(failure) message=\(UpdateChecker.downloadFailureMessage(failure))") + #expect(driver.error != nil && Self.build(of: app) != expectedBuild) + return + } + if let error = driver.error { + Issue.record("Sparkle reported \(error) relayFailure=\(String(describing: relay.lastFailure))") + } + #expect(installed && Self.build(of: app) == expectedBuild) + #expect(delegate.relayedURLs.count == 1, "\(delegate.relayedURLs)") + print("E2E relayed=\(delegate.relayedURLs.map(\.absoluteString)) events=\(driver.events)") + } + + static func build(of app: URL) -> String? { + let plist = app.appendingPathComponent("Contents/Info.plist") + guard let data = try? Data(contentsOf: plist), + let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil } + return info["CFBundleVersion"] as? String + } + + @MainActor + private func poll(seconds: Double, _ condition: @MainActor () -> Bool) async -> Bool { + let deadline = Date(timeIntervalSinceNow: seconds) + while Date() < deadline { + if condition() { return true } + // test-stability: allow(swift-real-sleep) reason: Sparkle's out-of-process installer replaces the bundle without a callback, so poll it under a local deadline. + try? await Task.sleep(nanoseconds: 100_000_000) + } + return condition() + } +} + +private final class RelayingDelegate: NSObject, SPUUpdaterDelegate { + let feed: String + let relay: UpdateDownloadRelay + var relayedURLs: [URL] = [] + + init(feed: String, relay: UpdateDownloadRelay) { + self.feed = feed + self.relay = relay + } + + func feedURLString(for updater: SPUUpdater) -> String? { feed } + + // Mirrors `UpdateChecker.updater(_:willDownloadUpdate:with:)`. + func updater(_ updater: SPUUpdater, willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest) { + if let url = request.url, let relayed = relay.relayURL(for: url) { + relayedURLs.append(url) + request.url = relayed + } + } +} + +/// Accepts every offer and install prompt without presenting UI. +private final class HeadlessUserDriver: NSObject, SPUUserDriver { + var events: [String] = [] + var error: Error? + + func show(_ request: SPUUpdatePermissionRequest, reply: @escaping (SUUpdatePermissionResponse) -> Void) { + events.append("permission") + reply(SUUpdatePermissionResponse(automaticUpdateChecks: false, sendSystemProfile: false)) + } + func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { events.append("check") } + func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) { + events.append("found:\(appcastItem.versionString)") + reply(.install) + } + func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {} + func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {} + func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) { + events.append("notFound") + self.error = error + acknowledgement() + } + func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) { + events.append("error") + self.error = error + acknowledgement() + } + func showDownloadInitiated(cancellation: @escaping () -> Void) { events.append("download") } + func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { events.append("length:\(expectedContentLength)") } + func showDownloadDidReceiveData(ofLength length: UInt64) {} + func showDownloadDidStartExtractingUpdate() { events.append("extract") } + func showExtractionReceivedProgress(_ progress: Double) {} + func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) { + events.append("ready") + reply(.install) + } + func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { + events.append("installing") + } + func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { + events.append("installed") + acknowledgement() + } + func showUpdateInFocus() {} + func dismissUpdateInstallation() { events.append("dismiss") } +} diff --git a/macos/Tests/LitheTests/StableRollbackTests.swift b/macos/Tests/LitheTests/StableRollbackTests.swift index 959b04025a..0479e932ae 100644 --- a/macos/Tests/LitheTests/StableRollbackTests.swift +++ b/macos/Tests/LitheTests/StableRollbackTests.swift @@ -166,6 +166,40 @@ struct StableRollbackTests { #expect(!FileManager.default.fileExists(atPath: fixture.stage.appendingPathComponent("previous.app").path)) } + @Test + func launchRemovesFinishedAndAbandonedStagingButKeepsOthers() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("rollback-leftovers-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("Lithe.app") + let now = Date(timeIntervalSince1970: 1_000_000) + func stage(_ name: String, app: String, identifier: String, age: TimeInterval, mounted: Bool = false) throws -> URL { + let folder = root.appendingPathComponent(".lithe-stable-\(name)") + let contents = folder.appendingPathComponent("\(app)/Contents") + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + let info = ["CFBundleIdentifier": identifier] as NSDictionary + try info.write(to: contents.appendingPathComponent("Info.plist")) + if mounted { + try FileManager.default.createDirectory(at: folder.appendingPathComponent("mount/Lithe.app"), + withIntermediateDirectories: true) + } + try FileManager.default.setAttributes([.modificationDate: now.addingTimeInterval(-age)], ofItemAtPath: folder.path) + return folder + } + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + let finished = try stage("finished", app: "previous.app", identifier: "app.lithe.desktop", age: 60) + let abandoned = try stage("abandoned", app: "new.app", identifier: "app.lithe.desktop", age: 2 * 86_400) + let recent = try stage("recent", app: "new.app", identifier: "app.lithe.desktop", age: 60) + let other = try stage("other", app: "previous.app", identifier: "com.example.other", age: 60) + let mounted = try stage("mounted", app: "previous.app", identifier: "app.lithe.desktop", age: 60, mounted: true) + + let removed = StableRollbackPackage.removeLeftoverStaging(beside: target, identifier: "app.lithe.desktop", now: now) + + #expect(Set(removed.map(\.lastPathComponent)) == [finished.lastPathComponent, abandoned.lastPathComponent]) + for kept in [recent, other, mounted, target] { + #expect(FileManager.default.fileExists(atPath: kept.path)) + } + } + @Test @MainActor func unpreparedRollbackCannotRequestExitOrLaunchAHelper() { let rollback = MacStableRollback() diff --git a/macos/Tests/LitheTests/UpdateDownloadRelayTests.swift b/macos/Tests/LitheTests/UpdateDownloadRelayTests.swift new file mode 100644 index 0000000000..207be7694a --- /dev/null +++ b/macos/Tests/LitheTests/UpdateDownloadRelayTests.swift @@ -0,0 +1,424 @@ +import Foundation +import Sparkle +import Testing +@testable import Lithe + +@Suite("Update download relay", .serialized) +struct UpdateDownloadRelayTests { + @Test + func freshDownloadStreamsEverythingAndRemovesThePartial() async throws { + let fixture = try RelayFixture() + defer { fixture.cleanUp() } + fixture.script([.respond(status: 200, headers: ["ETag": "\"v1\"", "Content-Length": "10"], + chunks: ["abcde", "fghij"], failure: nil)]) + let result = try await fixture.run() + #expect(result.outcome == .completed) + #expect(result.body == "abcdefghij") + #expect(result.head.contains("Content-Length: 10")) + #expect(!FileManager.default.fileExists(atPath: fixture.entry.data.path)) + #expect(!FileManager.default.fileExists(atPath: fixture.entry.metadata.path)) + } + + @Test + func rejectsEncodedArchivesAndRequestsIdentity() async throws { + let fixture = try RelayFixture() + defer { fixture.cleanUp() } + fixture.script([.respond(status: 200, headers: ["Content-Encoding": "gzip"], chunks: ["bad"], failure: nil)]) + let result = try await fixture.run() + #expect(result.outcome == .failed(.invalidResponse)) + #expect(result.head.isEmpty) + #expect(fixture.requests().first?.value(forHTTPHeaderField: "Accept-Encoding") == "identity") + } + + @Test + func unknownLengthFailureDoesNotTerminateChunkedBody() async throws { + let fixture = try RelayFixture(policy: .init(noProgressBudget: 45, retryDelays: [0], requestTimeout: 1)) + defer { fixture.cleanUp() } + fixture.script([ + .respond(status: 200, headers: ["ETag": "v1"], chunks: ["hello"], failure: URLError(.timedOut)), + .fail(URLError(.timedOut)), + ]) + let result = try await fixture.run() + #expect(result.outcome == .failed(.timedOut)) + #expect(result.head.contains("Transfer-Encoding: chunked")) + #expect(result.body == "5\r\nhello\r\n") + } + + @Test + func unknownLengthCanResumeWhenRangeRevealsTotal() async throws { + let fixture = try RelayFixture() + defer { fixture.cleanUp() } + fixture.script([ + .respond(status: 200, headers: ["ETag": "v1"], chunks: ["hello"], failure: URLError(.networkConnectionLost)), + .respond(status: 206, headers: ["ETag": "v1", "Content-Range": "bytes 5-9/10"], chunks: ["world"], failure: nil), + ]) + let result = try await fixture.run() + #expect(result.outcome == .completed) + #expect(result.head.contains("Transfer-Encoding: chunked")) + #expect(result.body == "5\r\nhello\r\n5\r\nworld\r\n0\r\n\r\n") + } + + @Test + func droppedConnectionResumesWithRangeAndValidator() async throws { + let fixture = try RelayFixture() + defer { fixture.cleanUp() } + fixture.script([ + .respond(status: 200, headers: ["ETag": "\"v1\"", "Content-Length": "10"], + chunks: ["abcde"], failure: URLError(.networkConnectionLost)), + .respond(status: 206, headers: ["ETag": "\"v1\"", "Content-Range": "bytes 5-9/10", "Content-Length": "5"], + chunks: ["fghij"], failure: nil), + ]) + let result = try await fixture.run() + #expect(result.outcome == .completed) + #expect(result.body == "abcdefghij") + let requests = fixture.requests() + #expect(requests.count == 2) + #expect(requests.last?.value(forHTTPHeaderField: "Range") == "bytes=5-") + #expect(requests.last?.value(forHTTPHeaderField: "If-Range") == "\"v1\"") + } + + @Test + func exhaustedRetriesKeepThePartialForTheNextAttempt() async throws { + let fixture = try RelayFixture(policy: .init(noProgressBudget: 45, retryDelays: [0], requestTimeout: 1)) + defer { fixture.cleanUp() } + fixture.script([ + .respond(status: 200, headers: ["ETag": "\"v1\"", "Content-Length": "10"], + chunks: ["abcde"], failure: URLError(.timedOut)), + .fail(URLError(.timedOut)), + ]) + let first = try await fixture.run() + #expect(first.outcome == .failed(.timedOut)) + #expect(first.failure == .timedOut) + #expect(try Data(contentsOf: fixture.entry.data) == Data("abcde".utf8)) + + fixture.script([.respond(status: 206, headers: ["ETag": "\"v1\"", "Content-Range": "bytes 5-9/10"], + chunks: ["fghij"], failure: nil)]) + let second = try await fixture.run() + #expect(second.outcome == .completed) + // The stored prefix is replayed from disk before the resumed bytes. + #expect(second.body == "abcdefghij") + #expect(second.head.contains("Content-Length: 10")) + #expect(fixture.requests().last?.value(forHTTPHeaderField: "Range") == "bytes=5-") + } + + @Test + func changedArchiveRestartsFromTheBeginning() async throws { + let fixture = try RelayFixture(policy: .init(noProgressBudget: 45, retryDelays: [0], requestTimeout: 1)) + defer { fixture.cleanUp() } + fixture.script([ + .respond(status: 200, headers: ["ETag": "\"v1\"", "Content-Length": "10"], + chunks: ["abcde"], failure: URLError(.timedOut)), + .fail(URLError(.timedOut)), + ]) + _ = try await fixture.run() + // If-Range no longer matches, so the server sends the whole new archive. + fixture.script([.respond(status: 200, headers: ["ETag": "\"v2\"", "Content-Length": "6"], + chunks: ["NEWZIP"], failure: nil)]) + let result = try await fixture.run() + #expect(result.outcome == .completed) + #expect(result.body == "NEWZIP") + #expect(result.head.contains("Content-Length: 6")) + } + + @Test + func rangeResponseForAReuploadedArchiveIsNotSpliced() async throws { + let fixture = try RelayFixture(policy: .init(noProgressBudget: 45, retryDelays: [0], requestTimeout: 1)) + defer { fixture.cleanUp() } + fixture.script([ + .respond(status: 200, headers: ["ETag": "\"v1\"", "Content-Length": "10"], + chunks: ["abcde"], failure: URLError(.timedOut)), + .fail(URLError(.timedOut)), + ]) + _ = try await fixture.run() + // Like GitHub's CDN, the server ignores the stale If-Range and still answers 206. + fixture.script([ + .respond(status: 206, headers: ["ETag": "\"v2\"", "Content-Range": "bytes 5-9/10", "Content-Length": "5"], + chunks: ["VWXYZ"], failure: nil), + .respond(status: 200, headers: ["ETag": "\"v2\"", "Content-Length": "10"], + chunks: ["QRSTUVWXYZ"], failure: nil), + ]) + let result = try await fixture.run() + #expect(result.outcome == .completed) + #expect(result.body == "QRSTUVWXYZ") + #expect(fixture.requests().last?.value(forHTTPHeaderField: "Range") == nil) + } + + @Test + func clientErrorsAreReportedWithoutRetrying() async throws { + let fixture = try RelayFixture() + defer { fixture.cleanUp() } + fixture.script([.respond(status: 404, headers: [:], chunks: [], failure: nil)]) + let result = try await fixture.run() + #expect(result.outcome == .failed(.httpStatus(404))) + #expect(fixture.requests().count == 1) + #expect(result.head.isEmpty) + } + + @Test + func serverErrorsAreRetried() async throws { + let fixture = try RelayFixture() + defer { fixture.cleanUp() } + fixture.script([ + .respond(status: 503, headers: [:], chunks: [], failure: nil), + .respond(status: 200, headers: ["ETag": "\"v1\"", "Content-Length": "2"], chunks: ["ok"], failure: nil), + ]) + let result = try await fixture.run() + #expect(result.outcome == .completed) + #expect(result.body == "ok") + } + + @Test + func failuresAreClassifiedByNetworkCause() { + #expect(UpdateDownloadFailure(URLError(.timedOut)) == .timedOut) + #expect(UpdateDownloadFailure(URLError(.cannotConnectToHost)) == .connectionFailed) + #expect(UpdateDownloadFailure(URLError(.networkConnectionLost)) == .connectionFailed) + #expect(UpdateDownloadFailure(URLError(.serverCertificateUntrusted)) == .tlsOrProxyFailure) + #expect(UpdateDownloadFailure(URLError(.cancelled)) == nil) + #expect(UpdateDownloadFailure.httpStatus(503).isRetryable) + #expect(!UpdateDownloadFailure.httpStatus(404).isRetryable) + #expect(UpdateDownloadFailure.httpStatus(503).checkError.code == .httpStatus) + } + + @Test + func requestParsingAcceptsOnlyTokenPaths() { + let token = String(repeating: "ab", count: 16) + let parsed = UpdateDownloadRelay.parseRequest(Data( + "GET /\(token)/Lithe.dmg HTTP/1.1\r\nHost: 127.0.0.1\r\nUser-Agent: Lithe/1 Sparkle/2".utf8)) + #expect(parsed == .init(method: "GET", token: token, userAgent: "Lithe/1 Sparkle/2")) + #expect(UpdateDownloadRelay.parseRequest(Data("GET /nope/Lithe.dmg HTTP/1.1".utf8)) == nil) + #expect(UpdateDownloadRelay.parseRequest(Data("GET /\(token)".utf8)) == nil) + #expect(UpdateDownloadTransfer.contentRange("bytes 5-9/10")! == (5, 10)) + #expect(UpdateDownloadTransfer.contentRange("bytes */10") == nil) + } + + @Test + func pruningKeepsTheActiveDownloadAndRecentEntries() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-relay-prune-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = UpdateDownloadPartialStore(directory: directory, maximumAge: 100, maximumEntries: 2) + try store.prepareDirectory() + let now = Date(timeIntervalSince1970: 10_000) + let urls = (0..<4).map { URL(string: "https://example.invalid/\($0).dmg")! } + for (index, url) in urls.enumerated() { + let entry = store.entry(for: url) + try Data("x".utf8).write(to: entry.data) + let age = index == 3 ? 500.0 : Double(index) + try FileManager.default.setAttributes([.modificationDate: now.addingTimeInterval(-age)], + ofItemAtPath: entry.data.path) + } + store.prune(keeping: urls[2], now: now) + let remaining = urls.map { FileManager.default.fileExists(atPath: store.entry(for: $0).data.path) } + // Active entry stays; one other recent entry fits; the rest and the expired one go. + #expect(remaining == [true, false, true, false]) + } + + @Test + func loopbackListenerServesRegisteredDownloadsAndRejectsOthers() async throws { + let fixture = try RelayFixture() + defer { fixture.cleanUp() } + fixture.script([.respond(status: 200, headers: ["ETag": "\"v1\"", "Content-Length": "5"], + chunks: ["hello"], failure: nil)]) + let relay = UpdateDownloadRelay(store: fixture.store, configuration: fixture.configuration, + diagnosticSink: { _ in }) + defer { relay.stop() } + let relayed = try #require(relay.relayURL(for: fixture.upstream)) + #expect(relayed.host == "127.0.0.1") + #expect(relayed.lastPathComponent == fixture.upstream.lastPathComponent) + + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 5 + configuration.timeoutIntervalForResource = 5 + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + let (data, response) = try await session.data(from: relayed) + #expect((response as? HTTPURLResponse)?.statusCode == 200) + #expect(String(decoding: data, as: UTF8.self) == "hello") + #expect(relay.lastFailure == nil) + + var forged = URLComponents(url: relayed, resolvingAgainstBaseURL: false)! + forged.path = "/" + String(repeating: "0", count: 32) + "/Lithe.dmg" + let (_, rejected) = try await session.data(from: forged.url!) + #expect((rejected as? HTTPURLResponse)?.statusCode == 404) + } + + @MainActor + @Test + func sparkleDownloadErrorsUseTheObservedCause() { + let checker = UpdateChecker(bundle: .main, preferences: UserDefaults(suiteName: "relay-\(UUID().uuidString)")!, + downloadRelay: UpdateDownloadRelay(store: .init(directory: FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-relay-unused-\(UUID().uuidString)")))) + func sparkleError(_ underlying: URLError) -> NSError { + NSError(domain: SUSparkleErrorDomain, code: Int(SUError.downloadError.rawValue), + userInfo: [NSUnderlyingErrorKey: underlying]) + } + let remote = URLError(.timedOut, userInfo: [NSURLErrorFailingURLErrorKey: URL(string: "https://github.com/a.dmg")!]) + let loopback = URLError(.networkConnectionLost, + userInfo: [NSURLErrorFailingURLErrorKey: URL(string: "http://127.0.0.1:9/a.dmg")!]) + #expect(checker.downloadFailure(for: sparkleError(remote)) == .timedOut) + #expect(checker.downloadFailure(for: sparkleError(loopback)) == nil) + #expect(checker.downloadFailure(for: NSError(domain: SUSparkleErrorDomain, code: 1)) == nil) + #expect(UpdateChecker.downloadFailureMessage(.timedOut) + .hasPrefix(UpdateCheckError.timedOut.userMessage)) + } +} + +private struct RelayResult { + let outcome: UpdateDownloadTransfer.Outcome + let failure: UpdateDownloadFailure? + let head: String + let body: String +} + +private final class RelayFixture: @unchecked Sendable { + let host = "relay-\(UUID().uuidString.lowercased()).invalid" + let directory: URL + let store: UpdateDownloadPartialStore + let configuration: URLSessionConfiguration + let policy: UpdateDownloadTransfer.Policy + var upstream: URL { URL(string: "https://\(host)/releases/Lithe-1.dmg")! } + var entry: UpdateDownloadPartialStore.Entry { store.entry(for: upstream) } + + init(policy: UpdateDownloadTransfer.Policy = .init(noProgressBudget: 45, retryDelays: [0, 0], requestTimeout: 1)) throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-relay-\(UUID().uuidString)", isDirectory: true) + store = UpdateDownloadPartialStore(directory: directory) + try store.prepareDirectory() + configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ScriptedUpstreamProtocol.self] + self.policy = policy + } + + func script(_ steps: [ScriptedUpstreamProtocol.Step]) { ScriptedUpstreamProtocol.script(host: host, steps) } + func requests() -> [URLRequest] { ScriptedUpstreamProtocol.requests(host: host) } + + func cleanUp() { + ScriptedUpstreamProtocol.reset(host: host) + try? FileManager.default.removeItem(at: directory) + } + + func run() async throws -> RelayResult { + let delivered = TestGate() + ScriptedUpstreamProtocol.setDeliveryGate(host: host, delivered) + let client = BufferedRelayClient(delivered: delivered) + let finished = TestGate() + let box = OutcomeBox() + let transfer = UpdateDownloadTransfer(upstream: upstream, userAgent: "Test", entry: entry, client: client, + configuration: configuration, policy: policy, now: { 0 }, + schedule: { _, queue, work in queue.async(execute: work) }) { outcome, failure in + box.set(outcome, failure) + finished.open() + } + transfer.start() + let completed = await finished.waitUntilOpen(timeout: .seconds(5)) + try #require(completed, "Relay transfer must finish within the local deadline") + let (outcome, failure) = try #require(box.value) + let (head, body) = client.split() + return RelayResult(outcome: outcome, failure: failure, head: head, body: body) + } +} + +private final class OutcomeBox: @unchecked Sendable { + private let lock = NSLock() + private var stored: (UpdateDownloadTransfer.Outcome, UpdateDownloadFailure?)? + var value: (UpdateDownloadTransfer.Outcome, UpdateDownloadFailure?)? { lock.withLock { stored } } + func set(_ outcome: UpdateDownloadTransfer.Outcome, _ failure: UpdateDownloadFailure?) { + lock.withLock { stored = (outcome, failure) } + } +} + +private final class BufferedRelayClient: UpdateRelayClient, @unchecked Sendable { + private let lock = NSLock() + private let delivered: TestGate + private var data = Data() + private var sends = 0 + + init(delivered: TestGate) { self.delivered = delivered } + + func send(_ chunk: Data) -> Bool { + let bodyArrived = lock.withLock { () -> Bool in + data.append(chunk) + sends += 1 + return sends > 1 + } + // The first send is the response head; later sends carry upstream bytes. + if bodyArrived { delivered.open() } + return true + } + + func close() {} + + func split() -> (String, String) { + let text = lock.withLock { String(decoding: data, as: UTF8.self) } + guard let range = text.range(of: "\r\n\r\n") else { return (text, "") } + return (String(text[.. [URLRequest] { lock.withLock { received[host] ?? [] } } + static func reset(host: String) { + lock.withLock { + steps[host] = nil + received[host] = nil + gates[host] = nil + } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let host = request.url?.host ?? "" + let step = Self.lock.withLock { () -> Step? in + Self.received[host, default: []].append(request) + guard var queue = Self.steps[host], !queue.isEmpty else { return nil } + let step = queue.removeFirst() + Self.steps[host] = queue + return step + } + switch step { + case .none: + client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) + case .fail(let error): + client?.urlProtocol(self, didFailWithError: error) + case .respond(let status, let headers, let chunks, let failure): + // An explicit type keeps URLSession from buffering bytes for MIME sniffing. + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", + headerFields: headers.merging(["Content-Type": "application/octet-stream"]) { current, _ in current })! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + for chunk in chunks { client?.urlProtocol(self, didLoad: Data(chunk.utf8)) } + guard let failure else { return client?.urlProtocolDidFinishLoading(self) ?? () } + guard !chunks.isEmpty, let gate = Self.lock.withLock({ Self.gates[host] }) else { + return client?.urlProtocol(self, didFailWithError: failure) ?? () + } + // A failure reported in the same turn as the bytes makes URLSession + // drop them. Drop the connection only after the relay forwarded + // them, as a real network would, and report it on the loading run loop. + let runLoop = CFRunLoopGetCurrent() + DispatchQueue.global().async { [self] in + _ = gate.waitSynchronously(timeout: 5) + CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue) { + self.client?.urlProtocol(self, didFailWithError: failure) + } + CFRunLoopWakeUp(runLoop) + } + } + } + + override func stopLoading() {} +} diff --git a/scripts/release/sparkle/sparkle-relay-e2e-server.py b/scripts/release/sparkle/sparkle-relay-e2e-server.py new file mode 100644 index 0000000000..185db5e23e --- /dev/null +++ b/scripts/release/sparkle/sparkle-relay-e2e-server.py @@ -0,0 +1,80 @@ +"""Local update feed for test-sparkle-relay-e2e.sh. + +Serves files with ETag/Range/If-Range like GitHub release assets. Environment switches: +DROP_FIRST=1 cuts the first archive transfer at 40%, EXIT_AFTER_DROP=1 then stops the +server, IGNORE_IF_RANGE=1 answers 206 even for a stale If-Range (as GitHub's CDN does). +""" +import hashlib, http.server, json, os, sys, threading, time + +ROOT, PORT, LOG = sys.argv[1], int(sys.argv[2]), sys.argv[3] +dropped = set() +lock = threading.Lock() + +def log(entry): + with lock, open(LOG, "a") as f: + f.write(json.dumps(entry) + "\n") + +class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + def log_message(self, *a): pass + def do_GET(self): + name = self.path.split("?")[0].lstrip("/") + root = os.path.realpath(ROOT) + path = os.path.realpath(os.path.join(root, name)) + if os.path.commonpath((root, path)) != root or not os.path.isfile(path): + self.send_response(404); self.send_header("Content-Length", "0"); self.end_headers() + log({"path": name, "status": 404}); return + data = open(path, "rb").read() + etag = '"%s"' % hashlib.sha256(data).hexdigest()[:16] + start, status = 0, 200 + rng, if_range = self.headers.get("Range"), self.headers.get("If-Range") + if rng and rng.startswith("bytes=") and (if_range is None or if_range == etag or os.environ.get("IGNORE_IF_RANGE") == "1"): + start = int(rng[6:].split("-")[0]); status = 206 + body = data[start:] + self.send_response(status) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(body))) + self.send_header("ETag", etag) + self.send_header("Accept-Ranges", "bytes") + if status == 206: + self.send_header("Content-Range", "bytes %d-%d/%d" % (start, len(data) - 1, len(data))) + self.end_headers() + drop = os.environ.get("DROP_FIRST") == "1" and name.endswith((".zip", ".delta")) and name not in dropped + log({"path": name, "status": status, "range": rng, "ifRange": if_range, "drop": drop, "bytes": len(body)}) + if drop: + dropped.add(name) + cut = len(body) * 2 // 5 + self.wfile.write(body[:cut]) + self.wfile.flush() + # A reset can discard a small delta before URLSession delivers any + # bytes. Observe the relay's partial file before forcing a drop. + partials = os.environ.get("LITHE_E2E_PARTIALS_DIR") + deadline = time.monotonic() + 10 + observed = False + while partials and time.monotonic() < deadline: + try: + observed = any(entry.name.endswith(".part") and entry.stat().st_size > 0 + for entry in os.scandir(partials)) + except OSError: + observed = False + if observed: + break + # test-stability: allow(python-real-sleep) reason: real socket delivery is only observable via the partial file + time.sleep(0.01) + if partials and not observed: + log({"path": name, "error": "relay did not persist any bytes before the deadline"}) + self.close_connection = True + self.connection.shutdown(2) + if os.environ.get("EXIT_AFTER_DROP") == "1": + os._exit(0) + return + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass # the relay cancels responses it rejects + +class Server(http.server.ThreadingHTTPServer): + def handle_error(self, request, client_address): + pass # clients reset connections the relay abandons; the log records the requests + +Server(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/scripts/release/sparkle/test-sparkle-relay-e2e.sh b/scripts/release/sparkle/test-sparkle-relay-e2e.sh new file mode 100755 index 0000000000..f9cda1a601 --- /dev/null +++ b/scripts/release/sparkle/test-sparkle-relay-e2e.sh @@ -0,0 +1,176 @@ +#!/bin/zsh +# End-to-end check of the macOS update download relay against a local Sparkle feed. +# +# Builds two signed fixture apps (1.0.0/100 and 2.0.0/200), an appcast with a delta, and +# runs SparkleRelayEndToEndTests through the real Sparkle installer for each scenario: +# delta and full updates with a dropped connection, a server that dies mid-download and +# comes back in a later session, and an archive re-uploaded under the same name while the +# server ignores a stale If-Range. Everything lives in a temporary directory that is +# removed on exit, together with the fixture's defaults and caches. +# +# Usage: scripts/release/sparkle/test-sparkle-relay-e2e.sh [--keep] +set -euo pipefail + +script_dir="${0:A:h}" +repo="${script_dir:h:h:h}" +port="${LITHE_E2E_PORT:-18765}" +keep=0; [[ "${1:-}" == "--keep" ]] && keep=1 +fixture_id=app.lithe.e2e.fixture +root=$(mktemp -d "${TMPDIR:-/tmp}/lithe-sparkle-relay-e2e.XXXXXX") +server="" +failures=0 + +stop_server() { + [[ -n "$server" ]] || return 0 + kill "$server" 2>/dev/null || true + wait "$server" 2>/dev/null || true + server="" +} +cleanup() { + stop_server + defaults delete "$fixture_id" >/dev/null 2>&1 || true + rm -rf "$HOME/Library/Caches/$fixture_id" + if (( keep )); then print -r -- "kept $root"; else rm -rf "$root"; fi +} +trap cleanup EXIT + +# --- fixture ------------------------------------------------------------------------- +tools=$(zsh "$repo/scripts/release/sparkle/prepare-sparkle-tools.sh") +fix="$root/fixture" +mkdir -p "$fix/apps" "$fix/archives" "$fix/feed-delta" "$fix/feed-full" "$fix/feed-stale" +# Throwaway EdDSA key: 32 bytes of 0x01. Never used outside this fixture. +private_key=$(printf '\x01%.0s' {1..32} | base64) +public_key=$(swift -e 'import CryptoKit; import Foundation; print(try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 1, count: 32)).publicKey.rawRepresentation.base64EncodedString())') +print -r -- 'int main(void){return 0;}' > "$fix/main.c" +cc -o "$fix/fixture" "$fix/main.c" +head -c 24000000 /dev/urandom > "$fix/payload.bin" + +make_app() { # + local app="$fix/apps/$2/LitheE2E.app" + mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" + cp "$fix/fixture" "$app/Contents/MacOS/LitheE2E" + cp "$fix/payload.bin" "$app/Contents/Resources/payload.bin" + if [[ "$2" == 200 ]]; then # change one MiB so the delta stays small + dd if=/dev/urandom of="$app/Contents/Resources/payload.bin" bs=1048576 seek=10 count=1 conv=notrunc 2>/dev/null + fi + cat > "$app/Contents/Info.plist" < + + +CFBundleExecutableLitheE2E +CFBundleIdentifier$fixture_id +CFBundleNameLitheE2E +CFBundlePackageTypeAPPL +CFBundleShortVersionString$1 +CFBundleVersion$2 +LSMinimumSystemVersion13.0 +SUPublicEDKey$public_key +SUFeedURLhttp://127.0.0.1:$port/appcast.xml +SUEnableAutomaticChecks + +PLIST + codesign --force --deep --sign - "$app" 2>/dev/null + ditto -c -k --sequesterRsrc --keepParent "$app" "$fix/archives/LitheE2E-$1.zip" +} +make_app 1.0.0 100 +make_app 2.0.0 200 +print -r -- "$private_key" | "$tools/generate_appcast" --ed-key-file - --versions 200 --maximum-deltas 3 \ + --download-url-prefix "http://127.0.0.1:$port/" -o "$fix/archives/appcast.xml" "$fix/archives" >/dev/null +cp "$fix/archives/LitheE2E-2.0.0.zip" "$fix/archives/appcast.xml" "$fix/archives"/*.delta "$fix/feed-delta/" +cp "$fix/archives/LitheE2E-2.0.0.zip" "$fix/feed-full/" +ruby -e 's=File.read(ARGV[0]); s.sub!(%r{.*?}m, ""); File.write(ARGV[1], s)' \ + "$fix/archives/appcast.xml" "$fix/feed-full/appcast.xml" +# Same names, different archive bytes: what a client sees before a tag is re-uploaded. +cp "$fix/feed-full/appcast.xml" "$fix/feed-stale/" +head -c "$(stat -f%z "$fix/feed-full/LitheE2E-2.0.0.zip")" /dev/urandom > "$fix/feed-stale/LitheE2E-2.0.0.zip" + +print -r -- "Building tests…" +(cd "$repo/macos" && swift build --build-tests >/dev/null) + +# --- scenarios ----------------------------------------------------------------------- +start_server() { # [env…] + local feed=$1 log=$2; shift 2 + env "$@" python3 -I "$script_dir/sparkle-relay-e2e-server.py" "$feed" "$port" "$log" & + server=$! + for _ in {1..50}; do + curl -s -o /dev/null "http://127.0.0.1:$port/appcast.xml" && return 0 + sleep 0.1 + done + print -r -- "server did not start on port $port" >&2 + return 1 +} + +run_session() { # [env…] + local work=$1; shift + (cd "$repo/macos" && env LITHE_E2E_APP="$work/LitheE2E.app" \ + LITHE_E2E_FEED="http://127.0.0.1:$port/appcast.xml" LITHE_E2E_EXPECTED_BUILD=200 \ + LITHE_E2E_PARTIALS="$work/partials" "$@" \ + swift test --skip-build --filter SparkleRelayEndToEndTests 2>&1) > "$work/test.log" +} + +check() { # + local scenario=$1 description=$2; shift 2 + if "$@"; then print -r -- " ok $description" + else print -r -- " FAIL $description"; failures=$((failures + 1)); fi +} + +verify_installed() { # + local work=$2 + check "$1" "Sparkle installed build 200" \ + test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$work/LitheE2E.app/Contents/Info.plist")" = 200 + check "$1" "code signature is intact" codesign --verify --deep "$work/LitheE2E.app" + check "$1" "payload matches 2.0.0" cmp -s "$work/LitheE2E.app/Contents/Resources/payload.bin" \ + "$fix/apps/200/LitheE2E.app/Contents/Resources/payload.bin" + check "$1" "no partial download left" test -z "$(ls -A "$work/partials")" + check "$1" "test passed" grep -q "Test updateDownloadsThroughRelayAndInstalls() passed" "$work/test.log" +} + +new_work() { # + local work="$root/$1" + mkdir -p "$work/partials" + ditto "$fix/apps/100/LitheE2E.app" "$work/LitheE2E.app" + print -r -- "$work" +} + +scenario() { # + local work; work=$(new_work "$1") + print -r -- "== $1" + start_server "$fix/feed-$2" "$work/server.log" DROP_FIRST="$3" LITHE_E2E_PARTIALS_DIR="$work/partials" + run_session "$work" || true + stop_server + verify_installed "$1" "$work" + if [[ "$3" == 1 ]]; then + check "$1" "download resumed with Range" grep -q '"status": 206' "$work/server.log" + fi +} + +scenario delta-dropped delta 1 +scenario full-dropped full 1 +scenario full-clean full 0 + +for name in server-restart reupload; do + work=$(new_work "$name") + print -r -- "== $name" + first_feed=full; second_env=() + [[ "$name" == reupload ]] && { first_feed=stale; second_env=(IGNORE_IF_RANGE=1); } + start_server "$fix/feed-$first_feed" "$work/server.log" DROP_FIRST=1 EXIT_AFTER_DROP=1 LITHE_E2E_PARTIALS_DIR="$work/partials" + run_session "$work" LITHE_E2E_EXPECT_FAILURE=1 || true + stop_server + check "$name" "first session fails and keeps the partial" test -n "$(ls -A "$work/partials")" + check "$name" "first session reports a network cause" grep -q "E2E failure=" "$work/test.log" + start_server "$fix/feed-full" "$work/server.log" "${second_env[@]}" + run_session "$work" || true + stop_server + verify_installed "$name" "$work" + check "$name" "second session asks to resume" grep -q '"range": "bytes=' "$work/server.log" + if [[ "$name" == reupload ]]; then + check "$name" "stale prefix discarded and archive fetched again" \ + zsh -c "grep '\"path\": \"LitheE2E' '$work/server.log' | tail -1 | grep -q '\"status\": 200, \"range\": null'" + fi +done + +if (( failures )); then + print -r -- "$failures check(s) failed; rerun with --keep to inspect $root" >&2 + exit 1 +fi +print -r -- "All Sparkle relay scenarios passed." diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 21a92b8307..78d4447c4f 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9689,7 +9689,8 @@ "native.noWebBrowserCouldBeOpened": "No web browser could be opened.", "native.thisPluginAlreadyHasTooManyMessagesWaiting": "This plugin already has too many messages waiting.", "plugin.host.requestFailed": "The plugin host request could not be completed.", - "plugin.host.invalidParameters": "The plugin host request parameters are invalid." + "plugin.host.invalidParameters": "The plugin host request parameters are invalid.", + "native.downloadedDataWasKeptTryAgainToResumeFromWhereItStopped": "Downloaded data was kept. Try again to resume from where it stopped." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index f753c94407..3f488f93d2 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9689,7 +9689,8 @@ "native.noWebBrowserCouldBeOpened": "无法打开网页浏览器。", "native.thisPluginAlreadyHasTooManyMessagesWaiting": "此插件已有过多消息在等待。", "plugin.host.requestFailed": "未能完成插件宿主请求。", - "plugin.host.invalidParameters": "插件宿主请求的参数无效。" + "plugin.host.invalidParameters": "插件宿主请求的参数无效。", + "native.downloadedDataWasKeptTryAgainToResumeFromWhereItStopped": "已下载的部分已保留,重试时会从中断处继续下载。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 9243fb345c..29da0bde1c 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -35839,6 +35839,17 @@ "arguments": [] } }, + { + "id": "native.downloadedDataWasKeptTryAgainToResumeFromWhereItStopped", + "description": "Native UI: Downloaded data was kept. Try again to resume from where it stopped.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Downloaded data was kept. Try again to resume from where it stopped.", + "arguments": [] + } + }, { "id": "native.downloadedLldOfLldEnabled", "description": "Native UI: Downloaded (%lld of %lld enabled)", diff --git a/shared/platform-feature-matrix/features/updates-check-download.json b/shared/platform-feature-matrix/features/updates-check-download.json index 82d6ef8fb6..3c0442a076 100644 --- a/shared/platform-feature-matrix/features/updates-check-download.json +++ b/shared/platform-feature-matrix/features/updates-check-download.json @@ -14,7 +14,10 @@ "macos/Tests/LitheTests/MacDevelopmentProfileMigrationTests.swift", "scripts/release/sparkle/test-sparkle-update.rb", "scripts/release/sparkle/verify-release-signature.mjs", - "scripts/release/macos/verify-macos-release-install.sh" + "scripts/release/macos/verify-macos-release-install.sh", + "macos/Tests/LitheTests/UpdateDownloadRelayTests.swift", + "macos/Tests/LitheTests/SparkleRelayEndToEndTests.swift", + "scripts/release/sparkle/test-sparkle-relay-e2e.sh" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -37,6 +40,6 @@ }, "owner": "Release", "releaseSource": "个人仓库 1lck/Lithe-IDEA 发布正式版与固定 AutoBuild,继续使用现有 Sparkle/Tauri 应用签名;组织仓库 Lithe-IDEA/Lithe-IDEA 分发官方插件和索引,客户端保留插件 v2 信任公钥。", - "verification": "共享渠道与时间 fixture 覆盖正式版/独立开发版首次默认值、显式选择优先、损坏偏好和渠道安装路径。此前完成 macOS 11 项身份/渠道 Swift 测试、Windows 9 项前端测试、14 项工作树资源复用测试及 Sparkle 五个签名归档场景。新增 macOS/Windows 迁移回归覆盖首次独立复制、已有数据保护、无源数据标记、失败重试、中断快照恢复、Windows 占用拒绝及前端载入前只清理一次渠道/跳过版本;Sparkle 新增非法元数据/归档精确退出码及真实发布链中止场景。新增 macOS 真实数据库键、背景访问记录与仅有这些键的开发版保护回归;Windows 新增不可访问源下完成标记、已有配置和冻结快照恢复,以及新快照仍拒绝不可访问源的回归。按用户要求,这些新增回归、review 修复及 TS2769 修复均未运行本地测试/构建/静态门禁,需 CI 验证。当前主机 hdiutil 对最小输入也报目录非空,真实 DMG 创建/安装未通过;真实双架构签名升级及 Windows 原生安装/WebView 首次复制与双应用独立写入仍待对应平台验证,状态保留 pending。运行时发行包不可写。Windows 迁移引导插件显式使用空配置类型 (),避免 Tauri Builder 的配置泛型推断导致 E0283;验收时先完成 Release/NSIS 构建,再在无开发版数据且正式版已关闭时首次启动,确认 WebView 初始化前注册引导脚本、迁移设置可读且渠道/跳过版本只清理一次。PR #1219 的 69c7a987 已通过真实 Windows CI 宿主编译、现有 Rust 测试及汇总门禁;必须保留 WindowsRust lane 对真实 Tauri 启动入口的编译检查,不能只验证共享 Core。该结果不代替 Release/NSIS 安装、首次迁移和双应用独立写入验收,verificationStatus 继续 pending。", - "notes": "设置提供正式版(推荐)和 AutoBuild;正式版选择 AutoBuild 需要明确确认风险,持续提醒普通用户使用正式版。AutoBuild 安装为 Lithe-Dev,macOS/Windows 使用独立应用标识,设置、默认日志、缓存、凭据与正式版隔离;正式版保留历史标识及路径。独立开发版首次启动且没有自己的用户数据时,从正式版独立复制设置和工作区并标记完成;已有开发版数据不覆盖,之后两版各自保存。更新渠道、跳过版本、旧日志目的地、缓存及锁不迁移;Windows 在任何 WebView 创建前只复制关闭后的 localStorage 数据库与原生设置/Maven JSON,占用或复制失败提示重试且不标记成功,中断后从暂存快照继续。系统凭据仍隔离,相关账号可能需要重新登录。独立开发版缺少渠道偏好时默认 AutoBuild,显式保存值优先;旧共享身份 AutoBuild 须单独安装新开发版,跨身份基线不生成增量。独立开发版回到正式版打开下载页并保留两个应用,旧共享身份预览版仍使用原有签名回退。AutoBuild 显示构建自 YYYY-MM-DD HH:mm:ss UTC,用同一 UTC 时间的 YYYYMMDD.HHMMSS 判断先后,不使用 workflow 序号。macOS 同渠道保留 Sparkle 更新,跨渠道要求全量安装;Windows 正式版保留 Tauri 更新,AutoBuild 与跨渠道显式打开安装器下载页,因缺少签名滚动清单,不宣称已是最新版或支持自动安装。个人应用更新地址、正式回退与下载页指向个人仓库;插件市场组织地址和信任规则不变。" + "verification": "共享渠道与时间 fixture 覆盖正式版/独立开发版首次默认值、显式选择优先、损坏偏好和渠道安装路径。此前完成 macOS 11 项身份/渠道 Swift 测试、Windows 9 项前端测试、14 项工作树资源复用测试及 Sparkle 五个签名归档场景。新增 macOS/Windows 迁移回归覆盖首次独立复制、已有数据保护、无源数据标记、失败重试、中断快照恢复、Windows 占用拒绝及前端载入前只清理一次渠道/跳过版本;Sparkle 新增非法元数据/归档精确退出码及真实发布链中止场景。新增 macOS 真实数据库键、背景访问记录与仅有这些键的开发版保护回归;Windows 新增不可访问源下完成标记、已有配置和冻结快照恢复,以及新快照仍拒绝不可访问源的回归。按用户要求,这些新增回归、review 修复及 TS2769 修复均未运行本地测试/构建/静态门禁,需 CI 验证。当前主机 hdiutil 对最小输入也报目录非空,真实 DMG 创建/安装未通过;真实双架构签名升级及 Windows 原生安装/WebView 首次复制与双应用独立写入仍待对应平台验证,状态保留 pending。运行时发行包不可写。Windows 迁移引导插件显式使用空配置类型 (),避免 Tauri Builder 的配置泛型推断导致 E0283;验收时先完成 Release/NSIS 构建,再在无开发版数据且正式版已关闭时首次启动,确认 WebView 初始化前注册引导脚本、迁移设置可读且渠道/跳过版本只清理一次。PR #1219 的 69c7a987 已通过真实 Windows CI 宿主编译、现有 Rust 测试及汇总门禁;必须保留 WindowsRust lane 对真实 Tauri 启动入口的编译检查,不能只验证共享 Core。该结果不代替 Release/NSIS 安装、首次迁移和双应用独立写入验收,verificationStatus 继续 pending。macOS 下载断点续传:UpdateDownloadRelayTests 覆盖续传、重试耗尽保留断点、服务器无视过期 If-Range 时不拼接旧前缀、按网络原因分类失败;scripts/release/sparkle/test-sparkle-relay-e2e.sh 用本地服务器和真实 Sparkle 安装器跑通增量/全量断线续传、跨会话续传和同名重传五个场景。", + "notes": "设置提供正式版(推荐)和 AutoBuild;正式版选择 AutoBuild 需要明确确认风险,持续提醒普通用户使用正式版。AutoBuild 安装为 Lithe-Dev,macOS/Windows 使用独立应用标识,设置、默认日志、缓存、凭据与正式版隔离;正式版保留历史标识及路径。独立开发版首次启动且没有自己的用户数据时,从正式版独立复制设置和工作区并标记完成;已有开发版数据不覆盖,之后两版各自保存。更新渠道、跳过版本、旧日志目的地、缓存及锁不迁移;Windows 在任何 WebView 创建前只复制关闭后的 localStorage 数据库与原生设置/Maven JSON,占用或复制失败提示重试且不标记成功,中断后从暂存快照继续。系统凭据仍隔离,相关账号可能需要重新登录。独立开发版缺少渠道偏好时默认 AutoBuild,显式保存值优先;旧共享身份 AutoBuild 须单独安装新开发版,跨身份基线不生成增量。独立开发版回到正式版打开下载页并保留两个应用,旧共享身份预览版仍使用原有签名回退。AutoBuild 显示构建自 YYYY-MM-DD HH:mm:ss UTC,用同一 UTC 时间的 YYYYMMDD.HHMMSS 判断先后,不使用 workflow 序号。macOS 同渠道保留 Sparkle 更新,跨渠道要求全量安装;Windows 正式版保留 Tauri 更新,AutoBuild 与跨渠道显式打开安装器下载页,因缺少签名滚动清单,不宣称已是最新版或支持自动安装。个人应用更新地址、正式回退与下载页指向个人仓库;插件市场组织地址和信任规则不变。macOS 的 Sparkle 下载经本机回环转发,断点保存在 Caches/Lithe/UpdateDownloads,网络中断时自动重试约 15 秒,失败提示区分超时、无法连接和服务器错误,并说明已下载的数据会保留、重试可继续;文件 ETag/Last-Modified 变化时丢弃断点重新下载。启动时清理已完成或废弃的回退暂存。Windows 暂无断点续传。" }