|
|
@@ -12,6 +12,7 @@ import AVKit
|
|
12
|
12
|
import WebKit
|
|
13
|
13
|
import AuthenticationServices
|
|
14
|
14
|
import StoreKit
|
|
|
15
|
+import ScreenCaptureKit
|
|
15
|
16
|
|
|
16
|
17
|
private enum SidebarPage: Int {
|
|
17
|
18
|
case joinMeetings = 0
|
|
|
@@ -400,6 +401,8 @@ final class ViewController: NSViewController {
|
|
400
|
401
|
private var aiCompanionLocalRecordings: [MeetingRecordingSummary] = []
|
|
401
|
402
|
private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
|
|
402
|
403
|
private var activeMeetingAudioRecorder: AVAudioRecorder?
|
|
|
404
|
+ private var activeMeetingSystemAudioStopper: (() async -> Void)?
|
|
|
405
|
+ private var meetingRecordingMonitorTask: Task<Void, Never>?
|
|
403
|
406
|
private weak var aiCompanionStopRecordingButton: NSButton?
|
|
404
|
407
|
private let widgetSnapshotLimit: Int = 3
|
|
405
|
408
|
|
|
|
@@ -513,7 +516,6 @@ final class ViewController: NSViewController {
|
|
513
|
516
|
migrateLegacyRatingStateIfNeeded()
|
|
514
|
517
|
beginUsageTrackingSessionIfNeeded()
|
|
515
|
518
|
observeAppLifecycleForUsageTrackingIfNeeded()
|
|
516
|
|
- observeMeetingRecordingLifecycle()
|
|
517
|
519
|
registerWidgetNotificationObservers()
|
|
518
|
520
|
setupRootView()
|
|
519
|
521
|
buildMainLayout()
|
|
|
@@ -571,6 +573,7 @@ final class ViewController: NSViewController {
|
|
571
|
573
|
storeKitStartupTask?.cancel()
|
|
572
|
574
|
paywallPurchaseTask?.cancel()
|
|
573
|
575
|
launchPaywallWorkItem?.cancel()
|
|
|
576
|
+ meetingRecordingMonitorTask?.cancel()
|
|
574
|
577
|
}
|
|
575
|
578
|
}
|
|
576
|
579
|
|
|
|
@@ -1251,34 +1254,6 @@ private extension ViewController {
|
|
1251
|
1254
|
alert.runModal()
|
|
1252
|
1255
|
}
|
|
1253
|
1256
|
|
|
1254
|
|
- private func observeMeetingRecordingLifecycle() {
|
|
1255
|
|
- NotificationCenter.default.addObserver(
|
|
1256
|
|
- self,
|
|
1257
|
|
- selector: #selector(handleWorkspaceAppActivation(_:)),
|
|
1258
|
|
- name: NSWorkspace.didActivateApplicationNotification,
|
|
1259
|
|
- object: nil
|
|
1260
|
|
- )
|
|
1261
|
|
- }
|
|
1262
|
|
-
|
|
1263
|
|
- @objc private func handleWorkspaceAppActivation(_ notification: Notification) {
|
|
1264
|
|
- guard activeMeetingRecordingSession != nil else { return }
|
|
1265
|
|
- guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
|
|
1266
|
|
- guard app.bundleIdentifier == Bundle.main.bundleIdentifier else { return }
|
|
1267
|
|
- promptStopRecordingAfterReturningToApp()
|
|
1268
|
|
- }
|
|
1269
|
|
-
|
|
1270
|
|
- private func promptStopRecordingAfterReturningToApp() {
|
|
1271
|
|
- guard let session = activeMeetingRecordingSession else { return }
|
|
1272
|
|
- let alert = NSAlert()
|
|
1273
|
|
- alert.messageText = "Stop meeting recording?"
|
|
1274
|
|
- alert.informativeText = "You returned to the app for \"\(session.title)\". Stop local recording now?"
|
|
1275
|
|
- alert.addButton(withTitle: "Stop Recording")
|
|
1276
|
|
- alert.addButton(withTitle: "Keep Recording")
|
|
1277
|
|
- if alert.runModal() == .alertFirstButtonReturn {
|
|
1278
|
|
- finishActiveMeetingRecording()
|
|
1279
|
|
- }
|
|
1280
|
|
- }
|
|
1281
|
|
-
|
|
1282
|
1257
|
private func requestMeetingRecordingConsent(title: String) -> Bool {
|
|
1283
|
1258
|
let alert = NSAlert()
|
|
1284
|
1259
|
alert.messageText = "Record this meeting locally?"
|
|
|
@@ -1319,20 +1294,20 @@ private extension ViewController {
|
|
1319
|
1294
|
}
|
|
1320
|
1295
|
|
|
1321
|
1296
|
private func beginMeetingRecordingIfConsented(meetingTitle: String, meetingURL: URL) {
|
|
1322
|
|
- if activeMeetingRecordingSession != nil {
|
|
1323
|
|
- finishActiveMeetingRecording()
|
|
1324
|
|
- }
|
|
|
1297
|
+ meetingRecordingMonitorTask?.cancel()
|
|
|
1298
|
+ meetingRecordingMonitorTask = nil
|
|
|
1299
|
+ if activeMeetingRecordingSession != nil { finishActiveMeetingRecording(cancelMonitoring: false) }
|
|
1325
|
1300
|
guard requestMeetingRecordingConsent(title: meetingTitle) else { return }
|
|
1326
|
1301
|
let permission = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
1327
|
1302
|
switch permission {
|
|
1328
|
1303
|
case .authorized:
|
|
1329
|
|
- startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
1304
|
+ startAutomaticRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
1330
|
1305
|
case .notDetermined:
|
|
1331
|
1306
|
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
|
1332
|
1307
|
DispatchQueue.main.async {
|
|
1333
|
1308
|
guard let self else { return }
|
|
1334
|
1309
|
if granted {
|
|
1335
|
|
- self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
1310
|
+ self.startAutomaticRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
1336
|
1311
|
} else {
|
|
1337
|
1312
|
self.showSimpleAlert(title: "Microphone permission denied", message: "Enable microphone access in System Settings to record meetings locally.")
|
|
1338
|
1313
|
}
|
|
|
@@ -1345,40 +1320,129 @@ private extension ViewController {
|
|
1345
|
1320
|
}
|
|
1346
|
1321
|
}
|
|
1347
|
1322
|
|
|
|
1323
|
+ private func startAutomaticRecordingFlow(meetingTitle: String, meetingURL: URL) {
|
|
|
1324
|
+ guard let meetingCode = aiCompanionMeetMeetingCode(from: meetingURL), hasGoogleSessionAvailable() else {
|
|
|
1325
|
+ startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
1326
|
+ return
|
|
|
1327
|
+ }
|
|
|
1328
|
+
|
|
|
1329
|
+ showTopToast(message: "Recording will start when the meeting starts", isError: false)
|
|
|
1330
|
+ meetingRecordingMonitorTask = Task { [weak self] in
|
|
|
1331
|
+ guard let self else { return }
|
|
|
1332
|
+ defer { self.meetingRecordingMonitorTask = nil }
|
|
|
1333
|
+
|
|
|
1334
|
+ do {
|
|
|
1335
|
+ let space = try await self.googleMeetClient.getSpace(
|
|
|
1336
|
+ accessToken: try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window),
|
|
|
1337
|
+ spaceNameOrMeetingCode: "spaces/\(meetingCode)"
|
|
|
1338
|
+ )
|
|
|
1339
|
+ let spaceName = space.name ?? "spaces/\(meetingCode)"
|
|
|
1340
|
+ var hasStarted = false
|
|
|
1341
|
+ var inactivePollCount = 0
|
|
|
1342
|
+ let noStartDeadline = Date().addingTimeInterval(20 * 60)
|
|
|
1343
|
+
|
|
|
1344
|
+ while !Task.isCancelled {
|
|
|
1345
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
1346
|
+ let isActive = try await self.aiCompanionIsMeetingActive(spaceResourceName: spaceName, accessToken: token)
|
|
|
1347
|
+
|
|
|
1348
|
+ if isActive {
|
|
|
1349
|
+ inactivePollCount = 0
|
|
|
1350
|
+ if !hasStarted {
|
|
|
1351
|
+ self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
1352
|
+ hasStarted = true
|
|
|
1353
|
+ }
|
|
|
1354
|
+ } else if hasStarted {
|
|
|
1355
|
+ inactivePollCount += 1
|
|
|
1356
|
+ if inactivePollCount >= 1 {
|
|
|
1357
|
+ self.finishActiveMeetingRecording(cancelMonitoring: false)
|
|
|
1358
|
+ break
|
|
|
1359
|
+ }
|
|
|
1360
|
+ } else if Date() > noStartDeadline {
|
|
|
1361
|
+ break
|
|
|
1362
|
+ }
|
|
|
1363
|
+
|
|
|
1364
|
+ try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
|
1365
|
+ }
|
|
|
1366
|
+ } catch {
|
|
|
1367
|
+ if self.activeMeetingRecordingSession == nil {
|
|
|
1368
|
+ self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
1369
|
+ }
|
|
|
1370
|
+ }
|
|
|
1371
|
+ }
|
|
|
1372
|
+ }
|
|
|
1373
|
+
|
|
|
1374
|
+ private func aiCompanionIsMeetingActive(spaceResourceName: String, accessToken: String) async throws -> Bool {
|
|
|
1375
|
+ let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceResourceName)
|
|
|
1376
|
+ return records.contains { $0.startTime != nil && $0.endTime == nil }
|
|
|
1377
|
+ }
|
|
|
1378
|
+
|
|
1348
|
1379
|
private func startMeetingRecording(meetingTitle: String, meetingURL: URL) {
|
|
1349
|
1380
|
let recordingID = UUID().uuidString
|
|
1350
|
1381
|
let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
|
|
|
1382
|
+
|
|
|
1383
|
+ activeMeetingRecordingSession = ActiveMeetingRecordingSession(
|
|
|
1384
|
+ id: recordingID,
|
|
|
1385
|
+ title: meetingTitle,
|
|
|
1386
|
+ meetURL: meetingURL,
|
|
|
1387
|
+ startedAt: Date(),
|
|
|
1388
|
+ audioFileURL: outputURL
|
|
|
1389
|
+ )
|
|
|
1390
|
+ pageCache[.aiCompanion] = nil
|
|
|
1391
|
+ if selectedSidebarPage == .aiCompanion { showSidebarPage(.aiCompanion) }
|
|
|
1392
|
+
|
|
|
1393
|
+ if #available(macOS 13.0, *) {
|
|
|
1394
|
+ let systemRecorder = MeetingSystemAudioRecorder(outputURL: outputURL)
|
|
|
1395
|
+ activeMeetingSystemAudioStopper = { [systemRecorder] in
|
|
|
1396
|
+ try? await systemRecorder.stop()
|
|
|
1397
|
+ }
|
|
|
1398
|
+ Task { [weak self] in
|
|
|
1399
|
+ guard let self else { return }
|
|
|
1400
|
+ do {
|
|
|
1401
|
+ try await systemRecorder.start()
|
|
|
1402
|
+ await MainActor.run {
|
|
|
1403
|
+ self.showTopToast(message: "Meeting recording started (meeting audio)", isError: false)
|
|
|
1404
|
+ }
|
|
|
1405
|
+ } catch {
|
|
|
1406
|
+ await MainActor.run {
|
|
|
1407
|
+ self.activeMeetingSystemAudioStopper = nil
|
|
|
1408
|
+ self.startMicrophoneFallbackRecording(at: outputURL)
|
|
|
1409
|
+ }
|
|
|
1410
|
+ }
|
|
|
1411
|
+ }
|
|
|
1412
|
+ return
|
|
|
1413
|
+ }
|
|
|
1414
|
+
|
|
|
1415
|
+ startMicrophoneFallbackRecording(at: outputURL)
|
|
|
1416
|
+ }
|
|
|
1417
|
+
|
|
|
1418
|
+ private func startMicrophoneFallbackRecording(at outputURL: URL) {
|
|
1351
|
1419
|
let settings: [String: Any] = [
|
|
1352
|
1420
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
1353
|
1421
|
AVSampleRateKey: 44_100,
|
|
1354
|
1422
|
AVNumberOfChannelsKey: 1,
|
|
1355
|
1423
|
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
|
1356
|
1424
|
]
|
|
1357
|
|
-
|
|
1358
|
1425
|
do {
|
|
1359
|
1426
|
let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
1360
|
1427
|
recorder.prepareToRecord()
|
|
1361
|
1428
|
recorder.record()
|
|
1362
|
1429
|
activeMeetingAudioRecorder = recorder
|
|
1363
|
|
- activeMeetingRecordingSession = ActiveMeetingRecordingSession(
|
|
1364
|
|
- id: recordingID,
|
|
1365
|
|
- title: meetingTitle,
|
|
1366
|
|
- meetURL: meetingURL,
|
|
1367
|
|
- startedAt: Date(),
|
|
1368
|
|
- audioFileURL: outputURL
|
|
1369
|
|
- )
|
|
1370
|
|
- showTopToast(message: "Meeting recording started locally", isError: false)
|
|
1371
|
|
- pageCache[.aiCompanion] = nil
|
|
1372
|
|
- if selectedSidebarPage == .aiCompanion {
|
|
1373
|
|
- showSidebarPage(.aiCompanion)
|
|
1374
|
|
- }
|
|
|
1430
|
+ showTopToast(message: "Meeting recording started (microphone only)", isError: false)
|
|
1375
|
1431
|
} catch {
|
|
|
1432
|
+ activeMeetingRecordingSession = nil
|
|
1376
|
1433
|
showSimpleAlert(title: "Could not start recording", message: error.localizedDescription)
|
|
1377
|
1434
|
}
|
|
1378
|
1435
|
}
|
|
1379
|
1436
|
|
|
1380
|
|
- private func finishActiveMeetingRecording() {
|
|
|
1437
|
+ private func finishActiveMeetingRecording(cancelMonitoring: Bool = true) {
|
|
|
1438
|
+ if cancelMonitoring {
|
|
|
1439
|
+ meetingRecordingMonitorTask?.cancel()
|
|
|
1440
|
+ meetingRecordingMonitorTask = nil
|
|
|
1441
|
+ }
|
|
1381
|
1442
|
guard let session = activeMeetingRecordingSession else { return }
|
|
|
1443
|
+ let stopSystemAudio = activeMeetingSystemAudioStopper
|
|
|
1444
|
+ activeMeetingSystemAudioStopper = nil
|
|
|
1445
|
+ if let stopSystemAudio { Task { await stopSystemAudio() } }
|
|
1382
|
1446
|
activeMeetingAudioRecorder?.stop()
|
|
1383
|
1447
|
activeMeetingAudioRecorder = nil
|
|
1384
|
1448
|
activeMeetingRecordingSession = nil
|
|
|
@@ -5658,6 +5722,101 @@ extension ViewController: NSWindowDelegate {
|
|
5658
|
5722
|
}
|
|
5659
|
5723
|
}
|
|
5660
|
5724
|
|
|
|
5725
|
+@available(macOS 13.0, *)
|
|
|
5726
|
+private final class MeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
|
|
5727
|
+ private let outputURL: URL
|
|
|
5728
|
+ private var stream: SCStream?
|
|
|
5729
|
+ private var writer: AVAssetWriter?
|
|
|
5730
|
+ private var writerInput: AVAssetWriterInput?
|
|
|
5731
|
+ private let outputQueue = DispatchQueue(label: "meeting.system.audio.capture")
|
|
|
5732
|
+ private var didStartSession = false
|
|
|
5733
|
+
|
|
|
5734
|
+ init(outputURL: URL) {
|
|
|
5735
|
+ self.outputURL = outputURL
|
|
|
5736
|
+ }
|
|
|
5737
|
+
|
|
|
5738
|
+ func start() async throws {
|
|
|
5739
|
+ try? FileManager.default.removeItem(at: outputURL)
|
|
|
5740
|
+ let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
|
|
|
5741
|
+ guard let display = content.displays.first else {
|
|
|
5742
|
+ throw NSError(domain: "MeetingSystemAudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "No display available for system audio capture."])
|
|
|
5743
|
+ }
|
|
|
5744
|
+
|
|
|
5745
|
+ let writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
|
|
|
5746
|
+ let inputSettings: [String: Any] = [
|
|
|
5747
|
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
|
5748
|
+ AVSampleRateKey: 48_000,
|
|
|
5749
|
+ AVNumberOfChannelsKey: 2,
|
|
|
5750
|
+ AVEncoderBitRateKey: 128_000
|
|
|
5751
|
+ ]
|
|
|
5752
|
+ let input = AVAssetWriterInput(mediaType: .audio, outputSettings: inputSettings)
|
|
|
5753
|
+ input.expectsMediaDataInRealTime = true
|
|
|
5754
|
+ guard writer.canAdd(input) else {
|
|
|
5755
|
+ throw NSError(domain: "MeetingSystemAudioRecorder", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio writer input."])
|
|
|
5756
|
+ }
|
|
|
5757
|
+ writer.add(input)
|
|
|
5758
|
+ self.writer = writer
|
|
|
5759
|
+ self.writerInput = input
|
|
|
5760
|
+ didStartSession = false
|
|
|
5761
|
+
|
|
|
5762
|
+ let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
|
|
|
5763
|
+ let config = SCStreamConfiguration()
|
|
|
5764
|
+ config.width = 2
|
|
|
5765
|
+ config.height = 2
|
|
|
5766
|
+ config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
|
|
|
5767
|
+ config.queueDepth = 1
|
|
|
5768
|
+ config.capturesAudio = true
|
|
|
5769
|
+ if #available(macOS 13.0, *) {
|
|
|
5770
|
+ config.excludesCurrentProcessAudio = true
|
|
|
5771
|
+ }
|
|
|
5772
|
+
|
|
|
5773
|
+ let stream = SCStream(filter: filter, configuration: config, delegate: self)
|
|
|
5774
|
+ self.stream = stream
|
|
|
5775
|
+ try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
|
|
|
5776
|
+ try await stream.startCapture()
|
|
|
5777
|
+ }
|
|
|
5778
|
+
|
|
|
5779
|
+ func stop() async throws {
|
|
|
5780
|
+ if let stream {
|
|
|
5781
|
+ try? await stream.stopCapture()
|
|
|
5782
|
+ self.stream = nil
|
|
|
5783
|
+ }
|
|
|
5784
|
+ writerInput?.markAsFinished()
|
|
|
5785
|
+ guard let writer else { return }
|
|
|
5786
|
+ await withCheckedContinuation { continuation in
|
|
|
5787
|
+ writer.finishWriting {
|
|
|
5788
|
+ continuation.resume()
|
|
|
5789
|
+ }
|
|
|
5790
|
+ }
|
|
|
5791
|
+ self.writer = nil
|
|
|
5792
|
+ self.writerInput = nil
|
|
|
5793
|
+ }
|
|
|
5794
|
+
|
|
|
5795
|
+ func stream(_ stream: SCStream, didStopWithError error: Error) {
|
|
|
5796
|
+ // Capture may stop on permission changes or display changes; caller handles lifecycle.
|
|
|
5797
|
+ }
|
|
|
5798
|
+
|
|
|
5799
|
+ func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
|
|
|
5800
|
+ guard outputType == .audio else { return }
|
|
|
5801
|
+ guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
|
|
|
5802
|
+ guard let writer = writer, let input = writerInput else { return }
|
|
|
5803
|
+
|
|
|
5804
|
+ if writer.status == .unknown {
|
|
|
5805
|
+ writer.startWriting()
|
|
|
5806
|
+ }
|
|
|
5807
|
+ if writer.status == .writing {
|
|
|
5808
|
+ let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
|
|
5809
|
+ if !didStartSession {
|
|
|
5810
|
+ writer.startSession(atSourceTime: pts)
|
|
|
5811
|
+ didStartSession = true
|
|
|
5812
|
+ }
|
|
|
5813
|
+ if input.isReadyForMoreMediaData {
|
|
|
5814
|
+ input.append(sampleBuffer)
|
|
|
5815
|
+ }
|
|
|
5816
|
+ }
|
|
|
5817
|
+ }
|
|
|
5818
|
+}
|
|
|
5819
|
+
|
|
5661
|
5820
|
/// 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.
|
|
5662
|
5821
|
private final class TopAlignedClipView: NSClipView {
|
|
5663
|
5822
|
override var isFlipped: Bool { true }
|