暫無描述

GoogleOAuthService.swift 26KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  1. import Foundation
  2. import CryptoKit
  3. import AppKit
  4. import Network
  5. import WebKit
  6. struct GoogleOAuthTokens: Codable, Equatable {
  7. var accessToken: String
  8. var refreshToken: String?
  9. var expiresAt: Date
  10. var scope: String?
  11. var tokenType: String?
  12. }
  13. struct GoogleUserProfile: Codable, Equatable {
  14. var name: String?
  15. var email: String?
  16. var picture: String?
  17. }
  18. enum GoogleOAuthError: Error {
  19. case missingClientId
  20. case missingClientSecret
  21. case invalidCallbackURL
  22. case missingAuthorizationCode
  23. case tokenExchangeFailed(String)
  24. case unableToOpenBrowser
  25. case authenticationTimedOut
  26. case noStoredTokens
  27. }
  28. final class GoogleOAuthService: NSObject {
  29. static let shared = GoogleOAuthService()
  30. // Stored in UserDefaults so you can configure without rebuilding.
  31. // Configure at runtime or via Info.plist keys.
  32. private let clientIdDefaultsKey = "google.oauth.clientId"
  33. private let clientSecretDefaultsKey = "google.oauth.clientSecret"
  34. private let clientIdPlistKey = "GoogleOAuthClientID"
  35. private let clientSecretPlistKey = "GoogleOAuthClientSecret"
  36. // Bundled fallback for app-distributed sign-in (no user input required).
  37. private let bundledClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
  38. private let bundledClientSecret = "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"
  39. // Calendar is needed for schedule. Profile/email make login feel complete in-app.
  40. private let scopes = [
  41. "openid",
  42. "email",
  43. "profile",
  44. "https://www.googleapis.com/auth/calendar.events",
  45. // Required for Google Meet conferenceRecords/transcripts APIs.
  46. "https://www.googleapis.com/auth/meetings.space.readonly"
  47. ]
  48. private let tokenStore = KeychainTokenStore()
  49. @MainActor private var inAppOAuthWindowController: InAppOAuthWindowController?
  50. private override init() {}
  51. private let lastSignedInEmailDefaultsKey = "google.oauth.lastSignedInEmail"
  52. func lastSignedInEmailHint() -> String? {
  53. let value = UserDefaults.standard
  54. .string(forKey: lastSignedInEmailDefaultsKey)?
  55. .trimmingCharacters(in: .whitespacesAndNewlines)
  56. guard let value, value.isEmpty == false else { return nil }
  57. return value.lowercased()
  58. }
  59. /// Wraps a Google URL so the default browser is nudged to use the same account as the app.
  60. /// If the browser isn't signed into that account, Google will prompt to sign in.
  61. func urlForPreferredGoogleAccountIfPossible(_ url: URL) -> URL {
  62. guard let email = lastSignedInEmailHint() else { return url }
  63. guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { return url }
  64. guard let host = url.host?.lowercased(), host.isEmpty == false else { return url }
  65. // Only attempt for Google properties where account mixups are common.
  66. let isGoogle = host == "google.com" || host.hasSuffix(".google.com")
  67. guard isGoogle else { return url }
  68. var components = URLComponents(string: "https://accounts.google.com/AccountChooser")!
  69. components.queryItems = [
  70. URLQueryItem(name: "continue", value: url.absoluteString),
  71. URLQueryItem(name: "Email", value: email)
  72. ]
  73. return components.url ?? url
  74. }
  75. func configuredClientId() -> String? {
  76. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
  77. if let value, value.isEmpty == false { return value }
  78. let plistValue = Bundle.main.object(forInfoDictionaryKey: clientIdPlistKey) as? String
  79. let trimmed = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
  80. if let trimmed, trimmed.isEmpty == false { return trimmed }
  81. return bundledClientId
  82. }
  83. func setClientIdForTesting(_ clientId: String) {
  84. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  85. }
  86. func configuredClientSecret() -> String? {
  87. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
  88. if let value, value.isEmpty == false { return value }
  89. let plistValue = Bundle.main.object(forInfoDictionaryKey: clientSecretPlistKey) as? String
  90. let trimmed = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
  91. if let trimmed, trimmed.isEmpty == false { return trimmed }
  92. return bundledClientSecret
  93. }
  94. func setClientSecretForTesting(_ clientSecret: String) {
  95. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  96. }
  97. func signOut() throws {
  98. try tokenStore.deleteTokens()
  99. }
  100. func signOutAndRevoke() async throws {
  101. let existing = try tokenStore.readTokens()
  102. if let existing {
  103. try await revokeTokenIfPossible(existing)
  104. }
  105. try tokenStore.deleteTokens()
  106. }
  107. func loadTokens() -> GoogleOAuthTokens? {
  108. try? tokenStore.readTokens()
  109. }
  110. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  111. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  112. request.httpMethod = "GET"
  113. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  114. let (data, response) = try await URLSession.shared.data(for: request)
  115. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  116. let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
  117. throw GoogleOAuthError.tokenExchangeFailed(details)
  118. }
  119. let profile = try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  120. let cleaned = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
  121. if let cleaned, cleaned.isEmpty == false {
  122. UserDefaults.standard.set(cleaned.lowercased(), forKey: lastSignedInEmailDefaultsKey)
  123. }
  124. return profile
  125. }
  126. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  127. if var tokens = try tokenStore.readTokens() {
  128. if tokens.expiresAt.timeIntervalSinceNow > 60 {
  129. return tokens.accessToken
  130. }
  131. if let refreshed = try await refreshTokens(tokens) {
  132. tokens = refreshed
  133. try tokenStore.writeTokens(tokens)
  134. return tokens.accessToken
  135. }
  136. }
  137. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  138. try tokenStore.writeTokens(tokens)
  139. return tokens.accessToken
  140. }
  141. // MARK: - Interactive sign-in (Authorization Code + PKCE)
  142. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  143. guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
  144. guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
  145. let codeVerifier = Self.randomURLSafeString(length: 64)
  146. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  147. let state = Self.randomURLSafeString(length: 32)
  148. let loopback = try await OAuthLoopbackServer.start()
  149. defer { loopback.stop() }
  150. let redirectURI = loopback.redirectURI
  151. let lastEmailHint = UserDefaults.standard
  152. .string(forKey: lastSignedInEmailDefaultsKey)?
  153. .trimmingCharacters(in: .whitespacesAndNewlines)
  154. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  155. var queryItems: [URLQueryItem] = [
  156. URLQueryItem(name: "client_id", value: clientId),
  157. URLQueryItem(name: "redirect_uri", value: redirectURI),
  158. URLQueryItem(name: "response_type", value: "code"),
  159. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  160. // Reuse already granted scopes so users are not repeatedly asked.
  161. URLQueryItem(name: "include_granted_scopes", value: "true"),
  162. URLQueryItem(name: "state", value: state),
  163. URLQueryItem(name: "code_challenge", value: codeChallenge),
  164. URLQueryItem(name: "code_challenge_method", value: "S256"),
  165. URLQueryItem(name: "access_type", value: "offline"),
  166. // Prefer the same Google account when multiple are present, and if none are signed in
  167. // this forces Google to show sign-in UI.
  168. URLQueryItem(name: "prompt", value: "select_account")
  169. ]
  170. if let lastEmailHint, lastEmailHint.isEmpty == false {
  171. queryItems.append(URLQueryItem(name: "login_hint", value: lastEmailHint))
  172. }
  173. components.queryItems = queryItems
  174. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  175. let opened = await MainActor.run { [self] in
  176. openAuthURLInDefaultBrowser(authURL)
  177. }
  178. guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
  179. let callbackURL = try await loopback.waitForCallback()
  180. guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
  181. .queryItems?.first(where: { $0.name == "state" })?.value,
  182. returnedState == state else {
  183. throw GoogleOAuthError.invalidCallbackURL
  184. }
  185. guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
  186. .queryItems?.first(where: { $0.name == "code" })?.value,
  187. code.isEmpty == false else {
  188. throw GoogleOAuthError.missingAuthorizationCode
  189. }
  190. return try await exchangeCodeForTokens(
  191. code: code,
  192. codeVerifier: codeVerifier,
  193. redirectURI: redirectURI,
  194. clientId: clientId,
  195. clientSecret: clientSecret
  196. )
  197. }
  198. @MainActor
  199. private func openAuthURLInDefaultBrowser(_ url: URL) -> Bool {
  200. NSWorkspace.shared.open(url)
  201. }
  202. @MainActor
  203. private func closeInAppOAuthWindow() {
  204. inAppOAuthWindowController?.close()
  205. inAppOAuthWindowController = nil
  206. }
  207. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> GoogleOAuthTokens {
  208. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  209. request.httpMethod = "POST"
  210. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  211. request.httpBody = Self.formURLEncoded([
  212. "client_id": clientId,
  213. "client_secret": clientSecret,
  214. "code": code,
  215. "code_verifier": codeVerifier,
  216. "redirect_uri": redirectURI,
  217. "grant_type": "authorization_code"
  218. ])
  219. let (data, response) = try await URLSession.shared.data(for: request)
  220. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  221. let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
  222. throw GoogleOAuthError.tokenExchangeFailed(details)
  223. }
  224. struct TokenResponse: Decodable {
  225. let access_token: String
  226. let expires_in: Double
  227. let refresh_token: String?
  228. let scope: String?
  229. let token_type: String?
  230. }
  231. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  232. return GoogleOAuthTokens(
  233. accessToken: decoded.access_token,
  234. refreshToken: decoded.refresh_token,
  235. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  236. scope: decoded.scope,
  237. tokenType: decoded.token_type
  238. )
  239. }
  240. private func refreshTokens(_ tokens: GoogleOAuthTokens) async throws -> GoogleOAuthTokens? {
  241. guard let refreshToken = tokens.refreshToken else { return nil }
  242. guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
  243. guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
  244. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  245. request.httpMethod = "POST"
  246. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  247. request.httpBody = Self.formURLEncoded([
  248. "client_id": clientId,
  249. "client_secret": clientSecret,
  250. "refresh_token": refreshToken,
  251. "grant_type": "refresh_token"
  252. ])
  253. let (data, response) = try await URLSession.shared.data(for: request)
  254. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  255. return nil
  256. }
  257. struct RefreshResponse: Decodable {
  258. let access_token: String
  259. let expires_in: Double
  260. let scope: String?
  261. let token_type: String?
  262. }
  263. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  264. return GoogleOAuthTokens(
  265. accessToken: decoded.access_token,
  266. refreshToken: refreshToken,
  267. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  268. scope: decoded.scope ?? tokens.scope,
  269. tokenType: decoded.token_type ?? tokens.tokenType
  270. )
  271. }
  272. // MARK: - Helpers
  273. private static func pkceChallenge(for verifier: String) -> String {
  274. let data = Data(verifier.utf8)
  275. let digest = SHA256.hash(data: data)
  276. return Data(digest).base64URLEncodedString()
  277. }
  278. private static func randomURLSafeString(length: Int) -> String {
  279. var bytes = [UInt8](repeating: 0, count: length)
  280. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  281. return Data(bytes).base64URLEncodedString()
  282. }
  283. private static func formURLEncoded(_ params: [String: String]) -> Data {
  284. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  285. let pairs = params.map { key, value -> String in
  286. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  287. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  288. return "\(k)=\(v)"
  289. }
  290. .joined(separator: "&")
  291. return Data(pairs.utf8)
  292. }
  293. private func revokeTokenIfPossible(_ tokens: GoogleOAuthTokens) async throws {
  294. let tokenToRevoke = tokens.refreshToken ?? tokens.accessToken
  295. guard tokenToRevoke.isEmpty == false else { return }
  296. var components = URLComponents(string: "https://oauth2.googleapis.com/revoke")!
  297. components.queryItems = [URLQueryItem(name: "token", value: tokenToRevoke)]
  298. guard let url = components.url else { return }
  299. var request = URLRequest(url: url)
  300. request.httpMethod = "POST"
  301. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  302. let (_, response) = try await URLSession.shared.data(for: request)
  303. guard let http = response as? HTTPURLResponse else {
  304. throw GoogleOAuthError.tokenExchangeFailed("Invalid revoke response.")
  305. }
  306. guard (200..<300).contains(http.statusCode) else {
  307. throw GoogleOAuthError.tokenExchangeFailed("Token revoke failed with status \(http.statusCode).")
  308. }
  309. }
  310. }
  311. private extension Data {
  312. func base64URLEncodedString() -> String {
  313. let s = base64EncodedString()
  314. return s
  315. .replacingOccurrences(of: "+", with: "-")
  316. .replacingOccurrences(of: "/", with: "_")
  317. .replacingOccurrences(of: "=", with: "")
  318. }
  319. }
  320. private final class OAuthLoopbackServer {
  321. private let queue = DispatchQueue(label: "google.oauth.loopback.server")
  322. private let listener: NWListener
  323. private var readyContinuation: CheckedContinuation<Void, Error>?
  324. private var callbackContinuation: CheckedContinuation<URL, Error>?
  325. private var callbackURL: URL?
  326. private init(listener: NWListener) {
  327. self.listener = listener
  328. }
  329. static func start() async throws -> OAuthLoopbackServer {
  330. let listener = try NWListener(using: .tcp, on: .any)
  331. let server = OAuthLoopbackServer(listener: listener)
  332. try await server.startListening()
  333. return server
  334. }
  335. var redirectURI: String {
  336. let port = listener.port?.rawValue ?? 0
  337. return "http://127.0.0.1:\(port)/oauth2redirect"
  338. }
  339. func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
  340. try await withThrowingTaskGroup(of: URL.self) { group in
  341. group.addTask { [weak self] in
  342. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  343. return try await self.awaitCallback()
  344. }
  345. group.addTask {
  346. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  347. throw GoogleOAuthError.authenticationTimedOut
  348. }
  349. let url = try await group.next()!
  350. group.cancelAll()
  351. return url
  352. }
  353. }
  354. func stop() {
  355. queue.async {
  356. self.listener.cancel()
  357. if let callbackContinuation = self.callbackContinuation {
  358. self.callbackContinuation = nil
  359. callbackContinuation.resume(throwing: GoogleOAuthError.authenticationTimedOut)
  360. }
  361. }
  362. }
  363. private func startListening() async throws {
  364. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  365. queue.async {
  366. self.readyContinuation = continuation
  367. self.listener.stateUpdateHandler = { [weak self] state in
  368. guard let self else { return }
  369. switch state {
  370. case .ready:
  371. if let readyContinuation = self.readyContinuation {
  372. self.readyContinuation = nil
  373. readyContinuation.resume()
  374. }
  375. case .failed(let error):
  376. if let readyContinuation = self.readyContinuation {
  377. self.readyContinuation = nil
  378. readyContinuation.resume(throwing: error)
  379. }
  380. case .cancelled:
  381. if let readyContinuation = self.readyContinuation {
  382. self.readyContinuation = nil
  383. readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
  384. }
  385. default:
  386. break
  387. }
  388. }
  389. self.listener.newConnectionHandler = { [weak self] connection in
  390. self?.handle(connection: connection)
  391. }
  392. self.listener.start(queue: self.queue)
  393. }
  394. }
  395. }
  396. private func awaitCallback() async throws -> URL {
  397. if let callbackURL { return callbackURL }
  398. return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  399. queue.async {
  400. if let callbackURL = self.callbackURL {
  401. continuation.resume(returning: callbackURL)
  402. return
  403. }
  404. self.callbackContinuation = continuation
  405. }
  406. }
  407. }
  408. private func handle(connection: NWConnection) {
  409. connection.start(queue: queue)
  410. connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
  411. guard let self else { return }
  412. let requestLine = data
  413. .flatMap { String(data: $0, encoding: .utf8) }?
  414. .split(separator: "\r\n", omittingEmptySubsequences: false)
  415. .first
  416. .map(String.init)
  417. var parsedURL: URL?
  418. if let requestLine {
  419. let parts = requestLine.split(separator: " ")
  420. if parts.count >= 2 {
  421. let pathAndQuery = String(parts[1])
  422. parsedURL = URL(string: "http://127.0.0.1\(pathAndQuery)")
  423. }
  424. }
  425. self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
  426. if let parsedURL {
  427. self.callbackURL = parsedURL
  428. DispatchQueue.main.async {
  429. // Bring the app back to foreground once OAuth redirects.
  430. NSApp.activate(ignoringOtherApps: true)
  431. }
  432. if let continuation = self.callbackContinuation {
  433. self.callbackContinuation = nil
  434. continuation.resume(returning: parsedURL)
  435. }
  436. self.listener.cancel()
  437. }
  438. connection.cancel()
  439. }
  440. }
  441. private func sendHTTPResponse(connection: NWConnection, success: Bool) {
  442. let body = success
  443. ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>"
  444. : "<html><body><h3>Authentication failed</h3></body></html>"
  445. let response = """
  446. HTTP/1.1 200 OK\r
  447. Content-Type: text/html; charset=utf-8\r
  448. Content-Length: \(body.utf8.count)\r
  449. Connection: close\r
  450. \r
  451. \(body)
  452. """
  453. connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
  454. }
  455. }
  456. extension GoogleOAuthError: LocalizedError {
  457. var errorDescription: String? {
  458. switch self {
  459. case .missingClientId:
  460. return "Missing Google OAuth Client ID."
  461. case .missingClientSecret:
  462. return "Missing Google OAuth Client Secret."
  463. case .invalidCallbackURL:
  464. return "Invalid OAuth callback URL."
  465. case .missingAuthorizationCode:
  466. return "Google did not return an authorization code."
  467. case .tokenExchangeFailed(let details):
  468. return "Token exchange failed: \(details)"
  469. case .unableToOpenBrowser:
  470. return "Could not open browser for Google sign-in."
  471. case .authenticationTimedOut:
  472. return "Google sign-in timed out."
  473. case .noStoredTokens:
  474. return "No stored Google tokens found."
  475. }
  476. }
  477. }
  478. @MainActor
  479. private final class OAuthWebViewContainerView: NSView {
  480. private let webView: WKWebView
  481. init(webView: WKWebView) {
  482. self.webView = webView
  483. super.init(frame: .zero)
  484. autoresizingMask = [.width, .height]
  485. addSubview(webView)
  486. }
  487. @available(*, unavailable)
  488. required init?(coder: NSCoder) {
  489. nil
  490. }
  491. override func layout() {
  492. super.layout()
  493. webView.frame = bounds
  494. }
  495. }
  496. @MainActor
  497. private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate {
  498. private let webView: WKWebView
  499. private let defaultWindowSize = NSSize(width: 980, height: 760)
  500. init() {
  501. let config = WKWebViewConfiguration()
  502. if #available(macOS 11.0, *) {
  503. config.defaultWebpagePreferences.allowsContentJavaScript = true
  504. }
  505. self.webView = WKWebView(frame: .zero, configuration: config)
  506. let container = OAuthWebViewContainerView(webView: webView)
  507. let window = NSWindow(
  508. contentRect: NSRect(origin: .zero, size: defaultWindowSize),
  509. styleMask: [.titled, .closable, .miniaturizable, .resizable],
  510. backing: .buffered,
  511. defer: false
  512. )
  513. window.title = "Google Sign-In"
  514. window.contentView = container
  515. window.center()
  516. super.init(window: window)
  517. webView.navigationDelegate = self
  518. webView.uiDelegate = self
  519. }
  520. @available(*, unavailable)
  521. required init?(coder: NSCoder) {
  522. nil
  523. }
  524. func load(url: URL) {
  525. webView.load(URLRequest(url: url))
  526. }
  527. func alignWithPresentingWindow(_ presentingWindow: NSWindow?) {
  528. guard let window else { return }
  529. if let presentingWindow {
  530. window.setFrame(presentingWindow.frame, display: false)
  531. return
  532. }
  533. if let screenFrame = NSScreen.main?.visibleFrame {
  534. let origin = NSPoint(
  535. x: screenFrame.midX - (defaultWindowSize.width / 2),
  536. y: screenFrame.midY - (defaultWindowSize.height / 2)
  537. )
  538. window.setFrame(NSRect(origin: origin, size: defaultWindowSize), display: false)
  539. } else {
  540. window.center()
  541. }
  542. }
  543. private func shouldOpenURLExternally(_ url: URL) -> Bool {
  544. let scheme = (url.scheme ?? "").lowercased()
  545. guard !scheme.isEmpty else { return false }
  546. return scheme != "about" && scheme != "javascript"
  547. }
  548. func webView(
  549. _ webView: WKWebView,
  550. decidePolicyFor navigationAction: WKNavigationAction,
  551. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  552. ) {
  553. guard let url = navigationAction.request.url else {
  554. decisionHandler(.allow)
  555. return
  556. }
  557. let scheme = (url.scheme ?? "").lowercased()
  558. if scheme == "http" || scheme == "https" {
  559. decisionHandler(.allow)
  560. return
  561. }
  562. if shouldOpenURLExternally(url) {
  563. NSWorkspace.shared.open(url)
  564. }
  565. decisionHandler(.cancel)
  566. }
  567. func webView(
  568. _ webView: WKWebView,
  569. createWebViewWith configuration: WKWebViewConfiguration,
  570. for navigationAction: WKNavigationAction,
  571. windowFeatures: WKWindowFeatures
  572. ) -> WKWebView? {
  573. if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
  574. let scheme = (requestURL.scheme ?? "").lowercased()
  575. if scheme == "http" || scheme == "https" {
  576. webView.load(URLRequest(url: requestURL))
  577. } else if shouldOpenURLExternally(requestURL) {
  578. NSWorkspace.shared.open(requestURL)
  579. }
  580. }
  581. return nil
  582. }
  583. }