Bez popisu

IndeedJobBrowserWindowController.swift 23KB

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