Jelajahi Sumber

Fix Zoom/Google OAuth window sizing and sign-in UI reset

Align the in-app OAuth NSWindow content rect with the host window using
shared AppWindowMetrics (1020×690) and frameRect(forContentRect:).

Attach OAuth as a child window, reuse fixed content min/max after layout.

When the user closes the OAuth window, resume the pending continuation with
authenticationCancelled so the main thread can reset the top bar and Google
button instead of waiting for the two-minute timeout.

Map cancellation through ZoomOAuthService, skip error alerts for cancel on
primary sign-in and schedule flows, and reset the Google chip label in
resetLoginSigningInState.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 bulan lalu
induk
melakukan
cd8da569db
1 mengubah file dengan 111 tambahan dan 26 penghapusan
  1. 111 26
      zoom_app/ViewController.swift

+ 111 - 26
zoom_app/ViewController.swift

@@ -10,6 +10,12 @@ import CryptoKit
 import StoreKit
 import WebKit
 
+/// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
+private enum AppWindowMetrics {
+    static let defaultContentWidth: CGFloat = 1020
+    static let defaultContentHeight: CGFloat = 690
+}
+
 class ViewController: NSViewController {
     enum HomeSection: String {
         case home = "Home"
@@ -532,7 +538,9 @@ class ViewController: NSViewController {
     override func viewDidAppear() {
         super.viewDidAppear()
         if let window = view.window {
-            window.setContentSize(NSSize(width: 1020, height: 690))
+            window.setContentSize(
+                NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
+            )
             applyWindowBackgroundForCurrentTheme(window)
             // Use full-size content view so custom top chrome sits in the titlebar region.
             window.titleVisibility = .hidden
@@ -676,9 +684,8 @@ class ViewController: NSViewController {
                 }
             } catch {
                 await MainActor.run {
-                    self.isSigningIn = false
-                    self.googleButton?.title = "G"
-                    self.googleButton?.isEnabled = true
+                    self.resetLoginSigningInState()
+                    if case GoogleOAuthError.authenticationCancelled = error { return }
                     self.showSimpleError("Google login failed", error: error)
                 }
             }
@@ -714,6 +721,7 @@ class ViewController: NSViewController {
             } catch {
                 await MainActor.run {
                     self.resetLoginSigningInState()
+                    if case ZoomOAuthError.authenticationCancelled = error { return }
                     self.showSimpleError("Zoom sign-in failed", error: error)
                 }
             }
@@ -1023,7 +1031,8 @@ class ViewController: NSViewController {
                     await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
                     return
                 }
-                let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
+                let oauthParent = await MainActor.run { self.scheduleMeetingWindow ?? self.view.window }
+                let token = try await zoomOAuth.validAccessToken(presentingWindow: oauthParent)
                 let result = try await createZoomMeeting(
                     accessToken: token,
                     topic: topic,
@@ -1063,6 +1072,8 @@ class ViewController: NSViewController {
                         let seconds = max(retryAfter ?? 300, 30)
                         let minutes = Int(ceil(Double(seconds) / 60.0))
                         self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
+                    } else if case ZoomOAuthError.authenticationCancelled = error {
+                        // User closed the sign-in window; no alert.
                     } else {
                         self.showSimpleError("Could not schedule", error: error)
                     }
@@ -2046,6 +2057,7 @@ class ViewController: NSViewController {
     @MainActor
     private func resetLoginSigningInState() {
         isSigningIn = false
+        googleButton?.title = "G"
         updateTopBarAuthButton()
         googleButton?.isEnabled = true
     }
@@ -6598,6 +6610,7 @@ enum ZoomOAuthError: Error {
     case rateLimited(retryAfterSeconds: Int?)
     case unableToOpenBrowser
     case authenticationTimedOut
+    case authenticationCancelled
 }
 
 final class ZoomOAuthTokenStore {
@@ -6690,7 +6703,7 @@ final class ZoomOAuthService: NSObject {
     }
 
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
-        _ = presentingWindow
+        guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
         let oauthWindow = await MainActor.run { makeOAuthWindowController() }
         guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
         guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
@@ -6709,7 +6722,11 @@ final class ZoomOAuthService: NSObject {
         ]
         guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
         do {
-            let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
+            let callbackURL = try await oauthWindow.authenticate(
+                startURL: authURL,
+                callbackURLPrefix: redirectURI,
+                presentingWindow: presentingWindow
+            )
             let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
             guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
             guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
@@ -6718,6 +6735,8 @@ final class ZoomOAuthService: NSObject {
             return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
         } catch GoogleOAuthError.authenticationTimedOut {
             throw ZoomOAuthError.authenticationTimedOut
+        } catch GoogleOAuthError.authenticationCancelled {
+            throw ZoomOAuthError.authenticationCancelled
         } catch {
             throw error
         }
@@ -6841,6 +6860,7 @@ enum GoogleOAuthError: Error {
     case tokenExchangeFailed(String)
     case unableToOpenBrowser
     case authenticationTimedOut
+    case authenticationCancelled
 }
 
 final class GoogleOAuthService: NSObject {
@@ -6881,7 +6901,7 @@ final class GoogleOAuthService: NSObject {
     }
 
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
-        _ = presentingWindow
+        guard let presentingWindow else { throw GoogleOAuthError.unableToOpenBrowser }
         let oauthWindow = await MainActor.run { makeOAuthWindowController() }
         let codeVerifier = Self.randomURLSafeString(length: 64)
         let codeChallenge = Self.pkceChallenge(for: codeVerifier)
@@ -6903,7 +6923,11 @@ final class GoogleOAuthService: NSObject {
         ]
 
         guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
-        let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
+        let callbackURL = try await oauthWindow.authenticate(
+            startURL: authURL,
+            callbackURLPrefix: redirectURI,
+            presentingWindow: presentingWindow
+        )
         let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
         guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
         guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
@@ -7029,56 +7053,114 @@ private final class OAuthWebViewContainerView: NSView {
     }
 }
 
+/// Separate titled window matching the app’s default content size (see `AppWindowMetrics`); when a parent window is provided, the OAuth content area is aligned to the parent’s content rect.
 @MainActor
-private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate {
+private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
     private let webView: WKWebView
     private var callbackPrefix: String?
     private var callbackContinuation: CheckedContinuation<URL, Error>?
+    private weak var parentWindowForChild: NSWindow?
+    /// When true, `windowWillClose` does not treat the close as user cancellation (we closed after success/timeout/error).
+    private var suppressUserCloseCancellation = false
 
     init(title: String) {
         let configuration = WKWebViewConfiguration()
         configuration.websiteDataStore = .nonPersistent()
         self.webView = WKWebView(frame: .zero, configuration: configuration)
         let container = OAuthWebViewContainerView(webView: webView)
-        let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
+        let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
+        let window = NSWindow(contentRect: NSRect(origin: .zero, size: contentSize), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false)
         window.title = title
         window.contentView = container
+        window.contentMinSize = contentSize
+        window.contentMaxSize = contentSize
+        window.isReleasedWhenClosed = false
         super.init(window: window)
+        window.delegate = self
         webView.navigationDelegate = self
     }
 
     @available(*, unavailable) required init?(coder: NSCoder) { nil }
 
-    func authenticate(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double = 120) async throws -> URL {
+    func windowWillClose(_ notification: Notification) {
+        guard suppressUserCloseCancellation == false else { return }
+        removeFromParentIfNeeded()
+        webView.stopLoading()
+        if let continuation = callbackContinuation {
+            callbackContinuation = nil
+            continuation.resume(throwing: GoogleOAuthError.authenticationCancelled)
+        }
+    }
+
+    func authenticate(startURL: URL, callbackURLPrefix: String, presentingWindow: NSWindow?, timeoutSeconds: Double = 120) async throws -> URL {
         self.callbackPrefix = callbackURLPrefix
+        parentWindowForChild = presentingWindow
+        applyFixedOAuthFrame(presentingWindow: presentingWindow)
+        if let parent = presentingWindow, let w = window {
+            parent.addChildWindow(w, ordered: .above)
+        }
         showWindow(nil)
         window?.makeKeyAndOrderFront(nil)
-        window?.center()
         webView.load(URLRequest(url: startURL))
 
-        return try await withThrowingTaskGroup(of: URL.self) { group in
-            group.addTask { [weak self] in
-                guard let self else { throw GoogleOAuthError.invalidCallbackURL }
-                return try await self.awaitCallback()
-            }
-            group.addTask {
-                try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
-                throw GoogleOAuthError.authenticationTimedOut
+        do {
+            return try await withThrowingTaskGroup(of: URL.self) { group in
+                group.addTask { [weak self] in
+                    guard let self else { throw GoogleOAuthError.invalidCallbackURL }
+                    return try await self.awaitCallback()
+                }
+                group.addTask {
+                    try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
+                    throw GoogleOAuthError.authenticationTimedOut
+                }
+                let url = try await group.next()!
+                group.cancelAll()
+                self.closeOAuthWindowAfterFlow()
+                return url
             }
-            let url = try await group.next()!
-            group.cancelAll()
-            self.webView.stopLoading()
-            self.close()
-            return url
+        } catch {
+            self.closeOAuthWindowAfterFlow()
+            throw error
         }
     }
 
+    private func closeOAuthWindowAfterFlow() {
+        suppressUserCloseCancellation = true
+        webView.stopLoading()
+        removeFromParentIfNeeded()
+        close()
+        suppressUserCloseCancellation = false
+    }
+
     private func awaitCallback() async throws -> URL {
         try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
             callbackContinuation = continuation
         }
     }
 
+    private func removeFromParentIfNeeded() {
+        if let parent = parentWindowForChild, let w = window {
+            parent.removeChildWindow(w)
+        }
+        parentWindowForChild = nil
+    }
+
+    private func applyFixedOAuthFrame(presentingWindow: NSWindow?) {
+        guard let window else { return }
+        if let parent = presentingWindow {
+            let parentContent = parent.contentRect(forFrameRect: parent.frame)
+            let oauthFrame = window.frameRect(forContentRect: parentContent)
+            window.setFrame(oauthFrame, display: true)
+        } else {
+            let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
+            window.setContentSize(contentSize)
+            window.center()
+        }
+        let lockedContentSize = window.contentRect(forFrameRect: window.frame).size
+        window.contentMinSize = lockedContentSize
+        window.contentMaxSize = lockedContentSize
+    }
+
     func webView(_ webView: WKWebView,
                  decidePolicyFor navigationAction: WKNavigationAction,
                  decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
@@ -7105,6 +7187,7 @@ extension GoogleOAuthError: LocalizedError {
         case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
         case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
         case .authenticationTimedOut: return "Google sign-in timed out."
+        case .authenticationCancelled: return "Sign-in was cancelled."
         }
     }
 }
@@ -7133,6 +7216,8 @@ extension ZoomOAuthError: LocalizedError {
             return "Could not open the system browser for Zoom sign-in."
         case .authenticationTimedOut:
             return "Zoom sign-in timed out waiting for the browser redirect."
+        case .authenticationCancelled:
+            return "Zoom sign-in was cancelled."
         }
     }
 }