|
@@ -12,6 +12,7 @@ import AVKit
|
|
|
import WebKit
|
|
import WebKit
|
|
|
import AuthenticationServices
|
|
import AuthenticationServices
|
|
|
import StoreKit
|
|
import StoreKit
|
|
|
|
|
+import ScreenCaptureKit
|
|
|
|
|
|
|
|
private enum SidebarPage: Int {
|
|
private enum SidebarPage: Int {
|
|
|
case joinMeetings = 0
|
|
case joinMeetings = 0
|
|
@@ -400,6 +401,8 @@ final class ViewController: NSViewController {
|
|
|
private var aiCompanionLocalRecordings: [MeetingRecordingSummary] = []
|
|
private var aiCompanionLocalRecordings: [MeetingRecordingSummary] = []
|
|
|
private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
|
|
private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
|
|
|
private var activeMeetingAudioRecorder: AVAudioRecorder?
|
|
private var activeMeetingAudioRecorder: AVAudioRecorder?
|
|
|
|
|
+ private var activeMeetingSystemAudioStopper: (() async -> Void)?
|
|
|
|
|
+ private var meetingRecordingMonitorTask: Task<Void, Never>?
|
|
|
private weak var aiCompanionStopRecordingButton: NSButton?
|
|
private weak var aiCompanionStopRecordingButton: NSButton?
|
|
|
private let widgetSnapshotLimit: Int = 3
|
|
private let widgetSnapshotLimit: Int = 3
|
|
|
|
|
|
|
@@ -513,7 +516,6 @@ final class ViewController: NSViewController {
|
|
|
migrateLegacyRatingStateIfNeeded()
|
|
migrateLegacyRatingStateIfNeeded()
|
|
|
beginUsageTrackingSessionIfNeeded()
|
|
beginUsageTrackingSessionIfNeeded()
|
|
|
observeAppLifecycleForUsageTrackingIfNeeded()
|
|
observeAppLifecycleForUsageTrackingIfNeeded()
|
|
|
- observeMeetingRecordingLifecycle()
|
|
|
|
|
registerWidgetNotificationObservers()
|
|
registerWidgetNotificationObservers()
|
|
|
setupRootView()
|
|
setupRootView()
|
|
|
buildMainLayout()
|
|
buildMainLayout()
|
|
@@ -571,6 +573,7 @@ final class ViewController: NSViewController {
|
|
|
storeKitStartupTask?.cancel()
|
|
storeKitStartupTask?.cancel()
|
|
|
paywallPurchaseTask?.cancel()
|
|
paywallPurchaseTask?.cancel()
|
|
|
launchPaywallWorkItem?.cancel()
|
|
launchPaywallWorkItem?.cancel()
|
|
|
|
|
+ meetingRecordingMonitorTask?.cancel()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1251,34 +1254,6 @@ private extension ViewController {
|
|
|
alert.runModal()
|
|
alert.runModal()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func observeMeetingRecordingLifecycle() {
|
|
|
|
|
- NotificationCenter.default.addObserver(
|
|
|
|
|
- self,
|
|
|
|
|
- selector: #selector(handleWorkspaceAppActivation(_:)),
|
|
|
|
|
- name: NSWorkspace.didActivateApplicationNotification,
|
|
|
|
|
- object: nil
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @objc private func handleWorkspaceAppActivation(_ notification: Notification) {
|
|
|
|
|
- guard activeMeetingRecordingSession != nil else { return }
|
|
|
|
|
- guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
|
|
|
|
|
- guard app.bundleIdentifier == Bundle.main.bundleIdentifier else { return }
|
|
|
|
|
- promptStopRecordingAfterReturningToApp()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func promptStopRecordingAfterReturningToApp() {
|
|
|
|
|
- guard let session = activeMeetingRecordingSession else { return }
|
|
|
|
|
- let alert = NSAlert()
|
|
|
|
|
- alert.messageText = "Stop meeting recording?"
|
|
|
|
|
- alert.informativeText = "You returned to the app for \"\(session.title)\". Stop local recording now?"
|
|
|
|
|
- alert.addButton(withTitle: "Stop Recording")
|
|
|
|
|
- alert.addButton(withTitle: "Keep Recording")
|
|
|
|
|
- if alert.runModal() == .alertFirstButtonReturn {
|
|
|
|
|
- finishActiveMeetingRecording()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
private func requestMeetingRecordingConsent(title: String) -> Bool {
|
|
private func requestMeetingRecordingConsent(title: String) -> Bool {
|
|
|
let alert = NSAlert()
|
|
let alert = NSAlert()
|
|
|
alert.messageText = "Record this meeting locally?"
|
|
alert.messageText = "Record this meeting locally?"
|
|
@@ -1319,20 +1294,20 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func beginMeetingRecordingIfConsented(meetingTitle: String, meetingURL: URL) {
|
|
private func beginMeetingRecordingIfConsented(meetingTitle: String, meetingURL: URL) {
|
|
|
- if activeMeetingRecordingSession != nil {
|
|
|
|
|
- finishActiveMeetingRecording()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ meetingRecordingMonitorTask?.cancel()
|
|
|
|
|
+ meetingRecordingMonitorTask = nil
|
|
|
|
|
+ if activeMeetingRecordingSession != nil { finishActiveMeetingRecording(cancelMonitoring: false) }
|
|
|
guard requestMeetingRecordingConsent(title: meetingTitle) else { return }
|
|
guard requestMeetingRecordingConsent(title: meetingTitle) else { return }
|
|
|
let permission = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
let permission = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
|
switch permission {
|
|
switch permission {
|
|
|
case .authorized:
|
|
case .authorized:
|
|
|
- startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
|
|
|
|
+ startAutomaticRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
case .notDetermined:
|
|
case .notDetermined:
|
|
|
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
|
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
|
|
DispatchQueue.main.async {
|
|
DispatchQueue.main.async {
|
|
|
guard let self else { return }
|
|
guard let self else { return }
|
|
|
if granted {
|
|
if granted {
|
|
|
- self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
|
|
|
|
+ self.startAutomaticRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
} else {
|
|
} else {
|
|
|
self.showSimpleAlert(title: "Microphone permission denied", message: "Enable microphone access in System Settings to record meetings locally.")
|
|
self.showSimpleAlert(title: "Microphone permission denied", message: "Enable microphone access in System Settings to record meetings locally.")
|
|
|
}
|
|
}
|
|
@@ -1345,40 +1320,129 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func startAutomaticRecordingFlow(meetingTitle: String, meetingURL: URL) {
|
|
|
|
|
+ guard let meetingCode = aiCompanionMeetMeetingCode(from: meetingURL), hasGoogleSessionAvailable() else {
|
|
|
|
|
+ startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ showTopToast(message: "Recording will start when the meeting starts", isError: false)
|
|
|
|
|
+ meetingRecordingMonitorTask = Task { [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ defer { self.meetingRecordingMonitorTask = nil }
|
|
|
|
|
+
|
|
|
|
|
+ do {
|
|
|
|
|
+ let space = try await self.googleMeetClient.getSpace(
|
|
|
|
|
+ accessToken: try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window),
|
|
|
|
|
+ spaceNameOrMeetingCode: "spaces/\(meetingCode)"
|
|
|
|
|
+ )
|
|
|
|
|
+ let spaceName = space.name ?? "spaces/\(meetingCode)"
|
|
|
|
|
+ var hasStarted = false
|
|
|
|
|
+ var inactivePollCount = 0
|
|
|
|
|
+ let noStartDeadline = Date().addingTimeInterval(20 * 60)
|
|
|
|
|
+
|
|
|
|
|
+ while !Task.isCancelled {
|
|
|
|
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
|
|
+ let isActive = try await self.aiCompanionIsMeetingActive(spaceResourceName: spaceName, accessToken: token)
|
|
|
|
|
+
|
|
|
|
|
+ if isActive {
|
|
|
|
|
+ inactivePollCount = 0
|
|
|
|
|
+ if !hasStarted {
|
|
|
|
|
+ self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
|
|
+ hasStarted = true
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if hasStarted {
|
|
|
|
|
+ inactivePollCount += 1
|
|
|
|
|
+ if inactivePollCount >= 1 {
|
|
|
|
|
+ self.finishActiveMeetingRecording(cancelMonitoring: false)
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if Date() > noStartDeadline {
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ if self.activeMeetingRecordingSession == nil {
|
|
|
|
|
+ self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func aiCompanionIsMeetingActive(spaceResourceName: String, accessToken: String) async throws -> Bool {
|
|
|
|
|
+ let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceResourceName)
|
|
|
|
|
+ return records.contains { $0.startTime != nil && $0.endTime == nil }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func startMeetingRecording(meetingTitle: String, meetingURL: URL) {
|
|
private func startMeetingRecording(meetingTitle: String, meetingURL: URL) {
|
|
|
let recordingID = UUID().uuidString
|
|
let recordingID = UUID().uuidString
|
|
|
let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
|
|
let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
|
|
|
|
|
+
|
|
|
|
|
+ activeMeetingRecordingSession = ActiveMeetingRecordingSession(
|
|
|
|
|
+ id: recordingID,
|
|
|
|
|
+ title: meetingTitle,
|
|
|
|
|
+ meetURL: meetingURL,
|
|
|
|
|
+ startedAt: Date(),
|
|
|
|
|
+ audioFileURL: outputURL
|
|
|
|
|
+ )
|
|
|
|
|
+ pageCache[.aiCompanion] = nil
|
|
|
|
|
+ if selectedSidebarPage == .aiCompanion { showSidebarPage(.aiCompanion) }
|
|
|
|
|
+
|
|
|
|
|
+ if #available(macOS 13.0, *) {
|
|
|
|
|
+ let systemRecorder = MeetingSystemAudioRecorder(outputURL: outputURL)
|
|
|
|
|
+ activeMeetingSystemAudioStopper = { [systemRecorder] in
|
|
|
|
|
+ try? await systemRecorder.stop()
|
|
|
|
|
+ }
|
|
|
|
|
+ Task { [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await systemRecorder.start()
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ self.showTopToast(message: "Meeting recording started (meeting audio)", isError: false)
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ self.activeMeetingSystemAudioStopper = nil
|
|
|
|
|
+ self.startMicrophoneFallbackRecording(at: outputURL)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ startMicrophoneFallbackRecording(at: outputURL)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func startMicrophoneFallbackRecording(at outputURL: URL) {
|
|
|
let settings: [String: Any] = [
|
|
let settings: [String: Any] = [
|
|
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
|
AVSampleRateKey: 44_100,
|
|
AVSampleRateKey: 44_100,
|
|
|
AVNumberOfChannelsKey: 1,
|
|
AVNumberOfChannelsKey: 1,
|
|
|
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
|
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
|
|
]
|
|
]
|
|
|
-
|
|
|
|
|
do {
|
|
do {
|
|
|
let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
|
recorder.prepareToRecord()
|
|
recorder.prepareToRecord()
|
|
|
recorder.record()
|
|
recorder.record()
|
|
|
activeMeetingAudioRecorder = recorder
|
|
activeMeetingAudioRecorder = recorder
|
|
|
- activeMeetingRecordingSession = ActiveMeetingRecordingSession(
|
|
|
|
|
- id: recordingID,
|
|
|
|
|
- title: meetingTitle,
|
|
|
|
|
- meetURL: meetingURL,
|
|
|
|
|
- startedAt: Date(),
|
|
|
|
|
- audioFileURL: outputURL
|
|
|
|
|
- )
|
|
|
|
|
- showTopToast(message: "Meeting recording started locally", isError: false)
|
|
|
|
|
- pageCache[.aiCompanion] = nil
|
|
|
|
|
- if selectedSidebarPage == .aiCompanion {
|
|
|
|
|
- showSidebarPage(.aiCompanion)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ showTopToast(message: "Meeting recording started (microphone only)", isError: false)
|
|
|
} catch {
|
|
} catch {
|
|
|
|
|
+ activeMeetingRecordingSession = nil
|
|
|
showSimpleAlert(title: "Could not start recording", message: error.localizedDescription)
|
|
showSimpleAlert(title: "Could not start recording", message: error.localizedDescription)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func finishActiveMeetingRecording() {
|
|
|
|
|
|
|
+ private func finishActiveMeetingRecording(cancelMonitoring: Bool = true) {
|
|
|
|
|
+ if cancelMonitoring {
|
|
|
|
|
+ meetingRecordingMonitorTask?.cancel()
|
|
|
|
|
+ meetingRecordingMonitorTask = nil
|
|
|
|
|
+ }
|
|
|
guard let session = activeMeetingRecordingSession else { return }
|
|
guard let session = activeMeetingRecordingSession else { return }
|
|
|
|
|
+ let stopSystemAudio = activeMeetingSystemAudioStopper
|
|
|
|
|
+ activeMeetingSystemAudioStopper = nil
|
|
|
|
|
+ if let stopSystemAudio { Task { await stopSystemAudio() } }
|
|
|
activeMeetingAudioRecorder?.stop()
|
|
activeMeetingAudioRecorder?.stop()
|
|
|
activeMeetingAudioRecorder = nil
|
|
activeMeetingAudioRecorder = nil
|
|
|
activeMeetingRecordingSession = nil
|
|
activeMeetingRecordingSession = nil
|
|
@@ -5658,6 +5722,101 @@ extension ViewController: NSWindowDelegate {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+@available(macOS 13.0, *)
|
|
|
|
|
+private final class MeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
|
|
|
|
+ private let outputURL: URL
|
|
|
|
|
+ private var stream: SCStream?
|
|
|
|
|
+ private var writer: AVAssetWriter?
|
|
|
|
|
+ private var writerInput: AVAssetWriterInput?
|
|
|
|
|
+ private let outputQueue = DispatchQueue(label: "meeting.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 NSError(domain: "MeetingSystemAudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "No display available for system audio capture."])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 NSError(domain: "MeetingSystemAudioRecorder", code: 2, userInfo: [NSLocalizedDescriptionKey: "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
|
|
|
|
|
+ if #available(macOS 13.0, *) {
|
|
|
|
|
+ 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)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/// Default `NSClipView` uses a non-flipped coordinate system, so a document shorter than the visible area is anchored to the **bottom** of the clip, leaving a large gap above (e.g. Schedule empty state). Flipped coordinates match Auto Layout’s top-leading anchors and keep content top-aligned.
|
|
/// Default `NSClipView` uses a non-flipped coordinate system, so a document shorter than the visible area is anchored to the **bottom** of the clip, leaving a large gap above (e.g. Schedule empty state). Flipped coordinates match Auto Layout’s top-leading anchors and keep content top-aligned.
|
|
|
private final class TopAlignedClipView: NSClipView {
|
|
private final class TopAlignedClipView: NSClipView {
|
|
|
override var isFlipped: Bool { true }
|
|
override var isFlipped: Bool { true }
|