Skip to content
Merged
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
56 changes: 45 additions & 11 deletions Sources/HeardCore/Services.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1164,12 +1164,31 @@ public final class PermissionCenter: ObservableObject {

// Performs the async screen-capture check then calls the sync refresh.
private func refreshAsync() async {
screenCaptureGrantedLive = await checkScreenCapturePermission()
// Use CGPreflightScreenCaptureAccess() for background polling — it reads the
// live TCC database without triggering any permission dialog.
//
// IMPORTANT: do NOT use SCShareableContent here. Calling
// SCShareableContent.excludingDesktopWindows() when screen recording hasn't been
// granted yet causes macOS to show the TCC permission dialog. Running that call
// every 3 s in this loop was the source of the infinite permission-request loop
// seen on fresh installs and non-dev machines.
//
// On macOS 15+ CGPreflightScreenCaptureAccess() may return a cached value for
// the running process. That cache is invalidated by TCC database changes, so a
// grant made in System Settings is visible to the background poll within a few
// seconds. If the very first poll after launch returns false, the user will see
// the "Not Granted" UI and can click "Grant…" to trigger the one-shot live check.
screenCaptureGrantedLive = CGPreflightScreenCaptureAccess()
refresh()
}

// SCShareableContent bypasses CGPreflightScreenCaptureAccess() caching on macOS 15+.
private func checkScreenCapturePermission() async -> Bool {
/// One-shot live permission check via SCShareableContent, which bypasses the
/// CGPreflightScreenCaptureAccess() per-process cache on macOS 15+.
///
/// WARNING: This call triggers the macOS TCC permission dialog if screen recording
/// has not yet been granted. It must ONLY be called in direct response to an explicit
/// user action (e.g. the "Grant…" button), never from a background polling loop.
private func checkScreenCapturePermissionLive() async -> Bool {
do {
_ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
return true
Expand Down Expand Up @@ -1313,12 +1332,26 @@ public final class PermissionCenter: ObservableObject {
// CGRequestScreenCaptureAccess() triggers the system prompt on macOS 14;
// on macOS 15+ it redirects to System Settings. Use it unconditionally.
CGRequestScreenCaptureAccess()
// Re-check after a brief delay using the live SCShareableContent path so the
// UI flips to "Granted" without waiting for the next 3 s polling tick.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
// After the user interacts with System Settings and returns to the app, do a
// one-shot live check so the UI flips to "Granted" promptly rather than waiting
// for the next background poll. The 3-second delay gives the user enough time to
// finish in System Settings before the check fires.
//
// This is safe because it is a single call triggered by the user pressing
// "Grant…" — it does not loop. The background polling loop (refreshAsync) uses
// CGPreflightScreenCaptureAccess() only and never calls this path.
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
Task { @MainActor in
guard let self else { return }
self.screenCaptureGrantedLive = await self.checkScreenCapturePermission()
// Fast path: sync check first (covers most cases and avoids an
// unnecessary SCShareableContent call if the grant is already visible).
if CGPreflightScreenCaptureAccess() {
self.screenCaptureGrantedLive = true
self.refresh()
return
}
// Bypass macOS 15+ per-process TCC cache with one-shot live check.
self.screenCaptureGrantedLive = await self.checkScreenCapturePermissionLive()
self.refresh()
}
}
Expand All @@ -1337,10 +1370,11 @@ public final class PermissionCenter: ObservableObject {
}

private func screenCaptureState() -> PermissionState {
// screenCaptureGrantedLive is updated async every 3 s via SCShareableContent,
// which bypasses the per-process cache that CGPreflightScreenCaptureAccess()
// uses on macOS 15+. The sync check handles the window before the first async
// result arrives (e.g. permission already granted at app launch).
// screenCaptureGrantedLive is updated every 3 s via the background polling task.
// Background polling uses CGPreflightScreenCaptureAccess() to avoid triggering
// TCC prompts. A one-shot SCShareableContent check (checkScreenCapturePermissionLive)
// is used to bypass the macOS 15+ per-process cache, but only when the user
// explicitly presses the "Grant…" button — never from the polling loop.
Self.screenCapturePermissionState(
syncGranted: CGPreflightScreenCaptureAccess(),
liveGranted: screenCaptureGrantedLive
Expand Down
Loading