Nenhuma descrição

IndeedJobBrowserWindowController.swift 24KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. //
  2. // IndeedJobBrowserWindowController.swift
  3. // App for Indeed
  4. //
  5. import Cocoa
  6. import WebKit
  7. /// Indeed job listing and apply flow in a `WKWebView`, embedded in the dashboard main panel or hosted in a window.
  8. final class IndeedJobBrowserViewController: NSViewController, WKNavigationDelegate, WKUIDelegate {
  9. /// Shared pool so Indeed / Cloudflare cookies persist across embedded browser sessions.
  10. private static let sharedProcessPool = WKProcessPool()
  11. /// When set, a leading **Home** control calls this so the host can hide the embedded browser (same-window UX).
  12. var onDismissEmbedded: (() -> Void)?
  13. private let webView: WKWebView = {
  14. let configuration = WKWebViewConfiguration()
  15. configuration.processPool = IndeedJobBrowserViewController.sharedProcessPool
  16. configuration.websiteDataStore = .default()
  17. configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
  18. if #available(macOS 11.0, *) {
  19. configuration.defaultWebpagePreferences.allowsContentJavaScript = true
  20. }
  21. return WKWebView(frame: .zero, configuration: configuration)
  22. }()
  23. private var pendingURL: URL?
  24. /// Set when the user starts Google sign-in from Indeed; cleared after one `prompt=select_account` rewrite.
  25. private var pendingGoogleAccountPicker = false
  26. private let backButton = NSButton()
  27. private let forwardButton = NSButton()
  28. private let reloadButton = NSButton()
  29. private let dismissEmbeddedButton = NSButton(title: L("Home"), target: nil, action: nil)
  30. private let toolbarContainer = NSView()
  31. private var appearanceObserver: NSObjectProtocol?
  32. private var languageObserver: NSObjectProtocol?
  33. override func loadView() {
  34. view = NSView(frame: NSRect(x: 0, y: 0, width: 920, height: 720))
  35. }
  36. override func viewDidLoad() {
  37. super.viewDidLoad()
  38. webView.translatesAutoresizingMaskIntoConstraints = false
  39. webView.navigationDelegate = self
  40. webView.uiDelegate = self
  41. webView.customUserAgent = Self.desktopSafariLikeUserAgent
  42. configureToolbarButton(backButton, symbolName: "chevron.backward", action: #selector(goBack))
  43. configureToolbarButton(forwardButton, symbolName: "chevron.forward", action: #selector(goForward))
  44. configureToolbarButton(reloadButton, symbolName: "arrow.clockwise", action: #selector(reload))
  45. dismissEmbeddedButton.translatesAutoresizingMaskIntoConstraints = false
  46. dismissEmbeddedButton.bezelStyle = .rounded
  47. dismissEmbeddedButton.isBordered = true
  48. dismissEmbeddedButton.target = self
  49. dismissEmbeddedButton.action = #selector(dismissEmbedded)
  50. dismissEmbeddedButton.toolTip = L("Return to the previous screen")
  51. toolbarContainer.translatesAutoresizingMaskIntoConstraints = false
  52. toolbarContainer.wantsLayer = true
  53. let barStack: NSStackView
  54. if onDismissEmbedded != nil {
  55. barStack = NSStackView(views: [dismissEmbeddedButton, backButton, forwardButton, reloadButton, NSView()])
  56. } else {
  57. barStack = NSStackView(views: [backButton, forwardButton, reloadButton, NSView()])
  58. }
  59. barStack.orientation = .horizontal
  60. barStack.spacing = 8
  61. barStack.alignment = .centerY
  62. barStack.distribution = .fill
  63. barStack.translatesAutoresizingMaskIntoConstraints = false
  64. toolbarContainer.addSubview(barStack)
  65. view.addSubview(toolbarContainer)
  66. view.addSubview(webView)
  67. var layoutConstraints: [NSLayoutConstraint] = [
  68. toolbarContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  69. toolbarContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  70. toolbarContainer.topAnchor.constraint(equalTo: view.topAnchor),
  71. toolbarContainer.heightAnchor.constraint(equalToConstant: 48),
  72. barStack.leadingAnchor.constraint(equalTo: toolbarContainer.leadingAnchor, constant: 12),
  73. barStack.trailingAnchor.constraint(equalTo: toolbarContainer.trailingAnchor, constant: -12),
  74. barStack.centerYAnchor.constraint(equalTo: toolbarContainer.centerYAnchor),
  75. backButton.widthAnchor.constraint(equalToConstant: 32),
  76. backButton.heightAnchor.constraint(equalToConstant: 28),
  77. forwardButton.widthAnchor.constraint(equalToConstant: 32),
  78. forwardButton.heightAnchor.constraint(equalToConstant: 28),
  79. reloadButton.widthAnchor.constraint(equalToConstant: 32),
  80. reloadButton.heightAnchor.constraint(equalToConstant: 28),
  81. webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  82. webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  83. webView.topAnchor.constraint(equalTo: toolbarContainer.bottomAnchor),
  84. webView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  85. ]
  86. if onDismissEmbedded != nil {
  87. layoutConstraints.append(dismissEmbeddedButton.heightAnchor.constraint(equalToConstant: 28))
  88. }
  89. NSLayoutConstraint.activate(layoutConstraints)
  90. updateNavigationButtons()
  91. applyCurrentAppearance()
  92. appearanceObserver = NotificationCenter.default.addObserver(
  93. forName: AppAppearanceManager.didChangeNotification,
  94. object: nil,
  95. queue: .main
  96. ) { [weak self] _ in
  97. self?.applyCurrentAppearance()
  98. }
  99. languageObserver = NotificationCenter.default.addObserver(
  100. forName: AppLanguageManager.didChangeNotification,
  101. object: nil,
  102. queue: .main
  103. ) { [weak self] _ in
  104. self?.applyLocalizedStrings()
  105. }
  106. if let pendingURL {
  107. webView.load(URLRequest(url: pendingURL))
  108. self.pendingURL = nil
  109. }
  110. }
  111. deinit {
  112. if let appearanceObserver {
  113. NotificationCenter.default.removeObserver(appearanceObserver)
  114. }
  115. if let languageObserver {
  116. NotificationCenter.default.removeObserver(languageObserver)
  117. }
  118. }
  119. func loadPage(_ url: URL) {
  120. if isViewLoaded {
  121. webView.load(URLRequest(url: url))
  122. } else {
  123. pendingURL = url
  124. }
  125. updateNavigationButtons()
  126. }
  127. /// Adds this controller as a child of `parent` and pins `view` to `host` (used by the dashboard main panel).
  128. func embed(in host: NSView, parent: NSViewController) {
  129. parent.addChild(self)
  130. view.translatesAutoresizingMaskIntoConstraints = false
  131. host.addSubview(view)
  132. NSLayoutConstraint.activate([
  133. view.leadingAnchor.constraint(equalTo: host.leadingAnchor),
  134. view.trailingAnchor.constraint(equalTo: host.trailingAnchor),
  135. view.topAnchor.constraint(equalTo: host.topAnchor),
  136. view.bottomAnchor.constraint(equalTo: host.bottomAnchor)
  137. ])
  138. }
  139. private func configureToolbarButton(_ button: NSButton, symbolName: String, action: Selector) {
  140. button.translatesAutoresizingMaskIntoConstraints = false
  141. button.bezelStyle = .texturedRounded
  142. button.isBordered = true
  143. button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
  144. button.imagePosition = .imageOnly
  145. button.target = self
  146. button.action = action
  147. }
  148. private func applyCurrentAppearance() {
  149. toolbarContainer.layer?.backgroundColor = AppDashboardTheme.chromeBackground.cgColor
  150. let accent = AppDashboardTheme.brandBlue
  151. dismissEmbeddedButton.contentTintColor = accent
  152. backButton.contentTintColor = accent
  153. forwardButton.contentTintColor = accent
  154. reloadButton.contentTintColor = accent
  155. }
  156. private func applyLocalizedStrings() {
  157. dismissEmbeddedButton.title = L("Home")
  158. dismissEmbeddedButton.toolTip = L("Return to the previous screen")
  159. }
  160. private func updateNavigationButtons() {
  161. backButton.isEnabled = webView.canGoBack
  162. forwardButton.isEnabled = webView.canGoForward
  163. }
  164. @objc private func goBack() {
  165. webView.goBack()
  166. }
  167. @objc private func goForward() {
  168. webView.goForward()
  169. }
  170. @objc private func reload() {
  171. webView.reload()
  172. }
  173. @objc private func dismissEmbedded() {
  174. onDismissEmbedded?()
  175. }
  176. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  177. if let host = webView.url?.host?.lowercased(), IndeedWebBrowsingPolicy.isIndeedHost(host) {
  178. pendingGoogleAccountPicker = false
  179. }
  180. updateNavigationButtons()
  181. }
  182. func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
  183. updateNavigationButtons()
  184. }
  185. func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
  186. guard IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else {
  187. decisionHandler(.cancel)
  188. return
  189. }
  190. if let url = navigationAction.request.url,
  191. IndeedWebBrowsingPolicy.shouldOpenInSystemBrowser(url: url) {
  192. NSWorkspace.shared.open(url)
  193. decisionHandler(.cancel)
  194. return
  195. }
  196. noteGoogleSignInStartedFromIndeed(navigationAction)
  197. if let url = navigationAction.request.url,
  198. applyGoogleAccountPickerIfNeeded(to: url, in: webView) {
  199. decisionHandler(.cancel)
  200. return
  201. }
  202. decisionHandler(.allow)
  203. }
  204. /// Target=_blank / `window.open` without a frame: Indeed stays in-app; company apply sites open in the default browser.
  205. func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
  206. guard navigationAction.targetFrame == nil,
  207. IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else {
  208. return nil
  209. }
  210. if let url = navigationAction.request.url,
  211. IndeedWebBrowsingPolicy.shouldOpenInSystemBrowser(url: url) {
  212. NSWorkspace.shared.open(url)
  213. return nil
  214. }
  215. noteGoogleSignInStartedFromIndeed(navigationAction)
  216. if let url = navigationAction.request.url,
  217. applyGoogleAccountPickerIfNeeded(to: url, in: webView) {
  218. return nil
  219. }
  220. webView.load(navigationAction.request)
  221. return nil
  222. }
  223. private func noteGoogleSignInStartedFromIndeed(_ navigationAction: WKNavigationAction) {
  224. guard let destination = navigationAction.request.url else { return }
  225. let sourceHost = navigationAction.sourceFrame.request.url?.host?.lowercased()
  226. ?? navigationAction.request.mainDocumentURL?.host?.lowercased()
  227. ?? webView.url?.host?.lowercased()
  228. guard let sourceHost, IndeedWebBrowsingPolicy.isIndeedHost(sourceHost) else { return }
  229. guard IndeedWebBrowsingPolicy.isGoogleSignInEntryURL(destination) else { return }
  230. pendingGoogleAccountPicker = true
  231. }
  232. @discardableResult
  233. private func applyGoogleAccountPickerIfNeeded(to url: URL, in webView: WKWebView) -> Bool {
  234. guard pendingGoogleAccountPicker,
  235. IndeedWebBrowsingPolicy.shouldApplyGoogleAccountPicker(to: url) else {
  236. return false
  237. }
  238. pendingGoogleAccountPicker = false
  239. let pickerURL = IndeedWebBrowsingPolicy.urlAddingGoogleAccountPickerPrompt(url)
  240. guard pickerURL != url else { return false }
  241. webView.load(URLRequest(url: pickerURL))
  242. return true
  243. }
  244. func webView(
  245. _ webView: WKWebView,
  246. runJavaScriptAlertPanelWithMessage message: String,
  247. initiatedByFrame frame: WKFrameInfo,
  248. completionHandler: @escaping () -> Void
  249. ) {
  250. let alert = NSAlert()
  251. alert.messageText = message
  252. alert.addButton(withTitle: L("OK"))
  253. alert.runModal()
  254. completionHandler()
  255. }
  256. func webView(
  257. _ webView: WKWebView,
  258. runJavaScriptConfirmPanelWithMessage message: String,
  259. initiatedByFrame frame: WKFrameInfo,
  260. completionHandler: @escaping (Bool) -> Void
  261. ) {
  262. let alert = NSAlert()
  263. alert.messageText = message
  264. alert.addButton(withTitle: L("OK"))
  265. alert.addButton(withTitle: L("Cancel"))
  266. completionHandler(alert.runModal() == .alertFirstButtonReturn)
  267. }
  268. func webView(
  269. _ webView: WKWebView,
  270. runJavaScriptTextInputPanelWithPrompt prompt: String,
  271. defaultText: String?,
  272. initiatedByFrame frame: WKFrameInfo,
  273. completionHandler: @escaping (String?) -> Void
  274. ) {
  275. let alert = NSAlert()
  276. alert.messageText = prompt
  277. alert.addButton(withTitle: L("OK"))
  278. alert.addButton(withTitle: L("Cancel"))
  279. let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24))
  280. field.stringValue = defaultText ?? ""
  281. alert.accessoryView = field
  282. guard alert.runModal() == .alertFirstButtonReturn else {
  283. completionHandler(nil)
  284. return
  285. }
  286. completionHandler(field.stringValue)
  287. }
  288. /// Safari-like UA without the app name suffix — Cloudflare / Indeed bot checks reject `App for Indeed/…` in the UA string.
  289. private static let desktopSafariLikeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15"
  290. }
  291. // MARK: - Embedded browsing policy
  292. /// Keeps Indeed and apply helpers (Cloudflare, Google sign-in, reCAPTCHA) in-app; opens company career sites in the system browser.
  293. enum IndeedWebBrowsingPolicy {
  294. /// Company career sites and other non-Indeed apply links (e.g. “Apply on company site”).
  295. static func shouldOpenInSystemBrowser(url: URL) -> Bool {
  296. guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
  297. return false
  298. }
  299. guard let host = url.host?.lowercased() else { return false }
  300. if shouldRemainInEmbeddedWebView(url: url) { return false }
  301. if isRestrictedPlatformHost(host) { return false }
  302. return true
  303. }
  304. /// URLs that must load inside the embedded `WKWebView` (not the default browser).
  305. static func shouldRemainInEmbeddedWebView(url: URL) -> Bool {
  306. guard let scheme = url.scheme?.lowercased() else { return false }
  307. if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
  308. guard scheme == "http" || scheme == "https" else { return false }
  309. guard let host = url.host?.lowercased() else { return false }
  310. if isIndeedHost(host) { return true }
  311. if isAllowedGoogleSignInHost(host, path: url.path) { return true }
  312. if isGooglePolicyOrLegalPage(host: host, path: url.path) { return true }
  313. if isGoogleRecaptchaHost(host, path: url.path) { return true }
  314. if isCloudflareChallengeHost(host) { return true }
  315. if isHCaptchaHost(host) { return true }
  316. return false
  317. }
  318. static func isIndeedHost(_ host: String) -> Bool {
  319. if host == "indeed.com" { return true }
  320. if host.hasPrefix("indeed.") { return true }
  321. return host.contains(".indeed.")
  322. }
  323. static func allows(navigationAction: WKNavigationAction) -> Bool {
  324. guard let url = navigationAction.request.url else { return true }
  325. let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
  326. if isMainFrame {
  327. return allowsMainFrameNavigation(to: url)
  328. }
  329. return allowsSubframeNavigation(to: url)
  330. }
  331. static func allowsMainFrameNavigation(to url: URL) -> Bool {
  332. guard let scheme = url.scheme?.lowercased() else { return false }
  333. if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
  334. guard scheme == "http" || scheme == "https" else { return false }
  335. guard let host = url.host?.lowercased() else { return false }
  336. if shouldRemainInEmbeddedWebView(url: url) { return true }
  337. if isRestrictedPlatformHost(host) { return false }
  338. return true
  339. }
  340. static func allowsSubframeNavigation(to url: URL) -> Bool {
  341. guard let scheme = url.scheme?.lowercased() else { return false }
  342. if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
  343. guard scheme == "http" || scheme == "https" else { return false }
  344. guard let host = url.host?.lowercased() else { return false }
  345. if isYouTubeRelatedHost(host) {
  346. return false
  347. }
  348. if !isGooglePropertyHost(host) {
  349. return true
  350. }
  351. if isAllowedGoogleSignInHost(host, path: url.path) {
  352. return true
  353. }
  354. if isGooglePolicyOrLegalPage(host: host, path: url.path) {
  355. return true
  356. }
  357. if isGoogleRecaptchaHost(host, path: url.path) {
  358. return true
  359. }
  360. if isCloudflareChallengeHost(host) {
  361. return true
  362. }
  363. return isHCaptchaHost(host)
  364. }
  365. /// Google, YouTube, and similar — stay in the sign-in flow; never open in the system browser.
  366. private static func isRestrictedPlatformHost(_ host: String) -> Bool {
  367. isGooglePropertyHost(host) || isYouTubeRelatedHost(host)
  368. }
  369. private static func isYouTubeRelatedHost(_ host: String) -> Bool {
  370. if host == "youtu.be" { return true }
  371. if host == "youtube.com" || host.hasSuffix(".youtube.com") { return true }
  372. if host.contains("youtube") { return true }
  373. return false
  374. }
  375. /// Privacy / Terms / Help links on the Google account chooser (not YouTube, Gmail, or Search).
  376. private static func isGooglePolicyOrLegalPage(host: String, path: String) -> Bool {
  377. if host == "policies.google.com" || host == "privacy.google.com" {
  378. return true
  379. }
  380. if host == "support.google.com" {
  381. let pathLower = path.lowercased()
  382. return pathLower.contains("/accounts")
  383. }
  384. if host == "www.google.com" || host == "google.com" {
  385. let pathLower = path.lowercased()
  386. return pathLower.contains("/policies")
  387. || pathLower.contains("/privacy")
  388. || pathLower.contains("/terms")
  389. || pathLower.contains("/intl/")
  390. }
  391. return false
  392. }
  393. /// Cloudflare Turnstile / bot check during Indeed apply (`challenges.cloudflare.com`, …).
  394. private static func isCloudflareChallengeHost(_ host: String) -> Bool {
  395. if host == "cloudflare.com" || host.hasSuffix(".cloudflare.com") { return true }
  396. return false
  397. }
  398. /// hCaptcha widget hosts used on some apply flows.
  399. private static func isHCaptchaHost(_ host: String) -> Bool {
  400. host == "hcaptcha.com" || host.hasSuffix(".hcaptcha.com")
  401. }
  402. /// Google reCAPTCHA during Indeed Easy Apply (`recaptcha.net`, `google.com/recaptcha`, `gstatic.com`, …).
  403. private static func isGoogleRecaptchaHost(_ host: String, path: String) -> Bool {
  404. if host == "recaptcha.net" || host.hasSuffix(".recaptcha.net") {
  405. return true
  406. }
  407. let pathLower = path.lowercased()
  408. if host == "recaptcha.google.com" || host.hasPrefix("recaptcha.google.") {
  409. return true
  410. }
  411. if host == "google.com" || host.hasSuffix(".google.com"), pathLower.contains("recaptcha") {
  412. return true
  413. }
  414. if host == "gstatic.com" || host.hasSuffix(".gstatic.com") {
  415. return true
  416. }
  417. return false
  418. }
  419. private static func isGooglePropertyHost(_ host: String) -> Bool {
  420. if host == "google.com" || host.hasSuffix(".google.com") { return true }
  421. if host == "gmail.com" || host.hasSuffix(".gmail.com") { return true }
  422. if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") { return true }
  423. if host == "gstatic.com" || host.hasSuffix(".gstatic.com") { return true }
  424. if host == "youtube.com" || host.hasSuffix(".youtube.com") { return true }
  425. if host == "blogger.com" || host.hasSuffix(".blogger.com") { return true }
  426. if host == "withgoogle.com" || host.hasSuffix(".withgoogle.com") { return true }
  427. return false
  428. }
  429. /// Hosts used during “Sign in with Google” — not Help Center, Gmail, Drive, Search, or the apps launcher.
  430. private static func isAllowedGoogleSignInHost(_ host: String, path: String) -> Bool {
  431. if host == "accounts.google.com" || host.hasPrefix("accounts.google.") {
  432. return true
  433. }
  434. if host == "signin.google.com" {
  435. return true
  436. }
  437. if host == "oauth.googleusercontent.com" {
  438. return true
  439. }
  440. if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") {
  441. return true
  442. }
  443. if host == "apis.google.com" {
  444. return true
  445. }
  446. if isGooglePolicyOrLegalPage(host: host, path: path) {
  447. return true
  448. }
  449. if host == "myaccount.google.com" {
  450. return isGoogleSignInRelatedPath(path)
  451. }
  452. if host == "www.google.com" || host == "google.com" {
  453. return isGoogleSignInRelatedPath(path)
  454. }
  455. return false
  456. }
  457. private static func isGoogleSignInRelatedPath(_ path: String) -> Bool {
  458. let pathLower = path.lowercased()
  459. return pathLower.contains("/signin")
  460. || pathLower.contains("/accounts")
  461. || pathLower.contains("servicelogin")
  462. || pathLower.contains("/oauth")
  463. || pathLower.contains("/gsi")
  464. || pathLower.contains("/device")
  465. || pathLower.contains("/security")
  466. }
  467. /// First Google OAuth hop after leaving Indeed (`/o/oauth2/.../auth`, account chooser, …).
  468. static func isGoogleSignInEntryURL(_ url: URL) -> Bool {
  469. guard let host = url.host?.lowercased(),
  470. host == "accounts.google.com" || host.hasPrefix("accounts.google.") else {
  471. return false
  472. }
  473. let path = url.path.lowercased()
  474. if path.contains("/oauth2/") && path.contains("auth") { return true }
  475. if path.contains("accountchooser") { return true }
  476. return false
  477. }
  478. /// Whether to inject `prompt=select_account` once for this OAuth attempt.
  479. static func shouldApplyGoogleAccountPicker(to url: URL) -> Bool {
  480. guard isGoogleSignInEntryURL(url) else { return false }
  481. let path = url.path.lowercased()
  482. if path.contains("/signin/oauth/") || path.contains("/signin/v2/") { return false }
  483. let query = url.query?.lowercased() ?? ""
  484. if query.contains("select_account") { return false }
  485. if query.contains("authuser=") { return false }
  486. return true
  487. }
  488. /// Adds `prompt=select_account` so Google shows the account chooser instead of silent sign-in.
  489. static func urlAddingGoogleAccountPickerPrompt(_ url: URL) -> URL {
  490. guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
  491. var items = components.queryItems ?? []
  492. items.removeAll { $0.name.compare("login_hint", options: .caseInsensitive) == .orderedSame }
  493. items.removeAll { $0.name.compare("hint", options: .caseInsensitive) == .orderedSame }
  494. if let promptIndex = items.firstIndex(where: { $0.name.compare("prompt", options: .caseInsensitive) == .orderedSame }) {
  495. let existing = items[promptIndex].value ?? ""
  496. if !existing.lowercased().contains("select_account") {
  497. let merged = existing.isEmpty ? "select_account" : "\(existing) select_account"
  498. items[promptIndex] = URLQueryItem(name: "prompt", value: merged)
  499. }
  500. } else {
  501. items.append(URLQueryItem(name: "prompt", value: "select_account"))
  502. }
  503. components.queryItems = items.isEmpty ? nil : items
  504. return components.url ?? url
  505. }
  506. }