diff --git a/.lane/memory/native/sources/callers/updates.swift/01M6EGNHQ7F88SS5SRFCE25PAQ-a-copy-of-native2-s-desktopc.md b/.lane/memory/native/sources/callers/updates.swift/01M6EGNHQ7F88SS5SRFCE25PAQ-a-copy-of-native2-s-desktopc.md new file mode 100644 index 0000000..e4d0280 --- /dev/null +++ b/.lane/memory/native/sources/callers/updates.swift/01M6EGNHQ7F88SS5SRFCE25PAQ-a-copy-of-native2-s-desktopc.md @@ -0,0 +1,12 @@ +--- +id: 01M6EGNHQ7F88SS5SRFCE25PAQ +anchor: '@file' +created: 2026-11-02T16:14:51Z +norm: '1' +sig: 9d55c295e4357b94 +body_hash: 7094cf0980eaf202 +raw_hash: 3395477bf2fd8d30 +lines: 1-313 +--- + +a copy of native2's DesktopCore/Updates.swift so native/'s app build compiles; delete with native/ diff --git a/.lane/memory/native2/Sources/DesktopCore/Updates.swift/01M6EGNHQ0W644YEB8MJQSSSNW-never-install-code-that-fail.md b/.lane/memory/native2/Sources/DesktopCore/Updates.swift/01M6EGNHQ0W644YEB8MJQSSSNW-never-install-code-that-fail.md new file mode 100644 index 0000000..82c5a28 --- /dev/null +++ b/.lane/memory/native2/Sources/DesktopCore/Updates.swift/01M6EGNHQ0W644YEB8MJQSSSNW-never-install-code-that-fail.md @@ -0,0 +1,12 @@ +--- +id: 01M6EGNHQ0W644YEB8MJQSSSNW +anchor: '@file' +created: 2026-11-02T16:14:51Z +norm: '1' +sig: 389d8172009399de +body_hash: f9625dee71be3c17 +raw_hash: d04b70656f426188 +lines: 1-314 +--- + +never install code that fails requirement(team:identifier:) plus the running app's designated requirement: TCC grants are keyed to them, and they are the only thing standing between the feed and code execution diff --git a/.lane/memory/native2/Sources/desktop-tools-app/updater.swift/01M6EGNHQD071BCTT6MCZ500BE-install-only-after-runtime-s.md b/.lane/memory/native2/Sources/desktop-tools-app/updater.swift/01M6EGNHQD071BCTT6MCZ500BE-install-only-after-runtime-s.md new file mode 100644 index 0000000..19e411d --- /dev/null +++ b/.lane/memory/native2/Sources/desktop-tools-app/updater.swift/01M6EGNHQD071BCTT6MCZ500BE-install-only-after-runtime-s.md @@ -0,0 +1,12 @@ +--- +id: 01M6EGNHQD071BCTT6MCZ500BE +anchor: '@file' +created: 2026-11-02T16:14:51Z +norm: '1' +sig: 27abcba5e975f16e +body_hash: 982615dac862f90b +raw_hash: a6b683e33b522bcc +lines: 1-256 +--- + +install only after runtime.stop() has drained (status "stopped"); never kill an agent's in-flight call, and never relaunch while input is paused, since pausing isn't saved diff --git a/CHANGELOG.md b/CHANGELOG.md index 037b5a1..76e84f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Notable changes to Desktop Tools, newest first. Each release's GitHub release no ## [Unreleased] +### Added + +- The app updates itself from GitHub Releases. **Update Automatically**, on by default, checks at launch and daily, downloads a newer release, and installs it once agents have been idle for 10 minutes, after the calls already running finish, then relaunches. **Check for Updates…** checks at once and asks before installing. Only a release notarized and signed with Developer ID by the Desktop Tools team, with the app's bundle ID, is installed, so permissions and approved agents are kept. The development app never updates itself. See [Updates](docs/app.md#updates). + ### Removed - The Peekaboo stack, which the app stopped shipping in 0.17.0: `native/` with its vendored Peekaboo, its client and runtime, the release workflow's `stack` input, and wire 1, the Node tool logic behind `createDesktop` without `wire: 2`. `desktop-tools-build`, `desktop-tools-app` and `npm run dev-app` build only the Swift host in `native2/`, and accept `--native2` without effect; `createDesktop` and `dist/mcp.js` with options always forward to its `desktop-tools-client`, and accept `wire: 2` and `--wire 2` without effect, while `--wire 1` is refused. `dist/mcp.js` with no options still hands over to the app's bundled MCP server. An embedder whose app still embeds `native/`'s runtime must stay on 0.18.0 or move to `native2/`'s. The npm package no longer ships `native/`, and `npm run peekaboo-test` is gone. ([#233](https://github.com/githubnext/desktop-tools/pull/233)) diff --git a/docs/app.md b/docs/app.md index 80ab95c..0c5e2c4 100644 --- a/docs/app.md +++ b/docs/app.md @@ -26,6 +26,22 @@ Download the notarized DMG from the [latest release](https://github.com/githubne Anything running as the user could use those grants, so the app asks before serving each agent (see [approved agents](#approved-agents)). Grant them only on machines where that is acceptable, and quit the app when it isn't needed. +## Updates + +The app keeps itself up to date from this repository's [GitHub Releases](https://github.com/githubnext/desktop-tools/releases), which anyone can read without signing in. + +- **Update Automatically**, on by default, checks a minute after launch and then once a day (a failed check is retried an hour later). A newer release is downloaded and checked in the background, then installed once agents have run no operation for 10 minutes, input isn't paused and no Desktop Tools prompt is open. Turn it off and the app only checks when you ask. +- **Check for Updates…** checks at once. It says when the app is up to date or why a check failed; when there is a newer release it downloads it and asks before installing (**Install and Relaunch** or **Later**). Below the item the menu shows the running version and when it last checked, or why the last update failed. +- An update that is downloaded but not installed yet shows as **Install Desktop Tools *version* and Relaunch**, and is also installed when you quit the app, ready for the next launch. + +Installing stops serving new calls, lets the calls already running finish (at most 30 seconds), replaces the app in place in one rename, and opens the new version through LaunchServices once the old one has exited. Agents reconnect on their next call: the client waits for the app to serve again, as in [Keeping the socket served](#keeping-the-socket-served). Accessibility, Screen Recording and Keyboard input stay granted, and approved agents and settings are kept, because the new app has the same bundle ID and signature requirement. + +Nothing is installed unless the release's disk image and the app inside it are signed with Developer ID by team `8S43JJMN6B`, notarized, and pass Gatekeeper's assessment, and the app has the bundle ID `dev.githubnext.desktop-tools`, satisfies the running app's own designated requirement, and is the newer version the release names. The app must be able to write to the folder it is installed in, such as Applications; otherwise the update fails and names the release page to download it from. Each check and update is noted in the [log](#approved-agents). + +The development app (`Desktop Tools Dev`) and any build not signed by that team never update themselves and have no update items in their menu. + +To test a release build against another release list, open it with `DESKTOP_TOOLS_UPDATE_FEED` set to a URL that returns the same JSON as GitHub's list releases API, such as a `file:` URL; the app passes it on when it relaunches, as it does `DESKTOP_TOOLS_STATE`. Updates from it are checked the same way. + ## Approved agents The first time an agent calls, the app asks **Allow *name* to control this Mac?**, naming who signed it and where it runs from, with **Allow** and **Don't Allow**. Until you answer, and after Don't Allow, every request from that agent is refused with `CALLER_NOT_APPROVED`, listings and screen captures included; the agent is told to ask you, and is never left waiting. The answer is remembered in `callers.json` in the state folder (`~/.local/state/desktop-tools`), readable only by you, and the app asks about each agent once. Updating from 0.13.1 or earlier, each agent you use is asked about once. diff --git a/native2/Sources/DesktopCore/Updates.swift b/native2/Sources/DesktopCore/Updates.swift new file mode 100644 index 0000000..9202e5c --- /dev/null +++ b/native2/Sources/DesktopCore/Updates.swift @@ -0,0 +1,314 @@ +// The menu bar app's updater, without its menu: finding a newer release, and fetching, checking and installing it. +// native/sources/callers/updates.swift is a copy, so native/'s build of the same app compiles. +import Darwin +import Foundation +import Security + +/// A release version such as 0.18.0 or 0.19.0-beta.1, compared part by part; a prerelease sorts before its release. +public struct AppVersion: Comparable, Sendable, CustomStringConvertible { + public let numbers: [Int] + public let prerelease: String? + /// As written, without a leading v: what release assets are named with. + public let description: String + + public init?(_ text: String) { + let text = text.hasPrefix("v") ? String(text.dropFirst()) : text + let parts = text.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + let fields = parts[0].split(separator: ".", omittingEmptySubsequences: false) + guard (1...4).contains(fields.count), + fields.allSatisfy({ !$0.isEmpty && $0.count < 10 && $0.allSatisfy { $0.isASCII && $0.isNumber } }) + else { return nil } + numbers = fields.map { Int($0)! } + if parts.count == 2 { + guard !parts[1].isEmpty else { return nil } + prerelease = String(parts[1]) + } else { + prerelease = nil + } + description = text + } + + public static func == (a: AppVersion, b: AppVersion) -> Bool { + !(a < b) && !(b < a) + } + + public static func < (a: AppVersion, b: AppVersion) -> Bool { + for index in 0.. String { + "desktop-tools-\(version)-macos-arm64.dmg" + } + + /// The newest published, non-prerelease release above `current` that carries its disk image, or nil. + public static func select(_ releases: [Release], newerThan current: AppVersion) -> Update? { + releases.compactMap { release -> Update? in + guard !release.draft, !release.prerelease, let version = AppVersion(release.tag), version.prerelease == nil, + current < version, + let asset = release.assets.first(where: { $0.name == assetName(version) }), + let url = URL(string: asset.url), url.scheme == "https" || url.scheme == "file" + else { return nil } + return Update(version: version, dmg: url, size: asset.size, page: release.page.flatMap(URL.init(string:))) + }.max { $0.version < $1.version } + } + + /// What a release's code must satisfy: Developer ID signed by `team`, notarized, with this identifier when given. + /// The installed app's macOS permissions are tied to its identifier and team, so a build that satisfies this keeps + /// them. + public static func requirement(team: String, identifier: String?) -> String { + (identifier.map { "identifier \"\($0)\" and " } ?? "") + + "anchor apple generic and certificate leaf[subject.OU] = \"\(team)\"" + // Developer ID: the intermediate is Apple's Developer ID CA, the leaf a Developer ID Application certificate. + + " and certificate 1[field.1.2.840.113635.100.6.2.6] exists" + + " and certificate leaf[field.1.2.840.113635.100.6.1.13] exists" + + " and notarized" + } + + /// Validates the code at `path`, all of it, against each requirement. Throws what failed. + public static func verify(_ path: URL, requirements: [String]) throws { + var code: SecStaticCode? + guard SecStaticCodeCreateWithPath(path as CFURL, [], &code) == errSecSuccess, let code else { + throw UpdateError("\(path.lastPathComponent) isn't signed code") + } + for text in requirements { + var requirement: SecRequirement? + guard SecRequirementCreateWithString(text as CFString, [], &requirement) == errSecSuccess, let requirement else { + throw UpdateError("Invalid code requirement: \(text)") + } + let flags = SecCSFlags(rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode) + var error: Unmanaged? + let status = SecStaticCodeCheckValidityWithErrors(code, flags, requirement, &error) + guard status == errSecSuccess else { + let reason = error.map { CFErrorCopyDescription($0.takeRetainedValue()) as String } + ?? (SecCopyErrorMessageString(status, nil) as String?) ?? "status \(status)" + throw UpdateError("\(path.lastPathComponent) isn't a notarized Desktop Tools release signed by team \(Updates.team): \(reason)") + } + } + } + + /// The running process's team and designated requirement, when its signature is valid. + public static func ownSigning() -> (team: String?, designated: String)? { + var running: SecCode? + guard SecCodeCopySelf([], &running) == errSecSuccess, let running, + SecCodeCheckValidity(running, [], nil) == errSecSuccess + else { return nil } + var code: SecStaticCode? + var requirement: SecRequirement? + var text: CFString? + var info: CFDictionary? + guard SecCodeCopyStaticCode(running, [], &code) == errSecSuccess, let code, + SecCodeCopyDesignatedRequirement(code, [], &requirement) == errSecSuccess, let requirement, + SecRequirementCopyString(requirement, [], &text) == errSecSuccess, let text, + SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &info) == errSecSuccess + else { return nil } + let team = (info as? [String: Any])?[kSecCodeInfoTeamIdentifier as String] as? String + return (team, text as String) + } +} + +/// Finds, fetches and installs updates for the app at `installed`. Every step runs off the main thread. +public struct Installer: Sendable { + /// The running app's bundle, which an update replaces in place. + public let installed: URL + public let current: AppVersion + public let identifier: String + public let team: String + /// The running app's own designated requirement, which macOS keys its permissions to; an update must satisfy it + /// too. Nil skips it, as tests do. + public let designated: String? + /// Gatekeeper's assessment, the same `spctl` check the release script runs; off only in tests of unnotarized code. + public let assess: Bool + + public init(installed: URL, current: AppVersion, identifier: String = Updates.identifier, team: String = Updates.team, + designated: String?, assess: Bool = true) + { + self.installed = installed + self.current = current + self.identifier = identifier + self.team = team + self.designated = designated + self.assess = assess + } + + /// The newest release above the running version, or nil when the app is up to date. + public func check(_ feed: URL) async throws -> Update? { + var request = URLRequest(url: feed, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("Desktop Tools/\(current)", forHTTPHeaderField: "User-Agent") + let (data, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, http.statusCode != 200 { + let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["message"] as? String + throw UpdateError("GitHub answered \(http.statusCode)\(message.map { ": \($0)" } ?? "")") + } + guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { + throw UpdateError("The release list from \(feed.host ?? feed.path) couldn't be read") + } + return Updates.select(releases, newerThan: current) + } + + /// A folder on the installed app's volume, so the finished update can be swapped in with one rename. + public func stagingFolder() throws -> URL { + try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: installed, create: true) + } + + /// Downloads `update`'s disk image, checks its signature, copies the app out of it, and checks the app. Returns + /// the app, ready for `replace`; it is in its own staging folder, which the caller removes if it isn't installed. + public func prepare(_ update: Update) async throws -> URL { + let folder = try stagingFolder() + do { + let dmg = folder.appendingPathComponent(update.dmg.lastPathComponent) + let (downloaded, response) = try await URLSession.shared.download(from: update.dmg) + if let http = response as? HTTPURLResponse, http.statusCode != 200 { + try? FileManager.default.removeItem(at: downloaded) + throw UpdateError("Downloading \(update.dmg.lastPathComponent) failed with HTTP \(http.statusCode)") + } + try FileManager.default.moveItem(at: downloaded, to: dmg) + let size = (try? FileManager.default.attributesOfItem(atPath: dmg.path)[.size] as? Int) ?? -1 + guard update.size <= 0 || size == update.size else { + throw UpdateError("\(dmg.lastPathComponent) is \(size) bytes, not the \(update.size) the release lists") + } + // The disk image is signed and notarized as well; nothing is mounted unless it checks out. + try Updates.verify(dmg, requirements: [Updates.requirement(team: team, identifier: nil)]) + if assess { + try run("/usr/sbin/spctl", ["--assess", "--type", "open", "--context", "context:primary-signature", dmg.path]) + } + let mount = folder.appendingPathComponent("mount") + try FileManager.default.createDirectory(at: mount, withIntermediateDirectories: false) + try run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountpoint", mount.path, dmg.path]) + let app = folder.appendingPathComponent(installed.lastPathComponent) + do { + try run("/usr/bin/ditto", [mount.appendingPathComponent("Desktop Tools.app").path, app.path]) + } catch { + _ = try? run("/usr/bin/hdiutil", ["detach", "-force", mount.path]) + throw error + } + _ = try? run("/usr/bin/hdiutil", ["detach", "-force", mount.path]) + try? FileManager.default.removeItem(at: dmg) + try verify(app, version: update.version) + return app + } catch { + try? FileManager.default.removeItem(at: folder) + throw error + } + } + + /// Checks a prepared app: its whole signature, Gatekeeper's assessment, its bundle ID, and that it is `version`. + public func verify(_ app: URL, version: AppVersion) throws { + try Updates.verify(app, requirements: [Updates.requirement(team: team, identifier: identifier)] + (designated.map { [$0] } ?? [])) + if assess { try run("/usr/sbin/spctl", ["--assess", "--type", "execute", app.path]) } + let info = NSDictionary(contentsOf: app.appendingPathComponent("Contents/Info.plist")) as? [String: Any] ?? [:] + guard info["CFBundleIdentifier"] as? String == identifier else { + throw UpdateError("The update's bundle ID is \(info["CFBundleIdentifier"] ?? "missing"), not \(identifier)") + } + guard let found = (info["CFBundleShortVersionString"] as? String).flatMap(AppVersion.init), found == version, + current < found + else { + throw UpdateError("The update says it is version \(info["CFBundleShortVersionString"] ?? "?"), not \(version)") + } + } + + /// Checks `app` again, then swaps it with the installed app in one rename and removes the old one. The installed + /// path never names a half-copied app. + public func replace(with app: URL, version: AppVersion) throws { + try verify(app, version: version) + guard renamex_np(app.path, installed.path, UInt32(RENAME_SWAP)) == 0 else { + let reason = String(cString: strerror(errno)) + throw UpdateError("Desktop Tools can't replace itself at \(installed.path): \(reason)") + } + // The old app is now where the update was. + try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) + } + + @discardableResult + private func run(_ tool: String, _ arguments: [String]) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: tool) + process.arguments = arguments + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + guard process.terminationStatus == 0 else { + throw UpdateError("\((tool as NSString).lastPathComponent) \(arguments.first ?? "") failed: \(text)") + } + return text + } +} diff --git a/native2/Sources/desktop-tools-app/app.swift b/native2/Sources/desktop-tools-app/app.swift index e527b12..8e2914d 100644 --- a/native2/Sources/desktop-tools-app/app.swift +++ b/native2/Sources/desktop-tools-app/app.swift @@ -7,6 +7,7 @@ private struct Runtime { let start: @convention(c) (UnsafePointer, UnsafePointer) -> Void let status: @convention(c) () -> UnsafeMutablePointer? let stop: @convention(c) () -> Void + let activity: @convention(c) () -> UnsafeMutablePointer? let check: @convention(c) () -> Void let free: @convention(c) (UnsafeMutableRawPointer?) -> Void let permission: @convention(c) (UnsafePointer) -> Void @@ -32,6 +33,7 @@ private struct Runtime { start = symbol("desktop_tools_start") status = symbol("desktop_tools_status") stop = symbol("desktop_tools_stop") + activity = symbol("desktop_tools_activity") check = symbol("desktop_tools_check") free = symbol("desktop_tools_free") permission = symbol("desktop_tools_permission") @@ -112,6 +114,8 @@ private final class Host: NSObject, NSApplicationDelegate, NSMenuDelegate { private var waiting: [Caller] = [] // Agent-supplied passwords need the user's approval for each fill unless the user turned this on. private var passwords = UserDefaults.standard.bool(forKey: "agentPasswords") + /// Nil in the development app and any build not signed by the release team; they never update themselves. + private var updater: Updater? override init() { let bin = Bundle.main.executableURL!.deletingLastPathComponent() @@ -181,9 +185,40 @@ private final class Host: NSObject, NSApplicationDelegate, NSMenuDelegate { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" log("Desktop Tools \(version) started, serving \(socket)") runtime.start(socket, client) + updater = Updater.make( + log: { [weak self] in self?.log($0) }, + lastActivity: { [weak self] in self?.lastActivity() }, + busy: { [weak self] in + guard let self else { return true } + // Relaunching would resume paused input and drop the questions on screen. + return paused || !waiting.isEmpty || Confirmations.shared.asking + }, + drain: { [weak self] in await self?.drain() }, + resume: { [weak self] in + guard let self else { return } + runtime.start(socket, client) + }) + updater?.start() render() } + /// When an agent last ran an operation the activity log records, if any has since launch. + private func lastActivity() -> Date? { + struct Entry: Decodable { var time: Double } + guard let pointer = runtime.activity() else { return nil } + defer { runtime.free(pointer) } + let entries = try? JSONDecoder().decode([Entry].self, from: Data(String(cString: pointer).utf8)) + return entries?.map(\.time).max().map(Date.init(timeIntervalSince1970:)) + } + + /// Stops serving and waits for the calls already running to finish; the runtime gives them at most 30 seconds. + private func drain() async { + runtime.stop() + for _ in 0..<700 where read()?.state != "stopped" { + try? await Task.sleep(for: .milliseconds(50)) + } + } + /// Opening the app again, as a person does when it seems stuck and `desktop-client repair` does, checks that /// it still serves its socket and publishes it again if not. func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool { @@ -194,6 +229,7 @@ private final class Host: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationWillTerminate(_: Notification) { log("Desktop Tools quit") runtime.stop() + updater?.installAtQuit() } private func log(_ message: String) { @@ -250,6 +286,20 @@ private final class Host: NSObject, NSApplicationDelegate, NSMenuDelegate { open.toolTip = "Every operation agents ran, with its app, window, agent and outcome, but never typed text or clipboard contents." menu.addItem(open) menu.addItem(.separator()) + if let updater { + let update = NSMenuItem(title: updater.title, action: updater.enabled ? #selector(chooseUpdate) : nil, + keyEquivalent: "") + update.target = self + update.subtitle = updater.detail + menu.addItem(update) + let automatic = NSMenuItem(title: "Update Automatically", action: #selector(toggleAutomaticUpdates), + keyEquivalent: "") + automatic.target = self + automatic.state = updater.automatic ? .on : .off + automatic.toolTip = "Check for a new release daily and install it once agents have been idle for 10 minutes. Only releases notarized and signed by the Desktop Tools team are installed." + menu.addItem(automatic) + menu.addItem(.separator()) + } let pause = NSMenuItem(title: "Pause All Input", action: #selector(togglePause), keyEquivalent: "") pause.target = self pause.state = paused ? .on : .off @@ -385,6 +435,16 @@ private final class Host: NSObject, NSApplicationDelegate, NSMenuDelegate { item.button?.toolTip = paused ? "Desktop Tools: input paused" : "Desktop Tools" } + @objc private func chooseUpdate() { + updater?.choose() + } + + @objc private func toggleAutomaticUpdates() { + guard let updater else { return } + updater.automatic.toggle() + log(updater.automatic ? "Automatic updates on" : "Automatic updates off") + } + @objc private func toggleCursor() { cursor.toggle() UserDefaults.standard.set(cursor, forKey: "agentCursor") diff --git a/native2/Sources/desktop-tools-app/confirmations.swift b/native2/Sources/desktop-tools-app/confirmations.swift index bed69b6..ac14206 100644 --- a/native2/Sources/desktop-tools-app/confirmations.swift +++ b/native2/Sources/desktop-tools-app/confirmations.swift @@ -32,6 +32,7 @@ final class Confirmations: NSObject { private var queue: [Pending] = [] private var showing: Pending? private var alert: NSAlert? + private var notice: NSAlert? /// The user's answer, or nil when the request was cancelled before it was shown. func ask(_ request: Request) async -> Answer? { @@ -62,6 +63,34 @@ final class Confirmations: NSObject { } } + /// Whether a question is showing or waiting to show. + var asking: Bool { + showing != nil || !queue.isEmpty + } + + /// Shows a message with only an OK button, such as the result of checking for updates. It answers nothing, so it + /// doesn't wait in the queue; a second one replaces the first. + func tell(_ title: String, _ message: String) { + notice?.window.orderOut(nil) + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + let ok = alert.addButton(withTitle: "OK") + ok.target = self + ok.action = #selector(dismissNotice) + alert.layout() + alert.window.level = .modalPanel + alert.window.center() + notice = alert + NSApp.activate() + alert.window.makeKeyAndOrderFront(nil) + } + + @objc private func dismissNotice() { + notice?.window.orderOut(nil) + notice = nil + } + private func showNext() { guard showing == nil, !queue.isEmpty else { return } showing = queue.removeFirst() diff --git a/native2/Sources/desktop-tools-app/updater.swift b/native2/Sources/desktop-tools-app/updater.swift new file mode 100644 index 0000000..3225fb8 --- /dev/null +++ b/native2/Sources/desktop-tools-app/updater.swift @@ -0,0 +1,256 @@ +import AppKit +#if canImport(DesktopCore) +import DesktopCore +#else +import DesktopToolsCallers +#endif + +/// Keeps the release app up to date from the repository's GitHub Releases: a check at launch and once a day while +/// automatic updates are on, and whenever the user asks. Only notarized builds signed by the release team are +/// installed (see `Installer`). An automatic update installs once agents have been quiet for a while, and every call +/// already running finishes first; the app then relaunches, keeping its permissions. +@MainActor +final class Updater { + enum Phase { + case idle + case checking + case downloading(AppVersion) + /// Checked and staged; `app` replaces the installed one on install or quit. + case ready(Update, app: URL) + case installing(AppVersion) + } + + private(set) var phase = Phase.idle + /// Why the last check or install failed, until the next one succeeds. + private(set) var failure: String? + private(set) var lastChecked = UserDefaults.standard.object(forKey: "lastUpdateCheck") as? Date + var automatic = UserDefaults.standard.object(forKey: "automaticUpdates") as? Bool ?? true { + didSet { UserDefaults.standard.set(automatic, forKey: "automaticUpdates") } + } + + private let installer: Installer + private let feed: URL + private var lastAttempt: Date? + private var timer: Timer? + /// Writes a line to the activity log. + private let log: (String) -> Void + /// When an agent last operated, if one has since launch; an automatic install waits until this is long past. + private let lastActivity: () -> Date? + /// Whether the user is in the middle of something the restart would undo, such as paused input or an open prompt. + private let busy: () -> Bool + /// Stops serving and returns once every running call has finished; `resume` serves again if the install fails. + private let drain: () async -> Void + private let resume: () -> Void + + /// How long agents must have been quiet before an automatic install restarts the app. + static let quiet: TimeInterval = 10 * 60 + static let interval: TimeInterval = 24 * 60 * 60 + + /// Nil for any build that isn't the release app signed by the release team, such as `Desktop Tools Dev`: those + /// never update themselves. + static func make(log: @escaping (String) -> Void, lastActivity: @escaping () -> Date?, busy: @escaping () -> Bool, + drain: @escaping () async -> Void, resume: @escaping () -> Void) -> Updater? + { + let bundle = Bundle.main + guard bundle.bundleIdentifier == Updates.identifier, + let version = (bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String).flatMap(AppVersion.init), + let own = Updates.ownSigning(), own.team == Updates.team + else { return nil } + // DESKTOP_TOOLS_UPDATE_FEED points a test copy at a local release list. + let override = ProcessInfo.processInfo.environment["DESKTOP_TOOLS_UPDATE_FEED"].flatMap(URL.init(string:)) + let installer = Installer(installed: bundle.bundleURL, current: version, designated: own.designated) + return Updater(installer: installer, feed: override ?? Updates.feed, log: log, lastActivity: lastActivity, + busy: busy, drain: drain, resume: resume) + } + + private init(installer: Installer, feed: URL, log: @escaping (String) -> Void, lastActivity: @escaping () -> Date?, + busy: @escaping () -> Bool, drain: @escaping () async -> Void, resume: @escaping () -> Void) + { + self.installer = installer + self.feed = feed + self.log = log + self.lastActivity = lastActivity + self.busy = busy + self.drain = drain + self.resume = resume + } + + /// Checks a minute after launch, then every 10 minutes looks whether a day has passed or a staged update can go in. + func start() { + Timer.scheduledTimer(withTimeInterval: 60, repeats: false) { _ in + MainActor.assumeIsolated { self.tick() } + } + timer = Timer.scheduledTimer(withTimeInterval: 10 * 60, repeats: true) { _ in + MainActor.assumeIsolated { self.tick() } + } + } + + private func tick() { + guard automatic else { return } + switch phase { + case .idle: + // A failed check is tried again an hour later. + let due = lastChecked.map { Date().timeIntervalSince($0) >= Self.interval } ?? true + if due, lastAttempt.map({ Date().timeIntervalSince($0) >= 60 * 60 }) ?? true { + lastAttempt = Date() + Task { await check(manual: false) } + } + case let .ready(update, app): + let last = lastActivity() ?? .distantPast + if Date().timeIntervalSince(last) >= Self.quiet, !busy() { + Task { await install(update, app: app, manual: false) } + } + default: + break + } + } + + /// The title of the menu's update item. + var title: String { + switch phase { + case .idle: "Check for Updates…" + case .checking: "Checking for Updates…" + case let .downloading(version): "Downloading Desktop Tools \(version)…" + case let .ready(update, _): "Install Desktop Tools \(update.version) and Relaunch" + case let .installing(version): "Installing Desktop Tools \(version)…" + } + } + + /// What the menu shows under it: when it last checked, or why it failed. + var detail: String { + if let failure { return "Update failed: \(failure)" } + if case .ready = phase { + return automatic ? "Installs when agents have been idle for 10 minutes, or at quit" : "Installs at quit" + } + guard let lastChecked else { return "Version \(installer.current)" } + let ago = RelativeDateTimeFormatter().localizedString(for: lastChecked, relativeTo: Date()) + return "Version \(installer.current), checked \(ago)" + } + + var enabled: Bool { + switch phase { + case .idle, .ready: true + default: false + } + } + + /// The menu item: checks, or installs what is staged. + func choose() { + switch phase { + case .idle: Task { await check(manual: true) } + case let .ready(update, app): Task { await install(update, app: app, manual: true) } + default: break + } + } + + /// Looks for a newer release and stages it. A manual check reports what it found, and offers to install at once. + func check(manual: Bool) async { + guard case .idle = phase else { return } + phase = .checking + let installer = installer + let feed = feed + let found: Update? + do { + found = try await Task.detached { try await installer.check(feed) }.value + lastChecked = Date() + UserDefaults.standard.set(lastChecked, forKey: "lastUpdateCheck") + } catch { + return fail(error, manual: manual, doing: "checking for updates") + } + guard let update = found else { + phase = .idle + failure = nil + if manual { + Confirmations.shared.tell("Desktop Tools is up to date", + "Version \(installer.current) is the newest release.") + } + return + } + phase = .downloading(update.version) + log("Downloading Desktop Tools \(update.version)") + let app: URL + do { + app = try await Task.detached { try await installer.prepare(update) }.value + } catch { + return fail(error, manual: manual, doing: "downloading Desktop Tools \(update.version)", page: update.page) + } + failure = nil + phase = .ready(update, app: app) + log("Desktop Tools \(update.version) is downloaded and verified") + if manual { + let answer = await Confirmations.shared.ask(.init( + key: "update \(update.version)", + title: "Install Desktop Tools \(update.version)?", + message: """ + Desktop Tools will relaunch. Calls agents are running finish first, and agents reconnect on their \ + next call. Your permissions and approved agents are kept. + """, + allow: "Install and Relaunch", + deny: "Later")) + if answer == .allow { await install(update, app: app, manual: true) } + } else { + tick() + } + } + + /// Stops serving once running calls finish, swaps the new app in, and relaunches it. On failure the app keeps + /// running the old version and serves again. + func install(_ update: Update, app: URL, manual: Bool) async { + guard case .ready = phase else { return } + phase = .installing(update.version) + log("Installing Desktop Tools \(update.version)") + await drain() + let installer = installer + do { + try await Task.detached { try installer.replace(with: app, version: update.version) }.value + } catch { + resume() + try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) + return fail(error, manual: manual, doing: "installing Desktop Tools \(update.version)", page: update.page) + } + relaunch() + } + + /// Installs a staged update when the app quits, without relaunching. Returns at once otherwise. + func installAtQuit() { + guard case let .ready(update, app) = phase else { return } + do { + try installer.replace(with: app, version: update.version) + log("Installed Desktop Tools \(update.version); it runs next time the app opens") + } catch { + log("Couldn't install Desktop Tools \(update.version) at quit: \(error.localizedDescription)") + try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) + } + } + + /// Opens the new app through LaunchServices once this process has exited, then quits. A small shell waits for the + /// exit, since the new app won't start while this one holds the state folder's lock. + private func relaunch() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + var open = ["/usr/bin/open", "-n", "-g"] + // A copy run with its own state folder or feed, as in testing, keeps them. + for name in ["DESKTOP_TOOLS_STATE", "DESKTOP_TOOLS_UPDATE_FEED"] { + if let value = ProcessInfo.processInfo.environment[name] { open += ["--env", "\(name)=\(value)"] } + } + process.arguments = [ + "-c", "while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.2; done; shift; exec \"$@\"", "sh", + "\(getpid())", + ] + open + [installer.installed.path] + do { + try process.run() + } catch { + log("Couldn't relaunch Desktop Tools: \(error.localizedDescription); open it again to finish updating") + } + NSApp.terminate(nil) + } + + private func fail(_ error: Error, manual: Bool, doing what: String, page: URL? = nil) { + phase = .idle + failure = error.localizedDescription + log("Update failed while \(what): \(error.localizedDescription)") + guard manual else { return } + let where_ = page.map { " You can download it from \($0.absoluteString)." } ?? "" + Confirmations.shared.tell("Desktop Tools couldn't update", "Failed while \(what): \(error.localizedDescription)\(where_)") + } +} diff --git a/native2/Tests/CoreTests/UpdatesTests.swift b/native2/Tests/CoreTests/UpdatesTests.swift new file mode 100644 index 0000000..8f94660 --- /dev/null +++ b/native2/Tests/CoreTests/UpdatesTests.swift @@ -0,0 +1,156 @@ +import DesktopCore +import Foundation +import Testing + +struct UpdatesTests { + private func version(_ text: String) -> AppVersion { + AppVersion(text)! + } + + @Test + func `versions compare part by part, and a prerelease comes before its release`() { + #expect(version("0.18.0") < version("0.18.1")) + #expect(version("0.9.0") < version("0.10.0")) + #expect(version("0.18") == version("0.18.0")) + #expect(version("v1.0.0") == version("1.0.0")) + #expect(version("1.0.0-beta.2") < version("1.0.0-beta.10")) + #expect(version("1.0.0-beta.10") < version("1.0.0")) + #expect(!(version("1.0.0") < version("1.0.0"))) + #expect(version("v0.18.0").description == "0.18.0") + for bad in ["", "v", "1..0", "1.0.0-", "1.x", "+1.0", "1.0.0.0.0", "-1.0", " 1.0"] { + #expect(AppVersion(bad) == nil, "\(bad)") + } + } + + private func release(_ tag: String, draft: Bool = false, prerelease: Bool = false, asset: String? = nil, + url: String? = nil) -> Release + { + let name = asset ?? "desktop-tools-\(tag.dropFirst())-macos-arm64.dmg" + return Release(tag: tag, draft: draft, prerelease: prerelease, page: "https://github.com/x/releases/tag/\(tag)", + assets: [.init(name: name, url: url ?? "https://github.com/x/releases/download/\(tag)/\(name)", size: 42)]) + } + + @Test + func `the newest published release above the running one is chosen`() { + let releases = [ + release("v0.17.0"), release("v0.20.0", draft: true), release("v0.19.0"), release("v0.21.0-beta.1"), + release("v0.19.5", prerelease: true), release("v0.18.1"), + ] + let update = Updates.select(releases, newerThan: version("0.18.0")) + #expect(update?.version == version("0.19.0")) + #expect(update?.dmg.absoluteString == "https://github.com/x/releases/download/v0.19.0/desktop-tools-0.19.0-macos-arm64.dmg") + #expect(update?.size == 42) + #expect(Updates.select(releases, newerThan: version("0.19.0")) == nil) + #expect(Updates.select([], newerThan: version("0.18.0")) == nil) + } + + @Test + func `a release without its disk image, or served over plain http, is skipped`() { + let releases = [ + release("v0.20.0", asset: "desktop-tools-0.20.0-macos-x64.dmg"), + release("v0.19.1", url: "http://example.com/desktop-tools-0.19.1-macos-arm64.dmg"), + release("not-a-version"), + release("v0.19.0"), + ] + #expect(Updates.select(releases, newerThan: version("0.18.0"))?.version == version("0.19.0")) + } + + @Test + func `GitHub's release list decodes`() throws { + let json = """ + [{"tag_name":"v0.18.0","draft":false,"prerelease":false,"html_url":"https://github.com/githubnext/desktop-tools/releases/tag/v0.18.0", + "assets":[{"name":"desktop-tools-0.18.0-macos-arm64.dmg","size":2789765, + "browser_download_url":"https://github.com/githubnext/desktop-tools/releases/download/v0.18.0/desktop-tools-0.18.0-macos-arm64.dmg"}]}] + """ + let releases = try JSONDecoder().decode([Release].self, from: Data(json.utf8)) + let update = Updates.select(releases, newerThan: version("0.17.1")) + #expect(update?.version == version("0.18.0")) + #expect(update?.page?.absoluteString == "https://github.com/githubnext/desktop-tools/releases/tag/v0.18.0") + } + + @Test + func `the requirement names the team, Developer ID, notarization and the bundle ID`() { + let app = Updates.requirement(team: "8S43JJMN6B", identifier: "dev.githubnext.desktop-tools") + #expect(app.hasPrefix("identifier \"dev.githubnext.desktop-tools\" and anchor apple generic")) + #expect(app.contains("certificate leaf[subject.OU] = \"8S43JJMN6B\"")) + #expect(app.contains("certificate 1[field.1.2.840.113635.100.6.2.6] exists")) + #expect(app.contains("certificate leaf[field.1.2.840.113635.100.6.1.13] exists")) + #expect(app.hasSuffix(" and notarized")) + #expect(!Updates.requirement(team: "8S43JJMN6B", identifier: nil).contains("identifier")) + } + + @Test + func `code from anyone else is refused`() throws { + let requirement = Updates.requirement(team: Updates.team, identifier: Updates.identifier) + // Apple's own app: valid, but not this team's. + #expect(throws: UpdateError.self) { + try Updates.verify(URL(fileURLWithPath: "/System/Applications/Calculator.app"), requirements: [requirement]) + } + // An ad hoc signed copy of an app, with the release's bundle ID. + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("updates-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: folder) } + let app = folder.appendingPathComponent("Desktop Tools.app") + let bin = app.appendingPathComponent("Contents/MacOS") + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + try FileManager.default.copyItem(at: URL(fileURLWithPath: "/usr/bin/true"), to: bin.appendingPathComponent("desktop-tools-app")) + let plist: [String: Any] = ["CFBundleIdentifier": Updates.identifier, "CFBundleExecutable": "desktop-tools-app", + "CFBundlePackageType": "APPL", "CFBundleShortVersionString": "99.0.0"] + try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + .write(to: app.appendingPathComponent("Contents/Info.plist")) + let sign = try Process.run(URL(fileURLWithPath: "/usr/bin/codesign"), arguments: ["--force", "--sign", "-", app.path]) + sign.waitUntilExit() + #expect(sign.terminationStatus == 0) + #expect(throws: UpdateError.self) { try Updates.verify(app, requirements: [requirement]) } + // Unsigned: nothing at all. + #expect(throws: UpdateError.self) { try Updates.verify(folder, requirements: [requirement]) } + } + + /// Runs where a release is installed, which CI's runners don't have. + @Test(.enabled(if: FileManager.default.fileExists(atPath: "/Applications/Desktop Tools.app"))) + func `an installed release satisfies the requirement`() throws { + let installed = URL(fileURLWithPath: "/Applications/Desktop Tools.app") + try Updates.verify(installed, requirements: [Updates.requirement(team: Updates.team, identifier: Updates.identifier)]) + // The same release fails for another bundle ID or team. + #expect(throws: UpdateError.self) { + try Updates.verify(installed, requirements: [Updates.requirement(team: Updates.team, identifier: "dev.githubnext.desktop-tools.dev")]) + } + #expect(throws: UpdateError.self) { + try Updates.verify(installed, requirements: [Updates.requirement(team: "Q6L2SF6YDW", identifier: Updates.identifier)]) + } + } + + /// The whole install, from a local release list to the swapped bundle, against a real release disk image named by + /// DESKTOP_TOOLS_UPDATE_DMG, into a copy of an app in a temporary folder. It needs no network. + @Test(.enabled(if: ProcessInfo.processInfo.environment["DESKTOP_TOOLS_UPDATE_DMG"] != nil)) + func `a release disk image installs over an older app in place`() async throws { + let dmg = URL(fileURLWithPath: ProcessInfo.processInfo.environment["DESKTOP_TOOLS_UPDATE_DMG"]!) + let name = dmg.lastPathComponent + let shipped = try #require(AppVersion(String(name.dropFirst("desktop-tools-".count).dropLast("-macos-arm64.dmg".count)))) + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("updates-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: folder) } + let installed = folder.appendingPathComponent("Desktop Tools.app") + try FileManager.default.createDirectory(at: installed.appendingPathComponent("Contents"), withIntermediateDirectories: true) + try Data("old".utf8).write(to: installed.appendingPathComponent("Contents/old")) + let size = try FileManager.default.attributesOfItem(atPath: dmg.path)[.size] as! Int + let feed = folder.appendingPathComponent("releases.json") + let list = [["tag_name": "v\(shipped)", "draft": false, "prerelease": false, + "assets": [["name": name, "size": size, "browser_download_url": dmg.absoluteString]]]] + try JSONSerialization.data(withJSONObject: list).write(to: feed) + + let older = Installer(installed: installed, current: AppVersion("0.1.0")!, designated: nil) + let update = try #require(try await older.check(feed)) + #expect(update.version == shipped) + let app = try await older.prepare(update) + #expect(app.deletingLastPathComponent() != folder) + try older.replace(with: app, version: update.version) + #expect(!FileManager.default.fileExists(atPath: installed.appendingPathComponent("Contents/old").path)) + #expect(!FileManager.default.fileExists(atPath: app.deletingLastPathComponent().path)) + try Updates.verify(installed, requirements: [Updates.requirement(team: Updates.team, identifier: Updates.identifier)]) + + // The same release is not an update for itself, and a staged app is refused if it isn't the version listed. + let current = Installer(installed: installed, current: shipped, designated: nil) + #expect(try await current.check(feed) == nil) + let wrong = try #require(AppVersion("\(shipped.numbers[0] + 1).0.0")) + #expect(throws: UpdateError.self) { try older.verify(installed, version: wrong) } + } +}