IndeedJobBrowserWindowController.swift 25 KB

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