Açıklama Yok

IndeedJobBrowserWindowController.swift 25KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  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()
  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. dismissEmbeddedButton,
  47. symbolName: "house.fill",
  48. action: #selector(dismissEmbedded),
  49. toolTipKey: "Return to the previous screen"
  50. )
  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(contentsOf: [
  88. dismissEmbeddedButton.widthAnchor.constraint(equalToConstant: 32),
  89. dismissEmbeddedButton.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. dismissEmbeddedButton.contentTintColor = labelColor
  176. reloadButton.contentTintColor = labelColor
  177. updateNavigationButtons()
  178. }
  179. private func applyLocalizedStrings() {
  180. dismissEmbeddedButton.toolTip = L("Return to the previous screen")
  181. updateSymbolToolbarButtonImage(
  182. dismissEmbeddedButton,
  183. symbolName: "house.fill",
  184. accessibilityLabelKey: "Return to the previous screen"
  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 dismissEmbedded() {
  211. onDismissEmbedded?()
  212. }
  213. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  214. if let host = webView.url?.host?.lowercased(), IndeedWebBrowsingPolicy.isIndeedHost(host) {
  215. pendingGoogleAccountPicker = false
  216. }
  217. updateNavigationButtons()
  218. }
  219. func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
  220. updateNavigationButtons()
  221. }
  222. func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
  223. guard IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else {
  224. decisionHandler(.cancel)
  225. return
  226. }
  227. if let url = navigationAction.request.url,
  228. IndeedWebBrowsingPolicy.shouldOpenInSystemBrowser(url: url) {
  229. NSWorkspace.shared.open(url)
  230. decisionHandler(.cancel)
  231. return
  232. }
  233. noteGoogleSignInStartedFromIndeed(navigationAction)
  234. if let url = navigationAction.request.url,
  235. applyGoogleAccountPickerIfNeeded(to: url, in: webView) {
  236. decisionHandler(.cancel)
  237. return
  238. }
  239. decisionHandler(.allow)
  240. }
  241. /// Target=_blank / `window.open` without a frame: Indeed stays in-app; company apply sites open in the default browser.
  242. func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
  243. guard navigationAction.targetFrame == nil,
  244. IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else {
  245. return nil
  246. }
  247. if let url = navigationAction.request.url,
  248. IndeedWebBrowsingPolicy.shouldOpenInSystemBrowser(url: url) {
  249. NSWorkspace.shared.open(url)
  250. return nil
  251. }
  252. noteGoogleSignInStartedFromIndeed(navigationAction)
  253. if let url = navigationAction.request.url,
  254. applyGoogleAccountPickerIfNeeded(to: url, in: webView) {
  255. return nil
  256. }
  257. webView.load(navigationAction.request)
  258. return nil
  259. }
  260. private func noteGoogleSignInStartedFromIndeed(_ navigationAction: WKNavigationAction) {
  261. guard let destination = navigationAction.request.url else { return }
  262. let sourceHost = navigationAction.sourceFrame.request.url?.host?.lowercased()
  263. ?? navigationAction.request.mainDocumentURL?.host?.lowercased()
  264. ?? webView.url?.host?.lowercased()
  265. guard let sourceHost, IndeedWebBrowsingPolicy.isIndeedHost(sourceHost) else { return }
  266. guard IndeedWebBrowsingPolicy.isGoogleSignInEntryURL(destination) else { return }
  267. pendingGoogleAccountPicker = true
  268. }
  269. @discardableResult
  270. private func applyGoogleAccountPickerIfNeeded(to url: URL, in webView: WKWebView) -> Bool {
  271. guard pendingGoogleAccountPicker,
  272. IndeedWebBrowsingPolicy.shouldApplyGoogleAccountPicker(to: url) else {
  273. return false
  274. }
  275. pendingGoogleAccountPicker = false
  276. let pickerURL = IndeedWebBrowsingPolicy.urlAddingGoogleAccountPickerPrompt(url)
  277. guard pickerURL != url else { return false }
  278. webView.load(URLRequest(url: pickerURL))
  279. return true
  280. }
  281. func webView(
  282. _ webView: WKWebView,
  283. runJavaScriptAlertPanelWithMessage message: String,
  284. initiatedByFrame frame: WKFrameInfo,
  285. completionHandler: @escaping () -> Void
  286. ) {
  287. let alert = NSAlert()
  288. alert.messageText = message
  289. alert.addButton(withTitle: L("OK"))
  290. alert.runModal()
  291. completionHandler()
  292. }
  293. func webView(
  294. _ webView: WKWebView,
  295. runJavaScriptConfirmPanelWithMessage message: String,
  296. initiatedByFrame frame: WKFrameInfo,
  297. completionHandler: @escaping (Bool) -> Void
  298. ) {
  299. let alert = NSAlert()
  300. alert.messageText = message
  301. alert.addButton(withTitle: L("OK"))
  302. alert.addButton(withTitle: L("Cancel"))
  303. completionHandler(alert.runModal() == .alertFirstButtonReturn)
  304. }
  305. func webView(
  306. _ webView: WKWebView,
  307. runJavaScriptTextInputPanelWithPrompt prompt: String,
  308. defaultText: String?,
  309. initiatedByFrame frame: WKFrameInfo,
  310. completionHandler: @escaping (String?) -> Void
  311. ) {
  312. let alert = NSAlert()
  313. alert.messageText = prompt
  314. alert.addButton(withTitle: L("OK"))
  315. alert.addButton(withTitle: L("Cancel"))
  316. let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24))
  317. field.stringValue = defaultText ?? ""
  318. alert.accessoryView = field
  319. guard alert.runModal() == .alertFirstButtonReturn else {
  320. completionHandler(nil)
  321. return
  322. }
  323. completionHandler(field.stringValue)
  324. }
  325. /// Safari-like UA without the app name suffix — Cloudflare / Indeed bot checks reject `App for Indeed/…` in the UA string.
  326. 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"
  327. }
  328. // MARK: - Embedded browsing policy
  329. /// Keeps Indeed and apply helpers (Cloudflare, Google sign-in, reCAPTCHA) in-app; opens company career sites in the system browser.
  330. enum IndeedWebBrowsingPolicy {
  331. /// Company career sites and other non-Indeed apply links (e.g. “Apply on company site”).
  332. static func shouldOpenInSystemBrowser(url: URL) -> Bool {
  333. guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
  334. return false
  335. }
  336. guard let host = url.host?.lowercased() else { return false }
  337. if shouldRemainInEmbeddedWebView(url: url) { return false }
  338. if isRestrictedPlatformHost(host) { return false }
  339. return true
  340. }
  341. /// URLs that must load inside the embedded `WKWebView` (not the default browser).
  342. static func shouldRemainInEmbeddedWebView(url: URL) -> Bool {
  343. guard let scheme = url.scheme?.lowercased() else { return false }
  344. if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
  345. guard scheme == "http" || scheme == "https" else { return false }
  346. guard let host = url.host?.lowercased() else { return false }
  347. if isIndeedHost(host) { return true }
  348. if isAllowedGoogleSignInHost(host, path: url.path) { return true }
  349. if isGooglePolicyOrLegalPage(host: host, path: url.path) { return true }
  350. if isGoogleRecaptchaHost(host, path: url.path) { return true }
  351. if isCloudflareChallengeHost(host) { return true }
  352. if isHCaptchaHost(host) { return true }
  353. return false
  354. }
  355. static func isIndeedHost(_ host: String) -> Bool {
  356. if host == "indeed.com" { return true }
  357. if host.hasPrefix("indeed.") { return true }
  358. return host.contains(".indeed.")
  359. }
  360. static func allows(navigationAction: WKNavigationAction) -> Bool {
  361. guard let url = navigationAction.request.url else { return true }
  362. let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
  363. if isMainFrame {
  364. return allowsMainFrameNavigation(to: url)
  365. }
  366. return allowsSubframeNavigation(to: url)
  367. }
  368. static func allowsMainFrameNavigation(to url: URL) -> Bool {
  369. guard let scheme = url.scheme?.lowercased() else { return false }
  370. if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
  371. guard scheme == "http" || scheme == "https" else { return false }
  372. guard let host = url.host?.lowercased() else { return false }
  373. if shouldRemainInEmbeddedWebView(url: url) { return true }
  374. if isRestrictedPlatformHost(host) { return false }
  375. return true
  376. }
  377. static func allowsSubframeNavigation(to url: URL) -> Bool {
  378. guard let scheme = url.scheme?.lowercased() else { return false }
  379. if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
  380. guard scheme == "http" || scheme == "https" else { return false }
  381. guard let host = url.host?.lowercased() else { return false }
  382. if isYouTubeRelatedHost(host) {
  383. return false
  384. }
  385. if !isGooglePropertyHost(host) {
  386. return true
  387. }
  388. if isAllowedGoogleSignInHost(host, path: url.path) {
  389. return true
  390. }
  391. if isGooglePolicyOrLegalPage(host: host, path: url.path) {
  392. return true
  393. }
  394. if isGoogleRecaptchaHost(host, path: url.path) {
  395. return true
  396. }
  397. if isCloudflareChallengeHost(host) {
  398. return true
  399. }
  400. return isHCaptchaHost(host)
  401. }
  402. /// Google, YouTube, and similar — stay in the sign-in flow; never open in the system browser.
  403. private static func isRestrictedPlatformHost(_ host: String) -> Bool {
  404. isGooglePropertyHost(host) || isYouTubeRelatedHost(host)
  405. }
  406. private static func isYouTubeRelatedHost(_ host: String) -> Bool {
  407. if host == "youtu.be" { return true }
  408. if host == "youtube.com" || host.hasSuffix(".youtube.com") { return true }
  409. if host.contains("youtube") { return true }
  410. return false
  411. }
  412. /// Privacy / Terms / Help links on the Google account chooser (not YouTube, Gmail, or Search).
  413. private static func isGooglePolicyOrLegalPage(host: String, path: String) -> Bool {
  414. if host == "policies.google.com" || host == "privacy.google.com" {
  415. return true
  416. }
  417. if host == "support.google.com" {
  418. let pathLower = path.lowercased()
  419. return pathLower.contains("/accounts")
  420. }
  421. if host == "www.google.com" || host == "google.com" {
  422. let pathLower = path.lowercased()
  423. return pathLower.contains("/policies")
  424. || pathLower.contains("/privacy")
  425. || pathLower.contains("/terms")
  426. || pathLower.contains("/intl/")
  427. }
  428. return false
  429. }
  430. /// Cloudflare Turnstile / bot check during Indeed apply (`challenges.cloudflare.com`, …).
  431. private static func isCloudflareChallengeHost(_ host: String) -> Bool {
  432. if host == "cloudflare.com" || host.hasSuffix(".cloudflare.com") { return true }
  433. return false
  434. }
  435. /// hCaptcha widget hosts used on some apply flows.
  436. private static func isHCaptchaHost(_ host: String) -> Bool {
  437. host == "hcaptcha.com" || host.hasSuffix(".hcaptcha.com")
  438. }
  439. /// Google reCAPTCHA during Indeed Easy Apply (`recaptcha.net`, `google.com/recaptcha`, `gstatic.com`, …).
  440. private static func isGoogleRecaptchaHost(_ host: String, path: String) -> Bool {
  441. if host == "recaptcha.net" || host.hasSuffix(".recaptcha.net") {
  442. return true
  443. }
  444. let pathLower = path.lowercased()
  445. if host == "recaptcha.google.com" || host.hasPrefix("recaptcha.google.") {
  446. return true
  447. }
  448. if host == "google.com" || host.hasSuffix(".google.com"), pathLower.contains("recaptcha") {
  449. return true
  450. }
  451. if host == "gstatic.com" || host.hasSuffix(".gstatic.com") {
  452. return true
  453. }
  454. return false
  455. }
  456. private static func isGooglePropertyHost(_ host: String) -> Bool {
  457. if host == "google.com" || host.hasSuffix(".google.com") { return true }
  458. if host == "gmail.com" || host.hasSuffix(".gmail.com") { return true }
  459. if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") { return true }
  460. if host == "gstatic.com" || host.hasSuffix(".gstatic.com") { return true }
  461. if host == "youtube.com" || host.hasSuffix(".youtube.com") { return true }
  462. if host == "blogger.com" || host.hasSuffix(".blogger.com") { return true }
  463. if host == "withgoogle.com" || host.hasSuffix(".withgoogle.com") { return true }
  464. return false
  465. }
  466. /// Hosts used during “Sign in with Google” — not Help Center, Gmail, Drive, Search, or the apps launcher.
  467. private static func isAllowedGoogleSignInHost(_ host: String, path: String) -> Bool {
  468. if host == "accounts.google.com" || host.hasPrefix("accounts.google.") {
  469. return true
  470. }
  471. if host == "signin.google.com" {
  472. return true
  473. }
  474. if host == "oauth.googleusercontent.com" {
  475. return true
  476. }
  477. if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") {
  478. return true
  479. }
  480. if host == "apis.google.com" {
  481. return true
  482. }
  483. if isGooglePolicyOrLegalPage(host: host, path: path) {
  484. return true
  485. }
  486. if host == "myaccount.google.com" {
  487. return isGoogleSignInRelatedPath(path)
  488. }
  489. if host == "www.google.com" || host == "google.com" {
  490. return isGoogleSignInRelatedPath(path)
  491. }
  492. return false
  493. }
  494. private static func isGoogleSignInRelatedPath(_ path: String) -> Bool {
  495. let pathLower = path.lowercased()
  496. return pathLower.contains("/signin")
  497. || pathLower.contains("/accounts")
  498. || pathLower.contains("servicelogin")
  499. || pathLower.contains("/oauth")
  500. || pathLower.contains("/gsi")
  501. || pathLower.contains("/device")
  502. || pathLower.contains("/security")
  503. }
  504. /// First Google OAuth hop after leaving Indeed (`/o/oauth2/.../auth`, account chooser, …).
  505. static func isGoogleSignInEntryURL(_ url: URL) -> Bool {
  506. guard let host = url.host?.lowercased(),
  507. host == "accounts.google.com" || host.hasPrefix("accounts.google.") else {
  508. return false
  509. }
  510. let path = url.path.lowercased()
  511. if path.contains("/oauth2/") && path.contains("auth") { return true }
  512. if path.contains("accountchooser") { return true }
  513. return false
  514. }
  515. /// Whether to inject `prompt=select_account` once for this OAuth attempt.
  516. static func shouldApplyGoogleAccountPicker(to url: URL) -> Bool {
  517. guard isGoogleSignInEntryURL(url) else { return false }
  518. let path = url.path.lowercased()
  519. if path.contains("/signin/oauth/") || path.contains("/signin/v2/") { return false }
  520. let query = url.query?.lowercased() ?? ""
  521. if query.contains("select_account") { return false }
  522. if query.contains("authuser=") { return false }
  523. return true
  524. }
  525. /// Adds `prompt=select_account` so Google shows the account chooser instead of silent sign-in.
  526. static func urlAddingGoogleAccountPickerPrompt(_ url: URL) -> URL {
  527. guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
  528. var items = components.queryItems ?? []
  529. items.removeAll { $0.name.compare("login_hint", options: .caseInsensitive) == .orderedSame }
  530. items.removeAll { $0.name.compare("hint", options: .caseInsensitive) == .orderedSame }
  531. if let promptIndex = items.firstIndex(where: { $0.name.compare("prompt", options: .caseInsensitive) == .orderedSame }) {
  532. let existing = items[promptIndex].value ?? ""
  533. if !existing.lowercased().contains("select_account") {
  534. let merged = existing.isEmpty ? "select_account" : "\(existing) select_account"
  535. items[promptIndex] = URLQueryItem(name: "prompt", value: merged)
  536. }
  537. } else {
  538. items.append(URLQueryItem(name: "prompt", value: "select_account"))
  539. }
  540. components.queryItems = items.isEmpty ? nil : items
  541. return components.url ?? url
  542. }
  543. }