ScreenCaptureKit

RSS for tag

ScreenCaptureKit brings high-performance screen capture, including audio and video, to macOS.

Posts under ScreenCaptureKit tag

35 Posts

Post

Replies

Boosts

Views

Activity

Unable to capture only the cursor in macOS Tahoe
Precondition: In system settings, scale the pointer size up to the max. Our SCScreenshotManager code currently works in macOS 15 and earlier to capture the cursor at it's larger size, but broke in one of the minor releases of macOS Tahoe. The error it produces now is "Failed to start stream due to audio/video capture failure". This only seems to happen with the cursor window, not any others. Another way to get the cursor is with https://developer.apple.com/documentation/appkit/nscursor/currentsystem, but that is now deprecated, which makes me think the capture of the cursor is being blocked deliberately. We see this as a critical loss of functionality for our apps, and could use guidance on what to use instead.
0
0
68
1d
Building Real-Time Voice Input on macOS 26 with SpeechAnalyzer + ScreenCaptureKit
We built an open-source macOS menu bar app that turns speech into text and pastes it into the active app — using SpeechAnalyzer for on-device transcription, ScreenCaptureKit + Vision for screen-aware context, and FluidAudio for speaker diarization in meeting mode. Here's what we learned shipping it on macOS 26. GitHub: github.com/Marvinngg/ambient-voice Architecture The app has two modes: hotkey dictation (press to talk, release to inject) and meeting recording (continuous transcription with a floating panel). Dictation Mode Audio capture uses AVCaptureSession (more on why below). The captured audio feeds into SpeechAnalyzer via an AsyncStream: let transcriber = SpeechTranscriber( locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults, .alternativeTranscriptions], attributeOptions: [.audioTimeRange, .transcriptionConfidence] ) let analyzer = SpeechAnalyzer(modules: [transcriber]) let (inputSequence, inputBuilder) = AsyncStream.makeStream() try await analyzer.start(inputSequence: inputSequence) While recording, we capture a screenshot of the focused window using ScreenCaptureKit, run Vision OCR (VNRecognizeTextRequest), extract keywords, and inject them into SpeechAnalyzer as contextual bias: let context = AnalysisContext() context.contextualStrings[.general] = ocrKeywords try await analyzer.setContext(context) This improves accuracy for technical terms and proper nouns visible on screen. If your screen shows "SpeechAnalyzer", saying it out loud is more likely to be transcribed correctly. After transcription, an optional L2 step sends the text through a local LLM (ollama) for spoken-to-written cleanup, then CGEvent simulates Cmd+V to paste into the active app. Meeting Mode Meeting mode forks the same audio stream to two consumers: SpeechAnalyzer — real-time streaming transcription, displayed in a floating NSPanel FluidAudio buffer — accumulates 16kHz Float32 mono samples for batch speaker diarization after recording stops When the user ends the meeting, FluidAudio's performCompleteDiarization() runs on the accumulated audio. We align transcription segments with speaker segments using audioTimeRange overlap matching — each transcription segment gets assigned the speaker ID with the most time overlap. Results export to Markdown. Pitfalls We Hit on macOS 26 1. AVAudioEngine installTap doesn't fire with Bluetooth devices We started with AVAudioEngine.inputNode.installTap() for audio capture. It worked fine with built-in mics but the tap callback never fired with Bluetooth devices (tested with vivo TWS 4 Hi-Fi). Fix: switched to AVCaptureSession. The delegate callback captureOutput(_:didOutput:from:) fires reliably regardless of audio device. The tradeoff is you get CMSampleBuffer instead of AVAudioPCMBuffer, so you need a conversion step. 2. NSEvent addGlobalMonitorForEvents crashes Our global hotkey listener used NSEvent.addGlobalMonitorForEvents. On macOS 26, this crashes with a Bus error inside GlobalObserverHandler — appears to be a Swift actor runtime issue. Fix: switched to CGEventTap. Works reliably, but the callback runs on a CFRunLoop context, which Swift doesn't recognize as MainActor. 3. CGEventTap callbacks aren't on MainActor If your CGEventTap callback touches any @MainActor state, you'll get concurrency violations. The callback runs on whatever thread owns the CFRunLoop. Fix: bridge with DispatchQueue.main.async {} inside the tap callback before touching any MainActor state. 4. CGPreflightScreenCaptureAccess doesn't request permission We used CGPreflightScreenCaptureAccess() as a guard before calling ScreenCaptureKit. If it returned false, we'd bail out. The problem: this function only checks — it never triggers macOS to add your app to the Screen Recording permission list. Chicken-and-egg: you can't get permission because you never ask for it. Fix: call CGRequestScreenCaptureAccess() at app startup. This adds your app to System Settings → Screen Recording. Then let ScreenCaptureKit calls proceed without the preflight guard — SCShareableContent will also trigger the permission prompt on first use. 5. Ad-hoc signing breaks TCC permissions on every rebuild During development, codesign --sign - (ad-hoc) generates a different code directory hash on every build. macOS TCC tracks permissions by this hash, so every rebuild = new app identity = all permissions reset. Fix: sign with a stable certificate. If you have an Apple Development certificate, use that. The TeamIdentifier stays constant across rebuilds, so TCC permissions persist. We also discovered that launching via open WE.app (LaunchServices) instead of directly executing the binary is required — otherwise macOS attributes TCC permissions to Terminal, not your app. Benchmarks We ran end-to-end benchmarks on public datasets (Mac Mini M4 16GB, macOS 26): Transcription (SpeechAnalyzer, AliMeeting Chinese): • Near-field CER 34% (excluding outliers ~25%) • Far-field CER 40% (single channel, no beamforming, >30% overlap) • Processing speed 74-89x real-time Speaker diarization (FluidAudio offline): • AMI English 16 meetings: avg DER 23.2% (collar=0.25s, ignoreOverlap=True) • AliMeeting Chinese 8 meetings: DER 48.5% (including overlap regions) • Memory: RSS ~500MB, peak 730-930MB Full evaluation methodology, scripts, and raw results are in the repo. Open Source The project is MIT licensed: github.com/Marvinngg/ambient-voice It includes the macOS client (Swift 6.2, SPM), server-side distillation/training scripts (Python), and a complete evaluation framework with reproducible benchmarks. Feedback and contributions welcome.
0
0
152
1d
Building Real-Time Voice Input on macOS 26 with SpeechAnalyzer + ScreenCaptureKit
We built an open-source macOS menu bar app that turns speech into text and pastes it into the active app — using SpeechAnalyzer for on-device transcription, ScreenCaptureKit + Vision for screen-aware context, and FluidAudio for speaker diarization in meeting mode. Here's what we learned shipping it on macOS 26. GitHub: github.com/Marvinngg/ambient-voice Architecture The app has two modes: hotkey dictation (press to talk, release to inject) and meeting recording (continuous transcription with a floating panel). Dictation Mode Audio capture uses AVCaptureSession (more on why below). The captured audio feeds into SpeechAnalyzer via an AsyncStream: let transcriber = SpeechTranscriber( locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults, .alternativeTranscriptions], attributeOptions: [.audioTimeRange, .transcriptionConfidence] ) let analyzer = SpeechAnalyzer(modules: [transcriber]) let (inputSequence, inputBuilder) = AsyncStream.makeStream() try await analyzer.start(inputSequence: inputSequence) While recording, we capture a screenshot of the focused window using ScreenCaptureKit, run Vision OCR (VNRecognizeTextRequest), extract keywords, and inject them into SpeechAnalyzer as contextual bias: let context = AnalysisContext() context.contextualStrings[.general] = ocrKeywords try await analyzer.setContext(context) This improves accuracy for technical terms and proper nouns visible on screen. If your screen shows "SpeechAnalyzer", saying it out loud is more likely to be transcribed correctly. After transcription, an optional L2 step sends the text through a local LLM (ollama) for spoken-to-written cleanup, then CGEvent simulates Cmd+V to paste into the active app. Meeting Mode Meeting mode forks the same audio stream to two consumers: SpeechAnalyzer — real-time streaming transcription, displayed in a floating NSPanel FluidAudio buffer — accumulates 16kHz Float32 mono samples for batch speaker diarization after recording stops When the user ends the meeting, FluidAudio's performCompleteDiarization() runs on the accumulated audio. We align transcription segments with speaker segments using audioTimeRange overlap matching — each transcription segment gets assigned the speaker ID with the most time overlap. Results export to Markdown. Pitfalls We Hit on macOS 26 1. AVAudioEngine installTap doesn't fire with Bluetooth devices We started with AVAudioEngine.inputNode.installTap() for audio capture. It worked fine with built-in mics but the tap callback never fired with Bluetooth devices (tested with vivo TWS 4 Hi-Fi). Fix: switched to AVCaptureSession. The delegate callback captureOutput(_:didOutput:from:) fires reliably regardless of audio device. The tradeoff is you get CMSampleBuffer instead of AVAudioPCMBuffer, so you need a conversion step. 2. NSEvent addGlobalMonitorForEvents crashes Our global hotkey listener used NSEvent.addGlobalMonitorForEvents. On macOS 26, this crashes with a Bus error inside GlobalObserverHandler — appears to be a Swift actor runtime issue. Fix: switched to CGEventTap. Works reliably, but the callback runs on a CFRunLoop context, which Swift doesn't recognize as MainActor. 3. CGEventTap callbacks aren't on MainActor If your CGEventTap callback touches any @MainActor state, you'll get concurrency violations. The callback runs on whatever thread owns the CFRunLoop. Fix: bridge with DispatchQueue.main.async {} inside the tap callback before touching any MainActor state. 4. CGPreflightScreenCaptureAccess doesn't request permission We used CGPreflightScreenCaptureAccess() as a guard before calling ScreenCaptureKit. If it returned false, we'd bail out. The problem: this function only checks — it never triggers macOS to add your app to the Screen Recording permission list. Chicken-and-egg: you can't get permission because you never ask for it. Fix: call CGRequestScreenCaptureAccess() at app startup. This adds your app to System Settings → Screen Recording. Then let ScreenCaptureKit calls proceed without the preflight guard — SCShareableContent will also trigger the permission prompt on first use. 5. Ad-hoc signing breaks TCC permissions on every rebuild During development, codesign --sign - (ad-hoc) generates a different code directory hash on every build. macOS TCC tracks permissions by this hash, so every rebuild = new app identity = all permissions reset. Fix: sign with a stable certificate. If you have an Apple Development certificate, use that. The TeamIdentifier stays constant across rebuilds, so TCC permissions persist. We also discovered that launching via open WE.app (LaunchServices) instead of directly executing the binary is required — otherwise macOS attributes TCC permissions to Terminal, not your app. Benchmarks We ran end-to-end benchmarks on public datasets (Mac Mini M4 16GB, macOS 26): Transcription (SpeechAnalyzer, AliMeeting Chinese): • Near-field CER 34% (excluding outliers ~25%) • Far-field CER 40% (single channel, no beamforming, >30% overlap) • Processing speed 74-89x real-time Speaker diarization (FluidAudio offline): • AMI English 16 meetings: avg DER 23.2% (collar=0.25s, ignoreOverlap=True) • AliMeeting Chinese 8 meetings: DER 48.5% (including overlap regions) • Memory: RSS ~500MB, peak 730-930MB Full evaluation methodology, scripts, and raw results are in the repo. Open Source The project is MIT licensed: github.com/Marvinngg/ambient-voice It includes the macOS client (Swift 6.2, SPM), server-side distillation/training scripts (Python), and a complete evaluation framework with reproducible benchmarks. Feedback and contributions welcome.
0
0
160
1d
ScreenCaptureKit permissions lost after every build — solved by switching signing identity
Sharing a solution for a problem that took me a while to figure out. Problem: During development of a macOS 26 app that uses ScreenCaptureKit, the screen capture permissions were being reset after every build. Each time I compiled and ran the app from Xcode, I had to re-authorize screen capture in System Settings. CGPreflightScreenCaptureAccess() would return false even though I'd just granted permission minutes ago. Root cause: I was using ad-hoc code signing during development. macOS ties screen capture permissions to the app's code signing identity. With ad-hoc signing, the identity changes on every build, so the system treats each build as a "new" app. Solution: Switch to an Apple Development certificate for debug builds. In Xcode: Build Settings → Code Signing Identity → Debug → set to "Apple Development" Make sure your development team is selected After this change, the signing identity remains stable across builds, and screen capture permissions persist. This might be related to the broader issue discussed in this forum about ScreenCapture permissions disappearing — if other developers are seeing permissions vanish, it's worth checking whether the code signing identity is changing between sessions.
1
0
238
1d
ScreenCapture permissions disappear and don't return
On Tahoe and earlier, ScreenCapture permissions can disappear and not return. Customers are having an issue with this disappearing and when our code executes CGRequestScreenCaptureAccess() nothing happens, the prompt does not appear. I can reproduce this by using the "-" button and removing the entry in the settings, then adding it back with the "+" button. CGPreflightScreenCaptureAccess() always returns the correct value but once the entry has been removed, CGRequestScreenCaptureAccess() requires a reboot before it will work again.
3
0
309
3d
Requested access to the Persistent Content Capture Entitlement
Two months ago I requested the subject entitlement. I'm still waiting for it to be added to our account. Who or how can I find out what going on with it. I have no correspondence from Apple yet saying it was denied and why. https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.persistent-content-capture?language=objc Thank you.
1
0
154
3w
Cannot capture HDR content using ScreenCaptureKit
With the help of Claude and Codex, I've tried upgrading a screen magnification, capture, and pixel peeping app (SnoopX, from Stanford's software repository) to be HDR-aware. For images that are truly HDR (displayed in Apple Photos, Google Photos in Chrome, or Mac Preview), and on an HDR monitor with plenty of EDR headroom (Apple XDR display), I should see pixel values well above 1.0. Apple's Digital Color Meter does. However, my app does not. I think the code is doing all the right things with ScreenCaptureKit, but it never returns values above 1.0. Has anybody gotten this to work? Here is what Codex says about the code it helped me build in my app: • Set captureDynamicRange: We set config​.capture​Dynamic​Range = ​SCCapture​Dynamic​Range​HDRLocal​Display (when available). Note: Gemini’s SCCapture​Mode​HDRLocal​Display isn’t a real symbol — the actual API is SCCapture​Dynamic​Range. • Use preset: We try init​With​Preset: ​SCStream​Configuration​Preset​Capture​HDRStream​Local​Display, but your runtime crashes on init​With​Preset: (unrecognized selector), so we fall back to manual config. That means your system SDK headers mention the preset, but the runtime doesn’t implement it. • Pixel format: We explicitly set k​CVPixel​Format​Type​_64​RGBAHalf. Your logs show the stream buffer is 64RGBAHalf. • Enable HDR on display: EDR headroom logs show current=6.40, so HDR is active. • Color space: We set Extended Linear Display P3 for stream, and we forced the screenshot sampler to convert into extended‑linear P3 before reading. The screenshot itself reports Extended sRGB, but conversion doesn’t yield >1.0 either. Despite all of that, the OS is still delivering tone‑mapped values (max 1.0000) for both the stream and the screenshot path. So I don’t think there’s a missing knob here; it looks like a system‑level limitation on Tahoe for desktop capture.
1
0
152
Feb ’26
ScreenCaptureKit recording output is corrupted when captureMicrophone is true
Hello everyone, I'm working on a screen recording app using ScreenCaptureKit and I've hit a strange issue. My app records the screen to an .mp4 file, and everything works perfectly until the .captureMicrophone is false In this case, I get a valid, playable .mp4 file. However, as soon as I try to enable the microphone by setting streamConfig.captureMicrophone = true, the recording seems to work, but the final .mp4 file is corrupted and cannot be played by QuickTime or any other player. This happens whether capturesAudio (app audio) is on or off. I've already added the "Privacy - Microphone Usage Description" (NSMicrophoneUsageDescription) to my Info.plist, so I don't think it's a permissions problem. I have my logic split into a ScreenRecorder class that manages state and a CaptureEngine that handles the SCStream. Here is how I'm configuring my SCStream: ScreenRecorder.swift // This is my main SCStreamConfiguration private var streamConfiguration: SCStreamConfiguration { var streamConfig = SCStreamConfiguration() // ... other HDR/preset config ... // These are the problem properties streamConfig.capturesAudio = isAudioCaptureEnabled streamConfig.captureMicrophone = isMicCaptureEnabled // breaks it if true streamConfig.excludesCurrentProcessAudio = false streamConfig.showsCursor = false if let region = selectedRegion, let display = currentDisplay { // My region/frame logic (works fine) let regionWidth = Int(region.frame.width) let regionHeight = Int(region.frame.height) streamConfig.width = regionWidth * scaleFactor streamConfig.height = regionHeight * scaleFactor // ... (sourceRect logic) ... } streamConfig.pixelFormat = kCVPixelFormatType_32BGRA streamConfig.colorSpaceName = CGColorSpace.sRGB streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: 60) return streamConfig } And here is how I'm setting up the SCRecordingOutput that writes the file: ScreenRecorder.swift private func initRecordingOutput(for region: ScreenPickerManager.SelectedRegion) throws { let screeRecordingOutputURL = try RecordingWorkspace.createScreenRecordingVideoFile( in: workspaceURL, sessionIndex: sessionIndex ) let recordingConfiguration = SCRecordingOutputConfiguration() recordingConfiguration.outputURL = screeRecordingOutputURL recordingConfiguration.outputFileType = .mp4 recordingConfiguration.videoCodecType = .hevc let recordingOutput = SCRecordingOutput(configuration: recordingConfiguration, delegate: self) self.recordingOutput = recordingOutput } Finally, my CaptureEngine adds these to the SCStream: CaptureEngine.swift class CaptureEngine: NSObject, @unchecked Sendable { private(set) var stream: SCStream? private var streamOutput: CaptureEngineStreamOutput? // ... (dispatch queues) ... func startCapture(configuration: SCStreamConfiguration, filter: SCContentFilter, recordingOutput: SCRecordingOutput) async throws { let streamOutput = CaptureEngineStreamOutput() self.streamOutput = streamOutput do { stream = SCStream(filter: filter, configuration: configuration, delegate: streamOutput) // Add outputs for raw buffers (not used for file recording) try stream?.addStreamOutput(streamOutput, type: .screen, sampleHandlerQueue: videoSampleBufferQueue) try stream?.addStreamOutput(streamOutput, type: .audio, sampleHandlerQueue: audioSampleBufferQueue) try stream?.addStreamOutput(streamOutput, type: .microphone, sampleHandlerQueue: micSampleBufferQueue) // Add the file recording output try stream?.addRecordingOutput(recordingOutput) try await stream?.startCapture() } catch { logger.error("Failed to start capture: \(error.localizedDescription)") throw error } } // ... (stopCapture, etc.) ... } When I had the .captureMicrophone value to be false, I get a perfect .mp4 video playable everywhere, however, when its true, I am getting corrupted video which doesn't play at all :-
1
0
489
Feb ’26
Capturing screen buffer at macOS Login Window with ScreenCaptureKit and PrivilegedHelper
I am developing a remote support tool for macOS. While we have successfully implemented a Privileged Helper Tool and LaunchDaemon architecture that works within an active Aqua session, we have observed a total failure to capture the screen buffer or receive input at the macOS Login Window. Our observation of competitor software (AnyDesk, TeamViewer) shows they maintain graphical continuity through logout/restart. We are seeking the official architectural path to replicate this system-level access. Current Technical Implementation Architecture: A root-level LaunchDaemon manages the persistent network connection. A PrivilegedHelperTool (installed in /Library/PrivilegedHelperTools/) is used for elevated tasks. Environment: Tested on macOS 14.x (Sonoma) and macOS 15.x (Sequoia) on Apple Silicon. Capture Methods: We have implemented ScreenCaptureKit (SCK) as the primary engine and CGDisplayCreateImage as a fallback. Binary Status: All components are signed with a Developer ID and have been successfully Notarized. Observed Behavior & Blockers The "Aqua" Success: Within a logged-in user session, our CGI correctly identifies Display IDs and initializes the capture stream. Remote control is fully functional. The "Pre-Login" Failure: When the Mac is at the Login Window (no user logged in), the following occurs: The Daemon remains active, but the screen capture buffer returns NULL or an empty frame. ScreenCaptureKit fails to initialize, citing a lack of graphical context. No TCC (Transparency, Consent, and Control) prompt can appear because no user session exists. The "Bootstrap" Observation: We have identified that the loginwindow process exists in a restricted Mach bootstrap namespace that our Daemon (running in the System domain) cannot natively bridge. Comparative Analysis (Competitor Benchmarking) We have analyzed established remote desktop solutions like AnyDesk and Jump Desktop to understand their success at the login screen. Our findings suggest: Dual-Context Execution: They appear to use a Global LaunchAgent with LimitLoadToSessionType = ["LoginWindow"]. This allows a child process to run as root inside the login window’s graphical domain. Specialized Entitlements: These apps have migrated to the com.apple.developer.persistent-content-capture entitlement. This restricted capability allows them to bypass the weekly/monthly TCC re-authorization prompts and function in unattended scenarios where a user cannot click "Allow." Questions Entitlement Requirement: Is the persistent-content-capture entitlement the only supported way for a third-party app to capture the LoginWindow buffer without manual user intervention? LaunchAgent Strategy: To gain a graphical context at the login screen, is it recommended to load a specialized agent into the loginwindow domain via launchctl bootstrap loginwindow ...? ScreenCaptureKit vs. Legacy: Does ScreenCaptureKit officially support the LoginWindow session, or does it require an active Aqua session to initialize? MDM Bypass: For Enterprise environments, can a Privacy Preferences Policy Control (PPPC) payload grant "Screen Recording" to a non-entitled Daemon specifically for the login window context?
1
0
439
Jan ’26
SCK/replayd behaviors and delays
We're troubleshooting SCK issues. They occur with a relatively small amount of sessions, but lack of context and/or ability to advise the customer on how they could make behavior more predictable and reliable is problematic. Generally, there is 2 distinct issues which may or may not have the same root cause: Failure to establish SCK session. Usually manifests within the app as SCShareableContent.getWithCompletionHandler call either never invoking the completion handler, or taking prohibitively long time (we usually give it 3-10 sec before giving up). In the system log it may look like this: (log omitted - suspecting it triggers the content filter) Note the 6 seconds delay to completion of fetchShareableContentWithOption (normally it's a 30-40ms operation). Sometime, we'd see the stream established, but some minutes (or even hours) into the recording we'd stop receiving frames. Both scenarios are likely to occur when the disk space is low, with reliable repro of the problem #2 at below 8gb of free space (in that case, we've seen replayd silently dropping the session, without ever notifying the client ... improving API could go a long way there). However, out of recent occurrences, while most have less than 100GB available, we've seen it on machines with as much as 500GB free. Unfortunately, it's almost never reproducible in dev environment, so we have to rely on diagnostics we're able to collect in the field -- which nothing obvious yet. I'd like to understand the root cause of both scenarios better and/or how what specific frameworks can cause these behaviors.
0
0
558
Jan ’26
Why is ScreenCaptureKit throttled to about 7 fps?
I have an app that records a 32 x 32 rect under the cursor as the user moves it around and it sends it to Flutter. It suffers from major lag. Instead of getting 30 fps, I get about 7 fps. That is, there are significant lags between screen grabs. This on an Intel Mac mini x64 with 15.7.3 and one display. flutter: NATIVE: ExplodedView framesIn=2 timeSinceStart=1115.7ms gapSinceLastFrame=838.8ms flutter: NATIVE: ExplodedView framesIn=4 timeSinceStart=1382.6ms gapSinceLastFrame=149.9ms flutter: NATIVE: ExplodedView framesIn=5 timeSinceStart=1511.0ms gapSinceLastFrame=128.4ms flutter: NATIVE: ExplodedView framesIn=7 timeSinceStart=1698.3ms gapSinceLastFrame=102.9ms flutter: NATIVE: ExplodedView STOP polling totalTime=4482.6ms framesIn=28 framesSent=28 acks=28 Here's a testable excerpt: import ScreenCaptureKit import CoreMedia import CoreVideo import QuartzCore final class Test: NSObject, SCStreamOutput, SCStreamDelegate { private let q = DispatchQueue(label: "cap.q") private var stream: SCStream? private var lastFrameAt: CFTimeInterval = 0 private var frames = 0 func start() { SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: true) { content, err in guard err == nil, let display = content?.displays.first else { print("shareableContent error: \(String(describing: err))"); return } let filter = SCContentFilter(display: display, excludingWindows: []) let config = SCStreamConfiguration() config.showsCursor = false config.queueDepth = 1 config.minimumFrameInterval = CMTime(value: 1, timescale: 30) config.pixelFormat = kCVPixelFormatType_32BGRA config.width = 32 config.height = 32 config.sourceRect = CGRect(x: 100, y: 100, width: 32, height: 32) let s = SCStream(filter: filter, configuration: config, delegate: self) try! s.addStreamOutput(self, type: .screen, sampleHandlerQueue: self.q) self.stream = s s.startCapture { startErr in print("startCapture err=\(String(describing: startErr))") } // Optional: move sourceRect at 30Hz (cursor-follow simulation) Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { _ in let c2 = SCStreamConfiguration() c2.showsCursor = false c2.queueDepth = 1 c2.minimumFrameInterval = CMTime(value: 1, timescale: 30) c2.pixelFormat = kCVPixelFormatType_32BGRA c2.width = 32 c2.height = 32 let t = CACurrentMediaTime() c2.sourceRect = CGRect(x: 100 + (sin(t) * 50), y: 100, width: 32, height: 32) s.updateConfiguration(c2) { _ in } } } } func stream(_ stream: SCStream, didOutputSampleBuffer sb: CMSampleBuffer, of type: SCStreamOutputType) { guard type == .screen else { return } let now = CACurrentMediaTime() let gapMs = (lastFrameAt == 0) ? 0 : (now - lastFrameAt) * 1000 lastFrameAt = now frames += 1 if frames <= 10 || frames % 60 == 0 { print("frames=\(frames) gapMs=\(String(format: "%.1f", gapMs))") } } }
0
0
140
Dec ’25
Issue: Plain Executables Do Not Appear Under “Screen & System Audio Recording” on macOS 26.1 (Tahoe)
Summary I am investigating a change in macOS 26.1 (Tahoe) where plain (non-bundled) executables that request screen recording access no longer appear under: System Settings → Privacy & Security → Screen & System Audio Recording This behavior differs from macOS Sequoia, where these executables did appear in the list and could be managed through the UI. Tahoe still prompts for permission and still allows the executable to capture the screen once permission is granted, but the executable never shows up in the UI list. This breaks user expectations and removes UI-based permission management. To confirm the behavior, I created a small reproduction project with both: a plain executable, and an identical executable packaged inside an .app bundle. Only the bundled version appears in System Settings. Observed Behaviour 1. Plain Executable (from my reproduction project) When running a plain executable that captures the screen: macOS displays the normal screen-recording permission prompt. Before granting permission: screenshots show only the desktop background. After granting permission: screenshots capture the full display. The executable does not appear under “Screen & System Audio Recording”. Even when permission is granted manually (e.g., dragging the executable into the pane), the executable still does not appear, which prevents the user from modifying or revoking the permission through the UI. If the executable is launched from inside another app (e.g., VS Code, Terminal), the parent app appears in the list instead, not the executable itself. 2. Bundled App Version (from the reproduction project) I packaged the same code into a simple .app bundle (ScreenCaptureApp.app). When running the app: The same permission prompt appears. Pre-permission screenshots show the desktop background. Post-permission screenshots capture the full display. The app does appear under “Screen & System Audio Recording”. This bundle uses the same underlying executable — the only difference is packaging. Hypothesis macOS 26.1 (Tahoe) appears to require app bundles for an item to be shown in the Screen Recording privacy UI. Plain executables: still request and receive permission, still function correctly after permission is granted, but do not appear in the System Settings list. This may be an intentional change, undocumented behavior, or a regression. Reproduction Project The reproduction project includes: screen_capture.go A simple Go program that captures screenshots in a loop. screen_capture_executable Plain executable built from the Go source. ScreenCaptureApp.app/ App bundle containing the same executable. build.sh Builds both the plain executable and the app bundle. Permission reset and TCC testing scripts. The project demonstrates the behavior consistently. Steps to Reproduce Plain Executable Build: ./build.sh Reset screen capture permissions: sudo tccutil reset ScreenCapture Run: ./screen_capture_executable Before granting: screenshots show desktop only. Grant permission when prompted. After granting: full screenshots. Executable does not appear in “Screen & System Audio Recording”. Bundled App Build (if not already built): ./build.sh Reset permissions (optional): sudo tccutil reset ScreenCapture Run: open ScreenCaptureApp.app Before granting: screenshots show desktop. After granting: full screenshots. App bundle appears in the System Settings list. Additional Check I also tested launching the plain executable as a child process of another executable, similar to how some software architectures work. Result: Permission prompt appears Permission can be granted Executable still does not appear in the UI, even though TCC tracks it internally → consistent with the plain-executable behaviour. This reinforces that only app bundles are listed. Questions for Apple Is the removal of plain executables from “Screen & System Audio Recording” an intentional change in macOS Tahoe? If so, does Apple now require all screen-recording capable binaries to be packaged as .app bundles for the UI to display them? Is there a supported method for making a plain executable (launched by a parent process) appear in the list? If this is not intentional, what is the recommended path for reporting this as a regression? Files Unfortunately, I have discovered the zip file that contains my reproduction project can't be directly uploaded here. Here is a Google Drive link instead: https://drive.google.com/file/d/1sXsr3Q0g6_UzlOIL54P5wbS7yBkpMJ7A/view?usp=sharing Thank you for taking the time to review this. Any insight into whether this change is intentional or a regression would be very helpful.
3
0
1k
Dec ’25
ScreenCaptureKit sample initially omits application with NSWindowSharingType NSWindowSharingNone
The ScreenCaptureKit sample application (https://developer.apple.com/documentation/screencapturekit/capturing-screen-content-in-macos) uses a filter initially set to capture content from the selected display, excluding only the sample application, and excepting no windows: private var contentFilter: SCContentFilter { var filter: SCContentFilter switch captureType { case .display: guard let display = selectedDisplay else { fatalError("No display selected.") } var excludedApps = [SCRunningApplication]() // If a user chooses to exclude the app from the stream, // exclude it by matching its bundle identifier. if isAppExcluded { excludedApps = availableApps.filter { app in Bundle.main.bundleIdentifier == app.bundleIdentifier } } // Create a content filter with excluded apps. filter = SCContentFilter(display: display, excludingApplications: excludedApps, exceptingWindows: []) ....... return filter However, if another application uses the legacy NSWindowSharingType NSWindowSharingNone attribute, that application is initially not included in the captured stream. Only by toggling either the "Capture Type" or "Exclude sample from stream" checkbox does the initially hidden application become visible. Additionally, if the "Stop Capture" button is used followed by "Start Capture", the application using the legacy NSWindowSharingType NSWindowSharingNone attribute is once again hidden from the stream, and is only made visible by toggling either the "Capture Type" or "Exclude sample from stream" checkbox. Does some additional filter element or other SCStream configuration need to be included to verify that all applications, regardless of NSWindowSharingType, are captured using ScreenCaptureKit without requiring manual user interaction/filter refreshing? It seems odd that QuickTime screen recording (using ScreenCaptureKit) immediately captures an application using the NSWindowSharingNone attribute while the ScreenCaptureKit sample application linked above does not. See images below showing the stream preview before and after toggling the "Capture Type" or "Exclude sample from stream" checkbox. Images were taken from a QuickTime screen recording during testing.
2
0
264
Nov ’25
Value of type 'SCRecordingOutput' has no member 'delegate'
Hello, I am trying to capture screen recording ( output.mp4 ) using ScreenCaptureKit and also the mouse positions during the recording ( mouse.json ). The recording and the mouse positions ( tracked based on mouse movements events only ) needs to be perfectly synced in order to add effects in post editing. I started off by using the await stream?.startCapture() and after that starting my mouse tracking function :- try await captureEngine.startCapture(configuration: config, filter: filter, recordingOutput: recordingOutput) let captureStartTime = Date() mouseTracker?.startTracking(with: captureStartTime) But every time I tested, there is a clear inconsistency in sync between the recorded video and the recorded mouse positions. The only thing I want is to know when exactly does the recording "actually" started so that I can start the mouse capture at that same time, and thus I tried using the Delegates, but being able to set them up perfectly. import Foundation import AVFAudio import ScreenCaptureKit import OSLog import Combine class CaptureEngine: NSObject, @unchecked Sendable { private let logger = Logger() private(set) var stream: SCStream? private var streamOutput: CaptureEngineStreamOutput? private var recordingOutput: SCRecordingOutput? private let videoSampleBufferQueue = DispatchQueue(label: "com.francestudio.phia.VideoSampleBufferQueue") private let audioSampleBufferQueue = DispatchQueue(label: "com.francestudio.phia.AudioSampleBufferQueue") private let micSampleBufferQueue = DispatchQueue(label: "com.francestudio.phia.MicSampleBufferQueue") func startCapture(configuration: SCStreamConfiguration, filter: SCContentFilter, recordingOutput: SCRecordingOutput) async throws { // Create the stream output delegate. let streamOutput = CaptureEngineStreamOutput() self.streamOutput = streamOutput do { stream = SCStream(filter: filter, configuration: configuration, delegate: streamOutput) try stream?.addStreamOutput(streamOutput, type: .screen, sampleHandlerQueue: videoSampleBufferQueue) try stream?.addStreamOutput(streamOutput, type: .audio, sampleHandlerQueue: audioSampleBufferQueue) try stream?.addStreamOutput(streamOutput, type: .microphone, sampleHandlerQueue: micSampleBufferQueue) self.recordingOutput = recordingOutput recordingOutput.delegate = self try stream?.addRecordingOutput(recordingOutput) try await stream?.startCapture() } catch { logger.error("Failed to start capture: \(error.localizedDescription)") throw error } } func stopCapture() async throws { do { try await stream?.stopCapture() } catch { logger.error("Failed to stop capture: \(error.localizedDescription)") throw error } } func update(configuration: SCStreamConfiguration, filter: SCContentFilter) async { do { try await stream?.updateConfiguration(configuration) try await stream?.updateContentFilter(filter) } catch { logger.error("Failed to update the stream session: \(String(describing: error))") } } func stopRecordingOutputForStream(_ recordingOutput: SCRecordingOutput) throws { try self.stream?.removeRecordingOutput(recordingOutput) } } // MARK: - SCRecordingOutputDelegate extension CaptureEngine: SCRecordingOutputDelegate { func recordingOutputDidStartRecording(_ recordingOutput: SCRecordingOutput) { let startTime = Date() logger.info("Recording output did start recording \(startTime)") } func recordingOutputDidFinishRecording(_ recordingOutput: SCRecordingOutput) { logger.info("Recording output did finish recording") } func recordingOutput(_ recordingOutput: SCRecordingOutput, didFailWithError error: any Error) { logger.error("Recording output failed with error: \(error.localizedDescription)") } } private class CaptureEngineStreamOutput: NSObject, SCStreamOutput, SCStreamDelegate { private let logger = Logger() override init() { super.init() } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { guard sampleBuffer.isValid else { return } switch outputType { case .screen: break case .audio: break case .microphone: break @unknown default: logger.error("Encountered unknown stream output type:") } } func stream(_ stream: SCStream, didStopWithError error: Error) { logger.error("Stream stopped with error: \(error.localizedDescription)") } } I am getting error Value of type 'SCRecordingOutput' has no member 'delegate' Even though I am targeting macOs 15+ ( macOs 26 actually ) and macOs only. What is the best way to achieving the desired result? Is there any other / better way to do it?
1
0
293
Oct ’25
Recorded video looks blurry, color-washed, low bitrate, compressed using ScreenCaptureKit
Hello everyone, I am trying to implement ScreenCaptureKit into my project, I am using MacOs 26 for the target version and followed this official project from apple regarding the screencapture kit. https://developer.apple.com/documentation/ScreenCaptureKit/capturing-screen-content-in-macos I used the official exact code and implemented in my app, but the results are not good. The video look blurry, unclear, lost colors and its like 720p honestly. The 1st video frame t is result when I integrate it in my app. After that, I used another app ( which was built in electron, they were using screencapturekit as well ) and there results were a lot better. The 2nd video frame is when I recorded using their application. It appears as close to as system display I tried multiple things, but no impressive results. For my purpose, I want to the final recorded video to be as good as the display quality of the system. I also applied .hdr local display and coronolicial, but no help with that as well. Changed codecs to .mov, .hevc, but still no help Why is not the recoded video as high quality as the display
3
0
433
Oct ’25
Take correctly sized screenshots with ScreenCaptureKit
I've been using CGWindowListCreateImage which automatically creates an image with the size of the captured window. But SCScreenshotManager.captureImage(contentFilter:configuration:) always creates images with the width and height specified in the provided SCStreamConfiguration. I could be setting the size explicitly by reading SCWindow.frame or SCContentFilter.contentRect and multiplying the width and height by SCContentFilter.pointPixelScale , but it won't work if I want to keep the window shadow with SCStreamConfiguration.ignoreShadowsSingleWindow = false. Is there a way and what's the best way to take full-resolution screenshots of the correct size? import Cocoa import ScreenCaptureKit class ViewController: NSViewController { @IBOutlet weak var imageView: NSImageView! override func viewDidAppear() { imageView.imageScaling = .scaleProportionallyUpOrDown view.wantsLayer = true view.layer!.backgroundColor = .init(red: 1, green: 0, blue: 0, alpha: 1) Task { let windows = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true).windows let window = windows[0] let filter = SCContentFilter(desktopIndependentWindow: window) let configuration = SCStreamConfiguration() configuration.ignoreShadowsSingleWindow = false configuration.showsCursor = false configuration.width = Int(Float(filter.contentRect.width) * filter.pointPixelScale) configuration.height = Int(Float(filter.contentRect.height) * filter.pointPixelScale) print(filter.contentRect) let windowImage = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration) imageView.image = NSImage(cgImage: windowImage, size: CGSize(width: windowImage.width, height: windowImage.height)) } } }
5
0
975
Oct ’25
SCStreamUpdateFrameContentRect X coordinate always returns 48 instead of expected 0
SCStreamUpdateFrameContentRect X coordinate always returns 48 instead of expected 0 Environment Device: MacBook Pro 13-inch macOS: Sequoia 15.6.1 Xcode: 16.4 Framework: Screen Capture Kit Issue Description I'm experiencing an unexpected behavior with Screen Capture Kit where the SCStreamUpdateFrameContentRect X coordinate consistently returns 48 instead of the expected 0. Code Context I'm using SCContentSharingPicker to capture screen content and implementing the SCStreamOutput protocol to receive frame data. In my stream(_:didOutputSampleBuffer:of:) method, I'm extracting the content rect information from the sample buffer attachments: func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { switch type { case .screen: guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]] else { return } guard let attachments = attachmentsArray.first else { return } if !attachments.keys.contains(.contentRect) { return } print(attachments) // X coordinate always shows 48 /* }, __C.SCStreamFrameInfo(_rawValue: SCStreamUpdateFrameContentRect): { Height = 540; Width = 864; X = 48; <<-- unexpected value Y = 0; }] */ return // ... other cases } } Expected vs Actual Behavior Expected: X coordinate should be 0 (indicating the content starts at the left edge of the screen) Actual: X coordinate is consistently 48 Visual verification: When I display the captured screen content, it appears correctly without any offset, suggesting the actual content should indeed start at X=0 Main ViewModel Class import Foundation import ScreenCaptureKit import SwiftUICore class VM: NSObject, ObservableObject, SCContentSharingPickerObserver, SCStreamDelegate, SCStreamOutput { @State var isRecording = false // Error handling delegate func stream(_ stream: SCStream, didStopWithError error: Error) { DispatchQueue.main.async { self.isRecording = false } } var picker: SCContentSharingPicker? func createPicker() -> SCContentSharingPicker { if let p = picker { return p } let picker = SCContentSharingPicker.shared var config = SCContentSharingPicker.shared.defaultConfiguration //SCContentSharingPickerConfiguration() config.allowedPickerModes = .singleDisplay config.allowsChangingSelectedContent = false config.excludedBundleIDs.append(Bundle.main.bundleIdentifier!) picker.add(self) picker.isActive = true SCContentSharingPicker.shared.present(using: .display) return picker } var stream: SCStream? let videoSampleBufferQueue = DispatchQueue(label: "com.example.apple-samplecode.VideoSampleBufferQueue") // observer call back for picker func contentSharingPicker(_ picker: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream?) { if let stream = stream { stream.updateContentFilter(filter) } else { let config = SCStreamConfiguration() config.capturesAudio = false config.captureMicrophone = false config.captureResolution = .automatic config.captureDynamicRange = .SDR config.showMouseClicks = false config.showsCursor = false // Set the frame rate for screen capture config.minimumFrameInterval = CMTime(value: 1, timescale: 5) self.stream = SCStream(filter: filter, configuration: config, delegate: self) do { try self.stream?.addStreamOutput(self, type: .screen, sampleHandlerQueue: self.videoSampleBufferQueue) } catch { print("\(error)") } self.stream?.updateContentFilter(filter) DispatchQueue.main.async { self.stream?.startCapture() } } } func contentSharingPicker(_ picker: SCContentSharingPicker, didCancelFor stream: SCStream?) {} func contentSharingPickerStartDidFailWithError(_ error: any Error) { print(error) } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { switch type { case .screen: guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]] else { return } guard let attachments = attachmentsArray.first else { return } if !attachments.keys.contains(.contentRect) { return } print(attachments) return case .audio: return case .microphone: return @unknown default: return } } func outputVideoEffectDidStart(for stream: SCStream) { print("outputVideoEffectDidStart") } func outputVideoEffectDidStop(for stream: SCStream) { print("outputVideoEffectDidStop") } func streamDidBecomeActive(_ stream: SCStream) { print("streamDidBecomeActive") } func streamDidBecomeInactive(_ stream: SCStream) { print("streamDidBecomeInactive") } }
1
0
117
Sep ’25
SCStreamUpdateFrameContentRect X coordinate always returns 48 instead of expected 0
SCStreamUpdateFrameContentRect X coordinate always returns 48 instead of expected 0 Environment Device: MacBook Pro 13-inch macOS: Sequoia 15.6.1 Xcode: 16.4 Framework: Screen Capture Kit Issue Description I'm experiencing an unexpected behavior with Screen Capture Kit where the SCStreamUpdateFrameContentRect X coordinate consistently returns 48 instead of the expected 0. Code Context I'm using SCContentSharingPicker to capture screen content and implementing the SCStreamOutput protocol to receive frame data. In my stream(_:didOutputSampleBuffer:of:) method, I'm extracting the content rect information from the sample buffer attachments: func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { switch type { case .screen: guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]] else { return } guard let attachments = attachmentsArray.first else { return } if !attachments.keys.contains(.contentRect) { return } print(attachments) // X coordinate always shows 48 /* , __C.SCStreamFrameInfo(_rawValue: SCStreamUpdateFrameContentRect): { Height = 540; Width = 864; X = 48; <<-- unexpected offset Y = 0; }] */ return // ... other cases } } Expected vs Actual Behavior Expected: X coordinate should be 0 (indicating the content starts at the left edge of the screen) Actual: X coordinate is consistently 48 Visual verification: When I display the captured screen content, it appears correctly without any offset, suggesting the actual content should indeed start at X=0 Additional Information The picker is configured with .singleDisplay mode I'm excluding the current app's bundle ID from capture The captured content visually appears correct, only the reported coordinates seem off Main ViewModel Class import Foundation import ScreenCaptureKit import SwiftUICore class VM: NSObject, ObservableObject, SCContentSharingPickerObserver, SCStreamDelegate, SCStreamOutput { @State var isRecording = false // Error handling delegate func stream(_ stream: SCStream, didStopWithError error: Error) { DispatchQueue.main.async { self.isRecording = false } } var picker: SCContentSharingPicker? func createPicker() -> SCContentSharingPicker { if let p = picker { return p } let picker = SCContentSharingPicker.shared picker.add(self) picker.isActive = true SCContentSharingPicker.shared.present(using: .display) return picker } var stream: SCStream? let videoSampleBufferQueue = DispatchQueue(label: "com.example.apple-samplecode.VideoSampleBufferQueue") // observer call back for picker func contentSharingPicker(_ picker: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream?) { if let stream = stream { stream.updateContentFilter(filter) } else { let config = SCStreamConfiguration() config.capturesAudio = false config.captureMicrophone = false config.captureResolution = .automatic config.captureDynamicRange = .SDR config.showMouseClicks = false config.showsCursor = false // Set the frame rate for screen capture config.minimumFrameInterval = CMTime(value: 1, timescale: 5) // 10 FPS self.stream = SCStream(filter: filter, configuration: config, delegate: self) do { try self.stream?.addStreamOutput(self, type: .screen, sampleHandlerQueue: self.videoSampleBufferQueue) } catch { print("\(error)") } self.stream?.updateContentFilter(filter) DispatchQueue.main.async { self.stream?.startCapture() } } } func contentSharingPicker(_ picker: SCContentSharingPicker, didCancelFor stream: SCStream?) {} func contentSharingPickerStartDidFailWithError(_ error: any Error) { print(error) } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { switch type { case .screen: guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]] else { return } guard let attachments = attachmentsArray.first else { return } if !attachments.keys.contains(.contentRect) { return } print(attachments) return case .audio: return case .microphone: return @unknown default: return } } func outputVideoEffectDidStart(for stream: SCStream) { print("outputVideoEffectDidStart") } func outputVideoEffectDidStop(for stream: SCStream) { print("outputVideoEffectDidStop") } func streamDidBecomeActive(_ stream: SCStream) { print("streamDidBecomeActive") } func streamDidBecomeInactive(_ stream: SCStream) { print("streamDidBecomeInactive") } }
0
0
81
Sep ’25
Unable to capture only the cursor in macOS Tahoe
Precondition: In system settings, scale the pointer size up to the max. Our SCScreenshotManager code currently works in macOS 15 and earlier to capture the cursor at it's larger size, but broke in one of the minor releases of macOS Tahoe. The error it produces now is "Failed to start stream due to audio/video capture failure". This only seems to happen with the cursor window, not any others. Another way to get the cursor is with https://developer.apple.com/documentation/appkit/nscursor/currentsystem, but that is now deprecated, which makes me think the capture of the cursor is being blocked deliberately. We see this as a critical loss of functionality for our apps, and could use guidance on what to use instead.
Replies
0
Boosts
0
Views
68
Activity
1d
Building Real-Time Voice Input on macOS 26 with SpeechAnalyzer + ScreenCaptureKit
We built an open-source macOS menu bar app that turns speech into text and pastes it into the active app — using SpeechAnalyzer for on-device transcription, ScreenCaptureKit + Vision for screen-aware context, and FluidAudio for speaker diarization in meeting mode. Here's what we learned shipping it on macOS 26. GitHub: github.com/Marvinngg/ambient-voice Architecture The app has two modes: hotkey dictation (press to talk, release to inject) and meeting recording (continuous transcription with a floating panel). Dictation Mode Audio capture uses AVCaptureSession (more on why below). The captured audio feeds into SpeechAnalyzer via an AsyncStream: let transcriber = SpeechTranscriber( locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults, .alternativeTranscriptions], attributeOptions: [.audioTimeRange, .transcriptionConfidence] ) let analyzer = SpeechAnalyzer(modules: [transcriber]) let (inputSequence, inputBuilder) = AsyncStream.makeStream() try await analyzer.start(inputSequence: inputSequence) While recording, we capture a screenshot of the focused window using ScreenCaptureKit, run Vision OCR (VNRecognizeTextRequest), extract keywords, and inject them into SpeechAnalyzer as contextual bias: let context = AnalysisContext() context.contextualStrings[.general] = ocrKeywords try await analyzer.setContext(context) This improves accuracy for technical terms and proper nouns visible on screen. If your screen shows "SpeechAnalyzer", saying it out loud is more likely to be transcribed correctly. After transcription, an optional L2 step sends the text through a local LLM (ollama) for spoken-to-written cleanup, then CGEvent simulates Cmd+V to paste into the active app. Meeting Mode Meeting mode forks the same audio stream to two consumers: SpeechAnalyzer — real-time streaming transcription, displayed in a floating NSPanel FluidAudio buffer — accumulates 16kHz Float32 mono samples for batch speaker diarization after recording stops When the user ends the meeting, FluidAudio's performCompleteDiarization() runs on the accumulated audio. We align transcription segments with speaker segments using audioTimeRange overlap matching — each transcription segment gets assigned the speaker ID with the most time overlap. Results export to Markdown. Pitfalls We Hit on macOS 26 1. AVAudioEngine installTap doesn't fire with Bluetooth devices We started with AVAudioEngine.inputNode.installTap() for audio capture. It worked fine with built-in mics but the tap callback never fired with Bluetooth devices (tested with vivo TWS 4 Hi-Fi). Fix: switched to AVCaptureSession. The delegate callback captureOutput(_:didOutput:from:) fires reliably regardless of audio device. The tradeoff is you get CMSampleBuffer instead of AVAudioPCMBuffer, so you need a conversion step. 2. NSEvent addGlobalMonitorForEvents crashes Our global hotkey listener used NSEvent.addGlobalMonitorForEvents. On macOS 26, this crashes with a Bus error inside GlobalObserverHandler — appears to be a Swift actor runtime issue. Fix: switched to CGEventTap. Works reliably, but the callback runs on a CFRunLoop context, which Swift doesn't recognize as MainActor. 3. CGEventTap callbacks aren't on MainActor If your CGEventTap callback touches any @MainActor state, you'll get concurrency violations. The callback runs on whatever thread owns the CFRunLoop. Fix: bridge with DispatchQueue.main.async {} inside the tap callback before touching any MainActor state. 4. CGPreflightScreenCaptureAccess doesn't request permission We used CGPreflightScreenCaptureAccess() as a guard before calling ScreenCaptureKit. If it returned false, we'd bail out. The problem: this function only checks — it never triggers macOS to add your app to the Screen Recording permission list. Chicken-and-egg: you can't get permission because you never ask for it. Fix: call CGRequestScreenCaptureAccess() at app startup. This adds your app to System Settings → Screen Recording. Then let ScreenCaptureKit calls proceed without the preflight guard — SCShareableContent will also trigger the permission prompt on first use. 5. Ad-hoc signing breaks TCC permissions on every rebuild During development, codesign --sign - (ad-hoc) generates a different code directory hash on every build. macOS TCC tracks permissions by this hash, so every rebuild = new app identity = all permissions reset. Fix: sign with a stable certificate. If you have an Apple Development certificate, use that. The TeamIdentifier stays constant across rebuilds, so TCC permissions persist. We also discovered that launching via open WE.app (LaunchServices) instead of directly executing the binary is required — otherwise macOS attributes TCC permissions to Terminal, not your app. Benchmarks We ran end-to-end benchmarks on public datasets (Mac Mini M4 16GB, macOS 26): Transcription (SpeechAnalyzer, AliMeeting Chinese): • Near-field CER 34% (excluding outliers ~25%) • Far-field CER 40% (single channel, no beamforming, >30% overlap) • Processing speed 74-89x real-time Speaker diarization (FluidAudio offline): • AMI English 16 meetings: avg DER 23.2% (collar=0.25s, ignoreOverlap=True) • AliMeeting Chinese 8 meetings: DER 48.5% (including overlap regions) • Memory: RSS ~500MB, peak 730-930MB Full evaluation methodology, scripts, and raw results are in the repo. Open Source The project is MIT licensed: github.com/Marvinngg/ambient-voice It includes the macOS client (Swift 6.2, SPM), server-side distillation/training scripts (Python), and a complete evaluation framework with reproducible benchmarks. Feedback and contributions welcome.
Replies
0
Boosts
0
Views
152
Activity
1d
Building Real-Time Voice Input on macOS 26 with SpeechAnalyzer + ScreenCaptureKit
We built an open-source macOS menu bar app that turns speech into text and pastes it into the active app — using SpeechAnalyzer for on-device transcription, ScreenCaptureKit + Vision for screen-aware context, and FluidAudio for speaker diarization in meeting mode. Here's what we learned shipping it on macOS 26. GitHub: github.com/Marvinngg/ambient-voice Architecture The app has two modes: hotkey dictation (press to talk, release to inject) and meeting recording (continuous transcription with a floating panel). Dictation Mode Audio capture uses AVCaptureSession (more on why below). The captured audio feeds into SpeechAnalyzer via an AsyncStream: let transcriber = SpeechTranscriber( locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults, .alternativeTranscriptions], attributeOptions: [.audioTimeRange, .transcriptionConfidence] ) let analyzer = SpeechAnalyzer(modules: [transcriber]) let (inputSequence, inputBuilder) = AsyncStream.makeStream() try await analyzer.start(inputSequence: inputSequence) While recording, we capture a screenshot of the focused window using ScreenCaptureKit, run Vision OCR (VNRecognizeTextRequest), extract keywords, and inject them into SpeechAnalyzer as contextual bias: let context = AnalysisContext() context.contextualStrings[.general] = ocrKeywords try await analyzer.setContext(context) This improves accuracy for technical terms and proper nouns visible on screen. If your screen shows "SpeechAnalyzer", saying it out loud is more likely to be transcribed correctly. After transcription, an optional L2 step sends the text through a local LLM (ollama) for spoken-to-written cleanup, then CGEvent simulates Cmd+V to paste into the active app. Meeting Mode Meeting mode forks the same audio stream to two consumers: SpeechAnalyzer — real-time streaming transcription, displayed in a floating NSPanel FluidAudio buffer — accumulates 16kHz Float32 mono samples for batch speaker diarization after recording stops When the user ends the meeting, FluidAudio's performCompleteDiarization() runs on the accumulated audio. We align transcription segments with speaker segments using audioTimeRange overlap matching — each transcription segment gets assigned the speaker ID with the most time overlap. Results export to Markdown. Pitfalls We Hit on macOS 26 1. AVAudioEngine installTap doesn't fire with Bluetooth devices We started with AVAudioEngine.inputNode.installTap() for audio capture. It worked fine with built-in mics but the tap callback never fired with Bluetooth devices (tested with vivo TWS 4 Hi-Fi). Fix: switched to AVCaptureSession. The delegate callback captureOutput(_:didOutput:from:) fires reliably regardless of audio device. The tradeoff is you get CMSampleBuffer instead of AVAudioPCMBuffer, so you need a conversion step. 2. NSEvent addGlobalMonitorForEvents crashes Our global hotkey listener used NSEvent.addGlobalMonitorForEvents. On macOS 26, this crashes with a Bus error inside GlobalObserverHandler — appears to be a Swift actor runtime issue. Fix: switched to CGEventTap. Works reliably, but the callback runs on a CFRunLoop context, which Swift doesn't recognize as MainActor. 3. CGEventTap callbacks aren't on MainActor If your CGEventTap callback touches any @MainActor state, you'll get concurrency violations. The callback runs on whatever thread owns the CFRunLoop. Fix: bridge with DispatchQueue.main.async {} inside the tap callback before touching any MainActor state. 4. CGPreflightScreenCaptureAccess doesn't request permission We used CGPreflightScreenCaptureAccess() as a guard before calling ScreenCaptureKit. If it returned false, we'd bail out. The problem: this function only checks — it never triggers macOS to add your app to the Screen Recording permission list. Chicken-and-egg: you can't get permission because you never ask for it. Fix: call CGRequestScreenCaptureAccess() at app startup. This adds your app to System Settings → Screen Recording. Then let ScreenCaptureKit calls proceed without the preflight guard — SCShareableContent will also trigger the permission prompt on first use. 5. Ad-hoc signing breaks TCC permissions on every rebuild During development, codesign --sign - (ad-hoc) generates a different code directory hash on every build. macOS TCC tracks permissions by this hash, so every rebuild = new app identity = all permissions reset. Fix: sign with a stable certificate. If you have an Apple Development certificate, use that. The TeamIdentifier stays constant across rebuilds, so TCC permissions persist. We also discovered that launching via open WE.app (LaunchServices) instead of directly executing the binary is required — otherwise macOS attributes TCC permissions to Terminal, not your app. Benchmarks We ran end-to-end benchmarks on public datasets (Mac Mini M4 16GB, macOS 26): Transcription (SpeechAnalyzer, AliMeeting Chinese): • Near-field CER 34% (excluding outliers ~25%) • Far-field CER 40% (single channel, no beamforming, >30% overlap) • Processing speed 74-89x real-time Speaker diarization (FluidAudio offline): • AMI English 16 meetings: avg DER 23.2% (collar=0.25s, ignoreOverlap=True) • AliMeeting Chinese 8 meetings: DER 48.5% (including overlap regions) • Memory: RSS ~500MB, peak 730-930MB Full evaluation methodology, scripts, and raw results are in the repo. Open Source The project is MIT licensed: github.com/Marvinngg/ambient-voice It includes the macOS client (Swift 6.2, SPM), server-side distillation/training scripts (Python), and a complete evaluation framework with reproducible benchmarks. Feedback and contributions welcome.
Replies
0
Boosts
0
Views
160
Activity
1d
ScreenCaptureKit permissions lost after every build — solved by switching signing identity
Sharing a solution for a problem that took me a while to figure out. Problem: During development of a macOS 26 app that uses ScreenCaptureKit, the screen capture permissions were being reset after every build. Each time I compiled and ran the app from Xcode, I had to re-authorize screen capture in System Settings. CGPreflightScreenCaptureAccess() would return false even though I'd just granted permission minutes ago. Root cause: I was using ad-hoc code signing during development. macOS ties screen capture permissions to the app's code signing identity. With ad-hoc signing, the identity changes on every build, so the system treats each build as a "new" app. Solution: Switch to an Apple Development certificate for debug builds. In Xcode: Build Settings → Code Signing Identity → Debug → set to "Apple Development" Make sure your development team is selected After this change, the signing identity remains stable across builds, and screen capture permissions persist. This might be related to the broader issue discussed in this forum about ScreenCapture permissions disappearing — if other developers are seeing permissions vanish, it's worth checking whether the code signing identity is changing between sessions.
Replies
1
Boosts
0
Views
238
Activity
1d
ScreenCapture permissions disappear and don't return
On Tahoe and earlier, ScreenCapture permissions can disappear and not return. Customers are having an issue with this disappearing and when our code executes CGRequestScreenCaptureAccess() nothing happens, the prompt does not appear. I can reproduce this by using the "-" button and removing the entry in the settings, then adding it back with the "+" button. CGPreflightScreenCaptureAccess() always returns the correct value but once the entry has been removed, CGRequestScreenCaptureAccess() requires a reboot before it will work again.
Replies
3
Boosts
0
Views
309
Activity
3d
Requested access to the Persistent Content Capture Entitlement
Two months ago I requested the subject entitlement. I'm still waiting for it to be added to our account. Who or how can I find out what going on with it. I have no correspondence from Apple yet saying it was denied and why. https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.persistent-content-capture?language=objc Thank you.
Replies
1
Boosts
0
Views
154
Activity
3w
Cannot capture HDR content using ScreenCaptureKit
With the help of Claude and Codex, I've tried upgrading a screen magnification, capture, and pixel peeping app (SnoopX, from Stanford's software repository) to be HDR-aware. For images that are truly HDR (displayed in Apple Photos, Google Photos in Chrome, or Mac Preview), and on an HDR monitor with plenty of EDR headroom (Apple XDR display), I should see pixel values well above 1.0. Apple's Digital Color Meter does. However, my app does not. I think the code is doing all the right things with ScreenCaptureKit, but it never returns values above 1.0. Has anybody gotten this to work? Here is what Codex says about the code it helped me build in my app: • Set captureDynamicRange: We set config​.capture​Dynamic​Range = ​SCCapture​Dynamic​Range​HDRLocal​Display (when available). Note: Gemini’s SCCapture​Mode​HDRLocal​Display isn’t a real symbol — the actual API is SCCapture​Dynamic​Range. • Use preset: We try init​With​Preset: ​SCStream​Configuration​Preset​Capture​HDRStream​Local​Display, but your runtime crashes on init​With​Preset: (unrecognized selector), so we fall back to manual config. That means your system SDK headers mention the preset, but the runtime doesn’t implement it. • Pixel format: We explicitly set k​CVPixel​Format​Type​_64​RGBAHalf. Your logs show the stream buffer is 64RGBAHalf. • Enable HDR on display: EDR headroom logs show current=6.40, so HDR is active. • Color space: We set Extended Linear Display P3 for stream, and we forced the screenshot sampler to convert into extended‑linear P3 before reading. The screenshot itself reports Extended sRGB, but conversion doesn’t yield >1.0 either. Despite all of that, the OS is still delivering tone‑mapped values (max 1.0000) for both the stream and the screenshot path. So I don’t think there’s a missing knob here; it looks like a system‑level limitation on Tahoe for desktop capture.
Replies
1
Boosts
0
Views
152
Activity
Feb ’26
ScreenCaptureKit recording output is corrupted when captureMicrophone is true
Hello everyone, I'm working on a screen recording app using ScreenCaptureKit and I've hit a strange issue. My app records the screen to an .mp4 file, and everything works perfectly until the .captureMicrophone is false In this case, I get a valid, playable .mp4 file. However, as soon as I try to enable the microphone by setting streamConfig.captureMicrophone = true, the recording seems to work, but the final .mp4 file is corrupted and cannot be played by QuickTime or any other player. This happens whether capturesAudio (app audio) is on or off. I've already added the "Privacy - Microphone Usage Description" (NSMicrophoneUsageDescription) to my Info.plist, so I don't think it's a permissions problem. I have my logic split into a ScreenRecorder class that manages state and a CaptureEngine that handles the SCStream. Here is how I'm configuring my SCStream: ScreenRecorder.swift // This is my main SCStreamConfiguration private var streamConfiguration: SCStreamConfiguration { var streamConfig = SCStreamConfiguration() // ... other HDR/preset config ... // These are the problem properties streamConfig.capturesAudio = isAudioCaptureEnabled streamConfig.captureMicrophone = isMicCaptureEnabled // breaks it if true streamConfig.excludesCurrentProcessAudio = false streamConfig.showsCursor = false if let region = selectedRegion, let display = currentDisplay { // My region/frame logic (works fine) let regionWidth = Int(region.frame.width) let regionHeight = Int(region.frame.height) streamConfig.width = regionWidth * scaleFactor streamConfig.height = regionHeight * scaleFactor // ... (sourceRect logic) ... } streamConfig.pixelFormat = kCVPixelFormatType_32BGRA streamConfig.colorSpaceName = CGColorSpace.sRGB streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: 60) return streamConfig } And here is how I'm setting up the SCRecordingOutput that writes the file: ScreenRecorder.swift private func initRecordingOutput(for region: ScreenPickerManager.SelectedRegion) throws { let screeRecordingOutputURL = try RecordingWorkspace.createScreenRecordingVideoFile( in: workspaceURL, sessionIndex: sessionIndex ) let recordingConfiguration = SCRecordingOutputConfiguration() recordingConfiguration.outputURL = screeRecordingOutputURL recordingConfiguration.outputFileType = .mp4 recordingConfiguration.videoCodecType = .hevc let recordingOutput = SCRecordingOutput(configuration: recordingConfiguration, delegate: self) self.recordingOutput = recordingOutput } Finally, my CaptureEngine adds these to the SCStream: CaptureEngine.swift class CaptureEngine: NSObject, @unchecked Sendable { private(set) var stream: SCStream? private var streamOutput: CaptureEngineStreamOutput? // ... (dispatch queues) ... func startCapture(configuration: SCStreamConfiguration, filter: SCContentFilter, recordingOutput: SCRecordingOutput) async throws { let streamOutput = CaptureEngineStreamOutput() self.streamOutput = streamOutput do { stream = SCStream(filter: filter, configuration: configuration, delegate: streamOutput) // Add outputs for raw buffers (not used for file recording) try stream?.addStreamOutput(streamOutput, type: .screen, sampleHandlerQueue: videoSampleBufferQueue) try stream?.addStreamOutput(streamOutput, type: .audio, sampleHandlerQueue: audioSampleBufferQueue) try stream?.addStreamOutput(streamOutput, type: .microphone, sampleHandlerQueue: micSampleBufferQueue) // Add the file recording output try stream?.addRecordingOutput(recordingOutput) try await stream?.startCapture() } catch { logger.error("Failed to start capture: \(error.localizedDescription)") throw error } } // ... (stopCapture, etc.) ... } When I had the .captureMicrophone value to be false, I get a perfect .mp4 video playable everywhere, however, when its true, I am getting corrupted video which doesn't play at all :-
Replies
1
Boosts
0
Views
489
Activity
Feb ’26
Capturing screen buffer at macOS Login Window with ScreenCaptureKit and PrivilegedHelper
I am developing a remote support tool for macOS. While we have successfully implemented a Privileged Helper Tool and LaunchDaemon architecture that works within an active Aqua session, we have observed a total failure to capture the screen buffer or receive input at the macOS Login Window. Our observation of competitor software (AnyDesk, TeamViewer) shows they maintain graphical continuity through logout/restart. We are seeking the official architectural path to replicate this system-level access. Current Technical Implementation Architecture: A root-level LaunchDaemon manages the persistent network connection. A PrivilegedHelperTool (installed in /Library/PrivilegedHelperTools/) is used for elevated tasks. Environment: Tested on macOS 14.x (Sonoma) and macOS 15.x (Sequoia) on Apple Silicon. Capture Methods: We have implemented ScreenCaptureKit (SCK) as the primary engine and CGDisplayCreateImage as a fallback. Binary Status: All components are signed with a Developer ID and have been successfully Notarized. Observed Behavior & Blockers The "Aqua" Success: Within a logged-in user session, our CGI correctly identifies Display IDs and initializes the capture stream. Remote control is fully functional. The "Pre-Login" Failure: When the Mac is at the Login Window (no user logged in), the following occurs: The Daemon remains active, but the screen capture buffer returns NULL or an empty frame. ScreenCaptureKit fails to initialize, citing a lack of graphical context. No TCC (Transparency, Consent, and Control) prompt can appear because no user session exists. The "Bootstrap" Observation: We have identified that the loginwindow process exists in a restricted Mach bootstrap namespace that our Daemon (running in the System domain) cannot natively bridge. Comparative Analysis (Competitor Benchmarking) We have analyzed established remote desktop solutions like AnyDesk and Jump Desktop to understand their success at the login screen. Our findings suggest: Dual-Context Execution: They appear to use a Global LaunchAgent with LimitLoadToSessionType = ["LoginWindow"]. This allows a child process to run as root inside the login window’s graphical domain. Specialized Entitlements: These apps have migrated to the com.apple.developer.persistent-content-capture entitlement. This restricted capability allows them to bypass the weekly/monthly TCC re-authorization prompts and function in unattended scenarios where a user cannot click "Allow." Questions Entitlement Requirement: Is the persistent-content-capture entitlement the only supported way for a third-party app to capture the LoginWindow buffer without manual user intervention? LaunchAgent Strategy: To gain a graphical context at the login screen, is it recommended to load a specialized agent into the loginwindow domain via launchctl bootstrap loginwindow ...? ScreenCaptureKit vs. Legacy: Does ScreenCaptureKit officially support the LoginWindow session, or does it require an active Aqua session to initialize? MDM Bypass: For Enterprise environments, can a Privacy Preferences Policy Control (PPPC) payload grant "Screen Recording" to a non-entitled Daemon specifically for the login window context?
Replies
1
Boosts
0
Views
439
Activity
Jan ’26
SCK/replayd behaviors and delays
We're troubleshooting SCK issues. They occur with a relatively small amount of sessions, but lack of context and/or ability to advise the customer on how they could make behavior more predictable and reliable is problematic. Generally, there is 2 distinct issues which may or may not have the same root cause: Failure to establish SCK session. Usually manifests within the app as SCShareableContent.getWithCompletionHandler call either never invoking the completion handler, or taking prohibitively long time (we usually give it 3-10 sec before giving up). In the system log it may look like this: (log omitted - suspecting it triggers the content filter) Note the 6 seconds delay to completion of fetchShareableContentWithOption (normally it's a 30-40ms operation). Sometime, we'd see the stream established, but some minutes (or even hours) into the recording we'd stop receiving frames. Both scenarios are likely to occur when the disk space is low, with reliable repro of the problem #2 at below 8gb of free space (in that case, we've seen replayd silently dropping the session, without ever notifying the client ... improving API could go a long way there). However, out of recent occurrences, while most have less than 100GB available, we've seen it on machines with as much as 500GB free. Unfortunately, it's almost never reproducible in dev environment, so we have to rely on diagnostics we're able to collect in the field -- which nothing obvious yet. I'd like to understand the root cause of both scenarios better and/or how what specific frameworks can cause these behaviors.
Replies
0
Boosts
0
Views
558
Activity
Jan ’26
Why is ScreenCaptureKit throttled to about 7 fps?
I have an app that records a 32 x 32 rect under the cursor as the user moves it around and it sends it to Flutter. It suffers from major lag. Instead of getting 30 fps, I get about 7 fps. That is, there are significant lags between screen grabs. This on an Intel Mac mini x64 with 15.7.3 and one display. flutter: NATIVE: ExplodedView framesIn=2 timeSinceStart=1115.7ms gapSinceLastFrame=838.8ms flutter: NATIVE: ExplodedView framesIn=4 timeSinceStart=1382.6ms gapSinceLastFrame=149.9ms flutter: NATIVE: ExplodedView framesIn=5 timeSinceStart=1511.0ms gapSinceLastFrame=128.4ms flutter: NATIVE: ExplodedView framesIn=7 timeSinceStart=1698.3ms gapSinceLastFrame=102.9ms flutter: NATIVE: ExplodedView STOP polling totalTime=4482.6ms framesIn=28 framesSent=28 acks=28 Here's a testable excerpt: import ScreenCaptureKit import CoreMedia import CoreVideo import QuartzCore final class Test: NSObject, SCStreamOutput, SCStreamDelegate { private let q = DispatchQueue(label: "cap.q") private var stream: SCStream? private var lastFrameAt: CFTimeInterval = 0 private var frames = 0 func start() { SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: true) { content, err in guard err == nil, let display = content?.displays.first else { print("shareableContent error: \(String(describing: err))"); return } let filter = SCContentFilter(display: display, excludingWindows: []) let config = SCStreamConfiguration() config.showsCursor = false config.queueDepth = 1 config.minimumFrameInterval = CMTime(value: 1, timescale: 30) config.pixelFormat = kCVPixelFormatType_32BGRA config.width = 32 config.height = 32 config.sourceRect = CGRect(x: 100, y: 100, width: 32, height: 32) let s = SCStream(filter: filter, configuration: config, delegate: self) try! s.addStreamOutput(self, type: .screen, sampleHandlerQueue: self.q) self.stream = s s.startCapture { startErr in print("startCapture err=\(String(describing: startErr))") } // Optional: move sourceRect at 30Hz (cursor-follow simulation) Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { _ in let c2 = SCStreamConfiguration() c2.showsCursor = false c2.queueDepth = 1 c2.minimumFrameInterval = CMTime(value: 1, timescale: 30) c2.pixelFormat = kCVPixelFormatType_32BGRA c2.width = 32 c2.height = 32 let t = CACurrentMediaTime() c2.sourceRect = CGRect(x: 100 + (sin(t) * 50), y: 100, width: 32, height: 32) s.updateConfiguration(c2) { _ in } } } } func stream(_ stream: SCStream, didOutputSampleBuffer sb: CMSampleBuffer, of type: SCStreamOutputType) { guard type == .screen else { return } let now = CACurrentMediaTime() let gapMs = (lastFrameAt == 0) ? 0 : (now - lastFrameAt) * 1000 lastFrameAt = now frames += 1 if frames <= 10 || frames % 60 == 0 { print("frames=\(frames) gapMs=\(String(format: "%.1f", gapMs))") } } }
Replies
0
Boosts
0
Views
140
Activity
Dec ’25
Issue: Plain Executables Do Not Appear Under “Screen & System Audio Recording” on macOS 26.1 (Tahoe)
Summary I am investigating a change in macOS 26.1 (Tahoe) where plain (non-bundled) executables that request screen recording access no longer appear under: System Settings → Privacy & Security → Screen & System Audio Recording This behavior differs from macOS Sequoia, where these executables did appear in the list and could be managed through the UI. Tahoe still prompts for permission and still allows the executable to capture the screen once permission is granted, but the executable never shows up in the UI list. This breaks user expectations and removes UI-based permission management. To confirm the behavior, I created a small reproduction project with both: a plain executable, and an identical executable packaged inside an .app bundle. Only the bundled version appears in System Settings. Observed Behaviour 1. Plain Executable (from my reproduction project) When running a plain executable that captures the screen: macOS displays the normal screen-recording permission prompt. Before granting permission: screenshots show only the desktop background. After granting permission: screenshots capture the full display. The executable does not appear under “Screen & System Audio Recording”. Even when permission is granted manually (e.g., dragging the executable into the pane), the executable still does not appear, which prevents the user from modifying or revoking the permission through the UI. If the executable is launched from inside another app (e.g., VS Code, Terminal), the parent app appears in the list instead, not the executable itself. 2. Bundled App Version (from the reproduction project) I packaged the same code into a simple .app bundle (ScreenCaptureApp.app). When running the app: The same permission prompt appears. Pre-permission screenshots show the desktop background. Post-permission screenshots capture the full display. The app does appear under “Screen & System Audio Recording”. This bundle uses the same underlying executable — the only difference is packaging. Hypothesis macOS 26.1 (Tahoe) appears to require app bundles for an item to be shown in the Screen Recording privacy UI. Plain executables: still request and receive permission, still function correctly after permission is granted, but do not appear in the System Settings list. This may be an intentional change, undocumented behavior, or a regression. Reproduction Project The reproduction project includes: screen_capture.go A simple Go program that captures screenshots in a loop. screen_capture_executable Plain executable built from the Go source. ScreenCaptureApp.app/ App bundle containing the same executable. build.sh Builds both the plain executable and the app bundle. Permission reset and TCC testing scripts. The project demonstrates the behavior consistently. Steps to Reproduce Plain Executable Build: ./build.sh Reset screen capture permissions: sudo tccutil reset ScreenCapture Run: ./screen_capture_executable Before granting: screenshots show desktop only. Grant permission when prompted. After granting: full screenshots. Executable does not appear in “Screen & System Audio Recording”. Bundled App Build (if not already built): ./build.sh Reset permissions (optional): sudo tccutil reset ScreenCapture Run: open ScreenCaptureApp.app Before granting: screenshots show desktop. After granting: full screenshots. App bundle appears in the System Settings list. Additional Check I also tested launching the plain executable as a child process of another executable, similar to how some software architectures work. Result: Permission prompt appears Permission can be granted Executable still does not appear in the UI, even though TCC tracks it internally → consistent with the plain-executable behaviour. This reinforces that only app bundles are listed. Questions for Apple Is the removal of plain executables from “Screen & System Audio Recording” an intentional change in macOS Tahoe? If so, does Apple now require all screen-recording capable binaries to be packaged as .app bundles for the UI to display them? Is there a supported method for making a plain executable (launched by a parent process) appear in the list? If this is not intentional, what is the recommended path for reporting this as a regression? Files Unfortunately, I have discovered the zip file that contains my reproduction project can't be directly uploaded here. Here is a Google Drive link instead: https://drive.google.com/file/d/1sXsr3Q0g6_UzlOIL54P5wbS7yBkpMJ7A/view?usp=sharing Thank you for taking the time to review this. Any insight into whether this change is intentional or a regression would be very helpful.
Replies
3
Boosts
0
Views
1k
Activity
Dec ’25
“bash requesting screen access” popup in Mac OS 15
How can I allow the popup I am encountering while I run my UI tests with video recording in the Github actions. Since these tests are running on VMs, it's not possible to manually click Allow. Also the remote robot cannot interact with OS-level dialogs.
Replies
0
Boosts
0
Views
273
Activity
Nov ’25
ScreenCaptureKit sample initially omits application with NSWindowSharingType NSWindowSharingNone
The ScreenCaptureKit sample application (https://developer.apple.com/documentation/screencapturekit/capturing-screen-content-in-macos) uses a filter initially set to capture content from the selected display, excluding only the sample application, and excepting no windows: private var contentFilter: SCContentFilter { var filter: SCContentFilter switch captureType { case .display: guard let display = selectedDisplay else { fatalError("No display selected.") } var excludedApps = [SCRunningApplication]() // If a user chooses to exclude the app from the stream, // exclude it by matching its bundle identifier. if isAppExcluded { excludedApps = availableApps.filter { app in Bundle.main.bundleIdentifier == app.bundleIdentifier } } // Create a content filter with excluded apps. filter = SCContentFilter(display: display, excludingApplications: excludedApps, exceptingWindows: []) ....... return filter However, if another application uses the legacy NSWindowSharingType NSWindowSharingNone attribute, that application is initially not included in the captured stream. Only by toggling either the "Capture Type" or "Exclude sample from stream" checkbox does the initially hidden application become visible. Additionally, if the "Stop Capture" button is used followed by "Start Capture", the application using the legacy NSWindowSharingType NSWindowSharingNone attribute is once again hidden from the stream, and is only made visible by toggling either the "Capture Type" or "Exclude sample from stream" checkbox. Does some additional filter element or other SCStream configuration need to be included to verify that all applications, regardless of NSWindowSharingType, are captured using ScreenCaptureKit without requiring manual user interaction/filter refreshing? It seems odd that QuickTime screen recording (using ScreenCaptureKit) immediately captures an application using the NSWindowSharingNone attribute while the ScreenCaptureKit sample application linked above does not. See images below showing the stream preview before and after toggling the "Capture Type" or "Exclude sample from stream" checkbox. Images were taken from a QuickTime screen recording during testing.
Replies
2
Boosts
0
Views
264
Activity
Nov ’25
Value of type 'SCRecordingOutput' has no member 'delegate'
Hello, I am trying to capture screen recording ( output.mp4 ) using ScreenCaptureKit and also the mouse positions during the recording ( mouse.json ). The recording and the mouse positions ( tracked based on mouse movements events only ) needs to be perfectly synced in order to add effects in post editing. I started off by using the await stream?.startCapture() and after that starting my mouse tracking function :- try await captureEngine.startCapture(configuration: config, filter: filter, recordingOutput: recordingOutput) let captureStartTime = Date() mouseTracker?.startTracking(with: captureStartTime) But every time I tested, there is a clear inconsistency in sync between the recorded video and the recorded mouse positions. The only thing I want is to know when exactly does the recording "actually" started so that I can start the mouse capture at that same time, and thus I tried using the Delegates, but being able to set them up perfectly. import Foundation import AVFAudio import ScreenCaptureKit import OSLog import Combine class CaptureEngine: NSObject, @unchecked Sendable { private let logger = Logger() private(set) var stream: SCStream? private var streamOutput: CaptureEngineStreamOutput? private var recordingOutput: SCRecordingOutput? private let videoSampleBufferQueue = DispatchQueue(label: "com.francestudio.phia.VideoSampleBufferQueue") private let audioSampleBufferQueue = DispatchQueue(label: "com.francestudio.phia.AudioSampleBufferQueue") private let micSampleBufferQueue = DispatchQueue(label: "com.francestudio.phia.MicSampleBufferQueue") func startCapture(configuration: SCStreamConfiguration, filter: SCContentFilter, recordingOutput: SCRecordingOutput) async throws { // Create the stream output delegate. let streamOutput = CaptureEngineStreamOutput() self.streamOutput = streamOutput do { stream = SCStream(filter: filter, configuration: configuration, delegate: streamOutput) try stream?.addStreamOutput(streamOutput, type: .screen, sampleHandlerQueue: videoSampleBufferQueue) try stream?.addStreamOutput(streamOutput, type: .audio, sampleHandlerQueue: audioSampleBufferQueue) try stream?.addStreamOutput(streamOutput, type: .microphone, sampleHandlerQueue: micSampleBufferQueue) self.recordingOutput = recordingOutput recordingOutput.delegate = self try stream?.addRecordingOutput(recordingOutput) try await stream?.startCapture() } catch { logger.error("Failed to start capture: \(error.localizedDescription)") throw error } } func stopCapture() async throws { do { try await stream?.stopCapture() } catch { logger.error("Failed to stop capture: \(error.localizedDescription)") throw error } } func update(configuration: SCStreamConfiguration, filter: SCContentFilter) async { do { try await stream?.updateConfiguration(configuration) try await stream?.updateContentFilter(filter) } catch { logger.error("Failed to update the stream session: \(String(describing: error))") } } func stopRecordingOutputForStream(_ recordingOutput: SCRecordingOutput) throws { try self.stream?.removeRecordingOutput(recordingOutput) } } // MARK: - SCRecordingOutputDelegate extension CaptureEngine: SCRecordingOutputDelegate { func recordingOutputDidStartRecording(_ recordingOutput: SCRecordingOutput) { let startTime = Date() logger.info("Recording output did start recording \(startTime)") } func recordingOutputDidFinishRecording(_ recordingOutput: SCRecordingOutput) { logger.info("Recording output did finish recording") } func recordingOutput(_ recordingOutput: SCRecordingOutput, didFailWithError error: any Error) { logger.error("Recording output failed with error: \(error.localizedDescription)") } } private class CaptureEngineStreamOutput: NSObject, SCStreamOutput, SCStreamDelegate { private let logger = Logger() override init() { super.init() } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { guard sampleBuffer.isValid else { return } switch outputType { case .screen: break case .audio: break case .microphone: break @unknown default: logger.error("Encountered unknown stream output type:") } } func stream(_ stream: SCStream, didStopWithError error: Error) { logger.error("Stream stopped with error: \(error.localizedDescription)") } } I am getting error Value of type 'SCRecordingOutput' has no member 'delegate' Even though I am targeting macOs 15+ ( macOs 26 actually ) and macOs only. What is the best way to achieving the desired result? Is there any other / better way to do it?
Replies
1
Boosts
0
Views
293
Activity
Oct ’25
[SCShareableContent getShareableContentWithCompletionHandler:] takes over 5+seconds before returning response
We use SCK to screen share, however [SCShareableContent getShareableContentWithCompletionHandler:] takes over 5+ seconds before returning the response. Is it normal? What we can do to reduce the time consumption?
Replies
1
Boosts
0
Views
169
Activity
Oct ’25
Recorded video looks blurry, color-washed, low bitrate, compressed using ScreenCaptureKit
Hello everyone, I am trying to implement ScreenCaptureKit into my project, I am using MacOs 26 for the target version and followed this official project from apple regarding the screencapture kit. https://developer.apple.com/documentation/ScreenCaptureKit/capturing-screen-content-in-macos I used the official exact code and implemented in my app, but the results are not good. The video look blurry, unclear, lost colors and its like 720p honestly. The 1st video frame t is result when I integrate it in my app. After that, I used another app ( which was built in electron, they were using screencapturekit as well ) and there results were a lot better. The 2nd video frame is when I recorded using their application. It appears as close to as system display I tried multiple things, but no impressive results. For my purpose, I want to the final recorded video to be as good as the display quality of the system. I also applied .hdr local display and coronolicial, but no help with that as well. Changed codecs to .mov, .hevc, but still no help Why is not the recoded video as high quality as the display
Replies
3
Boosts
0
Views
433
Activity
Oct ’25
Take correctly sized screenshots with ScreenCaptureKit
I've been using CGWindowListCreateImage which automatically creates an image with the size of the captured window. But SCScreenshotManager.captureImage(contentFilter:configuration:) always creates images with the width and height specified in the provided SCStreamConfiguration. I could be setting the size explicitly by reading SCWindow.frame or SCContentFilter.contentRect and multiplying the width and height by SCContentFilter.pointPixelScale , but it won't work if I want to keep the window shadow with SCStreamConfiguration.ignoreShadowsSingleWindow = false. Is there a way and what's the best way to take full-resolution screenshots of the correct size? import Cocoa import ScreenCaptureKit class ViewController: NSViewController { @IBOutlet weak var imageView: NSImageView! override func viewDidAppear() { imageView.imageScaling = .scaleProportionallyUpOrDown view.wantsLayer = true view.layer!.backgroundColor = .init(red: 1, green: 0, blue: 0, alpha: 1) Task { let windows = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true).windows let window = windows[0] let filter = SCContentFilter(desktopIndependentWindow: window) let configuration = SCStreamConfiguration() configuration.ignoreShadowsSingleWindow = false configuration.showsCursor = false configuration.width = Int(Float(filter.contentRect.width) * filter.pointPixelScale) configuration.height = Int(Float(filter.contentRect.height) * filter.pointPixelScale) print(filter.contentRect) let windowImage = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration) imageView.image = NSImage(cgImage: windowImage, size: CGSize(width: windowImage.width, height: windowImage.height)) } } }
Replies
5
Boosts
0
Views
975
Activity
Oct ’25
SCStreamUpdateFrameContentRect X coordinate always returns 48 instead of expected 0
SCStreamUpdateFrameContentRect X coordinate always returns 48 instead of expected 0 Environment Device: MacBook Pro 13-inch macOS: Sequoia 15.6.1 Xcode: 16.4 Framework: Screen Capture Kit Issue Description I'm experiencing an unexpected behavior with Screen Capture Kit where the SCStreamUpdateFrameContentRect X coordinate consistently returns 48 instead of the expected 0. Code Context I'm using SCContentSharingPicker to capture screen content and implementing the SCStreamOutput protocol to receive frame data. In my stream(_:didOutputSampleBuffer:of:) method, I'm extracting the content rect information from the sample buffer attachments: func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { switch type { case .screen: guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]] else { return } guard let attachments = attachmentsArray.first else { return } if !attachments.keys.contains(.contentRect) { return } print(attachments) // X coordinate always shows 48 /* }, __C.SCStreamFrameInfo(_rawValue: SCStreamUpdateFrameContentRect): { Height = 540; Width = 864; X = 48; <<-- unexpected value Y = 0; }] */ return // ... other cases } } Expected vs Actual Behavior Expected: X coordinate should be 0 (indicating the content starts at the left edge of the screen) Actual: X coordinate is consistently 48 Visual verification: When I display the captured screen content, it appears correctly without any offset, suggesting the actual content should indeed start at X=0 Main ViewModel Class import Foundation import ScreenCaptureKit import SwiftUICore class VM: NSObject, ObservableObject, SCContentSharingPickerObserver, SCStreamDelegate, SCStreamOutput { @State var isRecording = false // Error handling delegate func stream(_ stream: SCStream, didStopWithError error: Error) { DispatchQueue.main.async { self.isRecording = false } } var picker: SCContentSharingPicker? func createPicker() -> SCContentSharingPicker { if let p = picker { return p } let picker = SCContentSharingPicker.shared var config = SCContentSharingPicker.shared.defaultConfiguration //SCContentSharingPickerConfiguration() config.allowedPickerModes = .singleDisplay config.allowsChangingSelectedContent = false config.excludedBundleIDs.append(Bundle.main.bundleIdentifier!) picker.add(self) picker.isActive = true SCContentSharingPicker.shared.present(using: .display) return picker } var stream: SCStream? let videoSampleBufferQueue = DispatchQueue(label: "com.example.apple-samplecode.VideoSampleBufferQueue") // observer call back for picker func contentSharingPicker(_ picker: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream?) { if let stream = stream { stream.updateContentFilter(filter) } else { let config = SCStreamConfiguration() config.capturesAudio = false config.captureMicrophone = false config.captureResolution = .automatic config.captureDynamicRange = .SDR config.showMouseClicks = false config.showsCursor = false // Set the frame rate for screen capture config.minimumFrameInterval = CMTime(value: 1, timescale: 5) self.stream = SCStream(filter: filter, configuration: config, delegate: self) do { try self.stream?.addStreamOutput(self, type: .screen, sampleHandlerQueue: self.videoSampleBufferQueue) } catch { print("\(error)") } self.stream?.updateContentFilter(filter) DispatchQueue.main.async { self.stream?.startCapture() } } } func contentSharingPicker(_ picker: SCContentSharingPicker, didCancelFor stream: SCStream?) {} func contentSharingPickerStartDidFailWithError(_ error: any Error) { print(error) } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { switch type { case .screen: guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]] else { return } guard let attachments = attachmentsArray.first else { return } if !attachments.keys.contains(.contentRect) { return } print(attachments) return case .audio: return case .microphone: return @unknown default: return } } func outputVideoEffectDidStart(for stream: SCStream) { print("outputVideoEffectDidStart") } func outputVideoEffectDidStop(for stream: SCStream) { print("outputVideoEffectDidStop") } func streamDidBecomeActive(_ stream: SCStream) { print("streamDidBecomeActive") } func streamDidBecomeInactive(_ stream: SCStream) { print("streamDidBecomeInactive") } }
Replies
1
Boosts
0
Views
117
Activity
Sep ’25
SCStreamUpdateFrameContentRect X coordinate always returns 48 instead of expected 0
SCStreamUpdateFrameContentRect X coordinate always returns 48 instead of expected 0 Environment Device: MacBook Pro 13-inch macOS: Sequoia 15.6.1 Xcode: 16.4 Framework: Screen Capture Kit Issue Description I'm experiencing an unexpected behavior with Screen Capture Kit where the SCStreamUpdateFrameContentRect X coordinate consistently returns 48 instead of the expected 0. Code Context I'm using SCContentSharingPicker to capture screen content and implementing the SCStreamOutput protocol to receive frame data. In my stream(_:didOutputSampleBuffer:of:) method, I'm extracting the content rect information from the sample buffer attachments: func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { switch type { case .screen: guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]] else { return } guard let attachments = attachmentsArray.first else { return } if !attachments.keys.contains(.contentRect) { return } print(attachments) // X coordinate always shows 48 /* , __C.SCStreamFrameInfo(_rawValue: SCStreamUpdateFrameContentRect): { Height = 540; Width = 864; X = 48; <<-- unexpected offset Y = 0; }] */ return // ... other cases } } Expected vs Actual Behavior Expected: X coordinate should be 0 (indicating the content starts at the left edge of the screen) Actual: X coordinate is consistently 48 Visual verification: When I display the captured screen content, it appears correctly without any offset, suggesting the actual content should indeed start at X=0 Additional Information The picker is configured with .singleDisplay mode I'm excluding the current app's bundle ID from capture The captured content visually appears correct, only the reported coordinates seem off Main ViewModel Class import Foundation import ScreenCaptureKit import SwiftUICore class VM: NSObject, ObservableObject, SCContentSharingPickerObserver, SCStreamDelegate, SCStreamOutput { @State var isRecording = false // Error handling delegate func stream(_ stream: SCStream, didStopWithError error: Error) { DispatchQueue.main.async { self.isRecording = false } } var picker: SCContentSharingPicker? func createPicker() -> SCContentSharingPicker { if let p = picker { return p } let picker = SCContentSharingPicker.shared picker.add(self) picker.isActive = true SCContentSharingPicker.shared.present(using: .display) return picker } var stream: SCStream? let videoSampleBufferQueue = DispatchQueue(label: "com.example.apple-samplecode.VideoSampleBufferQueue") // observer call back for picker func contentSharingPicker(_ picker: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream?) { if let stream = stream { stream.updateContentFilter(filter) } else { let config = SCStreamConfiguration() config.capturesAudio = false config.captureMicrophone = false config.captureResolution = .automatic config.captureDynamicRange = .SDR config.showMouseClicks = false config.showsCursor = false // Set the frame rate for screen capture config.minimumFrameInterval = CMTime(value: 1, timescale: 5) // 10 FPS self.stream = SCStream(filter: filter, configuration: config, delegate: self) do { try self.stream?.addStreamOutput(self, type: .screen, sampleHandlerQueue: self.videoSampleBufferQueue) } catch { print("\(error)") } self.stream?.updateContentFilter(filter) DispatchQueue.main.async { self.stream?.startCapture() } } } func contentSharingPicker(_ picker: SCContentSharingPicker, didCancelFor stream: SCStream?) {} func contentSharingPickerStartDidFailWithError(_ error: any Error) { print(error) } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { switch type { case .screen: guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]] else { return } guard let attachments = attachmentsArray.first else { return } if !attachments.keys.contains(.contentRect) { return } print(attachments) return case .audio: return case .microphone: return @unknown default: return } } func outputVideoEffectDidStart(for stream: SCStream) { print("outputVideoEffectDidStart") } func outputVideoEffectDidStop(for stream: SCStream) { print("outputVideoEffectDidStop") } func streamDidBecomeActive(_ stream: SCStream) { print("streamDidBecomeActive") } func streamDidBecomeInactive(_ stream: SCStream) { print("streamDidBecomeInactive") } }
Replies
0
Boosts
0
Views
81
Activity
Sep ’25