gardesk/ers / 32a4ba8

Browse files

use CGWindowList for focus detection, avoid SLS space query poisoning

replace SLSManagedDisplayGetCurrentSpace-based focus detection with
CGWindowListCopyWindowInfo front-to-back ordering. SLS display/space
queries poison SLSNewWindow globally even on fresh connections.

also use main_cid for overlay creation — fresh connections produce
invisible windows on Tahoe.
Authored by espadonne
SHA
32a4ba833f7111e177600e92e25f6937b9eedeb1
Parents
7db56ea
Tree
54330a5

2 changed files

StatusFile+-
A CLAUDE.md 48 0
M src/main.rs 119 85
CLAUDE.mdadded
@@ -0,0 +1,48 @@
1
+# CLAUDE.md
2
+
3
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+## What this is
6
+
7
+ers is a macOS window border renderer for the tarmac window manager. It draws colored overlay borders around application windows using private SkyLight framework APIs. macOS Tahoe only.
8
+
9
+## Build & Run
10
+
11
+```bash
12
+cargo build              # debug build
13
+cargo build --release    # release build
14
+cargo run                # run (borders all windows)
15
+cargo run -- --list      # list on-screen windows with IDs and bounds
16
+cargo run -- -w 6.0      # custom border width (default: 4.0)
17
+cargo run -- <wid>       # border a specific window ID
18
+```
19
+
20
+No tests — verification is visual. Use `RUST_LOG=debug` for tracing output.
21
+
22
+## Architecture
23
+
24
+Three source files (~1300 lines total):
25
+
26
+- **`src/main.rs`** — `BorderMap` struct manages overlay lifecycle. Event loop batches window events with 150ms debounce, then processes creates/destroys/moves/resizes. Focus detection recolors borders (active=white, inactive=gray). Main thread runs CFRunLoop; events dispatch from a background thread via mpsc.
27
+
28
+- **`src/skylight.rs`** — FFI bindings for private macOS frameworks: SkyLight (CGS window creation, event registration), CoreGraphics (drawing), CoreFoundation (collections, RunLoop). All types `repr(C)`.
29
+
30
+- **`src/events.rs`** — Event enum and SLSRegisterNotifyProc callbacks. Filters out the renderer's own windows to prevent feedback loops. Sends events over mpsc channel.
31
+
32
+- **`build.rs`** — Links SkyLight (private framework), CoreGraphics, CoreFoundation.
33
+
34
+## Critical macOS Tahoe constraints
35
+
36
+These are hard-won discoveries from debugging undocumented APIs:
37
+
38
+1. **SLSCopyManagedDisplaySpaces poisons SLSNewWindow** — calling it on ANY connection corrupts window creation on ALL connections. Use `CGWindowListCopyWindowInfo` instead.
39
+
40
+2. **Fresh SLS connection per border** — each overlay needs its own `SLSNewConnection`. Required for reliable rendering.
41
+
42
+3. **Create windows at final size** — the 1×1-then-reshape pattern breaks on Tahoe. Create at correct position/size immediately.
43
+
44
+4. **Draw before setting tags** — CGContext from `SLWindowContextCreate` must be used to draw BEFORE setting window tags/shadow. Re-obtaining context later for redraws uses the border's own connection.
45
+
46
+## Dependencies
47
+
48
+Only `serde`/`serde_json` (JSON parsing of window info) and `tracing`/`tracing-subscriber` (logging). No external runtime dependencies beyond macOS frameworks.
src/main.rsmodified
@@ -56,38 +56,13 @@ impl BorderMap {
56
         }
56
         }
57
     }
57
     }
58
 
58
 
59
-    /// Add border (event mode, fresh connection).
59
+    /// Add border (event mode). Uses main_cid — fresh connections create
60
+    /// invisible windows on Tahoe.
60
     fn add_fresh(&mut self, target_wid: u32) {
61
     fn add_fresh(&mut self, target_wid: u32) {
61
         if self.overlays.contains_key(&target_wid) { return; }
62
         if self.overlays.contains_key(&target_wid) { return; }
62
-
63
-        // Filter: must be visible, owned by another process, not tiny
64
-        unsafe {
65
-            let mut shown = false;
66
-            SLSWindowIsOrderedIn(self.main_cid, target_wid, &mut shown);
67
-            if !shown { return; }
68
-
69
-            let mut wid_cid: CGSConnectionID = 0;
70
-            SLSGetWindowOwner(self.main_cid, target_wid, &mut wid_cid);
71
-            let mut pid: i32 = 0;
72
-            SLSConnectionGetPID(wid_cid, &mut pid);
73
-            if pid == self.own_pid { return; }
74
-
75
-            let mut bounds = CGRect::default();
76
-            SLSGetWindowBounds(self.main_cid, target_wid, &mut bounds);
77
-            if bounds.size.width < 50.0 || bounds.size.height < 50.0 { return; }
78
-        }
79
-
80
-        let fresh = unsafe {
81
-            let mut c: CGSConnectionID = 0;
82
-            SLSNewConnection(0, &mut c);
83
-            c
84
-        };
85
-        if fresh == 0 { return; }
86
         let color = self.color_for(target_wid);
63
         let color = self.color_for(target_wid);
87
-        if let Some((cid, wid)) = create_overlay(fresh, target_wid, self.border_width, color) {
64
+        if let Some((cid, wid)) = create_overlay(self.main_cid, target_wid, self.border_width, color) {
88
             self.overlays.insert(target_wid, Overlay { cid, wid });
65
             self.overlays.insert(target_wid, Overlay { cid, wid });
89
-        } else {
90
-            unsafe { SLSReleaseConnection(fresh); }
91
         }
66
         }
92
     }
67
     }
93
 
68
 
@@ -176,74 +151,116 @@ impl BorderMap {
176
         }
151
         }
177
     }
152
     }
178
 
153
 
179
-    /// Detect focused window and update borders if focus changed.
154
+    /// Redraw an existing overlay with a new color (no destroy/recreate).
180
-    fn update_focus(&mut self) {
155
+    fn redraw(&self, target_wid: u32) {
181
-        let front = get_front_window(self.main_cid);
156
+        if let Some(overlay) = self.overlays.get(&target_wid) {
182
-        if front == 0 || front == self.focused_wid { return; }
157
+            unsafe {
158
+                let mut bounds = CGRect::default();
159
+                if SLSGetWindowBounds(overlay.cid, target_wid, &mut bounds) != kCGErrorSuccess {
160
+                    return;
161
+                }
162
+                let bw = self.border_width;
163
+                let ow = bounds.size.width + 2.0 * bw;
164
+                let oh = bounds.size.height + 2.0 * bw;
165
+
166
+                let ctx = SLWindowContextCreate(overlay.cid, overlay.wid, ptr::null());
167
+                if ctx.is_null() { return; }
168
+
169
+                let full = CGRect::new(0.0, 0.0, ow, oh);
170
+                CGContextClearRect(ctx, full);
171
+
172
+                let color = self.color_for(target_wid);
173
+                let stroke_rect = CGRect::new(bw / 2.0, bw / 2.0, ow - bw, oh - bw);
174
+                let radius = 10.0_f64;
175
+                let max_r = (stroke_rect.size.width.min(stroke_rect.size.height) / 2.0).max(0.0);
176
+                let r = radius.min(max_r);
177
+
178
+                CGContextSetRGBStrokeColor(ctx, color.0, color.1, color.2, color.3);
179
+                CGContextSetLineWidth(ctx, bw);
180
+                let path = CGPathCreateWithRoundedRect(stroke_rect, r, r, ptr::null());
181
+                if !path.is_null() {
182
+                    CGContextAddPath(ctx, path);
183
+                    CGContextStrokePath(ctx);
184
+                    CGPathRelease(path);
185
+                }
186
+
187
+                CGContextFlush(ctx);
188
+                SLSFlushWindowContentRegion(overlay.cid, overlay.wid, ptr::null());
189
+                CGContextRelease(ctx);
190
+            }
191
+        }
192
+    }
193
+
194
+    /// Detect focused window and update border colors if focus changed.
195
+    /// Returns true if focus changed (callers should resubscribe).
196
+    fn update_focus(&mut self) -> bool {
197
+        let front = get_front_window(self.own_pid);
198
+        if front == 0 || front == self.focused_wid { return false; }
183
 
199
 
184
         let old = self.focused_wid;
200
         let old = self.focused_wid;
185
         self.focused_wid = front;
201
         self.focused_wid = front;
186
-        let tracked = self.overlays.contains_key(&front);
202
+        eprintln!("[focus] {} -> {} (tracked={})", old, front, self.overlays.contains_key(&front));
187
-        eprintln!("[focus] {old} -> {front} (tracked={tracked})");
188
 
203
 
189
-        // Recreate both old and new focused borders with correct colors
204
+        // Recreate overlays with new colors — re-obtaining a CGContext
205
+        // for an existing window is unreliable on Tahoe
190
         if self.overlays.contains_key(&old) {
206
         if self.overlays.contains_key(&old) {
191
             self.recreate(old);
207
             self.recreate(old);
192
         }
208
         }
193
         if self.overlays.contains_key(&front) {
209
         if self.overlays.contains_key(&front) {
194
             self.recreate(front);
210
             self.recreate(front);
195
         }
211
         }
212
+        true
196
     }
213
     }
197
 }
214
 }
198
 
215
 
199
-/// Get the front (focused) window ID using a fresh connection
216
+/// Get the front (focused) window ID using CGWindowListCopyWindowInfo.
200
-/// (space queries poison the main cid).
217
+/// Avoids all SLS display/space queries which poison SLSNewWindow globally.
201
-fn get_front_window(_main_cid: CGSConnectionID) -> u32 {
218
+fn get_front_window(own_pid: i32) -> u32 {
202
     unsafe {
219
     unsafe {
203
-        let mut cid: CGSConnectionID = 0;
220
+        let list = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
204
-        SLSNewConnection(0, &mut cid);
221
+        if list.is_null() { return 0; }
205
-        if cid == 0 { return 0; }
206
-        let mut psn = ProcessSerialNumber { high: 0, low: 0 };
207
-        _SLPSGetFrontProcess(&mut psn);
208
-        let mut target_cid: CGSConnectionID = 0;
209
-        SLSGetConnectionIDForPSN(cid, &mut psn, &mut target_cid);
210
-
211
-        // Get active space
212
-        let uuid = SLSCopyActiveMenuBarDisplayIdentifier(cid);
213
-        if uuid.is_null() { return 0; }
214
-        let active_sid = SLSManagedDisplayGetCurrentSpace(cid, uuid);
215
-        CFRelease(uuid as CFTypeRef);
216
-        if active_sid == 0 { return 0; }
217
-
218
-        let set_tags: u64 = 1;
219
-        let clear_tags: u64 = 0;
220
-        let space_list = cfarray_of_cfnumbers(
221
-            &active_sid as *const _ as *const _,
222
-            std::mem::size_of::<u64>(),
223
-            1,
224
-            kCFNumberSInt64Type,
225
-        );
226
-
227
-        let window_list = SLSCopyWindowsWithOptionsAndTags(
228
-            cid, target_cid as u32, space_list, 0x2, &set_tags, &clear_tags,
229
-        );
230
 
222
 
231
-        let mut wid: u32 = 0;
223
+        let count = CFArrayGetCount(list);
232
-        if !window_list.is_null() {
224
+        let wid_key = CFStringCreateWithCString(ptr::null(), b"kCGWindowNumber\0".as_ptr(), kCFStringEncodingUTF8);
233
-            let count = CFArrayGetCount(window_list);
225
+        let pid_key = CFStringCreateWithCString(ptr::null(), b"kCGWindowOwnerPID\0".as_ptr(), kCFStringEncodingUTF8);
234
-            if count > 0 {
226
+        let layer_key = CFStringCreateWithCString(ptr::null(), b"kCGWindowLayer\0".as_ptr(), kCFStringEncodingUTF8);
235
-                // First window in the list is the frontmost
227
+
236
-                let mut v: CFTypeRef = ptr::null();
228
+        // CGWindowListCopyWindowInfo returns windows in front-to-back order.
237
-                let first = CFArrayGetValueAtIndex(window_list, 0);
229
+        // First layer-0 window not owned by us is the focused window.
238
-                if !first.is_null() {
230
+        let mut front_wid: u32 = 0;
239
-                    CFNumberGetValue(first, kCFNumberSInt32Type, &mut wid as *mut _ as *mut _);
231
+        for i in 0..count {
240
-                }
232
+            let dict = CFArrayGetValueAtIndex(list, i);
233
+            if dict.is_null() { continue; }
234
+
235
+            let mut v: CFTypeRef = ptr::null();
236
+
237
+            let mut layer: i32 = -1;
238
+            if CFDictionaryGetValueIfPresent(dict, layer_key as CFTypeRef, &mut v) {
239
+                CFNumberGetValue(v, kCFNumberSInt32Type, &mut layer as *mut _ as *mut _);
240
+            }
241
+            if layer != 0 { continue; }
242
+
243
+            let mut pid: i32 = 0;
244
+            if CFDictionaryGetValueIfPresent(dict, pid_key as CFTypeRef, &mut v) {
245
+                CFNumberGetValue(v, kCFNumberSInt32Type, &mut pid as *mut _ as *mut _);
246
+            }
247
+            if pid == own_pid { continue; }
248
+
249
+            let mut wid: u32 = 0;
250
+            if CFDictionaryGetValueIfPresent(dict, wid_key as CFTypeRef, &mut v) {
251
+                CFNumberGetValue(v, kCFNumberSInt32Type, &mut wid as *mut _ as *mut _);
252
+            }
253
+            if wid != 0 {
254
+                front_wid = wid;
255
+                break;
241
             }
256
             }
242
-            CFRelease(window_list);
243
         }
257
         }
244
-        CFRelease(space_list);
258
+
245
-        SLSReleaseConnection(cid);
259
+        CFRelease(wid_key as CFTypeRef);
246
-        wid
260
+        CFRelease(pid_key as CFTypeRef);
261
+        CFRelease(layer_key as CFTypeRef);
262
+        CFRelease(list);
263
+        front_wid
247
     }
264
     }
248
 }
265
 }
249
 
266
 
@@ -290,7 +307,9 @@ fn main() {
290
 
307
 
291
     borders.subscribe_all();
308
     borders.subscribe_all();
292
 
309
 
293
-    borders.update_focus();
310
+    if borders.update_focus() {
311
+        borders.subscribe_all();
312
+    }
294
 
313
 
295
     eprintln!("{} overlays tracked", borders.overlays.len());
314
     eprintln!("{} overlays tracked", borders.overlays.len());
296
 
315
 
@@ -414,8 +433,10 @@ fn main() {
414
                 }
433
                 }
415
             }
434
             }
416
 
435
 
417
-            // Update focus (detects front window, recolors if changed)
436
+            // Update focus (detects front window, recreates borders if changed)
418
-            borders.update_focus();
437
+            if borders.update_focus() {
438
+                needs_resubscribe = true;
439
+            }
419
 
440
 
420
             // Re-subscribe ALL tracked windows (SLSRequestNotificationsForWindows replaces, not appends)
441
             // Re-subscribe ALL tracked windows (SLSRequestNotificationsForWindows replaces, not appends)
421
             if needs_resubscribe || !destroyed.is_empty() {
442
             if needs_resubscribe || !destroyed.is_empty() {
@@ -507,10 +528,13 @@ fn create_overlay(
507
 ) -> Option<(CGSConnectionID, u32)> {
528
 ) -> Option<(CGSConnectionID, u32)> {
508
     unsafe {
529
     unsafe {
509
         let mut bounds = CGRect::default();
530
         let mut bounds = CGRect::default();
510
-        if SLSGetWindowBounds(cid, target_wid, &mut bounds) != kCGErrorSuccess {
531
+        let rc = SLSGetWindowBounds(cid, target_wid, &mut bounds);
532
+        if rc != kCGErrorSuccess {
533
+            eprintln!("[create_overlay] SLSGetWindowBounds failed for wid={target_wid} rc={rc}");
511
             return None;
534
             return None;
512
         }
535
         }
513
         if bounds.size.width < 10.0 || bounds.size.height < 10.0 {
536
         if bounds.size.width < 10.0 || bounds.size.height < 10.0 {
537
+            eprintln!("[create_overlay] wid={target_wid} too small: {}x{}", bounds.size.width, bounds.size.height);
514
             return None;
538
             return None;
515
         }
539
         }
516
 
540
 
@@ -523,12 +547,21 @@ fn create_overlay(
523
         let frame = CGRect::new(0.0, 0.0, ow, oh);
547
         let frame = CGRect::new(0.0, 0.0, ow, oh);
524
         let mut region: CFTypeRef = ptr::null();
548
         let mut region: CFTypeRef = ptr::null();
525
         CGSNewRegionWithRect(&frame, &mut region);
549
         CGSNewRegionWithRect(&frame, &mut region);
526
-        if region.is_null() { return None; }
550
+        if region.is_null() {
551
+            eprintln!("[create_overlay] CGSNewRegionWithRect failed for wid={target_wid}");
552
+            return None;
553
+        }
527
 
554
 
528
         let mut wid: u32 = 0;
555
         let mut wid: u32 = 0;
529
         SLSNewWindow(cid, 2, ox as f32, oy as f32, region, &mut wid);
556
         SLSNewWindow(cid, 2, ox as f32, oy as f32, region, &mut wid);
530
         CFRelease(region);
557
         CFRelease(region);
531
-        if wid == 0 { return None; }
558
+        if wid == 0 {
559
+            eprintln!("[create_overlay] SLSNewWindow returned 0 for target={target_wid} cid={cid}");
560
+            return None;
561
+        }
562
+
563
+        eprintln!("[create_overlay] created overlay wid={wid} for target={target_wid} color=({:.2},{:.2},{:.2},{:.2})",
564
+            color.0, color.1, color.2, color.3);
532
 
565
 
533
         SLSSetWindowResolution(cid, wid, 2.0);
566
         SLSSetWindowResolution(cid, wid, 2.0);
534
         SLSSetWindowOpacity(cid, wid, false);
567
         SLSSetWindowOpacity(cid, wid, false);
@@ -538,6 +571,7 @@ fn create_overlay(
538
         // Draw border (point coordinates)
571
         // Draw border (point coordinates)
539
         let ctx = SLWindowContextCreate(cid, wid, ptr::null());
572
         let ctx = SLWindowContextCreate(cid, wid, ptr::null());
540
         if ctx.is_null() {
573
         if ctx.is_null() {
574
+            eprintln!("[create_overlay] SLWindowContextCreate returned null for overlay wid={wid}");
541
             SLSReleaseWindow(cid, wid);
575
             SLSReleaseWindow(cid, wid);
542
             return None;
576
             return None;
543
         }
577
         }