diff --git a/README.md b/README.md
index c40be2f..f5a53da 100644
--- a/README.md
+++ b/README.md
@@ -134,6 +134,13 @@ 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 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.
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..7683301 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,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var restarts = 0
func applicationDidFinishLaunching(_ notification: Notification) {
+ // 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,
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 +43,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 +53,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 +190,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..a540c58 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"
+ nonisolated 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,11 +58,28 @@ 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 }
+ // 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."
+ 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,
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,
@@ -66,8 +89,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..d414e23
--- /dev/null
+++ b/macos/Sources/Catchbox/Updater.swift
@@ -0,0 +1,269 @@
+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?
+ 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, 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) }
+ }
+ }
+
+ // 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
+ }
+
+ // 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 — 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)
+ }
+
+ 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) {
+ 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"
+ 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?()
+
+ 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) }
+
+ 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, 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,
+ 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 {
+ 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)
+ }
+
+ /// 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)
+ }
+ }
+ }
+
+ /// 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: 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 {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ }
+
+ 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 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
new file mode 100644
index 0000000..e35959c
--- /dev/null
+++ b/macos/Sources/CatchboxCore/Release.swift
@@ -0,0 +1,51 @@
+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, 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 numbers = { (v: String) -> [Int] in
+ (v.split(separator: "-").first ?? "").split(separator: ".").map { Int($0) ?? 0 }
+ }
+ let a = numbers(candidate), b = numbers(current)
+ for i in 0.. y }
+ }
+ 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 67d8c99..4f5190e 100644
--- a/macos/Sources/CatchboxTests/main.swift
+++ b/macos/Sources/CatchboxTests/main.swift
@@ -62,6 +62,32 @@ 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")
+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",
+ "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")
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": {