소스 검색

Fix Google account mismatch when opening Meet links

Route Google OAuth through the default browser and add account hints when opening Google URLs so Meet continues with the same signed-in account (or prompts login).

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 달 전
부모
커밋
889274c388
3개의 변경된 파일56개의 추가작업 그리고 27개의 파일을 삭제
  1. 51 24
      meetings_app/Auth/GoogleOAuthService.swift
  2. 2 1
      meetings_app/StatusBar/StatusBarController.swift
  3. 3 2
      meetings_app/ViewController.swift

+ 51 - 24
meetings_app/Auth/GoogleOAuthService.swift

@@ -55,6 +55,35 @@ final class GoogleOAuthService: NSObject {
     private let tokenStore = KeychainTokenStore()
     @MainActor private var inAppOAuthWindowController: InAppOAuthWindowController?
     private override init() {}
+    
+    private let lastSignedInEmailDefaultsKey = "google.oauth.lastSignedInEmail"
+    
+    func lastSignedInEmailHint() -> String? {
+        let value = UserDefaults.standard
+            .string(forKey: lastSignedInEmailDefaultsKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        guard let value, value.isEmpty == false else { return nil }
+        return value.lowercased()
+    }
+    
+    /// Wraps a Google URL so the default browser is nudged to use the same account as the app.
+    /// If the browser isn't signed into that account, Google will prompt to sign in.
+    func urlForPreferredGoogleAccountIfPossible(_ url: URL) -> URL {
+        guard let email = lastSignedInEmailHint() else { return url }
+        guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { return url }
+        guard let host = url.host?.lowercased(), host.isEmpty == false else { return url }
+        
+        // Only attempt for Google properties where account mixups are common.
+        let isGoogle = host == "google.com" || host.hasSuffix(".google.com")
+        guard isGoogle else { return url }
+        
+        var components = URLComponents(string: "https://accounts.google.com/AccountChooser")!
+        components.queryItems = [
+            URLQueryItem(name: "continue", value: url.absoluteString),
+            URLQueryItem(name: "Email", value: email)
+        ]
+        return components.url ?? url
+    }
 
     func configuredClientId() -> String? {
         let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -108,7 +137,12 @@ final class GoogleOAuthService: NSObject {
             let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
             throw GoogleOAuthError.tokenExchangeFailed(details)
         }
-        return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
+        let profile = try JSONDecoder().decode(GoogleUserProfile.self, from: data)
+        let cleaned = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
+        if let cleaned, cleaned.isEmpty == false {
+            UserDefaults.standard.set(cleaned.lowercased(), forKey: lastSignedInEmailDefaultsKey)
+        }
+        return profile
     }
 
     func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
@@ -140,9 +174,13 @@ final class GoogleOAuthService: NSObject {
         let loopback = try await OAuthLoopbackServer.start()
         defer { loopback.stop() }
         let redirectURI = loopback.redirectURI
+        
+        let lastEmailHint = UserDefaults.standard
+            .string(forKey: lastSignedInEmailDefaultsKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
 
         var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
-        components.queryItems = [
+        var queryItems: [URLQueryItem] = [
             URLQueryItem(name: "client_id", value: clientId),
             URLQueryItem(name: "redirect_uri", value: redirectURI),
             URLQueryItem(name: "response_type", value: "code"),
@@ -152,19 +190,21 @@ final class GoogleOAuthService: NSObject {
             URLQueryItem(name: "state", value: state),
             URLQueryItem(name: "code_challenge", value: codeChallenge),
             URLQueryItem(name: "code_challenge_method", value: "S256"),
-            URLQueryItem(name: "access_type", value: "offline")
+            URLQueryItem(name: "access_type", value: "offline"),
+            // Prefer the same Google account when multiple are present, and if none are signed in
+            // this forces Google to show sign-in UI.
+            URLQueryItem(name: "prompt", value: "select_account")
         ]
+        if let lastEmailHint, lastEmailHint.isEmpty == false {
+            queryItems.append(URLQueryItem(name: "login_hint", value: lastEmailHint))
+        }
+        components.queryItems = queryItems
 
         guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
         let opened = await MainActor.run { [self] in
-            openAuthURLInApp(authURL, presentingWindow: presentingWindow)
+            openAuthURLInDefaultBrowser(authURL)
         }
         guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
-        defer {
-            Task { @MainActor [weak self] in
-                self?.closeInAppOAuthWindow()
-            }
-        }
         let callbackURL = try await loopback.waitForCallback()
 
         guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
@@ -189,21 +229,8 @@ final class GoogleOAuthService: NSObject {
     }
 
     @MainActor
-    private func openAuthURLInApp(_ url: URL, presentingWindow: NSWindow?) -> Bool {
-        let controller: InAppOAuthWindowController
-        if let existing = inAppOAuthWindowController {
-            controller = existing
-        } else {
-            controller = InAppOAuthWindowController()
-            inAppOAuthWindowController = controller
-        }
-        controller.alignWithPresentingWindow(presentingWindow)
-        controller.load(url: url)
-        controller.showWindow(nil)
-        controller.window?.makeKeyAndOrderFront(nil)
-        controller.window?.orderFrontRegardless()
-        NSApp.activate(ignoringOtherApps: true)
-        return true
+    private func openAuthURLInDefaultBrowser(_ url: URL) -> Bool {
+        NSWorkspace.shared.open(url)
     }
 
     @MainActor

+ 2 - 1
meetings_app/StatusBar/StatusBarController.swift

@@ -130,7 +130,8 @@ final class StatusBarController: NSObject {
     @objc private func meetingClicked(_ sender: NSMenuItem) {
         guard let link = sender.representedObject as? String,
               let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
-        NSWorkspace.shared.open(url)
+        let routed = authService.urlForPreferredGoogleAccountIfPossible(url)
+        NSWorkspace.shared.open(routed)
     }
 
     @objc private func quitClicked() {

+ 3 - 2
meetings_app/ViewController.swift

@@ -1076,7 +1076,8 @@ private extension ViewController {
     }
 
     private func openInDefaultBrowser(url: URL) {
-        NSWorkspace.shared.open(url, configuration: NSWorkspace.OpenConfiguration()) { [weak self] _, error in
+        let routed = googleOAuth.urlForPreferredGoogleAccountIfPossible(url)
+        NSWorkspace.shared.open(routed, configuration: NSWorkspace.OpenConfiguration()) { [weak self] _, error in
             if let error {
                 DispatchQueue.main.async {
                     self?.showSimpleAlert(title: "Unable to open browser", message: error.localizedDescription)
@@ -9534,7 +9535,7 @@ private extension ViewController {
         let title = meeting?.title ?? "Scheduled Meeting"
         let shouldOpenMeeting = beginMeetingRecordingIfConsented(meetingTitle: title, meetingURL: url)
         guard shouldOpenMeeting else { return }
-        NSWorkspace.shared.open(url)
+        openInDefaultBrowser(url: url)
     }
 
     private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {