From bf95f4a5421456cb8122394f2f37623dd503d09c Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Mon, 12 Oct 2026 17:56:36 +0200 Subject: [PATCH 1/7] fix(overview): timeout hung ScreenCaptureKit calls - Wrap SCShareableContent and SCScreenshotManager calls with 5-second timeout - Prevents hung native calls from holding the capture limiter slot indefinitely - Abandons stalled captures so subsequent overview sessions can proceed - Add test for timeout behavior and recovery - Add agent verification docs for overview, workspaces, scrolling, and service lifecycle --- .agents/skills/verify-defi/SKILL.md | 97 +++++++++++++++++++ .agents/skills/verify-defi/doctor.sh | 15 +++ .agents/skills/verify-defi/features/README.md | 10 ++ .../skills/verify-defi/features/overview.md | 30 ++++++ .../features/scrolling-navigation.md | 28 ++++++ .../verify-defi/features/service-lifecycle.md | 29 ++++++ .../skills/verify-defi/features/workspaces.md | 30 ++++++ .../DefiMacOS/OverviewPreviewCapture.swift | 50 ++++++++-- .../DefiMacOSTests/OverviewPreviewTests.swift | 12 +++ 9 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 .agents/skills/verify-defi/SKILL.md create mode 100755 .agents/skills/verify-defi/doctor.sh create mode 100644 .agents/skills/verify-defi/features/README.md create mode 100644 .agents/skills/verify-defi/features/overview.md create mode 100644 .agents/skills/verify-defi/features/scrolling-navigation.md create mode 100644 .agents/skills/verify-defi/features/service-lifecycle.md create mode 100644 .agents/skills/verify-defi/features/workspaces.md diff --git a/.agents/skills/verify-defi/SKILL.md b/.agents/skills/verify-defi/SKILL.md new file mode 100644 index 00000000..ea58c411 --- /dev/null +++ b/.agents/skills/verify-defi/SKILL.md @@ -0,0 +1,97 @@ +--- +name: verify-defi +description: Launch, drive and prove behavior of Defi, the macOS scrolling-columns window manager (defi-daemon plus the defi CLI). Use after any change to runtime, layout, overview, focus, parking or config to get evidence from the real daemon, not just unit tests. +--- + +# Verify Defi + +Defi is a per-user macOS daemon (`defi-daemon`) controlled by the `defi` CLI over a Unix socket. There is no web or simulator surface. The real surface is the installed daemon on the user's desktop, driven by `defi` commands and read back through `defi status`, `defi trace` and `defi list-workspaces --json`. Visual behavior needs Computer Use. + +Rules that override convenience (from `AGENTS.md`): + +- Exactly one `defi-daemon` per user session. Never `open -n`, never start a second binary by hand. +- You cannot run an isolated second instance. The instance lock is per user. Drive the one installed daemon, or stop it and replace it through the scripts below. +- Installation and desktop tests share one per-user desktop reservation. Scripts acquire it and exit 75 when busy. +- Restore the user's workspace and confirm one daemon before you finish. + +## Which tier to use + +| Change | Tier | Command | +| --- | --- | --- | +| Docs only | none | check the diff | +| Code, tests, build config | local | `python3 script/verify.py local` | +| Daemon wiring, platform integration, desktop-visible | stage + desktop | `python3 script/verify.py local --stage` then `python3 script/verify.py desktop dist/verification/ --wait [--filter DesktopE2ETests/]` | +| Animation, focus, parking, hotkeys, Dock/Command-Tab, overview visuals | all of the above plus live drive | sections below | + +`verify.py local` never installs and forces `DEFI_E2E=0`. `desktop` installs the exact staged bundle, runs XCTest, and restores the session store. Zero executed or skipped tests count as incomplete, not pass. + +## Launch + +The installed app is the unit under test. To put your change into it: + +```sh +python3 script/verify.py local --stage # prints dist/verification/ +python3 script/verify.py desktop dist/verification/ --wait +``` + +Signing must keep the installed designated requirement or macOS re-prompts for Accessibility and Screen Recording. If the identity is missing, set `DEFI_CODESIGN_IDENTITY` in the ignored `.env.local`. Do not choose another certificate. + +Restart the installed build with `defi service restart`. Ready means `defi status` prints `running session=active` and `pgrep -x defi-daemon | wc -l` is 1. + +## Doctor + +```sh +.agents/skills/verify-defi/doctor.sh +``` + +Read-only. Prints daemon count, binary path and start time, session state, overview permission, preview counters, and which `defi` is first on `PATH`. Run it first, and again whenever behavior looks wrong. A daemon that has been up for many hours and reports `overviewReceivedPreviews=0` after an overview open is a wedged daemon, not a passing one. + +## Drive + +Commands are plain CLI calls. Useful, real ones: + +```sh +defi toggle-overview # open or close the overview +defi focus-column left|right|first|last +defi focus-workspace up|down +defi focus-workspace-name dev # names come from list-workspaces +defi focus-workspace-position 2 +defi move-column left|right|first|last +defi cycle-width +defi --monitor 2 focus-workspace-name web +defi list-workspaces --json +defi status # one line of key=value counters +defi trace # recent timestamped runtime events +``` + +Note `defi workspace 2` in the usage text is rejected by the parser. Use `focus-workspace-position`. + +Parse `defi status` by splitting on spaces and grepping `key=`. Do not scrape by column. + +For real human timing, hotkeys and mouse behavior, hold the desktop reservation in an interactive shell (`python3 script/desktop_lock.py --wait bash`) and use Computer Use. Synthetic command bursts alone do not certify native focus or animation quality. + +## Evidence + +Write evidence under `dist/verification/evidence-/` (ignored by git, survives cleanup): + +- `before.status`, `after.status` from `defi status` +- `trace.log` from `defi trace` covering the action +- `workspaces-before.json`, `workspaces-after.json` from `list-workspaces --json` +- screenshots from Computer Use for visual claims +- `/usr/bin/log show --last 2m --info --predicate 'subsystem == "com.quentin.defi"'` for logger output. In zsh `log` is a builtin, so call `/usr/bin/log`. + +Proof standard: drive the user path (CLI command or real hotkey), capture the state before and after, and check the side effect in `status` counters or `list-workspaces`. A passing unit test is not proof of a desktop-visible change. For anything visual, correlate the screenshot with `status` and `trace`. + +## Cleanup + +1. Close any overview you opened (`defi toggle-overview` only if `overview=open`). +2. Restore the initial workspace you recorded in `before.status` with `defi focus-workspace-name `. +3. Restore any config file you edited from your backup. Never leave `~/.config/defi/config.toml` changed. +4. Exit the `desktop_lock.py` shell if you opened one. +5. Run the doctor and confirm exactly one daemon. + +Keep the evidence directory. + +## Feature map + +See `features/README.md`. A proof that drives one entry point is incomplete when the map lists others. diff --git a/.agents/skills/verify-defi/doctor.sh b/.agents/skills/verify-defi/doctor.sh new file mode 100755 index 00000000..cd573f7e --- /dev/null +++ b/.agents/skills/verify-defi/doctor.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Read-only health check for the running Defi daemon. +set -u +echo "daemons: $(pgrep -u "$(id -u)" -x defi-daemon | wc -l | tr -d ' ')" +for pid in $(pgrep -u "$(id -u)" -x defi-daemon); do + ps -o pid=,lstart=,etime=,command= -p "$pid" +done +echo "defi on PATH: $(command -v defi || echo missing)" +if ! status="$(defi status 2>&1)"; then + echo "status: unreachable ($status)" + exit 1 +fi +echo "$status" | cut -d' ' -f1-3 +echo "$status" | tr ' ' '\n' | grep -E '^(session|workspace|overview|overviewPermission|overviewPreviews|overviewReceivedPreviews|overviewPreviewFailures|overviewCaptures|overviewSurface|hotkeys|observationFailures)=' | tr '\n' ' ' +echo diff --git a/.agents/skills/verify-defi/features/README.md b/.agents/skills/verify-defi/features/README.md new file mode 100644 index 00000000..9cacfc25 --- /dev/null +++ b/.agents/skills/verify-defi/features/README.md @@ -0,0 +1,10 @@ +# Defi feature map + +Maintained verification source for `verify-defi`. One file per user-facing feature. + +| Feature | File | Primary proof | +| --- | --- | --- | +| Overview with window previews | [overview.md](overview.md) | `overviewReceivedPreviews > 0` and visible captures | +| Scrolling column navigation | [scrolling-navigation.md](scrolling-navigation.md) | focus and scroll converge, no rollback in trace | +| Virtual workspaces and parking | [workspaces.md](workspaces.md) | active workspace changes, inactive windows parked | +| Service lifecycle and single instance | [service-lifecycle.md](service-lifecycle.md) | restart keeps one daemon and the session store | diff --git a/.agents/skills/verify-defi/features/overview.md b/.agents/skills/verify-defi/features/overview.md new file mode 100644 index 00000000..cff28e01 --- /dev/null +++ b/.agents/skills/verify-defi/features/overview.md @@ -0,0 +1,30 @@ +# Overview with window previews + +## Sub-features + +- Open and close with `toggle-overview` or the configured hotkey. +- Window previews captured through ScreenCaptureKit, with title and icon. +- Desktop background per monitor. +- Keyboard navigation and window moves inside the overview. +- Experimental surface transitions (`overview.experimental_surface_transitions`). + +## How to get to it (user POV) + +Press the overview hotkey, or run `defi toggle-overview`. Requires Screen Recording permission and `overview.window_previews = true`. + +## Driving it with the defi CLI + +```sh +defi toggle-overview; sleep 3 +defi status | tr ' ' '\n' | grep -E '^overview(=|Permission|Captures|Previews|ReceivedPreviews|PreviewFailures|FirstPreviewMs)' +defi toggle-overview +``` + +Pass: `overview=open`, `overviewPermission=granted`, `overviewReceivedPreviews` greater than 0, `overviewFirstPreviewMs` a number. For visuals take a Computer Use screenshot while open and confirm cards show window content and the desktop background, not flat grey. + +## Gotchas + +- Status is published on change. Read it a few seconds after opening; `overviewCaptures` is in-flight work and returns to 0. +- `overviewReceivedPreviews=0` with `overviewPreviewFailures=0` and no ScreenCaptureKit activity in the log means captures never started, usually a wedged daemon. `defi service restart` recovers it. Check `/usr/bin/log` for `overview-capture` errors. +- The overview can close on its own when focus changes; poll `overview=` rather than assuming it stayed open. +- Another covering test: `DesktopE2ETests/testOverviewShowsBothMonitorsBeforeLoadingPreviews`. diff --git a/.agents/skills/verify-defi/features/scrolling-navigation.md b/.agents/skills/verify-defi/features/scrolling-navigation.md new file mode 100644 index 00000000..f5583289 --- /dev/null +++ b/.agents/skills/verify-defi/features/scrolling-navigation.md @@ -0,0 +1,28 @@ +# Scrolling column navigation + +## Sub-features + +- Focus left/right/first/last column. +- Move column, cycle and maximize width. +- Speculative, latest-wins animation with position-only writes. + +## How to get to it (user POV) + +Configured hotkeys, or the CLI. + +## Driving it with the defi CLI + +```sh +defi status > before.status +for i in 1 2 3; do defi focus-column right; done; defi focus-column left +sleep 1; defi status > after.status; defi trace > trace.log +``` + +Pass: `focused=` changes as expected, `animating=false`, `drift=0[]`, `droppedFrames=0`, `focusPending=false`. In `trace.log` no repeated corrections or rollbacks after the last command. + +## Gotchas + +- With one column on the workspace nothing moves; check `list-workspaces --json` window counts first. +- Slow apps (`slowAppDetails`) legitimately settle later. Wait for `axPending=false`. +- Visual smoothness needs Computer Use with human-paced input, not a tight loop. +- Covering tests: `testHorizontalAnimationFrameWritesPositionWithoutSize`, `testRapidNativeFocusKeepsLatestIntent`. diff --git a/.agents/skills/verify-defi/features/service-lifecycle.md b/.agents/skills/verify-defi/features/service-lifecycle.md new file mode 100644 index 00000000..a564b9b3 --- /dev/null +++ b/.agents/skills/verify-defi/features/service-lifecycle.md @@ -0,0 +1,29 @@ +# Service lifecycle and single instance + +## Sub-features + +- `defi service enable|disable|start|stop|restart|status`. +- Per-user instance lock before the IPC socket. +- Session store persistence across restart. + +## How to get to it (user POV) + +Login item, or `defi service ...`. + +## Driving it with the defi CLI + +```sh +defi list-workspaces --json > before.json +defi service restart; sleep 5 +pgrep -u "$(id -u)" -x defi-daemon | wc -l +defi list-workspaces --json > after.json +diff <(jq -S .monitors before.json) <(jq -S .monitors after.json) && echo same +``` + +Pass: one daemon, `status` reachable, workspace identity and order preserved. + +## Gotchas + +- Use `defi service restart`, never `open -n`. +- Restart interrupts any in-flight animation; do not run it during a Computer Use session you care about. +- Replacing the bundle needs the desktop reservation and an unchanged designated requirement. diff --git a/.agents/skills/verify-defi/features/workspaces.md b/.agents/skills/verify-defi/features/workspaces.md new file mode 100644 index 00000000..afa3099d --- /dev/null +++ b/.agents/skills/verify-defi/features/workspaces.md @@ -0,0 +1,30 @@ +# Virtual workspaces and parking + +## Sub-features + +- Named persistent workspaces from `[workspaces].names`, dynamic ordinary ones. +- Per-monitor workspace stacks. +- Inactive workspace windows parked offscreen through topology and Accessibility. +- Move column or window to a workspace or monitor. + +## How to get to it (user POV) + +Workspace hotkeys, the menu bar item, or the CLI. + +## Driving it with the defi CLI + +```sh +defi list-workspaces --json > workspaces-before.json +defi focus-workspace-name web; sleep 1 +defi status | tr ' ' '\n' | grep -E '^(workspace|hidden|visibility|drift)=' +defi focus-workspace-name dev # restore the recorded initial workspace +``` + +Pass: `workspace=` equals the target, `hidden=` equals the parked window count, `drift=0[]`, and the JSON `active` flag moved. + +## Gotchas + +- Names depend on the user's config; read them from `list-workspaces --json` first. +- `--monitor ` is 1-based display index, while `id` in JSON is the monitor ID. +- Always restore the initial workspace. +- Covering tests: `testFocusedWindowRemainsResolvableWhileParkedOffscreen`, `testUnhiddenOnePixelStripAnchorConvergesWithRealWindowFrame`. diff --git a/Sources/DefiMacOS/OverviewPreviewCapture.swift b/Sources/DefiMacOS/OverviewPreviewCapture.swift index e83566dc..d316f405 100644 --- a/Sources/DefiMacOS/OverviewPreviewCapture.swift +++ b/Sources/DefiMacOS/OverviewPreviewCapture.swift @@ -161,6 +161,37 @@ let overviewPreviewMaximumConcurrentCaptures = 2 let overviewCaptureLimiter = OverviewCaptureLimiter(limit: 1) private let overviewCaptureLogger = Logger(subsystem: "com.quentin.defi", category: "overview-capture") +struct OverviewCaptureTimeoutError: Error {} + +private struct UncheckedSendable: @unchecked Sendable { let value: Value } + +// A ScreenCaptureKit call that never returns would hold the single limiter slot until +// restart. Abandon it after the deadline so the next overview session can capture. +@MainActor +func withOverviewCaptureTimeout( + seconds: Double = 5, + _ operation: @escaping @MainActor () async throws -> Value +) async throws -> Value { + let outcome: UncheckedSendable> = await withCheckedContinuation { continuation in + let finished = OSAllocatedUnfairLock(initialState: false) + @Sendable func finish(_ result: Result) { + let first = finished.withLock { done -> Bool in + defer { done = true } + return !done + } + if first { continuation.resume(returning: UncheckedSendable(value: result)) } + } + Task { + do { finish(.success(try await operation())) } catch { finish(.failure(error)) } + } + Task { + try? await Task.sleep(for: .seconds(seconds)) + finish(.failure(OverviewCaptureTimeoutError())) + } + } + return try outcome.value.get() +} + actor OverviewCaptureLimiter { private var available: Int private var waiters: [(UUID, CheckedContinuation)] = [] @@ -333,7 +364,9 @@ func captureOverviewImages( let content: SCShareableContent do { try Task.checkCancellation() - content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: false) + content = try await withOverviewCaptureTimeout { + try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: false) + } } catch { await limiter.release(error: error is CancellationError ? nil : error as NSError) throw error @@ -363,8 +396,10 @@ func captureOverviewImages( break } do { - let image = try await SCScreenshotManager.captureImage( - contentFilter: filter, configuration: configuration) + let image = try await withOverviewCaptureTimeout { + try await SCScreenshotManager.captureImage( + contentFilter: filter, configuration: configuration) + } await limiter.release() desktops[request.monitorID] = image } catch { @@ -450,10 +485,11 @@ private final class OverviewScreenCaptureBatch { } let image: CGImage do { - image = try await SCScreenshotManager.captureImage( - contentFilter: SCContentFilter(desktopIndependentWindow: window), - configuration: configuration - ) + let filter = SCContentFilter(desktopIndependentWindow: window) + image = try await withOverviewCaptureTimeout { + try await SCScreenshotManager.captureImage( + contentFilter: filter, configuration: configuration) + } } catch { await limiter.release(error: error as NSError) throw error diff --git a/Tests/DefiMacOSTests/OverviewPreviewTests.swift b/Tests/DefiMacOSTests/OverviewPreviewTests.swift index ae30034c..292ad05a 100644 --- a/Tests/DefiMacOSTests/OverviewPreviewTests.swift +++ b/Tests/DefiMacOSTests/OverviewPreviewTests.swift @@ -105,6 +105,18 @@ struct OverviewPreviewTests { await limiter.release() } + @MainActor @Test + func hungNativeCaptureFailsAtDeadlineInsteadOfHoldingTheSlot() async throws { + await #expect(throws: OverviewCaptureTimeoutError.self) { + try await withOverviewCaptureTimeout(seconds: 0.05) { + try await Task.sleep(for: .seconds(60)) + return 1 + } + } + let value = try await withOverviewCaptureTimeout(seconds: 5) { 7 } + #expect(value == 7) + } + @Test func ordinaryCaptureFailureDoesNotDisableAuthorization() async { let limiter = OverviewCaptureLimiter(limit: 1) From 1c131cc8d13864df908f168e60f74a5c2ae9695a Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Mon, 12 Oct 2026 18:05:32 +0200 Subject: [PATCH 2/7] fix: address PR review feedback (#129) --- .agents/skills/verify-defi/SKILL.md | 4 ++-- Sources/DefiMacOS/OverviewController.swift | 9 ++++++++ .../DefiMacOS/OverviewSurfaceCapture.swift | 11 +++++++--- .../DefiMacOSTests/OverviewPreviewTests.swift | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.agents/skills/verify-defi/SKILL.md b/.agents/skills/verify-defi/SKILL.md index ea58c411..84d28623 100644 --- a/.agents/skills/verify-defi/SKILL.md +++ b/.agents/skills/verify-defi/SKILL.md @@ -57,14 +57,14 @@ defi focus-workspace up|down defi focus-workspace-name dev # names come from list-workspaces defi focus-workspace-position 2 defi move-column left|right|first|last -defi cycle-width +defi cycle-width next|prev defi --monitor 2 focus-workspace-name web defi list-workspaces --json defi status # one line of key=value counters defi trace # recent timestamped runtime events ``` -Note `defi workspace 2` in the usage text is rejected by the parser. Use `focus-workspace-position`. +Note `defi workspace 2` selects a workspace by ID, not by position, so the daemon answers `unknown workspace: 2`. Use `focus-workspace-position` for a position. Parse `defi status` by splitting on spaces and grepping `key=`. Do not scrape by column. diff --git a/Sources/DefiMacOS/OverviewController.swift b/Sources/DefiMacOS/OverviewController.swift index be0c8fe6..1903adf0 100644 --- a/Sources/DefiMacOS/OverviewController.swift +++ b/Sources/DefiMacOS/OverviewController.swift @@ -192,6 +192,9 @@ public final class OverviewController: NSObject { private var surfacePreparationPending = false private var previewTask: Task? private var desktopCaptureRetryTask: Task? + // Each timed-out native capture is abandoned, not cancelled, so retries are bounded per session. + private var desktopCaptureRetryCount = 0 + private var desktopCaptureRetryGeneration: UInt64 = 0 private var previewCache: [WindowID: NSImage] = [:] private let rememberedPreviews = OverviewPreviewCache() private var idlePreviewTask: Task? @@ -1483,6 +1486,12 @@ public final class OverviewController: NSObject { private func scheduleDesktopCaptureRetry(generation: UInt64) { cancelDesktopCaptureRetry() + if desktopCaptureRetryGeneration != generation { + desktopCaptureRetryGeneration = generation + desktopCaptureRetryCount = 0 + } + guard desktopCaptureRetryCount < 5 else { return } + desktopCaptureRetryCount += 1 desktopCaptureRetryTask = Task { @MainActor [weak self] in do { try await Task.sleep(for: .seconds(1)) diff --git a/Sources/DefiMacOS/OverviewSurfaceCapture.swift b/Sources/DefiMacOS/OverviewSurfaceCapture.swift index c54fd562..2928a499 100644 --- a/Sources/DefiMacOS/OverviewSurfaceCapture.swift +++ b/Sources/DefiMacOS/OverviewSurfaceCapture.swift @@ -128,7 +128,9 @@ final class OverviewSurfaceCapture { let content: SCShareableContent do { try Task.checkCancellation() - content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: false) + content = try await withOverviewCaptureTimeout { + try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: false) + } } catch { await overviewCaptureLimiter.release(error: error is CancellationError ? nil : error as NSError) throw error @@ -154,8 +156,11 @@ final class OverviewSurfaceCapture { } let sample: CMSampleBuffer do { - sample = try await SCScreenshotManager.captureSampleBuffer( - contentFilter: SCContentFilter(desktopIndependentWindow: window), configuration: config) + let filter = SCContentFilter(desktopIndependentWindow: window) + sample = try await withOverviewCaptureTimeout { + try await SCScreenshotManager.captureSampleBuffer( + contentFilter: filter, configuration: config) + } } catch { await overviewCaptureLimiter.release(error: error as NSError) throw error diff --git a/Tests/DefiMacOSTests/OverviewPreviewTests.swift b/Tests/DefiMacOSTests/OverviewPreviewTests.swift index 292ad05a..2cdf997b 100644 --- a/Tests/DefiMacOSTests/OverviewPreviewTests.swift +++ b/Tests/DefiMacOSTests/OverviewPreviewTests.swift @@ -117,6 +117,28 @@ struct OverviewPreviewTests { #expect(value == 7) } + @MainActor @Test + func timedOutCaptureReleasesTheLimiterSlotToQueuedCapture() async { + let limiter = OverviewCaptureLimiter(limit: 1) + #expect(await limiter.acquire()) + let queued = Task { await limiter.acquire() } + for _ in 0..<1_000 { + if await limiter.waitingCount == 1 { break } + await Task.yield() + } + do { + _ = try await withOverviewCaptureTimeout(seconds: 0.05) { + try await Task.sleep(for: .seconds(60)) + return 1 + } + } catch { + await limiter.release(error: error as NSError) + } + #expect(await queued.value) + #expect(await limiter.authorizationDeclined == false) + await limiter.release() + } + @Test func ordinaryCaptureFailureDoesNotDisableAuthorization() async { let limiter = OverviewCaptureLimiter(limit: 1) From 5f2f29bf42dc3b2c45f957975ca77b89661e236c Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Mon, 12 Oct 2026 18:12:58 +0200 Subject: [PATCH 3/7] fix: address PR review feedback (#129) --- Sources/DefiMacOS/OverviewController.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/DefiMacOS/OverviewController.swift b/Sources/DefiMacOS/OverviewController.swift index 1903adf0..c9d0318d 100644 --- a/Sources/DefiMacOS/OverviewController.swift +++ b/Sources/DefiMacOS/OverviewController.swift @@ -193,6 +193,7 @@ public final class OverviewController: NSObject { private var previewTask: Task? private var desktopCaptureRetryTask: Task? // Each timed-out native capture is abandoned, not cancelled, so retries are bounded per session. + private static let desktopCaptureRetryLimit = 5 private var desktopCaptureRetryCount = 0 private var desktopCaptureRetryGeneration: UInt64 = 0 private var previewCache: [WindowID: NSImage] = [:] @@ -1318,7 +1319,10 @@ public final class OverviewController: NSObject { selection: selection).filter { !attemptedPreviewWindowIDs.contains($0.windowID) } - let hasPendingDesktopCapture = panels.keys.contains { + // Panel updates must not bypass the retry cap once desktop captures keep failing. + let retriesExhausted = desktopCaptureRetryGeneration == sessionGeneration + && desktopCaptureRetryCount >= Self.desktopCaptureRetryLimit + let hasPendingDesktopCapture = !retriesExhausted && panels.keys.contains { !capturedDesktopMonitorIDs.contains($0) } guard overviewCaptureBatchNeeded( @@ -1490,7 +1494,7 @@ public final class OverviewController: NSObject { desktopCaptureRetryGeneration = generation desktopCaptureRetryCount = 0 } - guard desktopCaptureRetryCount < 5 else { return } + guard desktopCaptureRetryCount < Self.desktopCaptureRetryLimit else { return } desktopCaptureRetryCount += 1 desktopCaptureRetryTask = Task { @MainActor [weak self] in do { From d9cd8886826b5b01966907608ee0d1e42cd99912 Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Mon, 12 Oct 2026 18:16:19 +0200 Subject: [PATCH 4/7] fix: address PR review feedback (#129) --- Sources/DefiMacOS/OverviewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/DefiMacOS/OverviewController.swift b/Sources/DefiMacOS/OverviewController.swift index c9d0318d..7c17697f 100644 --- a/Sources/DefiMacOS/OverviewController.swift +++ b/Sources/DefiMacOS/OverviewController.swift @@ -1495,7 +1495,6 @@ public final class OverviewController: NSObject { desktopCaptureRetryCount = 0 } guard desktopCaptureRetryCount < Self.desktopCaptureRetryLimit else { return } - desktopCaptureRetryCount += 1 desktopCaptureRetryTask = Task { @MainActor [weak self] in do { try await Task.sleep(for: .seconds(1)) @@ -1507,6 +1506,8 @@ public final class OverviewController: NSObject { guard self.windowPreviewsEnabled, self.isOpen, generation == self.sessionGeneration else { return } + // Counted when the retry starts, so the last allowed retry is not blocked by its own count. + self.desktopCaptureRetryCount += 1 self.schedulePreviewsIfNeeded() } } From 98302189a789203ee4ad46b5936a34461adc4a10 Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Mon, 12 Oct 2026 18:18:50 +0200 Subject: [PATCH 5/7] fix: address PR review feedback (#129) --- Sources/DefiMacOS/OverviewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/DefiMacOS/OverviewController.swift b/Sources/DefiMacOS/OverviewController.swift index 7c17697f..6dad4326 100644 --- a/Sources/DefiMacOS/OverviewController.swift +++ b/Sources/DefiMacOS/OverviewController.swift @@ -1506,9 +1506,9 @@ public final class OverviewController: NSObject { guard self.windowPreviewsEnabled, self.isOpen, generation == self.sessionGeneration else { return } - // Counted when the retry starts, so the last allowed retry is not blocked by its own count. - self.desktopCaptureRetryCount += 1 + // Counted after the batch is requested, so the last allowed retry is not blocked by its own count. self.schedulePreviewsIfNeeded() + self.desktopCaptureRetryCount += 1 } } From 5bec1b81450c54ed906191c3da93cb843917fedf Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Mon, 12 Oct 2026 18:21:24 +0200 Subject: [PATCH 6/7] fix: address PR review feedback (#129) --- Sources/DefiMacOS/OverviewController.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/DefiMacOS/OverviewController.swift b/Sources/DefiMacOS/OverviewController.swift index 6dad4326..d63a47be 100644 --- a/Sources/DefiMacOS/OverviewController.swift +++ b/Sources/DefiMacOS/OverviewController.swift @@ -1320,9 +1320,7 @@ public final class OverviewController: NSObject { !attemptedPreviewWindowIDs.contains($0.windowID) } // Panel updates must not bypass the retry cap once desktop captures keep failing. - let retriesExhausted = desktopCaptureRetryGeneration == sessionGeneration - && desktopCaptureRetryCount >= Self.desktopCaptureRetryLimit - let hasPendingDesktopCapture = !retriesExhausted && panels.keys.contains { + let hasPendingDesktopCapture = !desktopCaptureRetriesExhausted(generation: sessionGeneration) && panels.keys.contains { !capturedDesktopMonitorIDs.contains($0) } guard overviewCaptureBatchNeeded( @@ -1385,7 +1383,8 @@ public final class OverviewController: NSObject { return } - let desktopRequests = desktopCaptureRequests() + let desktopRequests = desktopCaptureRetriesExhausted(generation: generation) + ? [] : desktopCaptureRequests() let results = await captureOverviewImages( previews: requests, desktops: desktopRequests, @@ -1488,6 +1487,11 @@ public final class OverviewController: NSObject { presentationChanged() } + private func desktopCaptureRetriesExhausted(generation: UInt64) -> Bool { + desktopCaptureRetryGeneration == generation + && desktopCaptureRetryCount >= Self.desktopCaptureRetryLimit + } + private func scheduleDesktopCaptureRetry(generation: UInt64) { cancelDesktopCaptureRetry() if desktopCaptureRetryGeneration != generation { From d50b5dc29106c8318e26da326e8243e23611229f Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Mon, 12 Oct 2026 18:27:16 +0200 Subject: [PATCH 7/7] fix: address PR review feedback (#129) --- Sources/DefiMacOS/OverviewController.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/DefiMacOS/OverviewController.swift b/Sources/DefiMacOS/OverviewController.swift index d63a47be..efaee8de 100644 --- a/Sources/DefiMacOS/OverviewController.swift +++ b/Sources/DefiMacOS/OverviewController.swift @@ -1320,7 +1320,9 @@ public final class OverviewController: NSObject { !attemptedPreviewWindowIDs.contains($0.windowID) } // Panel updates must not bypass the retry cap once desktop captures keep failing. - let hasPendingDesktopCapture = !desktopCaptureRetriesExhausted(generation: sessionGeneration) && panels.keys.contains { + // Decided here, before the batch task yields, so the retry that was granted still captures. + let includesDesktops = !desktopCaptureRetriesExhausted(generation: sessionGeneration) + let hasPendingDesktopCapture = includesDesktops && panels.keys.contains { !capturedDesktopMonitorIDs.contains($0) } guard overviewCaptureBatchNeeded( @@ -1336,6 +1338,7 @@ public final class OverviewController: NSObject { await Task.yield() await self?.capturePreviewBatch( requests, + includesDesktops: includesDesktops, generation: generation ) } @@ -1343,6 +1346,7 @@ public final class OverviewController: NSObject { private func capturePreviewBatch( _ requests: [OverviewPreviewRequest], + includesDesktops: Bool, generation: UInt64 ) async { guard windowPreviewsEnabled, isOpen, generation == sessionGeneration, @@ -1383,8 +1387,7 @@ public final class OverviewController: NSObject { return } - let desktopRequests = desktopCaptureRetriesExhausted(generation: generation) - ? [] : desktopCaptureRequests() + let desktopRequests = includesDesktops ? desktopCaptureRequests() : [] let results = await captureOverviewImages( previews: requests, desktops: desktopRequests,