| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770 |
- 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?
- /// GPT-generated notes (`notRequested` / `processing` / `ready` / `failed`). Omitted in older entries.
- let notesStatusRaw: String?
- let notesText: String?
- let notesError: 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
- /// Elapsed time since the current recording session began (0 when not recording).
- var currentRecordingElapsed: TimeInterval {
- guard let startedAt = recordingStartedAt else { return 0 }
- return Date().timeIntervalSince(startedAt)
- }
- /// Grace period after recording starts before ambiguous “left meeting” signals may auto-stop.
- static let recordingLeaveGraceInterval: TimeInterval = automaticStopGraceInterval
- 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?
- // MARK: - Automatic stop (meeting left / ended)
- private static let automaticStopPollInterval: TimeInterval = 2
- /// Do not treat "no meeting UI" as leave until the user has had time to connect.
- private static let automaticStopGraceInterval: TimeInterval = 20
- /// After grace, if we never saw meeting UI (common when window titles are hidden), still allow auto-stop
- /// after this many seconds of consecutive "not visible" polls so recording cannot run forever.
- private static let automaticStopBlindPresenceDelay: TimeInterval = 120
- /// Consecutive polls with no call UI after a prior positive detection (avoids flapping during layout changes).
- private static let automaticStopMissThreshold = 2
- private static let zoomDesktopBundleID = "us.zoom.xos"
- private var automaticStopTimer: Timer?
- private var automaticStopWorkspaceObserver: NSObjectProtocol?
- private var automaticStopConsecutiveMisses: Int = 0
- private var automaticStopHaveSeenCallUI: Bool = false
- /// Set by the embedded `WKWebView` when its URL indicates an active join/in-call web client page.
- private var embeddedWebClientCallUIVisible: Bool = false
- private var stopRecordingInFlight: Bool = false
- 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)
- }
- /// Persists AI notes fields for a recording and posts `recordingsDidChangeNotification`.
- func setMeetingNotes(recordingId: String, notesStatusRaw: String?, notesText: String?, notesError: 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: old.transcriptText,
- transcriptError: old.transcriptError,
- notesStatusRaw: notesStatusRaw,
- notesText: notesText,
- notesError: notesError
- )
- recordings[idx] = updated
- saveRecordingsToDisk()
- NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
- }
- /// 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)
- await MainActor.run {
- self.startAutomaticStopMonitoringOnMain()
- }
- }
- /// Embedded Zoom web client reports whether the current page looks like an active join / in-call flow.
- @MainActor
- func setEmbeddedWebClientCallUIVisible(_ visible: Bool) {
- embeddedWebClientCallUIVisible = visible
- if visible {
- automaticStopHaveSeenCallUI = true
- automaticStopConsecutiveMisses = 0
- }
- }
- func stopRecording() async throws {
- await MainActor.run {
- self.stopAutomaticStopMonitoringOnMain()
- }
- guard stopRecordingInFlight == false else { return }
- guard isRecording else { throw RecordingError.notRecording }
- stopRecordingInFlight = true
- defer { stopRecordingInFlight = false }
- 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,
- notesStatusRaw: nil,
- notesText: nil,
- notesError: 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)
- }
- @MainActor
- private func startAutomaticStopMonitoringOnMain() {
- assert(Thread.isMainThread)
- stopAutomaticStopMonitoringOnMain()
- automaticStopConsecutiveMisses = 0
- automaticStopHaveSeenCallUI = false
- embeddedWebClientCallUIVisible = false
- automaticStopWorkspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
- forName: NSWorkspace.didTerminateApplicationNotification,
- object: nil,
- queue: .main
- ) { [weak self] note in
- self?.handleZoomDesktopClientTerminated(note)
- }
- let timer = Timer(timeInterval: Self.automaticStopPollInterval, repeats: true) { [weak self] _ in
- self?.automaticStopPresenceTimerFired()
- }
- RunLoop.main.add(timer, forMode: .common)
- automaticStopTimer = timer
- }
- @MainActor
- private func stopAutomaticStopMonitoringOnMain() {
- assert(Thread.isMainThread)
- automaticStopTimer?.invalidate()
- automaticStopTimer = nil
- if let token = automaticStopWorkspaceObserver {
- NSWorkspace.shared.notificationCenter.removeObserver(token)
- automaticStopWorkspaceObserver = nil
- }
- automaticStopConsecutiveMisses = 0
- automaticStopHaveSeenCallUI = false
- embeddedWebClientCallUIVisible = false
- }
- @MainActor
- private func automaticStopPresenceTimerFired() {
- guard isRecording else {
- stopAutomaticStopMonitoringOnMain()
- return
- }
- guard let startedAt = recordingStartedAt else { return }
- let elapsed = Date().timeIntervalSince(startedAt)
- if elapsed < Self.automaticStopGraceInterval { return }
- let visible = embeddedWebClientCallUIVisible || ZoomMeetingPresenceWatcher.callPresenceLikelyVisible()
- if visible {
- automaticStopHaveSeenCallUI = true
- automaticStopConsecutiveMisses = 0
- return
- }
- if automaticStopHaveSeenCallUI {
- automaticStopConsecutiveMisses += 1
- } else if elapsed >= Self.automaticStopGraceInterval + Self.automaticStopBlindPresenceDelay {
- automaticStopConsecutiveMisses += 1
- } else {
- return
- }
- guard automaticStopConsecutiveMisses >= Self.automaticStopMissThreshold else { return }
- Task { [weak self] in
- guard let self else { return }
- try? await self.stopRecording()
- }
- }
- @MainActor
- private func handleZoomDesktopClientTerminated(_ note: Notification) {
- guard isRecording else { return }
- guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
- guard app.bundleIdentifier == Self.zoomDesktopBundleID else { return }
- guard let startedAt = recordingStartedAt, Date().timeIntervalSince(startedAt) >= Self.automaticStopGraceInterval else { return }
- guard automaticStopHaveSeenCallUI else { return }
- Task { [weak self] in
- guard let self else { return }
- try? await self.stopRecording()
- }
- }
- // 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,
- notesStatusRaw: old.notesStatusRaw,
- notesText: old.notesText,
- notesError: old.notesError
- )
- 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[..<idx]).trimmingCharacters(in: CharacterSet(charactersIn: " ._"))
- }
- }
- // MARK: - System audio (meetings_app-style)
- @available(macOS 13.0, *)
- private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
- private let outputURL: URL
- private var stream: SCStream?
- private var writer: AVAssetWriter?
- private var writerInput: AVAssetWriterInput?
- private let outputQueue = DispatchQueue(label: "zoom_app.system.audio.capture")
- private var didStartSession = false
- init(outputURL: URL) {
- self.outputURL = outputURL
- }
- func start() async throws {
- try? FileManager.default.removeItem(at: outputURL)
- let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
- guard let display = content.displays.first else {
- throw MeetingRecordingManager.RecordingError.noShareableContent
- }
- let writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
- let inputSettings: [String: Any] = [
- AVFormatIDKey: kAudioFormatMPEG4AAC,
- AVSampleRateKey: 48_000,
- AVNumberOfChannelsKey: 2,
- AVEncoderBitRateKey: 128_000
- ]
- let input = AVAssetWriterInput(mediaType: .audio, outputSettings: inputSettings)
- input.expectsMediaDataInRealTime = true
- guard writer.canAdd(input) else {
- throw MeetingRecordingManager.RecordingError.systemAudioUnavailable("Cannot add audio writer input.")
- }
- writer.add(input)
- self.writer = writer
- self.writerInput = input
- didStartSession = false
- let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
- let config = SCStreamConfiguration()
- config.width = 2
- config.height = 2
- config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
- config.queueDepth = 1
- config.capturesAudio = true
- config.sampleRate = 48_000
- config.channelCount = 2
- config.excludesCurrentProcessAudio = true
- let stream = SCStream(filter: filter, configuration: config, delegate: self)
- self.stream = stream
- try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
- try await stream.startCapture()
- }
- func stop() async throws {
- if let stream {
- try? await stream.stopCapture()
- self.stream = nil
- }
- writerInput?.markAsFinished()
- guard let writer else { return }
- await withCheckedContinuation { continuation in
- writer.finishWriting {
- continuation.resume()
- }
- }
- self.writer = nil
- self.writerInput = nil
- }
- func stream(_ stream: SCStream, didStopWithError error: Error) {
- // Capture may stop on permission changes or display changes; caller handles lifecycle.
- }
- func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
- guard outputType == .audio else { return }
- guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
- guard let writer = writer, let input = writerInput else { return }
- if writer.status == .unknown {
- writer.startWriting()
- }
- if writer.status == .writing {
- let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
- if !didStartSession {
- writer.startSession(atSourceTime: pts)
- didStartSession = true
- }
- if input.isReadyForMoreMediaData {
- input.append(sampleBuffer)
- }
- }
- }
- }
|