Explorar el Código

Stop AI Companion recording when meetings end or leave

MeetingRecordingManager polls window presence after a grace period, counts
consecutive misses when call UI disappears, and adds a blind timeout when
titles never appear so recording cannot run indefinitely. It also listens for
the native Meeting SDK session-ended notification and Zoom desktop quit.

ZoomMeetingPresenceWatcher narrows browser tab matching so Zoom web hub tabs
are not treated as active calls, adds Zoom desktop heuristics when titles are
empty, and wires the Zoom Meeting SDK bridge for broader in-session states and
reliable session-ended posts.

Includes Xcode project wiring, bridging header, optional Base.xcconfig,
Vendor ZoomSDK README, entitlements tweaks, menu Stop Recording in AppDelegate,
and ViewController join/recording flow updates.

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

+ 7 - 0
Config/Base.xcconfig

@@ -0,0 +1,7 @@
+// Zoom Meeting SDK (optional): copy `ZoomSDK.framework` and bundled dylibs from the Marketplace zip
+// into `Vendor/ZoomSDK/`, then set below to `-framework ZoomSDK` so the linker can resolve symbols.
+// See `Vendor/ZoomSDK/README.md`.
+ZOOM_SDK_LDFLAGS =
+
+OTHER_LDFLAGS = $(inherited) -ObjC $(ZOOM_SDK_LDFLAGS)
+FRAMEWORK_SEARCH_PATHS = $(inherited) "$(SRCROOT)/Vendor/ZoomSDK"

+ 35 - 0
Vendor/ZoomSDK/README.md

@@ -0,0 +1,35 @@
+# Zoom Meeting SDK for macOS (vendored)
+
+Per [Zoom’s documentation](https://developers.zoom.us/docs/meeting-sdk/macos/get-started/), the Meeting SDK for macOS **does not run in the App Sandbox**. This project has sandboxing disabled when you use the embedded SDK path.
+
+## Download
+
+1. Sign in to the [Zoom App Marketplace](https://marketplace.zoom.us/).
+2. Create or open a **General** app → **Embed** → enable **Meeting SDK**.
+3. Download the **macOS** package.
+4. From the unzipped `SampleApp/ZoomSDK` folder, copy into **this directory** (`Vendor/ZoomSDK/`):
+
+   - `ZoomSDK.framework`
+   - `libcrypto.dylib`, `libjson.dylib`, `libminizip.dylib`, `Libssl.dylib`, `libcares.dylib`, `libzoombase_crypto_shared.dylib` (names must match Zoom’s package; casing may vary—mirror the sample app’s **Link Binary With Libraries** list).
+
+5. From the package **Plugin** (or `Plugins`) folder, add **`ZoomAudioDevice.driver`** via Xcode **Build Phases → Copy Files** (Destination: **PlugIns**), as described in Zoom’s [integrate](https://developers.zoom.us/docs/meeting-sdk/macos/integrate/) guide.
+
+## Link the framework
+
+1. Open `Config/Base.xcconfig`.
+2. Set:
+
+   `ZOOM_SDK_LDFLAGS = -framework ZoomSDK`
+
+3. In Xcode: **General → Frameworks, Libraries, and Embedded Content** — add `ZoomSDK.framework` with **Embed & Sign** if not already picked up from search paths (match Zoom’s sample).
+
+4. **Other Linker Flags**: the xcconfig already adds `-ObjC`.
+
+## Credentials
+
+- **Meeting SDK JWT** is generated locally from the same **OAuth Client ID and Client Secret** you already use for Zoom sign-in (`ZoomOAuthClientId` / `ZOOM_OAUTH_CLIENT_SECRET`). Your Marketplace app must have **Meeting SDK** enabled for that client.
+- **ZAK** (recommended for joining as the signed-in user) uses the in-app OAuth token; add Zoom’s **`user_zak:read`** (and/or legacy `user:read`) scope to your app so `GET /users/me/token?type=zak` succeeds.
+
+## External meetings
+
+Joining meetings **outside** the developer account may require Zoom app review and **ZAK** / **OBF** per [Zoom’s policy](https://developers.zoom.us/docs/meeting-sdk/macos/get-started/). If join fails, the app falls back to opening the link in the default browser.

+ 24 - 2
zoom_app.xcodeproj/project.pbxproj

@@ -8,6 +8,7 @@
 
 /* Begin PBXFileReference section */
 		1391CC002F8E456500B3B198 /* Zoom App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Zoom App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+		2A31AC002F8E456500B3B198 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -32,6 +33,7 @@
 		1391CBF72F8E456500B3B198 = {
 			isa = PBXGroup;
 			children = (
+				2A31AC012F8E456500B3B198 /* Config */,
 				1391CC022F8E456500B3B198 /* zoom_app */,
 				1391CC012F8E456500B3B198 /* Products */,
 			);
@@ -45,6 +47,14 @@
 			name = Products;
 			sourceTree = "<group>";
 		};
+		2A31AC012F8E456500B3B198 /* Config */ = {
+			isa = PBXGroup;
+			children = (
+				2A31AC002F8E456500B3B198 /* Base.xcconfig */,
+			);
+			path = Config;
+			sourceTree = "<group>";
+		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
@@ -245,6 +255,7 @@
 		};
 		1391CC0F2F8E456700B3B198 /* Debug */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = 2A31AC002F8E456500B3B198 /* Base.xcconfig */;
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -253,14 +264,18 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_TEAM = 5AT3P29RBZ;
-				ENABLE_APP_SANDBOX = YES;
+				ENABLE_APP_SANDBOX = NO;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_CFBundleDisplayName = "Zoom App";
 				INFOPLIST_KEY_CFBundleName = "Zoom App";
+				INFOPLIST_KEY_NSAppleEventsUsageDescription = "Zoom App uses Apple Events only as required by the embedded Zoom Meeting SDK.";
+				INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zoom App may use Bluetooth audio devices during Zoom meetings.";
+				INFOPLIST_KEY_NSCameraUsageDescription = "Zoom App uses the camera during in-app Zoom meetings.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App records microphone audio during meetings so you can replay them in Ai Companion.";
+				INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Zoom App may access photos when sharing in an in-app Zoom meeting.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Zoom App captures system audio during meetings (when allowed) so recordings include computer sound in Ai Companion.";
 				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
@@ -276,6 +291,7 @@
 				SWIFT_APPROACHABLE_CONCURRENCY = YES;
 				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
 				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_OBJC_BRIDGING_HEADER = zoom_app/zoom_app-Bridging-Header.h;
 				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
 				SWIFT_VERSION = 5.0;
 			};
@@ -283,6 +299,7 @@
 		};
 		1391CC102F8E456700B3B198 /* Release */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = 2A31AC002F8E456500B3B198 /* Base.xcconfig */;
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -291,14 +308,18 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_TEAM = 5AT3P29RBZ;
-				ENABLE_APP_SANDBOX = YES;
+				ENABLE_APP_SANDBOX = NO;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_CFBundleDisplayName = "Zoom App";
 				INFOPLIST_KEY_CFBundleName = "Zoom App";
+				INFOPLIST_KEY_NSAppleEventsUsageDescription = "Zoom App uses Apple Events only as required by the embedded Zoom Meeting SDK.";
+				INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zoom App may use Bluetooth audio devices during Zoom meetings.";
+				INFOPLIST_KEY_NSCameraUsageDescription = "Zoom App uses the camera during in-app Zoom meetings.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App records microphone audio during meetings so you can replay them in Ai Companion.";
+				INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Zoom App may access photos when sharing in an in-app Zoom meeting.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Zoom App captures system audio during meetings (when allowed) so recordings include computer sound in Ai Companion.";
 				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
@@ -314,6 +335,7 @@
 				SWIFT_APPROACHABLE_CONCURRENCY = YES;
 				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
 				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_OBJC_BRIDGING_HEADER = zoom_app/zoom_app-Bridging-Header.h;
 				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
 				SWIFT_VERSION = 5.0;
 			};

+ 7 - 1
zoom_app/AppDelegate.swift

@@ -55,7 +55,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
     }
 
     func applicationWillTerminate(_ aNotification: Notification) {
-        // Insert code here to tear down your application
+        guard MeetingRecordingManager.shared.isRecording else { return }
+        let semaphore = DispatchSemaphore(value: 0)
+        Task {
+            try? await MeetingRecordingManager.shared.stopRecording()
+            semaphore.signal()
+        }
+        _ = semaphore.wait(timeout: .now() + 30)
     }
 
     func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {

+ 122 - 0
zoom_app/MeetingRecordingManager.swift

@@ -86,9 +86,44 @@ final class MeetingRecordingManager: NSObject {
     // Output
     private var outputAudioURL: URL?
 
+    // MARK: - Automatic stop (meeting left / ended)
+
+    private static let automaticStopPollInterval: TimeInterval = 4
+    /// Do not treat "no meeting UI" as leave until the user has had time to connect.
+    private static let automaticStopGraceInterval: TimeInterval = 50
+    /// 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 = 5
+    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
+
+    private var nativeZoomMeetingEndObserver: NSObjectProtocol?
+
     private override init() {
         super.init()
         loadRecordingsFromDisk()
+        nativeZoomMeetingEndObserver = NotificationCenter.default.addObserver(
+            forName: .zoomMeetingSDKNativeSessionEnded,
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            guard let self else { return }
+            Task {
+                try? await self.stopRecording()
+            }
+        }
+    }
+
+    deinit {
+        if let nativeZoomMeetingEndObserver {
+            NotificationCenter.default.removeObserver(nativeZoomMeetingEndObserver)
+        }
     }
 
     // MARK: - Public API
@@ -176,9 +211,15 @@ final class MeetingRecordingManager: NSObject {
 
         isRecording = true
         NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
+        await MainActor.run {
+            self.startAutomaticStopMonitoringOnMain()
+        }
     }
 
     func stopRecording() async throws {
+        await MainActor.run {
+            self.stopAutomaticStopMonitoringOnMain()
+        }
         guard isRecording else { throw RecordingError.notRecording }
         guard let id = recordingID, let startedAt = recordingStartedAt, let outURL = outputAudioURL else {
             throw RecordingError.exportFailed
@@ -224,6 +265,87 @@ final class MeetingRecordingManager: NSObject {
         NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
     }
 
+    private func startAutomaticStopMonitoringOnMain() {
+        assert(Thread.isMainThread)
+        stopAutomaticStopMonitoringOnMain()
+        automaticStopConsecutiveMisses = 0
+        automaticStopHaveSeenCallUI = 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
+    }
+
+    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
+    }
+
+    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 = ZoomMeetingPresenceWatcher.callPresenceLikelyVisible()
+        if visible {
+            automaticStopHaveSeenCallUI = true
+            automaticStopConsecutiveMisses = 0
+            return
+        }
+
+        // Left meeting / no meeting UI.
+        if automaticStopHaveSeenCallUI {
+            automaticStopConsecutiveMisses += 1
+        } else if elapsed >= Self.automaticStopGraceInterval + Self.automaticStopBlindPresenceDelay {
+            // Never got a positive presence (e.g. empty window titles); still stop after sustained "no UI".
+            automaticStopConsecutiveMisses += 1
+        } else {
+            return
+        }
+
+        guard automaticStopConsecutiveMisses >= Self.automaticStopMissThreshold else { return }
+
+        Task { [weak self] in
+            guard let self else { return }
+            try? await self.stopRecording()
+        }
+    }
+
+    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 {

+ 44 - 4
zoom_app/ViewController.swift

@@ -1648,13 +1648,16 @@ class ViewController: NSViewController {
 
     @objc private func joinMeetingSubmitTapped() {
         guard let segment = joinModeSegment else { return }
+        let displayName = joinMeetingDisplayNameField?.stringValue
         if segment.selectedSegment == 0 {
             let raw = joinURLField?.stringValue ?? ""
             guard let url = parseZoomJoinURLFromUserInput(raw) else {
                 showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
                 return
             }
-            openZoomMeetingInDefaultBrowser(url, meetingDisplayName: joinMeetingDisplayNameField?.stringValue)
+            Task { @MainActor in
+                await self.openZoomMeetingPreferringNativeSDKIfPossible(url, meetingDisplayName: displayName)
+            }
         } else {
             let idRaw = joinMeetingIDField?.stringValue ?? ""
             let digits = idRaw.filter(\.isNumber)
@@ -1667,7 +1670,9 @@ class ViewController: NSViewController {
                 showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
                 return
             }
-            openZoomMeetingInDefaultBrowser(url, meetingDisplayName: joinMeetingDisplayNameField?.stringValue)
+            Task { @MainActor in
+                await self.openZoomMeetingPreferringNativeSDKIfPossible(url, meetingDisplayName: displayName)
+            }
         }
     }
 
@@ -1702,9 +1707,18 @@ class ViewController: NSViewController {
     }
 
     /// Opens non-sign-in web content in the system default browser.
-    /// For Zoom meeting join/start links, also begins local audio recording (when not already recording), same as the Join flow.
+    /// For Zoom meeting join/start links, prefers the embedded Zoom Meeting SDK when vendored; otherwise opens the browser.
+    /// Local recording starts when a meeting session begins (embedded SDK or browser path).
     @MainActor
     private func openWebURLPreferringInApp(_ url: URL, zoomMeetingDisplayName: String? = nil) {
+        if isZoomMeetingJoinOrStartURL(url),
+           ZoomMeetingURLParser.parseJoinURL(url) != nil,
+           ZoomMeetingSDKJoinCoordinator.shared.isNativeSDKAvailable {
+            Task { @MainActor in
+                await self.openZoomMeetingPreferringNativeSDKIfPossible(url, meetingDisplayName: zoomMeetingDisplayName)
+            }
+            return
+        }
         _ = openURLInDefaultBrowser(url)
         beginZoomMeetingRecordingIfAppropriate(sourceURL: url, meetingDisplayName: zoomMeetingDisplayName)
     }
@@ -1739,6 +1753,28 @@ class ViewController: NSViewController {
         }
     }
 
+    @MainActor
+    private func openZoomMeetingPreferringNativeSDKIfPossible(_ url: URL, meetingDisplayName: String?) async {
+        guard ZoomMeetingSDKJoinCoordinator.shared.isNativeSDKAvailable,
+              let parsed = ZoomMeetingURLParser.parseJoinURL(url) else {
+            openZoomMeetingInDefaultBrowser(url, meetingDisplayName: meetingDisplayName)
+            return
+        }
+        let display = ZoomMeetingSDKJoinCoordinator.normalizedJoinDisplayName(meetingDisplayName)
+        do {
+            try await ZoomMeetingSDKJoinCoordinator.shared.join(
+                meetingNumber: parsed.meetingNumber,
+                password: parsed.password,
+                displayName: display,
+                presentingWindow: view.window
+            )
+            joinMeetingWindow?.performClose(nil)
+            beginZoomMeetingRecordingIfAppropriate(sourceURL: url, meetingDisplayName: meetingDisplayName)
+        } catch {
+            openZoomMeetingInDefaultBrowser(url, meetingDisplayName: meetingDisplayName)
+        }
+    }
+
     @MainActor
     private func openZoomMeetingInDefaultBrowser(_ url: URL, meetingDisplayName: String? = nil) {
         let opened = openURLInDefaultBrowser(url)
@@ -6782,7 +6818,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). Files are stored in the app sandbox—use Show folder to open the Recordings location in Finder."
+                "Recording… Stops automatically when the in-app Zoom meeting ends, or when the meeting window disappears in the browser/desktop client (after a short delay). Use Stop & save or the menu bar to end early. Files live in the app sandbox—Show folder opens Recordings in Finder."
         } else {
             aiCompanionStatusLabel?.stringValue =
                 "Start or join a meeting from this app to record. Saved audio is stored in the app sandbox; use Show folder to open the Recordings folder in Finder."
@@ -8698,6 +8734,10 @@ final class ZoomOAuthService: NSObject {
             "meeting:read:list_meetings",
             "meeting:write:meeting",
             "meeting:read:summary",
+            "user_zak:read",
+            "user_zak:read:user",
+            "user:read",
+            "user:read:user",
             "zoomapp:inmeeting"
         ]
 

+ 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")
+    }
+}

+ 30 - 0
zoom_app/ZoomMeetingSDKBridge.h

@@ -0,0 +1,30 @@
+#import <Foundation/Foundation.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// Bridges the Zoom Meeting SDK (Objective-C) to Swift. When the SDK is not vendored
+/// (`__has_include(<ZoomSDK/ZoomSDK.h>)` is false), all operations no-op / fail gracefully.
+@interface ZoomMeetingSDKBridge : NSObject
+
++ (instancetype)shared;
+
+/// Whether this build compiled the real SDK sources (`ZoomSDK.framework` present at compile time).
++ (BOOL)isCompiledWithNativeMeetingSDK;
+
+/// Initialize the SDK (idempotent). Safe to call on the main thread only.
+- (void)initSDKIfNeeded;
+
+/// Authenticate with a Meeting SDK JWT, then join the meeting. `completion` is always invoked on the main queue.
+- (void)authenticateWithJWT:(NSString *)jwt
+              meetingNumber:(long long)meetingNumber
+                   password:(NSString * _Nullable)password
+                displayName:(NSString *)displayName
+                        zak:(NSString * _Nullable)zak
+                 completion:(void (^)(BOOL success, NSString * _Nullable errorMessage))completion;
+
+@end
+
+NS_ASSUME_NONNULL_END
+
+/// Posted on the main thread when the embedded meeting session ends (user left or meeting ended).
+FOUNDATION_EXPORT NSNotificationName _Nonnull const ZoomMeetingSDKNativeSessionEndedNotification;

+ 182 - 0
zoom_app/ZoomMeetingSDKBridge.m

@@ -0,0 +1,182 @@
+#import "ZoomMeetingSDKBridge.h"
+
+#if __has_include(<ZoomSDK/ZoomSDK.h>)
+#import <ZoomSDK/ZoomSDK.h>
+#define ZOOM_MEETING_SDK 1
+#else
+#define ZOOM_MEETING_SDK 0
+#endif
+
+NSNotificationName const ZoomMeetingSDKNativeSessionEndedNotification = @"ZoomMeetingSDK.NativeSessionEnded";
+
+@interface ZoomMeetingSDKBridge ()
+#if ZOOM_MEETING_SDK
+<ZoomSDKAuthDelegate, ZoomSDKMeetingServiceDelegate>
+#endif
+@property (nonatomic, copy, nullable) void (^authJoinCompletion)(BOOL success, NSString * _Nullable message);
+@property (nonatomic, assign) BOOL wasInMeeting;
+@property (nonatomic, assign) BOOL sdkInitialized;
+@property (nonatomic, copy, nullable) NSString *pendingJWT;
+@property (nonatomic, assign) long long pendingMeetingNumber;
+@property (nonatomic, copy, nullable) NSString *pendingPassword;
+@property (nonatomic, copy, nullable) NSString *pendingDisplayName;
+@property (nonatomic, copy, nullable) NSString *pendingZAK;
+@end
+
+@implementation ZoomMeetingSDKBridge
+
++ (instancetype)shared {
+    static ZoomMeetingSDKBridge *instance;
+    static dispatch_once_t onceToken;
+    dispatch_once(&onceToken, ^{
+        instance = [ZoomMeetingSDKBridge new];
+    });
+    return instance;
+}
+
++ (BOOL)isCompiledWithNativeMeetingSDK {
+#if ZOOM_MEETING_SDK
+    return YES;
+#else
+    return NO;
+#endif
+}
+
+- (void)initSDKIfNeeded {
+#if ZOOM_MEETING_SDK
+    if (self.sdkInitialized) { return; }
+    ZoomSDKInitParams *params = [[ZoomSDKInitParams alloc] init];
+    params.zoomDomain = @"https://zoom.us";
+    params.needCustomizedUI = NO;
+    params.enableLog = YES;
+    ZoomSDKError initError = [[ZoomSDK sharedSDK] initSDKWithParams:params];
+    if (initError != ZoomSDKError_Success) {
+        NSLog(@"ZoomMeetingSDKBridge: initSDKWithParams failed (%d)", (int)initError);
+        return;
+    }
+    [[ZoomSDK sharedSDK] getMeetingService].delegate = self;
+    [[ZoomSDK sharedSDK] getAuthService].delegate = self;
+    self.sdkInitialized = YES;
+#endif
+}
+
+- (void)authenticateWithJWT:(NSString *)jwt
+              meetingNumber:(long long)meetingNumber
+                   password:(NSString *)password
+                displayName:(NSString *)displayName
+                        zak:(NSString *)zak
+                 completion:(void (^)(BOOL, NSString * _Nullable))completion {
+#if !ZOOM_MEETING_SDK
+    if (completion) {
+        completion(NO, @"Zoom Meeting SDK is not linked. Add Vendor/ZoomSDK from the Zoom Marketplace and set ZOOM_SDK_LDFLAGS in Config/Base.xcconfig. See Vendor/ZoomSDK/README.md.");
+    }
+    return;
+#else
+    NSAssert([NSThread isMainThread], @"ZoomMeetingSDKBridge must be used on the main thread");
+    [self initSDKIfNeeded];
+    if (self.sdkInitialized == NO) {
+        if (completion) { completion(NO, @"Zoom SDK failed to initialize."); }
+        return;
+    }
+
+    self.authJoinCompletion = completion;
+    self.pendingJWT = jwt;
+    self.pendingMeetingNumber = meetingNumber;
+    self.pendingPassword = (password != nil && password.length > 0) ? password : @"";
+    self.pendingDisplayName = displayName.length ? displayName : @"Guest";
+    self.pendingZAK = zak;
+
+    ZoomSDKAuthContext *ctx = [[ZoomSDKAuthContext alloc] init];
+    ctx.jwtToken = jwt;
+
+    ZoomSDKError authErr = [[[ZoomSDK sharedSDK] getAuthService] sdkAuth:ctx];
+    if (authErr != ZoomSDKError_Success) {
+        [self finishAuthJoin:NO message:[NSString stringWithFormat:@"sdkAuth returned %d", (int)authErr]];
+    }
+#endif
+}
+
+#if ZOOM_MEETING_SDK
+
+- (void)finishAuthJoin:(BOOL)ok message:(NSString *)message {
+    void (^block)(BOOL, NSString *) = self.authJoinCompletion;
+    self.authJoinCompletion = nil;
+    self.pendingJWT = nil;
+    self.pendingPassword = nil;
+    self.pendingDisplayName = nil;
+    self.pendingZAK = nil;
+    if (block) {
+        block(ok, ok ? nil : message);
+    }
+}
+
+- (void)onZoomSDKAuthReturn:(ZoomSDKAuthError)returnValue {
+    if (returnValue != ZoomSDKAuthError_Success) {
+        [self finishAuthJoin:NO message:[NSString stringWithFormat:@"Zoom SDK auth failed (code %ld). Ensure Meeting SDK is enabled for this OAuth client and the JWT is valid.", (long)returnValue]];
+        return;
+    }
+
+    ZoomSDKJoinMeetingElements *params = [[ZoomSDKJoinMeetingElements alloc] init];
+    params.meetingNumber = self.pendingMeetingNumber;
+    params.password = self.pendingPassword ?: @"";
+    params.displayName = self.pendingDisplayName ?: @"Guest";
+    if (self.pendingZAK.length) {
+        params.zak = self.pendingZAK;
+    }
+
+    ZoomSDKMeetingService *meeting = [[ZoomSDK sharedSDK] getMeetingService];
+    ZoomSDKError joinErr = [meeting joinMeeting:params];
+    if (joinErr != ZoomSDKError_Success) {
+        [self finishAuthJoin:NO message:[NSString stringWithFormat:@"joinMeeting failed (code %d)", (int)joinErr]];
+        return;
+    }
+
+    [self finishAuthJoin:YES message:nil];
+}
+
+- (void)onZoomAuthIdentityExpired {
+    [self finishAuthJoin:NO message:@"Zoom SDK JWT or identity expired. Try joining again."];
+}
+
+- (void)onMeetingStatusChange:(ZoomSDKMeetingStatus)state
+                 meetingError:(ZoomSDKMeetingError)error
+                     EndReason:(EndMeetingReason)reason {
+    // Treat any in-join / in-meeting / disconnecting state as "session active" so we still fire
+    // NativeSessionEnded when the user leaves from the waiting room, host never starts, etc.
+    // Recording starts right after joinMeeting returns; callbacks may still be Connecting / InWaitingRoom.
+    switch (state) {
+        case ZoomSDKMeetingStatus_Connecting:
+        case ZoomSDKMeetingStatus_WaitingForHost:
+        case ZoomSDKMeetingStatus_InMeeting:
+        case ZoomSDKMeetingStatus_Reconnecting:
+        case ZoomSDKMeetingStatus_AudioReady:
+        case ZoomSDKMeetingStatus_InWaitingRoom:
+        case ZoomSDKMeetingStatus_OtherMeetingInProgress:
+        case ZoomSDKMeetingStatus_Webinar_Promote:
+        case ZoomSDKMeetingStatus_Webinar_Depromote:
+        case ZoomSDKMeetingStatus_Join_Breakout_Room:
+        case ZoomSDKMeetingStatus_Leave_Breakout_Room:
+        case ZoomSDKMeetingStatus_Disconnecting:
+            self.wasInMeeting = YES;
+            break;
+        case ZoomSDKMeetingStatus_Ended:
+        case ZoomSDKMeetingStatus_Failed:
+            if (self.wasInMeeting) {
+                self.wasInMeeting = NO;
+                [[NSNotificationCenter defaultCenter] postNotificationName:ZoomMeetingSDKNativeSessionEndedNotification object:nil];
+            }
+            break;
+        case ZoomSDKMeetingStatus_Idle:
+            if (self.wasInMeeting) {
+                self.wasInMeeting = NO;
+                [[NSNotificationCenter defaultCenter] postNotificationName:ZoomMeetingSDKNativeSessionEndedNotification object:nil];
+            }
+            break;
+        default:
+            break;
+    }
+}
+
+#endif
+
+@end

+ 48 - 0
zoom_app/ZoomMeetingSDKJWT.swift

@@ -0,0 +1,48 @@
+import CryptoKit
+import Foundation
+
+enum ZoomMeetingSDKJWTError: Error {
+    case invalidUTF8
+    case jsonSerializationFailed
+}
+
+/// Builds a [Meeting SDK JWT](https://developers.zoom.us/docs/meeting-sdk/auth/) from the OAuth client id/secret
+/// (same credentials as the in-app Zoom OAuth app when Meeting SDK is enabled on that app).
+enum ZoomMeetingSDKJWT {
+
+    static func makeMeetingSDKToken(clientId: String, clientSecret: String) throws -> String {
+        let header: [String: String] = ["alg": "HS256", "typ": "JWT"]
+        let iat = Int(Date().timeIntervalSince1970) - 30
+        let exp = iat + 60 * 60 * 2
+        let payload: [String: Any] = [
+            "appKey": clientId,
+            "iat": iat,
+            "exp": exp,
+            "tokenExp": exp
+        ]
+        guard let headerData = try? JSONSerialization.data(withJSONObject: header, options: [.sortedKeys]),
+              let payloadData = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]),
+              let headerString = String(data: headerData, encoding: .utf8),
+              let payloadString = String(data: payloadData, encoding: .utf8) else {
+            throw ZoomMeetingSDKJWTError.jsonSerializationFailed
+        }
+
+        let headerPart = base64URLEncode(headerString.data(using: .utf8) ?? Data())
+        let payloadPart = base64URLEncode(payloadString.data(using: .utf8) ?? Data())
+        let signingInput = "\(headerPart).\(payloadPart)"
+        guard let signingData = signingInput.data(using: .utf8) else {
+            throw ZoomMeetingSDKJWTError.invalidUTF8
+        }
+        let key = SymmetricKey(data: Data(clientSecret.utf8))
+        let mac = HMAC<SHA256>.authenticationCode(for: signingData, using: key)
+        let signaturePart = base64URLEncode(Data(mac))
+        return "\(signingInput).\(signaturePart)"
+    }
+
+    private static func base64URLEncode(_ data: Data) -> String {
+        data.base64EncodedString()
+            .replacingOccurrences(of: "+", with: "-")
+            .replacingOccurrences(of: "/", with: "_")
+            .replacingOccurrences(of: "=", with: "")
+    }
+}

+ 103 - 0
zoom_app/ZoomMeetingSDKJoinCoordinator.swift

@@ -0,0 +1,103 @@
+import AppKit
+import Foundation
+
+/// Orchestrates Meeting SDK JWT auth + join using the vendored Zoom Meeting SDK (see `Vendor/ZoomSDK/README.md`).
+@MainActor
+final class ZoomMeetingSDKJoinCoordinator {
+    static let shared = ZoomMeetingSDKJoinCoordinator()
+
+    private init() {}
+
+    var isNativeSDKAvailable: Bool {
+        ZoomMeetingSDKBridge.isCompiledWithNativeMeetingSDK()
+            && ZoomOAuthService.shared.configuredClientId() != nil
+            && ZoomOAuthService.shared.configuredClientSecret() != nil
+    }
+
+    static func normalizedJoinDisplayName(_ raw: String?) -> String {
+        let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        return trimmed.isEmpty ? "Guest" : trimmed
+    }
+
+    func join(
+        meetingNumber: Int64,
+        password: String?,
+        displayName: String,
+        presentingWindow: NSWindow?
+    ) async throws {
+        let oauth = ZoomOAuthService.shared
+        guard let clientId = oauth.configuredClientId(),
+              let clientSecret = oauth.configuredClientSecret() else {
+            throw NSError(domain: "ZoomMeetingSDK", code: 1, userInfo: [NSLocalizedDescriptionKey: "Zoom OAuth client id/secret are not configured."])
+        }
+
+        let jwt = try ZoomMeetingSDKJWT.makeMeetingSDKToken(clientId: clientId, clientSecret: clientSecret)
+
+        var zak: String?
+        if let window = presentingWindow {
+            do {
+                let access = try await oauth.validAccessToken(
+                    presentingWindow: window,
+                    requiringAnyOf: [
+                        "user_zak:read",
+                        "user_zak:read:user",
+                        "user:read",
+                        "user:read:user"
+                    ]
+                )
+                zak = try await Self.fetchZAK(accessToken: access)
+            } catch {
+                zak = nil
+            }
+        }
+
+        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+            ZoomMeetingSDKBridge.shared().authenticate(
+                withJWT: jwt,
+                meetingNumber: meetingNumber,
+                password: password,
+                displayName: displayName,
+                zak: zak
+            ) { success, message in
+                if success {
+                    continuation.resume()
+                } else {
+                    let err = NSError(
+                        domain: "ZoomMeetingSDK",
+                        code: 2,
+                        userInfo: [NSLocalizedDescriptionKey: message ?? "Zoom Meeting SDK join failed."]
+                    )
+                    continuation.resume(throwing: err)
+                }
+            }
+        }
+    }
+
+    private static func fetchZAK(accessToken: String) async throws -> String {
+        var components = URLComponents(string: "https://api.zoom.us/v2/users/me/token")!
+        components.queryItems = [URLQueryItem(name: "type", value: "zak")]
+        guard let url = components.url else {
+            throw URLError(.badURL)
+        }
+        var request = URLRequest(url: url)
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw URLError(.badServerResponse)
+        }
+        guard (200 ... 299).contains(http.statusCode) else {
+            let body = String(data: data, encoding: .utf8) ?? ""
+            throw NSError(domain: "ZoomMeetingSDK", code: 3, userInfo: [NSLocalizedDescriptionKey: "ZAK request failed (\(http.statusCode)): \(body)"])
+        }
+        let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+        guard let token = obj?["token"] as? String, token.isEmpty == false else {
+            throw NSError(domain: "ZoomMeetingSDK", code: 4, userInfo: [NSLocalizedDescriptionKey: "ZAK missing in API response."])
+        }
+        return token
+    }
+}
+
+extension Notification.Name {
+    /// Mirrors `ZoomMeetingSDKNativeSessionEndedNotification` from `ZoomMeetingSDKBridge`.
+    static let zoomMeetingSDKNativeSessionEnded = Notification.Name("ZoomMeetingSDK.NativeSessionEnded")
+}

+ 44 - 0
zoom_app/ZoomMeetingURLParser.swift

@@ -0,0 +1,44 @@
+import Foundation
+
+enum ZoomMeetingURLParser {
+
+    struct JoinComponents: Sendable {
+        let meetingNumber: Int64
+        let password: String?
+    }
+
+    /// Extracts a numeric meeting id and optional `pwd` query for common Zoom join URLs.
+    static func parseJoinURL(_ url: URL) -> JoinComponents? {
+        guard let host = url.host?.lowercased(), host.contains("zoom.") else { return nil }
+        if host.hasPrefix("api.") { return nil }
+
+        let path = url.path
+        let idString: String?
+
+        if let range = path.range(of: "/j/", options: .caseInsensitive) {
+            let tail = path[range.upperBound...]
+            idString = String(tail).split(separator: "?").first.map(String.init)
+        } else if let range = path.range(of: "/wc/join/", options: .caseInsensitive) {
+            let tail = path[range.upperBound...]
+            idString = String(tail).split(separator: "?").first.map(String.init)
+        } else if let range = path.range(of: "/join/", options: .caseInsensitive) {
+            let tail = path[range.upperBound...]
+            idString = String(tail).split(separator: "?").first.map(String.init)
+        } else {
+            idString = nil
+        }
+
+        guard let rawId = idString else { return nil }
+        let digits = rawId.filter(\.isNumber)
+        guard digits.isEmpty == false, let number = Int64(digits) else { return nil }
+
+        let pwd = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+            .queryItems?
+            .first(where: { $0.name.lowercased() == "pwd" })?
+            .value
+        let trimmedPwd = pwd?.trimmingCharacters(in: .whitespacesAndNewlines)
+        let password = (trimmedPwd?.isEmpty == false) ? trimmedPwd : nil
+
+        return JoinComponents(meetingNumber: number, password: password)
+    }
+}

+ 1 - 0
zoom_app/zoom_app-Bridging-Header.h

@@ -0,0 +1 @@
+#import "ZoomMeetingSDKBridge.h"

+ 0 - 2
zoom_app/zoom_app.entitlements

@@ -2,8 +2,6 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
-	<key>com.apple.security.app-sandbox</key>
-	<true/>
 	<key>com.apple.security.device.audio-input</key>
 	<true/>
 	<key>com.apple.security.network.client</key>