AppKit F09 — Output enumeration & hotplug
macOS answers the F09 feature spec with two object models for the same display: AppKit's NSScreen (points, y-up global space, per-screen scale, localizedName) and CoreGraphics' CGDirectDisplayID (pixels, top-left-origin global space, modes, hardware identity) — bridged by the NSScreenNumber key in deviceDescription. This demo enumerates both sides, checks the bridge, reads -[NSWindow screen] (AppKit's own answer to "which output is the window on"), and registers every change signal a hotplug would fire. The program is ./examples/f09-outputs/app.d (with the shared instrument.d logger), built on the scaffold recipe.
Last reviewed: June 11, 2026
All run findings are A[ssh]: built and executed on mac-bsn (aarch64-darwin, macOS 26.3.1, LDC 1.41.0, single built-in Retina display) over SSH with the console session locked. For this feature the lock is not just a rendering caveat — it visibly changes one of the two enumeration APIs (the empty-active-list finding below), which is itself the headless-hotplug answer the spec asks for. A physical plug/unplug is Tier C (script).
| Measurement | Value |
|---|---|
| Outputs (NSScreen / CG online / CG active) | 1 / 1 / 0 — the locked session removes the display from the active list only |
Bridge NSScreenNumber ↔ CGDirectDisplayID | id 1 on both sides (in_cg_online_list=1); equals CGMainDisplayID() |
| CG enumeration prerequisites | none — ran before NSApplication.sharedApplication() existed |
CGDisplayPixelsWide/High | 1728×1117 — points-scaled, not pixels; CGDisplayModeGetPixelWidth says 3456×2234 |
| Refresh | CG mode refresh=120.00; NSScreen.maximumFramesPerSecond=120 (ProMotion panel) |
| Physical size | CGDisplayScreenSize → 344×223 mm; AppKit offers no physical-size API |
frame vs visibleFrame | 1728×1117 vs 1728×1084 — 33 pt menubar inset at the top, Dock inset 0 while locked |
| Window ↔ screen | window.screen answers at initWithContentRect: return, before ordering in |
| Hotplug signals fired headless | 0 of 3 (NSNotification ×2 by self-test only; CGDisplayRegisterReconfigurationCallback never) |
| Exit | clean 0 (loop_exit ticks=45 reconfig_cb=0 screen_params=1 window_screen=1) |
Two object models, one display A[ssh]
The same panel, enumerated through both APIs (verbatim, one run):
20128 APPKIT_F09 cg_displays when=startup online=1 active=0
24129 APPKIT_F09 output api=cg id=1 bounds=(0,0 1728x1117) px=1728x1117 mm=344x223 vendor=0x610 model=0xa05d serial=0xfd626d62 builtin=1 main=1 active=0 online=1 asleep=1 mirror=0
24173 APPKIT_F09 output_mode api=cg id=1 mode_pt=1728x1117 mode_px=3456x2234 refresh=120.00
47851 APPKIT_F09 output api=appkit idx=0 id=1 frame=(0,0 1728x1117) visible=(0,0 1728x1084) scale=2.0 max_fps=120 name="Built-in Retina Display"
47892 APPKIT_F09 output_device api=appkit id=1 device_size_pt=1728x1117 device_dpi=144x144
48062 APPKIT_F09 bridge nsscreen_idx=0 nsscreen_number=1 in_cg_online_list=1 in_cg_active_list=0 px_check=1728x1117| Fact | NSScreen (AppKit) | CGDirectDisplayID (CoreGraphics) |
|---|---|---|
| Identity | object per screen; NSScreenNumber in deviceDescription | the uint32_t display ID itself (1 here), + vendor/model/serial |
| Geometry | frame in points, y-up global space | CGDisplayBounds in the global display space, top-left origin |
| Usable area | visibleFrame (menubar/Dock insets) | — (no concept) |
| Scale | backingScaleFactor (2.0) | implied by mode_pt vs mode_px (1728 vs 3456) |
| Mode / refresh | maximumFramesPerSecond (cap only) | CGDisplayCopyDisplayMode → size + refreshRate |
| Physical size | — (only NSDeviceResolution 144 dpi) | CGDisplayScreenSize → 344×223 mm |
| Human name | localizedName ("Built-in Retina Display", macOS 10.15+) | — (numeric vendor/model/serial) |
| Power/session state | — (a locked/asleep screen still enumerates normally) | CGDisplayIsActive/IsOnline/IsAsleep flags |
| Needs | AppKit (NSApplication implicitly initialized) | nothing — ran before sharedApplication |
Notes from the probes:
- Enumeration is global, both sides. The CG pass ran before
NSApplication.sharedApplication()existed — only the WindowServer connection (bootstrapped by theCGMainDisplayID()headless guard) is needed. No window, no app object, no event loop. CGDisplayPixelsWide/Highdoes not return pixels on a Retina display: it reports 1728×1117 — the points-scaled size, identical toCGDisplayBounds— for a 3456×2234 panel. The truthful pixel count lives inCGDisplayModeGetPixelWidth/Height(mode_px=3456x2234). The function name predates HiDPI; treat it as "bounds units", never as buffer size.- Refresh agrees at 120 from both sides on this ProMotion panel —
CGDisplayModeGetRefreshRatereturns the mode's nominal rate andmaximumFramesPerSecond(the AppKit-side cap, macOS 12+) matches. Note the CG value is documented to read 0 for some displays ("Some displays may not use conventional video vertical and horizontal sweep in painting the screen; ... the return value is 0" per therefreshRatedocs) — code must tolerate 0 there, which makesmaximumFramesPerSecondthe more dependable number. deviceDescriptionextras:NSDeviceSizerepeats the frame in points andNSDeviceResolutionreads 144×144 dpi — which is just 72 dpi × the 2.0 scale, a logical density, not the panel's true ~226 ppi. The only honest physical-size source is CG's 344×223 mm.- The
frame/visibleFramesplit: 1117 − 1084 = 33 pt shaved off the top — the menubar — even while locked; the Dock contributed 0 pt in this session (dock_pt=0: the lock screen reserves no Dock strip). Maximizing intoframeinstead ofvisibleFrameis the classic menubar-overlap bug.
The bridge: NSScreenNumber is the CGDirectDisplayID A[ssh]
deviceDescription[@"NSScreenNumber"] unboxes to 1, which is exactly CGMainDisplayID() and a member of the CG online list (bridge nsscreen_number=1 in_cg_online_list=1 px_check=1728x1117). This is the documented escape hatch — the deviceDescription docs say to "specify the Objective-C string NSScreenNumber as the key" and that "the value associated with this key is an NSNumber object containing the display ID value" — and it is the only way to get from an NSScreen (no mode, no physical size, no hardware identity) to the CG facts. Every multi-monitor AppKit renderer ends up holding both handles per output.
Which screen is the window on? AppKit just tells you A[ssh]
84137 APPKIT_F09 window_screen when=post_init id=1 name="Built-in Retina Display" window_frame=(120,120 480x348) api=window.screen derived=no-[NSWindow screen] returns the screen object directly — already at initWithContentRect: return, before the window is ever ordered in — and the docs pin the tie-breaking rule for straddling windows: "the screen where most of the window is on" (and nil when offscreen). The cross-platform contrast in one line each: Win32 gives the same answer but only on demand via MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); X11 gives no answer at all — you intersect the window geometry against RandR monitor rects yourself. And tracking is push, not poll: NSWindowDidChangeScreenNotification fires when the answer changes (registration + a hand-posted self-test proven below; a real screen-to-screen drag is Tier C — single display here).
The locked session empties the active list A[ssh]
The headless probe the spec asks for ("does locking/display sleep change the active list?") has a sharp answer — yes, the active list, and only the active list:
20128 APPKIT_F09 cg_displays when=startup online=1 active=0
24129 APPKIT_F09 output api=cg id=1 ... builtin=1 main=1 active=0 online=1 asleep=1 mirror=0
44884 APPKIT_F09 ns_screens when=startup count=1CGGetActiveDisplayListreturns zero displays: the locked, slept panel (asleep=1) is not "active" — consistent with theCGGetActiveDisplayListdocs, which provide "a list of displays that are active for drawing". Mirroring is not the cause here (mirror=0); display sleep is.CGGetOnlineDisplayListstill returns the display — online covers displays "that are online (active, mirrored, or sleeping)".NSScreen.screensis unaffected (count=1, full geometry, scale, name), andwindow.screen,CGDisplayBounds, the mode query, andCGMainDisplayIDall keep answering for the inactive display.
So the two models even disagree about existence: CG truthfully reports the power state, AppKit preserves the desktop model regardless. An output enumerator that uses CGGetActiveDisplayList as its source of truth sees an empty world in a locked session — enumerate the online list (or NSScreen.screens) and treat active/ asleep as flags. The re-enumeration at tick 20 confirmed the state is static headless (hotplug_probe active_delta=0 online_delta=0 reconfig_cb_calls=0).
Hotplug: three signals registered, none fire headless A[ssh]
A real plug/unplug cannot happen over locked SSH, so the demo pins everything short of it:
- App level:
NSApplicationDidChangeScreenParametersNotification("the configuration of the displays attached to the computer is changed"). - Window level:
NSWindowDidChangeScreenNotification(this window'sscreenanswer changed). - CG level:
CGDisplayRegisterReconfigurationCallback(err=0on registration, also pre-AppKit) — fires per display with begin/add/remove/set-mode flags; the demo decodeskCGDisplayAddFlag/kCGDisplayRemoveFlag/kCGDisplaySetModeFlag.
The NSNotification wiring is proven by a hand-posted self-test — both handlers run and re-enumerate (unchanged) state:
615271 APPKIT_F09 step name=post_notifications note=selftest
615334 APPKIT_F09 screen_params_changed n=1
615614 APPKIT_F09 window_screen_changed n=1
852848 APPKIT_F09 loop_exit ticks=45 reconfig_cb=0 screen_params=1 window_screen=1The CG callback cannot be hand-posted (it is driven by actual reconfiguration), so its headless count is honestly 0; with the active list already empty, even locking/unlocking mid-run would be the obvious next probe — but the lock state cannot be toggled from a non-GUI SSH session, so the event-order question (CG callback vs NSNotification, begin/end pairing, stale window.screen during removal) is entirely Tier C.
Tier C script: plug/unplug
Run on mac-bsn in an unlocked GUI session with one external display (results → this doc):
- Build per the scaffold (binary staged at
/tmp/wsi-m5/f09-outputs/demo), run./demowith no env vars (interactive mode keeps the window up). - Plug the external display in. Record the order and timing of:
cg_reconfiglines (expect a begin/end pair for the new id,add=1),screen_params_changed, and whetherns_screensthen shows the new screen with a stableNSScreenNumber. Check the new id appears in both CG lists. - Drag the window to the external display; expect
window_screen_changed+window_screenwith the new id at some crossing point (record whether it is the majority-area point, per thescreendocs). - Unplug while the window is on the external display — the spec's "current output vanishes" case. Record:
cg_reconfig remove=1, where the window reappears, whatwindow.screenreports between the unplug and the reappearance, and that nothing crashes. - While there: toggle the lock screen and confirm
active=0/1flips onCGGetActiveDisplayList(validating the headless finding), and whether the reconfiguration callback fires on lock/unlock alone.
Findings summary (for event-sequences.md)
- One display, two object models:
NSScreen(points, y-up, scale, usable-area insets, human name) vsCGDirectDisplayID(pixels, top-left origin, modes, refresh, physical mm, power state); theNSScreenNumberdeviceDescription key is the bridge. A real renderer needs both per output. - Enumeration is global and app-free — the CG side answered before
NSApplicationexisted;NSScreen.screensneeds only AppKit loaded, not a window. CGDisplayPixelsWidereturns points-scaled units on HiDPI (1728 for a 3456-pixel panel); onlyCGDisplayModeGetPixelWidth/Heightis honest. Same trap family asNSDeviceResolution's logical 144 dpi.- The window→output question is answered by AppKit directly (
window.screen, majority-area rule, valid from init) — no Win32-style on-demand call, no X11-style derive-it-yourself. - A locked session empties
CGGetActiveDisplayListwhile the online list and the whole NSScreen model stay intact — display sleep ≠ display gone, and an enumerator keyed on the active list sees zero outputs headless. - Hotplug is three independent signals (app notification, window notification, CG callback with add/remove/mode flags) — registration and NSNotification wiring proven headless; ordering and the vanishing-output case are Tier C.
Sources
- This demo —
./examples/f09-outputs/app.d,./examples/f09-outputs/instrument.d; the AppKit scaffold findings (recipe, locked-session WindowServer evidence), the F08 DPI findings (the scale side ofNSScreen), and the AppKit survey. - Feature specs — F09 outputs; the Tier-C entry in the manual-run-queue.
- Apple Developer documentation (Wayback-pinned where a verified snapshot exists; this host is bot-hostile):
NSScreen.screens,deviceDescription,visibleFrame,maximumFramesPerSecond,localizedName,NSWindow.screen,NSWindowDidChangeScreenNotification,NSApplicationDidChangeScreenParametersNotification,CGGetActiveDisplayList,CGGetOnlineDisplayList,CGDisplayBounds,CGDisplayCopyDisplayMode,CGDisplayMode.refreshRate,CGDisplayScreenSize,CGDisplayRegisterReconfigurationCallback.