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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
xcode-version: "16.1"
xcode-version: "26.5"

- name: Build
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ jobs:
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
xcode-version: "16.1"
xcode-version: "26.5"

- name: Build
run: |
Expand Down
4 changes: 4 additions & 0 deletions Magic Switch.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
076AB21E2CE9D690004D45F0 /* ServiceBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076AB1FD2CE9D690004D45F0 /* ServiceBrowser.swift */; };
076AB21F2CE9D690004D45F0 /* ServicePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076AB1FE2CE9D690004D45F0 /* ServicePublisher.swift */; };
0A00003000000000000000B7 /* SleepMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00003000000000000000A7 /* SleepMonitor.swift */; };
0A00004000000000000000B8 /* DisplayMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00004000000000000000A8 /* DisplayMonitor.swift */; };
0A00001000000000000000B1 /* SecureChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00001000000000000000A1 /* SecureChannel.swift */; };
0A00001000000000000000B2 /* PairingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00001000000000000000A2 /* PairingStore.swift */; };
0A00001000000000000000B3 /* IncomingConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A00001000000000000000A3 /* IncomingConnection.swift */; };
Expand Down Expand Up @@ -46,6 +47,7 @@
076AB1FD2CE9D690004D45F0 /* ServiceBrowser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceBrowser.swift; sourceTree = "<group>"; };
076AB1FE2CE9D690004D45F0 /* ServicePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePublisher.swift; sourceTree = "<group>"; };
0A00003000000000000000A7 /* SleepMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepMonitor.swift; sourceTree = "<group>"; };
0A00004000000000000000A8 /* DisplayMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayMonitor.swift; sourceTree = "<group>"; };
0A00001000000000000000A1 /* SecureChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureChannel.swift; sourceTree = "<group>"; };
0A00001000000000000000A2 /* PairingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingStore.swift; sourceTree = "<group>"; };
0A00001000000000000000A3 /* IncomingConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingConnection.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -106,6 +108,7 @@
076AB1FD2CE9D690004D45F0 /* ServiceBrowser.swift */,
076AB1FE2CE9D690004D45F0 /* ServicePublisher.swift */,
0A00003000000000000000A7 /* SleepMonitor.swift */,
0A00004000000000000000A8 /* DisplayMonitor.swift */,
0A00001000000000000000A1 /* SecureChannel.swift */,
0A00001000000000000000A2 /* PairingStore.swift */,
0A00001000000000000000A3 /* IncomingConnection.swift */,
Expand Down Expand Up @@ -296,6 +299,7 @@
076AB21E2CE9D690004D45F0 /* ServiceBrowser.swift in Sources */,
076AB21F2CE9D690004D45F0 /* ServicePublisher.swift in Sources */,
0A00003000000000000000B7 /* SleepMonitor.swift in Sources */,
0A00004000000000000000B8 /* DisplayMonitor.swift in Sources */,
0A00001000000000000000B1 /* SecureChannel.swift in Sources */,
0A00001000000000000000B2 /* PairingStore.swift in Sources */,
0A00001000000000000000B3 /* IncomingConnection.swift in Sources */,
Expand Down
155 changes: 110 additions & 45 deletions Magic Switch/AppDelegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {

private let networkStore = NetworkDeviceStore.shared
private let bluetoothStore = BluetoothPeripheralStore.shared
/// Fires the automatic full-set take when a display the user marked as a
/// switch trigger (Settings → Other) connects to this Mac.
private let displayMonitor = DisplayMonitor.shared

// MARK: - UI Components

Expand Down Expand Up @@ -67,6 +70,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
setupActivationPolicyTracking()
setupPingFlashObserver()
setupTransferObservers()
setupDisplayTrigger()
// Best-effort, silent, throttled to once per 24h. Drives the "Update
// Available" affordances in the right-click menu and Settings → Other.
UpdateChecker.shared.checkIfNeeded()
Expand Down Expand Up @@ -439,51 +443,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
}
case .allDisconnected:
// Show "receiving" immediately. No preflight needed here:
// `executeCommand(.unregisterAll)` *is* the preflight — if it fails,
// nothing has changed locally yet.
beginTransfer(.receiving)
networkStore.executeCommand(.unregisterAll, on: device) { [weak self] result in
// `executeCommand`'s completion fires on the outgoing-connection queue;
// hop to main before touching the status-bar icon or the stores.
DispatchQueue.main.async {
guard let self = self else { return }
self.endTransfer()
switch result {
case .success, .failure(.connectionFailed), .failure(.connectTimeout):
// Either the peer released everything (success), or we couldn't
// reach it at all — in which case its machine is unreachable
// (asleep, off the network, app not running) and it isn't holding
// the peripherals anymore, since a Mac that drops off the network
// has already released its Bluetooth devices. Both ways the
// peripherals are free: grab them locally instead of stranding the
// user with an error they can't act on, and arm the auto-reconnect
// watcher as the retry safety net for any device stuck in the
// bonded-but-not-connected state that needs a power-cycle. Mirrors
// `takePeripheralFromPeer`'s success + unreachable arms, at full-set
// scope.
self.bluetoothStore.peripherals.forEach { peripheral in
self.bluetoothStore.connectPeripheralFromPeer(peripheral)
self.bluetoothStore.armReconnectForTakeover(peripheral.id)
}
case .failure(let err):
// Reachable peer but the release-all errored, so we can't be sure
// it let go. Don't grab outright (that could yank a peripheral from
// a peer that didn't release); arm the HOLDS_ONE-gated watcher,
// which reclaims each one only once the peer confirms it isn't
// holding it — and recovers the case where the peer released but
// the ack was lost.
self.bluetoothStore.peripherals.forEach { peripheral in
self.bluetoothStore.armReconnectForTakeover(peripheral.id)
}
NotificationManager.showNotification(
title: "Switch Failed",
body: err.userMessage,
identifier: "switch-disconnect-remote-failed"
)
}
}
}
takeAllPeripherals(from: device)
case .partial:
NotificationManager.showNotification(
title: "Peripherals in mixed state",
Expand All @@ -494,6 +454,111 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
}

/// Take the full registered set onto this Mac: ask `device` to release
/// everything, then connect each peripheral locally. Shows "receiving"
/// immediately; no preflight is needed because `executeCommand(.unregisterAll)`
/// *is* the preflight — if it fails, nothing has changed locally yet.
/// Shared by the menu's full-set switch and the display trigger. Safe when
/// some peripherals are already on this Mac: the peer only releases what it
/// holds, and `connectPeripheralFromPeer` adopts an already-live connection.
private func takeAllPeripherals(from device: NetworkDevice) {
beginTransfer(.receiving)
networkStore.executeCommand(.unregisterAll, on: device) { [weak self] result in
// `executeCommand`'s completion fires on the outgoing-connection queue;
// hop to main before touching the status-bar icon or the stores.
DispatchQueue.main.async {
guard let self = self else { return }
self.endTransfer()
switch result {
case .success, .failure(.connectionFailed), .failure(.connectTimeout):
// Either the peer released everything (success), or we couldn't
// reach it at all — in which case its machine is unreachable
// (asleep, off the network, app not running) and it isn't holding
// the peripherals anymore, since a Mac that drops off the network
// has already released its Bluetooth devices. Both ways the
// peripherals are free: grab them locally instead of stranding the
// user with an error they can't act on, and arm the auto-reconnect
// watcher as the retry safety net for any device stuck in the
// bonded-but-not-connected state that needs a power-cycle. Mirrors
// `takePeripheralFromPeer`'s success + unreachable arms, at full-set
// scope.
self.bluetoothStore.peripherals.forEach { peripheral in
self.bluetoothStore.connectPeripheralFromPeer(peripheral)
self.bluetoothStore.armReconnectForTakeover(peripheral.id)
}
case .failure(let err):
// Reachable peer but the release-all errored, so we can't be sure
// it let go. Don't grab outright (that could yank a peripheral from
// a peer that didn't release); arm the HOLDS_ONE-gated watcher,
// which reclaims each one only once the peer confirms it isn't
// holding it — and recovers the case where the peer released but
// the ack was lost.
self.bluetoothStore.peripherals.forEach { peripheral in
self.bluetoothStore.armReconnectForTakeover(peripheral.id)
}
NotificationManager.showNotification(
title: "Switch Failed",
body: err.userMessage,
identifier: "switch-disconnect-remote-failed"
)
}
}
}
}

/// Wire the display trigger: when a display the user marked in Settings →
/// Other connects, switch the peripherals to this Mac as if its Mac row
/// had been clicked in the menu.
private func setupDisplayTrigger() {
displayMonitor.onTriggerDisplaysConnected = { [weak self] names in
self?.handleTriggerDisplaysConnected(names)
}
displayMonitor.start()
}

/// A trigger display just connected (runs on main). Take the full set with
/// the same guards as the menu switch, plus two of its own: Bluetooth must
/// be healthy — an automatic trigger must never ask the peer to release
/// peripherals this Mac then can't take — and "everything already here" is
/// a silent no-op, so re-docking the Mac that owns the peripherals doesn't
/// spam. Unlike the menu there's no clicked row to target: use the trusted
/// peer, or fall back to a plain local grab when none is registered — the
/// display is a claim on the peripherals either way.
private func handleTriggerDisplaysConnected(_ names: [String]) {
guard !bluetoothStore.peripherals.isEmpty,
!bluetoothStore.isAnyPeripheralTransitioning,
// The same states the status-bar icon treats as healthy (`.unknown`
// just means CoreBluetooth hasn't reported yet, e.g. right at launch).
BluetoothManager.shared.state == .poweredOn || BluetoothManager.shared.state == .unknown
else { return }
bluetoothStore.checkActualConnectionStatusAsync { [weak self] status in
guard let self = self, status != .allConnected else { return }
NotificationManager.showNotification(
title: "Display Connected",
body:
"\(names.joined(separator: ", ")) connected — switching your peripherals to this Mac.",
identifier: "display-trigger-switch"
)
// Same trusted-peer rule as the sleep handoff: registered and not
// parked behind an identity mismatch. Reachability isn't pre-checked:
// `takeAllPeripherals` already treats an unreachable peer as "the
// peripherals are free — grab them locally".
if PairingStore.shared.isPaired,
let peer = self.networkStore.networkDevices.first(where: { $0.pendingFingerprint == nil })
{
self.takeAllPeripherals(from: peer)
} else {
// No trusted peer to ask. Connect whatever answers and arm the
// watcher for the rest, so a stuck device is caught the moment the
// user power-cycles it.
self.bluetoothStore.peripherals.forEach { peripheral in
self.bluetoothStore.connectPeripheral(peripheral)
self.bluetoothStore.armReconnectForTakeover(peripheral.id)
}
}
}
}

/// Disconnect peripherals locally then hand them to the peer. Called only
/// after a successful preflight, but the peer can still die between the
/// preflight and `CONNECT_ALL` — if it does, re-connect peripherals
Expand Down
Loading
Loading