Explorar el Código

Stop meeting recordings automatically when the call ends or the user leaves.

Restore window-presence monitoring and embedded web client URL/title detection
with SPA navigation hooks so Workplace hub pages stop recording immediately after leave.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 hace 2 meses
padre
commit
4d3ca27525

+ 2 - 2
zoom_app.xcodeproj/project.pbxproj

@@ -258,9 +258,9 @@
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_CFBundleDisplayName = "Zoom App";
 				INFOPLIST_KEY_CFBundleName = "Zoom App";
+				INFOPLIST_KEY_NSCameraUsageDescription = "Zoom App needs camera access for video in embedded Zoom meetings.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
-				INFOPLIST_KEY_NSCameraUsageDescription = "Zoom App needs camera access for video in embedded Zoom meetings.";
 				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App needs microphone access for audio in embedded Zoom meetings and for Ai Companion recordings.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Zoom App captures system audio during meetings (when allowed) so recordings include computer sound in Ai Companion.";
@@ -298,9 +298,9 @@
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_CFBundleDisplayName = "Zoom App";
 				INFOPLIST_KEY_CFBundleName = "Zoom App";
+				INFOPLIST_KEY_NSCameraUsageDescription = "Zoom App needs camera access for video in embedded Zoom meetings.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
-				INFOPLIST_KEY_NSCameraUsageDescription = "Zoom App needs camera access for video in embedded Zoom meetings.";
 				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App needs microphone access for audio in embedded Zoom meetings and for Ai Companion recordings.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Zoom App captures system audio during meetings (when allowed) so recordings include computer sound in Ai Companion.";

+ 124 - 0
zoom_app/MeetingRecordingManager.swift

@@ -95,6 +95,26 @@ final class MeetingRecordingManager: NSObject {
     // Output
     private var outputAudioURL: URL?
 
+    // MARK: - Automatic stop (meeting left / ended)
+
+    private static let automaticStopPollInterval: TimeInterval = 2
+    /// Do not treat "no meeting UI" as leave until the user has had time to connect.
+    private static let automaticStopGraceInterval: TimeInterval = 20
+    /// After grace, if we never saw meeting UI (common when window titles are hidden), still allow auto-stop
+    /// after this many seconds of consecutive "not visible" polls so recording cannot run forever.
+    private static let automaticStopBlindPresenceDelay: TimeInterval = 120
+    /// Consecutive polls with no call UI after a prior positive detection (avoids flapping during layout changes).
+    private static let automaticStopMissThreshold = 2
+    private static let zoomDesktopBundleID = "us.zoom.xos"
+
+    private var automaticStopTimer: Timer?
+    private var automaticStopWorkspaceObserver: NSObjectProtocol?
+    private var automaticStopConsecutiveMisses: Int = 0
+    private var automaticStopHaveSeenCallUI: Bool = false
+    /// Set by the embedded `WKWebView` when its URL indicates an active join/in-call web client page.
+    private var embeddedWebClientCallUIVisible: Bool = false
+    private var stopRecordingInFlight: Bool = false
+
     private override init() {
         super.init()
         loadRecordingsFromDisk()
@@ -207,10 +227,29 @@ final class MeetingRecordingManager: NSObject {
 
         isRecording = true
         NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
+        await MainActor.run {
+            self.startAutomaticStopMonitoringOnMain()
+        }
+    }
+
+    /// Embedded Zoom web client reports whether the current page looks like an active join / in-call flow.
+    @MainActor
+    func setEmbeddedWebClientCallUIVisible(_ visible: Bool) {
+        embeddedWebClientCallUIVisible = visible
+        if visible {
+            automaticStopHaveSeenCallUI = true
+            automaticStopConsecutiveMisses = 0
+        }
     }
 
     func stopRecording() async throws {
+        await MainActor.run {
+            self.stopAutomaticStopMonitoringOnMain()
+        }
+        guard stopRecordingInFlight == false else { return }
         guard isRecording else { throw RecordingError.notRecording }
+        stopRecordingInFlight = true
+        defer { stopRecordingInFlight = false }
         guard let id = recordingID, let startedAt = recordingStartedAt, let outURL = outputAudioURL else {
             throw RecordingError.exportFailed
         }
@@ -261,6 +300,91 @@ final class MeetingRecordingManager: NSObject {
         NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
     }
 
+    @MainActor
+    private func startAutomaticStopMonitoringOnMain() {
+        assert(Thread.isMainThread)
+        stopAutomaticStopMonitoringOnMain()
+        automaticStopConsecutiveMisses = 0
+        automaticStopHaveSeenCallUI = false
+        embeddedWebClientCallUIVisible = false
+
+        automaticStopWorkspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
+            forName: NSWorkspace.didTerminateApplicationNotification,
+            object: nil,
+            queue: .main
+        ) { [weak self] note in
+            self?.handleZoomDesktopClientTerminated(note)
+        }
+
+        let timer = Timer(timeInterval: Self.automaticStopPollInterval, repeats: true) { [weak self] _ in
+            self?.automaticStopPresenceTimerFired()
+        }
+        RunLoop.main.add(timer, forMode: .common)
+        automaticStopTimer = timer
+    }
+
+    @MainActor
+    private func stopAutomaticStopMonitoringOnMain() {
+        assert(Thread.isMainThread)
+        automaticStopTimer?.invalidate()
+        automaticStopTimer = nil
+        if let token = automaticStopWorkspaceObserver {
+            NSWorkspace.shared.notificationCenter.removeObserver(token)
+            automaticStopWorkspaceObserver = nil
+        }
+        automaticStopConsecutiveMisses = 0
+        automaticStopHaveSeenCallUI = false
+        embeddedWebClientCallUIVisible = false
+    }
+
+    @MainActor
+    private func automaticStopPresenceTimerFired() {
+        guard isRecording else {
+            stopAutomaticStopMonitoringOnMain()
+            return
+        }
+        guard let startedAt = recordingStartedAt else { return }
+
+        let elapsed = Date().timeIntervalSince(startedAt)
+        if elapsed < Self.automaticStopGraceInterval { return }
+
+        let visible = embeddedWebClientCallUIVisible || ZoomMeetingPresenceWatcher.callPresenceLikelyVisible()
+        if visible {
+            automaticStopHaveSeenCallUI = true
+            automaticStopConsecutiveMisses = 0
+            return
+        }
+
+        if automaticStopHaveSeenCallUI {
+            automaticStopConsecutiveMisses += 1
+        } else if elapsed >= Self.automaticStopGraceInterval + Self.automaticStopBlindPresenceDelay {
+            automaticStopConsecutiveMisses += 1
+        } else {
+            return
+        }
+
+        guard automaticStopConsecutiveMisses >= Self.automaticStopMissThreshold else { return }
+
+        Task { [weak self] in
+            guard let self else { return }
+            try? await self.stopRecording()
+        }
+    }
+
+    @MainActor
+    private func handleZoomDesktopClientTerminated(_ note: Notification) {
+        guard isRecording else { return }
+        guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
+        guard app.bundleIdentifier == Self.zoomDesktopBundleID else { return }
+        guard let startedAt = recordingStartedAt, Date().timeIntervalSince(startedAt) >= Self.automaticStopGraceInterval else { return }
+        guard automaticStopHaveSeenCallUI else { return }
+
+        Task { [weak self] in
+            guard let self else { return }
+            try? await self.stopRecording()
+        }
+    }
+
     // MARK: - Persistence
 
     private func indexURL() throws -> URL {

+ 182 - 8
zoom_app/ViewController.swift

@@ -38,6 +38,24 @@ protocol EmbeddedOAuthPresenting: AnyObject {
 }
 
 class ViewController: NSViewController {
+    private static let embeddedZoomMeetingLifecycleHandlerName = "zoomAppMeetingLifecycle"
+    private static let embeddedZoomMeetingLifecycleJavaScript = """
+    (function() {
+      function notify() {
+        try {
+          window.webkit.messageHandlers.\(embeddedZoomMeetingLifecycleHandlerName).postMessage(window.location.href || '');
+        } catch (e) {}
+      }
+      var pushState = history.pushState;
+      history.pushState = function() { pushState.apply(this, arguments); notify(); };
+      var replaceState = history.replaceState;
+      history.replaceState = function() { replaceState.apply(this, arguments); notify(); };
+      window.addEventListener('popstate', notify);
+      window.addEventListener('hashchange', notify);
+      setInterval(notify, 1500);
+    })();
+    """
+
     enum HomeSection: String {
         case home = "Home"
         case meetings = "Meetings"
@@ -452,6 +470,9 @@ class ViewController: NSViewController {
     private weak var meetingConsentSecondaryLanguagePopup: NSPopUpButton?
 
     private var aiCompanionTranscriptionActiveRecordingIDs: Set<String> = []
+    /// True after the embedded web client loaded a join/in-call URL during the current recording session.
+    private var embeddedMeetingReachedInCallPage = false
+    private var embeddedMeetingRecordingMonitorTimer: Timer?
 
     private enum LocalRecordingNotesStatus: String {
         case notRequested
@@ -753,6 +774,9 @@ class ViewController: NSViewController {
         ) { [weak self] _ in
             Task { @MainActor [weak self] in
                 guard let self else { return }
+                if MeetingRecordingManager.shared.isRecording == false {
+                    self.stopEmbeddedMeetingRecordingMonitor()
+                }
                 self.updateAiCompanionRecordingUI()
             }
         }
@@ -2083,6 +2107,116 @@ class ViewController: NSViewController {
         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)
+        }
+        return false
+    }
+
+    /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
+    private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
+        guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
+        let path = url.path.lowercased()
+        let absolute = url.absoluteString.lowercased()
+        if path.contains("/leave") || path.contains("postattendee") || path.contains("/feedback") { return true }
+        if absolute.contains("meeting_ended") || absolute.contains("meeting-ended") { return true }
+
+        let workplaceHubMarkers = [
+            "/wc/home", "/wc/profile", "/wc/calendar", "/wc/chat", "/wc/mail",
+            "/wc/whiteboard", "/wc/meeting", "/wc/recordings", "/wc/notes",
+            "/wc/contacts", "/wc/scheduler", "/wc/documents", "/wc/apps"
+        ]
+        if workplaceHubMarkers.contains(where: { path == $0 || path.hasPrefix($0 + "/") }) { return true }
+        if path == "/wc" || path == "/wc/" { return true }
+
+        if path.contains("/wc/") == false, path.isEmpty || path == "/" || path.hasPrefix("/signin") { return true }
+        return false
+    }
+
+    private func embeddedWebViewTitleIndicatesLeftMeeting(_ title: String) -> Bool {
+        let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard t.isEmpty == false else { return false }
+        if t.contains("zoom meeting") || t.contains("zoom webinar") { return false }
+        if t.contains("waiting for the host") || t.contains("waiting for host") { return false }
+        let hubTitles = [
+            "zoom workplace", "meetings | zoom", "home | zoom", "calendar | zoom",
+            "team chat | zoom", "chat | zoom", "sign in", "sign-in", "thank you for"
+        ]
+        return hubTitles.contains(where: { t.contains($0) })
+    }
+
+    @MainActor
+    private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
+        guard MeetingRecordingManager.shared.isRecording else {
+            stopEmbeddedMeetingRecordingMonitor()
+            return
+        }
+
+        let inCallURL = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
+        let leftByURL = url.map { isZoomMeetingLeaveOrEndURL($0) } ?? false
+        let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
+        let inCall = inCallURL && !leftByURL
+
+        if inCall {
+            embeddedMeetingReachedInCallPage = true
+        }
+        MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inCall)
+
+        let shouldStopNow = leftByURL || leftByTitle
+            || (embeddedMeetingReachedInCallPage && !inCall)
+        if shouldStopNow {
+            endZoomMeetingRecordingAutomatically()
+        }
+    }
+
+    @MainActor
+    private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
+        stopEmbeddedMeetingRecordingMonitor()
+        guard MeetingRecordingManager.shared.isRecording else { return }
+        guard embeddedBrowserWebView != nil else { return }
+        embeddedMeetingReachedInCallPage = false
+        let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
+            guard let self else { return }
+            let webView = self.embeddedBrowserWebView
+            self.updateEmbeddedMeetingRecordingPresence(for: webView?.url, webViewTitle: webView?.title)
+        }
+        RunLoop.main.add(timer, forMode: .common)
+        embeddedMeetingRecordingMonitorTimer = timer
+    }
+
+    @MainActor
+    private func stopEmbeddedMeetingRecordingMonitor() {
+        embeddedMeetingRecordingMonitorTimer?.invalidate()
+        embeddedMeetingRecordingMonitorTimer = nil
+        embeddedMeetingReachedInCallPage = false
+    }
+
+    @MainActor
+    private func endZoomMeetingRecordingAutomatically() {
+        guard MeetingRecordingManager.shared.isRecording else { return }
+        stopEmbeddedMeetingRecordingMonitor()
+        Task { @MainActor in
+            guard MeetingRecordingManager.shared.isRecording else { return }
+            do {
+                try await MeetingRecordingManager.shared.stopRecording()
+                self.recordedMeetings = MeetingRecordingManager.shared.recordings
+                self.renderAiCompanionList()
+                self.updateAiCompanionRecordingUI()
+            } catch {
+                NSLog("ViewController: auto-stop recording failed — \(error.localizedDescription)")
+            }
+        }
+    }
+
     @MainActor
     private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
         guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
@@ -2091,6 +2225,10 @@ class ViewController: NSViewController {
             do {
                 try await MeetingRecordingManager.shared.startRecording(sourceURL: sourceURL, meetingDisplayName: meetingDisplayName)
                 self.updateAiCompanionRecordingUI()
+                if self.embeddedBrowserWebView != nil,
+                   (self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
+                    self.startEmbeddedMeetingRecordingMonitorIfNeeded()
+                }
             } catch {
                 self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
             }
@@ -2113,6 +2251,7 @@ class ViewController: NSViewController {
             Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
         }
         webView.load(URLRequest(url: url))
+        updateEmbeddedMeetingRecordingPresence(for: url)
         refreshEmbeddedBrowserNavButtons()
         view.window?.contentView?.layoutSubtreeIfNeeded()
     }
@@ -2193,12 +2332,17 @@ class ViewController: NSViewController {
 
     @MainActor
     private func closeEmbeddedBrowserPanel() {
+        if MeetingRecordingManager.shared.isRecording {
+            endZoomMeetingRecordingAutomatically()
+        }
         if let cont = oauthSessionContinuation {
             oauthSessionContinuation = nil
             oauthSessionCallbackPrefix = nil
             cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
         }
         embeddedBrowserWebView?.stopLoading()
+        stopEmbeddedMeetingRecordingMonitor()
+        MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
         embeddedBrowserPanelWidthConstraint?.constant = 0
         embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
         homeMainContentColumnView?.isHidden = false
@@ -6594,6 +6738,15 @@ class ViewController: NSViewController {
         let embeddedBrowserConfiguration = WKWebViewConfiguration()
         embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
         embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
+        let meetingLifecycleController = WKUserContentController()
+        meetingLifecycleController.add(self, name: Self.embeddedZoomMeetingLifecycleHandlerName)
+        let lifecycleScript = WKUserScript(
+            source: Self.embeddedZoomMeetingLifecycleJavaScript,
+            injectionTime: .atDocumentEnd,
+            forMainFrameOnly: true
+        )
+        meetingLifecycleController.addUserScript(lifecycleScript)
+        embeddedBrowserConfiguration.userContentController = meetingLifecycleController
         let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
         embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
         embeddedWK.translatesAutoresizingMaskIntoConstraints = false
@@ -7429,7 +7582,7 @@ class ViewController: NSViewController {
         aiCompanionStopRecordingButton?.isHidden = !mgr.isRecording
         if mgr.isRecording {
             aiCompanionStatusLabel?.stringValue =
-                "Recording… When you leave the meeting, click Stop & save (here or in the menu bar)."
+                "Recording… Stops automatically when you leave or the meeting ends."
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
             aiCompanionStatusLabel?.textColor = secondaryText
         } else {
@@ -7451,6 +7604,7 @@ class ViewController: NSViewController {
     }
 
     @objc private func aiCompanionStopRecordingTapped() {
+        stopEmbeddedMeetingRecordingMonitor()
         Task { @MainActor in
             do {
                 try await MeetingRecordingManager.shared.stopRecording()
@@ -7537,13 +7691,15 @@ class ViewController: NSViewController {
         return "Saved time: \(formatter.string(from: date))"
     }
 
+    private let aiCompanionGetNotesButtonHeight: CGFloat = 28
+
     private func styleAiCompanionGetNotesButton(_ button: NSButton, enabled: Bool) {
-        let pillHeight: CGFloat = 36
+        let pillHeight = aiCompanionGetNotesButtonHeight
         button.isBordered = false
         button.wantsLayer = true
         button.layer?.cornerRadius = pillHeight / 2
         button.layer?.borderWidth = 1
-        button.font = .systemFont(ofSize: 13, weight: .semibold)
+        button.font = .systemFont(ofSize: 12, weight: .semibold)
         button.imagePosition = .imageLeading
         button.imageHugsTitle = true
 
@@ -7559,7 +7715,7 @@ class ViewController: NSViewController {
             button.layer?.borderColor = accentBlue.cgColor
             button.contentTintColor = accentBlue
             if let icon = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil) {
-                let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
+                let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
                     .applying(NSImage.SymbolConfiguration(hierarchicalColor: accentBlue))
                 button.image = icon.withSymbolConfiguration(config)
             }
@@ -7569,7 +7725,7 @@ class ViewController: NSViewController {
             button.layer?.borderColor = mutedBlue.cgColor
             button.contentTintColor = mutedBlue
             if let icon = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil) {
-                let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
+                let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
                     .applying(NSImage.SymbolConfiguration(hierarchicalColor: mutedBlue))
                 button.image = icon.withSymbolConfiguration(config)
             }
@@ -7671,10 +7827,10 @@ class ViewController: NSViewController {
             savedTimeLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
             savedTimeLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
 
-            getNotesBtn.topAnchor.constraint(equalTo: savedTimeLabel.bottomAnchor, constant: 14),
+            getNotesBtn.topAnchor.constraint(equalTo: savedTimeLabel.bottomAnchor, constant: 12),
             getNotesBtn.leadingAnchor.constraint(equalTo: title.leadingAnchor),
-            getNotesBtn.heightAnchor.constraint(equalToConstant: 36),
-            getNotesBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
+            getNotesBtn.heightAnchor.constraint(equalToConstant: aiCompanionGetNotesButtonHeight),
+            getNotesBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 108),
 
             statusLabel.topAnchor.constraint(equalTo: getNotesBtn.bottomAnchor, constant: 8),
             statusLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
@@ -9532,6 +9688,17 @@ class ViewController: NSViewController {
 
 extension ViewController: EmbeddedOAuthPresenting {}
 
+extension ViewController: WKScriptMessageHandler {
+    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)
+        }
+    }
+}
+
 extension ViewController: WKNavigationDelegate, WKUIDelegate {
     func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
         guard webView === embeddedBrowserWebView else {
@@ -9547,16 +9714,23 @@ extension ViewController: WKNavigationDelegate, WKUIDelegate {
             decisionHandler(.cancel)
             return
         }
+        if let targetURL = navigationAction.request.url {
+            Task { @MainActor in
+                self.updateEmbeddedMeetingRecordingPresence(for: targetURL, webViewTitle: webView.title)
+            }
+        }
         decisionHandler(.allow)
     }
 
     func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
         guard webView === embeddedBrowserWebView else { return }
+        updateEmbeddedMeetingRecordingPresence(for: webView.url, webViewTitle: webView.title)
         refreshEmbeddedBrowserNavButtons()
     }
 
     func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
         guard webView === embeddedBrowserWebView else { return }
+        updateEmbeddedMeetingRecordingPresence(for: webView.url, webViewTitle: webView.title)
         refreshEmbeddedBrowserNavButtons()
     }
 

+ 122 - 0
zoom_app/ZoomMeetingPresenceWatcher.swift

@@ -0,0 +1,122 @@
+import AppKit
+import CoreGraphics
+
+/// Heuristic detection of an active Zoom meeting UI (desktop client or common browsers).
+/// Relies on `CGWindowListCopyWindowInfo` window titles (often blank until Screen Recording permission is granted).
+enum ZoomMeetingPresenceWatcher {
+
+    /// `true` when on-screen windows suggest the user is still in a Zoom call or join flow.
+    static func callPresenceLikelyVisible() -> Bool {
+        let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
+        guard let rawList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
+            return false
+        }
+
+        let appDisplayName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
+            ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
+            ?? "").lowercased()
+
+        for entry in rawList {
+            let layer = entry[kCGWindowLayer as String] as? Int ?? 0
+            if layer != 0 { continue }
+
+            let owner = (entry[kCGWindowOwnerName as String] as? String ?? "").lowercased()
+            let title = (entry[kCGWindowName as String] as? String ?? "").lowercased()
+
+            if title.isEmpty == false {
+                if matchesZoomDesktopClient(owner: owner, title: title) { return true }
+                if matchesBrowserTab(owner: owner, title: title) { return true }
+                if appDisplayName.isEmpty == false, owner.contains(appDisplayName), titleIndicatesEmbeddedZoom(title) {
+                    return true
+                }
+            } else if zoomDesktopLikelyMainWindow(owner: owner, bounds: cgWindowBounds(from: entry)) {
+                // Without Screen Recording permission, macOS often omits other apps' window titles.
+                // Large Zoom-owned windows are a reasonable proxy for an active meeting UI.
+                return true
+            }
+        }
+        return false
+    }
+
+    private static func cgWindowBounds(from entry: [String: Any]) -> CGRect {
+        guard let dict = entry[kCGWindowBounds as String] as? [String: Any] else { return .null }
+        let x = doubleField(dict["X"]) ?? doubleField(dict["x"]) ?? 0
+        let y = doubleField(dict["Y"]) ?? doubleField(dict["y"]) ?? 0
+        let w = doubleField(dict["Width"]) ?? doubleField(dict["width"]) ?? 0
+        let h = doubleField(dict["Height"]) ?? doubleField(dict["height"]) ?? 0
+        return CGRect(x: x, y: y, width: w, height: h)
+    }
+
+    private static func doubleField(_ any: Any?) -> CGFloat? {
+        if let n = any as? NSNumber { return CGFloat(truncating: n) }
+        if let d = any as? Double { return CGFloat(d) }
+        if let f = any as? CGFloat { return f }
+        return nil
+    }
+
+    /// True for typical Zoom meeting / main client chrome when window titles are unavailable.
+    private static func zoomDesktopLikelyMainWindow(owner: String, bounds: CGRect) -> Bool {
+        guard bounds.isNull == false, bounds.isInfinite == false else { return false }
+        let isZoomProcess = owner == "zoom" || owner.hasSuffix("zoom.us")
+        guard isZoomProcess else { return false }
+        let w = bounds.width
+        let h = bounds.height
+        return w >= 360 && h >= 240
+    }
+
+    private static func matchesZoomDesktopClient(owner: String, title: String) -> Bool {
+        let isZoomProcess = owner == "zoom" || owner.hasSuffix("zoom.us")
+        guard isZoomProcess else { return false }
+
+        let strongHints = [
+            "zoom meeting", "zoom webinar", "webinar", "personal meeting room",
+            "gallery view", "speaker view", "immersive view", "side-by-side",
+            "you are screen sharing", "screen sharing", "sharing screen",
+            "viewing ", "'s screen", "share sound", "remote control"
+        ]
+        if strongHints.contains(where: { title.contains($0) }) { return true }
+
+        // Exclude obvious non-call surfaces.
+        let homeHints = ["zoom workplace", "sign in", "sign-in", "settings", "preferences", "upcoming meetings"]
+        if homeHints.contains(where: { title.contains($0) }) { return false }
+
+        // Long topic-style titles while in-call (best-effort; may miss short titles).
+        if title != "zoom", title.count >= 18, title.contains("zoom") == false {
+            return true
+        }
+
+        return false
+    }
+
+    private static func matchesBrowserTab(owner: String, title: String) -> Bool {
+        let browserMarkers = ["chrome", "safari", "firefox", "edge", "arc", "brave", "opera", "vivaldi", "comet", "chromium"]
+        let isBrowser = browserMarkers.contains { owner.contains($0) }
+        guard isBrowser else { return false }
+
+        // Most signed-in Zoom web tabs use "| zoom" in the title, including home, meetings list, chat, etc.
+        // Treating that as "in a call" prevents auto-stop after the user leaves (Safari still shows app.zoom.us).
+        let hubExclusions = [
+            "zoom workplace", "meetings | zoom", "meeting list", "calendar | zoom", "mail | zoom",
+            "team chat | zoom", "chat | zoom", "scheduler | zoom", "notes | zoom", "recordings | zoom",
+            "whiteboard | zoom", "marketplace | zoom", "sign in", "sign-in", "settings | zoom",
+            "preferences", "home | zoom", "contacts | zoom", "zoom rooms", "my profile"
+        ]
+        if hubExclusions.contains(where: { title.contains($0) }) { return false }
+
+        let hints = [
+            "zoom meeting", "zoom webinar", "launch meeting", "join meeting",
+            "start video", "gallery view", "speaker view", "immersive view", "side-by-side",
+            "waiting for the host", "waiting for host", "the meeting has not started", "this meeting is being recorded",
+            "personal meeting room", "you are screen sharing", "screen sharing", "sharing screen",
+            "viewing ", "'s screen", "remote control", "share sound", "breakout room"
+        ]
+        if hints.contains(where: { title.contains($0) }) { return true }
+
+        return false
+    }
+
+    private static func titleIndicatesEmbeddedZoom(_ title: String) -> Bool {
+        title.contains("zoom meeting") || title.contains("zoom webinar")
+            || title.contains("join meeting") || title.contains("| zoom")
+    }
+}