|
@@ -66,6 +66,43 @@ class ViewController: NSViewController {
|
|
|
setInterval(notify, 1500);
|
|
setInterval(notify, 1500);
|
|
|
})();
|
|
})();
|
|
|
"""
|
|
"""
|
|
|
|
|
+ /// Stops camera/mic tracks and attempts to leave an in-progress Zoom web meeting before tearing down the web view.
|
|
|
|
|
+ private static let embeddedZoomWebClientTeardownJavaScript = """
|
|
|
|
|
+ (function() {
|
|
|
|
|
+ function stopAllMediaTracks() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ document.querySelectorAll('video, audio').forEach(function(node) {
|
|
|
|
|
+ var stream = node.srcObject;
|
|
|
|
|
+ if (stream && typeof stream.getTracks === 'function') {
|
|
|
|
|
+ stream.getTracks().forEach(function(track) { try { track.stop(); } catch (e) {} });
|
|
|
|
|
+ }
|
|
|
|
|
+ node.srcObject = null;
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ }
|
|
|
|
|
+ function tryLeaveMeeting() {
|
|
|
|
|
+ var selectors = [
|
|
|
|
|
+ 'button[aria-label*="Leave"]',
|
|
|
|
|
+ 'button[aria-label*="leave"]',
|
|
|
|
|
+ 'button[aria-label*="End meeting"]',
|
|
|
|
|
+ 'button[aria-label*="end meeting"]',
|
|
|
|
|
+ 'button[aria-label*="Leave Meeting"]',
|
|
|
|
|
+ '[data-testid="leave-meeting-button"]'
|
|
|
|
|
+ ];
|
|
|
|
|
+ for (var i = 0; i < selectors.length; i++) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ var btn = document.querySelector(selectors[i]);
|
|
|
|
|
+ if (btn && typeof btn.click === 'function') {
|
|
|
|
|
+ btn.click();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ tryLeaveMeeting();
|
|
|
|
|
+ stopAllMediaTracks();
|
|
|
|
|
+ })();
|
|
|
|
|
+ """
|
|
|
|
|
|
|
|
enum HomeSection: String {
|
|
enum HomeSection: String {
|
|
|
case home = "Home"
|
|
case home = "Home"
|
|
@@ -491,6 +528,8 @@ class ViewController: NSViewController {
|
|
|
private static let embeddedMeetingAbsentCallMissThreshold = 5
|
|
private static let embeddedMeetingAbsentCallMissThreshold = 5
|
|
|
/// Armed after recording consent; capture starts once the user is in the call (not on the join/lobby page).
|
|
/// Armed after recording consent; capture starts once the user is in the call (not on the join/lobby page).
|
|
|
private var pendingZoomMeetingRecording: (sourceURL: URL, displayName: String?)?
|
|
private var pendingZoomMeetingRecording: (sourceURL: URL, displayName: String?)?
|
|
|
|
|
+ /// Bumped when the embedded web panel closes or loads a new meeting URL so stale async work cannot start recording.
|
|
|
|
|
+ private var embeddedZoomWebSessionID: UInt = 0
|
|
|
|
|
|
|
|
private enum LocalRecordingNotesStatus: String {
|
|
private enum LocalRecordingNotesStatus: String {
|
|
|
case notRequested
|
|
case notRequested
|
|
@@ -2610,18 +2649,21 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
guard let pending = pendingZoomMeetingRecording else { return }
|
|
guard let pending = pendingZoomMeetingRecording else { return }
|
|
|
pendingZoomMeetingRecording = nil
|
|
pendingZoomMeetingRecording = nil
|
|
|
|
|
+ let sessionID = embeddedZoomWebSessionID
|
|
|
|
|
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
|
|
+ guard sessionID == self.embeddedZoomWebSessionID, self.isEmbeddedBrowserPanelOpen else { return }
|
|
|
do {
|
|
do {
|
|
|
try await MeetingRecordingManager.shared.startRecording(
|
|
try await MeetingRecordingManager.shared.startRecording(
|
|
|
sourceURL: pending.sourceURL,
|
|
sourceURL: pending.sourceURL,
|
|
|
meetingDisplayName: pending.displayName
|
|
meetingDisplayName: pending.displayName
|
|
|
)
|
|
)
|
|
|
- self.updateAiCompanionRecordingUI()
|
|
|
|
|
- if self.embeddedBrowserWebView != nil,
|
|
|
|
|
- (self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
|
|
|
|
|
- self.startEmbeddedMeetingRecordingMonitorIfNeeded()
|
|
|
|
|
|
|
+ guard sessionID == self.embeddedZoomWebSessionID, self.isEmbeddedBrowserPanelOpen else {
|
|
|
|
|
+ self.endZoomMeetingRecordingAutomatically()
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
|
|
+ self.updateAiCompanionRecordingUI()
|
|
|
|
|
+ self.startEmbeddedMeetingRecordingMonitorIfNeeded()
|
|
|
} catch {
|
|
} catch {
|
|
|
self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
|
|
self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
|
|
|
}
|
|
}
|
|
@@ -2633,12 +2675,18 @@ class ViewController: NSViewController {
|
|
|
NSWorkspace.shared.open(url)
|
|
NSWorkspace.shared.open(url)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private var isEmbeddedBrowserPanelOpen: Bool {
|
|
|
|
|
+ (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func presentURLInEmbeddedRightPanel(_ url: URL) {
|
|
private func presentURLInEmbeddedRightPanel(_ url: URL) {
|
|
|
guard let webView = embeddedBrowserWebView else {
|
|
guard let webView = embeddedBrowserWebView else {
|
|
|
NSWorkspace.shared.open(url)
|
|
NSWorkspace.shared.open(url)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+ embeddedZoomWebSessionID &+= 1
|
|
|
updateEmbeddedRightPanelWidthForOpenState()
|
|
updateEmbeddedRightPanelWidthForOpenState()
|
|
|
if isZoomMeetingJoinOrStartURL(url) {
|
|
if isZoomMeetingJoinOrStartURL(url) {
|
|
|
Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
|
|
Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
|
|
@@ -2727,23 +2775,41 @@ class ViewController: NSViewController {
|
|
|
closeEmbeddedBrowserPanel()
|
|
closeEmbeddedBrowserPanel()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Leaves any in-call Zoom web session, stops camera/mic tracks, and navigates away so WKWebView releases hardware.
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func teardownEmbeddedZoomWebClient() {
|
|
|
|
|
+ guard let webView = embeddedBrowserWebView else { return }
|
|
|
|
|
+ webView.evaluateJavaScript(Self.embeddedZoomWebClientTeardownJavaScript) { [weak self] _, _ in
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ guard let self, self.embeddedBrowserWebView === webView else { return }
|
|
|
|
|
+ webView.stopLoading()
|
|
|
|
|
+ if let blankURL = URL(string: "about:blank") {
|
|
|
|
|
+ webView.load(URLRequest(url: blankURL))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
|
|
private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
|
|
|
|
|
+ embeddedZoomWebSessionID &+= 1
|
|
|
pendingZoomMeetingRecording = nil
|
|
pendingZoomMeetingRecording = nil
|
|
|
|
|
+ stopEmbeddedMeetingRecordingMonitor()
|
|
|
|
|
+ MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
|
|
|
if MeetingRecordingManager.shared.isRecording {
|
|
if MeetingRecordingManager.shared.isRecording {
|
|
|
|
|
+ MeetingRecordingManager.shared.stopCaptureHardwareImmediately()
|
|
|
endZoomMeetingRecordingAutomatically()
|
|
endZoomMeetingRecordingAutomatically()
|
|
|
}
|
|
}
|
|
|
|
|
+ updateAiCompanionRecordingUI()
|
|
|
if let cont = oauthSessionContinuation {
|
|
if let cont = oauthSessionContinuation {
|
|
|
oauthSessionContinuation = nil
|
|
oauthSessionContinuation = nil
|
|
|
oauthSessionCallbackPrefix = nil
|
|
oauthSessionCallbackPrefix = nil
|
|
|
cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
|
|
cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
|
|
|
}
|
|
}
|
|
|
if stoppingWebLoad {
|
|
if stoppingWebLoad {
|
|
|
- embeddedBrowserWebView?.stopLoading()
|
|
|
|
|
|
|
+ teardownEmbeddedZoomWebClient()
|
|
|
}
|
|
}
|
|
|
embeddedZoomPendingWebAuth = nil
|
|
embeddedZoomPendingWebAuth = nil
|
|
|
- stopEmbeddedMeetingRecordingMonitor()
|
|
|
|
|
- MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
|
|
|
|
|
embeddedBrowserPanelWidthConstraint?.constant = 0
|
|
embeddedBrowserPanelWidthConstraint?.constant = 0
|
|
|
embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
|
|
embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
|
|
|
homeMainContentColumnView?.isHidden = false
|
|
homeMainContentColumnView?.isHidden = false
|