mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #30 from Ebullioscopic/feature/screenrecord
Working: Optimized - No polling
This commit is contained in:
commit
86d64de338
4 changed files with 90 additions and 147 deletions
|
|
@ -252,6 +252,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
window.orderFrontRegardless()
|
window.orderFrontRegardless()
|
||||||
NotchSpaceManager.shared.notchSpace.windows.insert(window)
|
NotchSpaceManager.shared.notchSpace.windows.insert(window)
|
||||||
|
|
||||||
return window
|
return window
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -430,6 +431,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
ScreenRecordingManager.shared.stopMonitoring()
|
ScreenRecordingManager.shared.stopMonitoring()
|
||||||
}
|
}
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Note: Polling setting removed - now uses event-driven private API detection only
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,8 @@ struct GeneralSettings: View {
|
||||||
Defaults.Toggle("Show Recording Indicator", key: .showRecordingIndicator)
|
Defaults.Toggle("Show Recording Indicator", key: .showRecordingIndicator)
|
||||||
.disabled(!enableScreenRecordingDetection)
|
.disabled(!enableScreenRecordingDetection)
|
||||||
|
|
||||||
|
// Note: Polling removed - now uses event-driven private API detection
|
||||||
|
|
||||||
if recordingManager.isMonitoring {
|
if recordingManager.isMonitoring {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Detection Status")
|
Text("Detection Status")
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,40 @@
|
||||||
//
|
//
|
||||||
// Created for screen recording detection feature
|
// Created for screen recording detection feature
|
||||||
// Monitors system for active screen recording and provides real-time status updates
|
// Monitors system for active screen recording and provides real-time status updates
|
||||||
|
// Uses private CoreGraphics APIs for accurate, event-driven screen capture detection
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import ScreenCaptureKit
|
|
||||||
import AppKit
|
import AppKit
|
||||||
import Defaults
|
import Defaults
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Private API Declarations
|
||||||
|
// These private APIs provide direct screen capture detection
|
||||||
|
// Use at your own risk - may break in future macOS versions
|
||||||
|
|
||||||
|
@_silgen_name("CGSIsScreenWatcherPresent")
|
||||||
|
func CGSIsScreenWatcherPresent() -> Bool
|
||||||
|
|
||||||
|
@_silgen_name("CGSRegisterNotifyProc")
|
||||||
|
func CGSRegisterNotifyProc(
|
||||||
|
_ callback: (@convention(c) (Int32, Int32, Int32, UnsafeMutableRawPointer?) -> Void)?,
|
||||||
|
_ event: Int32,
|
||||||
|
_ context: UnsafeMutableRawPointer?
|
||||||
|
) -> Bool
|
||||||
|
|
||||||
|
// MARK: - Global Callback Function
|
||||||
|
// C function pointer cannot capture context, so we need a global function
|
||||||
|
private func screenCaptureEventCallback(eventType: Int32, _: Int32, _: Int32, context: UnsafeMutableRawPointer?) {
|
||||||
|
guard let context = context else { return }
|
||||||
|
let manager = Unmanaged<ScreenRecordingManager>.fromOpaque(context).takeUnretainedValue()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
print("ScreenRecordingManager: 📢 Screen capture event received (type: \(eventType))")
|
||||||
|
manager.checkRecordingStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class ScreenRecordingManager: ObservableObject {
|
class ScreenRecordingManager: ObservableObject {
|
||||||
static let shared = ScreenRecordingManager()
|
static let shared = ScreenRecordingManager()
|
||||||
|
|
@ -27,27 +53,24 @@ class ScreenRecordingManager: ObservableObject {
|
||||||
@Published var lastUpdated: Date = .distantPast
|
@Published var lastUpdated: Date = .distantPast
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
private var monitoringTimer: Timer?
|
|
||||||
private var lastScreensHaveSeparateSpaces: Bool = false
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var recordingStartTime: Date?
|
private var recordingStartTime: Date?
|
||||||
private var durationTimer: Timer?
|
private var durationTimer: Timer?
|
||||||
private var debounceIdleTask: Task<Void, Never>?
|
private var debounceIdleTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
private let monitoringInterval: TimeInterval = 1.0 // Check every second
|
private let debounceDelay: TimeInterval = 0.2 // Debounce rapid changes
|
||||||
private let debounceDelay: TimeInterval = 0.5 // Debounce rapid changes
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
private init() {
|
private init() {
|
||||||
setupInitialState()
|
// No initial setup needed
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
Task { @MainActor in
|
// Clean up monitoring state
|
||||||
stopMonitoring()
|
// Note: We can't call async methods in deinit, so we just clean up local state
|
||||||
debounceIdleTask?.cancel()
|
debounceIdleTask?.cancel()
|
||||||
}
|
durationTimer?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
@ -60,19 +83,16 @@ class ScreenRecordingManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
isMonitoring = true
|
isMonitoring = true
|
||||||
lastScreensHaveSeparateSpaces = NSScreen.screensHaveSeparateSpaces
|
|
||||||
|
|
||||||
print("ScreenRecordingManager: 🟢 Starting monitoring...")
|
print("ScreenRecordingManager: 🟢 Starting screen capture monitoring (Private API)...")
|
||||||
print("ScreenRecordingManager: Initial screensHaveSeparateSpaces = \(lastScreensHaveSeparateSpaces)")
|
|
||||||
|
|
||||||
// Start timer-based monitoring
|
// Setup event-driven capture detection using private CoreGraphics APIs
|
||||||
monitoringTimer = Timer.scheduledTimer(withTimeInterval: monitoringInterval, repeats: true) { [weak self] _ in
|
setupPrivateAPINotifications()
|
||||||
Task { @MainActor in
|
|
||||||
await self?.checkRecordingStatus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("ScreenRecordingManager: ✅ Started monitoring with \(monitoringInterval)s interval")
|
// Check initial state
|
||||||
|
checkRecordingStatus()
|
||||||
|
|
||||||
|
print("ScreenRecordingManager: ✅ Started monitoring (event-driven, no polling)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop monitoring for screen recording activity
|
/// Stop monitoring for screen recording activity
|
||||||
|
|
@ -85,8 +105,9 @@ class ScreenRecordingManager: ObservableObject {
|
||||||
print("ScreenRecordingManager: 🛑 Stopping monitoring...")
|
print("ScreenRecordingManager: 🛑 Stopping monitoring...")
|
||||||
|
|
||||||
isMonitoring = false
|
isMonitoring = false
|
||||||
monitoringTimer?.invalidate()
|
|
||||||
monitoringTimer = nil
|
// Note: We don't unregister the callback as there's no CGSUnregisterNotifyProc API
|
||||||
|
// The callback will simply not be processed when isMonitoring is false
|
||||||
|
|
||||||
// Stop duration tracking
|
// Stop duration tracking
|
||||||
stopDurationTracking()
|
stopDurationTracking()
|
||||||
|
|
@ -111,140 +132,60 @@ class ScreenRecordingManager: ObservableObject {
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func setupInitialState() {
|
/// Setup private API notifications for screen capture events
|
||||||
lastScreensHaveSeparateSpaces = NSScreen.screensHaveSeparateSpaces
|
private func setupPrivateAPINotifications() {
|
||||||
|
// Pass self as context to the global callback function
|
||||||
|
let context = Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
|
||||||
|
// Register for remote session events (screen capture start/stop)
|
||||||
|
// kCGSessionRemoteConnect - fires when screen sharing/recording starts
|
||||||
|
let registered1 = CGSRegisterNotifyProc(screenCaptureEventCallback, 1502, context)
|
||||||
|
|
||||||
|
// kCGSessionRemoteDisconnect - fires when screen sharing/recording stops
|
||||||
|
let registered2 = CGSRegisterNotifyProc(screenCaptureEventCallback, 1503, context)
|
||||||
|
|
||||||
|
if registered1 && registered2 {
|
||||||
|
print("ScreenRecordingManager: ✅ Private API notifications registered")
|
||||||
|
} else {
|
||||||
|
print("ScreenRecordingManager: ⚠️ Failed to register private API notifications")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check current recording status using multiple detection methods
|
/// Check current recording status using private API
|
||||||
private func checkRecordingStatus() async {
|
func checkRecordingStatus() {
|
||||||
let currentRecordingState = await detectScreenRecording()
|
let currentRecordingState = CGSIsScreenWatcherPresent()
|
||||||
|
|
||||||
// Debug: Always log current check
|
// Debug: Always log current check
|
||||||
print("ScreenRecordingManager: 🔍 Checking... current=\(isRecording), detected=\(currentRecordingState)")
|
print("ScreenRecordingManager: 🔍 Checking... current=\(isRecording), detected=\(currentRecordingState)")
|
||||||
|
|
||||||
// Debounce changes to avoid flickering
|
// Debounce changes to avoid flickering
|
||||||
if currentRecordingState != isRecording {
|
if currentRecordingState != isRecording {
|
||||||
print("ScreenRecordingManager: 🔄 State change detected (\(isRecording) -> \(currentRecordingState)), debouncing...")
|
print("ScreenRecordingManager: 🔄 State change detected (\(isRecording) -> \(currentRecordingState))")
|
||||||
|
|
||||||
// Add a small delay to confirm the state change
|
if currentRecordingState && !isRecording {
|
||||||
try? await Task.sleep(nanoseconds: UInt64(debounceDelay * 1_000_000_000))
|
// Started recording
|
||||||
|
lastUpdated = Date()
|
||||||
// Double-check the state after debounce
|
startDurationTracking()
|
||||||
let confirmedState = await detectScreenRecording()
|
updateIdleState(recording: true)
|
||||||
print("ScreenRecordingManager: 🔍 After debounce: confirmed=\(confirmedState), original=\(currentRecordingState)")
|
// Trigger expanding view like music activity
|
||||||
|
coordinator.toggleExpandingView(status: true, type: .recording)
|
||||||
if confirmedState == currentRecordingState {
|
withAnimation(.smooth) {
|
||||||
if confirmedState && !isRecording {
|
isRecording = currentRecordingState
|
||||||
// Started recording
|
|
||||||
lastUpdated = Date()
|
|
||||||
startDurationTracking()
|
|
||||||
updateIdleState(recording: true)
|
|
||||||
// Trigger expanding view like music activity
|
|
||||||
coordinator.toggleExpandingView(status: true, type: .recording)
|
|
||||||
withAnimation(.smooth) {
|
|
||||||
isRecording = confirmedState
|
|
||||||
}
|
|
||||||
} else if !confirmedState && isRecording {
|
|
||||||
// Stopped recording - let expanding view auto-collapse naturally (like music)
|
|
||||||
lastUpdated = Date()
|
|
||||||
stopDurationTracking()
|
|
||||||
updateIdleState(recording: false)
|
|
||||||
withAnimation(.smooth) {
|
|
||||||
isRecording = confirmedState
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isRecording = confirmedState
|
|
||||||
}
|
}
|
||||||
|
print("ScreenRecordingManager: 🔴 Screen recording STARTED")
|
||||||
print("ScreenRecordingManager: ✅ Recording state changed to \(confirmedState)")
|
} else if !currentRecordingState && isRecording {
|
||||||
} else {
|
// Stopped recording - let expanding view auto-collapse naturally (like music)
|
||||||
print("ScreenRecordingManager: ❌ State not confirmed, keeping \(isRecording)")
|
lastUpdated = Date()
|
||||||
|
stopDurationTracking()
|
||||||
|
updateIdleState(recording: false)
|
||||||
|
withAnimation(.smooth) {
|
||||||
|
isRecording = currentRecordingState
|
||||||
|
}
|
||||||
|
print("ScreenRecordingManager: ⚪ Screen recording STOPPED")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect screen recording using hybrid approach
|
|
||||||
private func detectScreenRecording() async -> Bool {
|
|
||||||
print("ScreenRecordingManager: 🔎 Starting detection...")
|
|
||||||
|
|
||||||
// Method 1: NSScreen.screensHaveSeparateSpaces detection
|
|
||||||
let screensHaveSeparateSpaces = NSScreen.screensHaveSeparateSpaces
|
|
||||||
let screenSpacesChanged = screensHaveSeparateSpaces != lastScreensHaveSeparateSpaces
|
|
||||||
|
|
||||||
print("ScreenRecordingManager: Method 1 - screensHaveSeparateSpaces: \(screensHaveSeparateSpaces), last: \(lastScreensHaveSeparateSpaces), changed: \(screenSpacesChanged)")
|
|
||||||
|
|
||||||
// Update last known state
|
|
||||||
lastScreensHaveSeparateSpaces = screensHaveSeparateSpaces
|
|
||||||
|
|
||||||
// Method 2: ScreenCaptureKit detection (if available)
|
|
||||||
let screencaptureKitDetection = await detectWithScreenCaptureKit()
|
|
||||||
print("ScreenRecordingManager: Method 2 - ScreenCaptureKit: \(screencaptureKitDetection)")
|
|
||||||
|
|
||||||
// Method 3: Process-based detection for common recording apps
|
|
||||||
let processBasedDetection = detectRecordingProcesses()
|
|
||||||
print("ScreenRecordingManager: Method 3 - Process detection: \(processBasedDetection)")
|
|
||||||
|
|
||||||
// Combine detection methods (any positive result indicates recording)
|
|
||||||
let isCurrentlyRecording = screenSpacesChanged || screencaptureKitDetection || processBasedDetection
|
|
||||||
|
|
||||||
print("ScreenRecordingManager: 🎯 Final result: \(isCurrentlyRecording) (spaces:\(screenSpacesChanged) || kit:\(screencaptureKitDetection) || process:\(processBasedDetection))")
|
|
||||||
|
|
||||||
return isCurrentlyRecording
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect recording using ScreenCaptureKit (requires macOS 12.3+)
|
|
||||||
private func detectWithScreenCaptureKit() async -> Bool {
|
|
||||||
guard #available(macOS 12.3, *) else { return false }
|
|
||||||
|
|
||||||
do {
|
|
||||||
// Check if there are any active capture sessions
|
|
||||||
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
|
|
||||||
|
|
||||||
// If we can successfully get content and there are running applications with capture capabilities,
|
|
||||||
// it might indicate active screen capture
|
|
||||||
let runningApps = content.applications.filter { $0.applicationName.lowercased().contains("screen") ||
|
|
||||||
$0.applicationName.lowercased().contains("record") ||
|
|
||||||
$0.applicationName.lowercased().contains("capture") }
|
|
||||||
|
|
||||||
return !runningApps.isEmpty
|
|
||||||
} catch {
|
|
||||||
// If ScreenCaptureKit fails, fall back to other detection methods
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect recording by checking for known recording processes
|
|
||||||
private func detectRecordingProcesses() -> Bool {
|
|
||||||
let recordingProcessNames = [
|
|
||||||
"QuickTime Player",
|
|
||||||
"ScreenSearch",
|
|
||||||
"OBS",
|
|
||||||
"Camtasia",
|
|
||||||
"ScreenFlow",
|
|
||||||
"CleanMyMac",
|
|
||||||
"Screenshot",
|
|
||||||
"screencapture"
|
|
||||||
]
|
|
||||||
|
|
||||||
let runningApps = NSWorkspace.shared.runningApplications
|
|
||||||
|
|
||||||
print("ScreenRecordingManager: Checking \(runningApps.count) running apps for recording processes...")
|
|
||||||
|
|
||||||
for app in runningApps {
|
|
||||||
if let appName = app.localizedName {
|
|
||||||
for processName in recordingProcessNames {
|
|
||||||
if appName.lowercased().contains(processName.lowercased()) {
|
|
||||||
print("ScreenRecordingManager: 🎬 Found recording app: \(appName)")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("ScreenRecordingManager: No recording processes found")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start tracking recording duration
|
/// Start tracking recording duration
|
||||||
private func startDurationTracking() {
|
private func startDurationTracking() {
|
||||||
recordingStartTime = Date()
|
recordingStartTime = Date()
|
||||||
|
|
@ -311,12 +252,7 @@ extension ScreenRecordingManager {
|
||||||
|
|
||||||
/// Check if monitoring is available (for settings UI)
|
/// Check if monitoring is available (for settings UI)
|
||||||
var isMonitoringAvailable: Bool {
|
var isMonitoringAvailable: Bool {
|
||||||
return true // Basic monitoring is always available
|
return true // Window-based monitoring is always available
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current monitoring interval for debugging
|
|
||||||
var currentMonitoringInterval: TimeInterval {
|
|
||||||
return monitoringInterval
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get formatted recording duration string
|
/// Get formatted recording duration string
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,8 @@ extension Defaults.Keys {
|
||||||
// MARK: Screen Recording Detection Feature
|
// MARK: Screen Recording Detection Feature
|
||||||
static let enableScreenRecordingDetection = Key<Bool>("enableScreenRecordingDetection", default: true)
|
static let enableScreenRecordingDetection = Key<Bool>("enableScreenRecordingDetection", default: true)
|
||||||
static let showRecordingIndicator = Key<Bool>("showRecordingIndicator", default: true)
|
static let showRecordingIndicator = Key<Bool>("showRecordingIndicator", default: true)
|
||||||
|
// Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent)
|
||||||
|
// static let enableScreenRecordingPolling = Key<Bool>("enableScreenRecordingPolling", default: false)
|
||||||
|
|
||||||
// Helper to determine the default media controller based on macOS version
|
// Helper to determine the default media controller based on macOS version
|
||||||
static var defaultMediaController: MediaControllerType {
|
static var defaultMediaController: MediaControllerType {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue