diff --git a/ScreenSlanger/config.swift b/ScreenSlanger/config.swift index 36c1e1f..a2211b3 100644 --- a/ScreenSlanger/config.swift +++ b/ScreenSlanger/config.swift @@ -68,6 +68,7 @@ class Config: Codable { } /// Toggle whether a display is enabled + @MainActor func toggleDisplay(_ displayID: CGDirectDisplayID) { let availableDisplayIDs = NSScreen.screens.compactMap { screen -> CGDirectDisplayID? in guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { @@ -191,6 +192,7 @@ class Config: Codable { } /// Apply stored values to a parameter state + @MainActor func applyStoredParameters(to state: ShaderParameterState) { let stored = getParameterValues() for (name, value) in stored { @@ -199,6 +201,7 @@ class Config: Codable { } /// Save current parameter state to config + @MainActor func saveParameters(from state: ShaderParameterState) { for param in state.parameters { setParameterValue(name: param.name, value: state.getValue(for: param.name)) diff --git a/ScreenSlanger/config_view.swift b/ScreenSlanger/config_view.swift index 249add1..ebf41b1 100644 --- a/ScreenSlanger/config_view.swift +++ b/ScreenSlanger/config_view.swift @@ -2,11 +2,11 @@ import AppKit class ConfigViewController: NSViewController { var config: Config! = nil - var onConfigUpdate: () -> Void = {} - var onReloadShader: () -> Void = {} + var onConfigUpdate: @MainActor () -> Void = {} + var onReloadShader: @MainActor () -> Void = {} var errorMessage: ErrorMessage! = nil var parameterState: ShaderParameterState? = nil - var onParameterChanged: ((String, Float) -> Void)? = nil + var onParameterChanged: (@MainActor (String, Float) -> Void)? = nil private var stackView: NSStackView! = nil private var shaderPathField: NSTextField! = nil @@ -379,10 +379,10 @@ class ConfigViewController: NSViewController { class ConfigWindowController: NSWindowController { var config: Config! = nil var errorMessage: ErrorMessage! = nil - var onConfigUpdate: () -> Void = {} - var onReloadShader: () -> Void = {} + var onConfigUpdate: @MainActor () -> Void = {} + var onReloadShader: @MainActor () -> Void = {} var parameterState: ShaderParameterState? = nil - var onParameterChanged: ((String, Float) -> Void)? = nil + var onParameterChanged: (@MainActor (String, Float) -> Void)? = nil private var configViewController: ConfigViewController! = ConfigViewController() diff --git a/ScreenSlanger/error_message.swift b/ScreenSlanger/error_message.swift index bb1d2db..c093bec 100644 --- a/ScreenSlanger/error_message.swift +++ b/ScreenSlanger/error_message.swift @@ -1,6 +1,7 @@ +@MainActor class ErrorMessage { private var message: String? = nil - var onMessageChanged: (() -> Void)? = nil + var onMessageChanged: (@MainActor () -> Void)? = nil func get() -> String? { return self.message diff --git a/ScreenSlanger/main.swift b/ScreenSlanger/main.swift index 7d6de31..e39bb9d 100644 --- a/ScreenSlanger/main.swift +++ b/ScreenSlanger/main.swift @@ -1,6 +1,7 @@ import AppKit import CoreGraphics +@MainActor class AppDelegate: NSObject, NSApplicationDelegate { private var config: Config! private var configChanged: Bool = false @@ -14,18 +15,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { // ScreenCaptureKit requests permission when an effect is activated. Keep // Settings available if permission is denied so the user can retry later. self.config = Config.load() - let configTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in - if self.configChanged { - self.config.save() - self.configChanged = false - } - } + let configTimer = Timer.scheduledTimer( + timeInterval: 1.0, target: self, selector: #selector(saveConfigIfNeeded), + userInfo: nil, repeats: true) RunLoop.current.add(configTimer, forMode: .common) - let metricsTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in - self.metrics.updateStats() - self.metrics.printStats() - } + let metricsTimer = Timer.scheduledTimer( + timeInterval: 10.0, target: self, selector: #selector(updateMetrics), + userInfo: nil, repeats: true) RunLoop.current.add(metricsTimer, forMode: .common) setupMenuBar() @@ -36,6 +33,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.refreshConfig() self.openConfigWindow() } + + @objc private func saveConfigIfNeeded() { + if self.configChanged { + self.config.save() + self.configChanged = false + } + } + + @objc private func updateMetrics() { + self.metrics.updateStats() + self.metrics.printStats() + } /// Create overlay controllers for all enabled screens private func createOverlayControllers() { diff --git a/ScreenSlanger/metrics.swift b/ScreenSlanger/metrics.swift index 15969a0..736449f 100644 --- a/ScreenSlanger/metrics.swift +++ b/ScreenSlanger/metrics.swift @@ -1,6 +1,7 @@ import AppKit -class Metrics { +// Every mutable field is accessed only on dispatchQueue, including reads. +final class Metrics: @unchecked Sendable { private var nextFrameID: Int = 0 private var screenCaptureTimestamps: [Int: Double] = [:] private var numRenders: Int = 0 @@ -8,9 +9,9 @@ class Metrics { private var prevUpdateTimestamp: Double = 0 private let dispatchQueue = DispatchQueue(label: "metrics.dispatchQueue") - var screenCaptureFPS: Double = 0 - var renderFPS: Double = 0 - var averageLatency: Double = 0 + private var screenCaptureFPS: Double = 0 + private var renderFPS: Double = 0 + private var averageLatency: Double = 0 func newFrameID() -> Int { var frameID: Int! diff --git a/ScreenSlanger/overlay.swift b/ScreenSlanger/overlay.swift index df9d003..dfbffdd 100644 --- a/ScreenSlanger/overlay.swift +++ b/ScreenSlanger/overlay.swift @@ -2,8 +2,9 @@ import AppKit import Metal import MetalKit -class OverlayController: NSObject, MTKViewDelegate { - var onCaptureStopped: () -> Void = {} +@MainActor +class OverlayController: NSObject, @MainActor MTKViewDelegate { + var onCaptureStopped: @MainActor () -> Void = {} private var config: Config private var metrics: Metrics private var errorMessage: ErrorMessage @@ -11,9 +12,7 @@ class OverlayController: NSObject, MTKViewDelegate { private var screen: NSScreen private var screenCapture: ScreenCapture! private var renderer: MetalRenderer! - private var contentBuffer: CVPixelBuffer? - private var frameID: Int? - private let dispatchQueue = DispatchQueue(label: "overlayController.queue") + private let pendingFrame = PendingCaptureFrame() private var isCleanedUp = false init(config: Config, metrics: Metrics, errorMessage: ErrorMessage, screen: NSScreen) { @@ -48,8 +47,10 @@ class OverlayController: NSObject, MTKViewDelegate { self.screenCapture = ScreenCapture(screen: screen) self.screenCapture.config = self.config self.screenCapture.excludedWindowIDs = [CGWindowID(self.window.windowNumber)] - self.screenCapture.onFrameReceived = { [weak self] contentBuffer in - self?.receiveFrame(contentBuffer: contentBuffer) + self.screenCapture.onFrameReceived = { [pendingFrame, metrics] contentBuffer in + let frameID = metrics.newFrameID() + metrics.recordScreenCapture(frameID: frameID) + pendingFrame.store(buffer: contentBuffer, frameID: frameID) } self.screenCapture.onCaptureStopped = { [weak self] in guard let self = self, !self.isCleanedUp else { return } @@ -81,29 +82,13 @@ class OverlayController: NSObject, MTKViewDelegate { // Stop screen capture self.screenCapture?.stopCapture() - // Drain any pending operations on our queue - self.dispatchQueue.sync { - self.contentBuffer = nil - self.frameID = nil - } + // stopCapture drains its callback before we discard the final pending frame. + self.pendingFrame.clear() // Close and release the window self.window?.orderOut(nil) } - func receiveFrame(contentBuffer: CVPixelBuffer) { - let frameID = self.metrics.newFrameID() - self.metrics.recordScreenCapture(frameID: frameID) - - self.dispatchQueue.async { [weak self] in - // stopCapture invalidates and drains the callback before cleanup clears - // this queue, so no unsynchronized read of UI state is needed here. - guard let self = self else { return } - self.frameID = frameID - self.contentBuffer = contentBuffer - } - } - func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} func draw(in view: MTKView) { @@ -114,22 +99,9 @@ class OverlayController: NSObject, MTKViewDelegate { func render() { guard !isCleanedUp else { return } - var contentBuffer: CVPixelBuffer? - var frameID: Int? - - self.dispatchQueue.sync { - contentBuffer = self.contentBuffer - frameID = self.frameID - self.frameID = nil - self.contentBuffer = nil - } - - // Double-check after sync in case cleanup happened while waiting - guard !isCleanedUp else { return } - - if let contentBuffer = contentBuffer, let frameID = frameID { - self.renderer.renderContentBuffer(window: self.window, contentBuffer: contentBuffer) - self.metrics.recordRender(frameID: frameID) + if let frame = self.pendingFrame.take() { + self.renderer.renderContentBuffer(window: self.window, contentBuffer: frame.buffer) + self.metrics.recordRender(frameID: frame.frameID) } } @@ -161,10 +133,7 @@ class OverlayController: NSObject, MTKViewDelegate { self.window.orderFrontRegardless() } else { self.window.orderOut(nil) - self.dispatchQueue.sync { - self.contentBuffer = nil - self.frameID = nil - } + self.pendingFrame.clear() } } @@ -191,3 +160,30 @@ class OverlayController: NSObject, MTKViewDelegate { return self.screen } } + +/// Transfers the latest read-only capture surface from ScreenCaptureKit to the UI. +/// The lock protects the slot; retaining a pixel buffer keeps its IOSurface alive. +/// Neither the capture callback nor renderer modifies the buffer's pixels. +private final class PendingCaptureFrame: @unchecked Sendable { + private let lock = NSLock() + private var frame: (buffer: CVPixelBuffer, frameID: Int)? + + func store(buffer: CVPixelBuffer, frameID: Int) { + lock.withLock { + frame = (buffer, frameID) + } + } + + func take() -> (buffer: CVPixelBuffer, frameID: Int)? { + lock.withLock { + defer { frame = nil } + return frame + } + } + + func clear() { + lock.withLock { + frame = nil + } + } +} diff --git a/ScreenSlanger/renderer.swift b/ScreenSlanger/renderer.swift index 1ef559d..76889cb 100644 --- a/ScreenSlanger/renderer.swift +++ b/ScreenSlanger/renderer.swift @@ -12,6 +12,7 @@ class MetalView: MTKView { } /// Shared Metal resources to avoid duplicating expensive objects across multiple renderers +@MainActor class SharedMetalResources { static let shared = SharedMetalResources() @@ -249,6 +250,7 @@ enum ActiveShaderType { } /// Holds the current state of shader parameters +@MainActor class ShaderParameterState { var parameters: [ShaderParameter] = [] var values: [String: Float] = [:] @@ -275,6 +277,7 @@ class ShaderParameterState { } } +@MainActor class MetalRenderer { private let shared = SharedMetalResources.shared private var textureCache: CVMetalTextureCache! @@ -581,8 +584,9 @@ class MetalRenderer { // Core Video may recycle the capture surface before the GPU has sampled it. // Retain its texture wrapper and pixel buffer until this command finishes. - commandBuffer.addCompletedHandler { [textureRef, contentBuffer] _ in - withExtendedLifetime((textureRef, contentBuffer)) {} + let retainedSurface = RetainedCaptureSurface(texture: textureRef, buffer: contentBuffer) + commandBuffer.addCompletedHandler { _ in + withExtendedLifetime(retainedSurface) {} } commandBuffer.present(drawable) commandBuffer.commit() @@ -634,3 +638,10 @@ class MetalRenderer { // Note: We don't need vertex uniforms since our custom vertex shader generates the quad procedurally } } + +/// A lifetime token for immutable capture surfaces read by the GPU. The completion +/// handler only releases these references; it never accesses or modifies their pixels. +private struct RetainedCaptureSurface: @unchecked Sendable { + let texture: CVMetalTexture + let buffer: CVPixelBuffer +} diff --git a/ScreenSlanger/screen_capture.swift b/ScreenSlanger/screen_capture.swift index 3a333b8..7c06d2e 100644 --- a/ScreenSlanger/screen_capture.swift +++ b/ScreenSlanger/screen_capture.swift @@ -1,13 +1,13 @@ import ScreenCaptureKit +@MainActor class ScreenCapture { var config: Config! = nil var excludedWindowIDs: [CGWindowID] = [] - var onFrameReceived: (CVPixelBuffer) -> Void = { _ in } - var onError: (Error) -> Void = { _ in } - var onCaptureStopped: () -> Void = {} + var onFrameReceived: @Sendable (CVPixelBuffer) -> Void = { _ in } + var onError: @MainActor (Error) -> Void = { _ in } + var onCaptureStopped: @MainActor () -> Void = {} - private let stateLock = NSLock() private var session: CaptureSession? private var latestSessionID: UUID? private let streamQueue = DispatchQueue(label: "ScreenCaptureKitStreamQueue") @@ -31,17 +31,15 @@ class ScreenCapture { let excludedWindowIDs = self.excludedWindowIDs let sessionID = UUID() let output = StreamOutput(onFrameReceived: self.onFrameReceived) { [weak self] error in - self?.captureStopped(sessionID: sessionID, error: error) + Task { @MainActor [weak self] in + self?.captureStopped(sessionID: sessionID, error: error) + } } let newSession = CaptureSession( id: sessionID, output: output, onError: self.onError, onCaptureStopped: self.onCaptureStopped) - stateLock.lock() - guard session == nil else { - stateLock.unlock() - return - } + guard session == nil else { return } session = newSession latestSessionID = sessionID @@ -90,11 +88,9 @@ class ScreenCapture { } } } - stateLock.unlock() } func stopCapture() { - stateLock.lock() let stoppedSession = session session = nil // Also suppress a pending error/stopped callback from an earlier session. @@ -102,7 +98,6 @@ class ScreenCapture { let startTask = stoppedSession?.startTask stoppedSession?.startTask = nil let stream = stoppedSession?.stream - stateLock.unlock() guard let stoppedSession = stoppedSession else { return } stoppedSession.output.invalidate() @@ -124,8 +119,6 @@ class ScreenCapture { } private func isCurrentSession(_ candidate: CaptureSession) -> Bool { - stateLock.lock() - defer { stateLock.unlock() } return session === candidate } @@ -143,8 +136,6 @@ class ScreenCapture { } private func finishStarting(_ candidate: CaptureSession, stream: SCStream) -> Bool { - stateLock.lock() - defer { stateLock.unlock() } guard session === candidate else { return false } candidate.stream = stream candidate.startTask = nil @@ -152,24 +143,17 @@ class ScreenCapture { } private func captureStopped(sessionID: UUID, error: Error) { - stateLock.lock() - guard let stoppedSession = session, stoppedSession.id == sessionID else { - stateLock.unlock() - return - } + guard let stoppedSession = session, stoppedSession.id == sessionID else { return } session = nil let startTask = stoppedSession.startTask stoppedSession.startTask = nil - stateLock.unlock() stoppedSession.output.invalidate() startTask?.cancel() DispatchQueue.main.async { [weak self] in guard let self = self else { return } - self.stateLock.lock() let shouldNotify = self.latestSessionID == sessionID - self.stateLock.unlock() guard shouldNotify else { return } stoppedSession.onCaptureStopped() @@ -183,18 +167,20 @@ class ScreenCapture { } } +@MainActor private final class CaptureSession { let id: UUID let output: StreamOutput - let onError: (Error) -> Void - let onCaptureStopped: () -> Void - // Accessed only while ScreenCapture.stateLock is held. + let onError: @MainActor (Error) -> Void + let onCaptureStopped: @MainActor () -> Void + // Session transitions are confined to the main actor, including after awaits. var stream: SCStream? var startTask: Task? init( id: UUID, output: StreamOutput, - onError: @escaping (Error) -> Void, onCaptureStopped: @escaping () -> Void + onError: @escaping @MainActor (Error) -> Void, + onCaptureStopped: @escaping @MainActor () -> Void ) { self.id = id self.output = output @@ -203,13 +189,18 @@ private final class CaptureSession { } } -private final class StreamOutput: NSObject, SCStreamOutput, SCStreamDelegate { - private let onFrameReceived: (CVPixelBuffer) -> Void - private let onStopped: (Error) -> Void +// The callbacks are immutable and Sendable. callbackLock guards active and drains +// any frame callback before invalidation returns, so stopped captures cannot refill the UI slot. +private final class StreamOutput: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable { + private let onFrameReceived: @Sendable (CVPixelBuffer) -> Void + private let onStopped: @Sendable (Error) -> Void private let callbackLock = NSRecursiveLock() private var active = true - init(onFrameReceived: @escaping (CVPixelBuffer) -> Void, onStopped: @escaping (Error) -> Void) { + init( + onFrameReceived: @escaping @Sendable (CVPixelBuffer) -> Void, + onStopped: @escaping @Sendable (Error) -> Void + ) { self.onFrameReceived = onFrameReceived self.onStopped = onStopped }