ソースを参照

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

+ 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]) {