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
3 changes: 3 additions & 0 deletions Magic Switch/AppDelegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
106 changes: 105 additions & 1 deletion Magic Switch/Model/Entity/BluetoothPeripheral.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Foundation
import IOBluetooth

Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
92 changes: 90 additions & 2 deletions Magic Switch/Model/Store/BluetoothPeripheralStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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] = [:]
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -803,12 +842,14 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip

var paired: [BluetoothPeripheral] = []
var connectedAddresses: Set<String> = []
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")
)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
29 changes: 22 additions & 7 deletions Magic Switch/View/MenuBar/DropdownContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading