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
74 changes: 44 additions & 30 deletions Magic Switch/AppDelegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
.sink { [weak self] _ in
self?.refreshStatusBarIcon()
}
peripheralsObserver = bluetoothStore.$peripherals.map { _ in () }
.merge(with: bluetoothStore.$connectionStates.map { _ in () })
peripheralsObserver = bluetoothStore.$peripherals
.combineLatest(bluetoothStore.$connectionStates) {
BluetoothPeripheralStore.presence(of: $0, connectionStates: $1)
}
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshStatusBarIcon()
Expand Down Expand Up @@ -256,6 +259,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
case .poweredOn:
// The radio is back; retire a delivered "Bluetooth Off" alert.
NotificationManager.removeNotification(identifier: Self.bluetoothOffNotificationID)
// The store's init-time snapshot bails while the radio reads off (e.g.
// a login-item launch), and already-connected peripherals fire no new
// connect event — re-resolve connection states now that it's up.
bluetoothStore.fetchConnectedPeripherals()
case .resetting, .unknown:
break
@unknown default:
Expand Down Expand Up @@ -358,19 +365,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
dropdownContentView?.updateFrameToFit()
}

/// `NSImage(named:)` returns the shared cache instance — never mutate it,
/// copy first.
private static let statusBarGlyph = NSImage(named: "StatusBarIcon")

/// 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
guard let icon = statusBarGlyph?.copy() as? NSImage else { return nil }
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 }
guard let glyph = statusBarGlyph?.copy() as? NSImage 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(
Expand All @@ -381,14 +392,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
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
Expand All @@ -399,6 +402,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
private func refreshStatusBarIcon() {
guard let button = statusItem?.button else { return }

let needsAttention =
!PairingStore.shared.isPaired
|| (BluetoothManager.shared.state != .poweredOn
&& BluetoothManager.shared.state != .unknown)

// A transfer arrow or attention icon forfeits an in-flight ping flash —
// that state change is more important to surface. Routine connection
// churn must not: the idle branch below leaves the bell alone.
if transferState != .idle || needsAttention {
pingFlashTimer?.cancel()
pingFlashTimer = nil
}

switch transferState {
case .sending:
let img = NSImage(
Expand All @@ -422,26 +438,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
break
}

let needsAttention =
!PairingStore.shared.isPaired
|| (BluetoothManager.shared.state != .poweredOn
&& BluetoothManager.shared.state != .unknown)

let tooltip = statusBarTooltip()
if needsAttention {
let image = NSImage(
systemSymbolName: "exclamationmark.triangle.fill",
accessibilityDescription: "Magic Switch needs attention")
image?.isTemplate = true
button.image = image
button.toolTip = statusBarTooltip()
} else {
let connected = idlePeripheralPresence() == .connectedHere
} else if pingFlashTimer == nil {
let connected = bluetoothStore.peripheralPresence == .connectedHere
if let image = connected ? Self.statusBarConnectedIcon : Self.statusBarIdleIcon {
button.image = image
}
button.toolTip = statusBarTooltip()
}
button.setAccessibilityLabel(statusBarTooltip())
button.toolTip = tooltip
button.setAccessibilityLabel(tooltip)
}

/// Set the transfer-direction icon for the duration of a transfer.
Expand Down Expand Up @@ -547,7 +558,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
case .resetting:
return "Magic Switch: Bluetooth is resetting."
case .poweredOn, .unknown:
switch idlePeripheralPresence() {
switch bluetoothStore.peripheralPresence {
case .connectedHere:
return "Magic Switch — peripherals connected to this Mac"
case .away:
Expand Down Expand Up @@ -960,10 +971,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
}

/// Briefly swap the status-bar icon to a "bell" symbol, then restore
/// the real state via `refreshStatusBarIcon()`. If a subsequent
/// state-change (pairing flip, Bluetooth state) triggers a refresh
/// during the flash window, the flash gets cut shortthat's fine,
/// the state change is more important to surface.
/// the real state via `refreshStatusBarIcon()`. A transfer or
/// needs-attention refresh during the flash window cuts it short —
/// that's fine, the state change is more important to surfacebut
/// idle refreshes (connection-state churn) leave it up for the full 3s.
private func flashStatusBarIcon() {
guard let button = statusItem?.button else { return }
// A transfer arrow outranks the bell: mid-transfer the user is watching
Expand All @@ -978,7 +989,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
pingFlashTimer?.cancel()
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
timer.schedule(deadline: .now() + 3.0)
timer.setEventHandler { [weak self] in self?.refreshStatusBarIcon() }
timer.setEventHandler { [weak self] in
self?.pingFlashTimer = nil
self?.refreshStatusBarIcon()
}
timer.resume()
pingFlashTimer = timer
}
Expand Down
28 changes: 23 additions & 5 deletions Magic Switch/Model/Store/BluetoothPeripheralStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -325,14 +325,32 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip
case away
}

/// Whether any registered peripheral is connected to this Mac, none are,
/// or none are registered at all. Reads `connectionStates`; main-thread only.
/// Whether any registered peripheral is on this Mac, none are, or the
/// answer isn't known. `.releasing` counts as here: the device stays
/// physically connected through the handoff preflight, and the transfer
/// arrow takes over the icon once the release actually starts. Reads
/// `connectionStates`; main-thread only.
var peripheralPresence: PeripheralPresence {
Self.presence(of: peripherals, connectionStates: connectionStates)
}

/// A registered peripheral with no `connectionStates` entry hasn't been
/// resolved by a snapshot yet (every snapshot writes one per registered
/// id), so its absence means "unknown", not "away".
static func presence(
of peripherals: [BluetoothPeripheral],
connectionStates: [String: PeripheralConnectionState]
) -> PeripheralPresence {
guard !peripherals.isEmpty else { return .none }
let anyConnected = peripherals.contains { peripheral in
connectionState(for: peripheral.id) == .connected
var unresolved = false
for peripheral in peripherals {
switch connectionStates[peripheral.id] {
case .connected, .releasing: return .connectedHere
case .disconnected, .connecting: break
case nil: unresolved = true
}
}
return anyConnected ? .connectedHere : .away
return unresolved ? .none : .away
}

/// Resolved display type for `peripheral`: the user's manual override if set,
Expand Down
Loading