Commit 0e045fa
authored
perf(capture): route window capture through ScreenCaptureKit to cut SkyLight leaks (#613)
* perf(capture): route window capture through ScreenCaptureKit to cut SkyLight leaks
The `leaks` tool against a fresh Thaw process on macOS 26.4.1 attributed 250 `NSMutableDictionary` instances (~42 KB of ~44 KB total leaked bytes) to a single allocation site: `CGRectCreateDictionaryRepresentation` inside the private `SLSWindowListCreateImageFromArrayProxying`, called by every `SLWindowListCreateImageFromArray` invocation. The dictionary is allocated with retain count 1 and never released. The path was being exercised by every Thaw window-capture call site: `MenuBarItemImageCache.individualCapture`, `compositeCapture`, and `refreshImages`, plus the three color-sampler call sites in `MenuBarManager.updateAverageColorInfo`, `IceBarColorManager.updateWindowImage`, and `MenuBarSearchModel.updateAverageColorInfo`. The leak lives inside Apple's SkyLight framework, so there is no way to release the dictionary from Swift; the only levers we have are to stop calling SkyLight or to call it less.
This change introduces SCK-backed async equivalents `Bridging.captureWindowsImageSCK` (using `SCScreenshotManager.captureImage(contentFilter:configuration:)` with an `SCContentFilter(display:including:)` filter) and corresponding `ScreenCapture.captureWindowsAsync` / `captureWindowAsync` wrappers, then migrates every capture call site whose target windows fit within display bounds. The menu-bar item cache (`compositeCapture`, `individualCapture`) becomes async and routes through SCK; the three color samplers keep their public synchronous signatures but wrap the capture in a `Task` so the SCK call runs off the main actor and the resulting state mutation hops back to the owning `@MainActor` class via the captured `self`.
The `refreshImages` path cannot move to SCK: it captures items in the hidden and always-hidden sections, which Thaw positions at large negative x past the display's left edge. Both SCK filter shapes reject those windows on macOS 26: `SCContentFilter(display:including:)` returns error -3812 (sourceRect outside display bounds) and `SCContentFilter(desktopIndependentWindow:)` returns error -3811 (stream start failure). To shrink that residual floor, `refreshImages` gains an opt-in `viaSCK` parameter and `runLiveRefreshLoop` is restructured: tick-global guards (`lastMoveOperationOccurred`, `isResettingLayout`) hoist out of the per-section loop, the `.visible` section refreshes individually via the leak-free SCK path, and `.hidden` plus `.alwaysHidden` items batch into a single SkyLight call per tick. A full all-sections refresh now leaks one dictionary per tick instead of three.
On a fresh `MallocStackLogging=1` launch with the menu bar idle, hidden + always-hidden sections expanded, and `Settings → Menu Bar Layout` opened, `leaks` reports 122 leaks / 10.8 KB total: 53 `NSMutableDictionary` instances (all from the batched SkyLight `refreshImages` call) and 19 framework CGRegion leaks from AppKit's `_regionForOpaqueDescendants` display-cycle path. Versus the original baseline of 507 leaks / 44 KB that is a 79% reduction in dictionary leak count and a 76% reduction in leaked bytes. The remaining 53 instances are the irreducible Apple-framework floor until either `SLSWindowListCreateImageFromArrayProxying` stops dropping the dictionary or SCK adds support for capturing windows positioned outside any `SCDisplay.frame`. Apple Feedback drafts for the SkyLight leak, the AppKit CGRegion leak, and a separate `AppIntents` `NSProgress` retain cycle observed when materializing `SetFocusFilterIntent` metadata are tracked separately and will be filed via Feedback Assistant.
* refactor: code suggestions
Tightens the SCK migration's edges in response to follow-up review. Five small fixes across four files; no leak-rate change, all behavioural correctness.
`Bridging.captureWindowsImageSCK` now requires an exact match between the requested `windowIDs` and the `SCWindow`s resolved from `SCShareableContent`. The previous `!scWindows.isEmpty` guard let a partial subset through, which is unsafe for cache composites (the post-capture crop math assumes every requested window's bounds is covered) and for the color samplers (a missing `menuBarWindow` would silently produce a wallpaper-only strip whose `averageColor` no longer represents the menu bar). On mismatch we now log the requested vs matched counts plus the missing IDs and return `nil` so callers fall back cleanly to SkyLight or skip the tick.
`IceBarColorManager` adds a generation-token pattern to `updateWindowImage`. The function becomes `async`, bumps `windowImageGeneration` before suspending, and writes `windowImage` after the SCK await only if the generation still matches. A new `clearWindowImage()` helper bumps + nils together so `stopPeriodicRefresh` and the notification sink can invalidate any capture in flight from before the clear; the previous fire-and-forget shape let a late completion undo `self.windowImage = nil`. Four sinks and `updateAllProperties` are now wrapped in `Task { await updateWindowImage(...); updateColorInfo(...) }` so the post-refresh color read sees the freshly captured image instead of the previous cycle's leftover, which closes the visible ~5 s color lag users would see after a theme change or first IceBar open while the previous async capture was still in flight. `updateAllProperties` keeps its synchronous signature so `IceBar.show()` doesn't ripple async upstream.
`MenuBarManager.updateAverageColorInfo` is split into a sync fire-and-forget wrapper and an awaitable `updateAverageColorInfoAsync()` that uses `withTaskGroup` for concurrent per-screen captures and collects results back on the enclosing `@MainActor` so the `averageColors` / `averageColorInfo` writes are complete before `await` returns. `captureAdaptiveColorWithRetry` is rewritten as a single async retry loop that awaits each capture before reading `averageColors`; the previous fire-and-forget call meant the post-call read saw stale state and burned all ten retries even when the first capture would have succeeded. The wake-poll Timer sink wraps its body in a `Task` that awaits the async variant before reading `after = averageColors`, so the stabilization detector (`wakePollDidChange`, `wakePollStableCount`, `wakePollPrevColors`) sees real frame-to-frame changes instead of always-stale snapshots that previously prevented it from ever marking the wake as settled within the 10 s window.
`MenuBarSearchModel.updateAverageColorInfo` gets the same generation-token treatment as `IceBarColorManager`. A new `clearAverageColorInfo()` helper bumps the token and nils `averageColorInfo`; the panel-visibility-off sink and the screen-params sink now route through it, and the capture Task captures the generation before suspending and skips its write if the token has advanced. Closes the previously-possible races where a late capture undid an intentional clear or where out-of-order completions from rapid screen switches let a stale color overwrite a fresher one. The public function stays sync because no callers in this file read `averageColorInfo` immediately after.
* refactor: code suggestions
Two follow-up tightenings in `Bridging.captureWindowsImageSCK`. No leak-rate change, both behavioural correctness for edge cases the existing callers happen not to hit today.
Display selection now requires a single `SCDisplay` whose `frame` fully contains BOTH the effective capture bounds AND the union of all selected windows. The previous chain (`intersects(effectiveBounds)` → `intersects(unionBounds)` → `displays.first`) could pick a display that only partially overlapped the windows, and `SCContentFilter(display:including:)` silently clips at `display.frame`, so anything outside the chosen display was lost from the capture without an error. Tightening to `contains` returns a clean `nil` for cross-display spans and frame-reporting quirks so callers fall back (cache paths excluded-item retry) or skip the tick (`refreshImages viaSCK: true`) instead of feeding clipped pixels to downstream crops and `averageColor`. The diagLog warning now reports both bounds so the rejection is debuggable.
`configuration.ignoreShadowsDisplay` no longer treats `options.isEmpty` as if `.boundsIgnoreFraming` were set. The legacy `SLWindowListCreateImageFromArray` API treats an empty option set as the default capture (framing and shadows preserved); only the explicit `.boundsIgnoreFraming` flag suppresses them. The previous `|| options.isEmpty` clause would have given an unexpected frameless capture to any future caller that relied on the function's `options: CGWindowImageOption = []` default. All current callers pass either `.boundsIgnoreFraming` (cache paths) or `.nominalResolution` (color samplers), so observable behaviour is unchanged today.1 parent e5d3556 commit 0e045fa
6 files changed
Lines changed: 420 additions & 128 deletions
File tree
- Thaw
- MenuBar
- IceBar
- MenuBarItems
- Search
- Utilities
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| 10 | + | |
10 | 11 | | |
11 | 12 | | |
12 | 13 | | |
| |||
560 | 561 | | |
561 | 562 | | |
562 | 563 | | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
20 | 26 | | |
21 | 27 | | |
22 | 28 | | |
| |||
42 | 48 | | |
43 | 49 | | |
44 | 50 | | |
45 | | - | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
46 | 55 | | |
47 | 56 | | |
48 | 57 | | |
| |||
82 | 91 | | |
83 | 92 | | |
84 | 93 | | |
85 | | - | |
| 94 | + | |
| 95 | + | |
86 | 96 | | |
87 | 97 | | |
88 | 98 | | |
| |||
91 | 101 | | |
92 | 102 | | |
93 | 103 | | |
94 | | - | |
95 | | - | |
96 | | - | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
97 | 111 | | |
98 | 112 | | |
99 | 113 | | |
| |||
106 | 120 | | |
107 | 121 | | |
108 | 122 | | |
109 | | - | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
110 | 127 | | |
111 | | - | |
112 | | - | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
113 | 134 | | |
114 | 135 | | |
115 | 136 | | |
| |||
137 | 158 | | |
138 | 159 | | |
139 | 160 | | |
140 | | - | |
141 | | - | |
142 | | - | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
143 | 168 | | |
144 | 169 | | |
145 | 170 | | |
| |||
148 | 173 | | |
149 | 174 | | |
150 | 175 | | |
151 | | - | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
152 | 185 | | |
153 | 186 | | |
154 | 187 | | |
155 | | - | |
| 188 | + | |
156 | 189 | | |
157 | 190 | | |
158 | 191 | | |
| |||
163 | 196 | | |
164 | 197 | | |
165 | 198 | | |
166 | | - | |
167 | | - | |
168 | | - | |
169 | | - | |
170 | | - | |
171 | | - | |
172 | | - | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
173 | 208 | | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
174 | 215 | | |
175 | 216 | | |
176 | 217 | | |
| |||
201 | 242 | | |
202 | 243 | | |
203 | 244 | | |
204 | | - | |
205 | | - | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
206 | 254 | | |
207 | 255 | | |
0 commit comments