Replace SLS-only overlay windows with NSWindow + sharingType=.none —
the only mechanism Tahoe's screenshot picker honors. Includes follow-up
fixes for stack overlay leaks, hotplug-driven NSScreen staleness,
sleep/wake CAShapeLayer reset, and focus-tracking races during
on-demand overlay creation.
Move CLAUDE.md and AGENTS.md to local-only scratch, matching the parent
tarmac repo's convention. The file remains on disk for the working
copy but is no longer in version control.
The SLS-overlay path (probe_cg_window_info, draw_border, create_overlay,
display_scale_for_bounds, SCALE_EPSILON, set_alpha, recreate, remove_all)
became dead when the NSWindow refactor took over screenshot exclusion.
Drop it.
Logging cleanup:
- Default filter goes from ers=debug to ers=info; debug still available
with RUST_LOG=ers=debug.
- Drop the /tmp/ers-debug.log file sink left over from the
research-branch diagnostics.
- focus-retry no longer logs every poll; it only logs once when the
retry finally succeeds.
- set_bounds drops the per-call diagnostic log; only warns now if the
NSWindow rejects the requested placement (placed_correctly=false).
NSWindow.frame survives display sleep/wake correctly (placed_correctly
logs always returned true), but the CAShapeLayer's frame/path can be
reset to a small rect at the layer origin during the wake transition.
Because SLS window bounds didn't change, sync_overlay never re-applied
state — leaving a tiny border in the bottom-left corner of an
otherwise-correctly-positioned window.
Add OverlayWindow::reapply_layer (cheap — only the layer, not the
NSWindow) and call it once a second from the periodic reconcile, plus
on every CGDisplayReconfiguration hotplug.
If macOS doesn't accept the requested frame (e.g. cross-screen moves
AppKit silently rejects), set_bounds now shows the actual post-write
frame and a placed_correctly flag — separates 'we computed the wrong
cocoa Y' from 'we computed the right one but NSWindow refused it'.
Register CGDisplayRegisterReconfigurationCallback so a monitor plug
or resolution change re-fetches every overlay's bounds and re-applies
set_bounds. Without this, overlays whose CG bounds didn't change but
whose cocoa frame depends on the new primary screen height stayed at
their pre-hotplug positions until the next SLS Move event.
NSScreen.screens caches and only refreshes on certain notifications, so
plugging an external monitor while ers is running left primary_height
stuck at the pre-hotplug value. Every CG-to-Cocoa Y conversion was
therefore off by the difference between the old primary height and the
new one — visible as borders drawn far from their windows on the
external display.
CGDisplayBounds(CGMainDisplayID()) reflects the current state on every
call and matches what tarmac's display module already uses.
Find the primary screen by Cocoa origin (0,0) instead of relying on
NSScreen.screens[0], which Apple no longer guarantees to be the primary
on every macOS version. Log the screen layout at startup and the
CG-to-Cocoa transform on every set_bounds so we can diagnose offset
border reports.
orderWindow:relativeTo: re-shows an off-screen window as a side effect.
sync_overlay called order_above whenever a target's bounds changed, which
defeated active_only mode for stacked windows: tarmac's stack peek-out
positions shift on every stack cycle, so each non-focused stack overlay
popped back onto the screen as a grey border.
Two bugs from user testing:
1. Spawning a new terminal made the border disappear until the next
focus move. Cause: focus moves to the new wid before its SLS state
passes the add_fresh filter (SLSWindowIsOrderedIn race). The
focus-changed branch attempted add_fresh once and gave up; the
focus-unchanged branch returned early, never retrying.
Fix: in update_focus, when front == focused_wid AND the focused
wid still has no overlay, retry add_fresh on every poll. The
filter passes once the new window settles (typically 1-2 ticks).
2. Closing the only window on a workspace left a phantom border.
Cause: sync_overlay returned early on SLSGetWindowBounds failure
(which is what happens for a destroyed wid) without removing the
overlay. The Drop never fired, so NSWindow.orderOut never ran.
Fix: on SLSGetWindowBounds failure, treat the window as gone and
call remove() to drop the overlay.
Also: timer's idle branch now runs reconcile_tracked once per second
as a safety net for any other class of missed Close/Destroy event.
tarmac multiplexes workspaces by hiding non-current windows; only
windows on the active workspace get enumerated by discover_windows
at startup. When the user switched to a workspace whose windows
weren't tracked, focus would move to a wid with no overlay and the
border would disappear (active-only mode hides the previous overlay
but has nothing to unhide for the new focus).
Trace from /tmp/ers-debug.log:
[focus] 130 -> 139 (tracked targets: [796, 130, 681, 795, 778])
[hide] target=130
(no unhide — 139 not in tracked set, border vanishes)
Fix: in update_focus, if the new front isn't tracked, add_fresh it
on the spot. Same treatment for Event::Unhide so workspace-switch
unhide notifications can pick up newly-visible windows.
Also: CFRunLoopTimerCreate flags arg was u32, should be u64
(CFOptionFlags = unsigned long on 64-bit Darwin) — corrupted ABI for
subsequent args and the timer was firing unreliably as a result.
Overlays were leaking onto every macOS space simultaneously due to
CanJoinAllSpaces, which presents as 'border stuck on the previous
workspace' when the user navigates between tarmac workspaces. Removing
that flag confines each overlay to the space where it was created.
Add diagnostic logging in update_focus / hide / unhide so a workspace-
switch trace can be read with RUST_LOG=ers=debug.
CAShapeLayer strokes are centered on the path, so to render a visible
border_width-thick ring inside the layer bounds we want
line_width = border_width and path inset = border_width/2 (not the
doubled values I had — those produced 2x-thick borders user reported).
BorderMap now stores nswindow_overlay::OverlayWindow per target. The
event-processing loop moves from a background thread to a CFRunLoopTimer
on the main thread, since AppKit calls (NSWindow, CALayer) must originate
from the main thread.
Verified empirically: screencapture -l <ers_wid> returns 'could not
create image from window' for all 5 overlays — Tahoe's screencaptureui
honors NSWindow.sharingType where it ignores SLS sharing-state for
SLS-only windows. Full-screen screencapture cleanly captures app
contents without the borders blocking them.
Empirically disproved approaches for excluding ers overlays from
Tahoe's cmd+shift+4 + space picker. All of these apply *correctly*
to the overlay window — they just don't influence the picker.
Verified via post-create probes:
- SLSNewWindowWithOpaqueShapeAndContext baking tag bits (1<<1)|(1<<9)
at creation succeeds; readback shows tags=0x202.
- SLSSetWindowSharingState(0) succeeds; SLSGetWindowSharingState
reads back state=0.
- CGWindowListCopyWindowInfo for the overlay reports
kCGWindowSharingState=0 (the SLS-side state propagates through to
the CG window list, which SCWindow filters on).
- kCGWindowIsOnscreen is absent from the dictionary entry (CG
doesn't know the on-screen state of an SLS-only window without
an NSWindow wrapper).
Despite all of the above, the screenshot picker still hit-tests onto
the overlay and captures the border. This means Tahoe's picker uses
a query path that bypasses standard CGWindowSharingState and tag-bit
filtering for SLS-only windows.
Approaches still untested but likely dead-ends without docs:
SLSSetWindowType, SLSSetWindowFiltering, SLSSetWindowSubLevel.
Probe helper probe_cg_window_info added for ongoing investigation.
Branch is for research; main remains the working EventShape build.
Setting any SLS window tags (bit 1 or bits 1|9) post-creation breaks
border visibility on Tahoe in our recreate-overlay-on-move lifecycle:
borders flicker on and disappear during the rapid sync_overlay churn
that tiling produces. JankyBorders works with tags 1|9 because it
creates each border window once and moves it via
SLSTransactionMoveWindowWithGroup; ers releases and recreates the SLS
window on every geometry change, and Tahoe's compositor handles the
tagged-then-released window very differently.
Restoring SLSSetWindowEventShape(empty) + SLSSetWindowEventMask(0) for
click-through; screenshots will again include the overlay until the
lifecycle is refactored to match JankyBorders'.
Sets tag bits (1<<1) | (1<<9) on every overlay window: bit 1 is
kCGSIgnoreForEvents (click-through), bit 9 hides the window from
ScreenCaptureKit, the screenshot picker, and screen recordings.
Mirrors JankyBorders' approach (.refs/JankyBorders/src/border.c:290
and .refs/JankyBorders/src/misc/window.h:266).
Tags are now set BEFORE SLWindowContextCreate / drawing, which is the
critical difference from the prior tag-based attempt that poisoned
SLSNewWindow on stack-cycle recreates. Removes the now-redundant
SLSSetWindowEventShape/Mask, SLSSetWindowSharingState, and
SLSSetWindowClientPerceivedType bindings.
The guessed FFI signature for SLSTransactionSetWindowBoundsPath does
not match Tahoe's actual ABI — calling it crashes ers with SIGSEGV
during overlay creation, which leaves tarmac with no border renderer.
Removing the donut path; capture-exclusion now relies solely on the
SharingState/ClientPerceivedType advisories. Screenshots will once
again include the overlay border, but borders themselves come back.
AX-driven window moves during stack cycles often don't emit SLS
WINDOW_MOVE notifications, so the stored overlay frame can be stale
when update_focus runs. Calling sync_overlay against the live
SLSGetWindowBounds for both old and new focused windows before the
hide/unhide pair keeps the active border on the right window during
stack-next/prev navigation.
Replaces the overlay's bounds region with a CGPath containing an outer
rect and an inner cutout. SLS evaluates the path with even-odd winding,
so the interior is treated as outside the window: screenshots taken
inside the bordered area capture the underlying app window instead of
the overlay. Pairs with SharingState/ClientPerceivedType advisories for
capture clients that honor them.
The kCGSIgnoreForEvents tag bit poisons the shared SLS connection's
SLSNewWindow path during stack-cycle recreates, dropping border
overlays on the inactive stack windows. An empty event shape achieves
the same click-through without mutating tags.