feat: switch peripherals to this Mac when a chosen display connects#61
Merged
Conversation
Adds the display-based switching requested in #59: mark an external display under Settings → Other, and whenever it connects to this Mac — docking the cable, or opening the lid of a MacBook that was docked while asleep — Magic Switch takes the full peripheral set, exactly as if the Mac row had been clicked in the menu. - New DisplayMonitor (Manager/): tracks online displays by their stable CoreGraphics display UUID (per-unit identity — vendor/product IDs match every unit of a model and EDID serials are unreliably populated, the two concerns raised in #59), debounces reconfiguration callbacks, and reconciles against CGGetOnlineDisplayList so the remove/add churn of a resolution change or wake-time renegotiation nets out to no edge. - Only a genuine absent → present edge fires: the launch snapshot is a no-trigger baseline (an app restart must not yank peripherals from the Mac being used), and displays present at willSleep re-enter silently during a post-wake settle window (staying docked across a sleep is not a dock) while a display absent at sleep still fires — the plug-in-while-asleep → wake flow the feature exists for. - The trigger reuses the menu's full-set take, extracted into AppDelegate.takeAllPeripherals(from:): UNREGISTER_ALL to the trusted peer, connect each peripheral locally, arm the HOLDS_ONE-gated watcher; an unreachable peer means the peripherals are free and they are grabbed locally. Automatic-trigger guards: silent no-op when everything is already on this Mac, when a switch or pairing is in flight, or when Bluetooth is off; with no trusted peer it falls back to a plain local grab. - Settings → Other lists connected external displays (plus remembered ones currently disconnected, still labeled and toggleable off) with a per-display trigger toggle; persisted as a UUID → last-known-name map. The pre-macOS 13 Form is wrapped in a ScrollView so the taller tab isn't clipped by the fixed Settings window. Closes #59
The feature-level explanation lived only on the empty-state row's tooltip, so once a display was actually listed the concept was no longer explained anywhere — each row's tooltip is phrased per-display. Hovering the section header now carries the general explanation, and the empty-state tooltip shrinks to explaining the emptiness itself.
Two identical monitors list under identical names; the stable display UUID at the end of each row's tooltip is the only way to tell the rows apart, and gives users a concrete value to quote when reporting a trigger that doesn't fire.
macos-latest no longer ships Xcode 16.x (the image now carries only 26.0.1–26.6), so the Set up Xcode step fails before anything builds — on every PR, and it would have failed the next release too. 26.5 is the toolchain the branch was verified against locally: clean build, no warnings, macOS 11.5 deployment target intact.
|
🎉 This PR is included in version 2.19.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #59.
Mark an external display under Settings → Other → "Take peripherals when a display connects", and whenever that display connects to this Mac — plugging in the cable, or opening the lid of a MacBook that was docked while it slept — Magic Switch switches the full peripheral set to this Mac, exactly as if the Mac row had been clicked in the menu.
How displays are identified
By the CoreGraphics display UUID (
CGDisplayCreateUUIDFromDisplayID), which is stable per physical panel across replugs and reboots. This resolves the identification concerns in #59 in one move: vendor/product IDs match every unit of a model (useless in an office full of identical Studio Displays), and EDID serials are unreliably populated — the UUID is per-unit, needs no System Profiler digging, and works for any display, not just Apple's. The user never sees the UUID; Settings shows the display'sNSScreen.localizedNamewith a toggle.The trigger is an edge, not a state
DisplayMonitor(new, a sibling ofSleepMonitor) only fires on a genuine absent → present transition:CGGetOnlineDisplayList, so the remove/add churn of a resolution change or a wake-time renegotiation nets out to no edge. Flag parsing isn't needed — presence diffing does the work.willSleep(before the transition can churn it). For a 30s settle window after wake, a display in that snapshot re-enters silently: staying docked across a sleep is not a dock, and wake-time re-enumeration (Thunderbolt docks especially) must not steal peripherals from a peer you switched to meanwhile. A display not present at sleep still fires normally — carry the MacBook to the desk, plug it in while asleep, open the lid, and the peripherals come over. A display plugged in moments before the lid closed also correctly fires at wake (the pre-churn snapshot doesn't contain it). Pending reconciles are dropped atwillSleep(acting there would race the radio teardown) and forced atdidWake(a picture that changed during sleep may produce no further callback).NSWorkspacesleep notifications rather than a secondIORegisterForSystemPowerport: unlikeSleepMonitorthis doesn't need to hold the power transition, only bracket it.What the trigger does
The menu's full-set take was extracted verbatim into
AppDelegate.takeAllPeripherals(from:)and is now shared by both entry points:UNREGISTER_ALLto the peer,connectPeripheralFromPeereach, arm theHOLDS_ONE-gated watcher; an unreachable peer means the peripherals are free, so they're grabbed locally (same arms as #57/#58).Automatic-trigger-specific guards, since there's no user click behind it:
isAnyPeripheralTransitioningguard as the menu; whatever is in flight is already moving things).connectPeripheralFromPeeradopts an already-live local connection, so taking "the rest" is safe.UI
New section on the Other tab listing connected external displays, each with a toggle. A marked display that's currently disconnected stays listed (dimmed "Not connected" caption) so it can be toggled off without reconnecting it; turning it off forgets it. Persisted as a UUID → last-known-name map, with names refreshed from the OS when the display is seen. The pre-macOS 13 Form is wrapped in a ScrollView so the taller tab isn't clipped by the fixed 600×400 Settings window.
Testing
swift formatis a no-op on the diff.