|
@@ -489,6 +489,8 @@ class ViewController: NSViewController {
|
|
|
/// Consecutive polls suggesting the user left the call (avoids stopping on transient SPA redirects).
|
|
/// Consecutive polls suggesting the user left the call (avoids stopping on transient SPA redirects).
|
|
|
private var embeddedMeetingAbsentCallConsecutiveMisses = 0
|
|
private var embeddedMeetingAbsentCallConsecutiveMisses = 0
|
|
|
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).
|
|
|
|
|
+ private var pendingZoomMeetingRecording: (sourceURL: URL, displayName: String?)?
|
|
|
|
|
|
|
|
private enum LocalRecordingNotesStatus: String {
|
|
private enum LocalRecordingNotesStatus: String {
|
|
|
case notRequested
|
|
case notRequested
|
|
@@ -1858,7 +1860,7 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
guard let url = zoomURL else { return }
|
|
guard let url = zoomURL else { return }
|
|
|
|
|
|
|
|
- openWebURLPreferringInApp(url, zoomMeetingDisplayName: meetingName, recordingConsentWindowTitle: "Join Meeting")
|
|
|
|
|
|
|
+ joinZoomMeetingFromLink(url.absoluteString, displayName: meetingName)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private enum MeetingRecordingConsentChoice {
|
|
private enum MeetingRecordingConsentChoice {
|
|
@@ -2319,6 +2321,53 @@ class ViewController: NSViewController {
|
|
|
return url
|
|
return url
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Converts invite links (`zoom.us/j/…`) into embedded web-client join URLs (`app.zoom.us/wc/join/…`).
|
|
|
|
|
+ private func normalizedEmbeddedJoinURL(for url: URL) -> URL {
|
|
|
|
|
+ if let embedded = embeddedWebClientURLForZoomMeeting(meetingId: nil, joinURLString: url.absoluteString) {
|
|
|
|
|
+ return embedded
|
|
|
|
|
+ }
|
|
|
|
|
+ return zoomEmbeddedWebHostURL(for: url)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Join lobby / invite URL — not an active call (recording must not start here).
|
|
|
|
|
+ private func isZoomEmbeddedWebClientJoinLobbyURL(_ url: URL) -> Bool {
|
|
|
|
|
+ guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
|
|
|
|
|
+ let path = url.path.lowercased()
|
|
|
|
|
+ if path.contains("/wc/join") { return true }
|
|
|
|
|
+ if path.range(of: #"/j/\d"#, options: .regularExpression) != nil { return true }
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func embeddedWebViewTitleIndicatesActiveCall(_ title: String) -> Bool {
|
|
|
|
|
+ let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
|
+ guard t.isEmpty == false else { return false }
|
|
|
|
|
+ return t.contains("zoom meeting") || t.contains("zoom webinar")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Opens a Zoom join link in the embedded web panel (skips the Join sheet).
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func joinZoomMeetingFromLink(_ rawLink: String, displayName: String?) {
|
|
|
|
|
+ guard let parsed = parseZoomJoinURLFromUserInput(rawLink) else {
|
|
|
|
|
+ showSimpleAlert(
|
|
|
|
|
+ title: "Invalid link",
|
|
|
|
|
+ message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…)."
|
|
|
|
|
+ )
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ let url = normalizedEmbeddedJoinURL(for: parsed)
|
|
|
|
|
+ openWebURLPreferringInApp(
|
|
|
|
|
+ url,
|
|
|
|
|
+ zoomMeetingDisplayName: displayName,
|
|
|
|
|
+ recordingConsentWindowTitle: "Join Meeting"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func joinScheduledMeeting(_ meeting: ScheduledMeeting) {
|
|
|
|
|
+ guard let url = meeting.webURL else { return }
|
|
|
|
|
+ joinZoomMeetingFromLink(url.absoluteString, displayName: meeting.title)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/// Opens Zoom meeting join/start links in the right-panel `WKWebView` (same store as embedded Zoom OAuth).
|
|
/// Opens Zoom meeting join/start links in the right-panel `WKWebView` (same store as embedded Zoom OAuth).
|
|
|
/// Other URLs open in the system default browser.
|
|
/// Other URLs open in the system default browser.
|
|
|
/// For Zoom meeting join/start links, asks for recording consent first, persists transcription languages from that sheet,
|
|
/// For Zoom meeting join/start links, asks for recording consent first, persists transcription languages from that sheet,
|
|
@@ -2329,8 +2378,10 @@ class ViewController: NSViewController {
|
|
|
zoomMeetingDisplayName: String? = nil,
|
|
zoomMeetingDisplayName: String? = nil,
|
|
|
recordingConsentWindowTitle: String = "Meeting"
|
|
recordingConsentWindowTitle: String = "Meeting"
|
|
|
) {
|
|
) {
|
|
|
|
|
+ let targetURL = isZoomMeetingJoinOrStartURL(url) ? normalizedEmbeddedJoinURL(for: url) : url
|
|
|
|
|
+
|
|
|
let shouldRecordLocally: Bool
|
|
let shouldRecordLocally: Bool
|
|
|
- if isZoomMeetingJoinOrStartURL(url) {
|
|
|
|
|
|
|
+ if isZoomMeetingJoinOrStartURL(targetURL) {
|
|
|
let consentChoice = requestMeetingRecordingConsent(title: recordingConsentWindowTitle)
|
|
let consentChoice = requestMeetingRecordingConsent(title: recordingConsentWindowTitle)
|
|
|
if consentChoice == .dismissed { return }
|
|
if consentChoice == .dismissed { return }
|
|
|
shouldRecordLocally = (consentChoice == .allowAndContinue)
|
|
shouldRecordLocally = (consentChoice == .allowAndContinue)
|
|
@@ -2339,16 +2390,19 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
let opened: Bool
|
|
let opened: Bool
|
|
|
- if isZoomMeetingJoinOrStartURL(url), embeddedBrowserWebView != nil {
|
|
|
|
|
- presentURLInEmbeddedRightPanel(url)
|
|
|
|
|
|
|
+ if isZoomMeetingJoinOrStartURL(targetURL), embeddedBrowserWebView != nil {
|
|
|
|
|
+ presentURLInEmbeddedRightPanel(targetURL)
|
|
|
opened = true
|
|
opened = true
|
|
|
} else {
|
|
} else {
|
|
|
- opened = openURLInDefaultBrowser(url)
|
|
|
|
|
|
|
+ opened = openURLInDefaultBrowser(targetURL)
|
|
|
}
|
|
}
|
|
|
if opened {
|
|
if opened {
|
|
|
dismissJoinMeetingPanel()
|
|
dismissJoinMeetingPanel()
|
|
|
if shouldRecordLocally {
|
|
if shouldRecordLocally {
|
|
|
- beginZoomMeetingRecordingIfAppropriate(sourceURL: url, meetingDisplayName: zoomMeetingDisplayName)
|
|
|
|
|
|
|
+ armZoomMeetingRecordingIfAppropriate(
|
|
|
|
|
+ sourceURL: targetURL,
|
|
|
|
|
+ meetingDisplayName: zoomMeetingDisplayName
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
|
|
showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
|
|
@@ -2372,12 +2426,12 @@ class ViewController: NSViewController {
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Active join / in-call pages in the embedded Zoom web client (not Workplace hub routes under `/wc/`).
|
|
|
|
|
|
|
+ /// Active in-call pages in the embedded Zoom web client (not join lobby or Workplace hub routes).
|
|
|
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 isZoomMeetingExplicitLeaveURL(url) { return false }
|
|
if isZoomMeetingExplicitLeaveURL(url) { return false }
|
|
|
|
|
+ if isZoomEmbeddedWebClientJoinLobbyURL(url) { return false }
|
|
|
let path = url.path.lowercased()
|
|
let path = url.path.lowercased()
|
|
|
- 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 }
|
|
|
// Unknown `/wc/…` SPA routes during a call are treated as in-meeting unless they are hub pages.
|
|
// Unknown `/wc/…` SPA routes during a call are treated as in-meeting unless they are hub pages.
|
|
@@ -2433,12 +2487,17 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
|
|
private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
|
|
|
|
|
+ startPendingZoomMeetingRecordingIfNeeded(for: url, webViewTitle: webViewTitle)
|
|
|
|
|
+
|
|
|
guard MeetingRecordingManager.shared.isRecording else {
|
|
guard MeetingRecordingManager.shared.isRecording else {
|
|
|
- stopEmbeddedMeetingRecordingMonitor()
|
|
|
|
|
|
|
+ if pendingZoomMeetingRecording == nil {
|
|
|
|
|
+ stopEmbeddedMeetingRecordingMonitor()
|
|
|
|
|
+ }
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let inCall = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
|
|
|
|
|
|
|
+ let inCall = (url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false)
|
|
|
|
|
+ || (webViewTitle.map { embeddedWebViewTitleIndicatesActiveCall($0) } ?? false)
|
|
|
let explicitLeaveURL = url.map { isZoomMeetingExplicitLeaveURL($0) } ?? false
|
|
let explicitLeaveURL = url.map { isZoomMeetingExplicitLeaveURL($0) } ?? false
|
|
|
let workplaceHubURL = url.map { isZoomWorkplaceHubURL($0) } ?? false
|
|
let workplaceHubURL = url.map { isZoomWorkplaceHubURL($0) } ?? false
|
|
|
let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
|
|
let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
|
|
@@ -2480,7 +2539,7 @@ class ViewController: NSViewController {
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
|
|
private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
|
|
|
stopEmbeddedMeetingRecordingMonitor()
|
|
stopEmbeddedMeetingRecordingMonitor()
|
|
|
- guard MeetingRecordingManager.shared.isRecording else { return }
|
|
|
|
|
|
|
+ guard MeetingRecordingManager.shared.isRecording || pendingZoomMeetingRecording != nil else { return }
|
|
|
guard embeddedBrowserWebView != nil else { return }
|
|
guard embeddedBrowserWebView != nil else { return }
|
|
|
embeddedMeetingReachedInCallPage = false
|
|
embeddedMeetingReachedInCallPage = false
|
|
|
embeddedMeetingAbsentCallConsecutiveMisses = 0
|
|
embeddedMeetingAbsentCallConsecutiveMisses = 0
|
|
@@ -2519,12 +2578,40 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
- private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
|
|
|
|
|
|
|
+ private func armZoomMeetingRecordingIfAppropriate(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 }
|
|
|
|
|
+ pendingZoomMeetingRecording = (normalizedEmbeddedJoinURL(for: sourceURL), meetingDisplayName)
|
|
|
|
|
+ updateAiCompanionRecordingUI()
|
|
|
|
|
+ if embeddedBrowserWebView != nil,
|
|
|
|
|
+ (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
|
|
|
|
|
+ startEmbeddedMeetingRecordingMonitorIfNeeded()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func startPendingZoomMeetingRecordingIfNeeded(for url: URL?, webViewTitle: String?) {
|
|
|
|
|
+ guard pendingZoomMeetingRecording != nil else { return }
|
|
|
|
|
+ if MeetingRecordingManager.shared.isRecording {
|
|
|
|
|
+ pendingZoomMeetingRecording = nil
|
|
|
|
|
+ updateAiCompanionRecordingUI()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let urlInCall = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
|
|
|
|
|
+ let titleInCall = webViewTitle.map { embeddedWebViewTitleIndicatesActiveCall($0) } ?? false
|
|
|
|
|
+ let desktopInCall = ZoomMeetingPresenceWatcher.callPresenceLikelyVisible()
|
|
|
|
|
+ guard urlInCall || titleInCall || desktopInCall else { return }
|
|
|
|
|
+
|
|
|
|
|
+ guard let pending = pendingZoomMeetingRecording else { return }
|
|
|
|
|
+ pendingZoomMeetingRecording = nil
|
|
|
|
|
+
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
do {
|
|
do {
|
|
|
- try await MeetingRecordingManager.shared.startRecording(sourceURL: sourceURL, meetingDisplayName: meetingDisplayName)
|
|
|
|
|
|
|
+ try await MeetingRecordingManager.shared.startRecording(
|
|
|
|
|
+ sourceURL: pending.sourceURL,
|
|
|
|
|
+ meetingDisplayName: pending.displayName
|
|
|
|
|
+ )
|
|
|
self.updateAiCompanionRecordingUI()
|
|
self.updateAiCompanionRecordingUI()
|
|
|
if self.embeddedBrowserWebView != nil,
|
|
if self.embeddedBrowserWebView != nil,
|
|
|
(self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
|
|
(self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
|
|
@@ -2637,6 +2724,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
|
|
private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
|
|
|
|
|
+ pendingZoomMeetingRecording = nil
|
|
|
if MeetingRecordingManager.shared.isRecording {
|
|
if MeetingRecordingManager.shared.isRecording {
|
|
|
endZoomMeetingRecordingAutomatically()
|
|
endZoomMeetingRecordingAutomatically()
|
|
|
}
|
|
}
|
|
@@ -3163,8 +3251,8 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
@objc private func meetingSearchResultTapped(_ sender: NSButton) {
|
|
@objc private func meetingSearchResultTapped(_ sender: NSButton) {
|
|
|
guard let meeting = globalSearchResults[safe: sender.tag] else { return }
|
|
guard let meeting = globalSearchResults[safe: sender.tag] else { return }
|
|
|
- if let url = meeting.webURL {
|
|
|
|
|
- openWebURLPreferringInApp(url, zoomMeetingDisplayName: meeting.title, recordingConsentWindowTitle: "Join Meeting")
|
|
|
|
|
|
|
+ if meeting.webURL != nil {
|
|
|
|
|
+ joinScheduledMeeting(meeting)
|
|
|
} else {
|
|
} else {
|
|
|
let pasteboard = NSPasteboard.general
|
|
let pasteboard = NSPasteboard.general
|
|
|
pasteboard.clearContents()
|
|
pasteboard.clearContents()
|
|
@@ -7980,6 +8068,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 pendingZoomMeetingRecording != 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)
|
|
@@ -9114,9 +9207,8 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
|
|
center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
|
|
|
guard let self else { return }
|
|
guard let self else { return }
|
|
|
- guard let link = notification.userInfo?["link"] as? String,
|
|
|
|
|
- let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
|
|
|
|
|
- self.openWebURLPreferringInApp(url, zoomMeetingDisplayName: nil, recordingConsentWindowTitle: "Join Meeting")
|
|
|
|
|
|
|
+ guard let link = notification.userInfo?["link"] as? String else { return }
|
|
|
|
|
+ self.joinZoomMeetingFromLink(link, displayName: nil)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -9625,9 +9717,8 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
func menuBarOpenTopMeeting(joinLink: String?) {
|
|
func menuBarOpenTopMeeting(joinLink: String?) {
|
|
|
if let link = joinLink?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
if let link = joinLink?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
- !link.isEmpty,
|
|
|
|
|
- let url = URL(string: link) {
|
|
|
|
|
- openWebURLPreferringInApp(url, zoomMeetingDisplayName: nil, recordingConsentWindowTitle: "Join Meeting")
|
|
|
|
|
|
|
+ link.isEmpty == false {
|
|
|
|
|
+ joinZoomMeetingFromLink(link, displayName: nil)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
menuBarShowSection(.meetings)
|
|
menuBarShowSection(.meetings)
|
|
@@ -10007,8 +10098,8 @@ class ViewController: NSViewController {
|
|
|
url: meeting.webURL,
|
|
url: meeting.webURL,
|
|
|
hoverBackground: hoverBackground,
|
|
hoverBackground: hoverBackground,
|
|
|
hoverBorder: hoverBorder,
|
|
hoverBorder: hoverBorder,
|
|
|
- onTapURL: { [weak self] url in
|
|
|
|
|
- self?.openWebURLPreferringInApp(url, zoomMeetingDisplayName: meeting.title, recordingConsentWindowTitle: "Join Meeting")
|
|
|
|
|
|
|
+ onTapURL: { [weak self] _ in
|
|
|
|
|
+ self?.joinScheduledMeeting(meeting)
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
card.wantsLayer = true
|
|
card.wantsLayer = true
|