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": {