import AppKit import Foundation import AVFoundation import ScreenCaptureKit import CoreMedia final class MeetingRecordingManager: NSObject { static let shared = MeetingRecordingManager() struct RecordedMeeting: Codable, Equatable, Identifiable { let id: String let startedAt: Date let endedAt: Date let sourceURLString: String? /// Human-readable title for UI and for the saved file base name (older index entries omit this key). let meetingDisplayName: String? let audioFilename: String /// Apple Speech transcript (timeline text). Omitted in older `index.json` entries. let transcriptText: String? let transcriptError: String? var audioFilenameURLSafe: String { audioFilename } var resolvedDisplayTitle: String { let raw = meetingDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return raw.isEmpty ? "Meeting recording" : raw } } enum RecordingError: Error, LocalizedError { case alreadyRecording case notRecording case microphonePermissionDenied case noShareableContent case systemAudioUnavailable(String) case exportFailed case micCaptureFailed(String) var errorDescription: String? { switch self { case .alreadyRecording: return "A recording is already in progress. Stop it first, then start again." case .notRecording: return "Nothing is being recorded right now." case .microphonePermissionDenied: return "Microphone access was denied. Turn it on in System Settings → Privacy & Security → Microphone for this app." case .noShareableContent: return "Could not access a display for system audio capture." case .systemAudioUnavailable(let reason): return "System audio capture failed (\(reason)). Microphone-only recording may still work." case .exportFailed: return "Could not save the recording file. Check free disk space and try again." case .micCaptureFailed(let reason): return "Microphone recording failed: \(reason)" } } } static let recordingsDidChangeNotification = Notification.Name("MeetingRecordingManager.recordingsDidChange") static let recordingStateDidChangeNotification = Notification.Name("MeetingRecordingManager.recordingStateDidChange") static let transcriptionDidBeginNotification = Notification.Name("MeetingRecordingManager.transcriptionDidBegin") static let transcriptionDidEndNotification = Notification.Name("MeetingRecordingManager.transcriptionDidEnd") private let encoder: JSONEncoder = { let e = JSONEncoder() e.outputFormatting = [.prettyPrinted, .sortedKeys] e.dateEncodingStrategy = .iso8601 return e }() private let decoder: JSONDecoder = { let d = JSONDecoder() d.dateDecodingStrategy = .iso8601 return d }() private(set) var recordings: [RecordedMeeting] = [] private(set) var isRecording: Bool = false private var recordingID: String? private var recordingStartedAt: Date? private var recordingSourceURLString: String? private var pendingMeetingDisplayName: String? /// Microphone capture (same approach as meetings_app: `AVAudioRecorder` → AAC `.m4a`). private var micRecorder: AVAudioRecorder? /// System audio capture (ScreenCaptureKit + `AVAssetWriter` AAC, aligned with meetings_app). private var systemAudioRecorder: AnyObject? // Output private var outputAudioURL: URL? private override init() { super.init() loadRecordingsFromDisk() } // MARK: - Public API func recordingsDirectoryURL() throws -> URL { let fm = FileManager.default let appSupport = try fm.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) // Sandboxed apps: `appSupport` is inside the app container (Finder: ~/Library/Containers/…/Data/Library/Application Support/). // Use bundle ID so the folder matches this app (not a generic name). let bundleFolder = Bundle.main.bundleIdentifier ?? "zoom_app" let dir = appSupport.appendingPathComponent(bundleFolder, isDirectory: true) .appendingPathComponent("Recordings", isDirectory: true) if fm.fileExists(atPath: dir.path) == false { try fm.createDirectory(at: dir, withIntermediateDirectories: true) } return dir } func audioURL(for recording: RecordedMeeting) throws -> URL { try recordingsDirectoryURL().appendingPathComponent(recording.audioFilename) } /// Full path to the Recordings folder (sandboxed apps: inside `~/Library/Containers/…/Data/Library/Application Support/`). var recordingsDirectoryPath: String { (try? recordingsDirectoryURL().path) ?? "" } func revealRecordingsInFinder() { guard let url = try? recordingsDirectoryURL() else { return } NSWorkspace.shared.open(url) } func startRecording(sourceURL: URL?, meetingDisplayName: String? = nil) async throws { if isRecording { throw RecordingError.alreadyRecording } let micGranted = await AVCaptureDevice.requestAccess(for: .audio) if micGranted == false { throw RecordingError.microphonePermissionDenied } let id = UUID().uuidString let startedAt = Date() let dir = try recordingsDirectoryURL() let micURL = dir.appendingPathComponent("\(id)-mic.m4a") let sysURL = dir.appendingPathComponent("\(id)-system.m4a") let displayTitle = Self.resolvedMeetingTitle(explicit: meetingDisplayName, sourceURL: sourceURL) let outBase = Self.uniqueSanitizedFileURL(directory: dir, baseName: displayTitle, idSuffix: id, extension: "m4a") recordingID = id recordingStartedAt = startedAt recordingSourceURLString = sourceURL?.absoluteString pendingMeetingDisplayName = displayTitle outputAudioURL = outBase do { try startMicRecorder(to: micURL) if #available(macOS 13.0, *) { let recorder = ZoomMeetingSystemAudioRecorder(outputURL: sysURL) systemAudioRecorder = recorder do { try await recorder.start() } catch { systemAudioRecorder = nil } } else { systemAudioRecorder = nil } } catch { stopMicRecorder() if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder { try? await r.stop() } systemAudioRecorder = nil recordingID = nil recordingStartedAt = nil recordingSourceURLString = nil pendingMeetingDisplayName = nil outputAudioURL = nil throw error } isRecording = true NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self) } func stopRecording() async throws { guard isRecording else { throw RecordingError.notRecording } guard let id = recordingID, let startedAt = recordingStartedAt, let outURL = outputAudioURL else { throw RecordingError.exportFailed } let endedAt = Date() let dir = try recordingsDirectoryURL() let micURL = dir.appendingPathComponent("\(id)-mic.m4a") let sysURL = dir.appendingPathComponent("\(id)-system.m4a") stopMicRecorder() if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder { try? await r.stop() } systemAudioRecorder = nil let mixedURL = try await finalizeMeetingAudio(systemURL: sysURL, microphoneURL: micURL, outputURL: outURL) // Cleanup intermediates (ignore failures). try? FileManager.default.removeItem(at: micURL) try? FileManager.default.removeItem(at: sysURL) let displayName = pendingMeetingDisplayName let rec = RecordedMeeting( id: id, startedAt: startedAt, endedAt: endedAt, sourceURLString: recordingSourceURLString, meetingDisplayName: displayName, audioFilename: mixedURL.lastPathComponent, transcriptText: nil, transcriptError: nil ) recordings.insert(rec, at: 0) saveRecordingsToDisk() scheduleAppleTranscription(for: rec) recordingID = nil recordingStartedAt = nil recordingSourceURLString = nil pendingMeetingDisplayName = nil outputAudioURL = nil isRecording = false NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self) NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self) } // MARK: - Persistence private func indexURL() throws -> URL { try recordingsDirectoryURL().appendingPathComponent("index.json") } private func loadRecordingsFromDisk() { do { let url = try indexURL() guard FileManager.default.fileExists(atPath: url.path) else { return } let data = try Data(contentsOf: url) recordings = try decoder.decode([RecordedMeeting].self, from: data) } catch { recordings = [] } } /// Runs Apple Speech on the saved mixed audio; updates `recordings` and posts `recordingsDidChangeNotification` when finished. private func scheduleAppleTranscription(for recording: RecordedMeeting) { let recordingId = recording.id let filename = recording.audioFilename let locales = TranscriptionLanguagePreferences.localesForAppleTranscription() Task { await performAppleTranscription(recordingId: recordingId, audioFilename: filename, locales: locales) } } private func performAppleTranscription(recordingId: String, audioFilename: String, locales: [Locale]) async { NotificationCenter.default.post( name: Self.transcriptionDidBeginNotification, object: self, userInfo: ["recordingId": recordingId] ) defer { NotificationCenter.default.post( name: Self.transcriptionDidEndNotification, object: self, userInfo: ["recordingId": recordingId] ) } let service = MeetingTranscriptionService() do { let dir = try recordingsDirectoryURL() let audioURL = dir.appendingPathComponent(audioFilename) let segments = try await service.transcribeMeeting(micURL: audioURL, systemURL: nil, locales: locales) let text = segments.renderedTimelineText() await MainActor.run { applyTranscript(recordingId: recordingId, text: text.isEmpty ? nil : text, error: nil) } } catch { await MainActor.run { applyTranscript(recordingId: recordingId, text: nil, error: error.localizedDescription) } } } private func applyTranscript(recordingId: String, text: String?, error: String?) { guard let idx = recordings.firstIndex(where: { $0.id == recordingId }) else { return } let old = recordings[idx] let updated = RecordedMeeting( id: old.id, startedAt: old.startedAt, endedAt: old.endedAt, sourceURLString: old.sourceURLString, meetingDisplayName: old.meetingDisplayName, audioFilename: old.audioFilename, transcriptText: text, transcriptError: error ) recordings[idx] = updated saveRecordingsToDisk() NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self) } private func saveRecordingsToDisk() { do { let url = try indexURL() let data = try encoder.encode(recordings) try data.write(to: url, options: [.atomic]) } catch { NSLog("MeetingRecordingManager: failed to save index.json — \(error.localizedDescription)") } } // MARK: - Mic capture (meetings_app-style) private func startMicRecorder(to outputURL: URL) throws { try? FileManager.default.removeItem(at: outputURL) let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48_000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 128_000, AVEncoderAudioQualityKey: AVAudioQuality.max.rawValue ] let recorder = try AVAudioRecorder(url: outputURL, settings: settings) recorder.prepareToRecord() guard recorder.record() else { throw RecordingError.micCaptureFailed("AVAudioRecorder could not start.") } micRecorder = recorder } private func stopMicRecorder() { micRecorder?.stop() micRecorder = nil } // MARK: - Finalize / mix (meetings_app-style) private func fileSize(at url: URL) -> Int64 { let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) return attrs?[.size] as? Int64 ?? 0 } private func hasAudioPayload(at url: URL, minBytes: Int64 = 10_000) -> Bool { guard FileManager.default.fileExists(atPath: url.path) else { return false } return fileSize(at: url) >= minBytes } private func finalizeMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async throws -> URL { let fm = FileManager.default if fm.fileExists(atPath: outputURL.path) { try? fm.removeItem(at: outputURL) } let hasSystem = hasAudioPayload(at: systemURL) let hasMic = hasAudioPayload(at: microphoneURL) if hasSystem && hasMic { do { try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: outputURL) return outputURL } catch { // Fall through to single-track handling. } } let chosenURL: URL? if hasSystem { chosenURL = systemURL } else if hasMic { chosenURL = microphoneURL } else { chosenURL = nil } guard let chosenURL else { throw RecordingError.exportFailed } if chosenURL.path != outputURL.path { try? fm.removeItem(at: outputURL) do { try fm.copyItem(at: chosenURL, to: outputURL) } catch { return chosenURL } } return outputURL } private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws { try? FileManager.default.removeItem(at: destinationURL) let composition = AVMutableComposition() guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid), let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else { throw RecordingError.exportFailed } let systemAsset = AVURLAsset(url: systemURL) let micAsset = AVURLAsset(url: microphoneURL) if let src = systemAsset.tracks(withMediaType: .audio).first { let duration = systemAsset.duration if duration.isValid && !duration.isIndefinite && duration.seconds > 0 { try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero) } } if let src = micAsset.tracks(withMediaType: .audio).first { let duration = micAsset.duration if duration.isValid && !duration.isIndefinite && duration.seconds > 0 { try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero) } } guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else { throw RecordingError.exportFailed } let systemMix = AVMutableAudioMixInputParameters(track: systemTrack) systemMix.setVolume(1.0, at: .zero) let micMix = AVMutableAudioMixInputParameters(track: micTrack) micMix.setVolume(1.35, at: .zero) let audioMix = AVMutableAudioMix() audioMix.inputParameters = [systemMix, micMix] export.outputURL = destinationURL export.outputFileType = .m4a export.audioMix = audioMix await withCheckedContinuation { continuation in export.exportAsynchronously { continuation.resume() } } if export.status != .completed { throw export.error ?? RecordingError.exportFailed } } // MARK: - Meeting title + filenames /// Trimmed explicit name from the join sheet, otherwise a stable label from the Zoom URL (meeting id, instant meeting, etc.). static func resolvedMeetingTitle(explicit: String?, sourceURL: URL?) -> String { if let t = explicit?.trimmingCharacters(in: .whitespacesAndNewlines), t.isEmpty == false { return t } if let url = sourceURL { return derivedDisplayTitle(from: url) } return "Zoom meeting" } static func derivedDisplayTitle(from url: URL) -> String { let path = url.path.lowercased() if path.contains("/start/") || path.contains("videomeeting") { return "Instant meeting" } let parts = url.path.split(separator: "/").map(String.init) for (i, segment) in parts.enumerated() { let s = segment.lowercased() if (s == "j" || s == "join" || s == "wc" || s == "my") && i + 1 < parts.count { let next = parts[i + 1].filter(\.isNumber) if next.count >= 9 { return "Meeting \(next)" } } } if let last = parts.last { let digits = last.filter(\.isNumber) if digits.count >= 9 { return "Meeting \(digits)" } } return "Zoom meeting" } /// Safe file base: filesystem-safe, bounded length, then made unique in `directory` if needed. static func uniqueSanitizedFileURL(directory: URL, baseName: String, idSuffix: String, extension ext: String) -> URL { let sanitized = sanitizeFileBaseName(baseName) let shortID = String(idSuffix.replacingOccurrences(of: "-", with: "").prefix(8)) let fm = FileManager.default for attempt in 0..<50 { let suffix = attempt == 0 ? "" : "_\(attempt)" let name = "\(sanitized)_\(shortID)\(suffix).\(ext)" let url = directory.appendingPathComponent(name) if fm.fileExists(atPath: url.path) == false { return url } } return directory.appendingPathComponent("\(sanitized)_\(shortID)_\(UUID().uuidString.prefix(8)).\(ext)") } private static func sanitizeFileBaseName(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>") let cleaned = collapsed.unicodeScalars.map { invalid.contains($0) ? "_" : Character($0) } let joined = String(cleaned).trimmingCharacters(in: CharacterSet(charactersIn: " ._")) let base = joined.isEmpty ? "Zoom_meeting" : joined let maxLen = 72 if base.count <= maxLen { return base } let idx = base.index(base.startIndex, offsetBy: maxLen) return String(base[..