From aa60971f7f7c4c5948ffa1b7c6e79ef35fb23721 Mon Sep 17 00:00:00 2001 From: Brent Ceulemans Date: Mon, 5 Oct 2026 10:21:02 +0200 Subject: [PATCH 1/3] feat(macos): the app updates itself from GitHub releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks the latest release at launch and every six hours, announces a newer one once (notification with an Update button, plus an entry at the top of the tray menu), and on a click downloads the release zip, verifies bundle id, version and signature, swaps the bundle in place and relaunches. Check for Updates… in the app menu asks right away. The relaunch starts the new copy as a second instance with the pid it replaces: a helper waiting for the old one to exit is killed along with the app, and NSRunningApplication only sees the exit from the run loop. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 5 + macos/Sources/Catchbox/AppDelegate.swift | 19 +- macos/Sources/Catchbox/MainMenu.swift | 1 + macos/Sources/Catchbox/Notifier.swift | 23 +- .../Catchbox/StatusItemController.swift | 18 ++ macos/Sources/Catchbox/Updater.swift | 236 ++++++++++++++++++ macos/Sources/CatchboxCore/Release.swift | 50 ++++ macos/Sources/CatchboxTests/main.swift | 25 ++ 8 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 macos/Sources/Catchbox/Updater.swift create mode 100644 macos/Sources/CatchboxCore/Release.swift diff --git a/README.md b/README.md index c40be2f..f97cd85 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,11 @@ What it adds over a browser tab: ⇧⌘K copy latest code, ⇧⌘O open latest link, ⌘F filter, ⌘, settings. Every shortcut from the browser inbox works too. - Links open in your default browser, attachments land in `~/Downloads`. +- **It updates itself.** It checks GitHub for a new release every few hours and tells you + once — a notification and an entry at the top of the tray menu. Click it, and catchbox + downloads the release, checks it is catchbox at that version with an intact signature, + swaps it in and restarts. Your mailboxes are not touched. **catchbox → Check for + Updates…** asks right away. Closing the window keeps catchbox in the menu bar; click the Dock icon to bring it back. It needs `node` (18 or newer) — Homebrew installs it with the cask. If yours lives diff --git a/macos/Sources/Catchbox/AppDelegate.swift b/macos/Sources/Catchbox/AppDelegate.swift index 21de145..2ebe4fe 100644 --- a/macos/Sources/Catchbox/AppDelegate.swift +++ b/macos/Sources/Catchbox/AppDelegate.swift @@ -8,6 +8,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var window: InboxWindowController? private var statusItem: StatusItemController? private let notifier = Notifier() + private let updater = Updater() private var feed: Task? private var accounts: AccountsPayload? @@ -15,11 +16,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var restarts = 0 func applicationDidFinishLaunching(_ notification: Notification) { + // Started by an update: let the old copy finish quitting before looking for it. + let replaced = Updater.waitForPredecessor() // One copy at a time: a second one would start a second server and a second tray. let own = ProcessInfo.processInfo.processIdentifier if let id = Bundle.main.bundleIdentifier, let other = NSRunningApplication.runningApplications(withBundleIdentifier: id) - .first(where: { $0.processIdentifier != own }) { + .first(where: { $0.processIdentifier != own && $0.processIdentifier != replaced && !$0.isTerminated }) { other.activate() NSApp.terminate(nil) return @@ -31,6 +34,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.window?.show() self?.window?.open(message: id, account: account) } + notifier.onUpdate = { [weak self] in self?.updater.offerAvailable() } notifier.setUp() statusItem = StatusItemController(actions: .init( @@ -40,9 +44,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { openInbox: { [weak self] in self?.showInbox() }, newMailbox: { [weak self] in self?.newMailbox() }, toggleNotifications: { [weak self] in self?.toggleNotifications() }, + update: { [weak self] in self?.updater.offerAvailable() }, quit: { NSApp.terminate(nil) } )) + updater.onChange = { [weak self] in + guard let self else { return } + statusItem?.setUpdate(version: updater.available?.version, installing: updater.installing) + } + updater.onFound = { [weak self] release in + guard let self else { return } + notifier.postUpdate(version: release.version, current: updater.current) + } + updater.start() + Task { await launchServer() } } @@ -166,6 +181,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc func showInbox() { window?.show() } + @objc func checkForUpdates() { Task { await updater.check(userInitiated: true) } } + @objc func showSettings() { window?.show() window?.press(",") diff --git a/macos/Sources/Catchbox/MainMenu.swift b/macos/Sources/Catchbox/MainMenu.swift index ce5224f..9fdd2bd 100644 --- a/macos/Sources/Catchbox/MainMenu.swift +++ b/macos/Sources/Catchbox/MainMenu.swift @@ -9,6 +9,7 @@ enum MainMenu { main.addItem(submenu("catchbox", [ item("About catchbox", #selector(NSApplication.orderFrontStandardAboutPanel(_:)), target: nil), + item("Check for Updates…", #selector(AppDelegate.checkForUpdates), target: target), .separator(), item("Settings…", #selector(AppDelegate.showSettings), ",", target: target), .separator(), diff --git a/macos/Sources/Catchbox/Notifier.swift b/macos/Sources/Catchbox/Notifier.swift index 9ef241b..5f29656 100644 --- a/macos/Sources/Catchbox/Notifier.swift +++ b/macos/Sources/Catchbox/Notifier.swift @@ -9,10 +9,14 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { private static let category = "mail" private static let copyAction = "copy-code" private static let enabledKey = "notifyOnMail" + private static let updateCategory = "update" + private static let updateAction = "install-update" /// A tap on the notification itself: open that message. var onOpen: ((_ id: String, _ account: String?) -> Void)? var onCopy: ((String) -> Void)? + /// A tap on the update notification, or its Update button: show the offer. + var onUpdate: (() -> Void)? var enabled: Bool { get { UserDefaults.standard.object(forKey: Self.enabledKey) as? Bool ?? true } @@ -28,8 +32,10 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { guard let center else { return } center.delegate = self let copy = UNNotificationAction(identifier: Self.copyAction, title: "Copy Code") + let update = UNNotificationAction(identifier: Self.updateAction, title: "Update") center.setNotificationCategories([ UNNotificationCategory(identifier: Self.category, actions: [copy], intentIdentifiers: []), + UNNotificationCategory(identifier: Self.updateCategory, actions: [update], intentIdentifiers: []), ]) if enabled { center.requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } } } @@ -52,6 +58,18 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { center.add(UNNotificationRequest(identifier: message.id, content: content, trigger: nil)) } + /// Not tied to "Notify When Mail Arrives": that switch is about mail, and an update is + /// announced once per version anyway. + func postUpdate(version: String, current: String) { + guard let center else { return } + let content = UNMutableNotificationContent() + content.title = "catchbox \(version) is available" + content.body = "You have \(current). Click to update — your mailboxes stay as they are." + content.categoryIdentifier = Self.updateCategory + content.userInfo = ["update": version] + center.add(UNNotificationRequest(identifier: "update-\(version)", content: content, trigger: nil)) + } + // While the window is in front, the inbox shows new mail itself; a banner on top of // it is a second announcement of the same thing. nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, @@ -66,8 +84,11 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { let account = info["account"] as? String let code = info["code"] as? String let action = response.actionIdentifier + let isUpdate = info["update"] != nil await MainActor.run { - if action == Self.copyAction, let code { + if isUpdate { + if action != UNNotificationDismissActionIdentifier { self.onUpdate?() } + } else if action == Self.copyAction, let code { self.onCopy?(code) } else if let id { self.onOpen?(id, account) diff --git a/macos/Sources/Catchbox/StatusItemController.swift b/macos/Sources/Catchbox/StatusItemController.swift index 0e74e49..b34efd4 100644 --- a/macos/Sources/Catchbox/StatusItemController.swift +++ b/macos/Sources/Catchbox/StatusItemController.swift @@ -13,6 +13,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { var openInbox: () -> Void var newMailbox: () -> Void var toggleNotifications: () -> Void + var update: () -> Void var quit: () -> Void } @@ -21,6 +22,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { private var accounts: AccountsPayload? private var latest: Message? private var notificationsOn = true + private var update: (version: String, installing: Bool)? init(actions: Actions) { self.actions = actions @@ -38,6 +40,11 @@ final class StatusItemController: NSObject, NSMenuDelegate { render() } + /// A newer release to offer at the top of the menu, or nil when there is none. + func setUpdate(version: String?, installing: Bool) { + update = version.map { ($0, installing) } + } + private func render() { guard let button = item.button else { return } let unread = accounts?.totalUnread ?? 0 @@ -53,6 +60,16 @@ final class StatusItemController: NSObject, NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() + if let update { + let item = update.installing + ? NSMenuItem(title: "Installing catchbox \(update.version)…", action: nil, keyEquivalent: "") + : entry("Update to catchbox \(update.version)…", #selector(installUpdate)) + item.image = NSImage(systemSymbolName: "arrow.down.circle", accessibilityDescription: nil) + item.isEnabled = !update.installing + menu.addItem(item) + menu.addItem(.separator()) + } + if let active = accounts?.active { let header = NSMenuItem(title: active.displayName, action: nil, keyEquivalent: "") header.isEnabled = false @@ -98,5 +115,6 @@ final class StatusItemController: NSObject, NSMenuDelegate { @objc private func openInbox() { actions.openInbox() } @objc private func newMailbox() { actions.newMailbox() } @objc private func toggleNotifications() { actions.toggleNotifications() } + @objc private func installUpdate() { actions.update() } @objc private func quit() { actions.quit() } } diff --git a/macos/Sources/Catchbox/Updater.swift b/macos/Sources/Catchbox/Updater.swift new file mode 100644 index 0000000..961a74d --- /dev/null +++ b/macos/Sources/Catchbox/Updater.swift @@ -0,0 +1,236 @@ +import AppKit +import CatchboxCore + +/// Keeps catchbox current without Homebrew or the Mac App Store. It asks GitHub for the +/// latest release now and then; when there is a newer one it says so once — a notification +/// and an entry in the tray — and on a click downloads that release's app, checks it is +/// really catchbox at that version with an intact signature, swaps it in and restarts. +/// +/// The mailboxes live in ~/.config/testmail, outside the bundle, so an update never touches them. +@MainActor +final class Updater { + // CATCHBOX_UPDATE_FEED points the check at a release JSON of your own, to try an update + // end to end without publishing one. + private static let latest = ProcessInfo.processInfo.environment["CATCHBOX_UPDATE_FEED"] + .flatMap(URL.init(string:)) + ?? URL(string: "https://api.github.com/repos/brentc22/catchbox/releases/latest")! + private static let interval: TimeInterval = 6 * 60 * 60 + private static let skippedKey = "skippedUpdateVersion" + private static let announcedKey = "announcedUpdateVersion" + + /// A newer release, once one has been found. The tray offers it for as long as it is set. + private(set) var available: Release? + private(set) var installing = false + var onChange: (() -> Void)? + /// Called once per new version, to announce it outside the app. + var onFound: ((Release) -> Void)? + + let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + private var timer: Timer? + + func start() { + // `swift run` has no bundle and so no version to compare; it would always "find" one. + guard Bundle.main.bundleIdentifier != nil else { return } + Task { await check(userInitiated: false) } + timer = Timer.scheduledTimer(withTimeInterval: Self.interval, repeats: true) { [weak self] _ in + Task { @MainActor in await self?.check(userInitiated: false) } + } + } + + // MARK: - checking + + /// From the timer: quiet unless there is something new. From the menu: always answers. + func check(userInitiated: Bool) async { + let release: Release + do { + release = try await fetchLatest() + } catch { + if userInitiated { tell("Could not check for updates", "\(error.localizedDescription)") } + return + } + + guard isNewer(release.version, than: current), release.appArchive != nil else { + available = nil + onChange?() + if userInitiated { tell("catchbox is up to date", "Version \(current) is the latest.") } + return + } + + available = release + onChange?() + if userInitiated { return offer(release) } + + // One announcement per version, and none for a version you said to skip — a check + // every six hours must not turn into a notification every six hours. + let defaults = UserDefaults.standard + guard defaults.string(forKey: Self.skippedKey) != release.version, + defaults.string(forKey: Self.announcedKey) != release.version else { return } + defaults.set(release.version, forKey: Self.announcedKey) + onFound?(release) + } + + private func fetchLatest() async throws -> Release { + var request = URLRequest(url: Self.latest) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("catchbox/\(current)", forHTTPHeaderField: "User-Agent") + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw Failure.unreachable((response as? HTTPURLResponse)?.statusCode ?? 0) + } + return try JSONDecoder().decode(Release.self, from: data) + } + + // MARK: - offering + + /// The question, with the release notes, behind the notification and the tray entry. + func offerAvailable() { + if let available { offer(available) } else { Task { await check(userInitiated: true) } } + } + + private func offer(_ release: Release) { + guard !installing else { return } + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = "catchbox \(release.version) is available" + let notes = (release.notes ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + alert.informativeText = "You have \(current)." + + (notes.isEmpty ? "" : "\n\n" + String(notes.prefix(700)) + (notes.count > 700 ? "…" : "")) + alert.addButton(withTitle: "Update and Restart") + alert.addButton(withTitle: "Later") + alert.addButton(withTitle: "Skip This Version") + switch alert.runModal() { + case .alertFirstButtonReturn: + Task { await install(release) } + case .alertThirdButtonReturn: + UserDefaults.standard.set(release.version, forKey: Self.skippedKey) + available = nil + onChange?() + default: + break + } + } + + // MARK: - installing + + private func install(_ release: Release) async { + guard let archive = release.appArchive else { return } + installing = true + onChange?() + defer { installing = false; onChange?() } + + let fm = FileManager.default + let target = Bundle.main.bundleURL + do { + // A scratch directory on the same volume as the app, so the swap below is a rename + // and not a copy that could be cut off halfway. + let scratch = try fm.url(for: .itemReplacementDirectory, in: .userDomainMask, + appropriateFor: target, create: true) + defer { try? fm.removeItem(at: scratch) } + + let (download, response) = try await URLSession.shared.download(from: archive) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw Failure.unreachable((response as? HTTPURLResponse)?.statusCode ?? 0) + } + try await run("/usr/bin/ditto", "-x", "-k", download.path, scratch.path) + + // Only ever swap in catchbox, at the version that was offered, signed as shipped. + let fresh = scratch.appendingPathComponent("Catchbox.app") + guard let bundle = Bundle(url: fresh), + bundle.bundleIdentifier == Bundle.main.bundleIdentifier, + bundle.infoDictionary?["CFBundleShortVersionString"] as? String == release.version + else { throw Failure.notCatchbox } + try await run("/usr/bin/codesign", "--verify", "--deep", "--strict", fresh.path) + // Downloads by URLSession are not quarantined today; this keeps it that way if + // that ever changes — the app is not notarised, so Gatekeeper would refuse it. + try? await run("/usr/bin/xattr", "-dr", "com.apple.quarantine", fresh.path) + + _ = try fm.replaceItemAt(target, withItemAt: fresh) + } catch { + return failed(release, error) + } + relaunch(target) + } + + /// Starts the new copy first, then quits this one. A helper process that waits for this + /// one to exit and then opens the new copy does not work: macOS ends it along with the + /// app. So the new copy is launched as a second instance, told which process it replaces, + /// and waits for that one to go before the single-instance rule looks. + private func relaunch(_ app: URL) { + let config = NSWorkspace.OpenConfiguration() + config.createsNewApplicationInstance = true + config.arguments = [Self.replacingFlag, String(ProcessInfo.processInfo.processIdentifier)] + NSWorkspace.shared.openApplication(at: app, configuration: config) { _, error in + Task { @MainActor in + if let error { + self.tell("catchbox was updated", "Open it again to use the new version. (\(error.localizedDescription))") + } + NSApp.terminate(nil) + } + } + } + + static let replacingFlag = "--replacing" + + /// In the new copy, at launch: wait for the copy it replaces to have quit, so the two + /// never run a server side by side. Bounded, so a stuck predecessor cannot keep it from + /// starting — then the single-instance rule hands over to that one as usual. + /// Returns the pid it waited for once that one is gone: NSRunningApplication only learns + /// of the exit from the run loop, which this wait blocks, so the caller must skip it. + @discardableResult + static func waitForPredecessor(arguments: [String] = CommandLine.arguments) -> pid_t? { + guard let flag = arguments.firstIndex(of: replacingFlag), flag + 1 < arguments.count, + let pid = pid_t(arguments[flag + 1]) else { return nil } + let deadline = Date().addingTimeInterval(10) + while kill(pid, 0) == 0, Date() < deadline { usleep(100_000) } + return kill(pid, 0) == 0 ? nil : pid + } + + private func failed(_ release: Release, _ error: Error) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "The update could not be installed" + alert.informativeText = "\(error.localizedDescription)\n\nYou can download catchbox \(release.version) from its release page instead." + alert.addButton(withTitle: "Open Release Page") + alert.addButton(withTitle: "Cancel") + if alert.runModal() == .alertFirstButtonReturn { NSWorkspace.shared.open(release.page) } + } + + private func tell(_ title: String, _ text: String) { + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = title + alert.informativeText = text + alert.runModal() + } + + /// Runs a system tool off the main thread and throws if it fails. + private func run(_ tool: String, _ arguments: String...) async throws { + try await withCheckedThrowingContinuation { (done: CheckedContinuation) in + let process = Process() + process.executableURL = URL(fileURLWithPath: tool) + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.terminationHandler = { p in + p.terminationStatus == 0 + ? done.resume() + : done.resume(throwing: Failure.tool((tool as NSString).lastPathComponent, p.terminationStatus)) + } + do { try process.run() } catch { done.resume(throwing: error) } + } + } + + enum Failure: LocalizedError { + case unreachable(Int) + case notCatchbox + case tool(String, Int32) + + var errorDescription: String? { + switch self { + case .unreachable(let status): "GitHub did not answer (HTTP \(status))." + case .notCatchbox: "The download was not the catchbox version that was offered." + case .tool(let name, let status): "\(name) failed with status \(status)." + } + } + } +} diff --git a/macos/Sources/CatchboxCore/Release.swift b/macos/Sources/CatchboxCore/Release.swift new file mode 100644 index 0000000..04fcd3c --- /dev/null +++ b/macos/Sources/CatchboxCore/Release.swift @@ -0,0 +1,50 @@ +import Foundation + +/// A GitHub release, as `GET /repos/{owner}/{repo}/releases/latest` returns it — only the +/// fields the updater needs. +public struct Release: Decodable, Equatable, Sendable { + public struct Asset: Decodable, Equatable, Sendable { + public let name: String + public let downloadURL: URL + + enum CodingKeys: String, CodingKey { + case name + case downloadURL = "browser_download_url" + } + } + + public let tag: String + public let page: URL + public let notes: String? + public let assets: [Asset] + + enum CodingKeys: String, CodingKey { + case tag = "tag_name" + case page = "html_url" + case notes = "body" + case assets + } + + /// The tag without its leading "v": v1.4.0 → 1.4.0. + public var version: String { tag.hasPrefix("v") ? String(tag.dropFirst()) : tag } + + /// The app bundle the release carries — the same zip the Homebrew cask downloads. + public var appArchive: URL? { + assets.first { $0.name == "Catchbox-\(version).zip" }?.downloadURL + } +} + +/// Whether `candidate` is a later version than `current`, compared number by number, so +/// 1.10.0 is later than 1.9.0. A pre-release (1.4.0-beta) is never offered as an update. +public func isNewer(_ candidate: String, than current: String) -> Bool { + guard !candidate.contains("-") else { return false } + let parse = { (v: String) -> [Int] in + v.split(separator: "-").first.map { $0.split(separator: ".").map { Int($0) ?? 0 } } ?? [] + } + let a = parse(candidate), b = parse(current) + for i in 0.. y } + } + return false +} diff --git a/macos/Sources/CatchboxTests/main.swift b/macos/Sources/CatchboxTests/main.swift index 67d8c99..e8dd62f 100644 --- a/macos/Sources/CatchboxTests/main.swift +++ b/macos/Sources/CatchboxTests/main.swift @@ -62,6 +62,31 @@ check(badgeLabel(unread: 0) == nil, "no badge for an empty inbox") check(badgeLabel(unread: 7) == "7", "a small count is shown as is") check(badgeLabel(unread: 250) == "99+", "a large count is capped") +// --- updates ------------------------------------------------------------------ +check(isNewer("1.4.0", than: "1.3.0"), "a minor bump is an update") +check(isNewer("1.10.0", than: "1.9.2"), "versions compare as numbers, not text") +check(isNewer("2.0", than: "1.9.9"), "a shorter version still compares") +check(!isNewer("1.3.0", than: "1.3.0"), "the same version is not an update") +check(!isNewer("1.2.9", than: "1.3.0"), "an older release is not an update") +check(!isNewer("1.4.0-beta.1", than: "1.3.0"), "a pre-release is never offered") +do { + let json = #""" + {"tag_name":"v1.4.0","html_url":"https://github.com/brentc22/catchbox/releases/tag/v1.4.0", + "body":"Updates itself.","assets":[ + {"name":"Catchbox-1.3.0.zip","browser_download_url":"https://example.com/old.zip"}, + {"name":"Catchbox-1.4.0.zip","browser_download_url":"https://example.com/new.zip"}]} + """# + let release = try! JSONDecoder().decode(Release.self, from: Data(json.utf8)) + check(release.version == "1.4.0", "the tag's v is dropped") + check(release.appArchive?.absoluteString == "https://example.com/new.zip", + "the archive is the one for this release's version") +} +do { + let json = #"{"tag_name":"v1.4.0","html_url":"https://x.dev","body":null,"assets":[]}"# + let release = try! JSONDecoder().decode(Release.self, from: Data(json.utf8)) + check(release.appArchive == nil, "a release without the app zip offers nothing to install") +} + // --- port --------------------------------------------------------------------- if let port = freeLoopbackPort() { check(port > 1024, "a free port is an unprivileged one") From 41cd2e0ac73e4d2ecbc8ba77ed66d3ba1b27b5c4 Mon Sep 17 00:00:00 2001 From: Brent Ceulemans Date: Mon, 5 Oct 2026 10:32:15 +0200 Subject: [PATCH 2/3] fix(macos): updater review follow-ups - show the update notification even while catchbox is in front, and ask for notification permission for it when mail notifications are off - a skipped version stays out of the tray until a later one - keep 'installing' set after the swap, so the tray never offers it twice - one offer at a time; Check for Updates during an install says so - wait for the replaced copy off the main thread, and never hand over to it - remove the downloaded zip; only download from this repo's releases - a pre-release is offered its final release; no checks without a version - README no longer implies the signature proves who built the app Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 6 +- macos/Sources/Catchbox/AppDelegate.swift | 13 ++++- macos/Sources/Catchbox/Notifier.swift | 9 ++- macos/Sources/Catchbox/Updater.swift | 73 +++++++++++++++++------- macos/Sources/CatchboxCore/Release.swift | 11 ++-- macos/Sources/CatchboxTests/main.swift | 1 + 6 files changed, 82 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f97cd85..f5a53da 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,10 @@ What it adds over a browser tab: - Links open in your default browser, attachments land in `~/Downloads`. - **It updates itself.** It checks GitHub for a new release every few hours and tells you once — a notification and an entry at the top of the tray menu. Click it, and catchbox - downloads the release, checks it is catchbox at that version with an intact signature, - swaps it in and restarts. Your mailboxes are not touched. **catchbox → Check for + downloads the release from this repository's GitHub releases, checks it is catchbox at + that version with an intact signature, swaps it in and restarts. (catchbox is signed ad + hoc, not with an Apple Developer ID, so the signature check catches a damaged download; + the trust itself comes from where it is downloaded.) Your mailboxes are not touched. **catchbox → Check for Updates…** asks right away. Closing the window keeps catchbox in the menu bar; click the Dock icon to bring it back. diff --git a/macos/Sources/Catchbox/AppDelegate.swift b/macos/Sources/Catchbox/AppDelegate.swift index 2ebe4fe..7683301 100644 --- a/macos/Sources/Catchbox/AppDelegate.swift +++ b/macos/Sources/Catchbox/AppDelegate.swift @@ -16,8 +16,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var restarts = 0 func applicationDidFinishLaunching(_ notification: Notification) { - // Started by an update: let the old copy finish quitting before looking for it. - let replaced = Updater.waitForPredecessor() + // Started by an update: let the old copy finish quitting first. It is never the one + // to hand over to, even if it is slow to go — it is on its way out, and handing over + // to it would leave no catchbox running at all. + guard let replaced = Updater.predecessor() else { return launch(replacing: nil) } + Task { + await Updater.waitForExit(of: replaced) + launch(replacing: replaced) + } + } + + private func launch(replacing replaced: pid_t?) { // One copy at a time: a second one would start a second server and a second tray. let own = ProcessInfo.processInfo.processIdentifier if let id = Bundle.main.bundleIdentifier, diff --git a/macos/Sources/Catchbox/Notifier.swift b/macos/Sources/Catchbox/Notifier.swift index 5f29656..a540c58 100644 --- a/macos/Sources/Catchbox/Notifier.swift +++ b/macos/Sources/Catchbox/Notifier.swift @@ -9,7 +9,7 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { private static let category = "mail" private static let copyAction = "copy-code" private static let enabledKey = "notifyOnMail" - private static let updateCategory = "update" + nonisolated private static let updateCategory = "update" private static let updateAction = "install-update" /// A tap on the notification itself: open that message. @@ -62,6 +62,8 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { /// announced once per version anyway. func postUpdate(version: String, current: String) { guard let center else { return } + // Permission is otherwise only asked for when mail notifications are on. + center.requestAuthorization(options: [.alert, .sound]) { _, _ in } let content = UNMutableNotificationContent() content.title = "catchbox \(version) is available" content.body = "You have \(current). Click to update — your mailboxes stay as they are." @@ -74,7 +76,10 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { // it is a second announcement of the same thing. nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { - await MainActor.run { NSApp.isActive } ? [] : [.banner, .sound, .list] + // An update is shown regardless: unlike mail, nothing in the window announces it, + // and the updater records it as announced as soon as it is posted. + if notification.request.content.categoryIdentifier == Self.updateCategory { return [.banner, .list] } + return await MainActor.run { NSApp.isActive } ? [] : [.banner, .sound, .list] } nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, diff --git a/macos/Sources/Catchbox/Updater.swift b/macos/Sources/Catchbox/Updater.swift index 961a74d..d414e23 100644 --- a/macos/Sources/Catchbox/Updater.swift +++ b/macos/Sources/Catchbox/Updater.swift @@ -27,10 +27,11 @@ final class Updater { let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" private var timer: Timer? + private var offering = false func start() { // `swift run` has no bundle and so no version to compare; it would always "find" one. - guard Bundle.main.bundleIdentifier != nil else { return } + guard Bundle.main.bundleIdentifier != nil, current != "0" else { return } Task { await check(userInitiated: false) } timer = Timer.scheduledTimer(withTimeInterval: Self.interval, repeats: true) { [weak self] _ in Task { @MainActor in await self?.check(userInitiated: false) } @@ -56,15 +57,22 @@ final class Updater { return } + // A version you said to skip stays out of the tray too, until a later one comes — + // unless you ask yourself, through Check for Updates…. + let defaults = UserDefaults.standard + if !userInitiated, defaults.string(forKey: Self.skippedKey) == release.version { + available = nil + onChange?() + return + } + available = release onChange?() if userInitiated { return offer(release) } - // One announcement per version, and none for a version you said to skip — a check - // every six hours must not turn into a notification every six hours. - let defaults = UserDefaults.standard - guard defaults.string(forKey: Self.skippedKey) != release.version, - defaults.string(forKey: Self.announcedKey) != release.version else { return } + // One announcement per version — a check every six hours must not turn into a + // notification every six hours. + guard defaults.string(forKey: Self.announcedKey) != release.version else { return } defaults.set(release.version, forKey: Self.announcedKey) onFound?(release) } @@ -88,7 +96,11 @@ final class Updater { } private func offer(_ release: Release) { - guard !installing else { return } + if installing { return tell("Installing catchbox \(release.version)…", "catchbox restarts by itself when it is done.") } + // A click on the notification while this question is already open must not stack a second one. + guard !offering else { return } + offering = true + defer { offering = false } NSApp.activate(ignoringOtherApps: true) let alert = NSAlert() alert.messageText = "catchbox \(release.version) is available" @@ -116,7 +128,6 @@ final class Updater { guard let archive = release.appArchive else { return } installing = true onChange?() - defer { installing = false; onChange?() } let fm = FileManager.default let target = Bundle.main.bundleURL @@ -127,13 +138,19 @@ final class Updater { appropriateFor: target, create: true) defer { try? fm.removeItem(at: scratch) } + guard Self.trusted(archive) else { throw Failure.untrustedSource(archive.host ?? "?") } let (download, response) = try await URLSession.shared.download(from: archive) + // The async download leaves its file behind, unlike the completion-handler one. + defer { try? fm.removeItem(at: download) } guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw Failure.unreachable((response as? HTTPURLResponse)?.statusCode ?? 0) } try await run("/usr/bin/ditto", "-x", "-k", download.path, scratch.path) - // Only ever swap in catchbox, at the version that was offered, signed as shipped. + // Only ever swap in catchbox, at the version that was offered, with a signature that + // is intact — which catches a damaged download. It does not prove who built it: the + // app is signed ad hoc, not with a Developer ID, so that trust rests on the download + // coming from this repository's releases over HTTPS (see `trusted`). let fresh = scratch.appendingPathComponent("Catchbox.app") guard let bundle = Bundle(url: fresh), bundle.bundleIdentifier == Bundle.main.bundleIdentifier, @@ -146,8 +163,12 @@ final class Updater { _ = try fm.replaceItemAt(target, withItemAt: fresh) } catch { + installing = false + onChange?() return failed(release, error) } + // `installing` stays set from here on: the new copy is in place and this one is about + // to quit, so the tray must not offer the same update a second time. relaunch(target) } @@ -169,20 +190,30 @@ final class Updater { } } + /// Release assets of this repository, served over HTTPS by GitHub — or anything at all + /// while CATCHBOX_UPDATE_FEED is set, which only someone at this Mac can do. + static func trusted(_ url: URL) -> Bool { + if ProcessInfo.processInfo.environment["CATCHBOX_UPDATE_FEED"] != nil { return true } + return url.scheme == "https" && url.host == "github.com" + && url.path.hasPrefix("/brentc22/catchbox/releases/download/") + } + static let replacingFlag = "--replacing" - /// In the new copy, at launch: wait for the copy it replaces to have quit, so the two - /// never run a server side by side. Bounded, so a stuck predecessor cannot keep it from - /// starting — then the single-instance rule hands over to that one as usual. - /// Returns the pid it waited for once that one is gone: NSRunningApplication only learns - /// of the exit from the run loop, which this wait blocks, so the caller must skip it. - @discardableResult - static func waitForPredecessor(arguments: [String] = CommandLine.arguments) -> pid_t? { - guard let flag = arguments.firstIndex(of: replacingFlag), flag + 1 < arguments.count, - let pid = pid_t(arguments[flag + 1]) else { return nil } + /// In the new copy, at launch: the process it replaces, if it was started by an update. + static func predecessor(arguments: [String] = CommandLine.arguments) -> pid_t? { + guard let flag = arguments.firstIndex(of: replacingFlag), flag + 1 < arguments.count else { return nil } + return pid_t(arguments[flag + 1]) + } + + /// Waits, without blocking the main thread, for that process to have quit — so the two + /// never run a server side by side. Bounded, so a predecessor that hangs while quitting + /// cannot keep the new copy from starting. + static func waitForExit(of pid: pid_t) async { let deadline = Date().addingTimeInterval(10) - while kill(pid, 0) == 0, Date() < deadline { usleep(100_000) } - return kill(pid, 0) == 0 ? nil : pid + while kill(pid, 0) == 0, Date() < deadline { + try? await Task.sleep(nanoseconds: 100_000_000) + } } private func failed(_ release: Release, _ error: Error) { @@ -223,12 +254,14 @@ final class Updater { enum Failure: LocalizedError { case unreachable(Int) case notCatchbox + case untrustedSource(String) case tool(String, Int32) var errorDescription: String? { switch self { case .unreachable(let status): "GitHub did not answer (HTTP \(status))." case .notCatchbox: "The download was not the catchbox version that was offered." + case .untrustedSource(let host): "The update would come from \(host), not from catchbox's GitHub releases." case .tool(let name, let status): "\(name) failed with status \(status)." } } diff --git a/macos/Sources/CatchboxCore/Release.swift b/macos/Sources/CatchboxCore/Release.swift index 04fcd3c..e35959c 100644 --- a/macos/Sources/CatchboxCore/Release.swift +++ b/macos/Sources/CatchboxCore/Release.swift @@ -35,16 +35,17 @@ public struct Release: Decodable, Equatable, Sendable { } /// Whether `candidate` is a later version than `current`, compared number by number, so -/// 1.10.0 is later than 1.9.0. A pre-release (1.4.0-beta) is never offered as an update. +/// 1.10.0 is later than 1.9.0. A pre-release (1.4.0-beta) is never offered as an update, but +/// someone on 1.4.0-beta is offered 1.4.0 itself. public func isNewer(_ candidate: String, than current: String) -> Bool { guard !candidate.contains("-") else { return false } - let parse = { (v: String) -> [Int] in - v.split(separator: "-").first.map { $0.split(separator: ".").map { Int($0) ?? 0 } } ?? [] + let numbers = { (v: String) -> [Int] in + (v.split(separator: "-").first ?? "").split(separator: ".").map { Int($0) ?? 0 } } - let a = parse(candidate), b = parse(current) + let a = numbers(candidate), b = numbers(current) for i in 0.. y } } - return false + return current.contains("-") // same numbers: the release is newer than its own pre-release } diff --git a/macos/Sources/CatchboxTests/main.swift b/macos/Sources/CatchboxTests/main.swift index e8dd62f..4f5190e 100644 --- a/macos/Sources/CatchboxTests/main.swift +++ b/macos/Sources/CatchboxTests/main.swift @@ -69,6 +69,7 @@ check(isNewer("2.0", than: "1.9.9"), "a shorter version still compares") check(!isNewer("1.3.0", than: "1.3.0"), "the same version is not an update") check(!isNewer("1.2.9", than: "1.3.0"), "an older release is not an update") check(!isNewer("1.4.0-beta.1", than: "1.3.0"), "a pre-release is never offered") +check(isNewer("1.4.0", than: "1.4.0-beta.1"), "a release is offered to whoever runs its pre-release") do { let json = #""" {"tag_name":"v1.4.0","html_url":"https://github.com/brentc22/catchbox/releases/tag/v1.4.0", From 8dd858dbc1c0f574acbee91d882a3c6412b5cd04 Mon Sep 17 00:00:00 2001 From: Brent Ceulemans Date: Mon, 5 Oct 2026 10:32:41 +0200 Subject: [PATCH 3/3] chore: release 1.4.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2e3abac..bdb3298 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "catchbox", - "version": "1.3.0", + "version": "1.4.0", "description": "A disposable inbox for developers — grab an address, catch the mail, pull out the link or the code.", "type": "module", "bin": {