diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ed0c98..b6ff31c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} run: | set -euo pipefail ./script/validate_version.sh "$GITHUB_REF_NAME" @@ -46,9 +47,10 @@ jobs: "$MACOS_SIGNING_IDENTITY" \ "$APPLE_API_KEY_ID" \ "$APPLE_API_ISSUER_ID" \ - "$APPLE_API_PRIVATE_KEY"; do + "$APPLE_API_PRIVATE_KEY" \ + "$SPARKLE_ED_PRIVATE_KEY"; do if [[ -z "$value" ]]; then - echo "signed releases require all documented signing and notarization secrets" >&2 + echo "signed releases require all documented signing, notarization, and Sparkle secrets" >&2 exit 1 fi done @@ -96,6 +98,7 @@ jobs: API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} run: | set -euo pipefail if [[ "$RELEASE_MODE" == "unsigned-prerelease" ]]; then @@ -114,6 +117,7 @@ jobs: --api-key-id "$API_KEY_ID" \ --api-issuer-id "$API_ISSUER_ID" rm -f "$API_KEY_PATH" + ./script/generate_appcast.sh - name: Publish GitHub release env: @@ -141,6 +145,7 @@ jobs: gh release create "$GITHUB_REF_NAME" \ "$ARCHIVE" \ "$ARCHIVE.sha256" \ + "dist/appcast.xml" \ --verify-tag \ --generate-notes \ --title "Agent Notch $VERSION" diff --git a/CHANGELOG.md b/CHANGELOG.md index b5630ed..02cbfb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,17 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- In-app updates download and install from GitHub Releases. Check, Download, + then Restart to Update, the same two-step flow as T3 Code. Homebrew marks + the cask as self-updating. + ### Changed +- Update checks use the Sparkle appcast instead of opening the GitHub releases + page. Automatic checks default on, run at launch and once a day, and never + download or install until you ask. - Notch controls show hover and press. Waiting-prompt shortcuts activate after a click and remain active only while the pointer is over the prompt. - Primary actions pick black or white text from the system accent so light @@ -15,6 +24,12 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- Retry after a Sparkle startup failure starts the updater again instead of + doing nothing until relaunch. +- An ineligible update (newer macOS required, and similar Sparkle reasons) no + longer shows as "Up to date". +- Check for Updates from the app menu opens Settings → General so the result + is visible. - Completed sessions can remain in Activity Center for three or seven days, but never longer than seven. - Git worktrees show the main repository as the project, not the worktree diff --git a/Casks/agent-notch.rb b/Casks/agent-notch.rb index 647147b..59fc369 100644 --- a/Casks/agent-notch.rb +++ b/Casks/agent-notch.rb @@ -9,19 +9,10 @@ livecheck do url :url - regex(/^v?(\d+(?:\.\d+)+)$/i) - strategy :github_releases do |json, regex| - json.map do |release| - next if release["draft"] - - match = release["tag_name"]&.match(regex) - next if match.blank? - - match[1] - end - end + strategy :github_latest end + auto_updates true depends_on macos: :sonoma depends_on arch: :arm64 diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..8cffaa6 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "ca3d6d23f1ec2cbae233f5c906e8420c58cc5a7b46028b363bde204eda3a283c", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a", + "version" : "2.9.6" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 134cc22..cafb835 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,9 @@ let package = Package( .executable(name: "AgentsNotch", targets: ["AgentsNotch"]), .executable(name: "AgentsNotchHook", targets: ["AgentsNotchHook"]), ], + dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.6"), + ], targets: [ .target( name: "AgentsNotchCore", @@ -19,7 +22,10 @@ let package = Package( ), .executableTarget( name: "AgentsNotch", - dependencies: ["AgentsNotchCore"], + dependencies: [ + "AgentsNotchCore", + .product(name: "Sparkle", package: "Sparkle"), + ], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v6)] ), @@ -30,7 +36,12 @@ let package = Package( ), .testTarget( name: "AgentsNotchTests", - dependencies: ["AgentsNotchCore", "AgentsNotch", "AgentsNotchHook"], + dependencies: [ + "AgentsNotchCore", + "AgentsNotch", + "AgentsNotchHook", + .product(name: "Sparkle", package: "Sparkle"), + ], swiftSettings: [.swiftLanguageMode(.v6)] ), ] diff --git a/README.md b/README.md index f5299e0..a5a1a12 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,10 @@ brew install --cask aforno/agentnotch/agent-notch Homebrew 6 does not trust a third-party tap when you add it. The fully qualified name trusts only this cask. -Later cask bumps land on `main`. Update with `brew update` and -`brew upgrade --cask aforno/agentnotch/agent-notch`. +Later cask bumps land on `main`. Packaged builds also update themselves: +Settings → General → Check for Updates, then Download, then Restart to Update. +Homebrew knows the app self-updates (`auto_updates true`). `brew upgrade --cask` +is still available if you prefer it. ### Manual @@ -250,9 +252,10 @@ waiting sessions stay waiting. Other actives enter a short `unknown` (Reconnecting) grace period until a live hook arrives or the grace expires. Session history stays on the machine. No analytics, no source upload, no remote -telemetry. If update checking is on, the app makes an HTTPS request to GitHub -Releases at most once a day. Manual checks use the same endpoint. Notifications -are opt-in and delivered by macOS. +telemetry. If update checking is on, the app fetches the Sparkle appcast from +GitHub Releases at launch and at most once a day. Choosing Download fetches +that release ZIP. Sparkle verifies the EdDSA signature and Developer ID before +replacing the app. Notifications are opt-in and delivered by macOS. ## Remove Agent Notch @@ -282,8 +285,8 @@ swift test -c release The ad-hoc package checks release configuration and bundle structure. Do not distribute it. Maintainers follow [`docs/RELEASING.md`](docs/RELEASING.md) for -Developer ID signing, notarization, stapling, checksums, and GitHub release -automation. +Developer ID signing, notarization, stapling, checksums, Sparkle appcast +signing, and GitHub release automation. ## Security and contributing diff --git a/Resources/SparklePublicEDKey b/Resources/SparklePublicEDKey new file mode 100644 index 0000000..b88498f --- /dev/null +++ b/Resources/SparklePublicEDKey @@ -0,0 +1 @@ +E24M1Ba8TfxAO1ELG+M83cczUzxBoP49t60vOlX9gfc= diff --git a/SECURITY.md b/SECURITY.md index 7f20778..3686fc8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,6 +56,10 @@ It modifies only its own entries in supported provider hook configuration. source, or send analytics or telemetry. The one Codex exception is a fail-open 4 MiB tail read used only to decide whether a PermissionRequest is waiting on a person; missing context stays visible. +- Update checks fetch only the GitHub Releases Sparkle appcast. The ZIP is + downloaded after the user chooses Download. Sparkle verifies the EdDSA + signature against the baked-in public key and the Developer ID signature + before replacing the running app. System profile data is not sent. - Integration changes are atomic and idempotent, preserve unrelated settings, preserve restrictive file permissions and symlinked dotfiles, and remove only Agent Notch entries. diff --git a/Sources/AgentsNotch/App/AgentsNotchApp.swift b/Sources/AgentsNotch/App/AgentsNotchApp.swift index a94ca0a..d9003e8 100644 --- a/Sources/AgentsNotch/App/AgentsNotchApp.swift +++ b/Sources/AgentsNotch/App/AgentsNotchApp.swift @@ -23,7 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { "notchEnabled": true, "showVirtualNotch": false, "hasCompletedOnboarding": false, - "automaticallyCheckForUpdates": false, + "automaticallyCheckForUpdates": true, "privacyModeEnabled": false, "answerFromNotchEnabled": false, "globalActivityShortcut": GlobalActivityShortcut.off.rawValue, @@ -42,7 +42,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { runtime.panelController = panel runtime.openActivityCenterHandler = { [weak self] in self?.showActivityCenter() } runtime.openOnboardingHandler = { [weak self] in self?.showOnboarding() } - runtime.openSettingsHandler = { [weak self] in self?.showSettings() } + runtime.openSettingsHandler = { [weak self] pane in self?.showSettings(pane: pane) } let shortcutController = GlobalActivityShortcutController { [weak self] in self?.runtime.openActivityCenter() } @@ -91,11 +91,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { controller.show() } - private func showSettings() { + private func showSettings(pane: SettingsPane? = nil) { let controller = settingsWindowController ?? SettingsWindowController(runtime: runtime) controller.onClose = { [weak self] in self?.settingsWindowController = nil } settingsWindowController = controller - controller.show() + controller.show(pane: pane) } } @@ -117,6 +117,11 @@ struct AgentsNotchApp: App { } .keyboardShortcut(",", modifiers: [.command]) } + CommandGroup(after: .appInfo) { + Button("Check for Updates…") { + appDelegate.runtime.updates.check() + } + } CommandMenu("Agents") { Button("Open Activity Center") { diff --git a/Sources/AgentsNotch/App/AppRuntime.swift b/Sources/AgentsNotch/App/AppRuntime.swift index 970658f..7218594 100644 --- a/Sources/AgentsNotch/App/AppRuntime.swift +++ b/Sources/AgentsNotch/App/AppRuntime.swift @@ -26,7 +26,7 @@ final class AppRuntime { private(set) var activitySearchRequest: UInt64 = 0 var openActivityCenterHandler: (() -> Void)? var openOnboardingHandler: (() -> Void)? - var openSettingsHandler: (() -> Void)? + var openSettingsHandler: ((SettingsPane?) -> Void)? var updateGlobalShortcutHandler: ((String) -> Void)? weak var panelController: NotchPanelController? @@ -124,6 +124,12 @@ final class AppRuntime { #if DEBUG simulator = DebugEventSimulator(activity: activity) #endif + updates.willInstall = { [weak self] in + self?.stop() + } + updates.presentStatus = { [weak self] in + self?.openSettings(pane: .general) + } notifications.onOpenSession = { [weak self] sessionID in self?.presentSession(sessionID) } @@ -138,7 +144,7 @@ final class AppRuntime { guard await restorePersistedState(generation: generation) else { return } completeRestoration(generation: generation) guard await startProviderMonitoring(generation: generation) else { return } - updates.checkAutomaticallyIfNeeded() + updates.start() } private func beginStartup() -> Int? { @@ -590,8 +596,8 @@ final class AppRuntime { openOnboardingHandler?() } - func openSettings() { - openSettingsHandler?() + func openSettings(pane: SettingsPane? = nil) { + openSettingsHandler?(pane) } func updateGlobalShortcut(_ rawValue: String) { diff --git a/Sources/AgentsNotch/Services/SparkleUpdateDriver.swift b/Sources/AgentsNotch/Services/SparkleUpdateDriver.swift new file mode 100644 index 0000000..845f082 --- /dev/null +++ b/Sources/AgentsNotch/Services/SparkleUpdateDriver.swift @@ -0,0 +1,170 @@ +import Foundation +import Sparkle + +/// Sparkle user driver that never presents Sparkle windows. Status lives in +/// `UpdateService` so Settings can check, download, then restart like T3 Code. +@MainActor +final class SparkleUpdateDriver: NSObject, SPUUserDriver { + weak var service: UpdateService? + + private var expectedContentLength: UInt64 = 0 + private var receivedContentLength: UInt64 = 0 + + func show( + _ request: SPUUpdatePermissionRequest, + reply: @escaping (SUUpdatePermissionResponse) -> Void + ) { + _ = request + let enabled = UserDefaults.standard.bool(forKey: UpdateService.automaticChecksDefaultsKey) + reply(SUUpdatePermissionResponse(automaticUpdateChecks: enabled, sendSystemProfile: false)) + } + + func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { + service?.noteUserInitiatedCheck() + _ = cancellation + } + + func showUpdateFound( + with appcastItem: SUAppcastItem, + state: SPUUserUpdateState, + reply: @escaping (SPUUserUpdateChoice) -> Void + ) { + if appcastItem.isInformationOnlyUpdate { + reply(.dismiss) + service?.noteCheckFailed("This update cannot be installed from inside Agent Notch.") + return + } + + let version = appcastItem.displayVersionString + switch state.stage { + case .notDownloaded: + service?.noteUpdateAvailable(version: version, download: reply) + case .downloaded: + service?.noteDownloaded(version: version, install: reply) + case .installing: + service?.noteInstalling(install: reply) + @unknown default: + reply(.dismiss) + } + } + + func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {} + + func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {} + + func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) { + switch sparkleNoUpdateOutcome(error) { + case .currentVersion: + service?.noteNoUpdate() + case let .unavailable(message): + service?.noteCheckFailed(message) + } + acknowledgement() + } + + func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) { + service?.noteUpdaterError(error.localizedDescription) + acknowledgement() + } + + func showDownloadInitiated(cancellation: @escaping () -> Void) { + expectedContentLength = 0 + receivedContentLength = 0 + service?.noteDownloadStarted() + _ = cancellation + } + + func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { + self.expectedContentLength = expectedContentLength + reportDownloadProgress() + } + + func showDownloadDidReceiveData(ofLength length: UInt64) { + receivedContentLength += length + reportDownloadProgress() + } + + func showDownloadDidStartExtractingUpdate() { + service?.noteDownloadProgress(1) + } + + func showExtractionReceivedProgress(_ progress: Double) { + service?.noteDownloadProgress(1) + } + + func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) { + service?.noteReadyToInstall(install: reply) + } + + func showInstallingUpdate( + withApplicationTerminated applicationTerminated: Bool, + retryTerminatingApplication: @escaping () -> Void + ) { + service?.noteInstalling() + _ = applicationTerminated + _ = retryTerminatingApplication + } + + func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { + acknowledgement() + } + + func dismissUpdateInstallation() { + expectedContentLength = 0 + receivedContentLength = 0 + service?.noteSessionEnded() + } + + func showUpdateInFocus() { + service?.presentStatusSurface() + } + + private func reportDownloadProgress() { + guard expectedContentLength > 0 else { return } + let percent = Double(receivedContentLength) / Double(expectedContentLength) + service?.noteDownloadProgress(percent) + } +} + +enum SparkleNoUpdateOutcome: Equatable { + case currentVersion + case unavailable(String) +} + +/// Sparkle's "no update" callback is not "you are on the latest version". +/// An update may exist and still be ineligible (older macOS, Intel Mac, etc.). +func sparkleNoUpdateOutcome(_ error: Error) -> SparkleNoUpdateOutcome { + let nsError = error as NSError + if let reason = sparkleNoUpdateReason(from: nsError) { + switch reason { + case .onLatestVersion, .onNewerThanLatestVersion: + return .currentVersion + case .unknown, .systemIsTooOld, .systemIsTooNew, .hardwareDoesNotSupportARM64: + return .unavailable(sparklePresentedMessage(nsError)) + @unknown default: + return .unavailable(sparklePresentedMessage(nsError)) + } + } + return .unavailable(sparklePresentedMessage(nsError)) +} + +private func sparkleNoUpdateReason(from error: NSError) -> SPUNoUpdateFoundReason? { + let value = error.userInfo[SPUNoUpdateFoundReasonKey] + if let reason = value as? SPUNoUpdateFoundReason { + return reason + } + if let number = value as? NSNumber { + return SPUNoUpdateFoundReason(rawValue: number.int32Value) + } + return nil +} + +private func sparklePresentedMessage(_ error: NSError) -> String { + let suggestion = error.localizedRecoverySuggestion? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let suggestion, !suggestion.isEmpty { + return suggestion + } + let description = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return description.isEmpty ? "A new update is not available." : description +} diff --git a/Sources/AgentsNotch/Services/UpdateService.swift b/Sources/AgentsNotch/Services/UpdateService.swift index 590860c..b415245 100644 --- a/Sources/AgentsNotch/Services/UpdateService.swift +++ b/Sources/AgentsNotch/Services/UpdateService.swift @@ -1,96 +1,193 @@ -import AppKit import Foundation import Observation +import os +import Sparkle @Observable @MainActor final class UpdateService { - enum State: Equatable { - case idle - case checking - case upToDate - case noRelease - case available(version: String) - case failed(String) - } + static let automaticChecksDefaultsKey = "automaticallyCheckForUpdates" + static let packagedOnlyMessage = "Automatic updates are only available in packaged production builds." + + private(set) var state: UpdateState = .idle + private(set) var lastError: String? - private(set) var state: State = .idle + /// Called just before Sparkle quits the process to swap in the new app. + var willInstall: (() -> Void)? + /// Opens Settings → General so a user-initiated check has a visible result. + var presentStatus: (() -> Void)? - private let latestReleaseURL = URL(string: "https://api.github.com/repos/Aforno/AgentNotch/releases/latest")! - private let releasePageURL = URL(string: "https://github.com/Aforno/AgentNotch/releases/latest")! - private let openURL: (URL) -> Void + private var updater: SPUUpdater? + private var driver: SparkleUpdateDriver? + private var foundReply: ((SPUUserUpdateChoice) -> Void)? + private var installReply: ((SPUUserUpdateChoice) -> Void)? + private var started = false + private static let logger = Logger(subsystem: "com.afonsoferreira.AgentNotch", category: "updates") - init(openURL: @escaping (URL) -> Void = { _ = NSWorkspace.shared.open($0) }) { - self.openURL = openURL + var currentVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" } - func checkAutomaticallyIfNeeded() { - guard UserDefaults.standard.bool(forKey: "automaticallyCheckForUpdates") else { return } - let lastCheck = UserDefaults.standard.object(forKey: "lastUpdateCheck") as? Date ?? .distantPast - guard Date().timeIntervalSince(lastCheck) >= 24 * 60 * 60 else { return } - Task { await check() } + static var hostCanUseSparkle: Bool { + let bundle = Bundle.main + guard bundle.bundleIdentifier == "com.afonsoferreira.AgentNotch" else { return false } + guard bundle.bundlePath.hasSuffix(".app") else { return false } + guard bundle.object(forInfoDictionaryKey: "SUFeedURL") is String else { return false } + guard bundle.object(forInfoDictionaryKey: "SUPublicEDKey") is String else { return false } + return FileManager.default.fileExists( + atPath: bundle.privateFrameworksPath.map { "\($0)/Sparkle.framework" } ?? "" + ) } - func check() async { - guard state != .checking else { return } - state = .checking + func start() { + guard !started else { return } + guard Self.hostCanUseSparkle else { + started = true + state = .unavailable(Self.packagedOnlyMessage) + return + } + + let driver = SparkleUpdateDriver() + driver.service = self + let updater = SPUUpdater( + hostBundle: .main, + applicationBundle: .main, + userDriver: driver, + delegate: nil + ) + updater.automaticallyDownloadsUpdates = false + updater.sendsSystemProfile = false do { - var request = URLRequest(url: latestReleaseURL) - request.timeoutInterval = 10 - request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - request.setValue("AgentNotch/\(currentVersion)", forHTTPHeaderField: "User-Agent") - let configuration = URLSessionConfiguration.ephemeral - configuration.waitsForConnectivity = false - let (data, response) = try await URLSession(configuration: configuration).data(for: request) - guard let http = response as? HTTPURLResponse else { - throw UpdateError.invalidResponse - } - if http.statusCode == 404 { - state = .noRelease - UserDefaults.standard.set(Date(), forKey: "lastUpdateCheck") - return - } - guard (200..<300).contains(http.statusCode) else { - throw UpdateError.http(http.statusCode) + try updater.start() + // Only latch after Sparkle accepts the session so Retry can start() again. + started = true + updater.automaticallyChecksForUpdates = UserDefaults.standard.bool( + forKey: Self.automaticChecksDefaultsKey + ) + self.driver = driver + self.updater = updater + if updater.automaticallyChecksForUpdates { + updater.checkForUpdatesInBackground() } - let release = try JSONDecoder().decode(GitHubRelease.self, from: data) - let version = release.tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV")) - if version.compare(currentVersion, options: .numeric) == .orderedDescending { - state = .available(version: version) - } else { - state = .upToDate - } - UserDefaults.standard.set(Date(), forKey: "lastUpdateCheck") } catch { + Self.logger.error("Sparkle failed to start: \(error.localizedDescription, privacy: .public)") state = .failed(error.localizedDescription) + lastError = error.localizedDescription } } - func openAvailableRelease() { - openURL(releasePageURL) + func setAutomaticChecksEnabled(_ enabled: Bool) { + start() + updater?.automaticallyChecksForUpdates = enabled + if enabled { + updater?.checkForUpdatesInBackground() + } } - var currentVersion: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" + func check() { + presentStatusSurface() + start() + guard let updater else { return } + lastError = nil + updater.checkForUpdates() + } + + func presentStatusSurface() { + presentStatus?() + } + + func download() { + lastError = nil + guard let reply = foundReply else { return } + foundReply = nil + apply(.downloadStarted) + reply(.install) + } + + func install() { + lastError = nil + guard let reply = installReply else { return } + installReply = nil + willInstall?() + apply(.installStarted) + reply(.install) + } + + func noteUserInitiatedCheck() { + apply(.checkStarted) + } + + func noteUpdateAvailable( + version: String, + download: @escaping (SPUUserUpdateChoice) -> Void + ) { + foundReply = download + apply(.updateAvailable(version: version)) + } + + func noteDownloaded( + version: String, + install: @escaping (SPUUserUpdateChoice) -> Void + ) { + installReply = install + apply(.updateAvailable(version: version)) + apply(.downloadComplete) } - private struct GitHubRelease: Decodable { - let tagName: String + func noteNoUpdate() { + apply(.noUpdate) + } + + func noteCheckFailed(_ message: String) { + lastError = message + apply(.checkFailed(message)) + } - private enum CodingKeys: String, CodingKey { - case tagName = "tag_name" + func noteUpdaterError(_ message: String) { + lastError = message + switch state { + case .downloading: + apply(.downloadFailed(message)) + case .installing, .downloaded: + apply(.installFailed(message)) + default: + apply(.checkFailed(message)) } } - private enum UpdateError: LocalizedError { - case invalidResponse - case http(Int) + func noteDownloadStarted() { + apply(.downloadStarted) + } - var errorDescription: String? { - switch self { - case .invalidResponse: "GitHub returned an invalid update response." - case let .http(code): "GitHub update check failed with HTTP \(code)." - } + func noteDownloadProgress(_ percent: Double) { + apply(.downloadProgress(percent)) + } + + func noteReadyToInstall(install: @escaping (SPUUserUpdateChoice) -> Void) { + installReply = install + apply(.downloadComplete) + } + + func noteInstalling(install: ((SPUUserUpdateChoice) -> Void)? = nil) { + if let install { + willInstall?() + install(.install) } + apply(.installStarted) + } + + func noteSessionEnded() { + switch state { + case .available, .downloaded, .installing: + return + default: + foundReply = nil + installReply = nil + apply(.sessionEnded) + } + } + + private func apply(_ event: UpdateEvent) { + state = reduceUpdateState(state, event) } } diff --git a/Sources/AgentsNotch/Services/UpdateStateMachine.swift b/Sources/AgentsNotch/Services/UpdateStateMachine.swift new file mode 100644 index 0000000..60bc4c9 --- /dev/null +++ b/Sources/AgentsNotch/Services/UpdateStateMachine.swift @@ -0,0 +1,137 @@ +/// Pure update-status transitions. Sparkle (and tests) feed events; the UI +/// only reads `UpdateState`. +enum UpdateState: Equatable { + case idle + case checking + case upToDate + case available(version: String) + case downloading(version: String, percent: Double) + case downloaded(version: String) + case installing(version: String) + case failed(String) + case unavailable(String) +} + +enum UpdateEvent: Equatable { + case checkStarted + case noUpdate + case updateAvailable(version: String) + case checkFailed(String) + case downloadStarted + case downloadProgress(Double) + case downloadFailed(String) + case downloadComplete + case installStarted + case installFailed(String) + case sessionEnded +} + +func reduceUpdateState(_ state: UpdateState, _ event: UpdateEvent) -> UpdateState { + switch event { + case .checkStarted: + switch state { + case .downloading, .installing, .unavailable, .downloaded: + return state + default: + return .checking + } + + case .noUpdate: + switch state { + case .downloaded(let version), .installing(let version): + return .downloaded(version: version) + case .downloading, .unavailable: + return state + default: + return .upToDate + } + + case let .updateAvailable(version): + switch state { + case .downloaded(let downloaded) where downloaded == version: + return .downloaded(version: version) + case .downloading, .installing, .unavailable: + return state + default: + return .available(version: version) + } + + case let .checkFailed(message): + switch state { + case .downloaded(let version), .installing(let version): + return .downloaded(version: version) + case .downloading, .unavailable: + return state + default: + return .failed(message) + } + + case .downloadStarted: + if case let .available(version) = state { + return .downloading(version: version, percent: 0) + } + return state + + case let .downloadProgress(percent): + if case let .downloading(version, _) = state { + return .downloading(version: version, percent: clampUpdateProgress(percent)) + } + return state + + case let .downloadFailed(message): + switch state { + case let .downloading(version, _): + return .available(version: version) + case .unavailable: + return state + default: + return .failed(message) + } + + case .downloadComplete: + switch state { + case let .downloading(version, _): + return .downloaded(version: version) + case let .available(version): + return .downloaded(version: version) + default: + return state + } + + case .installStarted: + switch state { + case let .downloaded(version): + return .installing(version: version) + case let .available(version): + return .installing(version: version) + default: + return state + } + + case let .installFailed(message): + switch state { + case let .installing(version), let .downloaded(version): + return .downloaded(version: version) + case .unavailable: + return state + default: + return .failed(message) + } + + case .sessionEnded: + switch state { + case .checking: + return .idle + case let .downloading(version, _): + return .available(version: version) + case .installing(let version): + return .downloaded(version: version) + default: + return state + } + } +} + +func clampUpdateProgress(_ percent: Double) -> Double { + min(1, max(0, percent)) +} diff --git a/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift b/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift index 2646ad4..d985506 100644 --- a/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift +++ b/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift @@ -35,7 +35,7 @@ struct ApplicationSettingsSection: View { ) SettingsToggleRow( title: "App update checks", - detail: "Automatically check GitHub for newer Agent Notch releases.", + detail: "Check for Agent Notch updates in the background. Downloads and installs only when you ask.", isOn: automaticallyCheckForUpdates ) SettingsMenuRow( @@ -164,7 +164,7 @@ struct SettingsUpdateControl: View { var body: some View { switch updates.state { case .idle: - Button("Check for Updates") { Task { await updates.check() } } + Button("Check for Updates") { updates.check() } .buttonStyle(NotchPillButtonStyle()) case .checking: ProgressView().controlSize(.small) @@ -172,17 +172,41 @@ struct SettingsUpdateControl: View { Label("Up to date", systemImage: "checkmark.circle.fill") .font(.caption) .foregroundStyle(.green) - case .noRelease: - Label("No releases yet", systemImage: "shippingbox") - .font(NotchWindowFont.caption) - .foregroundStyle(NotchWindowPalette.secondaryText) case let .available(version): - Button("Download \(version)") { updates.openAvailableRelease() } + Button("Download \(version)") { updates.download() } + .buttonStyle(NotchPillButtonStyle()) + .help(updates.lastError ?? "Download this update, then restart to install it.") + case let .downloading(_, percent): + HStack(spacing: 8) { + ProgressView(value: percent) + .frame(width: 72) + .controlSize(.small) + Text("\(Int((percent * 100).rounded()))%") + .font(NotchWindowFont.caption) + .foregroundStyle(NotchWindowPalette.secondaryText) + .monospacedDigit() + } + case let .downloaded(version): + Button("Restart to Update") { updates.install() } .buttonStyle(NotchPillButtonStyle()) + .help(updates.lastError ?? "Install \(version) and relaunch Agent Notch.") + case .installing: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Installing") + .font(NotchWindowFont.caption) + .foregroundStyle(NotchWindowPalette.secondaryText) + } case let .failed(message): - Button("Retry") { Task { await updates.check() } } + Button("Retry") { updates.check() } .buttonStyle(NotchPillButtonStyle()) .help(message) + .accessibilityHint(message) + case let .unavailable(message): + Text("Packaged builds only") + .font(NotchWindowFont.caption) + .foregroundStyle(NotchWindowPalette.secondaryText) + .help(message) } } } diff --git a/Sources/AgentsNotch/UI/Settings/SettingsView.swift b/Sources/AgentsNotch/UI/Settings/SettingsView.swift index c862dfd..34bba55 100644 --- a/Sources/AgentsNotch/UI/Settings/SettingsView.swift +++ b/Sources/AgentsNotch/UI/Settings/SettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SettingsView: View { let runtime: AppRuntime + @Bindable var presentation: SettingsPresentation @AppStorage("animationsEnabled") private var animationsEnabled = true @AppStorage("displayPreference") private var displayPreference = DisplayPreference.primary.rawValue @@ -11,7 +12,7 @@ struct SettingsView: View { @AppStorage("historyRetentionDays") private var historyRetentionDays = 7 @AppStorage("notchEnabled") private var notchEnabled = true @AppStorage("showVirtualNotch") private var showVirtualNotch = false - @AppStorage("automaticallyCheckForUpdates") private var automaticallyCheckForUpdates = false + @AppStorage("automaticallyCheckForUpdates") private var automaticallyCheckForUpdates = true @AppStorage("privacyModeEnabled") private var privacyModeEnabled = false @AppStorage("answerFromNotchEnabled") private var answerFromNotchEnabled = false @AppStorage("globalActivityShortcut") private var globalActivityShortcut = GlobalActivityShortcut.off.rawValue @@ -22,11 +23,10 @@ struct SettingsView: View { @State private var launchError: String? @State private var notificationError: String? @State private var confirmsClearHistory = false - @State private var pane = SettingsPane.general var body: some View { VStack(spacing: 0) { - SettingsPaneSelector(selection: $pane) + SettingsPaneSelector(selection: $presentation.pane) NotchHairline() selectedPane .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -42,7 +42,7 @@ struct SettingsView: View { } .onChange(of: historyRetentionDays) { _, days in runtime.applyHistoryRetention(days: days) } .onChange(of: automaticallyCheckForUpdates) { _, enabled in - if enabled { runtime.updates.checkAutomaticallyIfNeeded() } + runtime.updates.setAutomaticChecksEnabled(enabled) } .onChange(of: displayPreference) { _, _ in runtime.refreshNotchSurface() } .onChange(of: notchEnabled) { _, _ in runtime.refreshNotchSurface() } @@ -63,7 +63,7 @@ struct SettingsView: View { @ViewBuilder private var selectedPane: some View { - switch pane { + switch presentation.pane { case .general: GeneralSettingsPane( runtime: runtime, @@ -146,6 +146,12 @@ struct SettingsView: View { #endif } +@Observable +@MainActor +final class SettingsPresentation { + var pane = SettingsPane.general +} + enum SettingsPane: String, CaseIterable, Identifiable { case general case alertsPrivacy diff --git a/Sources/AgentsNotch/UI/Settings/SettingsWindowController.swift b/Sources/AgentsNotch/UI/Settings/SettingsWindowController.swift index 67b2aa1..72b2851 100644 --- a/Sources/AgentsNotch/UI/Settings/SettingsWindowController.swift +++ b/Sources/AgentsNotch/UI/Settings/SettingsWindowController.swift @@ -4,9 +4,10 @@ import SwiftUI @MainActor final class SettingsWindowController: NSWindowController, NSWindowDelegate { var onClose: (() -> Void)? + private let presentation = SettingsPresentation() init(runtime: AppRuntime) { - let root = SettingsView(runtime: runtime) + let root = SettingsView(runtime: runtime, presentation: presentation) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 580, height: 560), styleMask: [.titled, .closable, .miniaturizable, .resizable], @@ -36,7 +37,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { fatalError("init(coder:) has not been implemented") } - func show() { + func show(pane: SettingsPane? = nil) { + if let pane { + presentation.pane = pane + } NSApp.activate(ignoringOtherApps: true) showWindow(nil) window?.makeKeyAndOrderFront(nil) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 93abdd0..002a8b2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,5 +1,37 @@ # Third-party notices +## Sparkle + +In-app updates use Sparkle 2.9.6 (`https://github.com/sparkle-project/Sparkle`). + +Copyright (c) 2006-2013 Andy Matuschak. +Copyright (c) 2009-2013 Elgato Systems GmbH. +Copyright (c) 2011-2014 Kornel Lesiński. +Copyright (c) 2015-2017 Mayur Pawashe. +Copyright (c) 2014 C.W. Betts. +Copyright (c) 2014 Petroules Corporation. +Copyright (c) 2014 Big Nerd Ranch. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## Lobe Icons The provider SVG marks under diff --git a/Tests/AgentsNotchTests/AppRuntimeTests.swift b/Tests/AgentsNotchTests/AppRuntimeTests.swift index 971497d..eca8457 100644 --- a/Tests/AgentsNotchTests/AppRuntimeTests.swift +++ b/Tests/AgentsNotchTests/AppRuntimeTests.swift @@ -298,6 +298,24 @@ final class AppRuntimeTests: XCTestCase { "self-test traffic must not promote integration health" ) } + + @MainActor + func testCheckForUpdatesOpensGeneralSettings() { + let runtime = AppRuntime(monitorProviders: false) + var requested: SettingsPane? + runtime.openSettingsHandler = { requested = $0 } + runtime.updates.check() + XCTAssertEqual(requested, .general) + } + + @MainActor + func testOpenSettingsWithoutPaneLeavesSelectionToTheWindow() { + let runtime = AppRuntime(monitorProviders: false) + var requested: SettingsPane? = .integrations + runtime.openSettingsHandler = { requested = $0 } + runtime.openSettings() + XCTAssertNil(requested) + } } private final class LockedReplyBox: @unchecked Sendable { diff --git a/Tests/AgentsNotchTests/UpdateServiceTests.swift b/Tests/AgentsNotchTests/UpdateServiceTests.swift index e541a93..ec73850 100644 --- a/Tests/AgentsNotchTests/UpdateServiceTests.swift +++ b/Tests/AgentsNotchTests/UpdateServiceTests.swift @@ -1,17 +1,225 @@ @testable import AgentsNotch +import Sparkle import XCTest +final class UpdateStateMachineTests: XCTestCase { + func testBackgroundCheckThenDownloadThenRestart() { + var state = UpdateState.idle + state = reduceUpdateState(state, .checkStarted) + XCTAssertEqual(state, .checking) + state = reduceUpdateState(state, .updateAvailable(version: "0.3.0")) + XCTAssertEqual(state, .available(version: "0.3.0")) + state = reduceUpdateState(state, .downloadStarted) + XCTAssertEqual(state, .downloading(version: "0.3.0", percent: 0)) + state = reduceUpdateState(state, .downloadProgress(0.42)) + XCTAssertEqual(state, .downloading(version: "0.3.0", percent: 0.42)) + state = reduceUpdateState(state, .downloadComplete) + XCTAssertEqual(state, .downloaded(version: "0.3.0")) + state = reduceUpdateState(state, .installStarted) + XCTAssertEqual(state, .installing(version: "0.3.0")) + } + + func testDownloadFailureReturnsToAvailable() { + var state = UpdateState.available(version: "0.3.0") + state = reduceUpdateState(state, .downloadStarted) + state = reduceUpdateState(state, .downloadFailed("network down")) + XCTAssertEqual(state, .available(version: "0.3.0")) + } + + func testInstallFailureKeepsDownloadedUpdate() { + var state = UpdateState.downloaded(version: "0.3.0") + state = reduceUpdateState(state, .installStarted) + state = reduceUpdateState(state, .installFailed("relaunch failed")) + XCTAssertEqual(state, .downloaded(version: "0.3.0")) + } + + func testCheckDoesNotClearDownloadedUpdate() { + var state = UpdateState.downloaded(version: "0.3.0") + state = reduceUpdateState(state, .checkStarted) + XCTAssertEqual(state, .downloaded(version: "0.3.0")) + state = reduceUpdateState(state, .noUpdate) + XCTAssertEqual(state, .downloaded(version: "0.3.0")) + } + + func testProgressIsClamped() { + var state = UpdateState.downloading(version: "0.3.0", percent: 0) + state = reduceUpdateState(state, .downloadProgress(1.4)) + XCTAssertEqual(state, .downloading(version: "0.3.0", percent: 1)) + state = reduceUpdateState(state, .downloadProgress(-2)) + XCTAssertEqual(state, .downloading(version: "0.3.0", percent: 0)) + } + + func testUnavailableStateIgnoresCheckEvents() { + let unavailable = UpdateState.unavailable("packaged only") + XCTAssertEqual(reduceUpdateState(unavailable, .checkStarted), unavailable) + XCTAssertEqual(reduceUpdateState(unavailable, .noUpdate), unavailable) + XCTAssertEqual( + reduceUpdateState(unavailable, .updateAvailable(version: "0.3.0")), + unavailable + ) + } +} + final class UpdateServiceTests: XCTestCase { @MainActor - func testOpenAvailableReleaseAlwaysUsesOfficialGitHubPage() { - var openedURL: URL? - let service = UpdateService { openedURL = $0 } + func testUnpackagedHostDoesNotUseSparkle() { + XCTAssertFalse(UpdateService.hostCanUseSparkle) + let service = UpdateService() + service.start() + XCTAssertEqual(service.state, .unavailable(UpdateService.packagedOnlyMessage)) + } + + @MainActor + func testDownloadAndInstallAreNoOpsWithoutSparkleSession() { + let service = UpdateService() + service.start() + service.download() + service.install() + XCTAssertEqual(service.state, .unavailable(UpdateService.packagedOnlyMessage)) + } + + @MainActor + func testCheckPresentsStatusSurface() { + let service = UpdateService() + var presented = false + service.presentStatus = { presented = true } + service.check() + XCTAssertTrue(presented) + XCTAssertEqual(service.state, .unavailable(UpdateService.packagedOnlyMessage)) + } + + @MainActor + func testDriverTreatsLatestVersionAsUpToDate() { + let service = UpdateService() + let driver = SparkleUpdateDriver() + driver.service = service + var acknowledged = false + driver.showUpdateNotFoundWithError(sparkleNoUpdateError(reason: .onLatestVersion)) { + acknowledged = true + } + XCTAssertTrue(acknowledged) + XCTAssertEqual(service.state, .upToDate) + } + + @MainActor + func testDriverSurfacesIneligibleUpdate() { + let service = UpdateService() + let driver = SparkleUpdateDriver() + driver.service = service + driver.showUpdateNotFoundWithError( + sparkleNoUpdateError( + reason: .systemIsTooOld, + recovery: "0.3.0 is available but your macOS version is too old to install it." + ) + ) {} + XCTAssertEqual( + service.state, + .failed("0.3.0 is available but your macOS version is too old to install it.") + ) + XCTAssertEqual( + service.lastError, + "0.3.0 is available but your macOS version is too old to install it." + ) + } + + @MainActor + func testShowUpdateInFocusPresentsStatusSurface() { + let service = UpdateService() + let driver = SparkleUpdateDriver() + driver.service = service + var presented = false + service.presentStatus = { presented = true } + driver.showUpdateInFocus() + XCTAssertTrue(presented) + } +} + +final class SparkleNoUpdateOutcomeTests: XCTestCase { + func testOnLatestVersionIsCurrent() { + XCTAssertEqual( + sparkleNoUpdateOutcome(sparkleNoUpdateError(reason: .onLatestVersion)), + .currentVersion + ) + } + + func testNewerThanLatestIsCurrent() { + XCTAssertEqual( + sparkleNoUpdateOutcome(sparkleNoUpdateError(reason: .onNewerThanLatestVersion)), + .currentVersion + ) + } + + func testSystemTooOldSurfacesSparkleMessage() { + let error = sparkleNoUpdateError( + reason: .systemIsTooOld, + description: "No update found.", + recovery: "0.3.0 is available but your macOS version is too old to install it." + ) + XCTAssertEqual( + sparkleNoUpdateOutcome(error), + .unavailable("0.3.0 is available but your macOS version is too old to install it.") + ) + } + + func testSystemTooNewSurfacesSparkleMessage() { + XCTAssertEqual( + sparkleNoUpdateOutcome( + sparkleNoUpdateError( + reason: .systemIsTooNew, + recovery: "This update only supports up to macOS 14." + ) + ), + .unavailable("This update only supports up to macOS 14.") + ) + } + + func testUnknownReasonIsNotUpToDate() { + XCTAssertEqual( + sparkleNoUpdateOutcome( + sparkleNoUpdateError(reason: .unknown, description: "No valid update information could be loaded.") + ), + .unavailable("No valid update information could be loaded.") + ) + } - service.openAvailableRelease() + func testMissingReasonIsNotUpToDate() { + XCTAssertEqual( + sparkleNoUpdateOutcome( + sparkleNoUpdateError(reason: nil, description: "You're up to date!") + ), + .unavailable("You're up to date!") + ) + } + func testRecoverySuggestionBeatsDescription() { XCTAssertEqual( - openedURL, - URL(string: "https://github.com/Aforno/AgentNotch/releases/latest") + sparkleNoUpdateOutcome( + sparkleNoUpdateError( + reason: .hardwareDoesNotSupportARM64, + description: "Update Error!", + recovery: "0.3.0 is available but this update requires a new Apple silicon Mac." + ) + ), + .unavailable("0.3.0 is available but this update requires a new Apple silicon Mac.") ) } } + +private func sparkleNoUpdateError( + reason: SPUNoUpdateFoundReason?, + description: String = "No update available.", + recovery: String? = nil +) -> NSError { + var userInfo: [String: Any] = [NSLocalizedDescriptionKey: description] + if let reason { + userInfo[SPUNoUpdateFoundReasonKey] = NSNumber(value: reason.rawValue) + } + if let recovery { + userInfo[NSLocalizedRecoverySuggestionErrorKey] = recovery + } + return NSError( + domain: SUSparkleErrorDomain, + code: Int(SUError.noUpdateError.rawValue), + userInfo: userInfo + ) +} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1227331..0a954a4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -39,10 +39,22 @@ AGENT_NOTCH_BUILD_NUMBER=1 ./script/package_release.sh \ --keychain-profile agent-notch-notary ``` -The script builds the release configuration for arm64, signs nested code and -the app with hardened runtime, verifies the bundle, notarizes and staples it, -re-verifies Gatekeeper acceptance, and writes the ZIP plus `.sha256` file under -`dist/`. +The script builds the release configuration for arm64, signs nested code +(including Sparkle) and the app with hardened runtime, verifies the bundle, +notarizes and staples it, re-verifies Gatekeeper acceptance, and writes the ZIP +plus `.sha256` file under `dist/`. + +Signed releases also publish a Sparkle `appcast.xml`. Generate it from the ZIP +with the EdDSA private key that matches `Resources/SparklePublicEDKey`: + +```sh +SPARKLE_ED_PRIVATE_KEY="$(cat /path/to/sparkle-ed-private-key)" \ + ./script/generate_appcast.sh +``` + +The private key is the 32-byte EdDSA seed, base64-encoded. Never commit it. The +appcast enclosure URL is the GitHub release asset for that ZIP. In-app updates +read `https://github.com/Aforno/AgentNotch/releases/latest/download/appcast.xml`. ## GitHub release workflow @@ -54,15 +66,20 @@ The `Release` workflow requires these repository Actions secrets: - `APPLE_API_KEY_ID`: App Store Connect API key ID - `APPLE_API_ISSUER_ID`: App Store Connect issuer ID - `APPLE_API_PRIVATE_KEY`: complete `.p8` private-key contents +- `SPARKLE_ED_PRIVATE_KEY`: base64 EdDSA seed that matches + `Resources/SparklePublicEDKey` Push a signed `vX.Y.Z` tag only after CI passes. All tags share one `release` concurrency group, so one GitHub release runs at a time. The workflow validates that the tag matches `VERSION`, imports the temporary certificate, builds and -notarizes the app, creates the checksum, and publishes both files to the GitHub -release. After the GitHub files are up, it points `Casks/agent-notch.rb` at -that ZIP and checksum and pushes the cask bump to the default branch unless the -cask already names a newer version. Homebrew users on this tap pick that up -with `brew update`. +notarizes the app, creates the checksum, signs a Sparkle appcast, and publishes +the ZIP, checksum, and `appcast.xml` to the GitHub release. After the GitHub +files are up, it points `Casks/agent-notch.rb` at that ZIP and checksum and +pushes the cask bump to the default branch unless the cask already names a +newer version. Homebrew users on this tap pick that up with `brew update`. +Packaged apps check the appcast on launch and once a day, then wait for the +user to download and restart. The cask sets `auto_updates true` so Homebrew +does not fight the in-app updater. If the cask commit cannot push, update it locally from the published checksum: diff --git a/script/check_repository.sh b/script/check_repository.sh index 4422939..9621c83 100755 --- a/script/check_repository.sh +++ b/script/check_repository.sh @@ -18,6 +18,8 @@ required_files=( THIRD_PARTY_NOTICES.md VERSION Resources/AgentsNotch.entitlements + Resources/SparklePublicEDKey + Package.resolved Casks/agent-notch.rb .github/workflows/ci.yml .github/workflows/release.yml @@ -42,6 +44,18 @@ if ! rg -q '^ sha256 "[0-9a-f]{64}"$' Casks/agent-notch.rb; then echo "Homebrew cask is missing a 64-character sha256 stanza" >&2 exit 1 fi +if ! rg -q '^ auto_updates true$' Casks/agent-notch.rb; then + echo "Homebrew cask must declare auto_updates because the app self-updates" >&2 + exit 1 +fi +python3 - <<'PY' +import base64 +from pathlib import Path +key = Path("Resources/SparklePublicEDKey").read_text().strip() +decoded = base64.b64decode(key) +if len(decoded) != 32: + raise SystemExit("Resources/SparklePublicEDKey must decode to 32 bytes") +PY for script in script/*.sh; do bash -n "$script" @@ -76,6 +90,7 @@ fi local_path_matches="$( rg -n --hidden \ + -g '!.git' \ -g '!.git/**' \ -g '!.build/**' \ -g '!dist/**' \ diff --git a/script/generate_appcast.sh b/script/generate_appcast.sh new file mode 100755 index 0000000..477be85 --- /dev/null +++ b/script/generate_appcast.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Sign a Sparkle appcast for the packaged GitHub ZIP. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="$(tr -d '[:space:]' < "$ROOT_DIR/VERSION")" +ARCHIVE="$ROOT_DIR/dist/Agent-Notch-$VERSION-macOS-arm64.zip" +OUTPUT="" +ED_KEY_FILE="" +SPARKLE_VERSION="2.9.6" +SPARKLE_TARBALL_SHA256="52bf9e88cdd972fc0c81501377a880e90d47031bd8ca5462488f843e2609e192" +GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-Aforno/AgentNotch}" + +usage() { + cat >&2 <<'USAGE' +usage: generate_appcast.sh [--archive PATH] [--output PATH] [--ed-key-file PATH] +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --archive) + [[ $# -ge 2 ]] || { usage; exit 2; } + ARCHIVE="$2" + shift 2 + ;; + --output) + [[ $# -ge 2 ]] || { usage; exit 2; } + OUTPUT="$2" + shift 2 + ;; + --ed-key-file) + [[ $# -ge 2 ]] || { usage; exit 2; } + ED_KEY_FILE="$2" + shift 2 + ;; + --version) + [[ $# -ge 2 ]] || { usage; exit 2; } + VERSION="$2" + shift 2 + ;; + *) + usage + exit 2 + ;; + esac +done + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "VERSION must contain a semantic version" >&2 + exit 1 +fi +if [[ ! -f "$ARCHIVE" ]]; then + echo "release archive not found: $ARCHIVE" >&2 + exit 1 +fi +if [[ -z "$OUTPUT" ]]; then + OUTPUT="$(dirname "$ARCHIVE")/appcast.xml" +fi + +PRIVATE_KEY="" +if [[ -n "$ED_KEY_FILE" ]]; then + PRIVATE_KEY="$(tr -d '[:space:]' < "$ED_KEY_FILE")" +elif [[ -n "${SPARKLE_ED_PRIVATE_KEY:-}" ]]; then + PRIVATE_KEY="$(printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | tr -d '[:space:]')" +else + echo "generate_appcast.sh requires SPARKLE_ED_PRIVATE_KEY or --ed-key-file" >&2 + exit 1 +fi + +python3 - "$PRIVATE_KEY" <<'PY' +import base64, sys +key = sys.argv[1] +try: + decoded = base64.b64decode(key) +except Exception as error: + raise SystemExit(f"Sparkle private key is not valid base64: {error}") from error +if len(decoded) != 32: + raise SystemExit(f"Sparkle private key must decode to 32 bytes, not {len(decoded)}") +PY + +TOOLS_DIR="$(mktemp -d "${TMPDIR:-/tmp}/agentnotch-sparkle-tools.XXXXXX")" +ARCHIVES_DIR="$(mktemp -d "${TMPDIR:-/tmp}/agentnotch-sparkle-archives.XXXXXX")" +cleanup() { + rm -rf "$TOOLS_DIR" "$ARCHIVES_DIR" +} +trap cleanup EXIT + +TARBALL="$TOOLS_DIR/Sparkle-$SPARKLE_VERSION.tar.xz" +curl -fsSL -o "$TARBALL" \ + "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" +ACTUAL_SHA256="$(shasum -a 256 "$TARBALL" | awk '{print $1}')" +if [[ "$ACTUAL_SHA256" != "$SPARKLE_TARBALL_SHA256" ]]; then + echo "Sparkle tools tarball checksum mismatch: $ACTUAL_SHA256" >&2 + exit 1 +fi +tar -xJf "$TARBALL" -C "$TOOLS_DIR" +GENERATE_APPCAST="$(find "$TOOLS_DIR" -name generate_appcast -type f | head -n 1)" +if [[ -z "$GENERATE_APPCAST" ]]; then + echo "generate_appcast was not in the Sparkle tools tarball" >&2 + exit 1 +fi +chmod +x "$GENERATE_APPCAST" + +cp "$ARCHIVE" "$ARCHIVES_DIR/$(basename "$ARCHIVE")" +DOWNLOAD_PREFIX="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}/" +printf '%s\n' "$PRIVATE_KEY" | "$GENERATE_APPCAST" \ + --ed-key-file - \ + --maximum-deltas 0 \ + --download-url-prefix "$DOWNLOAD_PREFIX" \ + --link "https://github.com/${GITHUB_REPOSITORY}" \ + --disable-signing-warning \ + -o "$OUTPUT" \ + "$ARCHIVES_DIR" + +if [[ ! -s "$OUTPUT" ]]; then + echo "Sparkle appcast was not written: $OUTPUT" >&2 + exit 1 +fi +if ! grep -q 'sparkle:edSignature=' "$OUTPUT"; then + echo "Sparkle appcast is missing an EdDSA signature" >&2 + exit 1 +fi +echo "Created $OUTPUT" diff --git a/script/stage_app.sh b/script/stage_app.sh index f98d6c8..fd738f8 100755 --- a/script/stage_app.sh +++ b/script/stage_app.sh @@ -45,10 +45,27 @@ fi VERSION="$(tr -d '[:space:]' < "$ROOT_DIR/VERSION")" BUILD_NUMBER="${AGENT_NOTCH_BUILD_NUMBER:-1}" +SPARKLE_PUBLIC_KEY_FILE="$ROOT_DIR/Resources/SparklePublicEDKey" +SPARKLE_FEED_URL="https://github.com/Aforno/AgentNotch/releases/latest/download/appcast.xml" +SPARKLE_PUBLIC_KEY="$(tr -d '[:space:]' < "$SPARKLE_PUBLIC_KEY_FILE")" if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then echo "VERSION must contain a semantic version" >&2 exit 1 fi +if [[ ! "$SPARKLE_PUBLIC_KEY" =~ ^[A-Za-z0-9+/]+=*$ ]]; then + echo "Resources/SparklePublicEDKey must contain a base64 EdDSA public key" >&2 + exit 1 +fi +python3 - "$SPARKLE_PUBLIC_KEY" <<'PY' +import base64, sys +key = sys.argv[1] +try: + decoded = base64.b64decode(key) +except Exception as error: + raise SystemExit(f"Sparkle public key is not valid base64: {error}") from error +if len(decoded) != 32: + raise SystemExit(f"Sparkle public key must decode to 32 bytes, not {len(decoded)}") +PY if [[ ! "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]]; then echo "AGENT_NOTCH_BUILD_NUMBER must be a positive integer" >&2 exit 1 @@ -77,13 +94,37 @@ swift build "${swift_arguments[@]}" --product AgentsNotchHook BIN_DIR="$(swift build "${swift_arguments[@]}" --show-bin-path)" rm -rf "$APP_BUNDLE" -mkdir -p "$APP_MACOS" "$APP_RESOURCES/bin" +mkdir -p "$APP_MACOS" "$APP_RESOURCES/bin" "$APP_CONTENTS/Frameworks" cp "$BIN_DIR/$EXECUTABLE_NAME" "$APP_BINARY" cp "$BIN_DIR/AgentsNotchHook" "$HOOK_BINARY" cp "$APP_ICON_SOURCE" "$APP_RESOURCES/AppIcon.icns" cp -R "$PROVIDER_ICONS_SOURCE" "$APP_RESOURCES/ProviderIcons.xcassets" chmod 0755 "$APP_BINARY" "$HOOK_BINARY" +SPARKLE_FRAMEWORK_SOURCE="$( + python3 - "$ROOT_DIR" "$BIN_DIR" <<'PY' +from pathlib import Path +import sys + +root = Path(sys.argv[1]) +bin_dir = Path(sys.argv[2]) +candidates = [ + bin_dir / "Sparkle.framework", + *sorted(root.glob(".build/artifacts/**/Sparkle.xcframework/macos-*/Sparkle.framework")), +] +for candidate in candidates: + if (candidate / "Sparkle").exists() or (candidate / "Versions/Current/Sparkle").exists(): + print(candidate) + raise SystemExit(0) +raise SystemExit("Sparkle.framework was not found in the Swift build artifacts") +PY +)" +ditto "$SPARKLE_FRAMEWORK_SOURCE" "$APP_CONTENTS/Frameworks/Sparkle.framework" + +if ! otool -l "$APP_BINARY" | grep -q '@executable_path/../Frameworks'; then + install_name_tool -add_rpath '@executable_path/../Frameworks' "$APP_BINARY" +fi + cat >"$INFO_PLIST" < @@ -121,6 +162,18 @@ cat >"$INFO_PLIST" <Agent Notch uses Apple Events to focus the terminal tab where an agent is running. NSPrincipalClass NSApplication + SUFeedURL + $SPARKLE_FEED_URL + SUPublicEDKey + $SPARKLE_PUBLIC_KEY + SUEnableAutomaticChecks + + SUAutomaticallyUpdate + + SUAllowsAutomaticUpdates + + SUScheduledCheckInterval + 86400 PLIST @@ -130,6 +183,26 @@ if [[ "$SIGN_IDENTITY" != "-" ]]; then sign_arguments+=(--options runtime --timestamp) fi +sign_sparkle_framework() { + local framework="$APP_CONTENTS/Frameworks/Sparkle.framework" + local version_dir="$framework/Versions/Current" + if [[ ! -d "$version_dir" ]]; then + version_dir="$framework" + fi + local nested + while IFS= read -r nested; do + codesign "${sign_arguments[@]}" "$nested" + done < <(find "$version_dir/XPCServices" -name '*.xpc' -maxdepth 1 2>/dev/null | sort) + if [[ -d "$version_dir/Updater.app" ]]; then + codesign "${sign_arguments[@]}" "$version_dir/Updater.app" + fi + if [[ -f "$version_dir/Autoupdate" ]]; then + codesign "${sign_arguments[@]}" "$version_dir/Autoupdate" + fi + codesign "${sign_arguments[@]}" "$framework" +} + +sign_sparkle_framework codesign "${sign_arguments[@]}" "$HOOK_BINARY" codesign "${sign_arguments[@]}" --entitlements "$APP_ENTITLEMENTS" "$APP_BUNDLE" codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" diff --git a/script/verify_release.sh b/script/verify_release.sh index a451433..b3c0eb1 100755 --- a/script/verify_release.sh +++ b/script/verify_release.sh @@ -57,6 +57,26 @@ if ! APPLE_EVENTS_USAGE_DESCRIPTION="$( exit 1 fi +EXPECTED_SPARKLE_KEY="$(tr -d '[:space:]' < "$ROOT_DIR/Resources/SparklePublicEDKey")" +ACTUAL_SPARKLE_KEY="$(plutil -extract SUPublicEDKey raw "$INFO_PLIST")" +ACTUAL_SPARKLE_FEED="$(plutil -extract SUFeedURL raw "$INFO_PLIST")" +if [[ "$ACTUAL_SPARKLE_KEY" != "$EXPECTED_SPARKLE_KEY" ]]; then + echo "bundle Sparkle public key does not match Resources/SparklePublicEDKey" >&2 + exit 1 +fi +if [[ "$ACTUAL_SPARKLE_FEED" != "https://github.com/Aforno/AgentNotch/releases/latest/download/appcast.xml" ]]; then + echo "bundle Sparkle feed URL is not the GitHub latest appcast" >&2 + exit 1 +fi +if [[ "$(plutil -extract SUAutomaticallyUpdate raw "$INFO_PLIST")" != "false" ]]; then + echo "release app must not download and install updates silently" >&2 + exit 1 +fi +if [[ ! -d "$APP_BUNDLE/Contents/Frameworks/Sparkle.framework" ]]; then + echo "release app must embed Sparkle.framework" >&2 + exit 1 +fi + APP_FILE_DESCRIPTION="$(file "$APP_BINARY")" HOOK_FILE_DESCRIPTION="$(file "$HOOK_BINARY")" if [[ "$APP_FILE_DESCRIPTION" != *arm64* ]]; then