Repository navigation
Refactor: extract Sparkle self-update into CmuxUpdater package (WIP) #5132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d30a334
fab0380
954e406
d89be8e
0a70386
c551e7f
b857ab4
37d0bf9
eb490c1
da2b5d7
78fe53c
2063895
3fce1c2
2b82a24
a467b85
8b16a5e
afd8fad
df968da
296dbbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| // swift-tools-version: 6.0 | ||
|
|
||
| import PackageDescription | ||
|
|
||
| let package = Package( | ||
| name: "CmuxUpdater", | ||
| platforms: [ | ||
| .macOS(.v14), | ||
| ], | ||
| products: [ | ||
| .library( | ||
| name: "CmuxUpdater", | ||
| targets: ["CmuxUpdater"] | ||
| ), | ||
| ], | ||
| dependencies: [ | ||
| .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.5.1"), | ||
| ], | ||
| targets: [ | ||
| .target( | ||
| name: "CmuxUpdater", | ||
| dependencies: [ | ||
| .product(name: "Sparkle", package: "Sparkle"), | ||
| ], | ||
| swiftSettings: [ | ||
| .swiftLanguageMode(.v6), | ||
| .enableUpcomingFeature("ExistentialAny"), | ||
| .enableUpcomingFeature("InternalImportsByDefault"), | ||
| ] | ||
| ), | ||
| .testTarget( | ||
| name: "CmuxUpdaterTests", | ||
| dependencies: ["CmuxUpdater"], | ||
| swiftSettings: [ | ||
| .swiftLanguageMode(.v6), | ||
| .enableUpcomingFeature("ExistentialAny"), | ||
| .enableUpcomingFeature("InternalImportsByDefault"), | ||
| ] | ||
| ), | ||
| ] | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| #if DEBUG | ||
| import Foundation | ||
| @preconcurrency import Sparkle | ||
|
|
||
| /// Drives the ``UpdateStateModel`` into specific states for UI tests, and can run a mock feed | ||
| /// check against an in-process appcast (``UpdateTestURLProtocol``). | ||
| /// | ||
| /// DEBUG-only test scaffolding, gated on `CMUX_UI_TEST_*` environment variables. Constructed by | ||
| /// the host with the live model and logger. | ||
| @MainActor | ||
| public struct UpdateTestSupport { | ||
| let model: UpdateStateModel | ||
| let log: any UpdateLogging | ||
|
|
||
| /// Creates the test-support helper bound to a model and logger. | ||
| public init(model: UpdateStateModel, log: any UpdateLogging) { | ||
| self.model = model | ||
| self.log = log | ||
| } | ||
|
|
||
| /// Applies an initial detected-update banner and/or a forced state from the environment. | ||
| public func applyIfNeeded() { | ||
| let env = ProcessInfo.processInfo.environment | ||
| guard env["CMUX_UI_TEST_MODE"] == "1" else { return } | ||
|
|
||
| if let detectedVersion = env["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"], | ||
| !detectedVersion.isEmpty { | ||
| if let item = Self.makeAppcastItem(displayVersion: detectedVersion) { | ||
| model.recordDetectedUpdate(item) | ||
| } else { | ||
| model.debugSetDetectedVersion(UpdateStateModel.normalizedDetectedUpdateVersion(from: detectedVersion)) | ||
| } | ||
| } | ||
|
|
||
| guard let state = env["CMUX_UI_TEST_UPDATE_STATE"] else { return } | ||
| switch state { | ||
| case "available": | ||
| let version = env["CMUX_UI_TEST_UPDATE_VERSION"] ?? "9.9.9" | ||
| transition(to: .updateAvailable(.init( | ||
| appcastItem: Self.makeAppcastItem(displayVersion: version) ?? SUAppcastItem.empty(), | ||
| reply: { _ in } | ||
| ))) | ||
| case "notFound": | ||
| transition(to: .notFound(.init(acknowledgement: {}))) | ||
| case "downloading": | ||
| transition(to: .downloading(.init(cancel: {}, expectedLength: 100, progress: 50))) | ||
| case "extracting": | ||
| transition(to: .extracting(.init(progress: 0.5))) | ||
| case "installing": | ||
| transition(to: .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {}))) | ||
| case "error": | ||
| let message = env["CMUX_UI_TEST_UPDATE_ERROR_MESSAGE"] ?? "Test update error" | ||
| let error = NSError(domain: "cmux.update.uitest", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) | ||
| transition(to: .error(.init( | ||
| error: error, | ||
| retry: {}, | ||
| dismiss: {}, | ||
| technicalDetails: "ui-test error", | ||
| feedURLString: env["CMUX_UI_TEST_FEED_URL"] | ||
| ))) | ||
| default: | ||
| break | ||
| } | ||
| } | ||
|
|
||
| /// Runs a mock feed check against `CMUX_UI_TEST_FEED_URL` if requested. Returns whether it ran. | ||
| public func performMockFeedCheckIfNeeded() -> Bool { | ||
| let env = ProcessInfo.processInfo.environment | ||
| guard env["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" else { return false } | ||
| guard let feedURLString = env["CMUX_UI_TEST_FEED_URL"], | ||
| let feedURL = URL(string: feedURLString) else { return false } | ||
|
|
||
| log.append("ui test mock feed check: \(feedURLString)") | ||
| UpdateTestURLProtocol.registerIfNeeded() | ||
| model.applyDriverState(.checking(.init(cancel: {}))) | ||
|
|
||
| let model = self.model | ||
| let task = URLSession.shared.dataTask(with: feedURL) { data, _, _ in | ||
| let xml = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" | ||
| let version = env["CMUX_UI_TEST_UPDATE_VERSION"] ?? "9.9.9" | ||
| let hasItem = xml.contains("<item>") | ||
| let delayMilliseconds = Int(env["CMUX_UI_TEST_MOCK_FEED_DELAY_MS"] ?? "") ?? 0 | ||
| Task { @MainActor in | ||
| if delayMilliseconds > 0 { | ||
| try? await Task.sleep(for: .milliseconds(delayMilliseconds)) | ||
| } | ||
| if hasItem { | ||
| let appcastItem = Self.makeAppcastItem(displayVersion: version) ?? SUAppcastItem.empty() | ||
| model.applyDriverState(.updateAvailable(.init(appcastItem: appcastItem, reply: { _ in }))) | ||
| } else { | ||
| model.applyDriverState(.notFound(.init(acknowledgement: {}))) | ||
| } | ||
| } | ||
| } | ||
| task.resume() | ||
| return true | ||
| } | ||
|
|
||
| private func transition(to state: UpdateState) { | ||
| model.applyDriverState(.checking(.init(cancel: {}))) | ||
| let model = self.model | ||
| Task { @MainActor in | ||
| try? await Task.sleep(for: .milliseconds(200)) | ||
| model.applyDriverState(state) | ||
| } | ||
| } | ||
|
Comment on lines
+99
to
+106
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intentional, and allowed by the same CLAUDE.md rule: 'Deterministic sleeps remain fine in tests and explicit test-only scaffolding.' UpdateTestSupport is #if DEBUG, gated on CMUX_UI_TEST_MODE, and exists only to stage UI-test states - the bounded/injected-clock requirement targets shipping runtime code. The two Task.sleep calls are deterministic test-only staging delays, so I'm leaving them rather than threading a clock seam into DEBUG scaffolding. — Claude Code |
||
|
|
||
| private static func makeAppcastItem(displayVersion: String) -> SUAppcastItem? { | ||
| let enclosure: [String: Any] = [ | ||
| "url": "https://example.com/cmux.zip", | ||
| "length": "1024", | ||
| "sparkle:version": displayVersion, | ||
| "sparkle:shortVersionString": displayVersion, | ||
| ] | ||
| let dict: [String: Any] = [ | ||
| "title": "cmux \(displayVersion)", | ||
| "pubDate": "Wed, 25 Mar 2026 12:00:00 +0000", | ||
| "enclosure": enclosure, | ||
| ] | ||
| return SUAppcastItem(dictionary: dict) | ||
| } | ||
| } | ||
| #endif | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| /// The host-application actions the updater needs but cannot perform itself. | ||
| /// | ||
| /// This is the dependency-inversion seam between the `CmuxUpdater` package and the app: the | ||
| /// package calls up through this protocol instead of reaching `AppDelegate`/`TerminalController` | ||
| /// directly. The app's delegate conforms and is injected into ``UpdateController`` (which holds | ||
| /// it `weak`). | ||
| @MainActor | ||
| public protocol UpdateActionDelegate: AnyObject { | ||
| /// The user asked to retry after an update error. The host should re-initiate a check | ||
| /// through its normal entry point. | ||
| func updaterRequestsRetryCheckForUpdates() | ||
|
|
||
| /// Sparkle is about to relaunch the app to finish installing. The host should persist | ||
| /// session state, stop its terminal/runtime, and invalidate restorable state so the | ||
| /// relaunched instance starts cleanly. | ||
| func updaterWillRelaunchApplication() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| /// An injectable clock for the updater's bounded, cancellable UI delays (minimum check | ||
| /// display, "no updates" auto-dismiss, ready-timeout). | ||
| /// | ||
| /// Production uses ``SystemUpdateClock`` (a real `Task.sleep`); tests inject a fake that | ||
| /// returns immediately or coordinates timing, so delay-driven behavior is verified with no | ||
| /// real waiting. The delays this clock drives are *intended* delays, never a poll or a | ||
| /// "let state settle" hack. | ||
| public protocol UpdateClock: Sendable { | ||
| /// Suspends for `duration`, throwing `CancellationError` if the surrounding task is | ||
| /// cancelled first. | ||
| func sleep(for duration: Duration) async throws | ||
| } | ||
|
|
||
| /// The production ``UpdateClock``, backed by `Task.sleep`. | ||
| public struct SystemUpdateClock: UpdateClock { | ||
| /// Creates a system clock. | ||
| public init() {} | ||
|
|
||
| public func sleep(for duration: Duration) async throws { | ||
| // Bounded, cancellable, intended delay behind the UpdateClock seam (policy carve-out). | ||
| try await Task.sleep(for: duration) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Sparkle version constraint
from: "2.5.1"contradicts PR description's stated intent to pin Sparkle to 2.8.1. The loose range allows SPM to resolve to versions below 2.8.1 that lack the@preconcurrencycompatibility fixes the PR claims were added, which could cause build failures on fresh checkouts or CI environments without a Package.resolved.Prompt for AI agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mitigated. The @preconcurrency Sparkle import makes the code compile against any 2.x (2.8.1 or 2.9.x), so a fresh-checkout resolution difference won't break the build; the committed Package.resolved pins 2.8.1 to match nightly; and keeping the requirement at 'from: 2.5.1' matches the app target's own Sparkle requirement so the combined Xcode resolution unifies cleanly without this leaf package imposing a tighter cap on the whole app.
— Claude Code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mitigated. The @preconcurrency Sparkle import lets the code compile against any 2.x (2.8.1 or 2.9.x), so a fresh-checkout resolution difference won't break the build; the committed Package.resolved pins 2.8.1 (matching nightly); and 'from: 2.5.1' matches the app target's own Sparkle requirement so the combined Xcode resolution unifies without this leaf package capping the whole app.
— Claude Code