From 742e34fbdc6a98886276748733c56e981972a19f Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 14 Oct 2025 23:40:26 +0530 Subject: [PATCH 1/4] Modify gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 18c8cac..db30188 100644 --- a/.gitignore +++ b/.gitignore @@ -82,4 +82,7 @@ PrivateWorks *.sh comparison_report.json *.txt -ANIMATION_DEBUG_GUIDE.md \ No newline at end of file +ANIMATION_DEBUG_GUIDE.md +MIGRATION_SUMMARY.md +ONBOARDING_ENHANCEMENT.md +SHEET_PRESENTATION_FIX.md \ No newline at end of file From 05ec90e84089620e18ab16021a3fa24b7a9f6657 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 15 Oct 2025 22:31:28 +0530 Subject: [PATCH 2/4] BugFix: remove the accidental stats in gitignore --- .gitignore | 2 +- DynamicIsland/ContentView.swift | 4 + DynamicIsland/DynamicIslandApp.swift | 3 + .../DynamicIslandViewCoordinator.swift | 1 + DynamicIsland/Localizable.xcstrings | 23 ++ .../Privacy/PrivacyLiveActivity.swift | 131 ++++++ .../components/PrivacyIndicatorIcon.swift | 268 +++++++++++++ .../Recording/RecordingLiveActivity.swift | 1 + .../components/Stats/ProcessStats.swift | 61 +++ .../Stats/RankedProcessPopover.swift | 196 +++++++++ DynamicIsland/managers/CameraMonitor.swift | 372 ++++++++++++++++++ .../managers/MicrophoneMonitor.swift | 315 +++++++++++++++ .../managers/PrivacyIndicatorManager.swift | 252 ++++++++++++ 13 files changed, 1628 insertions(+), 1 deletion(-) create mode 100644 DynamicIsland/components/Privacy/PrivacyLiveActivity.swift create mode 100644 DynamicIsland/components/PrivacyIndicatorIcon.swift create mode 100644 DynamicIsland/components/Stats/ProcessStats.swift create mode 100644 DynamicIsland/components/Stats/RankedProcessPopover.swift create mode 100644 DynamicIsland/managers/CameraMonitor.swift create mode 100644 DynamicIsland/managers/MicrophoneMonitor.swift create mode 100644 DynamicIsland/managers/PrivacyIndicatorManager.swift 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 + } +} + + From 357335d35ebe44aed238f29420b719724478dd61 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 15 Oct 2025 22:51:14 +0530 Subject: [PATCH 3/4] Working Indicators --- .../Privacy/PrivacyLiveActivity.swift | 2 +- .../managers/MicrophoneMonitor.swift | 34 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift b/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift index 5c04b5c..c2a3785 100644 --- a/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift +++ b/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift @@ -65,7 +65,7 @@ struct PrivacyLiveActivity: View { .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(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight + 30 + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) .onAppear { diff --git a/DynamicIsland/managers/MicrophoneMonitor.swift b/DynamicIsland/managers/MicrophoneMonitor.swift index a298872..62ab7af 100644 --- a/DynamicIsland/managers/MicrophoneMonitor.swift +++ b/DynamicIsland/managers/MicrophoneMonitor.swift @@ -55,9 +55,9 @@ class MicrophoneMonitor: ObservableObject { // Remove property listener if isListenerRegistered, defaultInputDevice != 0 { var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceIsRunning, - mScope: kAudioDevicePropertyScopeInput, - mElement: kAudioObjectPropertyElementMain + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster ) let context = Unmanaged.passUnretained(self).toOpaque() @@ -150,7 +150,7 @@ class MicrophoneMonitor: ObservableObject { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain + mElement: kAudioObjectPropertyElementMaster ) let status = AudioObjectGetPropertyData( @@ -175,9 +175,9 @@ class MicrophoneMonitor: ObservableObject { guard defaultInputDevice != 0 else { return false } var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceIsRunning, - mScope: kAudioDevicePropertyScopeInput, - mElement: kAudioObjectPropertyElementMain + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster ) let hasProperty = AudioObjectHasProperty(defaultInputDevice, &address) @@ -190,11 +190,11 @@ class MicrophoneMonitor: ObservableObject { private func setupPropertyListener() { guard defaultInputDevice != 0 else { return } - // Try kAudioDevicePropertyDeviceIsRunning (simpler, more reliable for input devices) + // Use kAudioDevicePropertyDeviceIsRunningSomewhere (tracks when device is in use anywhere) var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceIsRunning, - mScope: kAudioDevicePropertyScopeInput, - mElement: kAudioObjectPropertyElementMain + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster ) // Pass self as context @@ -220,9 +220,9 @@ class MicrophoneMonitor: ObservableObject { guard defaultInputDevice != 0, isListenerRegistered else { return } var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceIsRunning, - mScope: kAudioDevicePropertyScopeInput, - mElement: kAudioObjectPropertyElementMain + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster ) let context = Unmanaged.passUnretained(self).toOpaque() @@ -273,9 +273,9 @@ class MicrophoneMonitor: ObservableObject { /// Check if audio device is running private func isDeviceRunning(_ deviceID: AudioDeviceID) -> Bool { var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceIsRunning, - mScope: kAudioDevicePropertyScopeInput, - mElement: kAudioObjectPropertyElementMain + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster ) var isRunning: UInt32 = 0 From 2ece36fe09cdceed4868238a0e5e662e8cd29472 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 15 Oct 2025 23:55:50 +0530 Subject: [PATCH 4/4] Final Fix: Working with Animation --- DynamicIsland/ContentView.swift | 2 +- DynamicIsland/Localizable.xcstrings | 30 +++-- .../Privacy/PrivacyLiveActivity.swift | 117 +++++++++--------- .../components/Settings/SettingsView.swift | 50 +++++++- .../managers/PrivacyIndicatorManager.swift | 29 +++-- DynamicIsland/models/Constants.swift | 4 + 6 files changed, 158 insertions(+), 74 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 8b1d5cb..aad7e35 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -422,7 +422,7 @@ 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 { + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !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) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 5d07b92..6cad043 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -3792,6 +3792,12 @@ "Camera" : { "comment" : "A label for the camera privacy indicator.", "isCommentAutoGenerated" : true + }, + "Camera Active" : { + + }, + "Camera Status" : { + }, "Cancel" : { "localizations" : { @@ -8847,6 +8853,9 @@ } } } + }, + "Inactive" : { + }, "Inline" : { "localizations" : { @@ -10799,6 +10808,12 @@ "Microphone" : { "comment" : "A label displayed next to the microphone icon in the privacy indicator detail view.", "isCommentAutoGenerated" : true + }, + "Microphone Active" : { + + }, + "Microphone Status" : { + }, "min" : { @@ -13097,10 +13112,6 @@ }, "Preview Zoom:" : { - }, - "Privacy & Security" : { - "comment" : "The title of the privacy and security settings screen.", - "isCommentAutoGenerated" : true }, "Privacy Indicators" : { "comment" : "A heading for the section that lists privacy indicators.", @@ -13908,9 +13919,8 @@ "Screen Assistant:" : { }, - "Screen recording detection shows a red indicator when your screen is being captured. Detection uses minimal system resources and can be disabled at any time." : { - "comment" : "A footer description for the \"Privacy & Security\" section of the settings.", - "isCommentAutoGenerated" : true + "Screen Recording Detection" : { + }, "Screenshot options" : { @@ -15096,6 +15106,9 @@ } } } + }, + "Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs." : { + }, "Shows the model's reasoning process before the final answer" : { @@ -17031,6 +17044,9 @@ } } } + }, + "Uses event-driven private API for real-time screen recording detection" : { + }, "Version" : { "localizations" : { diff --git a/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift b/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift index c2a3785..380c6f6 100644 --- a/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift +++ b/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift @@ -16,56 +16,76 @@ struct PrivacyLiveActivity: View { @State private var gestureProgress: CGFloat = 0 @State private var isExpanded: Bool = false + // Calculate if both camera and mic are active + private var bothIndicatorsActive: Bool { + privacyManager.indicatorLayout.showsCameraIndicator && + privacyManager.indicatorLayout.showsMicrophoneIndicator + } + var body: some View { HStack(spacing: 0) { - // Left side - Recording pulsator (if recording is active) + // Left side - Recording pulsator OR microphone (if both indicators active without recording) Color.clear .background { - if recordingManager.isRecording && isExpanded { + if isExpanded { HStack { - ZStack { - RoundedRectangle(cornerRadius: 6) - .fill(Color.red.opacity(0.15)) - - Circle() - .fill(Color.red) - .frame(width: 10, height: 10) - .modifier(PulsingModifier()) + if recordingManager.isRecording { + // Recording pulsator + 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) + } else if bothIndicatorsActive { + // Microphone on left when both active and no recording + PrivacyIcon(type: .microphone) } - .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)) + .frame(width: isExpanded && (recordingManager.isRecording || bothIndicatorsActive) ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2 + 20) : 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) + // Right side - Privacy indicators 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)) - } + HStack(spacing: 4) { + // When both active without recording: only camera on right (mic is on left) + // When recording + both: show both on right + // When only one active: show that one - // Camera indicator - if privacyManager.indicatorLayout.showsCameraIndicator { + if bothIndicatorsActive && recordingManager.isRecording { + // Both indicators on right when recording + PrivacyIcon(type: .microphone) PrivacyIcon(type: .camera) - .transition(.scale.combined(with: .opacity)) + } else if bothIndicatorsActive { + // Only camera on right when both active (mic is on left) + PrivacyIcon(type: .camera) + } else { + // Single indicator + if privacyManager.indicatorLayout.showsMicrophoneIndicator { + PrivacyIcon(type: .microphone) + } + if privacyManager.indicatorLayout.showsCameraIndicator { + PrivacyIcon(type: .camera) + } } } - .padding(.trailing, 8) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) } } - .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight + 30 + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2 + 20) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) .onAppear { @@ -92,40 +112,23 @@ struct PrivacyIcon: View { let type: PrivacyIndicatorType @State private var isPulsing = false + // Color based on type: camera = #26CC41, mic = #FF9402 + private var iconColor: Color { + type == .camera ? Color(red: 0.152, green: 0.804, blue: 0.256) : Color(red: 1.000, green: 0.584, blue: 0.010) + } + 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 + // Simple icon without blur/glow effects + Image(systemName: type.icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(iconColor) + .frame(width: 24, height: 24) + .scaleEffect(isPulsing ? 1.1 : 1.0) + .opacity(isPulsing ? 1.0 : 0.85) + .onAppear { + withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { + isPulsing = true + } } - } } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index c03aae3..0fcc1a4 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -157,6 +157,7 @@ struct GeneralSettings: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared + @ObservedObject var privacyManager = PrivacyIndicatorManager.shared @Default(.mirrorShape) var mirrorShape @Default(.showEmojis) var showEmojis @@ -245,9 +246,54 @@ struct GeneralSettings: View { } } } header: { - Text("Privacy & Security") + Text("Screen Recording Detection") } footer: { - Text("Screen recording detection shows a red indicator when your screen is being captured. Detection uses minimal system resources and can be disabled at any time.") + Text("Uses event-driven private API for real-time screen recording detection") + } + + Section { + Defaults.Toggle("Enable Camera Detection", key: .enableCameraDetection) + Defaults.Toggle("Enable Microphone Detection", key: .enableMicrophoneDetection) + + if privacyManager.isMonitoring { + HStack { + Text("Camera Status") + Spacer() + if privacyManager.cameraActive { + HStack(spacing: 4) { + Circle() + .fill(Color.green) + .frame(width: 8, height: 8) + Text("Camera Active") + .foregroundColor(.green) + } + } else { + Text("Inactive") + .foregroundColor(.secondary) + } + } + + HStack { + Text("Microphone Status") + Spacer() + if privacyManager.microphoneActive { + HStack(spacing: 4) { + Circle() + .fill(Color.yellow) + .frame(width: 8, height: 8) + Text("Microphone Active") + .foregroundColor(.yellow) + } + } else { + Text("Inactive") + .foregroundColor(.secondary) + } + } + } + } header: { + Text("Privacy Indicators") + } footer: { + Text("Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs.") } Section { diff --git a/DynamicIsland/managers/PrivacyIndicatorManager.swift b/DynamicIsland/managers/PrivacyIndicatorManager.swift index a02fb9a..0367481 100644 --- a/DynamicIsland/managers/PrivacyIndicatorManager.swift +++ b/DynamicIsland/managers/PrivacyIndicatorManager.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI import Combine +import Defaults // MARK: - Indicator Layout Enum enum IndicatorLayout { @@ -74,6 +75,7 @@ class PrivacyIndicatorManager: ObservableObject { @Published var cameraActive: Bool = false @Published var microphoneActive: Bool = false @Published var screenRecordingActive: Bool = false + @Published var isMonitoring: Bool = false // MARK: - Child Monitors private let cameraMonitor = CameraMonitor() @@ -87,8 +89,9 @@ class PrivacyIndicatorManager: ObservableObject { /// Current indicator layout based on active states var indicatorLayout: IndicatorLayout { - let camera = cameraActive - let mic = microphoneActive + // Respect user settings + let camera = cameraActive && Defaults[.enableCameraDetection] + let mic = microphoneActive && Defaults[.enableMicrophoneDetection] let recording = screenRecordingActive // 8 possible combinations @@ -112,9 +115,11 @@ class PrivacyIndicatorManager: ObservableObject { } } - /// Check if any indicator is active + /// Check if any indicator is active (respecting user settings) var hasAnyIndicator: Bool { - return cameraActive || microphoneActive || screenRecordingActive + let showCamera = cameraActive && Defaults[.enableCameraDetection] + let showMic = microphoneActive && Defaults[.enableMicrophoneDetection] + return showCamera || showMic || screenRecordingActive } // MARK: - Initialization @@ -134,7 +139,9 @@ class PrivacyIndicatorManager: ObservableObject { guard let self = self else { return } if self.cameraActive != isActive { print("PrivacyIndicatorManager: 📷 Camera state: \(isActive)") - self.cameraActive = isActive + withAnimation(.smooth) { + self.cameraActive = isActive + } self.logLayoutChange() } } @@ -147,7 +154,9 @@ class PrivacyIndicatorManager: ObservableObject { guard let self = self else { return } if self.microphoneActive != isActive { print("PrivacyIndicatorManager: 🎤 Microphone state: \(isActive)") - self.microphoneActive = isActive + withAnimation(.smooth) { + self.microphoneActive = isActive + } self.logLayoutChange() } } @@ -163,7 +172,9 @@ class PrivacyIndicatorManager: ObservableObject { guard let self = self else { return } if self.screenRecordingActive != isRecording { print("PrivacyIndicatorManager: 📹 Screen recording state: \(isRecording)") - self.screenRecordingActive = isRecording + withAnimation(.smooth) { + self.screenRecordingActive = isRecording + } self.logLayoutChange() } } @@ -182,6 +193,8 @@ class PrivacyIndicatorManager: ObservableObject { func startMonitoring() { print("PrivacyIndicatorManager: 🟢 Starting all monitors...") + isMonitoring = true + // Start camera monitoring if cameraMonitor.isMonitoringAvailable { cameraMonitor.startMonitoring() @@ -204,6 +217,8 @@ class PrivacyIndicatorManager: ObservableObject { func stopMonitoring() { print("PrivacyIndicatorManager: 🛑 Stopping all monitors...") + isMonitoring = false + cameraMonitor.stopMonitoring() microphoneMonitor.stopMonitoring() diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 7539643..4d5c6ff 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -432,6 +432,10 @@ extension Defaults.Keys { // Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent) // static let enableScreenRecordingPolling = Key("enableScreenRecordingPolling", default: false) + // MARK: Privacy Indicators (Camera & Microphone Detection) + static let enableCameraDetection = Key("enableCameraDetection", default: true) + static let enableMicrophoneDetection = Key("enableMicrophoneDetection", default: true) + // MARK: ImageService static let didClearLegacyURLCacheV1 = Key("didClearLegacyURLCacheV1", default: false)