Skip to content

feat: switch peripherals to this Mac when a chosen display connects#61

Merged
MegaManSec merged 4 commits into
mainfrom
feat/display-trigger-switch
Jul 14, 2026
Merged

feat: switch peripherals to this Mac when a chosen display connects#61
MegaManSec merged 4 commits into
mainfrom
feat/display-trigger-switch

Conversation

@MegaManSec

Copy link
Copy Markdown
Owner

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's NSScreen.localizedName with a toggle.

The trigger is an edge, not a state

DisplayMonitor (new, a sibling of SleepMonitor) only fires on a genuine absent → present transition:

  • Launch baseline. Displays already online when the app starts never fire — an app restart must not yank peripherals from the Mac you're actively using.
  • Debounced reconciliation. Reconfiguration callbacks are coalesced (2s) and then diffed against 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.
  • Sleep/wake handling. The present set is snapshotted at 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 at willSleep (acting there would race the radio teardown) and forced at didWake (a picture that changed during sleep may produce no further callback).
  • NSWorkspace sleep notifications rather than a second IORegisterForSystemPower port: unlike SleepMonitor this 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_ALL to the peer, connectPeripheralFromPeer each, arm the HOLDS_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:

  • Everything already here → silent no-op (re-docking the Mac that owns the peripherals doesn't spam, and wake-while-docked converges with the existing wake reclaim instead of fighting it).
  • A switch/pairing already in flight → skip (same isAnyPeripheralTransitioning guard as the menu; whatever is in flight is already moving things).
  • Bluetooth off/unauthorized → skip — an automatic trigger must never ask the peer to release peripherals this Mac then can't take.
  • Partial state is handled, unlike the menu's mixed-state notification: the peer only releases what it holds, and connectPeripheralFromPeer adopts an already-live local connection, so taking "the rest" is safe.
  • No trusted peer registered (or unpaired): falls back to a plain local grab with the watcher armed — the display is a claim on the peripherals either way.
  • A user notification ("Display connected — switching your peripherals to this Mac.") announces the automatic action; failures surface through the existing switch-failure notifications.

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

  • Built clean (no warnings) with the CI invocation; swift format is a no-op on the diff.
  • Display hotplug/sleep flows were traced against the reconcile semantics above (dock, undock+redock, wake-while-docked, dock-while-asleep, dock-then-immediate-lid-close); I couldn't exercise physical hotplug on this machine, so a two-Mac smoke test of the flagship flow is worth doing before merge.

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.
@MegaManSec
MegaManSec merged commit ff26de1 into main Jul 14, 2026
2 checks passed
@MegaManSec
MegaManSec deleted the feat/display-trigger-switch branch July 14, 2026 22:06
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.19.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: Switch based on detected display

1 participant