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
5 changes: 5 additions & 0 deletions Magic Switch/AppDelegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
/// the menu. `checkHealth` confirms the peer's TCP port is open before we
/// touch any local Bluetooth state.
private func performSwitch(with device: NetworkDevice) {
// Don't start a full-set switch while a per-peripheral pair/handoff is in
// flight: it would issue a re-entrant connect/unregister on a peripheral
// that's already transitioning. The dropdown disables the Mac row for this
// too; this guard closes the brief click-race before the row re-renders.
guard !bluetoothStore.isAnyPeripheralTransitioning else { return }
device.checkHealth { [weak self] result in
// `checkHealth` fires on its own queue. Hop to main before any UI or
// store mutations, and before calling `checkActualConnectionStatusAsync`
Expand Down
42 changes: 24 additions & 18 deletions Magic Switch/Manager/ServiceBrowser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,30 @@ extension ServiceBrowser: NetServiceDelegate {
return parsed["fp"].flatMap { String(data: $0, encoding: .utf8) }
}

for addressData in addresses {
if let host = getHost(from: addressData), sender.port != 0 {
let device = NetworkDevice(
id: sender.name,
name: sender.name,
host: host,
port: sender.port,
isActive: true,
fingerprint: fingerprint
)

DispatchQueue.main.async {
NetworkDeviceStore.shared.addDiscoveredNetworkDevice(device)
NetworkDeviceStore.shared.updateNetworkDevice(device)
print("Device information updated: \(sender.name)")
}
break
}
guard sender.port != 0 else { return }

// Prefer an IPv4 address. `sender.addresses` ordering isn't guaranteed, and
// a link-local IPv6 address (fe80::…%enX) frequently won't round-trip
// through `NWEndpoint.Host`, so taking "whichever resolved first" can hand
// us an unusable host. On the same-LAN setup this app targets IPv4 always
// works; fall back to the first resolvable address for IPv6-only networks.
let resolvedHosts = addresses.compactMap { getHost(from: $0) }
guard let host = resolvedHosts.first(where: { !$0.contains(":") }) ?? resolvedHosts.first
else { return }

let device = NetworkDevice(
id: sender.name,
name: sender.name,
host: host,
port: sender.port,
isActive: true,
fingerprint: fingerprint
)

DispatchQueue.main.async {
NetworkDeviceStore.shared.addDiscoveredNetworkDevice(device)
NetworkDeviceStore.shared.updateNetworkDevice(device)
print("Device information updated: \(sender.name)")
}
}

Expand Down
50 changes: 39 additions & 11 deletions Magic Switch/Model/Entity/NetworkDevice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,49 @@ struct NetworkDevice: Identifiable, Codable {
// MARK: - Public Methods

/// Updates the device information with data from another device, applying
/// the TOFU fingerprint pin: a mismatch between our stored fingerprint and
/// the peer's advertised fingerprint causes us to drop the new routing
/// info, mark the device inactive, and stash the incoming fingerprint as
/// `pendingFingerprint` so the user can explicitly trust it later.
/// the TOFU fingerprint pin. Once a fingerprint is pinned, the routing info
/// (`host`/`port`/`isActive`) is only updated by an advertisement carrying
/// that same fingerprint. A *different* fingerprint is dropped, the device
/// marked inactive, and the incoming value stashed as `pendingFingerprint`
/// for an explicit user "Trust". A *missing* fingerprint is ignored entirely
/// — it can't prove the pinned identity, so an impersonator advertising the
/// peer's Bonjour name with no `fp` can't re-point us at their machine.
mutating func update(with device: NetworkDevice) {
if let stored = fingerprint,
let incoming = device.fingerprint,
stored != incoming
{
isActive = false
// Once a fingerprint is pinned (TOFU), only an advertisement that proves
// that exact identity may move the routing info.
if let stored = fingerprint {
guard let incoming = device.fingerprint else {
// No fingerprint at all can't prove the pinned identity, so refuse to
// touch any state from it. A legitimately paired peer always
// advertises its `fp`, so a missing one is either a peer that unpaired
// (it would reject commands anyway, and the reachability ping to the
// still-pinned host will mark it unreachable) or an attacker
// advertising the peer's Bonjour name to silently re-point host/port
// at a machine they control. Leaving the verified routing and the
// active flag untouched denies the attacker any influence.
return
}
if stored != incoming {
// Different fingerprint: a possible re-pair. Park the new value for an
// explicit user "Trust" and stop treating the device as switchable
// until then.
isActive = false
lastUpdated = Date()
pendingFingerprint = incoming
return
}
// Fingerprint matches the pin — a trusted update.
pendingFingerprint = nil
host = device.host
port = device.port
lastUpdated = Date()
pendingFingerprint = incoming
isActive = device.isActive
return
}
if fingerprint == nil, let incoming = device.fingerprint {

// No pin yet: first contact. Capture any advertised fingerprint as the pin
// and accept the routing info (classic trust-on-first-use).
if let incoming = device.fingerprint {
fingerprint = incoming
}
pendingFingerprint = nil
Expand Down
11 changes: 11 additions & 0 deletions Magic Switch/Model/Store/BluetoothPeripheralStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,17 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip
connectionStates[peripheralID] ?? .disconnected
}

/// True while any registered peripheral is mid-transition (`.connecting` or
/// `.releasing`). The full-set switch is blocked while this holds so it can't
/// issue a re-entrant connect/release on a peripheral that's already pairing
/// or being handed off. Reads `connectionStates`; main-thread only.
var isAnyPeripheralTransitioning: Bool {
peripherals.contains { peripheral in
let state = connectionState(for: peripheral.id)
return state == .connecting || state == .releasing
}
}

/// 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 {
Expand Down
46 changes: 43 additions & 3 deletions Magic Switch/Model/Store/NetworkDeviceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,15 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable {
// MARK: - Computed Properties

var availableNetworkDevices: [NetworkDevice] {
discoveredNetworkDevices.filter { discovered in
// Exclude own device from the list
let isNotSelf = discovered.name != Host.current().localizedName
// "Self" is recognised by address, not by name. When two Macs share a
// device name, mDNS renames one of the advertised services, so the old
// name-based check (`discovered.name != Host.current().localizedName`)
// made a Mac hide its real peer (same name) while listing itself
// (renamed). The address we resolve for our own advertised service is
// always one of this machine's interface addresses; a peer's never is.
let localHosts = Self.localAddresses()
return discoveredNetworkDevices.filter { discovered in
let isNotSelf = !localHosts.contains(Self.normalizeHost(discovered.host))
let isNotRegistered = !networkDevices.contains(where: { $0.id == discovered.id })
return isNotSelf && isNotRegistered
}
Expand Down Expand Up @@ -285,6 +291,40 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable {

// MARK: - Private Methods

/// This Mac's active IPv4/IPv6 interface addresses, used by
/// `availableNetworkDevices` to recognise its own advertised service in
/// discovery results (robust to an mDNS rename of a duplicate device name).
/// Recomputed each call — `getifaddrs` is cheap and interface addresses
/// change (Wi-Fi reconnect, VPN, sleep/wake).
private static func localAddresses() -> Set<String> {
var result: Set<String> = []
var ifaddrPtr: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&ifaddrPtr) == 0 else { return result }
defer { freeifaddrs(ifaddrPtr) }
var cursor = ifaddrPtr
while let current = cursor {
defer { cursor = current.pointee.ifa_next }
guard let sa = current.pointee.ifa_addr else { continue }
let family = sa.pointee.sa_family
guard family == sa_family_t(AF_INET) || family == sa_family_t(AF_INET6) else { continue }
var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let status = getnameinfo(
sa, socklen_t(sa.pointee.sa_len),
&hostBuffer, socklen_t(hostBuffer.count),
nil, 0, NI_NUMERICHOST)
guard status == 0 else { continue }
result.insert(Self.normalizeHost(String(cString: hostBuffer)))
}
return result
}

/// Drop an IPv6 zone id (`fe80::1%en0` → `fe80::1`) so addresses compare
/// equal regardless of how the scope is formatted on each side.
private static func normalizeHost(_ host: String) -> String {
guard let pct = host.firstIndex(of: "%") else { return host }
return String(host[..<pct])
}

private func saveNetworkDevices() {
do {
let encoded = try JSONEncoder().encode(networkDevices)
Expand Down
14 changes: 11 additions & 3 deletions Magic Switch/View/MenuBar/DropdownContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,13 @@ final class DropdownContentView: NSView {
}

private func makeMacRow(_ device: NetworkDevice) -> NSView {
let switchable = networkStore.isSwitchable(device)
let reachable = networkStore.isSwitchable(device)
// Don't allow a full-set switch while any peripheral is mid pair/handoff —
// it would issue a re-entrant connect/release on a peripheral that's already
// transitioning. (The per-peripheral rows already disable themselves during
// their own transition; this is the matching guard for the all-at-once row.)
let busy = bluetoothStore.isAnyPeripheralTransitioning
let switchable = reachable && !busy
let row = MenuRowControl { [weak self] in self?.onSwitchMac(device) }
row.isEnabled = switchable

Expand All @@ -267,8 +273,10 @@ final class DropdownContentView: NSView {
content.addArrangedSubview(spacer())

row.toolTip =
switchable
? "Switch peripherals between this Mac and \(device.name)."
reachable
? (busy
? "Finish the peripheral that's currently switching before switching them all."
: "Switch peripherals between this Mac and \(device.name).")
: "\(device.name) isn't reachable on the network right now."
return clickableRow(row, content: content)
}
Expand Down