Rust · 58414 bytes Raw Blame History
1 //! ers — window border renderer
2
3 mod events;
4 mod skylight;
5 mod nswindow_overlay;
6
7 use events::Event;
8 use skylight::*;
9 use std::collections::HashMap;
10 use std::ptr;
11 use std::sync::Arc;
12 use std::sync::atomic::{AtomicBool, Ordering};
13 use std::sync::mpsc;
14 use tracing::debug;
15
16 static SIGNAL_STOP_REQUESTED: AtomicBool = AtomicBool::new(false);
17 const MIN_TRACKED_WINDOW_SIZE: f64 = 4.0;
18 const GEOMETRY_EPSILON: f64 = 0.5;
19 const SCALE_EPSILON: f64 = 0.01;
20 const WINDOW_ATTRIBUTE_REAL: u64 = 1 << 1;
21 const WINDOW_TAG_DOCUMENT: u64 = 1 << 0;
22 const WINDOW_TAG_FLOATING: u64 = 1 << 1;
23 const WINDOW_TAG_ATTACHED: u64 = 1 << 7;
24 const WINDOW_TAG_IGNORES_CYCLE: u64 = 1 << 18;
25 const WINDOW_TAG_MODAL: u64 = 1 << 31;
26 const WINDOW_TAG_REAL_SURFACE: u64 = 1 << 58;
27
28 /// Per-overlay state: an NSWindow drawing the rounded-rect border via
29 /// CAShapeLayer. Replaces the old SLS-only overlay window — see
30 /// nswindow_overlay.rs for the rationale (screencaptureui on Tahoe
31 /// only honors NSWindow.sharingType, not SLS sharing-state nor tag
32 /// bits, for raw SLS-only windows).
33 struct Overlay {
34 window: nswindow_overlay::OverlayWindow,
35 }
36
37 impl Overlay {
38 fn wid(&self) -> u32 {
39 self.window.wid()
40 }
41 fn bounds(&self) -> CGRect {
42 CGRect {
43 origin: CGPoint {
44 x: self.window.bounds_cg_x,
45 y: self.window.bounds_cg_y,
46 },
47 size: CGSize {
48 width: self.window.bounds_cg_w,
49 height: self.window.bounds_cg_h,
50 },
51 }
52 }
53 }
54
55 fn window_area(bounds: CGRect) -> f64 {
56 bounds.size.width * bounds.size.height
57 }
58
59 fn intersection_area(a: CGRect, b: CGRect) -> f64 {
60 let left = a.origin.x.max(b.origin.x);
61 let top = a.origin.y.max(b.origin.y);
62 let right = (a.origin.x + a.size.width).min(b.origin.x + b.size.width);
63 let bottom = (a.origin.y + a.size.height).min(b.origin.y + b.size.height);
64 let width = (right - left).max(0.0);
65 let height = (bottom - top).max(0.0);
66 width * height
67 }
68
69 fn is_same_window_surface(a: CGRect, b: CGRect) -> bool {
70 let smaller = window_area(a).min(window_area(b));
71 smaller > 0.0 && intersection_area(a, b) / smaller >= 0.9
72 }
73
74 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
75 enum SurfacePreference {
76 KeepExisting,
77 ReplaceExisting,
78 }
79
80 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
81 struct WindowMetadata {
82 parent_wid: u32,
83 tags: u64,
84 attributes: u64,
85 }
86
87 fn surface_preference(existing: CGRect, candidate: CGRect) -> Option<SurfacePreference> {
88 if !is_same_window_surface(existing, candidate) {
89 return None;
90 }
91
92 if window_area(candidate) > window_area(existing) {
93 Some(SurfacePreference::ReplaceExisting)
94 } else {
95 Some(SurfacePreference::KeepExisting)
96 }
97 }
98
99 fn minimum_trackable_dimension(border_width: f64) -> f64 {
100 border_width.max(MIN_TRACKED_WINDOW_SIZE)
101 }
102
103 fn is_trackable_window(bounds: CGRect, border_width: f64) -> bool {
104 let min_dimension = minimum_trackable_dimension(border_width);
105 bounds.size.width >= min_dimension && bounds.size.height >= min_dimension
106 }
107
108 fn origin_changed(a: CGRect, b: CGRect) -> bool {
109 (a.origin.x - b.origin.x).abs() > GEOMETRY_EPSILON
110 || (a.origin.y - b.origin.y).abs() > GEOMETRY_EPSILON
111 }
112
113 fn size_changed(a: CGRect, b: CGRect) -> bool {
114 (a.size.width - b.size.width).abs() > GEOMETRY_EPSILON
115 || (a.size.height - b.size.height).abs() > GEOMETRY_EPSILON
116 }
117
118 fn is_suitable_window_metadata(metadata: WindowMetadata) -> bool {
119 metadata.parent_wid == 0
120 && ((metadata.attributes & WINDOW_ATTRIBUTE_REAL) != 0
121 || (metadata.tags & WINDOW_TAG_REAL_SURFACE) != 0)
122 && (metadata.tags & WINDOW_TAG_ATTACHED) == 0
123 && (metadata.tags & WINDOW_TAG_IGNORES_CYCLE) == 0
124 && ((metadata.tags & WINDOW_TAG_DOCUMENT) != 0
125 || ((metadata.tags & WINDOW_TAG_FLOATING) != 0
126 && (metadata.tags & WINDOW_TAG_MODAL) != 0))
127 }
128
129 fn query_window_metadata(cid: CGSConnectionID, wid: u32) -> Option<WindowMetadata> {
130 unsafe {
131 let window_ref = cfarray_of_cfnumbers(
132 (&wid as *const u32).cast(),
133 std::mem::size_of::<u32>(),
134 1,
135 kCFNumberSInt32Type,
136 );
137 if window_ref.is_null() {
138 return None;
139 }
140
141 let query = SLSWindowQueryWindows(cid, window_ref, 0x0);
142 CFRelease(window_ref);
143 if query.is_null() {
144 return None;
145 }
146
147 let iterator = SLSWindowQueryResultCopyWindows(query);
148 CFRelease(query);
149 if iterator.is_null() {
150 return None;
151 }
152
153 let metadata = if SLSWindowIteratorAdvance(iterator) {
154 Some(WindowMetadata {
155 parent_wid: SLSWindowIteratorGetParentID(iterator),
156 tags: SLSWindowIteratorGetTags(iterator),
157 attributes: SLSWindowIteratorGetAttributes(iterator),
158 })
159 } else {
160 None
161 };
162
163 CFRelease(iterator);
164 metadata
165 }
166 }
167
168 fn is_suitable_window(cid: CGSConnectionID, wid: u32) -> bool {
169 match query_window_metadata(cid, wid) {
170 Some(metadata) => {
171 let suitable = is_suitable_window_metadata(metadata);
172 if !suitable {
173 debug!(
174 "[window_filter] rejecting wid={} parent={} tags={:#x} attributes={:#x}",
175 wid, metadata.parent_wid, metadata.tags, metadata.attributes
176 );
177 }
178 suitable
179 }
180 None => false,
181 }
182 }
183
184 fn cf_string_from_static(name: &std::ffi::CStr) -> CFStringRef {
185 unsafe { CFStringCreateWithCString(ptr::null(), name.as_ptr().cast(), kCFStringEncodingUTF8) }
186 }
187
188 unsafe extern "C" fn handle_sigint(_: libc::c_int) {
189 SIGNAL_STOP_REQUESTED.store(true, Ordering::Relaxed);
190 }
191
192 fn display_scale_for_bounds(bounds: CGRect) -> f64 {
193 let point = CGPoint {
194 x: bounds.origin.x + bounds.size.width / 2.0,
195 y: bounds.origin.y + bounds.size.height / 2.0,
196 };
197
198 unsafe {
199 let mut display_id = 0u32;
200 let mut count = 0u32;
201 if CGGetDisplaysWithPoint(point, 1, &mut display_id, &mut count) != kCGErrorSuccess
202 || count == 0
203 {
204 return 2.0;
205 }
206
207 let mode = CGDisplayCopyDisplayMode(display_id);
208 if mode.is_null() {
209 return 2.0;
210 }
211
212 let width = CGDisplayModeGetWidth(mode) as f64;
213 let height = CGDisplayModeGetHeight(mode) as f64;
214 let pixel_width = CGDisplayModeGetPixelWidth(mode) as f64;
215 let pixel_height = CGDisplayModeGetPixelHeight(mode) as f64;
216 CFRelease(mode as CFTypeRef);
217
218 let scale_x = if width > 0.0 {
219 pixel_width / width
220 } else {
221 0.0
222 };
223 let scale_y = if height > 0.0 {
224 pixel_height / height
225 } else {
226 0.0
227 };
228
229 let scale = match (scale_x.is_finite(), scale_y.is_finite()) {
230 (true, true) if scale_x >= 1.0 && scale_y >= 1.0 => (scale_x + scale_y) / 2.0,
231 (true, _) if scale_x >= 1.0 => scale_x,
232 (_, true) if scale_y >= 1.0 => scale_y,
233 _ => 2.0,
234 };
235
236 debug!(
237 "[display_scale] display={} point=({:.1},{:.1}) scale={:.2}",
238 display_id, point.x, point.y, scale
239 );
240
241 scale
242 }
243 }
244
245 /// Tracks overlays for target windows.
246 struct BorderMap {
247 overlays: HashMap<u32, Overlay>,
248 main_cid: CGSConnectionID,
249 own_pid: i32,
250 border_width: f64,
251 radius: f64,
252 focused_wid: u32,
253 active_color: (f64, f64, f64, f64),
254 inactive_color: (f64, f64, f64, f64),
255 active_only: bool,
256 mtm: objc2::MainThreadMarker,
257 }
258
259 impl BorderMap {
260 fn new(
261 cid: CGSConnectionID,
262 own_pid: i32,
263 border_width: f64,
264 mtm: objc2::MainThreadMarker,
265 ) -> Self {
266 Self {
267 overlays: HashMap::new(),
268 mtm,
269 main_cid: cid,
270 own_pid,
271 border_width,
272 radius: 10.0,
273 focused_wid: 0,
274 active_color: (0.32, 0.58, 0.89, 1.0), // #5294e2
275 inactive_color: (0.35, 0.35, 0.35, 0.8), // dim gray
276 active_only: false,
277 }
278 }
279
280 fn color_for(&self, target_wid: u32) -> (f64, f64, f64, f64) {
281 if target_wid == self.focused_wid {
282 self.active_color
283 } else {
284 self.inactive_color
285 }
286 }
287
288 fn is_overlay(&self, wid: u32) -> bool {
289 self.overlays.values().any(|o| o.wid() == wid)
290 }
291
292 /// Add border using the standard filtering path.
293 fn add_batch(&mut self, target_wid: u32) {
294 self.add_fresh(target_wid);
295 }
296
297 fn surface_replacements(&self, target_wid: u32, bounds: CGRect) -> Option<Vec<u32>> {
298 let mut replacements = Vec::new();
299
300 for &existing_wid in self.overlays.keys() {
301 if existing_wid == target_wid {
302 continue;
303 }
304
305 unsafe {
306 let mut existing_bounds = CGRect::default();
307 if SLSGetWindowBounds(self.main_cid, existing_wid, &mut existing_bounds)
308 != kCGErrorSuccess
309 {
310 continue;
311 }
312
313 match surface_preference(existing_bounds, bounds) {
314 Some(SurfacePreference::KeepExisting) => return None,
315 Some(SurfacePreference::ReplaceExisting) => replacements.push(existing_wid),
316 None => {}
317 }
318 }
319 }
320
321 Some(replacements)
322 }
323
324 /// Add border (event mode). Uses main_cid — fresh connections create
325 /// invisible windows on Tahoe.
326 fn add_fresh(&mut self, target_wid: u32) {
327 if self.overlays.contains_key(&target_wid) {
328 return;
329 }
330
331 // Filter: must be visible, owned by another process, not tiny
332 let bounds = unsafe {
333 let mut shown = false;
334 SLSWindowIsOrderedIn(self.main_cid, target_wid, &mut shown);
335 if !shown {
336 return;
337 }
338
339 let mut wid_cid: CGSConnectionID = 0;
340 SLSGetWindowOwner(self.main_cid, target_wid, &mut wid_cid);
341 let mut pid: i32 = 0;
342 SLSConnectionGetPID(wid_cid, &mut pid);
343 if pid == self.own_pid {
344 return;
345 }
346 if !is_suitable_window(self.main_cid, target_wid) {
347 return;
348 }
349
350 let mut bounds = CGRect::default();
351 SLSGetWindowBounds(self.main_cid, target_wid, &mut bounds);
352 if !is_trackable_window(bounds, self.border_width) {
353 return;
354 }
355 bounds
356 };
357
358 let Some(replacements) = self.surface_replacements(target_wid, bounds) else {
359 return;
360 };
361
362 for wid in replacements {
363 self.remove(wid);
364 }
365
366 let color = self.color_for(target_wid);
367 if let Some(window) = nswindow_overlay::OverlayWindow::new(
368 bounds.origin.x,
369 bounds.origin.y,
370 bounds.size.width,
371 bounds.size.height,
372 self.border_width,
373 self.radius,
374 color,
375 self.mtm,
376 ) {
377 window.order_above(target_wid);
378 self.overlays.insert(target_wid, Overlay { window });
379 }
380 }
381
382 fn remove_all(&mut self) {
383 // OverlayWindow's Drop closes the NSWindow.
384 self.overlays.clear();
385 }
386
387 fn remove(&mut self, target_wid: u32) {
388 if let Some(overlay) = self.overlays.remove(&target_wid) {
389 debug!(
390 "[remove] target={} overlay_wid={} dropping NSWindow",
391 target_wid,
392 overlay.wid()
393 );
394 // OverlayWindow's Drop runs orderOut + close.
395 drop(overlay);
396 } else {
397 debug!("[remove] target={} not tracked", target_wid);
398 }
399 }
400
401 /// Reconcile a tracked overlay against its target window.
402 fn sync_overlay(&mut self, target_wid: u32) -> bool {
403 if !self.overlays.contains_key(&target_wid) {
404 return false;
405 }
406
407 let mut bounds = CGRect::default();
408 unsafe {
409 if SLSGetWindowBounds(self.main_cid, target_wid, &mut bounds) != kCGErrorSuccess {
410 // Window is gone (destroyed). Reap the overlay.
411 debug!(
412 "[sync_overlay] target={} SLSGetWindowBounds failed — reaping overlay",
413 target_wid
414 );
415 self.remove(target_wid);
416 return true;
417 }
418
419 if !is_suitable_window(self.main_cid, target_wid) {
420 self.remove(target_wid);
421 return true;
422 }
423
424 if !is_trackable_window(bounds, self.border_width) {
425 self.remove(target_wid);
426 return true;
427 }
428 }
429
430 let active_only = self.active_only;
431 let focused = self.focused_wid;
432
433 if let Some(overlay) = self.overlays.get_mut(&target_wid) {
434 let prev = overlay.bounds();
435 if size_changed(prev, bounds) || origin_changed(prev, bounds) {
436 debug!(
437 "[sync_overlay] target={} geometry ({:.1},{:.1},{:.1},{:.1}) -> ({:.1},{:.1},{:.1},{:.1})",
438 target_wid,
439 prev.origin.x,
440 prev.origin.y,
441 prev.size.width,
442 prev.size.height,
443 bounds.origin.x,
444 bounds.origin.y,
445 bounds.size.width,
446 bounds.size.height
447 );
448 overlay.window.set_bounds(
449 bounds.origin.x,
450 bounds.origin.y,
451 bounds.size.width,
452 bounds.size.height,
453 );
454 // orderWindow:relativeTo: re-shows an off-screen window
455 // as a side effect. In active_only mode, non-focused
456 // overlays must remain hidden — otherwise stack peek
457 // positions cause every stacked window's overlay to
458 // pop onto the screen as their bounds shift.
459 if !active_only || target_wid == focused {
460 overlay.window.order_above(target_wid);
461 }
462 }
463 }
464
465 false
466 }
467
468 fn reconcile_tracked(&mut self) -> bool {
469 let tracked: Vec<u32> = self.overlays.keys().copied().collect();
470 let mut changed = false;
471
472 for wid in tracked {
473 changed |= self.sync_overlay(wid);
474 }
475
476 changed
477 }
478
479 /// Re-apply each overlay's CAShapeLayer geometry. Called on a slow
480 /// periodic schedule (and on hotplug) to repair layer state that
481 /// macOS occasionally resets during display sleep/wake without
482 /// changing the NSWindow's frame — sync_overlay won't fix it on
483 /// its own because the SLS bounds match what we already stored.
484 fn refresh_all_layers(&self) {
485 for overlay in self.overlays.values() {
486 overlay.window.reapply_layer();
487 }
488 }
489
490 /// Re-apply set_bounds for every tracked overlay even when the
491 /// stored CG bounds match the current SLS bounds. After a display
492 /// reconfiguration the cocoa frame depends on the (possibly new)
493 /// primary screen height, so unchanged CG bounds still need their
494 /// cocoa frame recomputed.
495 fn reconcile_all_force(&mut self) {
496 let tracked: Vec<u32> = self.overlays.keys().copied().collect();
497 let active_only = self.active_only;
498 let focused = self.focused_wid;
499 for wid in tracked {
500 let mut bounds = CGRect::default();
501 unsafe {
502 if SLSGetWindowBounds(self.main_cid, wid, &mut bounds) != kCGErrorSuccess {
503 self.remove(wid);
504 continue;
505 }
506 }
507 if let Some(overlay) = self.overlays.get_mut(&wid) {
508 overlay.window.set_bounds(
509 bounds.origin.x,
510 bounds.origin.y,
511 bounds.size.width,
512 bounds.size.height,
513 );
514 if !active_only || wid == focused {
515 overlay.window.order_above(wid);
516 }
517 }
518 }
519 }
520
521 /// With NSWindow.setFrame_display we no longer need a destroy-and-
522 /// recreate path on resize. Kept as a thin alias so existing call
523 /// sites keep working.
524 fn recreate(&mut self, target_wid: u32) {
525 self.sync_overlay(target_wid);
526 }
527
528 fn hide(&self, target_wid: u32) {
529 if let Some(o) = self.overlays.get(&target_wid) {
530 debug!("[hide] target={} overlay_wid={}", target_wid, o.wid());
531 o.window.order_out();
532 }
533 }
534
535 fn unhide(&self, target_wid: u32) {
536 if let Some(o) = self.overlays.get(&target_wid) {
537 debug!("[unhide] target={} overlay_wid={}", target_wid, o.wid());
538 o.window.order_above(target_wid);
539 }
540 }
541
542 fn subscribe_target(&self, target_wid: u32) {
543 unsafe {
544 SLSRequestNotificationsForWindows(self.main_cid, &target_wid, 1);
545 }
546 }
547
548 fn subscribe_all(&self) {
549 let target_wids: Vec<u32> = self.overlays.keys().copied().collect();
550 if target_wids.is_empty() {
551 return;
552 }
553 unsafe {
554 SLSRequestNotificationsForWindows(
555 self.main_cid,
556 target_wids.as_ptr(),
557 target_wids.len() as i32,
558 );
559 }
560 }
561
562 /// Redraw an existing overlay with a new color (no destroy/recreate).
563 fn redraw(&self, target_wid: u32) {
564 if let Some(overlay) = self.overlays.get(&target_wid) {
565 overlay.window.set_color(self.color_for(target_wid));
566 }
567 }
568
569 /// Detect focused window and update border colors if focus changed.
570 fn update_focus(&mut self) {
571 let front = get_front_window(self.own_pid);
572 if front == 0 {
573 return;
574 }
575 if front == self.focused_wid {
576 // Same focus as last poll. But a freshly-spawned window may
577 // have been focused before its SLS state was complete enough
578 // to pass the add_fresh filter — retry on every poll until
579 // it sticks.
580 if !self.overlays.contains_key(&front) {
581 debug!("[focus-retry] front={} still untracked, retrying add_fresh", front);
582 self.add_fresh(front);
583 if self.overlays.contains_key(&front) {
584 self.subscribe_target(front);
585 if self.active_only {
586 self.unhide(front);
587 }
588 }
589 }
590 return;
591 }
592
593 let old = self.focused_wid;
594 self.focused_wid = front;
595
596 // tarmac-style workspace switching can swap focus to a window
597 // that wasn't visible (and therefore not discovered) at ers
598 // startup. Discover_windows only enumerates on-current-space
599 // windows; tarmac stages other workspaces' windows in a hidden
600 // state ers never picked up. If focus lands on such a wid,
601 // create an overlay for it on demand.
602 let new_target = !self.overlays.contains_key(&front);
603 debug!(
604 "[focus] {} -> {} {}(tracked targets: {:?})",
605 old,
606 front,
607 if new_target { "[NEW] " } else { "" },
608 self.overlays.keys().collect::<Vec<_>>()
609 );
610 if new_target {
611 self.add_fresh(front);
612 self.subscribe_target(front);
613 }
614
615 // Pull both overlays' positions to the targets' current SLS bounds
616 // before un/hiding. AX-driven moves during a stack cycle frequently
617 // don't fire SLS WINDOW_MOVE notifications, so a stored overlay
618 // can be at stale coordinates. SLSGetWindowBounds (inside
619 // sync_overlay) is real-time and doesn't wait for a notification.
620 self.sync_overlay(old);
621 self.sync_overlay(front);
622
623 if self.active_only {
624 self.hide(old);
625 self.unhide(front);
626 }
627 self.redraw(old);
628 self.redraw(front);
629 }
630
631 /// Discover on-screen windows and create borders for any untracked ones.
632 /// Called on space changes to pick up windows from workspaces we haven't visited.
633 fn discover_untracked(&mut self) {
634 let wids = discover_windows(self.main_cid, self.own_pid);
635 let mut added = false;
636 for wid in wids {
637 if !self.overlays.contains_key(&wid) {
638 self.add_fresh(wid);
639 if self.active_only && wid != self.focused_wid {
640 self.hide(wid);
641 }
642 added = true;
643 }
644 }
645 if added {
646 self.subscribe_all();
647 }
648 }
649
650 /// In active-only mode, ensure only the focused overlay is visible.
651 fn enforce_active_only(&self) {
652 if !self.active_only {
653 return;
654 }
655 for (&target_wid, o) in &self.overlays {
656 if target_wid == self.focused_wid {
657 o.window.order_above(target_wid);
658 } else {
659 o.window.order_out();
660 }
661 }
662 }
663 }
664
665 /// Get the front (focused) window ID.
666 /// Uses _SLPSGetFrontProcess to find the active app, then CGWindowListCopyWindowInfo
667 /// to find its topmost layer-0 window. This works with tiling WMs where focus
668 /// changes don't alter z-order.
669 fn get_front_window(own_pid: i32) -> u32 {
670 unsafe {
671 // Step 1: get the front (active) process PID
672 let mut psn = ProcessSerialNumber { high: 0, low: 0 };
673 _SLPSGetFrontProcess(&mut psn);
674 let mut front_cid: CGSConnectionID = 0;
675 SLSGetConnectionIDForPSN(SLSMainConnectionID(), &mut psn, &mut front_cid);
676 let mut front_pid: i32 = 0;
677 SLSConnectionGetPID(front_cid, &mut front_pid);
678 if front_pid == 0 || front_pid == own_pid {
679 return 0;
680 }
681
682 // Step 2: find the topmost layer-0 window belonging to that process
683 let list = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
684 if list.is_null() {
685 return 0;
686 }
687
688 let count = CFArrayGetCount(list);
689 let wid_key = cf_string_from_static(c"kCGWindowNumber");
690 let pid_key = cf_string_from_static(c"kCGWindowOwnerPID");
691 let layer_key = cf_string_from_static(c"kCGWindowLayer");
692
693 let mut front_wid: u32 = 0;
694 let mut front_bounds = CGRect::default();
695 let mut have_front_bounds = false;
696 let mut fallback_wid: u32 = 0;
697 for i in 0..count {
698 let dict = CFArrayGetValueAtIndex(list, i);
699 if dict.is_null() {
700 continue;
701 }
702
703 let mut v: CFTypeRef = ptr::null();
704
705 let mut layer: i32 = -1;
706 if CFDictionaryGetValueIfPresent(dict, layer_key as CFTypeRef, &mut v) {
707 CFNumberGetValue(v, kCFNumberSInt32Type, &mut layer as *mut _ as *mut _);
708 }
709 if layer != 0 {
710 continue;
711 }
712
713 let mut pid: i32 = 0;
714 if CFDictionaryGetValueIfPresent(dict, pid_key as CFTypeRef, &mut v) {
715 CFNumberGetValue(v, kCFNumberSInt32Type, &mut pid as *mut _ as *mut _);
716 }
717 if pid == own_pid {
718 continue;
719 }
720
721 let mut wid: u32 = 0;
722 if CFDictionaryGetValueIfPresent(dict, wid_key as CFTypeRef, &mut v) {
723 CFNumberGetValue(v, kCFNumberSInt32Type, &mut wid as *mut _ as *mut _);
724 }
725 if wid == 0 {
726 continue;
727 }
728
729 if !is_suitable_window(SLSMainConnectionID(), wid) {
730 continue;
731 }
732
733 // Track first non-self window as fallback (z-order based)
734 if fallback_wid == 0 {
735 fallback_wid = wid;
736 }
737
738 // Prefer a window from the front process. If another layer-0 surface
739 // from that app nearly fully contains the current one, treat the
740 // larger surface as the real window. Firefox can surface a tab-strip
741 // child ahead of the outer window after a tile.
742 if pid == front_pid {
743 let mut bounds = CGRect::default();
744 if SLSGetWindowBounds(SLSMainConnectionID(), wid, &mut bounds) != kCGErrorSuccess {
745 if front_wid == 0 {
746 front_wid = wid;
747 }
748 continue;
749 }
750
751 if front_wid == 0 {
752 front_wid = wid;
753 front_bounds = bounds;
754 have_front_bounds = true;
755 continue;
756 }
757
758 if have_front_bounds
759 && is_same_window_surface(front_bounds, bounds)
760 && window_area(bounds) > window_area(front_bounds)
761 {
762 front_wid = wid;
763 front_bounds = bounds;
764 }
765 }
766 }
767
768 // Fall back to z-order if front process has no visible windows
769 // (e.g., switched to a workspace where the front app has no windows)
770 if front_wid == 0 {
771 front_wid = fallback_wid;
772 }
773
774 CFRelease(wid_key as CFTypeRef);
775 CFRelease(pid_key as CFTypeRef);
776 CFRelease(layer_key as CFTypeRef);
777 CFRelease(list);
778 front_wid
779 }
780 }
781
782 /// Parse hex color string (#RRGGBB or #RRGGBBAA) to (r, g, b, a) floats.
783 fn parse_color(s: &str) -> Option<(f64, f64, f64, f64)> {
784 let hex = s.strip_prefix('#').unwrap_or(s);
785 if hex.len() != 6 && hex.len() != 8 {
786 return None;
787 }
788 let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f64 / 255.0;
789 let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f64 / 255.0;
790 let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f64 / 255.0;
791 let a = if hex.len() == 8 {
792 u8::from_str_radix(&hex[6..8], 16).ok()? as f64 / 255.0
793 } else {
794 1.0
795 };
796 Some((r, g, b, a))
797 }
798
799 fn flag_value<'a>(args: &'a [String], flags: &[&str]) -> Option<&'a str> {
800 args.iter()
801 .position(|s| flags.iter().any(|f| s == f))
802 .and_then(|i| args.get(i + 1))
803 .map(|s| s.as_str())
804 }
805
806 fn print_help() {
807 eprintln!("ers — window border renderer for tarmac");
808 eprintln!();
809 eprintln!("USAGE: ers [OPTIONS] [WINDOW_ID]");
810 eprintln!();
811 eprintln!("OPTIONS:");
812 eprintln!(" -w, --width <PX> Border width in pixels (default: 4.0)");
813 eprintln!(" -r, --radius <PX> Corner radius (default: 10.0)");
814 eprintln!(" -c, --color <HEX> Active border color (default: #5294e2)");
815 eprintln!(" -i, --inactive <HEX> Inactive border color (default: #59595980)");
816 eprintln!(" --active-only Only show border on focused window");
817 eprintln!(" --list List on-screen windows and exit");
818 eprintln!(" -h, --help Show this help");
819 }
820
821 fn main() {
822 // On the screenshot-exclusion research branch, default to file
823 // logging at debug level so we can diagnose the NSWindow refactor
824 // even when ers is spawned by tarmac (which inherits ers's stderr
825 // to wherever tarmac was launched, often invisibly).
826 let log_path = std::path::PathBuf::from("/tmp/ers-debug.log");
827 let log_file = std::fs::OpenOptions::new()
828 .create(true)
829 .append(true)
830 .open(&log_path)
831 .ok();
832 let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
833 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("ers=debug"));
834 if let Some(file) = log_file {
835 tracing_subscriber::fmt()
836 .with_env_filter(env_filter)
837 .with_writer(std::sync::Mutex::new(file))
838 .with_ansi(false)
839 .init();
840 } else {
841 tracing_subscriber::fmt()
842 .with_env_filter(env_filter)
843 .with_writer(std::io::stderr)
844 .init();
845 }
846 debug!("[main] ers starting, pid={}", std::process::id());
847
848 let args: Vec<String> = std::env::args().collect();
849
850 if args.iter().any(|s| s == "--help" || s == "-h") {
851 print_help();
852 return;
853 }
854
855 if args.get(1).is_some_and(|s| s == "--list") {
856 list_windows();
857 return;
858 }
859
860 let border_width: f64 = flag_value(&args, &["--width", "-w"])
861 .and_then(|v| v.parse().ok())
862 .unwrap_or(4.0);
863
864 let radius: f64 = flag_value(&args, &["--radius", "-r"])
865 .and_then(|v| v.parse().ok())
866 .unwrap_or(10.0);
867
868 let active_color = flag_value(&args, &["--color", "-c"])
869 .and_then(parse_color)
870 .unwrap_or((0.32, 0.58, 0.89, 1.0));
871
872 let inactive_color = flag_value(&args, &["--inactive", "-i"])
873 .and_then(parse_color)
874 .unwrap_or((0.35, 0.35, 0.35, 0.8));
875
876 let active_only = args.iter().any(|s| s == "--active-only");
877
878 // Initialize NSApplication on the main thread before we touch any
879 // AppKit APIs. NSWindow operations (used by nswindow_overlay) all
880 // require a main-thread context.
881 let mtm = nswindow_overlay::init_application();
882 nswindow_overlay::log_screens(mtm);
883 register_display_hotplug_callback();
884
885 let cid = unsafe { SLSMainConnectionID() };
886 let own_pid = unsafe {
887 let mut pid: i32 = 0;
888 pid_for_task(mach_task_self(), &mut pid);
889 pid
890 };
891
892 // Event channel
893 let (tx, rx) = mpsc::channel();
894 events::init(tx, own_pid);
895 events::register(cid);
896 setup_event_port(cid);
897
898 // Discover and create borders
899 let mut borders = BorderMap::new(cid, own_pid, border_width, mtm);
900 borders.radius = radius;
901 borders.active_color = active_color;
902 borders.inactive_color = inactive_color;
903 borders.active_only = active_only;
904
905 if let Some(target) = args.get(1).and_then(|s| s.parse::<u32>().ok()) {
906 borders.add_batch(target);
907 } else {
908 let wids = discover_windows(cid, own_pid);
909 for &wid in &wids {
910 borders.add_batch(wid);
911 }
912 }
913
914 borders.subscribe_all();
915
916 borders.update_focus();
917
918 if borders.active_only {
919 let focused = borders.focused_wid;
920 let to_hide: Vec<u32> = borders
921 .overlays
922 .keys()
923 .filter(|&&wid| wid != focused)
924 .copied()
925 .collect();
926 for wid in to_hide {
927 borders.hide(wid);
928 }
929 }
930
931 debug!("{} overlays tracked", borders.overlays.len());
932
933 SIGNAL_STOP_REQUESTED.store(false, Ordering::Relaxed);
934
935 // Background watcher translates the signal-safe atomic into a normal
936 // CoreFoundation shutdown request on a Rust thread.
937 let running = Arc::new(AtomicBool::new(true));
938 let signal_watcher = std::thread::spawn(|| {
939 use std::time::Duration;
940
941 while !SIGNAL_STOP_REQUESTED.load(Ordering::Relaxed) {
942 std::thread::sleep(Duration::from_millis(10));
943 }
944
945 unsafe {
946 let run_loop = CFRunLoopGetMain();
947 CFRunLoopStop(run_loop);
948 CFRunLoopWakeUp(run_loop);
949 }
950 });
951
952 unsafe {
953 libc::signal(
954 libc::SIGINT,
955 handle_sigint as *const () as libc::sighandler_t,
956 );
957 libc::signal(
958 libc::SIGTERM,
959 handle_sigint as *const () as libc::sighandler_t,
960 );
961 }
962
963 // Process events on the main thread via a CFRunLoopTimer.
964 // BorderMap holds Retained<NSWindow> handles, which are
965 // !Send/!Sync — AppKit calls must originate from the main thread.
966 // Stash state in thread_local for the C callback to access.
967 MAIN_STATE.with(|cell| {
968 *cell.borrow_mut() = Some(MainState {
969 borders,
970 rx,
971 pending: HashMap::new(),
972 batch_events: Vec::new(),
973 batch_first_seen: None,
974 });
975 });
976
977 unsafe {
978 let mut ctx = CFRunLoopTimerContext {
979 version: 0,
980 info: ptr::null_mut(),
981 retain: None,
982 release: None,
983 copy_description: None,
984 };
985 let timer = CFRunLoopTimerCreate(
986 ptr::null(),
987 CFAbsoluteTimeGetCurrent() + 0.05,
988 0.016,
989 0u64,
990 0i64,
991 timer_callback,
992 &mut ctx,
993 );
994 CFRunLoopAddTimer(CFRunLoopGetMain(), timer, kCFRunLoopDefaultMode);
995 }
996
997 unsafe { CFRunLoopRun() };
998
999 // Drop everything on the main thread (NSWindow.close in Drop).
1000 MAIN_STATE.with(|cell| cell.borrow_mut().take());
1001
1002 SIGNAL_STOP_REQUESTED.store(true, Ordering::Relaxed);
1003 let _ = signal_watcher.join();
1004 drop(running);
1005 }
1006
1007 struct MainState {
1008 borders: BorderMap,
1009 rx: mpsc::Receiver<Event>,
1010 pending: HashMap<u32, std::time::Instant>,
1011 batch_events: Vec<Event>,
1012 batch_first_seen: Option<std::time::Instant>,
1013 }
1014
1015 thread_local! {
1016 static MAIN_STATE: std::cell::RefCell<Option<MainState>> = const { std::cell::RefCell::new(None) };
1017 }
1018
1019 extern "C" fn timer_callback(_timer: *mut std::ffi::c_void, _info: *mut std::ffi::c_void) {
1020 use std::time::{Duration, Instant};
1021 use std::sync::atomic::AtomicUsize;
1022 static TICK_COUNT: AtomicUsize = AtomicUsize::new(0);
1023 let tick = TICK_COUNT.fetch_add(1, Ordering::Relaxed);
1024 if tick == 0 {
1025 debug!("[timer] first fire — main-thread event loop is alive");
1026 } else if tick % 600 == 0 {
1027 // every ~10s if interval is 16ms
1028 debug!("[timer] tick {}", tick);
1029 }
1030 MAIN_STATE.with(|cell| {
1031 let mut state_opt = cell.borrow_mut();
1032 let s = match state_opt.as_mut() {
1033 Some(s) => s,
1034 None => return,
1035 };
1036 let mut received = 0usize;
1037 loop {
1038 match s.rx.try_recv() {
1039 Ok(e) => {
1040 if s.batch_events.is_empty() {
1041 s.batch_first_seen = Some(Instant::now());
1042 }
1043 s.batch_events.push(e);
1044 received += 1;
1045 }
1046 Err(mpsc::TryRecvError::Empty) => break,
1047 Err(mpsc::TryRecvError::Disconnected) => break,
1048 }
1049 }
1050 if received > 0 {
1051 debug!(
1052 "[timer] received {} new events; batch size now {}",
1053 received,
1054 s.batch_events.len()
1055 );
1056 }
1057 // Process the accumulated batch after a 16ms quiet window
1058 // (matches the old bg-thread behavior where it slept 16ms after
1059 // the first event then drained). Events keep arriving, the batch
1060 // grows; once 16ms passes without new events we flush.
1061 let should_flush = s.batch_first_seen.is_some_and(|t| {
1062 t.elapsed() >= Duration::from_millis(16) && received == 0
1063 }) || s
1064 .batch_first_seen
1065 .is_some_and(|t| t.elapsed() >= Duration::from_millis(120));
1066 if should_flush {
1067 let events = std::mem::take(&mut s.batch_events);
1068 s.batch_first_seen = None;
1069 debug!("[timer] processing batch of {}", events.len());
1070 process_event_batch(&mut s.borders, &mut s.pending, events);
1071 } else {
1072 // Even with no events, poll focus periodically so a missed
1073 // FrontChange notification doesn't strand the active border.
1074 // Cheap operation when focus hasn't changed.
1075 s.borders.update_focus();
1076 // Once per second, reconcile tracked overlays against
1077 // current SLS state. Catches missed Close/Destroy events
1078 // that would otherwise leave a dead border on screen.
1079 if tick % 60 == 0 && tick > 0 {
1080 let removed = s.borders.reconcile_tracked();
1081 if removed {
1082 debug!("[timer] periodic reconcile removed stale overlays");
1083 }
1084 // Cheap: re-applies just the CAShapeLayer frame/path
1085 // for every overlay. Recovers from layer state that
1086 // macOS resets during display sleep/wake without
1087 // touching the NSWindow frame.
1088 s.borders.refresh_all_layers();
1089 }
1090 }
1091 });
1092 }
1093
1094 fn process_event_batch(
1095 borders: &mut BorderMap,
1096 pending: &mut HashMap<u32, std::time::Instant>,
1097 events: Vec<Event>,
1098 ) {
1099 use std::collections::HashSet;
1100 use std::time::{Duration, Instant};
1101
1102 let mut moved: HashSet<u32> = HashSet::new();
1103 let mut resized: HashSet<u32> = HashSet::new();
1104 let mut destroyed: HashSet<u32> = HashSet::new();
1105 let mut needs_resubscribe = false;
1106
1107 for event in events {
1108 match event {
1109 Event::Move(wid) => {
1110 if !borders.is_overlay(wid) {
1111 moved.insert(wid);
1112 }
1113 }
1114 Event::Resize(wid) => {
1115 if !borders.is_overlay(wid) {
1116 resized.insert(wid);
1117 }
1118 }
1119 Event::Close(wid) | Event::Destroy(wid) => {
1120 if !borders.is_overlay(wid) {
1121 debug!("[event] Close/Destroy target_wid={}", wid);
1122 destroyed.insert(wid);
1123 pending.remove(&wid);
1124 }
1125 }
1126 Event::Create(wid) => {
1127 if !borders.is_overlay(wid) {
1128 pending.entry(wid).or_insert_with(Instant::now);
1129 borders.subscribe_target(wid);
1130 }
1131 }
1132 Event::Hide(wid) => borders.hide(wid),
1133 Event::Unhide(wid) => {
1134 if !borders.is_overlay(wid) {
1135 if !borders.overlays.contains_key(&wid) {
1136 borders.add_fresh(wid);
1137 borders.subscribe_target(wid);
1138 }
1139 if !borders.active_only || wid == borders.focused_wid {
1140 borders.unhide(wid);
1141 }
1142 }
1143 }
1144 Event::FrontChange => {
1145 needs_resubscribe = true;
1146 }
1147 Event::SpaceChange => {
1148 needs_resubscribe = true;
1149 }
1150 }
1151 }
1152
1153 for wid in &destroyed {
1154 borders.remove(*wid);
1155 }
1156
1157 let now = Instant::now();
1158 let ready: Vec<u32> = pending
1159 .iter()
1160 .filter(|(wid, seen_at)| {
1161 !destroyed.contains(wid) && now.duration_since(**seen_at) >= Duration::from_millis(100)
1162 })
1163 .map(|(wid, _)| *wid)
1164 .collect();
1165
1166 let mut bounds_map: Vec<(u32, CGRect)> = Vec::new();
1167 for &wid in &ready {
1168 unsafe {
1169 let mut b = CGRect::default();
1170 SLSGetWindowBounds(borders.main_cid, wid, &mut b);
1171 bounds_map.push((wid, b));
1172 }
1173 }
1174
1175 let mut skip: std::collections::HashSet<u32> = HashSet::new();
1176 for i in 0..bounds_map.len() {
1177 for j in (i + 1)..bounds_map.len() {
1178 let (wid_a, a) = &bounds_map[i];
1179 let (wid_b, b) = &bounds_map[j];
1180 if let Some(preference) = surface_preference(*a, *b) {
1181 match preference {
1182 SurfacePreference::KeepExisting => {
1183 skip.insert(*wid_b);
1184 }
1185 SurfacePreference::ReplaceExisting => {
1186 skip.insert(*wid_a);
1187 }
1188 }
1189 }
1190 }
1191 }
1192
1193 for &wid in &ready {
1194 pending.remove(&wid);
1195 if !skip.contains(&wid) {
1196 borders.add_fresh(wid);
1197 if borders.active_only && wid != borders.focused_wid {
1198 borders.hide(wid);
1199 }
1200 needs_resubscribe = true;
1201 }
1202 }
1203
1204 for wid in &moved {
1205 if !resized.contains(wid) && !ready.contains(wid) && borders.sync_overlay(*wid) {
1206 needs_resubscribe = true;
1207 }
1208 }
1209
1210 for wid in &resized {
1211 if !ready.contains(wid)
1212 && borders.overlays.contains_key(wid)
1213 && borders.sync_overlay(*wid)
1214 {
1215 needs_resubscribe = true;
1216 }
1217 }
1218
1219 if needs_resubscribe {
1220 borders.discover_untracked();
1221 }
1222
1223 needs_resubscribe |= borders.reconcile_tracked();
1224
1225 borders.update_focus();
1226
1227 if needs_resubscribe || !destroyed.is_empty() {
1228 borders.subscribe_all();
1229 }
1230
1231 borders.enforce_active_only();
1232 }
1233
1234 /// Re-log the screen layout when the display configuration changes
1235 /// (monitor plug/unplug, resolution change). The callback also nudges
1236 /// every tracked overlay to re-fetch its bounds so any cached cocoa Y
1237 /// computed against the old primary height gets refreshed.
1238 unsafe extern "C" fn display_reconfig_callback(
1239 display_id: u32,
1240 flags: u32,
1241 _user_info: *mut std::ffi::c_void,
1242 ) {
1243 debug!(display_id, flags, "[hotplug] CGDisplay reconfiguration");
1244 if let Some(mtm) = objc2::MainThreadMarker::new() {
1245 nswindow_overlay::log_screens(mtm);
1246 }
1247 MAIN_STATE.with(|cell| {
1248 if let Some(s) = cell.borrow_mut().as_mut() {
1249 s.borders.reconcile_all_force();
1250 s.borders.refresh_all_layers();
1251 }
1252 });
1253 }
1254
1255 fn register_display_hotplug_callback() {
1256 unsafe {
1257 let rc = CGDisplayRegisterReconfigurationCallback(
1258 Some(display_reconfig_callback),
1259 std::ptr::null_mut(),
1260 );
1261 debug!("[hotplug] register CGDisplayReconfiguration rc={}", rc);
1262 }
1263 }
1264
1265 fn setup_event_port(cid: CGSConnectionID) {
1266 unsafe {
1267 let mut port: u32 = 0;
1268 if SLSGetEventPort(cid, &mut port) != kCGErrorSuccess {
1269 return;
1270 }
1271 let cf_port = CFMachPortCreateWithPort(
1272 ptr::null(),
1273 port,
1274 drain_events as *const _,
1275 ptr::null(),
1276 false,
1277 );
1278 if cf_port.is_null() {
1279 return;
1280 }
1281 _CFMachPortSetOptions(cf_port, 0x40);
1282 let source = CFMachPortCreateRunLoopSource(ptr::null(), cf_port, 0);
1283 if !source.is_null() {
1284 CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
1285 CFRelease(source);
1286 }
1287 CFRelease(cf_port);
1288 }
1289 }
1290
1291 unsafe extern "C" fn drain_events(
1292 _: CFMachPortRef,
1293 _: *mut std::ffi::c_void,
1294 _: i64,
1295 _: *mut std::ffi::c_void,
1296 ) {
1297 unsafe {
1298 let cid = SLSMainConnectionID();
1299 let mut ev = SLEventCreateNextEvent(cid);
1300 while !ev.is_null() {
1301 CFRelease(ev as CFTypeRef);
1302 ev = SLEventCreateNextEvent(cid);
1303 }
1304 }
1305 }
1306
1307 /// Look up an overlay window in CGWindowListCopyWindowInfo and dump the
1308 /// keys that the screenshot picker / ScreenCaptureKit care about. Lets
1309 /// us tell whether SLSSetWindowSharingState(0) propagates through to
1310 /// the CG window list (the layer SCWindow filters on) or stops at SLS.
1311 fn probe_cg_window_info(target_wid: u32) {
1312 unsafe {
1313 let list = CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID);
1314 if list.is_null() {
1315 debug!("[probe_cg_window_info] wid={target_wid} list is null");
1316 return;
1317 }
1318 let count = CFArrayGetCount(list);
1319 let wid_key = cf_string_from_static(c"kCGWindowNumber");
1320 let sharing_key = cf_string_from_static(c"kCGWindowSharingState");
1321 let layer_key = cf_string_from_static(c"kCGWindowLayer");
1322 let alpha_key = cf_string_from_static(c"kCGWindowAlpha");
1323 let on_screen_key = cf_string_from_static(c"kCGWindowIsOnscreen");
1324 let mut found = false;
1325
1326 for i in 0..count {
1327 let dict = CFArrayGetValueAtIndex(list, i);
1328 if dict.is_null() {
1329 continue;
1330 }
1331 let mut v: CFTypeRef = ptr::null();
1332 let mut wid: u32 = 0;
1333 if CFDictionaryGetValueIfPresent(dict, wid_key as CFTypeRef, &mut v) {
1334 CFNumberGetValue(v, kCFNumberSInt32Type, &mut wid as *mut _ as *mut _);
1335 }
1336 if wid != target_wid {
1337 continue;
1338 }
1339
1340 let mut sharing: i32 = -1;
1341 if CFDictionaryGetValueIfPresent(dict, sharing_key as CFTypeRef, &mut v) {
1342 CFNumberGetValue(v, kCFNumberSInt32Type, &mut sharing as *mut _ as *mut _);
1343 }
1344 let mut layer: i32 = i32::MIN;
1345 if CFDictionaryGetValueIfPresent(dict, layer_key as CFTypeRef, &mut v) {
1346 CFNumberGetValue(v, kCFNumberSInt32Type, &mut layer as *mut _ as *mut _);
1347 }
1348 let mut alpha: f64 = -1.0;
1349 if CFDictionaryGetValueIfPresent(dict, alpha_key as CFTypeRef, &mut v) {
1350 CFNumberGetValue(v, 13 /* kCFNumberDoubleType */, &mut alpha as *mut _ as *mut _);
1351 }
1352 let on_screen_present =
1353 CFDictionaryGetValueIfPresent(dict, on_screen_key as CFTypeRef, &mut v);
1354
1355 debug!(
1356 "[probe_cg_window_info] wid={target_wid} cg_sharing={sharing} layer={layer} alpha={alpha:.3} on_screen_present={on_screen_present}"
1357 );
1358 found = true;
1359 break;
1360 }
1361
1362 if !found {
1363 debug!("[probe_cg_window_info] wid={target_wid} NOT FOUND in CGWindowList");
1364 }
1365 CFRelease(list as CFTypeRef);
1366 }
1367 }
1368
1369 fn discover_windows(cid: CGSConnectionID, own_pid: i32) -> Vec<u32> {
1370 unsafe {
1371 let list = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
1372 if list.is_null() {
1373 return vec![];
1374 }
1375
1376 let count = CFArrayGetCount(list);
1377 let wid_key = cf_string_from_static(c"kCGWindowNumber");
1378 let pid_key = cf_string_from_static(c"kCGWindowOwnerPID");
1379 let layer_key = cf_string_from_static(c"kCGWindowLayer");
1380
1381 let mut wids = Vec::new();
1382 for i in 0..count {
1383 let dict = CFArrayGetValueAtIndex(list, i);
1384 if dict.is_null() {
1385 continue;
1386 }
1387
1388 let mut v: CFTypeRef = ptr::null();
1389 let mut wid: u32 = 0;
1390 if CFDictionaryGetValueIfPresent(dict, wid_key as CFTypeRef, &mut v) {
1391 CFNumberGetValue(v, kCFNumberSInt32Type, &mut wid as *mut _ as *mut _);
1392 }
1393 if wid == 0 {
1394 continue;
1395 }
1396
1397 let mut pid: i32 = 0;
1398 if CFDictionaryGetValueIfPresent(dict, pid_key as CFTypeRef, &mut v) {
1399 CFNumberGetValue(v, kCFNumberSInt32Type, &mut pid as *mut _ as *mut _);
1400 }
1401 if pid == own_pid {
1402 continue;
1403 }
1404
1405 if !is_suitable_window(cid, wid) {
1406 continue;
1407 }
1408
1409 let mut layer: i32 = -1;
1410 if CFDictionaryGetValueIfPresent(dict, layer_key as CFTypeRef, &mut v) {
1411 CFNumberGetValue(v, kCFNumberSInt32Type, &mut layer as *mut _ as *mut _);
1412 }
1413 if layer != 0 {
1414 continue;
1415 }
1416
1417 wids.push(wid);
1418 }
1419
1420 CFRelease(wid_key as CFTypeRef);
1421 CFRelease(pid_key as CFTypeRef);
1422 CFRelease(layer_key as CFTypeRef);
1423 CFRelease(list);
1424 wids
1425 }
1426 }
1427
1428 /// Draw a border ring into an existing CGContext, clearing first.
1429 fn draw_border(
1430 ctx: CGContextRef,
1431 width: f64,
1432 height: f64,
1433 border_width: f64,
1434 radius: f64,
1435 color: (f64, f64, f64, f64),
1436 ) {
1437 unsafe {
1438 let full = CGRect::new(0.0, 0.0, width, height);
1439 CGContextClearRect(ctx, full);
1440
1441 let bw = border_width;
1442 let stroke_rect = CGRect::new(bw / 2.0, bw / 2.0, width - bw, height - bw);
1443 let max_r = (stroke_rect.size.width.min(stroke_rect.size.height) / 2.0).max(0.0);
1444 let r = radius.min(max_r);
1445
1446 CGContextSetRGBStrokeColor(ctx, color.0, color.1, color.2, color.3);
1447 CGContextSetLineWidth(ctx, bw);
1448 let path = CGPathCreateWithRoundedRect(stroke_rect, r, r, ptr::null());
1449 if !path.is_null() {
1450 CGContextAddPath(ctx, path);
1451 CGContextStrokePath(ctx);
1452 CGPathRelease(path);
1453 }
1454 CGContextFlush(ctx);
1455 }
1456 }
1457
1458 fn create_overlay(
1459 cid: CGSConnectionID,
1460 target_wid: u32,
1461 border_width: f64,
1462 radius: f64,
1463 color: (f64, f64, f64, f64),
1464 ) -> Option<(CGSConnectionID, u32, CGRect, f64)> {
1465 unsafe {
1466 let mut bounds = CGRect::default();
1467 let rc = SLSGetWindowBounds(cid, target_wid, &mut bounds);
1468 if rc != kCGErrorSuccess {
1469 debug!("[create_overlay] SLSGetWindowBounds failed for wid={target_wid} rc={rc}");
1470 return None;
1471 }
1472 if !is_trackable_window(bounds, border_width) {
1473 debug!(
1474 "[create_overlay] wid={target_wid} too small: {}x{}",
1475 bounds.size.width, bounds.size.height
1476 );
1477 return None;
1478 }
1479
1480 let bw = border_width;
1481 let ow = bounds.size.width + 2.0 * bw;
1482 let oh = bounds.size.height + 2.0 * bw;
1483 let ox = bounds.origin.x - bw;
1484 let oy = bounds.origin.y - bw;
1485 let scale = display_scale_for_bounds(bounds);
1486
1487 let frame = CGRect::new(0.0, 0.0, ow, oh);
1488 let mut region: CFTypeRef = ptr::null();
1489 CGSNewRegionWithRect(&frame, &mut region);
1490 if region.is_null() {
1491 debug!("[create_overlay] CGSNewRegionWithRect failed for wid={target_wid}");
1492 return None;
1493 }
1494
1495 // Empty hit-test shape: an SLS window with an empty opaque_shape
1496 // is click-through at the compositor level (no input region).
1497 let empty = CGRect::new(0.0, 0.0, 0.0, 0.0);
1498 let mut empty_region: CFTypeRef = ptr::null();
1499 if CGSNewRegionWithRect(&empty, &mut empty_region) != kCGErrorSuccess
1500 || empty_region.is_null()
1501 {
1502 debug!("[create_overlay] CGSNewRegionWithRect (empty) failed for wid={target_wid}");
1503 CFRelease(region);
1504 return None;
1505 }
1506
1507 // Create the overlay via SLSNewWindowWithOpaqueShapeAndContext
1508 // and bake tag bit 1 (click-through) and tag bit 9 (screenshot
1509 // exclusion) into the window at birth. Tahoe classifies windows
1510 // for capture/picker based on tags observed at creation time;
1511 // post-creation tag mutation lands too late and the picker keeps
1512 // including the overlay. Mirrors the JankyBorders unmanaged
1513 // create path (.refs/JankyBorders/src/misc/window.h:239).
1514 // options 13|(1<<18): documentation-window | ignores-cycle.
1515 let mut tags: u64 = (1u64 << 1) | (1u64 << 9);
1516 let mut wid: u32 = 0;
1517 SLSNewWindowWithOpaqueShapeAndContext(
1518 cid,
1519 2,
1520 region,
1521 empty_region,
1522 13 | (1 << 18),
1523 &mut tags as *mut u64,
1524 ox as f32,
1525 oy as f32,
1526 64,
1527 &mut wid,
1528 ptr::null_mut(),
1529 );
1530 CFRelease(region);
1531 CFRelease(empty_region);
1532 if wid == 0 {
1533 debug!(
1534 "[create_overlay] SLSNewWindowWithOpaqueShapeAndContext returned 0 for target={target_wid} cid={cid}"
1535 );
1536 return None;
1537 }
1538
1539 debug!(
1540 "[create_overlay] created overlay wid={wid} for target={target_wid} scale={scale:.2} color=({:.2},{:.2},{:.2},{:.2})",
1541 color.0, color.1, color.2, color.3
1542 );
1543
1544 if let Some(metadata) = query_window_metadata(cid, wid) {
1545 debug!(
1546 "[create_overlay] post-create overlay wid={wid} tags={:#x} attributes={:#x} parent={}",
1547 metadata.tags, metadata.attributes, metadata.parent_wid
1548 );
1549 } else {
1550 debug!("[create_overlay] post-create wid={wid} metadata query failed");
1551 }
1552
1553 SLSSetWindowSharingState(cid, wid, 0);
1554 let mut sharing_state: u32 = u32::MAX;
1555 let rc = SLSGetWindowSharingState(cid, wid, &mut sharing_state);
1556 debug!("[create_overlay] sharing_state wid={wid} get_rc={rc} sls_state={sharing_state}");
1557
1558 // Probe what CGWindowListCopyWindowInfo (which the screenshot
1559 // picker / SCWindow use) reports for our overlay. If
1560 // kCGWindowSharingState comes back != 0 here, then SLS-side
1561 // sharing state is not propagated to the CG window list and
1562 // we'll need a different exclusion mechanism.
1563 probe_cg_window_info(wid);
1564
1565 SLSSetWindowResolution(cid, wid, scale);
1566 SLSSetWindowOpacity(cid, wid, false);
1567 SLSSetWindowLevel(cid, wid, 0);
1568 SLSOrderWindow(cid, wid, 1, target_wid);
1569
1570 // Draw border (point coordinates)
1571 let ctx = SLWindowContextCreate(cid, wid, ptr::null());
1572 if ctx.is_null() {
1573 debug!("[create_overlay] SLWindowContextCreate returned null for overlay wid={wid}");
1574 SLSReleaseWindow(cid, wid);
1575 return None;
1576 }
1577
1578 draw_border(ctx, ow, oh, bw, radius, color);
1579 SLSFlushWindowContentRegion(cid, wid, ptr::null());
1580 CGContextRelease(ctx);
1581
1582 // Post-creation tag mutation matching JankyBorders' pattern
1583 // at .refs/JankyBorders/src/misc/window.h:266-267. Verified
1584 // ineffective on Tahoe: tags set on windows owned by a
1585 // SLSNewConnection-created cid do NOT propagate to the global
1586 // server-side tag store, regardless of which cid issues the
1587 // SLSSetWindowTags call (tested both fresh and main cid).
1588 // The screencaptureui picker queries via _CGSGetWindowTags
1589 // from its own connection (otool confirmed) and reads 0x0 for
1590 // our overlays. Kept here aligned with JB so the diff is
1591 // legible; the actual fix requires creating overlays on the
1592 // process main cid (conflicts with the per-border fresh-cid
1593 // requirement in ers/CLAUDE.md) or backing them with NSWindow.
1594 let mut set_tags: u64 = (1u64 << 1) | (1u64 << 9);
1595 let mut clear_tags: u64 = 0;
1596 SLSSetWindowTags(cid, wid, &mut set_tags as *mut u64, 64);
1597 SLSClearWindowTags(cid, wid, &mut clear_tags as *mut u64, 64);
1598
1599 Some((cid, wid, bounds, scale))
1600 }
1601 }
1602
1603 fn list_windows() {
1604 let cid = unsafe { SLSMainConnectionID() };
1605 unsafe {
1606 let list = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
1607 if list.is_null() {
1608 return;
1609 }
1610 let count = CFArrayGetCount(list);
1611 let wid_key = cf_string_from_static(c"kCGWindowNumber");
1612 let layer_key = cf_string_from_static(c"kCGWindowLayer");
1613
1614 eprintln!(
1615 "{:>6} {:>8} {:>8} {:>6} {:>6}",
1616 "wid", "x", "y", "w", "h"
1617 );
1618 for i in 0..count {
1619 let dict = CFArrayGetValueAtIndex(list, i);
1620 if dict.is_null() {
1621 continue;
1622 }
1623
1624 let mut v: CFTypeRef = ptr::null();
1625 let mut wid: u32 = 0;
1626 let mut layer: i32 = -1;
1627 if CFDictionaryGetValueIfPresent(dict, wid_key as CFTypeRef, &mut v) {
1628 CFNumberGetValue(v, kCFNumberSInt32Type, &mut wid as *mut _ as *mut _);
1629 }
1630 if CFDictionaryGetValueIfPresent(dict, layer_key as CFTypeRef, &mut v) {
1631 CFNumberGetValue(v, kCFNumberSInt32Type, &mut layer as *mut _ as *mut _);
1632 }
1633 if layer != 0 || wid == 0 {
1634 continue;
1635 }
1636
1637 let mut bounds = CGRect::default();
1638 SLSGetWindowBounds(cid, wid, &mut bounds);
1639 eprintln!(
1640 "{wid:>6} {:>8.0} {:>8.0} {:>6.0} {:>6.0}",
1641 bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height
1642 );
1643 }
1644 CFRelease(wid_key as CFTypeRef);
1645 CFRelease(layer_key as CFTypeRef);
1646 CFRelease(list);
1647 }
1648 }
1649
1650 #[cfg(test)]
1651 mod tests {
1652 use super::{
1653 CGRect, SurfacePreference, WindowMetadata, intersection_area, is_same_window_surface,
1654 is_suitable_window_metadata, is_trackable_window, surface_preference,
1655 };
1656
1657 #[test]
1658 fn same_surface_detects_contained_strip() {
1659 let outer = CGRect::new(100.0, 100.0, 1200.0, 900.0);
1660 let strip = CGRect::new(114.0, 105.0, 1160.0, 140.0);
1661 assert!(is_same_window_surface(outer, strip));
1662 }
1663
1664 #[test]
1665 fn different_windows_are_not_treated_as_one_surface() {
1666 let a = CGRect::new(100.0, 100.0, 1200.0, 900.0);
1667 let b = CGRect::new(300.0, 300.0, 1160.0, 140.0);
1668 assert!(!is_same_window_surface(a, b));
1669 }
1670
1671 #[test]
1672 fn intersection_area_is_zero_without_overlap() {
1673 let a = CGRect::new(100.0, 100.0, 200.0, 200.0);
1674 let b = CGRect::new(400.0, 400.0, 200.0, 200.0);
1675 assert_eq!(intersection_area(a, b), 0.0);
1676 }
1677
1678 #[test]
1679 fn same_surface_prefers_larger_bounds() {
1680 let strip = CGRect::new(114.0, 105.0, 1160.0, 140.0);
1681 let outer = CGRect::new(100.0, 100.0, 1200.0, 900.0);
1682 assert_eq!(
1683 surface_preference(strip, outer),
1684 Some(SurfacePreference::ReplaceExisting)
1685 );
1686 }
1687
1688 #[test]
1689 fn small_windows_remain_trackable() {
1690 let small = CGRect::new(100.0, 100.0, 12.0, 18.0);
1691 assert!(is_trackable_window(small, 4.0));
1692 }
1693
1694 #[test]
1695 fn suitable_window_metadata_matches_document_windows() {
1696 let metadata = WindowMetadata {
1697 parent_wid: 0,
1698 tags: super::WINDOW_TAG_DOCUMENT,
1699 attributes: super::WINDOW_ATTRIBUTE_REAL,
1700 };
1701 assert!(is_suitable_window_metadata(metadata));
1702 }
1703
1704 #[test]
1705 fn attached_windows_are_not_suitable_targets() {
1706 let metadata = WindowMetadata {
1707 parent_wid: 7,
1708 tags: super::WINDOW_TAG_DOCUMENT | super::WINDOW_TAG_ATTACHED,
1709 attributes: super::WINDOW_ATTRIBUTE_REAL,
1710 };
1711 assert!(!is_suitable_window_metadata(metadata));
1712 }
1713 }
1714