diff --git a/.gitignore b/.gitignore index db30188..5de2a33 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,7 @@ segmentedhud.swift.ref gemini-apireference.md ScreenshotApp-main -stats +/stats/ boring.notch referenceimages diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 2e67b87..8b1d5cb 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -24,6 +24,7 @@ struct ContentView: View { @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var statsManager = StatsManager.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared + @ObservedObject var privacyManager = PrivacyIndicatorManager.shared @Default(.enableStatsFeature) var enableStatsFeature @Default(.showCpuGraph) var showCpuGraph @@ -347,6 +348,7 @@ struct ContentView: View { .frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top) .animation(.easeInOut(duration: 0.4), value: dynamicNotchSize) .animation(.easeInOut(duration: 0.4), value: coordinator.currentView) + .environmentObject(privacyManager) .onChange(of: dynamicNotchSize) { oldSize, newSize in // Protect against hover interference during frame size changes if oldSize != newSize { @@ -420,6 +422,8 @@ struct ContentView: View { TimerLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed { RecordingLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && !vm.hideOnClosed { + PrivacyLiveActivity() } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index b7e3544..5e8703c 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -414,6 +414,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { ScreenRecordingManager.shared.startMonitoring() } + // Setup Privacy Indicator Manager (camera and microphone monitoring) + PrivacyIndicatorManager.shared.startMonitoring() + // Setup media key monitoring setupMediaKeyMonitoring() diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index e48141c..42fd14f 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -21,6 +21,7 @@ enum SneakContentType { case timer case recording case bluetoothAudio + case privacy } struct sneakPeek { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 250bb9a..5d07b92 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -3789,6 +3789,10 @@ } } }, + "Camera" : { + "comment" : "A label for the camera privacy indicator.", + "isCommentAutoGenerated" : true + }, "Cancel" : { "localizations" : { "ar" : { @@ -4824,6 +4828,10 @@ }, "Current Items" : { + }, + "Currently active" : { + "comment" : "A text indicating whether the camera is \"Currently active\" or \"Not active\".", + "isCommentAutoGenerated" : true }, "Custom" : { @@ -10788,6 +10796,10 @@ } } }, + "Microphone" : { + "comment" : "A label displayed next to the microphone icon in the privacy indicator detail view.", + "isCommentAutoGenerated" : true + }, "min" : { }, @@ -12258,6 +12270,10 @@ } } }, + "Not active" : { + "comment" : "A description of whether the camera is currently active.", + "isCommentAutoGenerated" : true + }, "Not Now" : { "localizations" : { "ar" : { @@ -13086,6 +13102,10 @@ "comment" : "The title of the privacy and security settings screen.", "isCommentAutoGenerated" : true }, + "Privacy Indicators" : { + "comment" : "A heading for the section that lists privacy indicators.", + "isCommentAutoGenerated" : true + }, "Privacy Policy" : { "comment" : "A link to the privacy policy.", "isCommentAutoGenerated" : true @@ -16063,6 +16083,9 @@ }, "TheDynamicIsland" : { + }, + "These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : { + }, "Time Range" : { diff --git a/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift b/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift new file mode 100644 index 0000000..5c04b5c --- /dev/null +++ b/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift @@ -0,0 +1,131 @@ +// +// PrivacyLiveActivity.swift +// DynamicIsland +// +// Created for camera and microphone privacy indicators +// + +import SwiftUI +import Defaults + +struct PrivacyLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var recordingManager = ScreenRecordingManager.shared + @State private var isHovering: Bool = false + @State private var gestureProgress: CGFloat = 0 + @State private var isExpanded: Bool = false + + var body: some View { + HStack(spacing: 0) { + // Left side - Recording pulsator (if recording is active) + Color.clear + .background { + if recordingManager.isRecording && isExpanded { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.red.opacity(0.15)) + + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + .modifier(PulsingModifier()) + } + .frame(width: vm.effectiveClosedNotchHeight - 12, height: vm.effectiveClosedNotchHeight - 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame(width: recordingManager.isRecording && isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + + // Center - Black fill + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0)) + + // Right side - Privacy indicators (camera and/or microphone) + Color.clear + .background { + if isExpanded { + HStack(spacing: 8) { + // Microphone indicator (shows first if both active) + if privacyManager.indicatorLayout.showsMicrophoneIndicator { + PrivacyIcon(type: .microphone) + .transition(.scale.combined(with: .opacity)) + } + + // Camera indicator + if privacyManager.indicatorLayout.showsCameraIndicator { + PrivacyIcon(type: .camera) + .transition(.scale.combined(with: .opacity)) + } + } + .padding(.trailing, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = true + } + } + .onChange(of: privacyManager.hasAnyIndicator) { _, newValue in + if !newValue { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = false + } + } else { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = true + } + } + } + } +} + +// Individual privacy icon component +struct PrivacyIcon: View { + let type: PrivacyIndicatorType + @State private var isPulsing = false + + var body: some View { + ZStack { + // Glow effect + Circle() + .fill( + RadialGradient( + colors: [ + Color.orange.opacity(0.4), + Color.orange.opacity(0.2), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 10 + ) + ) + .frame(width: 20, height: 20) + .opacity(isPulsing ? 0.8 : 0.5) + + // Icon background + Circle() + .fill(Color.orange.opacity(0.2)) + .frame(width: 16, height: 16) + + // Icon + Image(systemName: type.icon) + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(.orange) + } + .frame(width: 20, height: 20) + .onAppear { + withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + } +} diff --git a/DynamicIsland/components/PrivacyIndicatorIcon.swift b/DynamicIsland/components/PrivacyIndicatorIcon.swift new file mode 100644 index 0000000..9d52e80 --- /dev/null +++ b/DynamicIsland/components/PrivacyIndicatorIcon.swift @@ -0,0 +1,268 @@ +// +// PrivacyIndicatorIcon.swift +// DynamicIsland +// +// Created for privacy indicator feature +// Visual component for camera and microphone privacy indicators +// + +import SwiftUI + +// MARK: - Privacy Indicator Type +enum PrivacyIndicatorType { + case camera + case microphone + + var icon: String { + switch self { + case .camera: + return "video.fill" + case .microphone: + return "mic.fill" + } + } + + var label: String { + switch self { + case .camera: + return "Camera Active" + case .microphone: + return "Microphone Active" + } + } + + var accessibilityLabel: String { + switch self { + case .camera: + return "Camera is being used by an application" + case .microphone: + return "Microphone is being used by an application" + } + } +} + +// MARK: - Privacy Indicator Icon View +struct PrivacyIndicatorIcon: View { + // MARK: - Properties + let type: PrivacyIndicatorType + let showLabel: Bool + + @State private var isAnimating: Bool = false + + // MARK: - Initialization + init(type: PrivacyIndicatorType, showLabel: Bool = false) { + self.type = type + self.showLabel = showLabel + } + + // MARK: - Body + var body: some View { + HStack(spacing: 4) { + // Icon with glow effect + ZStack { + // Background glow + Circle() + .fill( + RadialGradient( + colors: [ + Color.orange.opacity(0.4), + Color.orange.opacity(0.2), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 12 + ) + ) + .frame(width: 24, height: 24) + .opacity(isAnimating ? 0.8 : 0.5) + + // Icon background circle + Circle() + .fill(Color.orange.opacity(0.2)) + .frame(width: 18, height: 18) + + // Icon + Image(systemName: type.icon) + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.orange) + } + .frame(width: 24, height: 24) + + // Optional label + if showLabel { + Text(type.label) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.primary.opacity(0.8)) + } + } + .accessibilityLabel(type.accessibilityLabel) + .onAppear { + startPulseAnimation() + } + } + + // MARK: - Animation + private func startPulseAnimation() { + withAnimation( + .easeInOut(duration: 1.5) + .repeatForever(autoreverses: true) + ) { + isAnimating = true + } + } +} + +// MARK: - Privacy Indicator Container +/// Container view that shows both camera and microphone indicators based on layout +struct PrivacyIndicatorContainer: View { + // MARK: - Environment + @EnvironmentObject var privacyManager: PrivacyIndicatorManager + + // MARK: - Properties + let showLabels: Bool + let spacing: CGFloat + + // MARK: - Initialization + init(showLabels: Bool = false, spacing: CGFloat = 8) { + self.showLabels = showLabels + self.spacing = spacing + } + + // MARK: - Body + var body: some View { + HStack(spacing: spacing) { + // Microphone indicator (on the left when both are shown) + if privacyManager.indicatorLayout.showsMicrophoneIndicator { + PrivacyIndicatorIcon(type: .microphone, showLabel: showLabels) + .transition(.scale.combined(with: .opacity)) + } + + // Camera indicator (on the right when both are shown) + if privacyManager.indicatorLayout.showsCameraIndicator { + PrivacyIndicatorIcon(type: .camera, showLabel: showLabels) + .transition(.scale.combined(with: .opacity)) + } + } + .animation(.smooth(duration: 0.3), value: privacyManager.indicatorLayout) + } +} + +// MARK: - Privacy Indicator Detail View +/// Detailed view showing privacy indicator status (for settings/tips) +struct PrivacyIndicatorDetailView: View { + @EnvironmentObject var privacyManager: PrivacyIndicatorManager + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Privacy Indicators") + .font(.headline) + + // Camera status + HStack(spacing: 12) { + PrivacyIndicatorIcon(type: .camera) + + VStack(alignment: .leading, spacing: 2) { + Text("Camera") + .font(.subheadline.weight(.medium)) + + Text(privacyManager.cameraActive ? "Currently active" : "Not active") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Circle() + .fill(privacyManager.cameraActive ? Color.green : Color.gray.opacity(0.3)) + .frame(width: 8, height: 8) + } + .padding(12) + .background(Color.primary.opacity(0.05)) + .cornerRadius(10) + + // Microphone status + HStack(spacing: 12) { + PrivacyIndicatorIcon(type: .microphone) + + VStack(alignment: .leading, spacing: 2) { + Text("Microphone") + .font(.subheadline.weight(.medium)) + + Text(privacyManager.microphoneActive ? "Currently active" : "Not active") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Circle() + .fill(privacyManager.microphoneActive ? Color.green : Color.gray.opacity(0.3)) + .frame(width: 8, height: 8) + } + .padding(12) + .background(Color.primary.opacity(0.05)) + .cornerRadius(10) + + // Info text + Text("These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators.") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 4) + } + } +} + +// MARK: - Preview +#Preview("Single Indicators") { + VStack(spacing: 20) { + PrivacyIndicatorIcon(type: .camera) + PrivacyIndicatorIcon(type: .microphone) + PrivacyIndicatorIcon(type: .camera, showLabel: true) + PrivacyIndicatorIcon(type: .microphone, showLabel: true) + } + .padding() + .background(Color.black) +} + +#Preview("Container") { + let manager = PrivacyIndicatorManager.shared + + VStack(spacing: 20) { + Text("Both Active") + PrivacyIndicatorContainer() + .onAppear { + manager.cameraActive = true + manager.microphoneActive = true + } + + Text("Camera Only") + PrivacyIndicatorContainer() + .onAppear { + manager.cameraActive = true + manager.microphoneActive = false + } + + Text("Microphone Only") + PrivacyIndicatorContainer() + .onAppear { + manager.cameraActive = false + manager.microphoneActive = true + } + } + .padding() + .background(Color.black) + .environmentObject(manager) +} + +#Preview("Detail View") { + let manager = PrivacyIndicatorManager.shared + + PrivacyIndicatorDetailView() + .padding() + .environmentObject(manager) + .onAppear { + manager.cameraActive = true + manager.microphoneActive = false + } +} diff --git a/DynamicIsland/components/Recording/RecordingLiveActivity.swift b/DynamicIsland/components/Recording/RecordingLiveActivity.swift index 6c4edd3..ca2d14d 100644 --- a/DynamicIsland/components/Recording/RecordingLiveActivity.swift +++ b/DynamicIsland/components/Recording/RecordingLiveActivity.swift @@ -68,6 +68,7 @@ struct RecordingLiveActivity: View { } // Pulsing animation modifier for recording indicator +// Note: Also used by PrivacyLiveActivity struct PulsingModifier: ViewModifier { @State private var isPulsing = false diff --git a/DynamicIsland/components/Stats/ProcessStats.swift b/DynamicIsland/components/Stats/ProcessStats.swift new file mode 100644 index 0000000..99a1130 --- /dev/null +++ b/DynamicIsland/components/Stats/ProcessStats.swift @@ -0,0 +1,61 @@ +// +// ProcessStats.swift +// DynamicIsland +// +// Process information structure for stats monitoring +// Adapted from boring.notch implementation + +import Foundation +import AppKit +import SwiftUI + +// Process information structure +struct ProcessStats: Identifiable, Hashable { + let id = UUID() + let pid: Int32 + let name: String + let cpuUsage: Double + let memoryUsage: UInt64 // in bytes + let icon: NSImage? + + var memoryUsageString: String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useGB] + formatter.countStyle = .memory + return formatter.string(fromByteCount: Int64(memoryUsage)) + } + + var cpuUsageString: String { + return String(format: "%.1f%%", cpuUsage) + } +} + +enum ProcessRankingType { + case cpu + case memory + case gpu + + var title: String { + switch self { + case .cpu: return "CPU Usage" + case .memory: return "Memory Usage" + case .gpu: return "GPU Usage" + } + } + + var icon: String { + switch self { + case .cpu: return "cpu" + case .memory: return "memorychip" + case .gpu: return "display" + } + } + + var color: Color { + switch self { + case .cpu: return .blue + case .memory: return .green + case .gpu: return .purple + } + } +} \ No newline at end of file diff --git a/DynamicIsland/components/Stats/RankedProcessPopover.swift b/DynamicIsland/components/Stats/RankedProcessPopover.swift new file mode 100644 index 0000000..d47e673 --- /dev/null +++ b/DynamicIsland/components/Stats/RankedProcessPopover.swift @@ -0,0 +1,196 @@ +// +// RankedProcessPopover.swift +// DynamicIsland +// +// Popover component for displaying ranked processes by different metrics +// Adapted from boring.notch implementation + +import SwiftUI + +struct RankedProcessPopover: View { + let rankingType: ProcessRankingType + @ObservedObject var statsManager = StatsManager.shared + @Environment(\.dismiss) private var dismiss + @State private var isHovering = false + + // Callback to notify parent about hover state + var onHoverChange: ((Bool) -> Void)? + + private var rankedProcesses: [ProcessStats] { + switch rankingType { + case .cpu: + return statsManager.getProcessesRankedByCPU() + case .memory: + return statsManager.getProcessesRankedByMemory() + case .gpu: + return statsManager.getProcessesRankedByGPU() + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Header + HStack { + Image(systemName: rankingType.icon) + .font(.title3) + .foregroundColor(rankingType.color) + + Text("Top Processes by \(rankingType.title)") + .font(.headline) + .fontWeight(.semibold) + + Spacer() + + Button(action: { dismiss() }) { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.bottom, 4) + + Divider() + + // Process list + ScrollView { + LazyVStack(spacing: 6) { + ForEach(Array(rankedProcesses.prefix(15).enumerated()), id: \.element.id) { index, process in + RankedProcessRow( + process: process, + rank: index + 1, + rankingType: rankingType + ) + } + } + .padding(.vertical, 4) + } + .frame(maxHeight: 300) + + // Footer with refresh button + HStack { + Text("\(rankedProcesses.count) processes") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Button("Refresh") { + // Trigger stats update + if statsManager.isMonitoring { + // Stats will auto-refresh + } + } + .font(.caption) + .foregroundColor(rankingType.color) + } + } + .padding() + .frame(width: 320) + .frame(minHeight: 200) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4) + .onHover { hovering in + isHovering = hovering + onHoverChange?(hovering) + } + } +} + +struct RankedProcessRow: View { + let process: ProcessStats + let rank: Int + let rankingType: ProcessRankingType + @State private var isHovered = false + + private var primaryValue: String { + switch rankingType { + case .cpu: + return process.cpuUsageString + case .memory: + return process.memoryUsageString + case .gpu: + return process.cpuUsageString // Using CPU as proxy for GPU + } + } + + private var secondaryValue: String { + switch rankingType { + case .cpu: + return process.memoryUsageString + case .memory: + return process.cpuUsageString + case .gpu: + return process.memoryUsageString + } + } + + var body: some View { + HStack(spacing: 8) { + // Rank + Text("\(rank)") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(rankingType.color) + .frame(width: 24, alignment: .center) + + // App icon + Group { + if let icon = process.icon { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "app") + .foregroundColor(.secondary) + } + } + .frame(width: 20, height: 20) + + // Process info + VStack(alignment: .leading, spacing: 2) { + Text(process.name) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.tail) + + HStack(spacing: 8) { + Text(primaryValue) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(rankingType.color) + + Text(secondaryValue) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + // PID + Text("PID: \(process.pid)") + .font(.caption2) + .foregroundColor(.secondary) + .opacity(0.7) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? Color(NSColor.selectedControlColor).opacity(0.2) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovered = hovering + } + } + } +} + +#Preview { + RankedProcessPopover(rankingType: .cpu) + .background(Color.black) +} \ No newline at end of file diff --git a/DynamicIsland/managers/CameraMonitor.swift b/DynamicIsland/managers/CameraMonitor.swift new file mode 100644 index 0000000..d270c6c --- /dev/null +++ b/DynamicIsland/managers/CameraMonitor.swift @@ -0,0 +1,372 @@ +// +// CameraMonitor.swift +// DynamicIsland +// +// Created for camera privacy indicator feature +// Monitors system camera usage with CoreMediaIO private APIs + AVFoundation fallback +// + +import Foundation +import CoreMediaIO +import AVFoundation +import SwiftUI +import Combine + +// MARK: - CMIOObject Property Listener (Event-driven approach) +private func cameraPropertyListener( + objectID: CMIOObjectID, + numberAddresses: UInt32, + addresses: UnsafePointer?, + clientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let context = clientData else { return OSStatus(kCMIOHardwareNoError) } + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + + DispatchQueue.main.async { + print("CameraMonitor: 📷 Camera property changed") + monitor.checkCameraStatus() + } + + return OSStatus(kCMIOHardwareNoError) +} + +@MainActor +class CameraMonitor: ObservableObject { + // MARK: - Published Properties + @Published var isCameraActive: Bool = false + @Published var activeApp: String? = nil + @Published var isMonitoring: Bool = false + + // MARK: - Private Properties + private var cancellables = Set() + private var isListenerRegistered: Bool = false + private var cameraDeviceIDs: [CMIOObjectID] = [] + + // MARK: - Configuration + // Pure event-driven - no polling + + // MARK: - Initialization + init() { + // No initial setup needed + } + + deinit { + // Clean up listeners synchronously + if isListenerRegistered, !cameraDeviceIDs.isEmpty { + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard) + ) + + let context = Unmanaged.passUnretained(self).toOpaque() + + for deviceID in cameraDeviceIDs { + CMIOObjectRemovePropertyListener( + deviceID, + &propertyAddress, + cameraPropertyListener, + context + ) + } + } + } + + // MARK: - Public Methods + + /// Start monitoring camera usage + func startMonitoring() { + guard !isMonitoring else { + print("CameraMonitor: Already monitoring, skipping start") + return + } + + print("CameraMonitor: 🟢 Starting camera monitoring...") + + isMonitoring = true + + // Enumerate camera devices + cameraDeviceIDs = enumerateCameraDevices() + + if cameraDeviceIDs.isEmpty { + print("CameraMonitor: ⚠️ No camera devices found") + } else { + print("CameraMonitor: 📷 Found \(cameraDeviceIDs.count) camera device(s)") + } + + // Setup event listener (CoreMediaIO approach) + setupPropertyListener() + + // Check initial state + checkCameraStatus() + + print("CameraMonitor: ✅ Started monitoring (event-driven CMIO + AVFoundation check)") + } + + /// Stop monitoring camera usage + func stopMonitoring() { + guard isMonitoring else { + print("CameraMonitor: Not monitoring, skipping stop") + return + } + + print("CameraMonitor: 🛑 Stopping monitoring...") + + isMonitoring = false + + // Remove property listener + if isListenerRegistered { + removePropertyListener() + } + + // Reset state + if isCameraActive { + isCameraActive = false + } + activeApp = nil + + print("CameraMonitor: ✅ Stopped monitoring") + } + + /// Toggle monitoring state + func toggleMonitoring() { + if isMonitoring { + stopMonitoring() + } else { + startMonitoring() + } + } + + // MARK: - Private Methods + + /// Enumerate all camera devices using CoreMediaIO + private func enumerateCameraDevices() -> [CMIOObjectID] { + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIOHardwarePropertyDevices), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + + var dataSize: UInt32 = 0 + var status = CMIOObjectGetPropertyDataSize( + CMIOObjectID(kCMIOObjectSystemObject), + &propertyAddress, + 0, + nil, + &dataSize + ) + + guard status == OSStatus(kCMIOHardwareNoError), dataSize > 0 else { + print("CameraMonitor: ⚠️ Failed to get devices data size (status: \(status))") + return [] + } + + let deviceCount = Int(dataSize) / MemoryLayout.size + var devices = [CMIOObjectID](repeating: 0, count: deviceCount) + + status = CMIOObjectGetPropertyData( + CMIOObjectID(kCMIOObjectSystemObject), + &propertyAddress, + 0, + nil, + dataSize, + &dataSize, + &devices + ) + + guard status == OSStatus(kCMIOHardwareNoError) else { + print("CameraMonitor: ⚠️ Failed to get devices (status: \(status))") + return [] + } + + // Filter for video input devices + let videoDevices = devices.filter { deviceID in + return isVideoInputDevice(deviceID) + } + + return videoDevices + } + + /// Check if device is a video input device + private func isVideoInputDevice(_ deviceID: CMIOObjectID) -> Bool { + // Check if device has video input streams + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyStreams), + mScope: CMIOObjectPropertyScope(kCMIODevicePropertyScopeInput), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + + var dataSize: UInt32 = 0 + let status = CMIOObjectGetPropertyDataSize( + deviceID, + &propertyAddress, + 0, + nil, + &dataSize + ) + + return status == OSStatus(kCMIOHardwareNoError) && dataSize > 0 + } + + /// Setup CoreMediaIO property listener + private func setupPropertyListener() { + guard !cameraDeviceIDs.isEmpty else { return } + + // Listen for device running state changes + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard) + ) + + let context = Unmanaged.passUnretained(self).toOpaque() + + // Add listener for each camera device + for deviceID in cameraDeviceIDs { + let status = CMIOObjectAddPropertyListener( + deviceID, + &propertyAddress, + cameraPropertyListener, + context + ) + + if status == OSStatus(kCMIOHardwareNoError) { + print("CameraMonitor: ✅ Property listener registered for device \(deviceID)") + isListenerRegistered = true + } else { + print("CameraMonitor: ⚠️ Failed to register listener for device \(deviceID) (status: \(status))") + } + } + } + + /// Remove CoreMediaIO property listener + private func removePropertyListener() { + guard !cameraDeviceIDs.isEmpty else { return } + + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard) + ) + + let context = Unmanaged.passUnretained(self).toOpaque() + + for deviceID in cameraDeviceIDs { + let status = CMIOObjectRemovePropertyListener( + deviceID, + &propertyAddress, + cameraPropertyListener, + context + ) + + if status == OSStatus(kCMIOHardwareNoError) { + print("CameraMonitor: ✅ Property listener removed for device \(deviceID)") + } else { + print("CameraMonitor: ⚠️ Failed to remove listener for device \(deviceID) (status: \(status))") + } + } + + isListenerRegistered = false + } + + /// Check current camera status (CMIO approach) + private func checkCameraStatusCMIO() -> Bool { + guard !cameraDeviceIDs.isEmpty else { return false } + + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard) + ) + + // Check if any camera device is running + for deviceID in cameraDeviceIDs { + var isRunning: UInt32 = 0 + var dataSize = UInt32(MemoryLayout.size) + + let status = CMIOObjectGetPropertyData( + deviceID, + &propertyAddress, + 0, + nil, + dataSize, + &dataSize, + &isRunning + ) + + if status == OSStatus(kCMIOHardwareNoError) && isRunning != 0 { + return true + } + } + + return false + } + + /// Check current camera status (AVFoundation fallback) + private func checkCameraStatusAVFoundation() -> Bool { + // Use AVFoundation to check if any camera is in use + let discoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera, .external], + mediaType: .video, + position: .unspecified + ) + + for device in discoverySession.devices { + if device.isInUseByAnotherApplication { + return true + } + } + + return false + } + + /// Check current camera status (hybrid approach) + func checkCameraStatus() { + // Try CMIO first, fallback to AVFoundation + let isCMIOActive = checkCameraStatusCMIO() + let isAVActive = checkCameraStatusAVFoundation() + + // Use OR logic - either method detects usage + let isActive = isCMIOActive || isAVActive + + // Debug logging + print("CameraMonitor: 🔍 Checking... current=\(isCameraActive), CMIO=\(isCMIOActive), AV=\(isAVActive), final=\(isActive)") + + // Update state if changed + if isActive != isCameraActive { + print("CameraMonitor: 🔄 State change detected (\(isCameraActive) -> \(isActive))") + + withAnimation(.smooth) { + isCameraActive = isActive + } + + if isActive { + print("CameraMonitor: 📷 Camera ACTIVE") + // Could try to identify app here (TODO: investigate) + activeApp = "Unknown App" + } else { + print("CameraMonitor: ⚪ Camera INACTIVE") + activeApp = nil + } + } + } + +} + +// MARK: - Extensions + +extension CameraMonitor { + /// Get current camera status without async + var currentCameraStatus: Bool { + return isCameraActive + } + + /// Check if monitoring is available + var isMonitoringAvailable: Bool { + return !enumerateCameraDevices().isEmpty || !AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera, .external], + mediaType: .video, + position: .unspecified + ).devices.isEmpty + } +} diff --git a/DynamicIsland/managers/MicrophoneMonitor.swift b/DynamicIsland/managers/MicrophoneMonitor.swift new file mode 100644 index 0000000..a298872 --- /dev/null +++ b/DynamicIsland/managers/MicrophoneMonitor.swift @@ -0,0 +1,315 @@ +// +// MicrophoneMonitor.swift +// DynamicIsland +// +// Created for microphone privacy indicator feature +// Monitors system microphone usage with event-driven CoreAudio property listeners +// + +import Foundation +import CoreAudio +import SwiftUI +import Combine + +// MARK: - CoreAudio Callback Function +// C function pointer for CoreAudio property listener +private func microphonePropertyListener( + inObjectID: AudioObjectID, + inNumberAddresses: UInt32, + inAddresses: UnsafePointer, + inClientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let context = inClientData else { return noErr } + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + + DispatchQueue.main.async { + print("MicrophoneMonitor: 📢 Microphone property changed") + monitor.checkMicrophoneStatus() + } + + return noErr +} + +@MainActor +class MicrophoneMonitor: ObservableObject { + // MARK: - Published Properties + @Published var isMicActive: Bool = false + @Published var activeApp: String? = nil + @Published var isMonitoring: Bool = false + + // MARK: - Private Properties + private var defaultInputDevice: AudioDeviceID = 0 + private var isListenerRegistered: Bool = false + private var cancellables = Set() + + // MARK: - Configuration + // Pure event-driven - no polling + + // MARK: - Initialization + init() { + // No initial setup needed + } + + deinit { + // Clean up listener synchronously + // Remove property listener + if isListenerRegistered, defaultInputDevice != 0 { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunning, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + + let context = Unmanaged.passUnretained(self).toOpaque() + + AudioObjectRemovePropertyListener( + defaultInputDevice, + &address, + microphonePropertyListener, + context + ) + } + } + + // MARK: - Public Methods + + /// Start monitoring microphone usage + func startMonitoring() { + guard !isMonitoring else { + print("MicrophoneMonitor: Already monitoring, skipping start") + return + } + + print("MicrophoneMonitor: 🟢 Starting microphone monitoring...") + + isMonitoring = true + + // Get default input device + defaultInputDevice = getDefaultInputDevice() + guard defaultInputDevice != 0 else { + print("MicrophoneMonitor: ⚠️ No input device found") + return + } + + print("MicrophoneMonitor: 🎤 Found input device ID: \(defaultInputDevice)") + + // Check if property exists + let propertyExists = checkPropertyExists() + print("MicrophoneMonitor: Property exists: \(propertyExists)") + + // Setup event listener + setupPropertyListener() + + // Check initial state + checkMicrophoneStatus() + + print("MicrophoneMonitor: ✅ Started monitoring (event-driven only)") + } + + /// Stop monitoring microphone usage + func stopMonitoring() { + guard isMonitoring else { + print("MicrophoneMonitor: Not monitoring, skipping stop") + return + } + + print("MicrophoneMonitor: 🛑 Stopping monitoring...") + + isMonitoring = false + + // Remove property listener + if isListenerRegistered { + removePropertyListener() + } + + // Reset state + if isMicActive { + isMicActive = false + } + activeApp = nil + + print("MicrophoneMonitor: ✅ Stopped monitoring") + } + + /// Toggle monitoring state + func toggleMonitoring() { + if isMonitoring { + stopMonitoring() + } else { + startMonitoring() + } + } + + // MARK: - Private Methods + + /// Get default input device ID + private func getDefaultInputDevice() -> AudioDeviceID { + var deviceID: AudioDeviceID = 0 + var size = UInt32(MemoryLayout.size) + + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + 0, + nil, + &size, + &deviceID + ) + + if status != noErr { + print("MicrophoneMonitor: ⚠️ Failed to get default input device (status: \(status))") + return 0 + } + + return deviceID + } + + /// Check if the property exists on the device + private func checkPropertyExists() -> Bool { + guard defaultInputDevice != 0 else { return false } + + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunning, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + + let hasProperty = AudioObjectHasProperty(defaultInputDevice, &address) + print("MicrophoneMonitor: Device \(defaultInputDevice) has property: \(hasProperty)") + + return hasProperty + } + + /// Setup CoreAudio property listener + private func setupPropertyListener() { + guard defaultInputDevice != 0 else { return } + + // Try kAudioDevicePropertyDeviceIsRunning (simpler, more reliable for input devices) + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunning, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + + // Pass self as context + let context = Unmanaged.passUnretained(self).toOpaque() + + let status = AudioObjectAddPropertyListener( + defaultInputDevice, + &address, + microphonePropertyListener, + context + ) + + if status == noErr { + isListenerRegistered = true + print("MicrophoneMonitor: ✅ Property listener registered") + } else { + print("MicrophoneMonitor: ⚠️ Failed to register property listener (status: \(status))") + } + } + + /// Remove CoreAudio property listener + private func removePropertyListener() { + guard defaultInputDevice != 0, isListenerRegistered else { return } + + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunning, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + + let context = Unmanaged.passUnretained(self).toOpaque() + + let status = AudioObjectRemovePropertyListener( + defaultInputDevice, + &address, + microphonePropertyListener, + context + ) + + if status == noErr { + isListenerRegistered = false + print("MicrophoneMonitor: ✅ Property listener removed") + } else { + print("MicrophoneMonitor: ⚠️ Failed to remove property listener (status: \(status))") + } + } + + /// Check current microphone status + func checkMicrophoneStatus() { + guard defaultInputDevice != 0 else { return } + + let isRunning = isDeviceRunning(defaultInputDevice) + + // Debug logging + print("MicrophoneMonitor: 🔍 Checking... current=\(isMicActive), detected=\(isRunning)") + + // Update state if changed + if isRunning != isMicActive { + print("MicrophoneMonitor: 🔄 State change detected (\(isMicActive) -> \(isRunning))") + + withAnimation(.smooth) { + isMicActive = isRunning + } + + if isRunning { + print("MicrophoneMonitor: 🎤 Microphone ACTIVE") + // Could try to identify app here (TODO: investigate) + activeApp = "Unknown App" + } else { + print("MicrophoneMonitor: ⚪ Microphone INACTIVE") + activeApp = nil + } + } + } + + /// Check if audio device is running + private func isDeviceRunning(_ deviceID: AudioDeviceID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunning, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + + var isRunning: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + + let status = AudioObjectGetPropertyData( + deviceID, + &address, + 0, + nil, + &size, + &isRunning + ) + + if status != noErr { + print("MicrophoneMonitor: ⚠️ Failed to check device running status (status: \(status))") + return false + } + + return isRunning != 0 + } + +} + +// MARK: - Extensions + +extension MicrophoneMonitor { + /// Get current microphone status without async + var currentMicStatus: Bool { + return isMicActive + } + + /// Check if monitoring is available + var isMonitoringAvailable: Bool { + return getDefaultInputDevice() != 0 + } +} diff --git a/DynamicIsland/managers/PrivacyIndicatorManager.swift b/DynamicIsland/managers/PrivacyIndicatorManager.swift new file mode 100644 index 0000000..a02fb9a --- /dev/null +++ b/DynamicIsland/managers/PrivacyIndicatorManager.swift @@ -0,0 +1,252 @@ +// +// PrivacyIndicatorManager.swift +// DynamicIsland +// +// Created for privacy indicator feature +// Coordinates camera, microphone, and screen recording indicators +// + +import Foundation +import SwiftUI +import Combine + +// MARK: - Indicator Layout Enum +enum IndicatorLayout { + case none + case cameraOnly + case microphoneOnly + case cameraAndMicrophone + case recordingOnly + case recordingWithCamera + case recordingWithMicrophone + case recordingWithBoth + + // Computed properties for UI positioning + var showsRecordingPulsator: Bool { + switch self { + case .recordingOnly, .recordingWithCamera, .recordingWithMicrophone, .recordingWithBoth: + return true + default: + return false + } + } + + var showsCameraIndicator: Bool { + switch self { + case .cameraOnly, .cameraAndMicrophone, .recordingWithCamera, .recordingWithBoth: + return true + default: + return false + } + } + + var showsMicrophoneIndicator: Bool { + switch self { + case .microphoneOnly, .cameraAndMicrophone, .recordingWithMicrophone, .recordingWithBoth: + return true + default: + return false + } + } + + // Description for debugging + var description: String { + switch self { + case .none: return "None" + case .cameraOnly: return "Camera Only" + case .microphoneOnly: return "Microphone Only" + case .cameraAndMicrophone: return "Camera + Microphone" + case .recordingOnly: return "Recording Only" + case .recordingWithCamera: return "Recording + Camera" + case .recordingWithMicrophone: return "Recording + Microphone" + case .recordingWithBoth: return "Recording + Camera + Microphone" + } + } +} + +// MARK: - Privacy Indicator Manager +@MainActor +class PrivacyIndicatorManager: ObservableObject { + // MARK: - Singleton + static let shared = PrivacyIndicatorManager() + + // MARK: - Published Properties + @Published var cameraActive: Bool = false + @Published var microphoneActive: Bool = false + @Published var screenRecordingActive: Bool = false + + // MARK: - Child Monitors + private let cameraMonitor = CameraMonitor() + private let microphoneMonitor = MicrophoneMonitor() + private var screenRecordingManager: ScreenRecordingManager? + + // MARK: - Cancellables + private var cancellables = Set() + + // MARK: - Computed Properties + + /// Current indicator layout based on active states + var indicatorLayout: IndicatorLayout { + let camera = cameraActive + let mic = microphoneActive + let recording = screenRecordingActive + + // 8 possible combinations + switch (recording, camera, mic) { + case (false, false, false): + return .none + case (false, true, false): + return .cameraOnly + case (false, false, true): + return .microphoneOnly + case (false, true, true): + return .cameraAndMicrophone + case (true, false, false): + return .recordingOnly + case (true, true, false): + return .recordingWithCamera + case (true, false, true): + return .recordingWithMicrophone + case (true, true, true): + return .recordingWithBoth + } + } + + /// Check if any indicator is active + var hasAnyIndicator: Bool { + return cameraActive || microphoneActive || screenRecordingActive + } + + // MARK: - Initialization + private init() { + print("PrivacyIndicatorManager: 🚀 Initializing...") + setupBindings() + } + + // MARK: - Setup Methods + + /// Setup bindings to child monitors + private func setupBindings() { + // Bind camera monitor + cameraMonitor.$isCameraActive + .receive(on: DispatchQueue.main) + .sink { [weak self] isActive in + guard let self = self else { return } + if self.cameraActive != isActive { + print("PrivacyIndicatorManager: 📷 Camera state: \(isActive)") + self.cameraActive = isActive + self.logLayoutChange() + } + } + .store(in: &cancellables) + + // Bind microphone monitor + microphoneMonitor.$isMicActive + .receive(on: DispatchQueue.main) + .sink { [weak self] isActive in + guard let self = self else { return } + if self.microphoneActive != isActive { + print("PrivacyIndicatorManager: 🎤 Microphone state: \(isActive)") + self.microphoneActive = isActive + self.logLayoutChange() + } + } + .store(in: &cancellables) + + // Bind screen recording manager + let screenRecManager = ScreenRecordingManager.shared + screenRecordingManager = screenRecManager + + screenRecManager.$isRecording + .receive(on: DispatchQueue.main) + .sink { [weak self] isRecording in + guard let self = self else { return } + if self.screenRecordingActive != isRecording { + print("PrivacyIndicatorManager: 📹 Screen recording state: \(isRecording)") + self.screenRecordingActive = isRecording + self.logLayoutChange() + } + } + .store(in: &cancellables) + } + + /// Log layout changes for debugging + private func logLayoutChange() { + print("PrivacyIndicatorManager: 🔄 Layout changed to: \(indicatorLayout.description)") + print("PrivacyIndicatorManager: 📊 States - Camera: \(cameraActive), Mic: \(microphoneActive), Recording: \(screenRecordingActive)") + } + + // MARK: - Public Methods + + /// Start monitoring all privacy indicators + func startMonitoring() { + print("PrivacyIndicatorManager: 🟢 Starting all monitors...") + + // Start camera monitoring + if cameraMonitor.isMonitoringAvailable { + cameraMonitor.startMonitoring() + } else { + print("PrivacyIndicatorManager: ⚠️ Camera monitoring not available") + } + + // Start microphone monitoring + if microphoneMonitor.isMonitoringAvailable { + microphoneMonitor.startMonitoring() + } else { + print("PrivacyIndicatorManager: ⚠️ Microphone monitoring not available") + } + + // Screen recording is already monitored by ScreenRecordingManager + print("PrivacyIndicatorManager: ✅ All monitors started") + } + + /// Stop monitoring all privacy indicators + func stopMonitoring() { + print("PrivacyIndicatorManager: 🛑 Stopping all monitors...") + + cameraMonitor.stopMonitoring() + microphoneMonitor.stopMonitoring() + + print("PrivacyIndicatorManager: ✅ All monitors stopped") + } + + /// Toggle monitoring state + func toggleMonitoring() { + if cameraMonitor.isMonitoring || microphoneMonitor.isMonitoring { + stopMonitoring() + } else { + startMonitoring() + } + } + + /// Get detailed status string for debugging + func getStatusString() -> String { + var status = "Privacy Indicators:\n" + status += " Camera: \(cameraActive ? "🟢 Active" : "⚪ Inactive")\n" + status += " Microphone: \(microphoneActive ? "🟢 Active" : "⚪ Inactive")\n" + status += " Screen Recording: \(screenRecordingActive ? "🟢 Active" : "⚪ Inactive")\n" + status += " Layout: \(indicatorLayout.description)" + return status + } +} + +// MARK: - Extensions + +extension PrivacyIndicatorManager { + /// Get camera monitor instance + var camera: CameraMonitor { + return cameraMonitor + } + + /// Get microphone monitor instance + var microphone: MicrophoneMonitor { + return microphoneMonitor + } + + /// Get screen recording manager instance + var screenRecording: ScreenRecordingManager? { + return screenRecordingManager + } +} + +