diff --git a/Magic Switch.xcodeproj/project.pbxproj b/Magic Switch.xcodeproj/project.pbxproj index edaa6fb..994084d 100644 --- a/Magic Switch.xcodeproj/project.pbxproj +++ b/Magic Switch.xcodeproj/project.pbxproj @@ -27,7 +27,7 @@ 076AB2212CE9D690004D45F0 /* NetworkDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076AB2012CE9D690004D45F0 /* NetworkDevice.swift */; }; 076AB2222CE9D690004D45F0 /* BluetoothPeripheralStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076AB2032CE9D690004D45F0 /* BluetoothPeripheralStore.swift */; }; 076AB2232CE9D690004D45F0 /* NetworkDeviceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076AB2042CE9D690004D45F0 /* NetworkDeviceStore.swift */; }; - 076AB2242CE9D690004D45F0 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076AB2092CE9D690004D45F0 /* MenuBarView.swift */; }; + 076AB2242CE9D690004D45F0 /* DropdownContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076AB2092CE9D690004D45F0 /* DropdownContentView.swift */; }; 076AB2252CE9D690004D45F0 /* BluetoothPeripheralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076AB20B2CE9D690004D45F0 /* BluetoothPeripheralSettingsView.swift */; }; 076AB2282CE9D690004D45F0 /* NetworkDeviceManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076AB20E2CE9D690004D45F0 /* NetworkDeviceManagementView.swift */; }; 076AB2292CE9D690004D45F0 /* OtherSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076AB20F2CE9D690004D45F0 /* OtherSettingsView.swift */; }; @@ -58,7 +58,7 @@ 076AB2032CE9D690004D45F0 /* BluetoothPeripheralStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothPeripheralStore.swift; sourceTree = ""; }; 076AB2042CE9D690004D45F0 /* NetworkDeviceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkDeviceStore.swift; sourceTree = ""; }; 076AB2072CE9D690004D45F0 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 076AB2092CE9D690004D45F0 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; + 076AB2092CE9D690004D45F0 /* DropdownContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownContentView.swift; sourceTree = ""; }; 076AB20B2CE9D690004D45F0 /* BluetoothPeripheralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothPeripheralSettingsView.swift; sourceTree = ""; }; 076AB20E2CE9D690004D45F0 /* NetworkDeviceManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkDeviceManagementView.swift; sourceTree = ""; }; 076AB20F2CE9D690004D45F0 /* OtherSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherSettingsView.swift; sourceTree = ""; }; @@ -154,7 +154,7 @@ 076AB20A2CE9D690004D45F0 /* MenuBar */ = { isa = PBXGroup; children = ( - 076AB2092CE9D690004D45F0 /* MenuBarView.swift */, + 076AB2092CE9D690004D45F0 /* DropdownContentView.swift */, ); path = MenuBar; sourceTree = ""; @@ -307,7 +307,7 @@ 076AB2212CE9D690004D45F0 /* NetworkDevice.swift in Sources */, 076AB2222CE9D690004D45F0 /* BluetoothPeripheralStore.swift in Sources */, 076AB2232CE9D690004D45F0 /* NetworkDeviceStore.swift in Sources */, - 076AB2242CE9D690004D45F0 /* MenuBarView.swift in Sources */, + 076AB2242CE9D690004D45F0 /* DropdownContentView.swift in Sources */, 076AB2252CE9D690004D45F0 /* BluetoothPeripheralSettingsView.swift in Sources */, 076AB2282CE9D690004D45F0 /* NetworkDeviceManagementView.swift in Sources */, 076AB2292CE9D690004D45F0 /* OtherSettingsView.swift in Sources */, diff --git a/Magic Switch/AppDelegate/AppDelegate.swift b/Magic Switch/AppDelegate/AppDelegate.swift index 754099e..5dce1e0 100644 --- a/Magic Switch/AppDelegate/AppDelegate.swift +++ b/Magic Switch/AppDelegate/AppDelegate.swift @@ -4,7 +4,7 @@ import CoreBluetooth import SwiftUI /// Application delegate handling lifecycle and UI setup -final class AppDelegate: NSObject, NSApplicationDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // MARK: - Dependencies private let networkStore = NetworkDeviceStore.shared @@ -13,6 +13,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - UI Components private var statusItem: NSStatusItem! + /// The AppKit view hosted by the dropdown menu's single item. Kept so it can + /// be re-measured each time the menu opens. + private var dropdownContentView: DropdownContentView? private var bluetoothStateObserver: AnyCancellable? private var pairingObserver: AnyCancellable? private var windowCloseObserver: NSObjectProtocol? @@ -205,16 +208,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.setActivationPolicy(.accessory) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) - guard let button = statusItem.button else { return } - - configureStatusBarButton(button) + // A tracked status-item NSMenu (not a popover): it keeps the menu bar + // revealed over full-screen Spaces and never activates the app. Its single + // item hosts an AppKit `DropdownContentView` whose controls consume the + // mouse-up, so clicks don't dismiss it — the dropdown stays open while a + // peripheral pairs. (SwiftUI controls don't track inside a menu.) + statusItem.menu = makeDropdownMenu() refreshStatusBarIcon() } - private func configureStatusBarButton(_ button: NSStatusBarButton) { - button.target = self - button.action = #selector(handleClick(_:)) - button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + /// Build the status-item dropdown: an `NSMenu` whose single item hosts the + /// AppKit `DropdownContentView`. The view drives switch actions directly; + /// Settings / Quit route back through the delegate. + private func makeDropdownMenu() -> NSMenu { + let menu = NSMenu() + menu.delegate = self + let item = NSMenuItem() + let content = DropdownContentView( + onSwitchMac: { [weak self] device in self?.performSwitch(with: device) }, + onOpenSettings: { [weak self] in self?.openSettingsWindow(nil) }, + onQuit: { [weak self] in self?.quitFromStatusBar(nil) }) + content.updateFrameToFit() + item.view = content + menu.addItem(item) + dropdownContentView = content + return menu + } + + /// Refresh reachability + re-measure the hosted view just before the dropdown + /// opens. Probe results land asynchronously and re-render the open menu. + func menuWillOpen(_ menu: NSMenu) { + networkStore.refreshReachability() + dropdownContentView?.updateFrameToFit() } /// Updates the menu-bar icon based on transfer state (highest priority), @@ -346,23 +371,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Action Handlers - @objc private func handleClick(_ sender: NSStatusBarButton) { - guard let event = NSApp.currentEvent else { return } - - // Both buttons open the menu; the switch is triggered by clicking a Mac - // row inside it (see `handleMacMenuClick`). - switch event.type { - case .rightMouseUp, .leftMouseUp: - showMenu() - default: - break - } - } - - private func showMenu() { - MenuBarView().showMenu(statusItem: statusItem) - } - /// Runs the switch handoff with `device`. Triggered by clicking a Mac row in /// the menu. `checkHealth` confirms the peer's TCP port is open before we /// touch any local Bluetooth state. @@ -525,36 +533,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Settings Management - /// Right-click menu's per-peripheral switch. `representedObject` carries - /// the peripheral's MAC; we dispatch based on the current local - /// connection state — if it's here, send it to the peer; if it's - /// elsewhere, ask the peer to release it. - @objc func handlePeripheralMenuClick(_ sender: NSMenuItem) { - guard let address = sender.representedObject as? String else { return } - let store = bluetoothStore - guard let peripheral = store.peripherals.first(where: { $0.id == address }) else { return } - switch store.connectionState(for: address) { - case .connected: - store.sendPeripheralToPeer(peripheral) - case .disconnected: - store.takePeripheralFromPeer(peripheral) - case .connecting: - break - } - } - - /// Menu's Mac entry. Performs the peripheral switch (handoff) with the - /// clicked Mac. `validateMenuItem` greys the row out when the peer isn't - /// reachable, so this only fires for an active device. - @objc func handleMacMenuClick(_ sender: NSMenuItem) { - guard let id = sender.representedObject as? String, - let device = networkStore.networkDevices.first(where: { $0.id == id }) - else { return } - performSwitch(with: device) - } - /// Opens the newest release in the default browser. Wired to the "Update - /// Available" item in the right-click menu (see `MenuBarView`). + /// Available" row in the dropdown (see `DropdownContentView`). @objc func openLatestReleasePage(_ sender: Any?) { guard let url = UpdateChecker.shared.releasePageURL else { return } NSWorkspace.shared.open(url) @@ -597,20 +577,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { pingFlashTimer = timer } - /// AppKit's auto-validation routes through here for menu items targeting - /// this delegate. Mac entries are enabled only when the peer is currently - /// reachable on the network (`device.isActive`); everything else passes - /// through. - @objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - if menuItem.action == #selector(handleMacMenuClick(_:)) { - guard let id = menuItem.representedObject as? String, - let device = networkStore.networkDevices.first(where: { $0.id == id }) - else { return false } - return device.isActive - } - return true - } - /// Opens the Settings window. We deliberately don't route through the /// SwiftUI `Settings { ... }` scene + `sendAction(showSettingsWindow:)` /// here — that path produces a Dock icon and an active app but no @@ -622,12 +588,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// `makeKeyAndOrderFront(_:)`. @objc func openSettingsWindow(_ sender: Any?) { NSApp.setActivationPolicy(.regular) - NSApp.activate(ignoringOtherApps: true) if settingsWindowController == nil { settingsWindowController = makeSettingsWindowController() } settingsWindowController?.showWindow(nil) settingsWindowController?.window?.makeKeyAndOrderFront(nil) + // Activate AFTER ordering the window front so macOS switches to the Space + // the window lives on (activating before a window exists can strand the + // user on a full-screen Space). Deliberately the deprecated + // `ignoringOtherApps:` form — the cooperative `activate()` won't take over + // another app's full-screen Space. + NSApp.activate(ignoringOtherApps: true) } private func makeSettingsWindowController() -> NSWindowController { diff --git a/Magic Switch/Manager/OutgoingConnection.swift b/Magic Switch/Manager/OutgoingConnection.swift index 574a0f4..6326a1a 100644 --- a/Magic Switch/Manager/OutgoingConnection.swift +++ b/Magic Switch/Manager/OutgoingConnection.swift @@ -114,6 +114,11 @@ final class OutgoingConnection { private let host: String private let pairingStore: PairingStore private let rateLimiter: OutboundRateLimiter + /// When false, this connection neither consults nor feeds the outbound rate + /// limiter. Used by the background reachability poll so its fixed-cadence + /// probes can't trip the limiter — and thereby block a user-initiated switch — + /// when a peer is down. + private let countsTowardRateLimit: Bool private let queue: DispatchQueue private var channel: SecureChannel? private var selfRef: OutgoingConnection? @@ -128,6 +133,7 @@ final class OutgoingConnection { port: UInt16, pairingStore: PairingStore = .shared, rateLimiter: OutboundRateLimiter = .shared, + countsTowardRateLimit: Bool = true, queue: DispatchQueue = DispatchQueue(label: "com.magicswitch.outgoing", qos: .userInitiated) ) { self.connection = NWConnection( @@ -138,6 +144,7 @@ final class OutgoingConnection { self.host = host self.pairingStore = pairingStore self.rateLimiter = rateLimiter + self.countsTowardRateLimit = countsTowardRateLimit self.queue = queue } @@ -153,7 +160,7 @@ final class OutgoingConnection { ) { selfRef = self - guard rateLimiter.shouldAttempt(host: host) else { + guard !countsTowardRateLimit || rateLimiter.shouldAttempt(host: host) else { print("OutgoingConnection: backing off — too many recent failures to \(host)") completion(.failure(.tooManyRecentFailures)) release() @@ -232,14 +239,16 @@ final class OutgoingConnection { // Feed the outbound rate limiter so a series of failures throttles // future attempts, and a success clears the counter immediately. Skip // `tooManyRecentFailures` — that's the limiter's own refusal and would - // double-count. - switch result { - case .success: - rateLimiter.recordSuccess(host: host) - case .failure(.tooManyRecentFailures): - break - case .failure: - rateLimiter.recordFailure(host: host) + // double-count. Background reachability probes opt out entirely. + if countsTowardRateLimit { + switch result { + case .success: + rateLimiter.recordSuccess(host: host) + case .failure(.tooManyRecentFailures): + break + case .failure: + rateLimiter.recordFailure(host: host) + } } completion(result) release() diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 918bcdf..95534db 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -119,6 +119,14 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// IOBluetooth disconnect notifications. @Published private(set) var connectionStates: [String: PeripheralConnectionState] = [:] + /// Inline per-peripheral error shown under the row in the menu-bar dropdown + /// (so a failed switch is visible without relying on the system notification). + /// Set on a switch failure; fades after 5s, or sooner when `setConnectionState` + /// sees the next attempt (`.connecting`) or success (`.connected`). + @Published private(set) var peripheralOperationError: [String: String] = [:] + /// Per-peripheral fade timers for `peripheralOperationError`. + private var peripheralErrorTimers: [String: DispatchSourceTimer] = [:] + /// In-flight `IOBluetoothDevicePair` instances, kept alive until /// `devicePairingFinished` fires. Without this, ARC frees the pair mid-op and /// macOS aborts pairing, dropping the peripheral seconds after it connects. @@ -430,6 +438,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // drop's auto-reconnect. clearIntentionalRelease(peripheral.id) print("Failed to release \(peripheral.name): closeConnection returned \(result)") + setPeripheralError("Couldn't release.", for: peripheral.id) NotificationManager.showNotification( title: "Couldn't Release Peripheral", body: @@ -512,6 +521,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // where the peer released but the ack was lost. self.setConnectionState(.disconnected, for: peripheral.id) self.armReconnect(peripheral.id) + self.setPeripheralError("Switch failed.", for: peripheral.id) NotificationManager.showNotification( title: "Couldn't Switch", body: @@ -528,25 +538,58 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// right-click menu's per-peripheral switch when the peripheral is /// currently on this Mac. /// - /// Falls back to a plain local unregister if there's no paired peer. + /// Preflights the peer with a `.ping` *before* releasing anything: `isActive` + /// (Bonjour) can lag reality by the mDNS TTL, so a PING that handshakes and + /// acks is the authoritative "the peer's app is up and will accept a command" + /// check. If it fails we keep the peripheral on this Mac untouched rather than + /// release it into a peer that can't pick it up (stranding it on neither). + /// Falls back to a plain local unregister only when there's no paired peer to + /// hand to. Mirrors the full-set handoff preflight in + /// `AppDelegate.handleSwitchAction`. /// - /// If the peer doesn't take it, the peripheral is rolled back onto this Mac - /// rather than left stranded on neither — directly when the peer was - /// unreachable, otherwise via the `HOLDS_ONE`-gated watcher. + /// If the peer dies *after* the preflight but before it takes the peripheral, + /// it's rolled back onto this Mac rather than left stranded — via the + /// `HOLDS_ONE`-gated watcher. func sendPeripheralToPeer(_ peripheral: BluetoothPeripheral) { let networkStore = NetworkDeviceStore.shared - guard let device = networkStore.networkDevices.first, - PairingStore.shared.isPaired, - device.isActive - else { + guard let device = networkStore.networkDevices.first, PairingStore.shared.isPaired else { + // No peer to hand off to — releasing locally is the only thing we can do. unregisterFromPC(peripheral) return } + networkStore.executeCommand(.ping, on: device) { [weak self] preflight in + DispatchQueue.main.async { + guard let self = self else { return } + switch preflight { + case .failure(let err): + // Peer unreachable — nothing released, peripheral stays on this Mac. + self.setPeripheralError("Other Mac unreachable.", for: peripheral.id) + NotificationManager.showNotification( + title: "Switch Cancelled", + body: + "Couldn't reach \(device.name) (\(err.userMessage)) — keeping \(peripheral.name) on this Mac.", + identifier: "send-preflight-failed-\(peripheral.id)" + ) + case .success: + self.performSendHandoff(peripheral, to: device) + } + } + } + } + + /// Release `peripheral` locally, wait for the IOBluetooth-level disconnect, + /// then ask the peer to take it. Split out of `sendPeripheralToPeer` so the + /// `.ping` preflight gates entry — by the time we get here the peer has just + /// acked, but it can still die before `CONNECT_ONE`, so the failure arms + /// re-pair this Mac rather than strand the peripheral. + private func performSendHandoff(_ peripheral: BluetoothPeripheral, to device: NetworkDevice) { + let networkStore = NetworkDeviceStore.shared // Peripheral is leaving this Mac for the peer — flash the sending arrow. NotificationCenter.default.post(name: .magicSwitchPeripheralOutgoing, object: nil) unregisterFromPC(peripheral) - waitForLocalDisconnect(of: peripheral) { success in + waitForLocalDisconnect(of: peripheral) { [weak self] success in guard success else { + self?.setPeripheralError("Didn't disconnect.", for: peripheral.id) NotificationManager.showNotification( title: "Couldn't Switch", body: "\(peripheral.name) didn't disconnect from this Mac.", @@ -560,10 +603,11 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip case .success: break // peer took it — done. case .failure(.connectionFailed), .failure(.connectTimeout): - // Peer never came up, so it didn't take the peripheral. We already - // released it locally, so roll it back onto this Mac rather than - // strand it on neither — the same recovery the full-set handoff does - // — and arm the watcher in case the reconnect needs a power-cycle. + // Peer acked the preflight but dropped before CONNECT_ONE, so it + // didn't take the peripheral. We already released it locally, so roll + // it back onto this Mac rather than strand it on neither — the same + // recovery the full-set handoff does — and arm the watcher in case + // the reconnect needs a power-cycle. self.clearIntentionalRelease(peripheral.id) self.connectPeripheral(peripheral) self.armReconnect(peripheral.id) @@ -579,6 +623,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // reclaims it only if the peer confirms it isn't holding it. self.clearIntentionalRelease(peripheral.id) self.armReconnect(peripheral.id) + self.setPeripheralError("Handoff failed.", for: peripheral.id) NotificationManager.showNotification( title: "Couldn't Switch", body: @@ -904,13 +949,36 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } private func setConnectionState(_ state: PeripheralConnectionState, for id: String) { - if Thread.isMainThread { - connectionStates[id] = state - } else { - DispatchQueue.main.async { [weak self] in - self?.connectionStates[id] = state - } + let apply: () -> Void = { [weak self] in + self?.connectionStates[id] = state + // A fresh attempt (.connecting) or a success (.connected) clears any prior + // inline error; a failure that ends in .disconnected keeps it on screen. + if state != .disconnected { self?.clearPeripheralError(id) } } + if Thread.isMainThread { apply() } else { DispatchQueue.main.async(execute: apply) } + } + + /// Set the inline error for a peripheral, and fade it after 5s so it doesn't + /// linger on the row. `setConnectionState` clears it sooner on a new attempt. + private func setPeripheralError(_ message: String, for id: String) { + let apply: () -> Void = { [weak self] in + guard let self = self else { return } + self.peripheralOperationError[id] = message + self.peripheralErrorTimers[id]?.cancel() + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + 5) + timer.setEventHandler { [weak self] in self?.clearPeripheralError(id) } + timer.resume() + self.peripheralErrorTimers[id] = timer + } + if Thread.isMainThread { apply() } else { DispatchQueue.main.async(execute: apply) } + } + + /// Clear a peripheral's inline error and cancel its fade timer (main thread). + private func clearPeripheralError(_ id: String) { + peripheralErrorTimers[id]?.cancel() + peripheralErrorTimers[id] = nil + peripheralOperationError[id] = nil } // MARK: - Pair Watchdog diff --git a/Magic Switch/Model/Store/NetworkDeviceStore.swift b/Magic Switch/Model/Store/NetworkDeviceStore.swift index 7b1616d..bdd3769 100644 --- a/Magic Switch/Model/Store/NetworkDeviceStore.swift +++ b/Magic Switch/Model/Store/NetworkDeviceStore.swift @@ -36,6 +36,15 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { @Published private(set) var discoveredNetworkDevices: [NetworkDevice] = [] @AppStorage("networkDevices") private var networkDevicesData: Data = Data() + /// Last active-probe result per device id (runtime only, never persisted). + /// Combined signal: Bonjour resolve/withdraw write it on each transition, and + /// a repeating `.ping` (plus an on-menu-open `.ping`) keep it honest between + /// Bonjour events — so a peer that vanished without a Bonjour goodbye flips + /// to false within one poll interval instead of waiting out the mDNS TTL. + @Published private(set) var deviceReachability: [String: Bool] = [:] + private var reachabilityTimer: DispatchSourceTimer? + private static let reachabilityInterval: TimeInterval = 30 + // MARK: - Computed Properties var availableNetworkDevices: [NetworkDevice] { @@ -52,10 +61,12 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { private init() { loadNetworkDevices() startServices() + startReachabilityPolling() } deinit { stopServices() + reachabilityTimer?.cancel() } // MARK: - Service Management @@ -91,6 +102,10 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { let priorFingerprint = networkDevices[index].fingerprint networkDevices[index].update(with: device) saveNetworkDevices() + // Fold the Bonjour signal into reachability: a fresh resolve is a good + // indication the peer is up; a mismatch (isActive == false) keeps it + // greyed. The `.ping` poll refines this between Bonjour events. + deviceReachability[device.id] = networkDevices[index].isActive if let prior = priorFingerprint, let incoming = device.fingerprint, prior != incoming @@ -116,6 +131,11 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { networkDevices[index].pendingFingerprint = nil networkDevices[index].isActive = true networkDevices[index].lastUpdated = Date() + // Trust is a positive presence signal — the peer is on the network, which + // is how we saw the new key — so clear the stale `false` reachability the + // mismatch left behind rather than make the user wait for the next poll to + // un-grey the menu row. + deviceReachability[deviceID] = true saveNetworkDevices() } @@ -149,6 +169,68 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { if let index = discoveredNetworkDevices.firstIndex(where: { $0.id == id }) { discoveredNetworkDevices[index].isActive = isActive } + // Mirror Bonjour's verdict into reachability (a withdraw is a valid, if + // slow, offline signal); the `.ping` poll provides the fast path. + deviceReachability[id] = isActive + } + + // MARK: - Reachability + + /// Pessimistic default: until a Bonjour resolve or a `.ping` has actually + /// confirmed the peer, treat it as unreachable. The probe is async, so it + /// can't gate the first (synchronous) menu build — an optimistic default + /// would show an offline peer's row enabled on a cold start until the first + /// probe lands. An online peer un-greys within ~1s: a Bonjour resolve writes + /// `true`, and the first poll confirms it. + func isReachable(_ id: String) -> Bool { deviceReachability[id] ?? false } + + /// A device is switchable when it's reachable *and* not parked behind a + /// pending TOFU identity mismatch (which the user must Trust first). Drives + /// the menu's Mac-row enablement and tooltip. + func isSwitchable(_ device: NetworkDevice) -> Bool { + device.pendingFingerprint == nil && isReachable(device.id) + } + + /// Repeating background probe — runs every `reachabilityInterval` for the + /// life of the app, independent of whether the menu is ever opened. + private func startReachabilityPolling() { + let timer = DispatchSource.makeTimerSource(queue: .main) + // First fire soon after launch so an online peer is confirmed quickly + // (it starts greyed under the pessimistic default); then settle into the + // steady interval. + timer.schedule( + deadline: .now() + 1, repeating: Self.reachabilityInterval, leeway: .seconds(5)) + timer.setEventHandler { [weak self] in self?.pollReachability() } + timer.resume() + reachabilityTimer = timer + } + + /// Kick an immediate probe — called when the menu opens so the *next* render + /// is fresh (the probe is async and can't update an already-built menu). The + /// background timer keeps running on its own cadence regardless. + func refreshReachability() { pollReachability() } + + private func pollReachability() { + // `.ping` rides the secure channel, so it's meaningless unpaired — skip + // (leaving the pessimistic default) rather than spam `.notPaired` failures. + // Skip mismatched peers too: a `.ping` with our old key would just auth-fail + // and feed the peer's inbound rate limiter. `countsTowardRateLimit: false` + // keeps these fixed-cadence probes from tripping our own outbound limiter. + guard PairingStore.shared.isPaired else { return } + for device in networkDevices where device.pendingFingerprint == nil { + executeCommand(.ping, on: device, countsTowardRateLimit: false) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + let reachable: Bool + if case .success = result { reachable = true } else { reachable = false } + // Publish only on change — a steady-state poll would otherwise fire + // objectWillChange every interval and needlessly re-render observers. + if self.deviceReachability[device.id] != reachable { + self.deviceReachability[device.id] = reachable + } + } + } + } } func sendNotification( @@ -250,6 +332,7 @@ extension NetworkDeviceStore { func executeCommand( _ command: DeviceCommand, on device: NetworkDevice, + countsTowardRateLimit: Bool = true, completion: @escaping (Result) -> Void ) { guard PairingStore.shared.isPaired else { @@ -257,7 +340,8 @@ extension NetworkDeviceStore { return } - let outgoing = OutgoingConnection(host: device.host, port: UInt16(device.port)) + let outgoing = OutgoingConnection( + host: device.host, port: UInt16(device.port), countsTowardRateLimit: countsTowardRateLimit) outgoing.run( body: { channel, done in channel.send(Data(command.rawValue.utf8)) { sendErr in diff --git a/Magic Switch/View/MenuBar/DropdownContentView.swift b/Magic Switch/View/MenuBar/DropdownContentView.swift new file mode 100644 index 0000000..68fbb54 --- /dev/null +++ b/Magic Switch/View/MenuBar/DropdownContentView.swift @@ -0,0 +1,436 @@ +import AppKit +import Combine + +/// A clickable row inside the status-bar dropdown. The key trick: `mouseDown` +/// runs the action then *consumes the mouse-up* off the window queue, so the +/// tracked `NSMenu` never sees a selection and doesn't dismiss — that's what +/// lets the dropdown stay open while a peripheral pairs. Disabled rows ignore +/// clicks (used for an unreachable Mac). Adapted from `TapControl` in the +/// wiz-light-controller reference. +final class MenuRowControl: NSControl { + private let onClick: () -> Void + private var hoverArea: NSTrackingArea? + + init(onClick: @escaping () -> Void) { + self.onClick = onClick + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 5 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + /// Route every click in the row to `onClick`, ignoring the visual subviews. + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + guard isEnabled, let window = window else { return } + onClick() + // Swallow the mouse-up so the tracked menu doesn't treat the tap as a + // selection and close. + _ = window.nextEvent(matching: [.leftMouseUp]) + } + + // MARK: - Hover highlight + + /// `.activeAlways` so the highlight tracks even though a menu window isn't + /// key; `.inVisibleRect` keeps the area sized to the row across rebuilds. + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let hoverArea { removeTrackingArea(hoverArea) } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil) + addTrackingArea(area) + hoverArea = area + } + + override func mouseEntered(with event: NSEvent) { + guard isEnabled else { return } + layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.1).cgColor + } + + override func mouseExited(with event: NSEvent) { + layer?.backgroundColor = nil + } +} + +/// The status-bar dropdown's content, in AppKit so it can live inside a tracked +/// `NSMenu` — the only surface that keeps the macOS menu bar revealed over a +/// full-screen Space and never activates the app (a popover does neither), and +/// SwiftUI controls don't track inside a menu. Mirrors the old `MenuBarView` +/// NSMenu — optional update row, Macs, Peripherals, Settings, Quit — but the +/// rows are live (it observes the stores) and a peripheral tap keeps the menu +/// open so progress ("Pairing…") and errors show inline. Pattern adapted from +/// wiz-light-controller's `DropdownContentView`. +final class DropdownContentView: NSView { + // MARK: - Dependencies + + private let networkStore = NetworkDeviceStore.shared + private let bluetoothStore = BluetoothPeripheralStore.shared + private let updateChecker = UpdateChecker.shared + + private let onSwitchMac: (NetworkDevice) -> Void + private let onOpenSettings: () -> Void + private let onQuit: () -> Void + + private let stack = NSStackView() + private var cancellables: Set = [] + private var syncScheduled = false + + private static let panelWidth: CGFloat = 260 + private static let inset: CGFloat = 12 + private static let contentWidth: CGFloat = panelWidth - inset * 2 + + init( + onSwitchMac: @escaping (NetworkDevice) -> Void, + onOpenSettings: @escaping () -> Void, + onQuit: @escaping () -> Void + ) { + self.onSwitchMac = onSwitchMac + self.onOpenSettings = onOpenSettings + self.onQuit = onQuit + super.init(frame: NSRect(x: 0, y: 0, width: Self.panelWidth, height: 80)) + + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 2 + stack.edgeInsets = NSEdgeInsets(top: 6, left: Self.inset, bottom: 6, right: Self.inset) + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + rebuild() + + // Live updates: rebuild when any source store changes. Menu tracking drains + // the main queue, so these fire and re-render even while the dropdown is open. + networkStore.objectWillChange + .sink { [weak self] _ in self?.scheduleSync() }.store(in: &cancellables) + bluetoothStore.objectWillChange + .sink { [weak self] _ in self?.scheduleSync() }.store(in: &cancellables) + updateChecker.objectWillChange + .sink { [weak self] _ in self?.scheduleSync() }.store(in: &cancellables) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Live updates + + /// Coalesce a burst of `objectWillChange` into one rebuild on the next tick — + /// which also lands *after* the publishers commit, so we read fresh values. + private func scheduleSync() { + guard !syncScheduled else { return } + syncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.syncScheduled = false + self.rebuild() + } + } + + // MARK: - Build + + private func rebuild() { + for view in stack.arrangedSubviews { + stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + + if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { + stack.addArrangedSubview(makeUpdateRow(latest)) + stack.addArrangedSubview(makeDivider()) + } + + let macs = networkStore.networkDevices + if !macs.isEmpty { + stack.addArrangedSubview(makeSectionHeader("Macs")) + for device in macs { stack.addArrangedSubview(makeMacRow(device)) } + stack.addArrangedSubview(makeDivider()) + } + + let peripherals = bluetoothStore.peripherals + if !peripherals.isEmpty { + stack.addArrangedSubview(makeSectionHeader("Peripherals")) + var previousRow: NSView? + for peripheral in peripherals { + let row = makePeripheralRow(peripheral) + stack.addArrangedSubview(row) + // Sit consecutive peripherals flush (0 gap) against each other; the + // section's normal spacing still applies after the header and before + // the divider below. + if let previousRow { stack.setCustomSpacing(0, after: previousRow) } + previousRow = row + } + stack.addArrangedSubview(makeDivider()) + } + + stack.addArrangedSubview( + makeActionRow(symbol: "gearshape", title: "Settings…") { [weak self] in + self?.dismissThen { self?.onOpenSettings() } + }) + stack.addArrangedSubview( + makeActionRow(symbol: "power", title: "Quit") { [weak self] in + self?.dismissThen { self?.onQuit() } + }) + + updateFrameToFit() + } + + /// Resize to fit the current content at the fixed width, so the menu measures + /// the right item size (it changes as peripherals pair / errors appear). Width + /// is pinned, not taken from `fittingSize`, because AppKit reports widths lazily. + func updateFrameToFit() { + setFrameSize(NSSize(width: Self.panelWidth, height: max(1, fittingSize.height))) + layoutSubtreeIfNeeded() + setFrameSize(NSSize(width: Self.panelWidth, height: max(1, fittingSize.height))) + invalidateIntrinsicContentSize() + } + + /// Close the tracked menu, then run `action` on the next tick — after the + /// menu's modal tracking loop has ended, so activating a window (Settings) or + /// terminating behaves. Switch rows skip this so the menu stays open. + private func dismissThen(_ action: @escaping () -> Void) { + enclosingMenuItem?.menu?.cancelTracking() + DispatchQueue.main.async(execute: action) + } + + // MARK: - Rows + + private func makeSectionHeader(_ title: String) -> NSView { + let label = NSTextField(labelWithString: title) + label.font = .systemFont(ofSize: 10, weight: .semibold) + label.textColor = .secondaryLabelColor + label.translatesAutoresizingMaskIntoConstraints = false + // A little space above the header so it's clear of the section before it, + // and flush below so it groups with its own rows underneath. + let container = NSView() + container.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: container.leadingAnchor), + label.trailingAnchor.constraint(equalTo: container.trailingAnchor), + label.topAnchor.constraint(equalTo: container.topAnchor, constant: 5), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + return fullWidth(container) + } + + private func makeDivider() -> NSView { + let box = NSBox() + box.boxType = .separator + return fullWidth(box) + } + + private func makeMacRow(_ device: NetworkDevice) -> NSView { + let switchable = networkStore.isSwitchable(device) + let row = MenuRowControl { [weak self] in self?.onSwitchMac(device) } + row.isEnabled = switchable + + let color: NSColor = switchable ? .labelColor : .tertiaryLabelColor + let content = NSStackView() + content.orientation = .horizontal + content.distribution = .fill + content.spacing = 8 + content.alignment = .centerY + content.addArrangedSubview(symbolView("desktopcomputer", color: color)) + content.addArrangedSubview(textLabel(device.name, color: color)) + content.addArrangedSubview(spacer()) + + row.toolTip = + switchable + ? "Switch peripherals between this Mac and \(device.name)." + : "\(device.name) isn't reachable on the network right now." + return clickableRow(row, content: content) + } + + private func makePeripheralRow(_ peripheral: BluetoothPeripheral) -> NSView { + let state = bluetoothStore.connectionState(for: peripheral.id) + let canSwitch = networkStore.networkDevices.contains { networkStore.isSwitchable($0) } + let row = MenuRowControl { [weak self] in self?.togglePeripheral(peripheral) } + // A disconnected peripheral is always clickable — take it (locally over + // Bluetooth if there's no peer to ask). A connected one can only be *sent*, + // so it greys out when no Mac is reachable to hand it to. A pairing row is + // disabled while in flight. + let enabled: Bool + switch state { + case .connecting: enabled = false + case .connected: enabled = canSwitch + case .disconnected: enabled = true + } + row.isEnabled = enabled + // Dim only the "nowhere to send it" case; a pairing row keeps its normal + // colour alongside the "Pairing…" label. + let dimmed = state == .connected && !canSwitch + let textColor: NSColor = dimmed ? .tertiaryLabelColor : .labelColor + + let top = NSStackView() + top.orientation = .horizontal + top.distribution = .fill + top.spacing = 8 + top.alignment = .centerY + top.addArrangedSubview(symbolView("keyboard", color: textColor)) + top.addArrangedSubview(textLabel(peripheral.name, color: textColor)) + top.addArrangedSubview(spacer()) + switch state { + case .connected: + top.addArrangedSubview(symbolView("checkmark", color: .controlAccentColor)) + case .connecting: + top.addArrangedSubview(caption("Pairing…", color: .secondaryLabelColor)) + case .disconnected: + break + } + + let column = NSStackView() + column.orientation = .vertical + column.alignment = .leading + column.spacing = 2 + column.addArrangedSubview(top) + top.translatesAutoresizingMaskIntoConstraints = false + top.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true + if let error = bluetoothStore.peripheralOperationError[peripheral.id] { + // Wrapping label pinned to the column width, so it wraps (instead of + // truncating) without depending on a hand-computed max-layout width. + let errorLabel = NSTextField(wrappingLabelWithString: error) + errorLabel.font = .systemFont(ofSize: 11) + errorLabel.textColor = .systemRed + errorLabel.translatesAutoresizingMaskIntoConstraints = false + column.addArrangedSubview(errorLabel) + errorLabel.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true + } + + switch state { + case .connecting: + row.toolTip = "Pairing \(peripheral.name)…" + case .connected: + row.toolTip = + canSwitch + ? "On this Mac — click to hand \(peripheral.name) to the other Mac." + : "On this Mac. No other Mac is reachable to hand it to." + case .disconnected: + row.toolTip = + canSwitch + ? "Click to bring \(peripheral.name) to this Mac." + : "Click to connect \(peripheral.name) to this Mac over Bluetooth." + } + return clickableRow(row, content: column) + } + + private func makeActionRow( + symbol: String, title: String, onClick: @escaping () -> Void + ) -> NSView { + let row = MenuRowControl(onClick: onClick) + let content = NSStackView() + content.orientation = .horizontal + content.distribution = .fill + content.spacing = 8 + content.alignment = .centerY + content.addArrangedSubview(symbolView(symbol, color: .labelColor)) + content.addArrangedSubview(textLabel(title, color: .labelColor)) + content.addArrangedSubview(spacer()) + return clickableRow(row, content: content) + } + + private func makeUpdateRow(_ latest: String) -> NSView { + let row = MenuRowControl { [weak self] in + self?.dismissThen { + if let url = UpdateChecker.shared.releasePageURL { NSWorkspace.shared.open(url) } + } + } + let content = NSStackView() + content.orientation = .horizontal + content.distribution = .fill + content.spacing = 8 + content.alignment = .centerY + content.addArrangedSubview(symbolView("arrow.down.circle.fill", color: .controlAccentColor)) + content.addArrangedSubview(textLabel("Update Available: v\(latest)", color: .labelColor)) + content.addArrangedSubview(spacer()) + row.toolTip = "A newer version of Magic Switch is available. Opens the release page." + return clickableRow(row, content: content) + } + + // MARK: - Actions + + private func togglePeripheral(_ peripheral: BluetoothPeripheral) { + let canSwitch = networkStore.networkDevices.contains { networkStore.isSwitchable($0) } + switch bluetoothStore.connectionState(for: peripheral.id) { + case .connected: + bluetoothStore.sendPeripheralToPeer(peripheral) + case .disconnected: + if canSwitch { + bluetoothStore.takePeripheralFromPeer(peripheral) + } else { + // No peer to ask — pair it to this Mac directly over Bluetooth. + bluetoothStore.connectPeripheral(peripheral) + } + case .connecting: + break + } + } + + // MARK: - Building blocks + + /// Pin a row to the fixed content width so every row lines up under the insets. + private func fullWidth(_ view: NSView) -> NSView { + view.translatesAutoresizingMaskIntoConstraints = false + view.widthAnchor.constraint(equalToConstant: Self.contentWidth).isActive = true + return view + } + + /// Drop `content` inside a clickable row with a little vertical padding. + private func clickableRow(_ control: MenuRowControl, content: NSView) -> NSView { + content.translatesAutoresizingMaskIntoConstraints = false + control.addSubview(content) + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: control.leadingAnchor, constant: 6), + content.trailingAnchor.constraint(equalTo: control.trailingAnchor, constant: -6), + content.topAnchor.constraint(equalTo: control.topAnchor, constant: 4), + content.bottomAnchor.constraint(equalTo: control.bottomAnchor, constant: -4), + ]) + return fullWidth(control) + } + + private func symbolView(_ name: String, color: NSColor) -> NSImageView { + let view = NSImageView() + view.image = NSImage(systemSymbolName: name, accessibilityDescription: nil) + view.contentTintColor = color + view.translatesAutoresizingMaskIntoConstraints = false + view.widthAnchor.constraint(equalToConstant: 18).isActive = true + view.setContentHuggingPriority(.required, for: .horizontal) + return view + } + + private func textLabel(_ string: String, color: NSColor) -> NSTextField { + let label = NSTextField(labelWithString: string) + label.font = .systemFont(ofSize: 13) + label.textColor = color + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return label + } + + private func caption(_ string: String, color: NSColor) -> NSTextField { + let label = NSTextField(labelWithString: string) + label.font = .systemFont(ofSize: 11) + label.textColor = color + return label + } + + /// A flexible spacer that pushes trailing accessories to the right edge. + private func spacer() -> NSView { + let view = NSView() + view.translatesAutoresizingMaskIntoConstraints = false + view.setContentHuggingPriority(.init(1), for: .horizontal) + view.setContentCompressionResistancePriority(.init(1), for: .horizontal) + return view + } +} diff --git a/Magic Switch/View/MenuBar/MenuBarView.swift b/Magic Switch/View/MenuBar/MenuBarView.swift deleted file mode 100644 index 0cc47b7..0000000 --- a/Magic Switch/View/MenuBar/MenuBarView.swift +++ /dev/null @@ -1,176 +0,0 @@ -import SwiftUI - -protocol MenuBarPresentable { - func showMenu(statusItem: NSStatusItem) -} - -final class MenuBarView: MenuBarPresentable { - // MARK: - Constants - - private enum Constants { - enum Menu { - static let updateAvailable = "Update Available" - static let macsHeader = "Macs" - static let peripheralsHeader = "Peripherals" - static let settings = "Settings..." - static let quit = "Quit" - } - - enum KeyEquivalents { - static let settings = "," - static let quit = "q" - } - - enum Symbols { - static let update = "arrow.down.circle.fill" - static let mac = "desktopcomputer" - static let peripheral = "keyboard" - static let settings = "gearshape" - static let quit = "power" - } - } - - // MARK: - Dependencies - - private let networkStore = NetworkDeviceStore.shared - private let bluetoothStore = BluetoothPeripheralStore.shared - private let updateChecker = UpdateChecker.shared - - // MARK: - Public Methods - - func showMenu(statusItem: NSStatusItem) { - let menu = createMenu() - presentMenu(menu, for: statusItem) - } - - // MARK: - Private Methods - - private func createMenu() -> NSMenu { - let menu = NSMenu() - - // Surface an available update at the very top — the most visible spot. - // Clicking opens the release page in the browser; nothing destructive, so - // it stays enabled (no `validateMenuItem` arm needed). - if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { - let update = makeItem( - title: "\(Constants.Menu.updateAvailable): v\(latest)", - symbol: Constants.Symbols.update, - action: #selector(AppDelegate.openLatestReleasePage(_:))) - update.toolTip = "A newer version of Magic Switch is available. Opens the release page." - menu.addItem(update) - menu.addItem(.separator()) - } - - let macs = networkStore.networkDevices - if !macs.isEmpty { - menu.addItem(makeSectionHeader(Constants.Menu.macsHeader)) - for device in macs { - // Click runs the switch (handoff) with this Mac. Enabled state is - // driven by `validateMenuItem` on AppDelegate using `device.isActive`, - // so a peer that's gone offline greys out instead of failing silently. - let item = makeItem( - title: device.name, - symbol: Constants.Symbols.mac, - action: #selector(AppDelegate.handleMacMenuClick(_:))) - item.representedObject = device.id - item.toolTip = - device.isActive - ? "Switch peripherals between this Mac and \(device.name)." - : "\(device.name) isn't reachable on the network right now." - menu.addItem(item) - } - menu.addItem(.separator()) - } - - let peripherals = bluetoothStore.peripherals - if !peripherals.isEmpty { - menu.addItem(makeSectionHeader(Constants.Menu.peripheralsHeader)) - for peripheral in peripherals { - let state = bluetoothStore.connectionState(for: peripheral.id) - let title = state == .connecting ? "\(peripheral.name) (Pairing…)" : peripheral.name - // Disable while pairing; otherwise wire to the per-peripheral switch. - let item = makeItem( - title: title, - symbol: Constants.Symbols.peripheral, - action: state == .connecting - ? nil : #selector(AppDelegate.handlePeripheralMenuClick(_:))) - // Checkmark for "currently on this Mac" so users can see at a - // glance which side holds which peripheral. - item.state = state == .connected ? .on : .off - // Pass the MAC down to the action handler; clicking dispatches - // take-from-peer or send-to-peer based on current state. - item.representedObject = peripheral.id - // Hover text mirrors the Peripheral tab's Connect/Disconnect copy and - // explains the disabled "(Pairing…)" row. - switch state { - case .connected: - item.toolTip = "On this Mac — click to hand \(peripheral.name) to the other Mac." - case .disconnected: - item.toolTip = "Click to bring \(peripheral.name) to this Mac." - case .connecting: - item.toolTip = "Pairing \(peripheral.name)…" - } - menu.addItem(item) - } - menu.addItem(.separator()) - } - - menu.addItem( - makeItem( - title: Constants.Menu.settings, - symbol: Constants.Symbols.settings, - action: #selector(AppDelegate.openSettingsWindow(_:)), - keyEquivalent: Constants.KeyEquivalents.settings)) - - // Route through AppDelegate so the status-bar Quit really exits; - // `applicationShouldTerminate` cancels other terminate sources - // (Cmd+Q with Settings focused, Dock-icon → Quit) and just closes - // the Settings window instead. - menu.addItem( - makeItem( - title: Constants.Menu.quit, - symbol: Constants.Symbols.quit, - action: #selector(AppDelegate.quitFromStatusBar(_:)), - keyEquivalent: Constants.KeyEquivalents.quit)) - - return menu - } - - private func makeItem( - title: String, - symbol: String, - action: Selector?, - keyEquivalent: String = "" - ) -> NSMenuItem { - let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) - if let img = NSImage(systemSymbolName: symbol, accessibilityDescription: title) { - item.image = img - } - return item - } - - /// Bold, disabled, non-selectable section header. Uses the native - /// `NSMenuItem.sectionHeader(title:)` on macOS 14+ and a manual styled - /// item on earlier versions. - private func makeSectionHeader(_ title: String) -> NSMenuItem { - if #available(macOS 14.0, *) { - return NSMenuItem.sectionHeader(title: title) - } - let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") - item.attributedTitle = NSAttributedString( - string: title, - attributes: [ - .font: NSFont.boldSystemFont(ofSize: NSFont.smallSystemFontSize), - .foregroundColor: NSColor.secondaryLabelColor, - ] - ) - item.isEnabled = false - return item - } - - private func presentMenu(_ menu: NSMenu, for statusItem: NSStatusItem) { - statusItem.menu = menu - statusItem.button?.performClick(nil) - statusItem.menu = nil - } -}