diff --git a/Magic Switch/AppDelegate/AppDelegate.swift b/Magic Switch/AppDelegate/AppDelegate.swift index 55c4e74..1cec0ba 100644 --- a/Magic Switch/AppDelegate/AppDelegate.swift +++ b/Magic Switch/AppDelegate/AppDelegate.swift @@ -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` diff --git a/Magic Switch/Manager/ServiceBrowser.swift b/Magic Switch/Manager/ServiceBrowser.swift index 6ce729c..2b71adc 100644 --- a/Magic Switch/Manager/ServiceBrowser.swift +++ b/Magic Switch/Manager/ServiceBrowser.swift @@ -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)") } } diff --git a/Magic Switch/Model/Entity/NetworkDevice.swift b/Magic Switch/Model/Entity/NetworkDevice.swift index 38e42ef..1c0e4d1 100644 --- a/Magic Switch/Model/Entity/NetworkDevice.swift +++ b/Magic Switch/Model/Entity/NetworkDevice.swift @@ -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 diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 541cf32..52b51bd 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -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 { diff --git a/Magic Switch/Model/Store/NetworkDeviceStore.swift b/Magic Switch/Model/Store/NetworkDeviceStore.swift index 9407f21..8683810 100644 --- a/Magic Switch/Model/Store/NetworkDeviceStore.swift +++ b/Magic Switch/Model/Store/NetworkDeviceStore.swift @@ -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 } @@ -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 { + var result: Set = [] + var ifaddrPtr: UnsafeMutablePointer? + 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[.. 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 @@ -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) }