|
@@ -39,6 +39,7 @@ protocol EmbeddedOAuthPresenting: AnyObject {
|
|
|
|
|
|
|
|
class ViewController: NSViewController {
|
|
class ViewController: NSViewController {
|
|
|
private static let embeddedZoomMeetingLifecycleHandlerName = "zoomAppMeetingLifecycle"
|
|
private static let embeddedZoomMeetingLifecycleHandlerName = "zoomAppMeetingLifecycle"
|
|
|
|
|
+ private static let embeddedZoomMicStateHandlerName = "zoomAppMicState"
|
|
|
private static let embeddedZoomMeetingLifecycleJavaScript = """
|
|
private static let embeddedZoomMeetingLifecycleJavaScript = """
|
|
|
(function() {
|
|
(function() {
|
|
|
function notify() {
|
|
function notify() {
|
|
@@ -55,6 +56,85 @@ class ViewController: NSViewController {
|
|
|
setInterval(notify, 1500);
|
|
setInterval(notify, 1500);
|
|
|
})();
|
|
})();
|
|
|
"""
|
|
"""
|
|
|
|
|
+ private static let embeddedZoomMicStateJavaScript = """
|
|
|
|
|
+ (function() {
|
|
|
|
|
+ function visible(el) {
|
|
|
|
|
+ if (!el || !el.getBoundingClientRect) return false;
|
|
|
|
|
+ var r = el.getBoundingClientRect();
|
|
|
|
|
+ return r.width > 0 && r.height > 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ function isMicrophoneControl(label) {
|
|
|
|
|
+ if (!label) return false;
|
|
|
|
|
+ if (label.indexOf('video') !== -1 || label.indexOf('camera') !== -1) return false;
|
|
|
|
|
+ if (label.indexOf('speaker') !== -1 || label.indexOf('headphone') !== -1) return false;
|
|
|
|
|
+ if (label.indexOf('share') !== -1 || label.indexOf('screen') !== -1) return false;
|
|
|
|
|
+ if (label.indexOf('record') !== -1 || label.indexOf('chat') !== -1) return false;
|
|
|
|
|
+ if (label.indexOf('participant') !== -1 || label.indexOf('reaction') !== -1) return false;
|
|
|
|
|
+ return label.indexOf('mic') !== -1 || label.indexOf('audio') !== -1
|
|
|
|
|
+ || label === 'mute' || label === 'unmute';
|
|
|
|
|
+ }
|
|
|
|
|
+ function readMicMuted() {
|
|
|
|
|
+ var nodes = document.querySelectorAll('button,[role="button"]');
|
|
|
|
|
+ for (var i = 0; i < nodes.length; i++) {
|
|
|
|
|
+ var el = nodes[i];
|
|
|
|
|
+ if (!visible(el)) continue;
|
|
|
|
|
+ var label = (el.getAttribute('aria-label') || el.getAttribute('title') || '').toLowerCase().trim();
|
|
|
|
|
+ if (!isMicrophoneControl(label)) continue;
|
|
|
|
|
+ if (label.indexOf('unmute') !== -1) return true;
|
|
|
|
|
+ if (label.indexOf('mute') !== -1) return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ var lastMuted = null;
|
|
|
|
|
+ function notify() {
|
|
|
|
|
+ var muted = readMicMuted();
|
|
|
|
|
+ if (muted === null || muted === lastMuted) return;
|
|
|
|
|
+ lastMuted = muted;
|
|
|
|
|
+ try {
|
|
|
|
|
+ window.webkit.messageHandlers.\(embeddedZoomMicStateHandlerName).postMessage({ muted: muted });
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ }
|
|
|
|
|
+ var observer = new MutationObserver(function() { notify(); });
|
|
|
|
|
+ function startObserver() {
|
|
|
|
|
+ if (!document.body) return;
|
|
|
|
|
+ observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-label', 'aria-pressed', 'title', 'class'] });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (document.body) startObserver();
|
|
|
|
|
+ else document.addEventListener('DOMContentLoaded', startObserver);
|
|
|
|
|
+ setInterval(notify, 500);
|
|
|
|
|
+ notify();
|
|
|
|
|
+ })();
|
|
|
|
|
+ """
|
|
|
|
|
+ /// Poll script for the embedded meeting monitor timer (returns `{ muted: boolean }` or `null`).
|
|
|
|
|
+ static let embeddedZoomMicStatePollJavaScript = """
|
|
|
|
|
+ (function() {
|
|
|
|
|
+ function visible(el) {
|
|
|
|
|
+ if (!el || !el.getBoundingClientRect) return false;
|
|
|
|
|
+ var r = el.getBoundingClientRect();
|
|
|
|
|
+ return r.width > 0 && r.height > 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ function isMicrophoneControl(label) {
|
|
|
|
|
+ if (!label) return false;
|
|
|
|
|
+ if (label.indexOf('video') !== -1 || label.indexOf('camera') !== -1) return false;
|
|
|
|
|
+ if (label.indexOf('speaker') !== -1 || label.indexOf('headphone') !== -1) return false;
|
|
|
|
|
+ if (label.indexOf('share') !== -1 || label.indexOf('screen') !== -1) return false;
|
|
|
|
|
+ if (label.indexOf('record') !== -1 || label.indexOf('chat') !== -1) return false;
|
|
|
|
|
+ if (label.indexOf('participant') !== -1 || label.indexOf('reaction') !== -1) return false;
|
|
|
|
|
+ return label.indexOf('mic') !== -1 || label.indexOf('audio') !== -1
|
|
|
|
|
+ || label === 'mute' || label === 'unmute';
|
|
|
|
|
+ }
|
|
|
|
|
+ var nodes = document.querySelectorAll('button,[role="button"]');
|
|
|
|
|
+ for (var i = 0; i < nodes.length; i++) {
|
|
|
|
|
+ var el = nodes[i];
|
|
|
|
|
+ if (!visible(el)) continue;
|
|
|
|
|
+ var label = (el.getAttribute('aria-label') || el.getAttribute('title') || '').toLowerCase().trim();
|
|
|
|
|
+ if (!isMicrophoneControl(label)) continue;
|
|
|
|
|
+ if (label.indexOf('unmute') !== -1) return { muted: true };
|
|
|
|
|
+ if (label.indexOf('mute') !== -1) return { muted: false };
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ })();
|
|
|
|
|
+ """
|
|
|
|
|
|
|
|
enum HomeSection: String {
|
|
enum HomeSection: String {
|
|
|
case home = "Home"
|
|
case home = "Home"
|
|
@@ -473,6 +553,9 @@ class ViewController: NSViewController {
|
|
|
/// True after the embedded web client loaded a join/in-call URL during the current recording session.
|
|
/// True after the embedded web client loaded a join/in-call URL during the current recording session.
|
|
|
private var embeddedMeetingReachedInCallPage = false
|
|
private var embeddedMeetingReachedInCallPage = false
|
|
|
private var embeddedMeetingRecordingMonitorTimer: Timer?
|
|
private var embeddedMeetingRecordingMonitorTimer: Timer?
|
|
|
|
|
+ private var endMeetingRecordingStopInFlight = false
|
|
|
|
|
+ /// Recording consent given; capture starts when the embedded client enters an active meeting (not the join lobby).
|
|
|
|
|
+ private var pendingEmbeddedRecording: (sourceURL: URL, meetingDisplayName: String?)?
|
|
|
|
|
|
|
|
private enum LocalRecordingNotesStatus: String {
|
|
private enum LocalRecordingNotesStatus: String {
|
|
|
case notRequested
|
|
case notRequested
|
|
@@ -524,8 +607,8 @@ class ViewController: NSViewController {
|
|
|
private var lastScrollEdgeRefreshAt = Date.distantPast
|
|
private var lastScrollEdgeRefreshAt = Date.distantPast
|
|
|
private var lastMeetingsPageAutoLoadAt = Date.distantPast
|
|
private var lastMeetingsPageAutoLoadAt = Date.distantPast
|
|
|
// Keep this conservative to avoid Zoom API rate limits.
|
|
// Keep this conservative to avoid Zoom API rate limits.
|
|
|
- private let meetingsRefreshInterval: TimeInterval = 60
|
|
|
|
|
- private let scrollRefreshCooldown: TimeInterval = 3
|
|
|
|
|
|
|
+ private let meetingsRefreshInterval: TimeInterval = 120
|
|
|
|
|
+ private let scrollRefreshCooldown: TimeInterval = 90
|
|
|
private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
|
|
private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
|
|
|
private var meetingsRateLimitedUntil: Date?
|
|
private var meetingsRateLimitedUntil: Date?
|
|
|
private var zoomAccountTimeZone: TimeZone?
|
|
private var zoomAccountTimeZone: TimeZone?
|
|
@@ -764,7 +847,9 @@ class ViewController: NSViewController {
|
|
|
Task { @MainActor [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
|
guard let self else { return }
|
|
guard let self else { return }
|
|
|
self.aiCompanionTranscriptionActiveRecordingIDs.remove(id)
|
|
self.aiCompanionTranscriptionActiveRecordingIDs.remove(id)
|
|
|
|
|
+ self.recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
self.renderAiCompanionList()
|
|
self.renderAiCompanionList()
|
|
|
|
|
+ self.autoGenerateMeetingNotesIfNeeded(forRecordingId: id)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
NotificationCenter.default.addObserver(
|
|
NotificationCenter.default.addObserver(
|
|
@@ -2107,21 +2192,28 @@ class ViewController: NSViewController {
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Active join / in-call pages in the embedded Zoom web client (not Workplace hub routes under `/wc/`).
|
|
|
|
|
- private func isZoomEmbeddedWebClientInCallURL(_ url: URL) -> Bool {
|
|
|
|
|
- guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
|
|
|
|
|
- if isZoomMeetingLeaveOrEndURL(url) { return false }
|
|
|
|
|
- let path = url.path.lowercased()
|
|
|
|
|
- if path.contains("/wc/join") { return true }
|
|
|
|
|
- if path.contains("/wc/inmeeting") { return true }
|
|
|
|
|
- if path.contains("/wc/video") { return true }
|
|
|
|
|
- // Non–web-client join links opened in the embedded panel (rare).
|
|
|
|
|
- if path.contains("/wc/") == false {
|
|
|
|
|
- return isZoomMeetingJoinOrStartURL(url)
|
|
|
|
|
|
|
+ /// True when the embedded web client is in a live meeting (not lobby, hub, or loading).
|
|
|
|
|
+ private func isEmbeddedZoomActiveMeeting(url: URL?, webViewTitle: String?) -> Bool {
|
|
|
|
|
+ if let title = webViewTitle, embeddedWebViewTitleIndicatesActiveMeeting(title) { return true }
|
|
|
|
|
+ if let url {
|
|
|
|
|
+ if isZoomMeetingLeaveOrEndURL(url) { return false }
|
|
|
|
|
+ let path = url.path.lowercased()
|
|
|
|
|
+ if path.contains("/wc/inmeeting") || path.contains("/wc/video") { return true }
|
|
|
|
|
+ if path.contains("/wc/join") { return true }
|
|
|
}
|
|
}
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func embeddedWebViewTitleIndicatesActiveMeeting(_ title: String) -> Bool {
|
|
|
|
|
+ let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
|
+ guard t.isEmpty == false else { return false }
|
|
|
|
|
+ if t.contains("waiting for the host") || t.contains("waiting for host") { return false }
|
|
|
|
|
+ if t.contains("sign in") || t.contains("sign-in") { return false }
|
|
|
|
|
+ if t.contains("zoom meeting") || t.contains("zoom webinar") { return true }
|
|
|
|
|
+ if t.contains("zoom workplace") && t.contains("|") == false { return true }
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
|
|
/// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
|
|
|
private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
|
|
private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
|
|
|
guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
|
|
guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
|
|
@@ -2156,38 +2248,78 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
|
|
private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
|
|
|
- guard MeetingRecordingManager.shared.isRecording else {
|
|
|
|
|
|
|
+ let monitoring = MeetingRecordingManager.shared.isRecording || pendingEmbeddedRecording != nil
|
|
|
|
|
+ guard monitoring else {
|
|
|
stopEmbeddedMeetingRecordingMonitor()
|
|
stopEmbeddedMeetingRecordingMonitor()
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let inCallURL = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
|
|
|
|
|
let leftByURL = url.map { isZoomMeetingLeaveOrEndURL($0) } ?? false
|
|
let leftByURL = url.map { isZoomMeetingLeaveOrEndURL($0) } ?? false
|
|
|
let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
|
|
let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
|
|
|
- let inCall = inCallURL && !leftByURL
|
|
|
|
|
-
|
|
|
|
|
- if inCall {
|
|
|
|
|
|
|
+ let detectedInMeeting = isEmbeddedZoomActiveMeeting(url: url, webViewTitle: webViewTitle)
|
|
|
|
|
+ if detectedInMeeting {
|
|
|
embeddedMeetingReachedInCallPage = true
|
|
embeddedMeetingReachedInCallPage = true
|
|
|
}
|
|
}
|
|
|
- MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inCall)
|
|
|
|
|
|
|
+ // Stay "in meeting" across mute UI changes until an explicit leave URL/title.
|
|
|
|
|
+ let inActiveMeeting = detectedInMeeting
|
|
|
|
|
+ || (embeddedMeetingReachedInCallPage && !leftByURL && !leftByTitle)
|
|
|
|
|
+
|
|
|
|
|
+ if inActiveMeeting {
|
|
|
|
|
+ startPendingEmbeddedRecordingIfNeeded()
|
|
|
|
|
+ if MeetingRecordingManager.shared.isRecording {
|
|
|
|
|
+ pollEmbeddedZoomMicMuteState()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inActiveMeeting)
|
|
|
|
|
|
|
|
let shouldStopNow = leftByURL || leftByTitle
|
|
let shouldStopNow = leftByURL || leftByTitle
|
|
|
- || (embeddedMeetingReachedInCallPage && !inCall)
|
|
|
|
|
if shouldStopNow {
|
|
if shouldStopNow {
|
|
|
- endZoomMeetingRecordingAutomatically()
|
|
|
|
|
|
|
+ pendingEmbeddedRecording = nil
|
|
|
|
|
+ if MeetingRecordingManager.shared.isRecording {
|
|
|
|
|
+ endZoomMeetingRecordingAutomatically()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ stopEmbeddedMeetingRecordingMonitor()
|
|
|
|
|
+ updateAiCompanionRecordingUI()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func startPendingEmbeddedRecordingIfNeeded() {
|
|
|
|
|
+ guard let pending = pendingEmbeddedRecording else { return }
|
|
|
|
|
+ guard MeetingRecordingManager.shared.isRecording == false else {
|
|
|
|
|
+ pendingEmbeddedRecording = nil
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ pendingEmbeddedRecording = nil
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ do {
|
|
|
|
|
+ MeetingRecordingManager.shared.configureCapture(embeddedZoomWebClient: true)
|
|
|
|
|
+ try await MeetingRecordingManager.shared.startRecording(
|
|
|
|
|
+ sourceURL: pending.sourceURL,
|
|
|
|
|
+ meetingDisplayName: pending.meetingDisplayName
|
|
|
|
|
+ )
|
|
|
|
|
+ self.updateAiCompanionRecordingUI()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
|
|
|
|
|
+ self.updateAiCompanionRecordingUI()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
|
|
private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
|
|
|
stopEmbeddedMeetingRecordingMonitor()
|
|
stopEmbeddedMeetingRecordingMonitor()
|
|
|
- guard MeetingRecordingManager.shared.isRecording else { return }
|
|
|
|
|
|
|
+ guard MeetingRecordingManager.shared.isRecording || pendingEmbeddedRecording != nil else { return }
|
|
|
guard embeddedBrowserWebView != nil else { return }
|
|
guard embeddedBrowserWebView != nil else { return }
|
|
|
embeddedMeetingReachedInCallPage = false
|
|
embeddedMeetingReachedInCallPage = false
|
|
|
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
|
guard let self else { return }
|
|
guard let self else { return }
|
|
|
let webView = self.embeddedBrowserWebView
|
|
let webView = self.embeddedBrowserWebView
|
|
|
self.updateEmbeddedMeetingRecordingPresence(for: webView?.url, webViewTitle: webView?.title)
|
|
self.updateEmbeddedMeetingRecordingPresence(for: webView?.url, webViewTitle: webView?.title)
|
|
|
|
|
+ if MeetingRecordingManager.shared.isRecording {
|
|
|
|
|
+ self.pollEmbeddedZoomMicMuteState()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
|
embeddedMeetingRecordingMonitorTimer = timer
|
|
embeddedMeetingRecordingMonitorTimer = timer
|
|
@@ -2198,22 +2330,58 @@ class ViewController: NSViewController {
|
|
|
embeddedMeetingRecordingMonitorTimer?.invalidate()
|
|
embeddedMeetingRecordingMonitorTimer?.invalidate()
|
|
|
embeddedMeetingRecordingMonitorTimer = nil
|
|
embeddedMeetingRecordingMonitorTimer = nil
|
|
|
embeddedMeetingReachedInCallPage = false
|
|
embeddedMeetingReachedInCallPage = false
|
|
|
|
|
+ pendingEmbeddedRecording = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func pollEmbeddedZoomMicMuteState() {
|
|
|
|
|
+ guard MeetingRecordingManager.shared.isRecording else { return }
|
|
|
|
|
+ guard let webView = embeddedBrowserWebView else { return }
|
|
|
|
|
+ webView.evaluateJavaScript(Self.embeddedZoomMicStatePollJavaScript) { result, _ in
|
|
|
|
|
+ guard let muted = Self.parseEmbeddedZoomMicMuted(from: result) else { return }
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(!muted)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func parseEmbeddedZoomMicMuted(from result: Any?) -> Bool? {
|
|
|
|
|
+ if let dict = result as? [String: Any], let muted = dict["muted"] as? Bool {
|
|
|
|
|
+ return muted
|
|
|
|
|
+ }
|
|
|
|
|
+ if let muted = result as? Bool {
|
|
|
|
|
+ return muted
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func applyEmbeddedZoomMicStateMessage(_ body: Any) {
|
|
|
|
|
+ guard MeetingRecordingManager.shared.isRecording else { return }
|
|
|
|
|
+ guard let muted = Self.parseEmbeddedZoomMicMuted(from: body) else { return }
|
|
|
|
|
+ MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(!muted)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func endZoomMeetingRecordingAutomatically() {
|
|
private func endZoomMeetingRecordingAutomatically() {
|
|
|
guard MeetingRecordingManager.shared.isRecording else { return }
|
|
guard MeetingRecordingManager.shared.isRecording else { return }
|
|
|
|
|
+ guard endMeetingRecordingStopInFlight == false else { return }
|
|
|
|
|
+ endMeetingRecordingStopInFlight = true
|
|
|
stopEmbeddedMeetingRecordingMonitor()
|
|
stopEmbeddedMeetingRecordingMonitor()
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
- guard MeetingRecordingManager.shared.isRecording else { return }
|
|
|
|
|
|
|
+ defer { self.endMeetingRecordingStopInFlight = false }
|
|
|
do {
|
|
do {
|
|
|
try await MeetingRecordingManager.shared.stopRecording()
|
|
try await MeetingRecordingManager.shared.stopRecording()
|
|
|
- self.recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
|
|
- self.renderAiCompanionList()
|
|
|
|
|
- self.updateAiCompanionRecordingUI()
|
|
|
|
|
} catch {
|
|
} catch {
|
|
|
NSLog("ViewController: auto-stop recording failed — \(error.localizedDescription)")
|
|
NSLog("ViewController: auto-stop recording failed — \(error.localizedDescription)")
|
|
|
|
|
+ self.showSimpleAlert(
|
|
|
|
|
+ title: "Recording could not be saved",
|
|
|
|
|
+ message: error.localizedDescription
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
|
|
+ self.recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
|
|
+ self.renderAiCompanionList()
|
|
|
|
|
+ self.updateAiCompanionRecordingUI()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -2221,15 +2389,24 @@ class ViewController: NSViewController {
|
|
|
private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
|
|
private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
|
|
|
guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
|
|
guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
|
|
|
guard MeetingRecordingManager.shared.isRecording == false else { return }
|
|
guard MeetingRecordingManager.shared.isRecording == false else { return }
|
|
|
|
|
+ let usesEmbeddedWebClient = embeddedBrowserWebView != nil
|
|
|
|
|
+ && (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5
|
|
|
|
|
+
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
do {
|
|
do {
|
|
|
- try await MeetingRecordingManager.shared.startRecording(sourceURL: sourceURL, meetingDisplayName: meetingDisplayName)
|
|
|
|
|
|
|
+ MeetingRecordingManager.shared.configureCapture(embeddedZoomWebClient: usesEmbeddedWebClient)
|
|
|
|
|
+ try await MeetingRecordingManager.shared.startRecording(
|
|
|
|
|
+ sourceURL: sourceURL,
|
|
|
|
|
+ meetingDisplayName: meetingDisplayName
|
|
|
|
|
+ )
|
|
|
|
|
+ MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(false)
|
|
|
|
|
+ self.pendingEmbeddedRecording = nil
|
|
|
self.updateAiCompanionRecordingUI()
|
|
self.updateAiCompanionRecordingUI()
|
|
|
- if self.embeddedBrowserWebView != nil,
|
|
|
|
|
- (self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
|
|
|
|
|
|
|
+ if usesEmbeddedWebClient {
|
|
|
self.startEmbeddedMeetingRecordingMonitorIfNeeded()
|
|
self.startEmbeddedMeetingRecordingMonitorIfNeeded()
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
|
|
+ self.pendingEmbeddedRecording = nil
|
|
|
self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
|
|
self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -2602,15 +2779,29 @@ class ViewController: NSViewController {
|
|
|
guard storeKitCoordinator.hasPremiumAccess else { return }
|
|
guard storeKitCoordinator.hasPremiumAccess else { return }
|
|
|
let now = Date()
|
|
let now = Date()
|
|
|
if let until = meetingsRateLimitedUntil, now < until {
|
|
if let until = meetingsRateLimitedUntil, now < until {
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ self.meetingsStatusLabel?.stringValue = self.meetingsListStatusMessage()
|
|
|
|
|
+ self.meetingsPageStatusLabel?.stringValue = self.meetingsListStatusMessage()
|
|
|
|
|
+ self.applyFilteredMeetings()
|
|
|
|
|
+ }
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
- if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
|
|
|
|
|
|
|
+ if now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
lastMeetingsRefreshAt = now
|
|
lastMeetingsRefreshAt = now
|
|
|
Task { await self.loadScheduledMeetings() }
|
|
Task { await self.loadScheduledMeetings() }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func meetingsListStatusMessage() -> String {
|
|
|
|
|
+ if let until = meetingsRateLimitedUntil, Date() < until {
|
|
|
|
|
+ let minutes = max(1, Int(ceil(until.timeIntervalSince(Date()) / 60.0)))
|
|
|
|
|
+ return "Zoom rate limit reached. Please try again in \(minutes) min."
|
|
|
|
|
+ }
|
|
|
|
|
+ return "Upcoming meetings"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func clearMeetingsScrollObserver() {
|
|
private func clearMeetingsScrollObserver() {
|
|
|
if let meetingsScrollObserver {
|
|
if let meetingsScrollObserver {
|
|
|
NotificationCenter.default.removeObserver(meetingsScrollObserver)
|
|
NotificationCenter.default.removeObserver(meetingsScrollObserver)
|
|
@@ -2891,7 +3082,7 @@ class ViewController: NSViewController {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
lastScrollEdgeRefreshAt = now
|
|
lastScrollEdgeRefreshAt = now
|
|
|
- triggerMeetingsRefresh(force: true)
|
|
|
|
|
|
|
+ triggerMeetingsRefresh(force: false)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
|
|
private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
|
|
@@ -3018,7 +3209,9 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
meetingsRefreshTimer?.invalidate()
|
|
meetingsRefreshTimer?.invalidate()
|
|
|
meetingsRefreshTimer = nil
|
|
meetingsRefreshTimer = nil
|
|
|
- meetingsRateLimitedUntil = nil
|
|
|
|
|
|
|
+ if loggedIn == false {
|
|
|
|
|
+ meetingsRateLimitedUntil = nil
|
|
|
|
|
+ }
|
|
|
if loggedIn {
|
|
if loggedIn {
|
|
|
if storeKitCoordinator.hasPremiumAccess {
|
|
if storeKitCoordinator.hasPremiumAccess {
|
|
|
startMeetingsAutoRefresh()
|
|
startMeetingsAutoRefresh()
|
|
@@ -3147,10 +3340,10 @@ class ViewController: NSViewController {
|
|
|
if ordered.isEmpty {
|
|
if ordered.isEmpty {
|
|
|
emptyMeetingLabel?.isHidden = showingHomeSection == false
|
|
emptyMeetingLabel?.isHidden = showingHomeSection == false
|
|
|
if source.isEmpty {
|
|
if source.isEmpty {
|
|
|
- meetingsStatusLabel?.stringValue = "Upcoming meetings"
|
|
|
|
|
|
|
+ meetingsStatusLabel?.stringValue = meetingsListStatusMessage()
|
|
|
emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
|
|
emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
|
|
|
} else {
|
|
} else {
|
|
|
- meetingsStatusLabel?.stringValue = "Upcoming meetings"
|
|
|
|
|
|
|
+ meetingsStatusLabel?.stringValue = meetingsListStatusMessage()
|
|
|
emptyMeetingLabel?.stringValue = "No meetings match your search."
|
|
emptyMeetingLabel?.stringValue = "No meetings match your search."
|
|
|
}
|
|
}
|
|
|
applySchedulerMeetingsForSelectedDate()
|
|
applySchedulerMeetingsForSelectedDate()
|
|
@@ -3159,7 +3352,7 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
emptyMeetingLabel?.isHidden = true
|
|
emptyMeetingLabel?.isHidden = true
|
|
|
- meetingsStatusLabel?.stringValue = "Upcoming meetings"
|
|
|
|
|
|
|
+ meetingsStatusLabel?.stringValue = meetingsListStatusMessage()
|
|
|
for meeting in ordered {
|
|
for meeting in ordered {
|
|
|
let card = makeMeetingRowCard(meeting)
|
|
let card = makeMeetingRowCard(meeting)
|
|
|
stack.addArrangedSubview(card)
|
|
stack.addArrangedSubview(card)
|
|
@@ -3667,7 +3860,25 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
await MainActor.run {
|
|
await MainActor.run {
|
|
|
- self.applyMeetings([])
|
|
|
|
|
|
|
+ let preserveCachedMeetings: Bool
|
|
|
|
|
+ if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
|
|
|
|
|
+ let seconds = max(retryAfterSeconds ?? 300, 30)
|
|
|
|
|
+ self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
|
|
|
|
|
+ preserveCachedMeetings = true
|
|
|
|
|
+ } else if case ZoomOAuthError.missingClientId = error {
|
|
|
|
|
+ preserveCachedMeetings = false
|
|
|
|
|
+ } else if case ZoomOAuthError.missingClientSecret = error {
|
|
|
|
|
+ preserveCachedMeetings = false
|
|
|
|
|
+ } else if case ZoomOAuthError.missingRequiredScope = error {
|
|
|
|
|
+ preserveCachedMeetings = false
|
|
|
|
|
+ } else {
|
|
|
|
|
+ preserveCachedMeetings = self.allScheduledMeetings.isEmpty == false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if preserveCachedMeetings == false {
|
|
|
|
|
+ self.applyMeetings([])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if case ZoomOAuthError.missingClientId = error {
|
|
if case ZoomOAuthError.missingClientId = error {
|
|
|
self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
@@ -3680,15 +3891,17 @@ class ViewController: NSViewController {
|
|
|
self.zoomOAuth.clearSavedTokens()
|
|
self.zoomOAuth.clearSavedTokens()
|
|
|
self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
|
|
self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
|
|
|
self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
|
|
self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
|
|
|
- } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
|
|
|
|
|
- let seconds = max(retryAfterSeconds ?? 300, 30)
|
|
|
|
|
- self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
|
|
|
|
|
- let minutes = Int(ceil(Double(seconds) / 60.0))
|
|
|
|
|
- self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
|
|
|
|
|
- self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
|
|
|
|
|
|
|
+ } else if case ZoomOAuthError.rateLimited = error {
|
|
|
|
|
+ self.meetingsStatusLabel?.stringValue = self.meetingsListStatusMessage()
|
|
|
|
|
+ self.meetingsPageStatusLabel?.stringValue = self.meetingsListStatusMessage()
|
|
|
} else {
|
|
} else {
|
|
|
- self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
|
|
|
|
|
- self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
|
|
|
|
|
|
|
+ self.meetingsStatusLabel?.stringValue = "Unable to refresh meetings. Showing last loaded list."
|
|
|
|
|
+ self.meetingsPageStatusLabel?.stringValue = "Unable to refresh meetings. Showing last loaded list."
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if preserveCachedMeetings {
|
|
|
|
|
+ self.applyFilteredMeetings()
|
|
|
|
|
+ self.applyMeetingsPageFilters(resetVisible: false)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -3941,7 +4154,7 @@ class ViewController: NSViewController {
|
|
|
var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
|
|
var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
|
|
|
var items: [URLQueryItem] = [
|
|
var items: [URLQueryItem] = [
|
|
|
URLQueryItem(name: "type", value: "scheduled"),
|
|
URLQueryItem(name: "type", value: "scheduled"),
|
|
|
- URLQueryItem(name: "page_size", value: "30")
|
|
|
|
|
|
|
+ URLQueryItem(name: "page_size", value: "300")
|
|
|
]
|
|
]
|
|
|
if let nextPageToken, nextPageToken.isEmpty == false {
|
|
if let nextPageToken, nextPageToken.isEmpty == false {
|
|
|
items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
|
|
items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
|
|
@@ -6740,12 +6953,19 @@ class ViewController: NSViewController {
|
|
|
embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
|
|
embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
|
|
|
let meetingLifecycleController = WKUserContentController()
|
|
let meetingLifecycleController = WKUserContentController()
|
|
|
meetingLifecycleController.add(self, name: Self.embeddedZoomMeetingLifecycleHandlerName)
|
|
meetingLifecycleController.add(self, name: Self.embeddedZoomMeetingLifecycleHandlerName)
|
|
|
|
|
+ meetingLifecycleController.add(self, name: Self.embeddedZoomMicStateHandlerName)
|
|
|
let lifecycleScript = WKUserScript(
|
|
let lifecycleScript = WKUserScript(
|
|
|
source: Self.embeddedZoomMeetingLifecycleJavaScript,
|
|
source: Self.embeddedZoomMeetingLifecycleJavaScript,
|
|
|
injectionTime: .atDocumentEnd,
|
|
injectionTime: .atDocumentEnd,
|
|
|
forMainFrameOnly: true
|
|
forMainFrameOnly: true
|
|
|
)
|
|
)
|
|
|
meetingLifecycleController.addUserScript(lifecycleScript)
|
|
meetingLifecycleController.addUserScript(lifecycleScript)
|
|
|
|
|
+ let micStateScript = WKUserScript(
|
|
|
|
|
+ source: Self.embeddedZoomMicStateJavaScript,
|
|
|
|
|
+ injectionTime: .atDocumentEnd,
|
|
|
|
|
+ forMainFrameOnly: true
|
|
|
|
|
+ )
|
|
|
|
|
+ meetingLifecycleController.addUserScript(micStateScript)
|
|
|
embeddedBrowserConfiguration.userContentController = meetingLifecycleController
|
|
embeddedBrowserConfiguration.userContentController = meetingLifecycleController
|
|
|
let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
|
|
let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
|
|
|
embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
|
|
embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
|
|
@@ -7585,6 +7805,11 @@ class ViewController: NSViewController {
|
|
|
"Recording… Stops automatically when you leave or the meeting ends."
|
|
"Recording… Stops automatically when you leave or the meeting ends."
|
|
|
aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
|
|
aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
|
|
|
aiCompanionStatusLabel?.textColor = secondaryText
|
|
aiCompanionStatusLabel?.textColor = secondaryText
|
|
|
|
|
+ } else if pendingEmbeddedRecording != nil {
|
|
|
|
|
+ aiCompanionStatusLabel?.stringValue =
|
|
|
|
|
+ "Waiting to join… Recording starts when you enter the meeting."
|
|
|
|
|
+ aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
|
|
|
|
|
+ aiCompanionStatusLabel?.textColor = secondaryText
|
|
|
} else {
|
|
} else {
|
|
|
aiCompanionStatusLabel?.stringValue = "Previous meetings"
|
|
aiCompanionStatusLabel?.stringValue = "Previous meetings"
|
|
|
aiCompanionStatusLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
|
aiCompanionStatusLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
|
@@ -7754,7 +7979,12 @@ class ViewController: NSViewController {
|
|
|
let msg = localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
|
|
let msg = localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
|
|
|
return (msg, secondaryText)
|
|
return (msg, secondaryText)
|
|
|
case .ready:
|
|
case .ready:
|
|
|
- return ("Notes ready", secondaryText)
|
|
|
|
|
|
|
+ if let notes = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines), notes.isEmpty == false {
|
|
|
|
|
+ let preview = String(notes.prefix(120))
|
|
|
|
|
+ let suffix = notes.count > 120 ? "…" : ""
|
|
|
|
|
+ return ("Notes: \(preview)\(suffix)", secondaryText)
|
|
|
|
|
+ }
|
|
|
|
|
+ return ("Notes ready — tap View Notes", secondaryText)
|
|
|
case .failed:
|
|
case .failed:
|
|
|
let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
if err.isEmpty {
|
|
if err.isEmpty {
|
|
@@ -8099,6 +8329,22 @@ class ViewController: NSViewController {
|
|
|
localRecordingNotesTaskByRecordingId[recordingId] = task
|
|
localRecordingNotesTaskByRecordingId[recordingId] = task
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func autoGenerateMeetingNotesIfNeeded(forRecordingId recordingId: String) {
|
|
|
|
|
+ recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
|
|
+ guard let recording = recordedMeetings.first(where: { $0.id == recordingId }) else { return }
|
|
|
|
|
+ guard localRecordingNotesStatus(for: recording) == .notRequested else { return }
|
|
|
|
|
+ let transcript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
+ guard transcript.isEmpty == false else { return }
|
|
|
|
|
+ let requestId = UUID()
|
|
|
|
|
+ startLocalRecordingNotesProcessing(
|
|
|
|
|
+ recordingId: recording.id,
|
|
|
|
|
+ transcript: transcript,
|
|
|
|
|
+ requestId: requestId,
|
|
|
|
|
+ autoPresentOnReady: false
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@objc private func recordingGetNotesTapped(_ sender: NSButton) {
|
|
@objc private func recordingGetNotesTapped(_ sender: NSButton) {
|
|
|
let idx = sender.tag
|
|
let idx = sender.tag
|
|
|
guard recordedMeetings.indices.contains(idx) else { return }
|
|
guard recordedMeetings.indices.contains(idx) else { return }
|
|
@@ -9690,11 +9936,19 @@ extension ViewController: EmbeddedOAuthPresenting {}
|
|
|
|
|
|
|
|
extension ViewController: WKScriptMessageHandler {
|
|
extension ViewController: WKScriptMessageHandler {
|
|
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
|
- guard message.name == Self.embeddedZoomMeetingLifecycleHandlerName else { return }
|
|
|
|
|
- guard let href = message.body as? String, let url = URL(string: href) else { return }
|
|
|
|
|
- Task { @MainActor in
|
|
|
|
|
- let title = self.embeddedBrowserWebView?.title
|
|
|
|
|
- self.updateEmbeddedMeetingRecordingPresence(for: url, webViewTitle: title)
|
|
|
|
|
|
|
+ switch message.name {
|
|
|
|
|
+ case Self.embeddedZoomMeetingLifecycleHandlerName:
|
|
|
|
|
+ guard let href = message.body as? String, let url = URL(string: href) else { return }
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ let title = self.embeddedBrowserWebView?.title
|
|
|
|
|
+ self.updateEmbeddedMeetingRecordingPresence(for: url, webViewTitle: title)
|
|
|
|
|
+ }
|
|
|
|
|
+ case Self.embeddedZoomMicStateHandlerName:
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ self.applyEmbeddedZoomMicStateMessage(message.body)
|
|
|
|
|
+ }
|
|
|
|
|
+ default:
|
|
|
|
|
+ break
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|