diff --git a/Magic Switch/AppDelegate/AppDelegate.swift b/Magic Switch/AppDelegate/AppDelegate.swift index bce55b5..5875271 100644 --- a/Magic Switch/AppDelegate/AppDelegate.swift +++ b/Magic Switch/AppDelegate/AppDelegate.swift @@ -24,6 +24,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, private var dropdownContentView: DropdownContentView? private var bluetoothStateObserver: AnyCancellable? private var pairingObserver: AnyCancellable? + private var peripheralsObserver: AnyCancellable? private var windowCloseObserver: NSObjectProtocol? private var lastBluetoothState: CBManagerState = .unknown private var sleepObserver: NSObjectProtocol? @@ -221,6 +222,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, .sink { [weak self] _ in self?.refreshStatusBarIcon() } + peripheralsObserver = bluetoothStore.$peripherals.map { _ in () } + .merge(with: bluetoothStore.$connectionStates.map { _ in () }) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshStatusBarIcon() + } BluetoothManager.shared.setup() } @@ -351,11 +358,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, dropdownContentView?.updateFrameToFit() } + /// The idle glyph, template-tinted like every other state the icon shows. + private static let statusBarIdleIcon: NSImage? = { + let icon = NSImage(named: "StatusBarIcon") + icon?.size = NSSize(width: 24, height: 24) + icon?.isTemplate = true + return icon + }() + + /// The idle glyph knocked out of a filled rounded square — the Control + /// Center "engaged" look — shown while peripherals are connected to this + /// Mac. Composited from the same asset so the two states can't drift. + private static let statusBarConnectedIcon: NSImage? = { + guard let glyph = NSImage(named: "StatusBarIcon") else { return nil } + let icon = NSImage(size: NSSize(width: 24, height: 24), flipped: false) { rect in + NSBezierPath(roundedRect: rect.insetBy(dx: 2, dy: 4), xRadius: 5, yRadius: 5).fill() + glyph.draw( + in: rect.insetBy(dx: 3, dy: 3), from: .zero, operation: .destinationOut, fraction: 1) + return true + } + icon.isTemplate = true + return icon + }() + + /// Peripheral presence for the idle icon/tooltip. Only trusts the store + /// once Bluetooth is up: before the first `.poweredOn`, `connectionStates` + /// is still empty and would misreport connected peripherals as away. + private func idlePeripheralPresence() -> BluetoothPeripheralStore.PeripheralPresence { + guard BluetoothManager.shared.state == .poweredOn else { return .none } + return bluetoothStore.peripheralPresence + } + /// Updates the menu-bar icon based on transfer state (highest priority), /// then Pairing + Bluetooth state. Transfer state shows arrow icons so /// the user can tell at a glance that peripherals are moving, and in /// which direction. When the app cannot function (unpaired, Bluetooth - /// off, etc.) we show a triangle exclamation mark instead. + /// off, etc.) we show a triangle exclamation mark instead. When idle, + /// the icon gains a filled background while peripherals are connected + /// to this Mac. private func refreshStatusBarIcon() { guard let button = statusItem?.button else { return } @@ -394,11 +434,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, image?.isTemplate = true button.image = image button.toolTip = statusBarTooltip() - } else if let normal = NSImage(named: "StatusBarIcon") { - normal.size = NSSize(width: 24, height: 24) - normal.isTemplate = true - button.image = normal - button.toolTip = "Magic Switch" + } else { + let connected = idlePeripheralPresence() == .connectedHere + if let image = connected ? Self.statusBarConnectedIcon : Self.statusBarIdleIcon { + button.image = image + } + button.toolTip = statusBarTooltip() } button.setAccessibilityLabel(statusBarTooltip()) } @@ -506,7 +547,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, case .resetting: return "Magic Switch: Bluetooth is resetting." case .poweredOn, .unknown: - return "Magic Switch" + switch idlePeripheralPresence() { + case .connectedHere: + return "Magic Switch — peripherals connected to this Mac" + case .away: + return "Magic Switch — no peripherals connected to this Mac" + case .none: + return "Magic Switch" + } @unknown default: return "Magic Switch" } diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index fd715e5..d02de28 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -319,6 +319,22 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } } + enum PeripheralPresence { + case none + case connectedHere + case away + } + + /// Whether any registered peripheral is connected to this Mac, none are, + /// or none are registered at all. Reads `connectionStates`; main-thread only. + var peripheralPresence: PeripheralPresence { + guard !peripherals.isEmpty else { return .none } + let anyConnected = peripherals.contains { peripheral in + connectionState(for: peripheral.id) == .connected + } + return anyConnected ? .connectedHere : .away + } + /// Resolved display type for `peripheral`: the user's manual override if set, /// otherwise auto-detected from the name and (when known) its Class of Device. func peripheralType(for peripheral: BluetoothPeripheral) -> PeripheralType { diff --git a/README.md b/README.md index a6e9598..5aa004f 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ On the **Macs** tab, find the other Mac under **Your Other Mac** and click its * | Menu → a peripheral | Switch just that one peripheral. Checkmark = currently on this Mac, with its battery level | | Menu → Settings | Open the Settings window | -The menu-bar icon also signals state: a **warning triangle** means Magic Switch needs attention (not paired, or Bluetooth off/denied) — hover for the reason; **up/down arrows** flash briefly while peripherals are moving between Macs (the dropdown is pictured at the top of this README). +The menu-bar icon also signals state: a **warning triangle** means Magic Switch needs attention (not paired, or Bluetooth off/denied) — hover for the reason; **up/down arrows** flash briefly while peripherals are moving between Macs; when idle, the icon gains a **filled background** while peripherals are connected to this Mac (the dropdown is pictured at the top of this README). ### Trigger switches from anything (URL scheme)