diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c2892d..9ae502f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 734d264..b4689c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | diff --git a/Magic Switch.xcodeproj/project.pbxproj b/Magic Switch.xcodeproj/project.pbxproj index 994084d..b573d18 100644 --- a/Magic Switch.xcodeproj/project.pbxproj +++ b/Magic Switch.xcodeproj/project.pbxproj @@ -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 */; }; @@ -46,6 +47,7 @@ 076AB1FD2CE9D690004D45F0 /* ServiceBrowser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceBrowser.swift; sourceTree = ""; }; 076AB1FE2CE9D690004D45F0 /* ServicePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePublisher.swift; sourceTree = ""; }; 0A00003000000000000000A7 /* SleepMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepMonitor.swift; sourceTree = ""; }; + 0A00004000000000000000A8 /* DisplayMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayMonitor.swift; sourceTree = ""; }; 0A00001000000000000000A1 /* SecureChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureChannel.swift; sourceTree = ""; }; 0A00001000000000000000A2 /* PairingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingStore.swift; sourceTree = ""; }; 0A00001000000000000000A3 /* IncomingConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingConnection.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */, diff --git a/Magic Switch/AppDelegate/AppDelegate.swift b/Magic Switch/AppDelegate/AppDelegate.swift index 352fd3a..b90f989 100644 --- a/Magic Switch/AppDelegate/AppDelegate.swift +++ b/Magic Switch/AppDelegate/AppDelegate.swift @@ -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 @@ -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() @@ -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", @@ -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 diff --git a/Magic Switch/Manager/DisplayMonitor.swift b/Magic Switch/Manager/DisplayMonitor.swift new file mode 100644 index 0000000..3ea19d9 --- /dev/null +++ b/Magic Switch/Manager/DisplayMonitor.swift @@ -0,0 +1,304 @@ +import AppKit +import CoreGraphics +import SwiftUI + +/// Watches which displays are physically connected and fires +/// `onTriggerDisplaysConnected` when one the user marked as a switch trigger +/// (Settings → Other) comes online — the "dock the MacBook at the desk, get +/// the desk's keyboard and trackpad" feature. +/// +/// Displays are identified by their CoreGraphics display UUID +/// (`CGDisplayCreateUUIDFromDisplayID`), which is stable per physical panel +/// across replugs and reboots. Vendor/model numbers would match every unit of +/// the same monitor (useless in an office full of identical displays) and +/// EDID serial numbers are unreliably populated — the UUID sidesteps both, +/// and works for any display, not just Apple's. +/// +/// Only a genuine absent → present *edge* fires the trigger: +/// - The set of displays online at launch is the baseline. A display that's +/// already attached when the app starts never fires — an app (re)start must +/// not yank peripherals from a Mac the user is actively working on. +/// - Reconfiguration callbacks are debounced and then *reconciled* against +/// `CGGetOnlineDisplayList`, so the remove/add churn of a resolution change +/// or a wake-time renegotiation nets out to no edge. +/// - Sleep: the present set is snapshotted at `willSleep`. For a settle +/// window after wake, a display in that snapshot re-enters silently — +/// staying docked across a sleep is not a dock, and must not steal the +/// peripherals from a peer the user switched to meanwhile. A display that +/// was *not* present at sleep still fires normally, which is exactly the +/// carry-the-MacBook-over, plug-in, lid-opens flow the feature exists for. +final class DisplayMonitor: ObservableObject { + // MARK: - Singleton + + static let shared = DisplayMonitor() + + // MARK: - Types & Constants + + /// An online external display: `id` is the stable CoreGraphics display + /// UUID string, `name` the user-facing name from `NSScreen`. + struct ExternalDisplay: Identifiable, Equatable { + let id: String + let name: String + } + + private enum Constants { + /// How long after the last reconfiguration callback to wait before + /// reconciling. Display changes arrive in bursts (begin/after passes, + /// remove+add churn); coalescing them means one reconcile sees the + /// settled end state, so transient churn produces no edge. + static let reconcileDebounce: TimeInterval = 2 + /// How long after wake a display that was present at sleep may re-enter + /// the present set silently. Wake-time renegotiation (Thunderbolt docks + /// especially) can re-enumerate a display that never physically left, + /// seconds after `didWake` — without this window that would read as a + /// fresh dock and grab the peripherals on every wake-while-docked. + /// Generous on purpose: it never delays a genuine trigger (a display + /// absent at sleep is exempt); it only shields re-appearances. + static let wakeSettleWindow: TimeInterval = 30 + /// Upper bound for `CGGetOnlineDisplayList`. + static let maxDisplays: UInt32 = 16 + } + + /// `@AppStorage` key for the trigger-display map (display UUID → + /// last-known name). Referenced by the Other settings tab too, so the key + /// string lives here as the single source of truth. + static let triggerDisplaysDefaultsKey = "displayTriggerDisplays" + + // MARK: - Properties + + /// External displays currently online, sorted by name — feeds the Settings + /// rows. Updated by `reconcile()`. + @Published private(set) var connectedDisplays: [ExternalDisplay] = [] + + /// Displays the user marked as switch triggers: UUID → last-known name. + /// The name is only a label (a remembered-but-disconnected row still needs + /// one); matching is by UUID. Persisted. + @Published private(set) var triggerDisplays: [String: String] = [:] { + didSet { saveTriggerDisplays() } + } + + @AppStorage(DisplayMonitor.triggerDisplaysDefaultsKey) + private var triggerDisplaysData: Data = Data() + + /// Called on the main run loop with the names of trigger displays that + /// just connected (usually one; a dock can bring up several at once). + var onTriggerDisplaysConnected: (([String]) -> Void)? + + /// Display UUIDs believed present. The trigger fires only for additions to + /// this set. Deliberately *not* rebuilt from scratch on wake — see the + /// sleep handling below. Main-only. + private var presentUUIDs: Set = [] + + /// `presentUUIDs` as of the last `willSleep` — taken before the sleep + /// transition can churn it. Members re-appearing during the wake settle + /// window are restorations, not docks. Main-only. + private var presentAtSleep: Set = [] + + /// End of the post-wake settle window. Main-only. + private var wakeSettleDeadline: Date = .distantPast + + /// Debounce timer for `reconcile()`; see `Constants.reconcileDebounce`. + private var reconcileTimer: DispatchSourceTimer? + + private var started = false + private var workspaceObservers: [NSObjectProtocol] = [] + + // MARK: - Lifecycle + + private init() { + loadTriggerDisplays() + } + + /// Begin monitoring. Snapshots the currently-online displays as the + /// no-edge baseline, then registers for reconfiguration callbacks and the + /// sleep/wake notifications the settle logic needs. + func start() { + guard !started else { return } + started = true + + let online = Self.onlineExternalDisplays() + presentUUIDs = Set(online.map { $0.id }) + connectedDisplays = online + refreshTriggerNames(from: online) + + let refCon = Unmanaged.passUnretained(self).toOpaque() + let error = CGDisplayRegisterReconfigurationCallback(Self.reconfigurationCallback, refCon) + if error != .success { + print("DisplayMonitor: CGDisplayRegisterReconfigurationCallback failed: \(error)") + } + + // NSWorkspace rather than IOKit on purpose: unlike `SleepMonitor` we + // don't need to hold the power transition, only to bracket it — the + // snapshot is instantaneous and the settle window absorbs ordering slack. + let center = NSWorkspace.shared.notificationCenter + workspaceObservers.append( + center.addObserver( + forName: NSWorkspace.willSleepNotification, object: nil, queue: .main + ) { [weak self] _ in self?.noteWillSleep() }) + workspaceObservers.append( + center.addObserver( + forName: NSWorkspace.didWakeNotification, object: nil, queue: .main + ) { [weak self] _ in self?.noteDidWake() }) + } + + deinit { + // The shared instance lives for the process; this is defensive only. + if started { + CGDisplayRemoveReconfigurationCallback( + Self.reconfigurationCallback, Unmanaged.passUnretained(self).toOpaque()) + } + let center = NSWorkspace.shared.notificationCenter + workspaceObservers.forEach { center.removeObserver($0) } + } + + // MARK: - Trigger Configuration + + /// Mark (or, with `false`, unmark) the display with `id` as a switch + /// trigger. `name` is remembered so the Settings row keeps its label while + /// the display is disconnected. Persisted immediately via the + /// `triggerDisplays` didSet. Main-thread (Settings UI) only. + func setTriggerEnabled(_ enabled: Bool, id: String, name: String) { + if enabled { + triggerDisplays[id] = name + } else { + triggerDisplays.removeValue(forKey: id) + } + } + + // MARK: - Reconfiguration Handling + + /// The C callback can't capture context, so `self` is recovered from the + /// `userInfo` we registered with. It fires at least twice per change (a + /// begin pass and an after pass, per display); every non-begin pass just + /// (re)schedules the debounced reconcile — presence diffing does the rest, + /// so the per-display add/remove flags don't need interpreting here. + private static let reconfigurationCallback: CGDisplayReconfigurationCallBack = { + _, flags, userInfo in + guard !flags.contains(.beginConfigurationFlag), let userInfo = userInfo else { return } + let monitor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + DispatchQueue.main.async { monitor.scheduleReconcile() } + } + + private func scheduleReconcile() { + reconcileTimer?.cancel() + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + Constants.reconcileDebounce) + timer.setEventHandler { [weak self] in self?.reconcile() } + timer.resume() + reconcileTimer = timer + } + + /// Diff the online displays against `presentUUIDs` (runs on main). + /// Additions are dock edges unless the wake settle window says they're + /// re-appearances; removals just leave the set — undocking takes no action + /// (closing the lid usually follows, and the sleep handoff owns that). + private func reconcile() { + reconcileTimer?.cancel() + reconcileTimer = nil + + let online = Self.onlineExternalDisplays() + let onlineIDs = Set(online.map { $0.id }) + let appeared = onlineIDs.subtracting(presentUUIDs) + presentUUIDs = onlineIDs + if connectedDisplays != online { connectedDisplays = online } + refreshTriggerNames(from: online) + + guard !appeared.isEmpty else { return } + let settling = Date() < wakeSettleDeadline + let triggered = online.filter { display in + appeared.contains(display.id) + && triggerDisplays[display.id] != nil + && !(settling && presentAtSleep.contains(display.id)) + } + guard !triggered.isEmpty else { return } + print("DisplayMonitor: trigger display(s) connected: \(triggered.map { $0.name })") + onTriggerDisplaysConnected?(triggered.map { $0.name }) + } + + // MARK: - Sleep Handling + + /// Snapshot what's present *before* the sleep transition churns it, and + /// drop any pending reconcile — acting between here and the actual sleep + /// would race the radio/network teardown. Whatever really changed is + /// re-observed by the wake reconcile; that includes a display plugged in + /// moments before the lid closed, which (correctly) fires at wake because + /// this pre-churn snapshot doesn't contain it. + private func noteWillSleep() { + reconcileTimer?.cancel() + reconcileTimer = nil + presentAtSleep = presentUUIDs + } + + /// Open the settle window and force a reconcile: if the display picture + /// changed while we slept there may be no further callback to prompt one, + /// and a reconcile cancelled by `noteWillSleep` must not stay lost. + private func noteDidWake() { + wakeSettleDeadline = Date().addingTimeInterval(Constants.wakeSettleWindow) + scheduleReconcile() + } + + // MARK: - Display Enumeration + + /// Online external displays, by stable UUID, sorted by name. "Online" + /// (`CGGetOnlineDisplayList`) deliberately includes mirrored and sleeping + /// displays — presence is about the cable, not what's drawn. Built-in + /// panels are excluded: they can't be docked, so they can't be triggers. + private static func onlineExternalDisplays() -> [ExternalDisplay] { + var ids = [CGDirectDisplayID](repeating: 0, count: Int(Constants.maxDisplays)) + var count: UInt32 = 0 + guard CGGetOnlineDisplayList(Constants.maxDisplays, &ids, &count) == .success else { + print("DisplayMonitor: CGGetOnlineDisplayList failed") + return [] + } + let displays: [ExternalDisplay] = ids.prefix(Int(count)).compactMap { id in + guard CGDisplayIsBuiltin(id) == 0 else { return nil } + guard let uuid = CGDisplayCreateUUIDFromDisplayID(id)?.takeRetainedValue() else { + // Virtual/headless entries can lack a UUID; they can't be triggers. + return nil + } + return ExternalDisplay( + id: CFUUIDCreateString(nil, uuid) as String, + name: displayName(for: id)) + } + return displays.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + /// User-facing name for a display, from the matching `NSScreen`. A display + /// that's online but not active has no screen; fall back to a generic label. + private static func displayName(for id: CGDirectDisplayID) -> String { + let match = NSScreen.screens.first { screen in + let key = NSDeviceDescriptionKey("NSScreenNumber") + return (screen.deviceDescription[key] as? NSNumber)?.uint32Value == id + } + return match?.localizedName ?? "External Display" + } + + // MARK: - Persistence + + /// Keep remembered names in sync with what the OS reports, so a renamed or + /// re-localized display doesn't leave a stale label in Settings. + private func refreshTriggerNames(from online: [ExternalDisplay]) { + var updated = triggerDisplays + for display in online where updated[display.id] != nil && updated[display.id] != display.name { + updated[display.id] = display.name + } + if updated != triggerDisplays { triggerDisplays = updated } + } + + private func saveTriggerDisplays() { + do { + triggerDisplaysData = try JSONEncoder().encode(triggerDisplays) + } catch { + print("Failed to save trigger displays: \(error)") + } + } + + private func loadTriggerDisplays() { + guard !triggerDisplaysData.isEmpty else { return } + do { + triggerDisplays = try JSONDecoder().decode([String: String].self, from: triggerDisplaysData) + } catch { + print("Failed to load trigger displays: \(error)") + } + } +} diff --git a/Magic Switch/View/Settings/OtherSettingsView.swift b/Magic Switch/View/Settings/OtherSettingsView.swift index 0895f35..8ee00e2 100644 --- a/Magic Switch/View/Settings/OtherSettingsView.swift +++ b/Magic Switch/View/Settings/OtherSettingsView.swift @@ -7,6 +7,7 @@ struct OtherSettingsView: View { @Environment(\.openURL) private var openURL @ObservedObject private var updateChecker = UpdateChecker.shared + @ObservedObject private var displayMonitor = DisplayMonitor.shared @State private var launchAtLogin: Bool = false @AppStorage(BluetoothPeripheralStore.releaseOnSleepDefaultsKey) private var releaseOnSleep: Bool = true @@ -37,6 +38,22 @@ struct OtherSettingsView: View { "If a Magic peripheral that should be on this Mac drops — for example after closing the lid, or when you power-cycle a peripheral that got stuck — keep trying to reconnect it until it's back. When your other Mac goes to sleep or drops off the network, this Mac also adopts the peripherals it left behind. Magic Switch won't take a peripheral your other Mac is actively using." ) } + Section( + header: Text("Take peripherals when a display connects") + .help( + "Displays connected to this Mac appear here. A display you mark acts as a docking trigger: whenever it connects to this Mac, Magic Switch switches your peripherals to this Mac automatically." + ) + ) { + if displayRows.isEmpty { + Text("No external displays connected") + .foregroundColor(.secondary) + .help("Connect a display to this Mac and it will appear here.") + } else { + ForEach(displayRows) { row in + displayToggle(for: row) + } + } + } Section { SettingsRowView( title: "License Information", @@ -88,9 +105,66 @@ struct OtherSettingsView: View { formContent .formStyle(.grouped) } else { - formContent - .padding() + // Plain (non-grouped) Forms don't scroll on their own, and the tab now + // holds more rows than the fixed Settings window shows at once. + ScrollView { + formContent + .padding() + } + } + } + + // MARK: - Display Trigger Rows + + /// A row in the display-trigger list: a connected external display, or one + /// remembered as a trigger while disconnected. + private struct DisplayRow: Identifiable { + let id: String + let name: String + let isConnected: Bool + } + + /// Connected external displays first (already name-sorted by the monitor), + /// then remembered trigger displays that aren't currently connected — + /// still labeled, still toggleable off. + private var displayRows: [DisplayRow] { + let connected = displayMonitor.connectedDisplays.map { + DisplayRow(id: $0.id, name: $0.name, isConnected: true) + } + let connectedIDs = Set(connected.map { $0.id }) + let remembered = displayMonitor.triggerDisplays + .filter { !connectedIDs.contains($0.key) } + .map { DisplayRow(id: $0.key, name: $0.value, isConnected: false) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + return connected + remembered + } + + @ViewBuilder + private func displayToggle(for row: DisplayRow) -> some View { + Toggle(isOn: triggerBinding(for: row)) { + if row.isConnected { + Text(row.name) + } else { + VStack(alignment: .leading, spacing: 2) { + Text(row.name) + Text("Not connected") + .font(.caption) + .foregroundColor(.secondary) + } + } } + .help( + row.isConnected + ? "When \(row.name) connects to this Mac — for example when you dock — automatically switch your Magic peripherals to this Mac, taking them from your other Mac if needed. Display ID: \(row.id)." + : "\(row.name) isn't connected right now. It still triggers the switch when it next connects; turn off to forget it. Display ID: \(row.id)." + ) + } + + private func triggerBinding(for row: DisplayRow) -> Binding { + Binding( + get: { displayMonitor.triggerDisplays[row.id] != nil }, + set: { displayMonitor.setTriggerEnabled($0, id: row.id, name: row.name) } + ) } /// Trailing status beside the Check-for-Updates button. The "Update diff --git a/README.md b/README.md index 1eff444..8f8c155 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ On the **Device** tab, find the other Mac under **Connected Devices** and click ### Other tab — preferences -**Launch at Login**, two peripheral-handling toggles (**Release peripherals when this Mac sleeps** and **Reconnect peripherals if they drop** — see [Troubleshooting](#troubleshooting)), the installed version, and update notifications (see [Updates](#updates)). +**Launch at Login**, two peripheral-handling toggles (**Release peripherals when this Mac sleeps** and **Reconnect peripherals if they drop** — see [Troubleshooting](#troubleshooting)), a **Take peripherals when a display connects** list (mark a display to make docking this Mac to it switch your peripherals over automatically — see [Troubleshooting](#troubleshooting)), the installed version, and update notifications (see [Updates](#updates)).

Other tab showing app preferences
@@ -111,6 +111,7 @@ Magic Switch tells you when there's a new version — it never updates itself. A - On the **Device** tab, **Ping** tests whether the two Macs can reach each other over the secure channel. - **Closing or sleeping one Mac hands its peripherals to the other.** When this Mac sleeps (or you close its lid), it hands the peripherals it holds to your other Mac — or, if that Mac isn't reachable yet, frees them so it can pick them up the moment it wakes. That's why you can close one Mac and find the keyboard and mouse already on the other. This is on by default; you can turn it off under **Settings → Other → "Release peripherals when this Mac sleeps."** - **A peripheral didn't come back after sleep or a lid-close.** Apple's Magic devices sometimes get stuck once the Bluetooth radio sleeps and won't reconnect — even a manual reconnect fails until you switch the peripheral **off and on** with its power switch. Magic Switch keeps watching for anything that was on this Mac before it slept: the moment the device reappears (which a power-cycle triggers), it reconnects automatically — as long as your other Mac isn't actively using it. This is on by default; you can turn it off under **Settings → Other → "Reconnect peripherals if they drop."** +- **Docking to a marked display switches peripherals automatically.** Under **Settings → Other → "Take peripherals when a display connects,"** every external display connected to this Mac gets a toggle. Mark your desk's display and, whenever it connects to this Mac — plugging in the cable, or opening the lid of a MacBook that was docked while it slept — Magic Switch switches your peripherals to this Mac, taking them from your other Mac if needed. Displays are recognized individually, so two identical monitors aren't confused; waking while already docked doesn't count as docking; and a display that's already connected when Magic Switch starts doesn't trigger a switch. This is off until you mark a display. ## Developer notes