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