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
4 changes: 1 addition & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@ playground.xcworkspace
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# Build outputs
build/

# .swiftpm

.build/

Expand Down
77 changes: 49 additions & 28 deletions Magic Switch/AppDelegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
.sink { [weak self] _ in
self?.refreshStatusBarIcon()
}
peripheralsObserver = bluetoothStore.$peripherals
peripheralsObserver = bluetoothStore.$peripherals.map { _ in () }
.merge(with: bluetoothStore.$connectionStates.map { _ in () })
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshStatusBarIcon()
Expand Down Expand Up @@ -357,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 }

Expand All @@ -372,9 +406,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
accessibilityDescription: "Sending peripherals to the other Mac")
img?.isTemplate = true
button.image = img
button.appearsDisabled = false
button.alphaValue = 1.0
button.contentTintColor = nil
button.toolTip = "Sending peripherals to the other Mac…"
button.setAccessibilityLabel(button.toolTip ?? "")
return
Expand All @@ -384,9 +415,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
accessibilityDescription: "Receiving peripherals from the other Mac")
img?.isTemplate = true
button.image = img
button.appearsDisabled = false
button.alphaValue = 1.0
button.contentTintColor = nil
button.toolTip = "Receiving peripherals from the other Mac…"
button.setAccessibilityLabel(button.toolTip ?? "")
return
Expand All @@ -405,17 +433,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
accessibilityDescription: "Magic Switch needs attention")
image?.isTemplate = true
button.image = image
button.appearsDisabled = false
button.alphaValue = 1.0
button.contentTintColor = nil
button.toolTip = statusBarTooltip()
} else if let normal = NSImage(named: "StatusBarIcon") {
normal.size = NSSize(width: 24, height: 24)
normal.isTemplate = true
button.image = normal
button.appearsDisabled = false
button.alphaValue = 1.0
button.contentTintColor = bluetoothStore.isAnyPeripheralConnected ? nil : .tertiaryLabelColor
} else {
let connected = idlePeripheralPresence() == .connectedHere
if let image = connected ? Self.statusBarConnectedIcon : Self.statusBarIdleIcon {
button.image = image
}
button.toolTip = statusBarTooltip()
}
button.setAccessibilityLabel(statusBarTooltip())
Expand Down Expand Up @@ -502,13 +525,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
self?.beginTransferHold(.sending)
}
// Settle signal for held transfers: any per-peripheral state flip
// re-evaluates whether the last transitioning row just resolved and
// updates the menu bar icon.
// re-evaluates whether the last transitioning row just resolved.
transferSettleObserver = bluetoothStore.$connectionStates
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.maybeEndHeldTransfer()
self?.refreshStatusBarIcon()
}
}

Expand All @@ -526,10 +547,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
case .resetting:
return "Magic Switch: Bluetooth is resetting."
case .poweredOn, .unknown:
if bluetoothStore.isAnyPeripheralConnected {
return "Magic Switch (Connected)"
} else {
return "Magic Switch (Disconnected)"
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"
Expand Down Expand Up @@ -951,9 +975,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate,
)
flash?.isTemplate = true
button.image = flash
button.appearsDisabled = false
button.alphaValue = 1.0
button.contentTintColor = nil
pingFlashTimer?.cancel()
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
timer.schedule(deadline: .now() + 3.0)
Expand Down
16 changes: 12 additions & 4 deletions Magic Switch/Model/Store/BluetoothPeripheralStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -319,12 +319,20 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip
}
}

/// True if at least one registered peripheral is currently connected to this Mac.
/// Reads `connectionStates`; main-thread only.
var isAnyPeripheralConnected: Bool {
peripherals.contains { peripheral in
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,6 @@ private struct PeripheralRowView: View {

// MARK: - Preview

#if canImport(PreviewsMacros)
#Preview {
BluetoothPeripheralSettingsView()
}
#endif
2 changes: 0 additions & 2 deletions Magic Switch/View/Settings/NetworkDeviceManagementView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -636,8 +636,6 @@ private struct NetworkDeviceListView: View {

// MARK: - Preview

#if canImport(PreviewsMacros)
#Preview {
NetworkDeviceManagementView()
}
#endif
2 changes: 0 additions & 2 deletions Magic Switch/View/Settings/OtherSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -385,8 +385,6 @@ private struct SettingsRowView: View {

// MARK: - Preview

#if canImport(PreviewsMacros)
#Preview {
OtherSettingsView()
}
#endif
2 changes: 0 additions & 2 deletions Magic Switch/View/Settings/PairingSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,6 @@ private struct EnterCodeSheet: View {

// MARK: - Preview

#if canImport(PreviewsMacros)
#Preview {
PairingSettingsView()
}
#endif
2 changes: 0 additions & 2 deletions Magic Switch/View/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,6 @@ struct SettingsView: View {

// MARK: - Preview

#if canImport(PreviewsMacros)
#Preview {
SettingsView()
}
#endif
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; when idle, the icon is **highlighted** when peripherals are connected to this Mac and **grayed out** when disconnected (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)

Expand Down