diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj index 6c9f23e..ff1aa60 100644 --- a/Meetingnotes.xcodeproj/project.pbxproj +++ b/Meetingnotes.xcodeproj/project.pbxproj @@ -296,6 +296,7 @@ MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0.6; ONLY_ACTIVE_ARCH = NO; + OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -331,6 +332,7 @@ MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0.6; ONLY_ACTIVE_ARCH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/meetingnotes/Managers/AudioManager.swift b/meetingnotes/Managers/AudioManager.swift index 0f13a1c..9b3053d 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -5,6 +5,7 @@ import AVFoundation import Foundation import SwiftUI import OSLog +import Combine /// Manages audio capture from microphone and system audio and handles real-time transcription via OpenAI @MainActor @@ -29,6 +30,7 @@ class AudioManager: NSObject, ObservableObject { private let permission = AudioRecordingPermission() private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated) private var isTapActive = false + private var isRestartingSystemTap = false // Add properties near the top, after existing private vars private var micRetryCount = 0 @@ -39,6 +41,7 @@ class AudioManager: NSObject, ObservableObject { // Add ping timers to keep WebSocket connections alive private var pingTimers: [AudioSource: Timer] = [:] + private var cancellables = Set() override init() { super.init() @@ -50,6 +53,19 @@ class AudioManager: NSObject, ObservableObject { // Activate the process controller to start monitoring audio-producing apps audioProcessController.activate() + + // When the list of running applications changes, check if we need to restart the system audio tap + NSWorkspace.shared.publisher(for: \.runningApplications) + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .sink { [weak self] _ in + guard let self, self.isTapActive else { return } + + print("🎤 Running applications changed, checking if tap restart is needed.") + Task { + await self.restartSystemAudioTapIfNeeded() + } + } + .store(in: &cancellables) } deinit { @@ -208,17 +224,16 @@ class AudioManager: NSObject, ObservableObject { print("✨ Fresh audio engine created") } - private func startSystemAudioTap() async { - print("🎧 Starting system audio tap...") + private func startSystemAudioTap(isRestart: Bool = false) async { + print(isRestart ? "🎧 Restarting system audio tap logic..." : "🎧 Starting system audio tap for the first time...") - // Ensure we have permission to record system audio. This might prompt the user. - guard await checkSystemAudioPermissions() else { - let errorMsg = "System audio recording permission denied." - print("❌ \(errorMsg)") - DispatchQueue.main.async { + if !isRestart { + guard await checkSystemAudioPermissions() else { + let errorMsg = "System audio recording permission denied." + print("❌ \(errorMsg)") self.errorMessage = errorMsg + return } - return } // Get all running processes that are producing audio @@ -236,9 +251,8 @@ class AudioManager: NSObject, ObservableObject { if let tapError = newTap.errorMessage { let errorMsg = "Failed to activate system audio tap: \(tapError)" print("❌ \(errorMsg)") - DispatchQueue.main.async { - self.errorMessage = errorMsg - } + self.errorMessage = errorMsg + if !isRestart { stopRecording() } return } @@ -248,24 +262,73 @@ class AudioManager: NSObject, ObservableObject { // Start receiving audio data from the tap do { try startTapIO(newTap) - connectToOpenAIRealtime(source: .system) - print("✅ System audio tap started successfully") - DispatchQueue.main.async { + if !isRestart { + connectToOpenAIRealtime(source: .system) self.isRecording = true AudioLevelManager.shared.updateRecordingState(true) } + print("✅ System audio tap started successfully (isRestart: \(isRestart))") + } catch { let errorMsg = "Failed to start system audio tap IO: \(error.localizedDescription)" print("❌ \(errorMsg)") - DispatchQueue.main.async { - self.errorMessage = errorMsg - } + self.errorMessage = errorMsg newTap.invalidate() self.isTapActive = false + if !isRestart { stopRecording() } } } + private func restartSystemAudioTapIfNeeded() async { + let newProcessObjectIDs = Set(audioProcessController.processes.map { $0.objectID }) + let currentProcessObjectIDs: Set + + if case .systemAudio(let processObjectIDs) = self.processTap?.target { + currentProcessObjectIDs = Set(processObjectIDs) + } else { + currentProcessObjectIDs = [] + } + + if newProcessObjectIDs != currentProcessObjectIDs { + print("Process list has changed. Restarting system audio tap.") + await restartSystemAudioTap() + } else { + print("Process list is the same. No restart needed.") + } + } + + private func restartSystemAudioTap() async { + print("🔄 Restarting system audio tap...") + + guard isRecording else { + print("Recording was stopped, aborting tap restart.") + return + } + + isRestartingSystemTap = true + defer { isRestartingSystemTap = false } + + // 1. Invalidate existing tap + if isTapActive { + processTap?.invalidate() + processTap = nil + isTapActive = false + print("System audio tap invalidated for restart.") + } + + // A small delay to let things settle. + try? await Task.sleep(for: .milliseconds(250)) + + guard self.isRecording else { + print("Recording was stopped during tap restart. Aborting.") + return + } + + // 2. Start a new one, but don't re-connect to OpenAI or change recording state + await startSystemAudioTap(isRestart: true) + } + @MainActor private func checkSystemAudioPermissions() async -> Bool { if permission.status == .authorized { @@ -325,9 +388,16 @@ class AudioManager: NSObject, ObservableObject { self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system) } invalidationHandler: { [weak self] _ in + guard let self else { return } print("Audio tap was invalidated.") - DispatchQueue.main.async { - self?.stopRecording() + + if !self.isRestartingSystemTap { + DispatchQueue.main.async { + print("Tap invalidated unexpectedly. Stopping recording.") + self.stopRecording() + } + } else { + print("Tap invalidated as part of a restart. Not stopping recording.") } } } diff --git a/meetingnotes/ProcessTap/AudioProcessController.swift b/meetingnotes/ProcessTap/AudioProcessController.swift index a44231c..d510033 100644 --- a/meetingnotes/ProcessTap/AudioProcessController.swift +++ b/meetingnotes/ProcessTap/AudioProcessController.swift @@ -49,7 +49,7 @@ extension String: @retroactive LocalizedError { @Observable final class AudioProcessController { - private let logger = Logger(subsystem: "codes.rambo.AudioCap", category: String(describing: AudioProcessController.self)) + private let logger = Logger(subsystem: "owen.meetingnotes", category: String(describing: AudioProcessController.self)) private(set) var processes = [AudioProcess]() { didSet { diff --git a/meetingnotes/ProcessTap/AudioRecordingPermission.swift b/meetingnotes/ProcessTap/AudioRecordingPermission.swift index 6842c79..ad84059 100644 --- a/meetingnotes/ProcessTap/AudioRecordingPermission.swift +++ b/meetingnotes/ProcessTap/AudioRecordingPermission.swift @@ -5,7 +5,7 @@ import OSLog /// Uses TCC SPI in order to check/request system audio recording permission. @Observable final class AudioRecordingPermission { - private let logger = Logger(subsystem: "codes.rambo.AudioCap", category: String(describing: AudioRecordingPermission.self)) + private let logger = Logger(subsystem: "owen.meetingnotes", category: String(describing: AudioRecordingPermission.self)) enum Status: String { case unknown diff --git a/meetingnotes/ProcessTap/ProcessTap.swift b/meetingnotes/ProcessTap/ProcessTap.swift index e7a1fad..00457df 100644 --- a/meetingnotes/ProcessTap/ProcessTap.swift +++ b/meetingnotes/ProcessTap/ProcessTap.swift @@ -51,7 +51,7 @@ final class ProcessTap { init(target: TapTarget, muteWhenRunning: Bool = false) { self.target = target self.muteWhenRunning = muteWhenRunning - self.logger = Logger(subsystem: "codes.rambo.AudioCap", category: "\(String(describing: ProcessTap.self))(\(target.loggingProcessName))") + self.logger = Logger(subsystem: "owen.meetingnotes", category: "\(String(describing: ProcessTap.self))(\(target.loggingProcessName))") } @ObservationIgnored @@ -327,7 +327,7 @@ final class ProcessTapRecorder { self.tapDisplayName = tap.displayName self.fileURL = fileURL self._tap = tap - self.logger = Logger(subsystem: "codes.rambo.AudioCap", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))") + self.logger = Logger(subsystem: "owen.meetingnotes", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))") self.icon = tap.target.iconImage } diff --git a/meetingnotes/Views/OnboardingView.swift b/meetingnotes/Views/OnboardingView.swift index deeb15c..67e6a3d 100644 --- a/meetingnotes/Views/OnboardingView.swift +++ b/meetingnotes/Views/OnboardingView.swift @@ -6,10 +6,13 @@ struct OnboardingView: View { @State private var apiKey = "" @State private var hasAcceptedTerms = false @State private var micPermissionGranted = false - @State private var screenPermissionGranted = false + @State private var systemAudioPermissionGranted = false @State private var showingPermissionAlert = false @State private var permissionAlertMessage = "" + // Add AudioRecordingPermission instance + @State private var audioRecordingPermission = AudioRecordingPermission() + var body: some View { GeometryReader { geometry in VStack(spacing: 0) { @@ -31,10 +34,10 @@ struct OnboardingView: View { ) PermissionRow( - title: "System Recording", + title: "System Audio Recording", description: "Required to transcribe what others say in meetings", - isGranted: screenPermissionGranted, - action: requestScreenPermission + isGranted: systemAudioPermissionGranted, + action: requestSystemAudioPermission ) } } @@ -155,11 +158,19 @@ struct OnboardingView: View { apiKey = settingsViewModel.settings.openAIKey hasAcceptedTerms = settingsViewModel.settings.hasAcceptedTerms } + .onChange(of: audioRecordingPermission.status) { oldValue, newValue in + // Update permission status when it changes + systemAudioPermissionGranted = (newValue == .authorized) + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + // Re-check permissions when app becomes active (in case they were changed in System Settings) + checkPermissions() + } } private var canProceed: Bool { return micPermissionGranted && - screenPermissionGranted && + systemAudioPermissionGranted && !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && hasAcceptedTerms } @@ -168,8 +179,8 @@ struct OnboardingView: View { // Check microphone permission using AVCaptureDevice (macOS compatible) micPermissionGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - // Check screen recording permission - screenPermissionGranted = CGPreflightScreenCaptureAccess() + // Check system audio recording permission + systemAudioPermissionGranted = (audioRecordingPermission.status == .authorized) } private func requestMicrophonePermission() { @@ -184,12 +195,13 @@ struct OnboardingView: View { } } - private func requestScreenPermission() { - let success = CGRequestScreenCaptureAccess() - DispatchQueue.main.async { - screenPermissionGranted = success - if !success { - permissionAlertMessage = "System recording access is required to capture system audio. Please enable it in System Preferences > Security & Privacy > Privacy > Screen Recording." + private func requestSystemAudioPermission() { + audioRecordingPermission.request() + + // Show alert if permission is denied after request + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if audioRecordingPermission.status == .denied { + permissionAlertMessage = "System audio recording access is required to capture what others say in meetings. Please enable 'meetingnotes' in System Preferences > Security & Privacy > Privacy > Microphone." showingPermissionAlert = true } }