|
@@ -473,6 +473,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?
|
|
|
|
|
+ /// Consecutive polls suggesting the user left the call (avoids stopping on transient SPA redirects).
|
|
|
|
|
+ private var embeddedMeetingAbsentCallConsecutiveMisses = 0
|
|
|
|
|
+ private static let embeddedMeetingAbsentCallMissThreshold = 5
|
|
|
|
|
|
|
|
private enum LocalRecordingNotesStatus: String {
|
|
private enum LocalRecordingNotesStatus: String {
|
|
|
case notRequested
|
|
case notRequested
|
|
@@ -2110,25 +2113,32 @@ class ViewController: NSViewController {
|
|
|
/// Active join / in-call pages in the embedded Zoom web client (not Workplace hub routes under `/wc/`).
|
|
/// Active join / in-call pages in the embedded Zoom web client (not Workplace hub routes under `/wc/`).
|
|
|
private func isZoomEmbeddedWebClientInCallURL(_ url: URL) -> Bool {
|
|
private func isZoomEmbeddedWebClientInCallURL(_ 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 }
|
|
|
- if isZoomMeetingLeaveOrEndURL(url) { return false }
|
|
|
|
|
|
|
+ if isZoomMeetingExplicitLeaveURL(url) { return false }
|
|
|
let path = url.path.lowercased()
|
|
let path = url.path.lowercased()
|
|
|
if path.contains("/wc/join") { return true }
|
|
if path.contains("/wc/join") { return true }
|
|
|
if path.contains("/wc/inmeeting") { return true }
|
|
if path.contains("/wc/inmeeting") { return true }
|
|
|
if path.contains("/wc/video") { 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)
|
|
|
|
|
|
|
+ // Unknown `/wc/…` SPA routes during a call are treated as in-meeting unless they are hub pages.
|
|
|
|
|
+ if path.contains("/wc/") {
|
|
|
|
|
+ return isZoomWorkplaceHubURL(url) == false
|
|
|
}
|
|
}
|
|
|
- return false
|
|
|
|
|
|
|
+ // Non–web-client join links opened in the embedded panel (rare).
|
|
|
|
|
+ return isZoomMeetingJoinOrStartURL(url)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
|
|
|
|
|
- private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
|
|
|
|
|
|
|
+ /// User explicitly left or the meeting ended (stop recording soon after this is detected).
|
|
|
|
|
+ private func isZoomMeetingExplicitLeaveURL(_ 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 }
|
|
|
let path = url.path.lowercased()
|
|
let path = url.path.lowercased()
|
|
|
let absolute = url.absoluteString.lowercased()
|
|
let absolute = url.absoluteString.lowercased()
|
|
|
if path.contains("/leave") || path.contains("postattendee") || path.contains("/feedback") { return true }
|
|
if path.contains("/leave") || path.contains("postattendee") || path.contains("/feedback") { return true }
|
|
|
- if absolute.contains("meeting_ended") || absolute.contains("meeting-ended") { return true }
|
|
|
|
|
|
|
+ return absolute.contains("meeting_ended") || absolute.contains("meeting-ended")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Workplace hub, sign-in, or marketing home — not an active call (may appear briefly during join).
|
|
|
|
|
+ private func isZoomWorkplaceHubURL(_ url: URL) -> Bool {
|
|
|
|
|
+ guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
|
|
|
|
|
+ let path = url.path.lowercased()
|
|
|
|
|
|
|
|
let workplaceHubMarkers = [
|
|
let workplaceHubMarkers = [
|
|
|
"/wc/home", "/wc/profile", "/wc/calendar", "/wc/chat", "/wc/mail",
|
|
"/wc/home", "/wc/profile", "/wc/calendar", "/wc/chat", "/wc/mail",
|
|
@@ -2142,6 +2152,11 @@ class ViewController: NSViewController {
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
|
|
|
|
|
+ private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
|
|
|
|
|
+ isZoomMeetingExplicitLeaveURL(url) || isZoomWorkplaceHubURL(url)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func embeddedWebViewTitleIndicatesLeftMeeting(_ title: String) -> Bool {
|
|
private func embeddedWebViewTitleIndicatesLeftMeeting(_ title: String) -> Bool {
|
|
|
let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
guard t.isEmpty == false else { return false }
|
|
guard t.isEmpty == false else { return false }
|
|
@@ -2161,21 +2176,43 @@ class ViewController: NSViewController {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let inCallURL = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
|
|
|
|
|
- let leftByURL = url.map { isZoomMeetingLeaveOrEndURL($0) } ?? false
|
|
|
|
|
|
|
+ let inCall = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
|
|
|
|
|
+ let explicitLeaveURL = url.map { isZoomMeetingExplicitLeaveURL($0) } ?? false
|
|
|
|
|
+ let workplaceHubURL = url.map { isZoomWorkplaceHubURL($0) } ?? false
|
|
|
let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
|
|
let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
|
|
|
- let inCall = inCallURL && !leftByURL
|
|
|
|
|
|
|
|
|
|
if inCall {
|
|
if inCall {
|
|
|
embeddedMeetingReachedInCallPage = true
|
|
embeddedMeetingReachedInCallPage = true
|
|
|
|
|
+ embeddedMeetingAbsentCallConsecutiveMisses = 0
|
|
|
}
|
|
}
|
|
|
MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inCall)
|
|
MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inCall)
|
|
|
|
|
|
|
|
- let shouldStopNow = leftByURL || leftByTitle
|
|
|
|
|
- || (embeddedMeetingReachedInCallPage && !inCall)
|
|
|
|
|
- if shouldStopNow {
|
|
|
|
|
|
|
+ // Definite leave/end pages and hub titles stop recording immediately.
|
|
|
|
|
+ if explicitLeaveURL || leftByTitle {
|
|
|
endZoomMeetingRecordingAutomatically()
|
|
endZoomMeetingRecordingAutomatically()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let elapsed = MeetingRecordingManager.shared.currentRecordingElapsed
|
|
|
|
|
+ if elapsed < MeetingRecordingManager.recordingLeaveGraceInterval {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // User may have joined via the desktop Zoom app while the embedded panel shows a hub page.
|
|
|
|
|
+ if ZoomMeetingPresenceWatcher.callPresenceLikelyVisible() {
|
|
|
|
|
+ embeddedMeetingAbsentCallConsecutiveMisses = 0
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let absentFromCall = workplaceHubURL || (embeddedMeetingReachedInCallPage && !inCall)
|
|
|
|
|
+ if absentFromCall {
|
|
|
|
|
+ embeddedMeetingAbsentCallConsecutiveMisses += 1
|
|
|
|
|
+ } else {
|
|
|
|
|
+ embeddedMeetingAbsentCallConsecutiveMisses = 0
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ guard embeddedMeetingAbsentCallConsecutiveMisses >= Self.embeddedMeetingAbsentCallMissThreshold else { return }
|
|
|
|
|
+ endZoomMeetingRecordingAutomatically()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
@@ -2184,6 +2221,7 @@ class ViewController: NSViewController {
|
|
|
guard MeetingRecordingManager.shared.isRecording else { return }
|
|
guard MeetingRecordingManager.shared.isRecording else { return }
|
|
|
guard embeddedBrowserWebView != nil else { return }
|
|
guard embeddedBrowserWebView != nil else { return }
|
|
|
embeddedMeetingReachedInCallPage = false
|
|
embeddedMeetingReachedInCallPage = false
|
|
|
|
|
+ embeddedMeetingAbsentCallConsecutiveMisses = 0
|
|
|
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
|
|
@@ -2198,6 +2236,7 @@ class ViewController: NSViewController {
|
|
|
embeddedMeetingRecordingMonitorTimer?.invalidate()
|
|
embeddedMeetingRecordingMonitorTimer?.invalidate()
|
|
|
embeddedMeetingRecordingMonitorTimer = nil
|
|
embeddedMeetingRecordingMonitorTimer = nil
|
|
|
embeddedMeetingReachedInCallPage = false
|
|
embeddedMeetingReachedInCallPage = false
|
|
|
|
|
+ embeddedMeetingAbsentCallConsecutiveMisses = 0
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|