From af860e11b5648178db44ea1121c6f232863d176f Mon Sep 17 00:00:00 2001 From: jrtxio Date: Tue, 13 Oct 2026 11:27:27 +0800 Subject: [PATCH 1/2] Add macOS RivetUpdate shared host-side update client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every downstream application was hand-rolling the same security-critical Swift flow (signed manifest check, artifact download, verify, atomic swap), and the class of bug that stalled taskly's in-app OTA — AsyncBytes parking its continuation on a lost end-of-stream flag — was free to recur in each copy. This extracts that flow into the platform package as RivetUpdate so hosts configure four facts (feed URL, Ed25519 public key, key id, bundle name) and keep their own UI. - Signed-wrapper verification matches rivet/distribution: Ed25519 over the exact payload bytes before any JSON parsing, schema and key-id checks, closed inner decode. - Delegate-driven downloader: completion is definitive, request/resource timeouts bound silent stalls, task cancellation always resumes promptly. - Signed size enforced twice — a disagreeing Content-Length fails the transfer immediately, and the delivered file must match even without a length; sha256 is chunked over the file on disk. - ditto unpack, bundle-version confirmation, swap-with-rollback script, relaunch and terminate. - Configuration fails closed at construction: HTTPS-only feeds outside loopback drills, raw-32-byte keys, bare *.app bundle names, positive timeouts. 28 tests cover the verification vectors, version comparison, configuration rejections, installed-bundle detection, swap-script content, sha256 known answer, and URLProtocol-stubbed download (exact bytes, monotonic progress, size mismatch, transport failure, cancellation) plus the check() paths. --- CHANGELOG.md | 13 + docs/release-and-updates.md | 13 + platform/macos/Package.swift | 15 +- .../Sources/RivetUpdate/RivetUpdate.swift | 604 ++++++++++++++++++ .../RivetUpdateTests/RivetUpdateTests.swift | 486 ++++++++++++++ 5 files changed, 1130 insertions(+), 1 deletion(-) create mode 100644 platform/macos/Sources/RivetUpdate/RivetUpdate.swift create mode 100644 platform/macos/Tests/RivetUpdateTests/RivetUpdateTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b3982b6d..22582a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- Add the macOS `RivetUpdate` Swift module: a shared host-side update client + for installed `.app` bundles, so applications stop hand-rolling the same + security-critical download/verify/swap flow per product. It verifies the + family's signed manifest wrapper (Ed25519 over exact payload bytes before + any JSON parsing), downloads through a delegate-driven session whose + completion callbacks are definitive — byte-iterating `AsyncBytes` can park + its continuation forever on a lost end-of-stream flag — enforces signed + size during transfer and on the delivered file, confirms the unpacked + bundle version, and swaps atomically with rollback and relaunch. + Configuration fails closed (HTTPS-only feeds outside loopback drills, + raw-32-byte keys, bare `*.app` bundle names), task cancellation always + resumes promptly, and progress stays within 0…100. + ## 0.8.0 - Reject self-contradictory signed update manifests before publication or diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index fbcf577e..75a6e2bf 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -134,6 +134,19 @@ The lower-level `make-install-plan`, `execute-install-plan!`, and `recover-insta Build metadata does not affect version precedence. +## Host-side updates on macOS + +The engine API above covers applications whose update flow runs in Racket. Applications whose macOS host owns the whole flow natively should not hand-roll it: the Swift package exposes `RivetUpdate` (`platform/macos`), a shared update client for installed `.app` bundles. It performs the authenticated check against the same signed wrapper the engine verifies (`RivetUpdateService.check`), a bounded delegate-driven artifact download with progress and cancellation (`downloadAndInstall`), SHA-256 and size verification against the signed manifest, `ditto` unpacking, a bundle-version confirmation, and an atomic swap-with-rollback that relaunches the application. + +Applications configure four facts — feed URL, Ed25519 public key, key id, and bundle name — and keep their own UI. Configuration fails closed at construction: non-HTTPS feeds outside loopback drills, malformed bundle names, empty key ids, and public keys that are not the 32 raw Ed25519 bytes are rejected before any transfer starts. The downloader rejects a definitive `Content-Length` that disagrees with the signed size immediately instead of downloading a wrong artifact to the end, and the file on disk must match the signed size even when the response carried no length. + +The downloader is delegate-driven on purpose. Byte-iterating `URLSession.AsyncBytes` can park its continuation forever when the stream's end-of-stream flag never surfaces (observed live against the GitHub CDN: all bytes delivered, the loop never wakes, the update sheet sticks at 100%). Delegate completion callbacks are definitive, and the request/resource timeout intervals bound a silent stall. Two UX rules follow for hosts driving this from SwiftUI: + +- A running transfer always offers an explicit Cancel affordance; task cancellation invalidates the session, so the awaited call always resumes promptly. +- The update surface stays dismissable by gesture except during the final swap-and-relaunch step, which terminates the process anyway. + +Windows hosts update through the MSI transaction (`msiexec` with elevation), and Linux AppImage/portable installs should use the engine's verified `prepare-platform-installation` / `execute-platform-installation!` replacement instead of re-implementing atomic swap in the host. Sharing the `RivetUpdate` pattern on those platforms belongs to their host story, not to each product. + ## External validation points CI can test signing, tampering, selection, hashing, packaging structure, and native compilation without publisher identities. A real release still needs the publisher's Authenticode certificate or Apple Developer ID/notary profile, an Ed25519 update key, an HTTPS artifact origin, and a final install/upgrade/rollback exercise on supported OS versions. diff --git a/platform/macos/Package.swift b/platform/macos/Package.swift index e988b276..5a21c90e 100644 --- a/platform/macos/Package.swift +++ b/platform/macos/Package.swift @@ -17,7 +17,8 @@ let package = Package( .library(name: "RivetDevice", targets: ["RivetDevice"]), .library(name: "RivetWatchConnectivity", targets: ["RivetWatchConnectivity"]), .library(name: "RivetEmbedding", targets: ["RivetEmbedding"]), - .library(name: "RivetSystem", targets: ["RivetSystem"]) + .library(name: "RivetSystem", targets: ["RivetSystem"]), + .library(name: "RivetUpdate", targets: ["RivetUpdate"]) ], targets: [ .target( @@ -53,6 +54,13 @@ let package = Package( .linkedFramework("UserNotifications") ] ), + .target( + name: "RivetUpdate", + path: "Sources/RivetUpdate", + linkerSettings: [ + .linkedFramework("CryptoKit") + ] + ), .testTarget( name: "RivetRuntimeTests", dependencies: ["RivetRuntime"], @@ -67,6 +75,11 @@ let package = Package( name: "RivetSystemTests", dependencies: ["RivetSystem"], path: "Tests/RivetSystemTests" + ), + .testTarget( + name: "RivetUpdateTests", + dependencies: ["RivetUpdate"], + path: "Tests/RivetUpdateTests" ) ] ) diff --git a/platform/macos/Sources/RivetUpdate/RivetUpdate.swift b/platform/macos/Sources/RivetUpdate/RivetUpdate.swift new file mode 100644 index 00000000..05fcdbd9 --- /dev/null +++ b/platform/macos/Sources/RivetUpdate/RivetUpdate.swift @@ -0,0 +1,604 @@ +import CryptoKit +import Foundation + +#if canImport(AppKit) +import AppKit +#endif + +/// Host-side online update client for installed `.app` bundles. +/// +/// Rivet's engine-side `rivet/distribution` owns manifest trust, verified +/// downloads, and portable installation transactions. This module is the +/// native half for applications whose macOS host performs the update itself: +/// an authenticated manifest check, a bounded delegate-driven artifact +/// download, SHA-256 and size verification against the signed manifest, +/// `ditto` unpacking, bundle-version confirmation, and an atomic +/// swap-with-rollback that relaunches the application. +/// +/// The downloader is delegate-driven on purpose. Byte-iterating +/// `URLSession.AsyncBytes` can park its continuation forever when the +/// stream's end-of-stream flag never surfaces (observed live against the +/// GitHub CDN: all bytes delivered, the loop never wakes, the update sheet +/// sticks at 100%). The delegate's `didFinishDownloadingTo` / +/// `didCompleteWithError` are definitive, and the request/resource timeout +/// intervals bound a silent stall. Hosts driving this from SwiftUI must keep +/// an explicit Cancel affordance while a transfer is running; a bounded +/// download that cannot be dismissed is a defect. +public struct RivetUpdateConfiguration: Sendable { + /// Release feed publishing the signed wrapper (`update-manifest.json`). + /// HTTPS is required; literal loopback hosts are accepted for local + /// drills and tests only. + public let feedURL: URL + + /// Ed25519 public key (base64, raw 32 bytes) paired with the release + /// signing key. + public let publicKeyBase64: String + + /// Key identity the manifest signature must carry. Rotation ships a + /// client trusting the next key before releases stop being signed with + /// this one. + public let keyID: String + + /// Installed bundle file name, for example `"Taskly.app"`. + public let bundleName: String + + /// Per-request idle deadline in seconds; bounds a silent stall. + public let requestTimeout: TimeInterval + + /// Whole-transfer deadline in seconds. + public let resourceTimeout: TimeInterval + + public init(feedURL: URL, + publicKeyBase64: String, + keyID: String, + bundleName: String, + requestTimeout: TimeInterval = 60, + resourceTimeout: TimeInterval = 600) { + self.feedURL = feedURL + self.publicKeyBase64 = publicKeyBase64 + self.keyID = keyID + self.bundleName = bundleName + self.requestTimeout = requestTimeout + self.resourceTimeout = resourceTimeout + } +} + +public enum RivetUpdateError: Error, LocalizedError, Equatable { + case invalidConfiguration(String) + case notInstalled + case badPublicKey + case manifestMissing + case signatureInvalid + case checksumMismatch + case sizeMismatch(expected: Int, actual: Int) + case versionMismatch(expected: String, got: String) + case network(String) + + public var errorDescription: String? { + switch self { + case .invalidConfiguration(let reason): + return "update configuration is invalid: \(reason)" + case .notInstalled: return "not an installed app bundle" + case .badPublicKey: return "embedded public key is invalid" + case .manifestMissing: return "release has no update manifest" + case .signatureInvalid: return "manifest signature check failed" + case .checksumMismatch: return "downloaded archive failed the sha256 check" + case .sizeMismatch(let expected, let actual): + return "downloaded size \(actual) != signed size \(expected)" + case .versionMismatch(let expected, let got): + return "bundle version \(got) != manifest \(expected)" + case .network(let message): return message + } + } +} + +/// The verified release manifest, decoded only after its Ed25519 signature +/// was checked over the exact payload bytes. +public struct RivetUpdateManifest: Decodable, Sendable, Equatable { + public struct Artifact: Decodable, Sendable, Equatable { + public let platform: String + public let architecture: String + public let url: URL + public let sha256: String + public let size: Int + } + + public let version: String + public let artifacts: [Artifact] + + /// The release feed carries one artifact per platform × architecture; + /// each built host targets exactly one architecture, so the match is + /// compile-time ("x64" on Intel builds, "arm64" on Apple silicon). + public static let hostArchitecture: String = { + #if arch(x86_64) + return "x64" + #elseif arch(arm64) + return "arm64" + #else + #error("unsupported macOS architecture") + #endif + }() + + public func artifact(forPlatform platform: String = "macos", + architecture: String = RivetUpdateManifest.hostArchitecture) -> Artifact? { + artifacts.first { + $0.platform == platform && $0.architecture == architecture + } + } +} + +public enum RivetUpdateCheckResult: Sendable { + case upToDate + case available(RivetUpdateManifest) +} + +/// One application's update client. Configure once at startup; `check` and +/// `downloadAndInstall` are free to interleave with normal UI work. +public final class RivetUpdateService: Sendable { + private let configuration: RivetUpdateConfiguration + private let session: URLSession + /// Test seams for the runtime environment; nil = derive from Bundle.main. + private let injectedBundlePath: String? + private let injectedCurrentVersion: String? + + public init(configuration: RivetUpdateConfiguration, + bundlePath: String? = nil, + currentVersion: String? = nil, + sessionConfiguration: URLSessionConfiguration? = nil) + throws { + self.configuration = try Self.validated(configuration) + let config = sessionConfiguration ?? URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = configuration.requestTimeout + config.timeoutIntervalForResource = configuration.resourceTimeout + self.session = URLSession(configuration: config) + self.injectedBundlePath = bundlePath + self.injectedCurrentVersion = currentVersion + } + + /// Fail closed on configurations that would only break mid-update: + /// non-HTTPS non-loopback feeds, empty key identities, malformed bundle + /// names, and public keys that are not the 32 raw Ed25519 bytes. + private static func validated(_ configuration: RivetUpdateConfiguration) + throws -> RivetUpdateConfiguration { + guard configuration.keyID.isEmpty == false else { + throw RivetUpdateError.invalidConfiguration("key id must not be empty") + } + guard configuration.bundleName.hasSuffix(".app"), + configuration.bundleName.contains("/") == false, + configuration.bundleName.count > ".app".count else { + throw RivetUpdateError.invalidConfiguration( + "bundle name must be a bare \".app\" file name") + } + guard let scheme = configuration.feedURL.scheme?.lowercased() else { + throw RivetUpdateError.invalidConfiguration("feed URL has no scheme") + } + if scheme != "https" { + let host = configuration.feedURL.host ?? "" + guard ["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) else { + throw RivetUpdateError.invalidConfiguration( + "feed URL must use HTTPS outside loopback drills") + } + } + guard configuration.requestTimeout > 0, configuration.resourceTimeout > 0 else { + throw RivetUpdateError.invalidConfiguration( + "request and resource timeouts must be positive") + } + guard let keyData = Data(base64Encoded: configuration.publicKeyBase64), + keyData.count == 32 else { + throw RivetUpdateError.badPublicKey + } + return configuration + } + + // MARK: - Environment + + /// The installed bundle this copy can update in place, or nil for + /// development copies (not under /Applications or ~/Applications). The + /// injected path (tests) passes through the same installed-copy rules. + public var installedBundleURL: URL? { + let path = injectedBundlePath ?? Bundle.main.bundlePath + guard path.hasSuffix(".app"), + path.hasPrefix("/Applications/") + || path.hasPrefix("/Users/") && path.contains("/Applications/") else { + return nil + } + return URL(fileURLWithPath: path) + } + + public var canUpdate: Bool { installedBundleURL != nil } + + private var currentVersion: String { + injectedCurrentVersion + ?? Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + ?? "0" + } + + // MARK: - Check + + /// Fetches the latest release manifest, verifies its Ed25519 signature + /// and compares versions. Throws with a precise reason otherwise; + /// presentation is the caller's job. + public func check() async throws -> RivetUpdateCheckResult { + guard installedBundleURL != nil else { + throw RivetUpdateError.notInstalled + } + let manifest = try await fetchSignedManifest() + if Self.isVersion(manifest.version, greaterThan: currentVersion) { + return .available(manifest) + } + return .upToDate + } + + private func fetchSignedManifest() async throws -> RivetUpdateManifest { + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(from: configuration.feedURL) + } catch { + throw RivetUpdateError.network(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw RivetUpdateError.network( + "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)") + } + return try Self.verifyManifest(data, + keyBase64: configuration.publicKeyBase64, + keyID: configuration.keyID) + } + + // MARK: - Pure steps (unit-tested) + + /// Verifies the signed wrapper (schema, key id, Ed25519 over the exact + /// payload bytes) and decodes the inner manifest. The payload stays + /// opaque until the signature check succeeds — unauthenticated input + /// never reaches the JSON decoder. + public static func verifyManifest(_ wrapperBytes: Data, + keyBase64: String, + keyID: String) throws -> RivetUpdateManifest { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let wrapper: SignedWrapper + do { + wrapper = try decoder.decode(SignedWrapper.self, from: wrapperBytes) + } catch { + throw RivetUpdateError.manifestMissing + } + guard wrapper.schema == 1 else { throw RivetUpdateError.manifestMissing } + guard wrapper.signature.algorithm == "ed25519", + wrapper.signature.keyId == keyID else { + throw RivetUpdateError.signatureInvalid + } + guard let payload = Data(base64Encoded: wrapper.payload), + let signature = Data(base64Encoded: wrapper.signature.value) else { + throw RivetUpdateError.signatureInvalid + } + guard signature.count == 64 else { throw RivetUpdateError.signatureInvalid } + guard let keyData = Data(base64Encoded: keyBase64), + let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: keyData) else { + throw RivetUpdateError.badPublicKey + } + guard publicKey.isValidSignature(signature, for: payload) else { + throw RivetUpdateError.signatureInvalid + } + do { + return try decoder.decode(RivetUpdateManifest.self, from: payload) + } catch { + throw RivetUpdateError.network("manifest unparsable") + } + } + + /// The signed wrapper: the inner manifest travels base64-encoded so the + /// Ed25519 signature covers the exact bytes every client verifies + /// (rivet/distribution's signed-wrapper format). + struct SignedWrapper: Decodable { + struct SignatureBlock: Decodable { + let algorithm: String + let keyId: String + let value: String + } + let schema: Int + let payload: String + let signature: SignatureBlock + } + + // MARK: - Download + install + + /// Downloads, verifies (sha256 and unpacked bundle version), swaps the + /// bundle in place and relaunches. Throws before touching the running + /// install when anything is off; on success this method does not return + /// (the application terminates as part of the swap). `onProgress` + /// reports download percent (0…100) on a background thread; hosts hop + /// to their UI actor themselves. + public func downloadAndInstall(_ manifest: RivetUpdateManifest, + onProgress: (@Sendable (Int) -> Void)? = nil) async throws { + guard let bundleURL = installedBundleURL else { + throw RivetUpdateError.notInstalled + } + guard let artifact = manifest.artifact(forPlatform: "macos") else { + throw RivetUpdateError.manifestMissing + } + try await Self.downloadAndSwap(artifact: artifact, + bundleURL: bundleURL, + bundleName: configuration.bundleName, + expectedVersion: manifest.version, + requestTimeout: configuration.requestTimeout, + resourceTimeout: configuration.resourceTimeout, + onProgress: onProgress ?? { _ in }) + } + + /// The whole download → verify → swap sequence, deliberately off the + /// main actor so the transfer never competes with UI work. + private static func downloadAndSwap( + artifact: RivetUpdateManifest.Artifact, + bundleURL: URL, + bundleName: String, + expectedVersion: String, + requestTimeout: TimeInterval, + resourceTimeout: TimeInterval, + onProgress: @escaping @Sendable (Int) -> Void + ) async throws { + // Download to a temp directory. + let workDir = FileManager.default.temporaryDirectory + .appendingPathComponent("rivet-update-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: workDir, + withIntermediateDirectories: true) + let zipURL = workDir.appendingPathComponent("update.zip") + defer { try? FileManager.default.removeItem(at: workDir) } + + let downloader = RivetArtifactDownloader(destination: zipURL, + expectedSize: artifact.size, + requestTimeout: requestTimeout, + resourceTimeout: resourceTimeout, + onProgress: onProgress) + do { + try await downloader.run(from: artifact.url) + } catch let error as RivetUpdateError { + throw error + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + throw RivetUpdateError.network(error.localizedDescription) + } + + // Size and sha256 against the signed manifest — from the file on + // disk, so the artifact never sits whole in memory. + let attributes = try FileManager.default.attributesOfItem(atPath: zipURL.path) + guard let fileSize = attributes[.size] as? Int, fileSize == artifact.size else { + let actual = (attributes[.size] as? Int) ?? -1 + throw RivetUpdateError.sizeMismatch(expected: artifact.size, actual: actual) + } + let digest = try Self.sha256Hex(ofFileAt: zipURL) + guard digest == artifact.sha256.lowercased() else { + throw RivetUpdateError.checksumMismatch + } + + // Unzip and verify the payload really is the promised version. + let unpacked = workDir.appendingPathComponent("unpacked") + try FileManager.default.createDirectory(at: unpacked, + withIntermediateDirectories: true) + let dittoStatus = try await Self.runProcess( + "/usr/bin/ditto", arguments: ["-x", "-k", zipURL.path, unpacked.path]) + guard dittoStatus == 0 else { throw RivetUpdateError.checksumMismatch } + let newBundle = unpacked.appendingPathComponent(bundleName) + guard let newVersion = Bundle(url: newBundle)? + .infoDictionary?["CFBundleShortVersionString"] as? String else { + throw RivetUpdateError.versionMismatch(expected: expectedVersion, + got: "missing") + } + guard newVersion == expectedVersion else { + throw RivetUpdateError.versionMismatch(expected: expectedVersion, + got: newVersion) + } + + // Swap: the shell script survives our exit; it also restores the + // old bundle if the new one fails to move in. + let script = Self.swapScript(bundlePath: bundleURL.path, + newBundlePath: newBundle.path) + let swapPath = workDir.appendingPathComponent("swap.sh") + try script.write(to: swapPath, atomically: true, encoding: .utf8) + try await Self.runProcess("/bin/chmod", arguments: ["+x", swapPath.path]) + + // Launch the swap, then leave through the normal exit path so + // AppKit tears the app down cleanly; the script's sleep 1 gives us + // time to quit before it moves the bundle. + try await Self.runProcess("/bin/bash", arguments: [swapPath.path]) + #if canImport(AppKit) + await MainActor.run { NSApplication.shared.terminate(nil) } + #else + exit(0) + #endif + } + + /// The swap helper: after a grace period it moves the running bundle + /// aside, moves the verified payload in, reopens the application, and + /// restores the previous bundle when the new one cannot be moved in. + /// Static and pure so the exact script is unit-testable. + static func swapScript(bundlePath: String, newBundlePath: String) -> String { + let old = bundlePath + ".old" + return """ + #!/bin/bash + sleep 1 + rm -rf \(shellQuoted(old)) + if mv \(shellQuoted(bundlePath)) \(shellQuoted(old)); then + if mv \(shellQuoted(newBundlePath)) \(shellQuoted(bundlePath)); then + open \(shellQuoted(bundlePath)) + rm -rf \(shellQuoted(old)) + exit 0 + fi + mv \(shellQuoted(old)) \(shellQuoted(bundlePath)) + fi + exit 1 + """ + } + + private static func shellQuoted(_ path: String) -> String { + "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + @discardableResult + private static func runProcess(_ path: String, + arguments: [String]) async throws -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + try process.run() + return await withCheckedContinuation { continuation in + process.terminationHandler = { termination in + continuation.resume(returning: termination.terminationStatus) + } + } + } + + /// Chunked sha256 over a file on disk; hashing by 1 MiB chunks keeps + /// peak memory flat regardless of artifact size. + static func sha256Hex(ofFileAt url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + let chunkSize = 1 << 20 + while let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty { + hasher.update(data: chunk) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Version compare (numeric per segment; 1.2 > 1.1.9) + + public static func isVersion(_ a: String, greaterThan b: String) -> Bool { + let av = a.split(separator: ".").map { Int($0) ?? 0 } + let bv = b.split(separator: ".").map { Int($0) ?? 0 } + let length = max(av.count, bv.count) + for i in 0.. y } + } + return false + } +} + +/// One-artifact file download driven by URLSessionDownloadDelegate. +/// Delegate callbacks arrive on the session's serial delegate queue; the +/// checked continuation is taken exactly once under the lock. +/// `finishTasksAndInvalidate` on completion also breaks the delegate retain +/// cycle (the session retains its delegate). +final class RivetArtifactDownloader: NSObject, + URLSessionDownloadDelegate, @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var lastPercent = -1 + private let destination: URL + private let expectedSize: Int + private let onProgress: @Sendable (Int) -> Void + // Set once in init after super.init (the session needs self as its + // delegate); never reassigned afterwards. + private var session: URLSession! + + init(destination: URL, + expectedSize: Int, + requestTimeout: TimeInterval = 60, + resourceTimeout: TimeInterval = 600, + sessionConfiguration: URLSessionConfiguration? = nil, + onProgress: @escaping @Sendable (Int) -> Void) { + self.destination = destination + self.expectedSize = expectedSize + self.onProgress = onProgress + super.init() + let config = sessionConfiguration ?? URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = requestTimeout + config.timeoutIntervalForResource = resourceTimeout + self.session = URLSession(configuration: config, delegate: self, + delegateQueue: nil) + } + + func run(from url: URL) async throws { + let operation: @Sendable () async throws -> Void = { [self] in + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + begin(cont, url: url) + } + } + let onCancel: @Sendable () -> Void = { [self] in + session.invalidateAndCancel() + } + try await withTaskCancellationHandler(operation: operation, + onCancel: onCancel) + } + + private func begin(_ cont: CheckedContinuation, url: URL) { + lock.lock() + if continuation != nil { + lock.unlock() + cont.resume(throwing: RivetUpdateError.network("download already running")) + return + } + continuation = cont + lock.unlock() + session.downloadTask(with: url).resume() + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64) { + // Content-Length from the CDN; the signed manifest size is the + // fallback when the response carries none. A definitive length that + // disagrees with the signed size fails the transfer immediately — + // downloading a wrong artifact to the end would only die later at + // the sha256 check. + if totalBytesExpectedToWrite > 0, + Int(totalBytesExpectedToWrite) != expectedSize { + downloadTask.cancel() + resume(with: .failure(RivetUpdateError.sizeMismatch( + expected: expectedSize, + actual: Int(totalBytesExpectedToWrite)))) + return + } + let total = totalBytesExpectedToWrite > 0 + ? Int(totalBytesExpectedToWrite) : max(expectedSize, 1) + let percent = min(100, Int(totalBytesWritten) * 100 / total) + if percent != lastPercent { + lastPercent = percent + onProgress(percent) + } + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + // `location` is valid only inside this callback — move now. + do { + try? FileManager.default.removeItem(at: destination) + try FileManager.default.moveItem(at: location, to: destination) + // The deterministic size gate: whatever the transfer reported, + // the file on disk must match the signed size or the transfer + // fails right here (a 404 body would otherwise "succeed"). + let attributes = try FileManager.default.attributesOfItem( + atPath: destination.path) + guard let size = attributes[.size] as? Int, size == expectedSize else { + let actual = (attributes[.size] as? Int) ?? -1 + resume(with: .failure(RivetUpdateError.sizeMismatch( + expected: expectedSize, actual: actual))) + return + } + resume(with: .success(())) + } catch { + resume(with: .failure(error)) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) { + // Success is reported by didFinishDownloadingTo; this callback + // carries the failure half (including cancellation and the + // request/resource timeouts that bound a silent stall). + guard let error else { return } + resume(with: .failure(error)) + } + + private func resume(with result: Result) { + lock.lock() + let cont = continuation + continuation = nil + lock.unlock() + session.finishTasksAndInvalidate() + cont?.resume(with: result) + } +} diff --git a/platform/macos/Tests/RivetUpdateTests/RivetUpdateTests.swift b/platform/macos/Tests/RivetUpdateTests/RivetUpdateTests.swift new file mode 100644 index 00000000..2d94d0b1 --- /dev/null +++ b/platform/macos/Tests/RivetUpdateTests/RivetUpdateTests.swift @@ -0,0 +1,486 @@ +import CryptoKit +import Foundation +import Testing +@testable import RivetUpdate + +// MARK: - Test vectors + +// A fixed seed derives the same Ed25519 pair on every access, so computed +// fixtures stay deterministic (and Sendable) under strict concurrency. +private let keySeed = Data(repeating: 0x42, count: 32) + +private var signingKey: Curve25519.Signing.PrivateKey { + try! Curve25519.Signing.PrivateKey(rawRepresentation: keySeed) +} + +private func innerManifestJSON(version: String) -> String { + let artifact = """ + {"platform":"macos","architecture":"arm64", + "url":"https://example.com/app.zip","sha256":"00","size":10} + """ + let x64 = """ + {"platform":"macos","architecture":"x64", + "url":"https://example.com/app-x64.zip","sha256":"01","size":11} + """ + return """ + {"version":"\(version)","artifacts":[\(artifact),\(x64)]} + """ +} + +private func wrapperData(payloadJSON: String, + key: Curve25519.Signing.PrivateKey = signingKey, + keyID: String = "test-key", + schema: Int = 1, + algorithm: String = "ed25519", + payloadBase64: String? = nil, + signature: Data? = nil) throws -> Data { + let payload = Data(payloadJSON.utf8) + let signatureValue = try signature ?? key.signature(for: payload) + let object: [String: Any] = [ + "schema": schema, + "payload": payloadBase64 ?? payload.base64EncodedString(), + "signature": [ + "algorithm": algorithm, + "key_id": keyID, + "value": signatureValue.base64EncodedString(), + ], + ] + return try JSONSerialization.data(withJSONObject: object) +} + +private var testConfiguration: RivetUpdateConfiguration { + RivetUpdateConfiguration( + feedURL: URL(string: "https://example.com/update-manifest.json")!, + publicKeyBase64: signingKey.publicKey.rawRepresentation.base64EncodedString(), + keyID: "test-key", + bundleName: "Taskly.app") +} + +// MARK: - Signed-manifest verification + +@Test func validSignedManifestDecodes() throws { + let wrapper = try wrapperData(payloadJSON: innerManifestJSON(version: "1.2.3")) + let manifest = try RivetUpdateService.verifyManifest( + wrapper, keyBase64: testConfiguration.publicKeyBase64, keyID: "test-key") + #expect(manifest.version == "1.2.3") + #expect(manifest.artifacts.count == 2) +} + +@Test func wrongSigningKeyIsRejected() throws { + let wrapper = try wrapperData(payloadJSON: innerManifestJSON(version: "1.2.3"), + key: Curve25519.Signing.PrivateKey()) + #expect(throws: RivetUpdateError.signatureInvalid) { + try RivetUpdateService.verifyManifest( + wrapper, keyBase64: testConfiguration.publicKeyBase64, keyID: "test-key") + } +} + +@Test func wrongKeyIDIsRejected() throws { + let wrapper = try wrapperData(payloadJSON: innerManifestJSON(version: "1.2.3"), + keyID: "other-key") + #expect(throws: RivetUpdateError.signatureInvalid) { + try RivetUpdateService.verifyManifest( + wrapper, keyBase64: testConfiguration.publicKeyBase64, keyID: "test-key") + } +} + +@Test func tamperedPayloadIsRejected() throws { + let wrapper = try wrapperData(payloadJSON: innerManifestJSON(version: "9.9.9"), + payloadBase64: Data("{}".utf8).base64EncodedString()) + #expect(throws: RivetUpdateError.signatureInvalid) { + try RivetUpdateService.verifyManifest( + wrapper, keyBase64: testConfiguration.publicKeyBase64, keyID: "test-key") + } +} + +@Test func nonEd25519AlgorithmIsRejected() throws { + let wrapper = try wrapperData(payloadJSON: innerManifestJSON(version: "1.2.3"), + algorithm: "sha256") + #expect(throws: RivetUpdateError.signatureInvalid) { + try RivetUpdateService.verifyManifest( + wrapper, keyBase64: testConfiguration.publicKeyBase64, keyID: "test-key") + } +} + +@Test func shortSignatureIsRejected() throws { + let wrapper = try wrapperData(payloadJSON: innerManifestJSON(version: "1.2.3"), + signature: Data(repeating: 0, count: 63)) + #expect(throws: RivetUpdateError.signatureInvalid) { + try RivetUpdateService.verifyManifest( + wrapper, keyBase64: testConfiguration.publicKeyBase64, keyID: "test-key") + } +} + +@Test func futureSchemaIsRejectedAsMissing() throws { + let wrapper = try wrapperData(payloadJSON: innerManifestJSON(version: "1.2.3"), + schema: 2) + #expect(throws: RivetUpdateError.manifestMissing) { + try RivetUpdateService.verifyManifest( + wrapper, keyBase64: testConfiguration.publicKeyBase64, keyID: "test-key") + } +} + +@Test func malformedWrapperJSONIsRejected() { + #expect(throws: RivetUpdateError.manifestMissing) { + try RivetUpdateService.verifyManifest( + Data("not json".utf8), + keyBase64: testConfiguration.publicKeyBase64, keyID: "test-key") + } +} + +@Test func signedButUnparsablePayloadIsRejected() throws { + let wrapper = try wrapperData(payloadJSON: "{\"version\":") + #expect(throws: RivetUpdateError.network("manifest unparsable")) { + try RivetUpdateService.verifyManifest( + wrapper, keyBase64: testConfiguration.publicKeyBase64, keyID: "test-key") + } +} + +// MARK: - Version comparison + +@Test func versionComparisonIsNumericPerSegment() { + #expect(RivetUpdateService.isVersion("1.2", greaterThan: "1.1.9")) + #expect(RivetUpdateService.isVersion("0.1.2", greaterThan: "0.1.1")) + #expect(RivetUpdateService.isVersion("2.10.0", greaterThan: "2.9.9")) + #expect(!RivetUpdateService.isVersion("2.0.0", greaterThan: "2")) + #expect(!RivetUpdateService.isVersion("1.0.0", greaterThan: "1.0.0")) + #expect(!RivetUpdateService.isVersion("0.9", greaterThan: "1.0")) +} + +// MARK: - Artifact selection + +@Test func artifactSelectionMatchesPlatformAndArchitecture() throws { + let manifest = try RivetUpdateService.verifyManifest( + try wrapperData(payloadJSON: innerManifestJSON(version: "1.2.3")), + keyBase64: testConfiguration.publicKeyBase64, keyID: "test-key") + #expect(manifest.artifact(forPlatform: "macos", architecture: "arm64")? + .sha256 == "00") + #expect(manifest.artifact(forPlatform: "macos", architecture: "x64")? + .sha256 == "01") + #expect(manifest.artifact(forPlatform: "windows", architecture: "arm64") == nil) + #expect(manifest.artifact(forPlatform: "macos", architecture: "riscv") == nil) +} + +// MARK: - Configuration validation + +@Test func plainHTTPFeedIsRejectedOutsideLoopback() { + let config = RivetUpdateConfiguration( + feedURL: URL(string: "http://example.com/update-manifest.json")!, + publicKeyBase64: testConfiguration.publicKeyBase64, + keyID: "test-key", + bundleName: "Taskly.app") + #expect(throws: RivetUpdateError.invalidConfiguration("feed URL must use HTTPS outside loopback drills")) { + try RivetUpdateService(configuration: config) + } +} + +@Test func loopbackHTTPFeedIsAcceptedForDrills() throws { + let config = RivetUpdateConfiguration( + feedURL: URL(string: "http://127.0.0.1:8787/update-manifest.json")!, + publicKeyBase64: testConfiguration.publicKeyBase64, + keyID: "test-key", + bundleName: "Taskly.app") + _ = try RivetUpdateService(configuration: config) +} + +@Test func bareBundleNameIsRejected() { + let config = RivetUpdateConfiguration( + feedURL: testConfiguration.feedURL, + publicKeyBase64: testConfiguration.publicKeyBase64, + keyID: "test-key", + bundleName: "Taskly") + #expect(throws: RivetUpdateError.self) { + try RivetUpdateService(configuration: config) + } +} + +@Test func emptyKeyIDIsRejected() { + let config = RivetUpdateConfiguration( + feedURL: testConfiguration.feedURL, + publicKeyBase64: testConfiguration.publicKeyBase64, + keyID: "", + bundleName: "Taskly.app") + #expect(throws: RivetUpdateError.invalidConfiguration("key id must not be empty")) { + try RivetUpdateService(configuration: config) + } +} + +@Test func nonEd25519PublicKeyIsRejected() { + let config = RivetUpdateConfiguration( + feedURL: testConfiguration.feedURL, + publicKeyBase64: Data(repeating: 1, count: 16).base64EncodedString(), + keyID: "test-key", + bundleName: "Taskly.app") + #expect(throws: RivetUpdateError.badPublicKey) { + try RivetUpdateService(configuration: config) + } +} + +// MARK: - Installed-bundle detection + +@Test func installedBundleDetectionAppliesToInjectedPaths() throws { + let installed = try RivetUpdateService( + configuration: testConfiguration, + bundlePath: "/Applications/Taskly.app", + currentVersion: "1.0.0") + #expect(installed.installedBundleURL != nil) + #expect(installed.canUpdate) + + let userInstalled = try RivetUpdateService( + configuration: testConfiguration, + bundlePath: "/Users/tester/Applications/Taskly.app") + #expect(userInstalled.installedBundleURL != nil) + + let development = try RivetUpdateService( + configuration: testConfiguration, + bundlePath: "/tmp/rivet-build/Taskly.app") + #expect(development.installedBundleURL == nil) + #expect(!development.canUpdate) +} + +// MARK: - Swap script and hashing + +@Test func swapScriptQuotesPathsAndRestoresBackup() { + let script = RivetUpdateService.swapScript( + bundlePath: "/Applications/Ta skly.app", + newBundlePath: "/tmp/rivet-update/unpacked/Ta skly.app") + #expect(script.contains("sleep 1")) + #expect(script.contains("rm -rf '/Applications/Ta skly.app.old'")) + #expect(script.contains("mv '/Applications/Ta skly.app' '/Applications/Ta skly.app.old'")) + // The failed-move branch restores the previous bundle before exiting. + #expect(script.contains("mv '/Applications/Ta skly.app.old' '/Applications/Ta skly.app'")) + #expect(script.contains("open '/Applications/Ta skly.app'")) + #expect(script.hasSuffix("exit 1")) +} + +@Test func swapScriptEscapesSingleQuotesInPaths() { + let script = RivetUpdateService.swapScript( + bundlePath: "/Applications/it's.app", + newBundlePath: "/tmp/x/it's.app") + #expect(script.contains("'\\''")) +} + +@Test func sha256HexMatchesKnownVector() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("rivet-update-test-\(UUID()).txt") + try Data("abc".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + #expect(try RivetUpdateService.sha256Hex(ofFileAt: url) + == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") +} + +// MARK: - Networked tests (serialized: they share the URLProtocol stub) + +private enum StubResponse { + case success(Data) + case httpStatus(Int, Data) + case failure(URLError) + case parkForever +} + +private final class StubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) -> StubResponse)? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)) + return + } + switch handler(request) { + case .success(let data): + deliver(status: 200, data: data) + case .httpStatus(let status, let data): + deliver(status: status, data: data) + case .failure(let error): + client?.urlProtocol(self, didFailWithError: error) + case .parkForever: + break + } + } + + private func deliver(status: Int, data: Data) { + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", + headerFields: ["Content-Length": String(data.count)])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +private final class ProgressLog: @unchecked Sendable { + private let lock = NSLock() + private var values: [Int] = [] + func append(_ value: Int) { + lock.lock() + values.append(value) + lock.unlock() + } + var snapshot: [Int] { + lock.lock() + defer { lock.unlock() } + return values + } +} + +@Suite(.serialized) +struct RivetUpdateNetworkedTests { + private func stubbedConfiguration() -> URLSessionConfiguration { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubURLProtocol.self] + return config + } + + @Test func downloaderDeliversExactBytesWithMonotonicProgress() async throws { + let payload = Data((0..<200_000).map { UInt8(truncatingIfNeeded: $0) }) + StubURLProtocol.handler = { _ in .success(payload) } + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("rivet-update-test-\(UUID()).bin") + defer { try? FileManager.default.removeItem(at: destination) } + let progress = ProgressLog() + let downloader = RivetArtifactDownloader( + destination: destination, + expectedSize: payload.count, + sessionConfiguration: stubbedConfiguration(), + onProgress: { progress.append($0) }) + try await downloader.run(from: URL(string: "https://example.com/a.zip")!) + #expect(try Data(contentsOf: destination) == payload) + let values = progress.snapshot + #expect(values == values.sorted()) + #expect(values.allSatisfy { (0...100).contains($0) }) + if let last = values.last { #expect(last == 100) } + } + + @Test func downloaderFailsFastOnContentLengthMismatch() async throws { + let payload = Data(repeating: 7, count: 4096) + StubURLProtocol.handler = { _ in .success(payload) } + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("rivet-update-test-\(UUID()).bin") + defer { try? FileManager.default.removeItem(at: destination) } + let downloader = RivetArtifactDownloader( + destination: destination, + expectedSize: payload.count + 1, + sessionConfiguration: stubbedConfiguration(), + onProgress: { _ in }) + do { + try await downloader.run(from: URL(string: "https://example.com/a.zip")!) + Issue.record("expected a sizeMismatch failure") + } catch let error as RivetUpdateError { + guard case .sizeMismatch(let expected, let actual) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(expected == payload.count + 1) + #expect(actual == payload.count) + } + } + + @Test func downloaderSurfacesTransportFailures() async throws { + StubURLProtocol.handler = { _ in .failure(URLError(.notConnectedToInternet)) } + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("rivet-update-test-\(UUID()).bin") + defer { try? FileManager.default.removeItem(at: destination) } + let downloader = RivetArtifactDownloader( + destination: destination, + expectedSize: 128, + sessionConfiguration: stubbedConfiguration(), + onProgress: { _ in }) + do { + try await downloader.run(from: URL(string: "https://example.com/a.zip")!) + Issue.record("expected a transport failure") + } catch let error as URLError { + #expect(error.code == .notConnectedToInternet) + } + } + + @Test func downloaderCancellationAlwaysResumes() async throws { + StubURLProtocol.handler = { _ in .parkForever } + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("rivet-update-test-\(UUID()).bin") + defer { try? FileManager.default.removeItem(at: destination) } + let downloader = RivetArtifactDownloader( + destination: destination, + expectedSize: 128, + sessionConfiguration: stubbedConfiguration(), + onProgress: { _ in }) + let task = Task { + try await downloader.run(from: URL(string: "https://example.com/a.zip")!) + } + try await Task.sleep(for: .milliseconds(200)) + task.cancel() + do { + try await task.value + Issue.record("expected a cancellation failure") + } catch let error as URLError { + // Task cancellation invalidates the session, so the parked + // continuation always resumes promptly (the escape hatch a + // stuck download sheet needs). + #expect(error.code == .cancelled) + } + } + + @Test func checkReportsAvailableForNewerSignedRelease() async throws { + let wrapper = try wrapperData(payloadJSON: innerManifestJSON(version: "1.2.3")) + StubURLProtocol.handler = { _ in .success(wrapper) } + let service = try RivetUpdateService( + configuration: testConfiguration, + bundlePath: "/Applications/Taskly.app", + currentVersion: "1.0.0", + sessionConfiguration: stubbedConfiguration()) + let result = try await service.check() + guard case .available(let manifest) = result else { + Issue.record("expected an available update") + return + } + #expect(manifest.version == "1.2.3") + } + + @Test func checkReportsUpToDateForSameVersion() async throws { + let wrapper = try wrapperData(payloadJSON: innerManifestJSON(version: "1.0.0")) + StubURLProtocol.handler = { _ in .success(wrapper) } + let service = try RivetUpdateService( + configuration: testConfiguration, + bundlePath: "/Applications/Taskly.app", + currentVersion: "1.0.0", + sessionConfiguration: stubbedConfiguration()) + let result = try await service.check() + guard case .upToDate = result else { + Issue.record("expected up-to-date") + return + } + } + + @Test func checkRejectsBadHTTPStatus() async throws { + StubURLProtocol.handler = { _ in .httpStatus(404, Data("missing".utf8)) } + let service = try RivetUpdateService( + configuration: testConfiguration, + bundlePath: "/Applications/Taskly.app", + currentVersion: "1.0.0", + sessionConfiguration: stubbedConfiguration()) + do { + _ = try await service.check() + Issue.record("expected an HTTP failure") + } catch let error as RivetUpdateError { + guard case .network(let message) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(message.contains("404")) + } + } + + @Test func checkRefusesDevelopmentCopies() async throws { + // No network involved: the installed-bundle guard fires first. + let service = try RivetUpdateService( + configuration: testConfiguration, + bundlePath: "/tmp/rivet-build/Taskly.app") + await #expect(throws: RivetUpdateError.notInstalled) { + try await service.check() + } + } +} From ed0afea712890db26cd7d210e4cc9b912f875c67 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Tue, 13 Oct 2026 12:07:42 +0800 Subject: [PATCH 2/2] RivetUpdate: absorb the full stalled-OTA fix set into the handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same-day taskly verification (turinglambdaai/taskly@27ab8c6) exposed two more coupled defects behind the stuck OTA beyond the AsyncBytes EOF park; both belong in the shared module, not in each product: - The spawned swap script owns the work directory. An unconditional defer cleanup races the script's grace sleep and destroys the payload it is about to unpack; gate the cleanup on a swapLaunched flag and add cleanupStaleWorkDirectories() for hosts to sweep crashed attempts at startup (fresh directories are kept — another process may be updating). - A presented SwiftUI sheet silently vetoes NSApplication.terminate. The module now ends every sheet, terminates, and stops the run loop after a grace delay if the quit was deferred — a surviving old process holding a single-instance lease would otherwise quietly kill the relaunched update. Docs and changelog describe both handoff traps. 29 tests. --- CHANGELOG.md | 7 ++- docs/release-and-updates.md | 2 + .../Sources/RivetUpdate/RivetUpdate.swift | 58 +++++++++++++++++-- .../RivetUpdateTests/RivetUpdateTests.swift | 25 ++++++++ 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22582a14..c1ac4403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,12 @@ bundle version, and swaps atomically with rollback and relaunch. Configuration fails closed (HTTPS-only feeds outside loopback drills, raw-32-byte keys, bare `*.app` bundle names), task cancellation always - resumes promptly, and progress stays within 0…100. + resumes promptly, and progress stays within 0…100. The swap handoff owns + two traps verified on a real stalled OTA: the spawned script keeps the + work directory (`cleanupStaleWorkDirectories` sweeps crashed attempts at + startup), and a presented sheet vetoes `terminate` silently — the module + ends every sheet, terminates, and stops the run loop if the quit was + deferred. ## 0.8.0 diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index 75a6e2bf..0aa19f0f 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -145,6 +145,8 @@ The downloader is delegate-driven on purpose. Byte-iterating `URLSession.AsyncBy - A running transfer always offers an explicit Cancel affordance; task cancellation invalidates the session, so the awaited call always resumes promptly. - The update surface stays dismissable by gesture except during the final swap-and-relaunch step, which terminates the process anyway. +The handoff at the end of an install owns two further traps the module encodes: the spawned swap script takes ownership of the work directory (an eager `defer` cleanup would race the script's grace sleep and destroy the payload — call `RivetUpdateService.cleanupStaleWorkDirectories()` at startup to sweep directories left by crashed attempts), and a presented SwiftUI sheet silently vetoes `NSApplication.terminate`, so the module ends every sheet, terminates, and stops the run loop if the quit was deferred — a surviving old process could otherwise hold a single-instance lease and quietly kill the relaunched update. + Windows hosts update through the MSI transaction (`msiexec` with elevation), and Linux AppImage/portable installs should use the engine's verified `prepare-platform-installation` / `execute-platform-installation!` replacement instead of re-implementing atomic swap in the host. Sharing the `RivetUpdate` pattern on those platforms belongs to their host story, not to each product. ## External validation points diff --git a/platform/macos/Sources/RivetUpdate/RivetUpdate.swift b/platform/macos/Sources/RivetUpdate/RivetUpdate.swift index 05fcdbd9..5c3aecac 100644 --- a/platform/macos/Sources/RivetUpdate/RivetUpdate.swift +++ b/platform/macos/Sources/RivetUpdate/RivetUpdate.swift @@ -343,7 +343,13 @@ public final class RivetUpdateService: Sendable { try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) let zipURL = workDir.appendingPathComponent("update.zip") - defer { try? FileManager.default.removeItem(at: workDir) } + var swapLaunched = false + defer { + // Once the swap script is spawned it owns the work dir (it + // unpacks from there after we are gone); deleting it here would + // race the script's grace sleep and destroy the payload. + if !swapLaunched { try? FileManager.default.removeItem(at: workDir) } + } let downloader = RivetArtifactDownloader(destination: zipURL, expectedSize: artifact.size, @@ -399,11 +405,34 @@ public final class RivetUpdateService: Sendable { try await Self.runProcess("/bin/chmod", arguments: ["+x", swapPath.path]) // Launch the swap, then leave through the normal exit path so - // AppKit tears the app down cleanly; the script's sleep 1 gives us - // time to quit before it moves the bundle. - try await Self.runProcess("/bin/bash", arguments: [swapPath.path]) + // AppKit tears the app down cleanly; the script's grace sleep gives + // us time to quit before it moves the bundle. + let bash = Process() + bash.executableURL = URL(fileURLWithPath: "/bin/bash") + bash.arguments = [swapPath.path] + do { + try bash.run() + swapLaunched = true + } catch { + throw error + } #if canImport(AppKit) - await MainActor.run { NSApplication.shared.terminate(nil) } + await MainActor.run { + // A presented SwiftUI sheet vetoes NSApplication.terminate + // silently; end every sheet first so the quit sequence runs. + for window in NSApplication.shared.windows { + for sheet in window.sheets { + window.endSheet(sheet) + } + } + NSApplication.shared.terminate(nil) + } + // If terminate was still vetoed (or deferred), stop the run loop so + // the app leaves inside the swap script's sleep window — otherwise + // a surviving old process can hold a single-instance lease and + // quietly kill the relaunched update. + try? await Task.sleep(nanoseconds: 1_500_000_000) + await MainActor.run { NSApplication.shared.stop(nil) } #else exit(0) #endif @@ -475,6 +504,25 @@ public final class RivetUpdateService: Sendable { } return false } + + /// Removes work directories left behind by update attempts that died + /// before their swap script finished. Call once at application startup; + /// fresh directories (an in-flight update from another process) are + /// kept. + public static func cleanupStaleWorkDirectories(olderThan interval: TimeInterval = 1800) { + let temporary = FileManager.default.temporaryDirectory + guard let entries = try? FileManager.default.contentsOfDirectory( + at: temporary, + includingPropertiesForKeys: [.contentModificationDateKey], + options: .skipsHiddenFiles) else { return } + for entry in entries where entry.lastPathComponent.hasPrefix("rivet-update-") { + if let date = try? entry.resourceValues( + forKeys: [.contentModificationDateKey]).contentModificationDate, + Date().timeIntervalSince(date) > interval { + try? FileManager.default.removeItem(at: entry) + } + } + } } /// One-artifact file download driven by URLSessionDownloadDelegate. diff --git a/platform/macos/Tests/RivetUpdateTests/RivetUpdateTests.swift b/platform/macos/Tests/RivetUpdateTests/RivetUpdateTests.swift index 2d94d0b1..2b6c045f 100644 --- a/platform/macos/Tests/RivetUpdateTests/RivetUpdateTests.swift +++ b/platform/macos/Tests/RivetUpdateTests/RivetUpdateTests.swift @@ -269,6 +269,31 @@ private var testConfiguration: RivetUpdateConfiguration { == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") } +@Test func staleWorkDirectoryCleanupKeepsFreshDirectories() throws { + let temporary = FileManager.default.temporaryDirectory + let stale = temporary.appendingPathComponent("rivet-update-\(UUID())") + let fresh = temporary.appendingPathComponent("rivet-update-\(UUID())") + let unrelated = temporary.appendingPathComponent("unrelated-\(UUID())") + for directory in [stale, fresh, unrelated] { + try FileManager.default.createDirectory(at: directory, + withIntermediateDirectories: true) + } + defer { + for directory in [stale, fresh, unrelated] { + try? FileManager.default.removeItem(at: directory) + } + } + let ancient = Date(timeIntervalSinceNow: -7200) + try FileManager.default.setAttributes([.modificationDate: ancient], + ofItemAtPath: stale.path) + try FileManager.default.setAttributes([.modificationDate: ancient], + ofItemAtPath: unrelated.path) + RivetUpdateService.cleanupStaleWorkDirectories() + #expect(!FileManager.default.fileExists(atPath: stale.path)) + #expect(FileManager.default.fileExists(atPath: fresh.path)) + #expect(FileManager.default.fileExists(atPath: unrelated.path)) +} + // MARK: - Networked tests (serialized: they share the URLProtocol stub) private enum StubResponse {