Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ What it adds over a browser tab:
<kbd>⇧⌘K</kbd> copy latest code, <kbd>⇧⌘O</kbd> open latest link, <kbd>⌘F</kbd> filter,
<kbd>⌘,</kbd> 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
Expand Down
28 changes: 27 additions & 1 deletion macos/Sources/Catchbox/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,30 @@ 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<Void, Never>?

private var accounts: AccountsPayload?
private var latest: Message? // the newest mail in the active mailbox
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
Expand All @@ -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(
Expand All @@ -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() }
}

Expand Down Expand Up @@ -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(",")
Expand Down
1 change: 1 addition & 0 deletions macos/Sources/Catchbox/MainMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
30 changes: 28 additions & 2 deletions macos/Sources/Catchbox/Notifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 } }
}
Expand All @@ -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,
Expand All @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions macos/Sources/Catchbox/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
var openInbox: () -> Void
var newMailbox: () -> Void
var toggleNotifications: () -> Void
var update: () -> Void
var quit: () -> Void
}

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() }
}
Loading
Loading