Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/failure-classes/FC-concurrent-capture-contention.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"schema_version": 1,
"id": "FC-concurrent-capture-contention",
"violated_contract": "Omi's periodic screen capture must yield while another app is actively capturing the screen (screenshot app, screen recorder, or call screen share); issuing ScreenCaptureKit requests during an external capture contends in WindowServer capture arbitration and can stall or terminate the other app's capture.",
"canonical_prevention": "Detect the external capture (frontmost screenshot app, share-indicator window) and pause the capture loop through ProactiveScreenshotCaptureGate with a post-capture backoff, instead of capturing and handling the fallout.",
"evidence_prs": [6819],
"scope_hints": ["desktop/macos/**"],
"status": "open"
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"files": {
"desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift": 1742,
"desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift": 1641
"desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift": 1628
},
"raise_justifications": {
"desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift": "Task candidate context now carries only backend action-item IDs, while local SQLite/staged evidence remains id-less so the model cannot target it for a backend mutation.",
Expand Down
57 changes: 57 additions & 0 deletions desktop/macos/Desktop/Sources/ConferencingApps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,63 @@ enum ConferencingApps {
return false
}

// MARK: - Active outgoing screen share detection

/// True if a single window — identified by owner app and title — is a share-indicator
/// window: the floating toolbar/status chrome a conferencing app shows **only while the
/// user is actively sharing their screen** in a call.
///
/// Known signatures (window names are internal identifiers, not localized UI strings,
/// except the browser bubble which is English-locale best effort):
/// - Zoom: "zoom share statusbar window" / "zoom share toolbar window" floating controls
/// - Microsoft Teams: "Screen sharing toolbar" window while presenting
/// - Browsers (Google Meet / Teams web): the "<site> is sharing your screen/a tab/a window"
/// stop-sharing bubble window
static func isShareIndicatorWindow(ownerName: String?, title: String?) -> Bool {
guard let ownerName = ownerName, let title = title, !title.isEmpty else { return false }
let lowerTitle = title.lowercased()

if ownerName == "zoom.us" {
return lowerTitle.contains("zoom share")
}

if ownerName.contains("Microsoft Teams") || ownerName == "MSTeams" {
return lowerTitle.contains("sharing toolbar")
}

if browserApps.contains(ownerName) {
return lowerTitle.contains("is sharing your screen")
|| lowerTitle.contains("is sharing a tab")
|| lowerTitle.contains("is sharing a window")
}

return false
}

/// True if any on-screen window indicates an active outgoing screen share (the user is
/// presenting in Zoom/Teams/Meet/etc.). Window titles require Screen Recording permission;
/// without it only windows with readable names are considered.
///
/// Used to pause Omi's periodic capture: a one-shot ScreenCaptureKit capture while another
/// app streams the screen contends in WindowServer capture arbitration and has been observed
/// to stop the other app's share (issue #10143).
static func activeScreenSharePresent() -> Bool {
guard
let windows = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]
else {
return false
}
for window in windows {
let owner = window[kCGWindowOwnerName as String] as? String
let title = window[kCGWindowName as String] as? String
if isShareIndicatorWindow(ownerName: owner, title: title) {
return true
}
}
return false
}

// MARK: - CoreAudio process API (macOS 14.4+) — microphone-in-use detection

@available(macOS 14.4, *)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation

/// Decides, once per capture tick, whether Omi must skip its periodic screen capture to
/// yield to an **external capture** in progress:
///
/// - a screenshot / screen-recording app frontmost (CleanShot, Shottr, macOS screenshot, …):
/// concurrent ScreenCaptureKit use stalls the user's capture UI for 20–60s (#6819), and
/// - an active outgoing call screen share (Zoom/Teams/Meet presenting): the same WindowServer
/// capture-arbitration contention has been observed to stop the user's share outright
/// (issue #10143).
///
/// Both conditions run the same pause/backoff state machine (`ProactiveScreenshotCaptureGate`):
/// pause every tick while the condition holds, then hold a short backoff after it clears so the
/// other app's teardown/editor UI isn't disturbed either.
/// Failure class: FC-concurrent-capture-contention.
struct ProactiveExternalCaptureYield {
private(set) var screenshotGate = ProactiveScreenshotCaptureGate()
private(set) var shareGate = ProactiveScreenshotCaptureGate()

/// True when this tick must skip capture. `isScreenShareActive` is an autoclosure so the
/// share check (a CGWindowList enumeration) is not evaluated while the screenshot gate
/// already pauses the tick.
mutating func shouldYield(
isScreenshotAppFrontmost: Bool,
isScreenShareActive: @autoclosure () -> Bool,
now: Date,
screenshotBackoffDuration: TimeInterval,
shareBackoffDuration: TimeInterval
) -> Bool {
let wasScreenshotAppFrontmost = screenshotGate.wasScreenshotAppFrontmost
switch screenshotGate.nextDecision(
isScreenshotAppFrontmost: isScreenshotAppFrontmost,
now: now,
backoffDuration: screenshotBackoffDuration
) {
case .pause:
if !wasScreenshotAppFrontmost {
log("ProactiveAssistantsPlugin: Screenshot app frontmost — pausing capture to avoid WindowServer contention")
}
return true
case .resumeIntoBackoff:
log(
"ProactiveAssistantsPlugin: Screenshot app no longer frontmost, holding backoff for \(Int(max(0, screenshotGate.backoffUntil.timeIntervalSinceNow)))s"
)
return true
case .resumeAndCapture:
log("ProactiveAssistantsPlugin: Screenshot app no longer frontmost, holding backoff for 0s")
case .continueBackoff:
return true
case .capture:
break
}

let wasScreenSharing = shareGate.wasScreenshotAppFrontmost
switch shareGate.nextDecision(
isScreenshotAppFrontmost: isScreenShareActive(),
now: now,
backoffDuration: shareBackoffDuration
) {
case .pause:
if !wasScreenSharing {
log("ProactiveAssistantsPlugin: Active screen share detected — pausing capture until the share ends")
}
return true
case .resumeIntoBackoff:
log(
"ProactiveAssistantsPlugin: Screen share ended, holding backoff for \(Int(max(0, shareGate.backoffUntil.timeIntervalSinceNow)))s"
)
return true
case .resumeAndCapture:
log("ProactiveAssistantsPlugin: Screen share ended, resuming capture")
return false
case .continueBackoff:
return true
case .capture:
return false
}
}

mutating func reset() {
screenshotGate.reset()
shareGate.reset()
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Cocoa

Check warning on line 1 in desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift is 1628 lines; consider splitting files over 800 lines.

Check warning on line 1 in desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift

View workflow job for this annotation

GitHub Actions / Desktop Swift Static Contracts

Large changed file

desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift is 1628 lines; consider splitting files over 800 lines.

Check warning on line 1 in desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift is 1628 lines; consider splitting files over 800 lines.
@preconcurrency import UserNotifications

/// Pure gating policy for scheduled screen-capture ticks. Extracted so the
Expand Down Expand Up @@ -78,12 +78,14 @@
private var videoCallThrottleGate = ProactiveVideoCallThrottleGate()
private let videoCallThrottleFactor = 5 // Capture 1 out of every 5 frames (effective ~5s interval)

// Screenshot-app yielding: pause capture entirely while another screenshot/recording
// app is frontmost, and hold a short backoff after it resigns so its editor UI isn't
// disturbed. Prevents Omi's 3s capture loop from locking WindowServer at the moment
// the user is trying to take a screenshot (CleanShot, Shottr, macOS screenshot, etc.).
private var screenshotCaptureGate = ProactiveScreenshotCaptureGate()
// External-capture yielding: pause capture entirely while a screenshot/recording app is
// frontmost (CleanShot, Shottr, macOS screenshot — WindowServer stalls, #6819) or while
// another app actively shares the screen in a call (Zoom/Teams/Meet presenting — the
// contention has been observed to stop the user's share, issue #10143). Each condition
// holds a short backoff after it clears. See ProactiveExternalCaptureYield.
private var externalCaptureYield = ProactiveExternalCaptureYield()
private let screenshotAppBackoffDuration: TimeInterval = 10
private let screenShareBackoffDuration: TimeInterval = 10

// Change-gated distribution: only distribute frames to assistants when context changes.
// Eliminates continuous polling when the user stays on the same app/window.
Expand Down Expand Up @@ -493,7 +495,7 @@
backgroundPollCount = 0
recoveryRetryCount = 0
isInDelayPeriod = false
screenshotCaptureGate.reset()
externalCaptureYield.reset()
videoCallThrottleGate.reset()
distributionGate.reset()
latestCapturedFrame = nil
Expand Down Expand Up @@ -664,7 +666,7 @@
}
}

private func captureFrame() async {

Check warning on line 669 in desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Long function

private func captureFrame() async is 286 lines; consider extracting focused helpers over 150 lines.

Check warning on line 669 in desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift

View workflow job for this annotation

GitHub Actions / Desktop Swift Static Contracts

Long function

private func captureFrame() async is 286 lines; consider extracting focused helpers over 150 lines.

Check warning on line 669 in desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin.swift

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Long function

private func captureFrame() async is 286 lines; consider extracting focused helpers over 150 lines.
guard isMonitoring, let screenCaptureService = screenCaptureService else { return }

// Periodic screen recording permission recheck (issue #5792).
Expand Down Expand Up @@ -694,31 +696,16 @@
return
}

// Skip capture while a screenshot / screen-recording app is frontmost.
// Both apps using ScreenCaptureKit at the same time contend for WindowServer
// locks, which can stall the user's capture UI for 20-60s. Yield to the user.
let wasScreenshotAppFrontmostBeforeDecision = screenshotCaptureGate.wasScreenshotAppFrontmost
switch screenshotCaptureGate.nextDecision(
// Yield to an external capture in progress: a frontmost screenshot/recording app, or an
// active outgoing call screen share. See ProactiveExternalCaptureYield for rationale.
if externalCaptureYield.shouldYield(
isScreenshotAppFrontmost: isScreenshotAppFrontmost(),
isScreenShareActive: ConferencingApps.activeScreenSharePresent(),
now: now,
backoffDuration: screenshotAppBackoffDuration
screenshotBackoffDuration: screenshotAppBackoffDuration,
shareBackoffDuration: screenShareBackoffDuration
) {
case .pause:
if !wasScreenshotAppFrontmostBeforeDecision {
log("ProactiveAssistantsPlugin: Screenshot app frontmost — pausing capture to avoid WindowServer contention")
}
return
case .resumeIntoBackoff:
log(
"ProactiveAssistantsPlugin: Screenshot app no longer frontmost, holding backoff for \(Int(max(0, screenshotCaptureGate.backoffUntil.timeIntervalSinceNow)))s"
)
return
case .resumeAndCapture:
log("ProactiveAssistantsPlugin: Screenshot app no longer frontmost, holding backoff for 0s")
case .continueBackoff:
return
case .capture:
break
}
// Get current window info (use real app name, not cached)
let (realAppName, windowTitle, windowID) = await WindowMonitor.getActiveWindowInfoAsync()
Expand Down
16 changes: 12 additions & 4 deletions desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import AppKit

Check warning on line 1 in desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift is 1969 lines; consider splitting files over 800 lines.

Check warning on line 1 in desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift

View workflow job for this annotation

GitHub Actions / Desktop Swift Static Contracts

Large changed file

desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift is 1969 lines; consider splitting files over 800 lines.

Check warning on line 1 in desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift is 1969 lines; consider splitting files over 800 lines.
import VoiceTurnDomain
import XCTest

Expand Down Expand Up @@ -373,7 +373,9 @@
XCTAssertFalse(windowSource.contains("resolveDelegationAndDispatch"))
XCTAssertTrue(windowSource.contains("await dispatchChatQuery("))
XCTAssertFalse(source.contains("AgentPillFollowUpRoutingPolicy"))
XCTAssertTrue(source.contains("manager.continueAgent(from: pill, text: trimmed, attachments: staged)"))
// The bar has no typed pill composer since typing moved to the app's chat:
// pill steering routes to the main app instead of parsing wording locally.
XCTAssertTrue(source.contains("(NSApp.delegate as? AppDelegate)?.openMainAppWindow()"))
}

func testSubagentChatRendersMarkdownAndLargeBackHitTarget() throws {
Expand Down Expand Up @@ -832,11 +834,14 @@
XCTAssertFalse(body.contains("FloatingControlBarGeometry.targetFrame("))
}

func testSubagentComposerOnlyContinuesItsCanonicalSession() throws {
func testSubagentFollowUpsOnlyContinueTheCanonicalSession() throws {
// omi-test-quality: source-inspection -- static contract: the bar's typed composer is gone; follow-ups continue only through the manager bound to the pill's canonical session.
let viewSource = try floatingControlBarViewSource()
let pillSource = try agentPillSource()

XCTAssertFalse(viewSource.contains("AgentPillFollowUpRoutingPolicy"))
XCTAssertTrue(viewSource.contains("manager.continueAgent(from: pill, text: trimmed, attachments: staged)"))
XCTAssertTrue(pillSource.contains("guard pill.canonicalSessionId == sessionId else { return }"))
XCTAssertTrue(pillSource.contains("DesktopCoordinatorService.shared.continueAgent("))
}

func testSpawnAgentToolCallOpensSubagentChat() throws {
Expand Down Expand Up @@ -921,7 +926,10 @@
let inputSource = String(viewSource[inputRange.lowerBound..<inputEnd.lowerBound])

XCTAssertTrue(inputSource.contains(".beginVisibleMainQuery(message, fromVoice: false, animated: true)"))
XCTAssertTrue(viewSource.contains("state.archiveCurrentExchange(using: floatingChatProvider)"))
// Archiving the previous exchange moved into the window alongside sizing;
// the view must not archive on its own.
XCTAssertFalse(viewSource.contains("archiveCurrentExchange"))
XCTAssertTrue(windowSource.contains("state.archiveCurrentExchange(using: self.historyChatProvider)"))
XCTAssertTrue(viewSource.contains(".beginVisibleMainQuery(message, fromVoice: false, animated: true)"))
XCTAssertFalse(inputSource.contains("state.showingAIResponse = true"))
XCTAssertFalse(viewSource.contains("state.conversationSurface == .mainResponse || state.showingAIResponse"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,6 @@ final class FloatingBarTimingSignalTests: XCTestCase {
"the transition should key off didBecomeKey, not a fixed delay")
}

func testFollowUpFocusUsesViewLifecycle() throws {
let source = try floatingBarViewSource()
XCTAssertTrue(
source.contains(".task {"),
"follow-up focus should be driven by the view lifecycle (.task), not asyncAfter")
XCTAssertTrue(
source.contains("isFollowUpFocused = true"), "the field must still be focused on appear")
}

/// Anti-regression: no new fixed-delay `asyncAfter` may be added under
/// `FloatingControlBar/` above the pinned baseline. Recurses the directory the
/// same way the Python ratchet does; comment mentions of the word are excluded
Expand Down
49 changes: 49 additions & 0 deletions desktop/macos/Desktop/Tests/MeetingGatedSystemAudioTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,55 @@ import XCTest
XCTAssertFalse(ConferencingApps.isNativeCallApp(bundleID: "com.google.Chrome"))
}

// Regression: issue #10143 — Omi's periodic capture stopped an active screen share.
// These signatures gate the capture pause; a match means "user is presenting now".
func testShareIndicatorMatchesKnownPresentingWindows() {
// Zoom floating share controls (internal window names, present only while sharing).
XCTAssertTrue(
ConferencingApps.isShareIndicatorWindow(
ownerName: "zoom.us", title: "zoom share statusbar window"))
XCTAssertTrue(
ConferencingApps.isShareIndicatorWindow(
ownerName: "zoom.us", title: "zoom share toolbar window"))
// Teams presenting toolbar.
XCTAssertTrue(
ConferencingApps.isShareIndicatorWindow(
ownerName: "Microsoft Teams", title: "Screen sharing toolbar"))
XCTAssertTrue(
ConferencingApps.isShareIndicatorWindow(
ownerName: "MSTeams", title: "Screen sharing toolbar"))
// Browser (Meet / Teams web) stop-sharing bubble.
XCTAssertTrue(
ConferencingApps.isShareIndicatorWindow(
ownerName: "Google Chrome", title: "meet.google.com is sharing your screen."))
XCTAssertTrue(
ConferencingApps.isShareIndicatorWindow(
ownerName: "Arc", title: "teams.microsoft.com is sharing a window."))
}

func testShareIndicatorIgnoresOrdinaryCallAndAppWindows() {
// In a Zoom call but NOT sharing: main meeting window must not pause capture.
XCTAssertFalse(
ConferencingApps.isShareIndicatorWindow(ownerName: "zoom.us", title: "Zoom Meeting"))
XCTAssertFalse(
ConferencingApps.isShareIndicatorWindow(ownerName: "Microsoft Teams", title: "Standup | Microsoft Teams"))
// A Teams chat about screen sharing is not the presenting toolbar.
XCTAssertFalse(
ConferencingApps.isShareIndicatorWindow(
ownerName: "Microsoft Teams", title: "Screen sharing issues | Microsoft Teams"))
// Ordinary browser tab, even one talking about screen sharing.
XCTAssertFalse(
ConferencingApps.isShareIndicatorWindow(
ownerName: "Google Chrome", title: "How to stop sharing your screen - Google Meet Help"))
// Non-conferencing app can never be a share indicator, whatever the title says.
XCTAssertFalse(
ConferencingApps.isShareIndicatorWindow(
ownerName: "Finder", title: "zoom share statusbar window"))
XCTAssertFalse(ConferencingApps.isShareIndicatorWindow(ownerName: "zoom.us", title: nil))
XCTAssertFalse(ConferencingApps.isShareIndicatorWindow(ownerName: nil, title: "zoom share"))
XCTAssertFalse(ConferencingApps.isShareIndicatorWindow(ownerName: "zoom.us", title: ""))
}

func testBrowserBundleIDPrefixMatchingCatchesHelpers() {
// Browsers route call audio through helper processes — match by prefix.
XCTAssertTrue(ConferencingApps.isBrowserBundleID("net.imput.helium.helper")) // Helium (Meet)
Expand Down
Loading
Loading