From dac8f36ea9576cd916d383b919858c11f670789a Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Mon, 8 Jun 2026 01:42:09 +0200 Subject: [PATCH] feat: per-peripheral type icons with detection and manual picker Replace the single shared keyboard glyph with a type-specific icon for each peripheral, in both the menu-bar dropdown and the Peripheral settings tab. The shown type is the user's override if set, else auto-detected from the Bluetooth Class of Device (reliable for audio gear) plus the device name. - PeripheralType enum: keyboard/mouse/trackpad/headphones/airpods/ microphone/unknown, with availability-resolving SF Symbols and detect(name:classOfDevice:). - Store: persisted local typeOverrides map + deviceClasses cache; peripheralType(for:) and setTypeOverride(_:for:). - Settings rows render the icon as a Menu picker (Automatic, the six types, and Other) in both Registered and Available sections. - Live names: reconcile registered names from the live paired list so a rename in System Settings -> Bluetooth propagates; poll while the Peripheral tab is visible, and fetch when the dropdown opens. - Dropdown rows act on mouse-up (released inside the row) rather than mouse-down. --- Magic Switch/AppDelegate/AppDelegate.swift | 3 + .../Model/Entity/BluetoothPeripheral.swift | 106 +++++++++++++++++- .../Store/BluetoothPeripheralStore.swift | 92 ++++++++++++++- .../View/MenuBar/DropdownContentView.swift | 29 +++-- .../BluetoothPeripheralSettingsView.swift | 55 +++++++++ 5 files changed, 275 insertions(+), 10 deletions(-) diff --git a/Magic Switch/AppDelegate/AppDelegate.swift b/Magic Switch/AppDelegate/AppDelegate.swift index 5dce1e0..4af6c9c 100644 --- a/Magic Switch/AppDelegate/AppDelegate.swift +++ b/Magic Switch/AppDelegate/AppDelegate.swift @@ -239,6 +239,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// opens. Probe results land asynchronously and re-render the open menu. func menuWillOpen(_ menu: NSMenu) { networkStore.refreshReachability() + // Re-read paired devices so a peripheral renamed in System Settings (and + // its icon) refreshes in the dropdown without needing to open Settings. + BluetoothPeripheralStore.shared.fetchConnectedPeripherals() dropdownContentView?.updateFrameToFit() } diff --git a/Magic Switch/Model/Entity/BluetoothPeripheral.swift b/Magic Switch/Model/Entity/BluetoothPeripheral.swift index 0081e05..3163e40 100644 --- a/Magic Switch/Model/Entity/BluetoothPeripheral.swift +++ b/Magic Switch/Model/Entity/BluetoothPeripheral.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import IOBluetooth @@ -10,7 +11,7 @@ enum PeripheralConnectionState: Equatable { } /// Represents a Bluetooth peripheral device with its connection state and identity information -struct BluetoothPeripheral: Identifiable, Codable { +struct BluetoothPeripheral: Identifiable, Codable, Equatable { // MARK: - Properties /// Unique identifier (MAC address) of the Bluetooth device @@ -58,3 +59,106 @@ struct BluetoothPeripheral: Identifiable, Codable { try container.encode(name, forKey: .name) } } + +/// Logical category of a peripheral. Drives the row glyph in the menu-bar +/// dropdown and the Peripheral settings tab, and the choices in the manual type +/// picker. The resolved type is `user override ?? auto-detected`; +/// `BluetoothPeripheralStore` owns both halves (`typeOverrides`, `deviceClasses`). +enum PeripheralType: String, Codable, CaseIterable { + case keyboard + case mouse + case trackpad + case headphones + case airpods + case microphone + case unknown + + /// Types offered in the manual picker, in order. Includes `.unknown` + /// ("Other") so a device can be forced to the generic glyph; the picker adds + /// "Automatic" (which clears the override) separately. + static let selectable: [PeripheralType] = [ + .keyboard, .mouse, .trackpad, .headphones, .airpods, .microphone, .unknown, + ] + + /// Title shown in the manual picker. + var label: String { + switch self { + case .keyboard: return "Keyboard" + case .mouse: return "Mouse" + case .trackpad: return "Trackpad" + case .headphones: return "Headphones" + case .airpods: return "AirPods" + case .microphone: return "Microphone" + case .unknown: return "Other" + } + } + + /// SF Symbol candidates, most-specific/newest first. The running macOS may + /// not have the newest glyph (`NSImage(systemSymbolName:)` returns nil for an + /// absent symbol), so `symbolName` walks these to the first that resolves. + var symbolCandidates: [String] { + switch self { + case .keyboard: return ["keyboard"] + case .mouse: return ["magicmouse", "computermouse", "cursorarrow"] + case .trackpad: + return ["rectangle.and.hand.point.up.left", "hand.point.up.left", "cursorarrow"] + case .headphones: return ["headphones"] + case .airpods: return ["airpods", "headphones"] + case .microphone: return ["mic"] + case .unknown: return ["questionmark.circle"] + } + } + + /// First candidate glyph available on this macOS, else a guaranteed fallback. + var symbolName: String { + symbolCandidates.first { NSImage(systemSymbolName: $0, accessibilityDescription: nil) != nil } + ?? "questionmark.circle" + } + + /// Classify from the device name and, when known, its Bluetooth Class of + /// Device. CoD wins for audio gear (names like "WH-1000XM4" don't say + /// "headphones"); the name settles what CoD can't — AirPods vs any other + /// headset, and mouse vs trackpad (both report as a generic pointing device). + static func detect(name: String, classOfDevice: UInt32?) -> PeripheralType { + let lower = name.lowercased() + + // CoD can't distinguish AirPods from any other Bluetooth headset, so the + // name is the only signal — check it before anything else. + if lower.contains("airpod") { return .airpods } + + if let cod = classOfDevice, cod != 0 { + // "Class of Device": major class in bits 8-12, minor class in bits 2-7. + let major = (cod >> 8) & 0x1F + let minor = (cod >> 2) & 0x3F + switch major { + case 0x05: // Peripheral + // Minor bits 4-5: keyboard (0x10) / pointing (0x20) / combo (0x30). + switch minor & 0x30 { + case 0x10: return .keyboard + case 0x20: return lower.contains("trackpad") ? .trackpad : .mouse + default: break + } + case 0x04: // Audio / Video + switch minor { + case 0x04: return .microphone // microphone + case 0x01, 0x02, 0x06, 0x07, 0x0A: return .headphones // headset / headphones + default: break + } + default: + break + } + } + + // No (or inconclusive) Class of Device — fall back to the name. + if lower.contains("keyboard") { return .keyboard } + if lower.contains("trackpad") { return .trackpad } + if lower.contains("mouse") { return .mouse } + if lower.contains("microphone") { return .microphone } + if lower.contains("headphone") || lower.contains("headset") + || lower.contains("buds") || lower.contains("beats") + { + return .headphones + } + return .unknown + } +} diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index c42bfb6..cb3eb5f 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -91,8 +91,14 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// lives here as the single source of truth. static let autoReconnectDefaultsKey = "autoReconnectDroppedPeripherals" + /// `@AppStorage` key for the per-peripheral icon/type overrides map. + static let typeOverridesDefaultsKey = "peripheralTypeOverrides" + @AppStorage("peripherals") private var peripheralsData: Data = Data() + @AppStorage(BluetoothPeripheralStore.typeOverridesDefaultsKey) + private var typeOverridesData: Data = Data() + /// When set (default), `prepareForSleep` releases held peripherals on /// system sleep. Off lets a user keep a peripheral bonded to a Mac that /// sleeps (the watcher still reclaims it on wake if it doesn't reconnect). @@ -115,6 +121,17 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip @Published private(set) var discoveredPeripherals: [BluetoothPeripheral] = [] + /// User-chosen type per peripheral address, overriding auto-detection. Local + /// to this Mac (not synced — the peer auto-detects its own icons). Persisted. + @Published private(set) var typeOverrides: [String: PeripheralType] = [:] { + didSet { saveTypeOverrides() } + } + + /// Bluetooth Class of Device per address, captured from the live paired + /// snapshot. Feeds auto-detection (especially audio gear, whose names rarely + /// say "headphones"). Not persisted — refreshed each `fetchConnectedPeripherals`. + @Published private(set) var deviceClasses: [String: UInt32] = [:] + /// Runtime connection state per peripheral id. Driven by pair completion and /// IOBluetooth disconnect notifications. @Published private(set) var connectionStates: [String: PeripheralConnectionState] = [:] @@ -201,11 +218,33 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip connectionStates[peripheralID] ?? .disconnected } + /// 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 { + if let override = typeOverrides[peripheral.id] { return override } + return PeripheralType.detect(name: peripheral.name, classOfDevice: deviceClasses[peripheral.id]) + } + + /// Set (or, with `nil`, clear → back to automatic) the icon/type override for + /// a peripheral address. Persisted immediately via the `typeOverrides` didSet. + func setTypeOverride(_ type: PeripheralType?, for id: String) { + let apply: () -> Void = { [weak self] in + guard let self = self else { return } + if let type { + self.typeOverrides[id] = type + } else { + self.typeOverrides.removeValue(forKey: id) + } + } + if Thread.isMainThread { apply() } else { DispatchQueue.main.async(execute: apply) } + } + // MARK: - Initialization private override init() { super.init() loadPeripherals() + loadTypeOverrides() fetchConnectedPeripherals() registerForSystemBluetoothConnects() setupSleepRelease() @@ -803,12 +842,14 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip var paired: [BluetoothPeripheral] = [] var connectedAddresses: Set = [] + var classes: [String: UInt32] = [:] for device in pairedDevices { guard let address = device.addressString else { continue } if device.isConnected() { connectedAddresses.insert(address) } + classes[address] = UInt32(device.classOfDevice) paired.append( BluetoothPeripheral(id: address, name: device.name ?? "Unknown Device") ) @@ -819,12 +860,21 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // registered ones at read time. Filtering here instead would mean // unregistering a peripheral can't immediately surface it under // "Available" until the next fetch (e.g. tab switch). - self.discoveredPeripherals = paired + // Assign only on change. The Peripheral tab polls this on a timer, so + // an unconditional reassign would fire `objectWillChange` every tick + // (needless re-renders, and it could dismiss an open type picker). + if self.discoveredPeripherals != paired { self.discoveredPeripherals = paired } + if self.deviceClasses != classes { self.deviceClasses = classes } + // Renaming a device in System Settings → Bluetooth should propagate + // to our stored list (and thus the dropdown / Settings), so reconcile + // registered names against the live ones we just read. + self.refreshRegisteredNames(from: paired) for id in registeredIDs { let isConnected = connectedAddresses.contains(id) // Don't overwrite an in-flight .connecting state with a stale read. if self.connectionStates[id] == .connecting { continue } - self.connectionStates[id] = isConnected ? .connected : .disconnected + let newState: PeripheralConnectionState = isConnected ? .connected : .disconnected + if self.connectionStates[id] != newState { self.connectionStates[id] = newState } if isConnected { if self.disconnectObservers[id] == nil, let device = IOBluetoothDevice(addressString: id) @@ -1262,6 +1312,26 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // MARK: - Private Methods + /// Reconcile registered peripheral names against the live paired-device list, + /// so a rename in System Settings → Bluetooth shows up in our stored list. + /// Only rewrites a name that actually changed to a non-empty live value, and + /// leaves alone peripherals not currently paired here (e.g. handed to the + /// peer). Runs on main; assigning `peripherals` saves and re-renders. + private func refreshRegisteredNames(from liveDevices: [BluetoothPeripheral]) { + let liveNames = Dictionary(liveDevices.map { ($0.id, $0.name) }) { first, _ in first } + var changed = false + let refreshed = peripherals.map { peripheral -> BluetoothPeripheral in + guard let live = liveNames[peripheral.id], + !live.isEmpty, live != "Unknown Device", live != peripheral.name + else { return peripheral } + changed = true + var updated = peripheral + updated.name = live + return updated + } + if changed { peripherals = refreshed } + } + private func savePeripherals() { do { let encoded = try JSONEncoder().encode(peripherals) @@ -1271,6 +1341,24 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } } + private func saveTypeOverrides() { + do { + typeOverridesData = try JSONEncoder().encode(typeOverrides) + } catch { + print("Failed to save type overrides: \(error)") + } + } + + private func loadTypeOverrides() { + guard !typeOverridesData.isEmpty else { return } + do { + typeOverrides = try JSONDecoder().decode( + [String: PeripheralType].self, from: typeOverridesData) + } catch { + print("Failed to load type overrides: \(error)") + } + } + private func loadPeripherals() { do { peripherals = try JSONDecoder().decode([BluetoothPeripheral].self, from: peripheralsData) diff --git a/Magic Switch/View/MenuBar/DropdownContentView.swift b/Magic Switch/View/MenuBar/DropdownContentView.swift index d143e36..d9d60d8 100644 --- a/Magic Switch/View/MenuBar/DropdownContentView.swift +++ b/Magic Switch/View/MenuBar/DropdownContentView.swift @@ -28,10 +28,20 @@ final class MenuRowControl: NSControl { 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]) + // Act on mouse-*up*, not mouse-down: track the press and fire only if the + // pointer is still inside the row when the button is released — a press that + // drags off cancels, like a standard button. Pulling the events off the + // window queue also swallows the mouse-up so the tracked NSMenu never treats + // the tap as a selection and stays open. + setHighlighted(true) + var inside = true + while let next = window.nextEvent(matching: [.leftMouseUp, .leftMouseDragged]) { + inside = bounds.contains(convert(next.locationInWindow, from: nil)) + if next.type == .leftMouseUp { break } + setHighlighted(inside) + } + setHighlighted(false) + if inside { onClick() } } // MARK: - Hover highlight @@ -52,11 +62,15 @@ final class MenuRowControl: NSControl { override func mouseEntered(with event: NSEvent) { guard isEnabled else { return } - layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.1).cgColor + setHighlighted(true) } override func mouseExited(with event: NSEvent) { - layer?.backgroundColor = nil + setHighlighted(false) + } + + private func setHighlighted(_ on: Bool) { + layer?.backgroundColor = on ? NSColor.labelColor.withAlphaComponent(0.1).cgColor : nil } } @@ -284,7 +298,8 @@ final class DropdownContentView: NSView { top.distribution = .fill top.spacing = 8 top.alignment = .centerY - top.addArrangedSubview(symbolView("keyboard", color: textColor)) + top.addArrangedSubview( + symbolView(bluetoothStore.peripheralType(for: peripheral).symbolName, color: textColor)) top.addArrangedSubview(textLabel(peripheral.name, color: textColor)) top.addArrangedSubview(spacer()) switch state { diff --git a/Magic Switch/View/Settings/BluetoothPeripheralSettingsView.swift b/Magic Switch/View/Settings/BluetoothPeripheralSettingsView.swift index 07baf29..b057064 100644 --- a/Magic Switch/View/Settings/BluetoothPeripheralSettingsView.swift +++ b/Magic Switch/View/Settings/BluetoothPeripheralSettingsView.swift @@ -10,6 +10,9 @@ struct BluetoothPeripheralSettingsView: View { @State private var peripheralToRemove: BluetoothPeripheral? + /// Polls live Bluetooth state while the tab is visible (see `handleOnAppear`). + @State private var refreshTimer: Timer? + // MARK: - View Content private var content: some View { @@ -26,6 +29,7 @@ struct BluetoothPeripheralSettingsView: View { ) } .onAppear(perform: handleOnAppear) + .onDisappear(perform: stopRefreshTimer) .alert(item: $peripheralToRemove) { peripheral in Alert( title: Text("Remove \(peripheral.name)?"), @@ -72,6 +76,24 @@ struct BluetoothPeripheralSettingsView: View { private func handleOnAppear() { bluetoothStore.fetchConnectedPeripherals() + startRefreshTimer() + } + + /// Re-snapshot Bluetooth every couple of seconds while this tab is on screen, + /// so a device renamed in System Settings → Bluetooth updates here live (and + /// connection states stay fresh) without having to switch tabs. The snapshot + /// no-ops `@Published` state when nothing changed, and `.default`-mode timers + /// don't fire while a menu is tracking, so an open type picker is undisturbed. + private func startRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + BluetoothPeripheralStore.shared.fetchConnectedPeripherals() + } + } + + private func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil } } @@ -168,9 +190,14 @@ private struct PeripheralRowView: View { store.connectionState(for: peripheral.id) } + private var resolvedType: PeripheralType { + store.peripheralType(for: peripheral) + } + var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { + typeMenu Text(peripheral.name) Spacer() if showConnectionStatus { @@ -202,6 +229,34 @@ private struct PeripheralRowView: View { } } + /// Leading icon that doubles as a type picker. Shows the resolved glyph; + /// tapping it overrides the auto-detected type (or resets to Automatic). The + /// override is stored per address, so setting it on an Available peripheral + /// carries over once it's added. + private var typeMenu: some View { + Menu { + Button { + store.setTypeOverride(nil, for: peripheral.id) + } label: { + Label("Automatic", systemImage: "wand.and.stars") + } + Divider() + ForEach(PeripheralType.selectable, id: \.self) { type in + Button { + store.setTypeOverride(type, for: peripheral.id) + } label: { + Label(type.label, systemImage: type.symbolName) + } + } + } label: { + Image(systemName: resolvedType.symbolName) + .frame(width: 22) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Set the icon for \(peripheral.name). Choose Automatic to detect it from the device.") + } + @ViewBuilder private var connectionButton: some View { switch connectionState {