Nessuna descrizione

IndeedJobBrowserWindowController.swift 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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. /// When set, a leading **Home** control calls this so the host can hide the embedded browser (same-window UX).
  10. var onDismissEmbedded: (() -> Void)?
  11. private let webView: WKWebView = {
  12. let configuration = WKWebViewConfiguration()
  13. configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
  14. return WKWebView(frame: .zero, configuration: configuration)
  15. }()
  16. private var pendingURL: URL?
  17. private let backButton = NSButton()
  18. private let forwardButton = NSButton()
  19. private let reloadButton = NSButton()
  20. private let dismissEmbeddedButton = NSButton(title: "Home", target: nil, action: nil)
  21. private let toolbarContainer = NSView()
  22. private var appearanceObserver: NSObjectProtocol?
  23. override func loadView() {
  24. view = NSView(frame: NSRect(x: 0, y: 0, width: 920, height: 720))
  25. }
  26. override func viewDidLoad() {
  27. super.viewDidLoad()
  28. webView.translatesAutoresizingMaskIntoConstraints = false
  29. webView.navigationDelegate = self
  30. webView.uiDelegate = self
  31. webView.customUserAgent = Self.desktopSafariLikeUserAgent
  32. configureToolbarButton(backButton, symbolName: "chevron.backward", action: #selector(goBack))
  33. configureToolbarButton(forwardButton, symbolName: "chevron.forward", action: #selector(goForward))
  34. configureToolbarButton(reloadButton, symbolName: "arrow.clockwise", action: #selector(reload))
  35. dismissEmbeddedButton.translatesAutoresizingMaskIntoConstraints = false
  36. dismissEmbeddedButton.bezelStyle = .rounded
  37. dismissEmbeddedButton.isBordered = true
  38. dismissEmbeddedButton.target = self
  39. dismissEmbeddedButton.action = #selector(dismissEmbedded)
  40. dismissEmbeddedButton.toolTip = "Return to the previous screen"
  41. toolbarContainer.translatesAutoresizingMaskIntoConstraints = false
  42. toolbarContainer.wantsLayer = true
  43. let barStack: NSStackView
  44. if onDismissEmbedded != nil {
  45. barStack = NSStackView(views: [dismissEmbeddedButton, backButton, forwardButton, reloadButton, NSView()])
  46. } else {
  47. barStack = NSStackView(views: [backButton, forwardButton, reloadButton, NSView()])
  48. }
  49. barStack.orientation = .horizontal
  50. barStack.spacing = 8
  51. barStack.alignment = .centerY
  52. barStack.distribution = .fill
  53. barStack.translatesAutoresizingMaskIntoConstraints = false
  54. toolbarContainer.addSubview(barStack)
  55. view.addSubview(toolbarContainer)
  56. view.addSubview(webView)
  57. var layoutConstraints: [NSLayoutConstraint] = [
  58. toolbarContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  59. toolbarContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  60. toolbarContainer.topAnchor.constraint(equalTo: view.topAnchor),
  61. toolbarContainer.heightAnchor.constraint(equalToConstant: 48),
  62. barStack.leadingAnchor.constraint(equalTo: toolbarContainer.leadingAnchor, constant: 12),
  63. barStack.trailingAnchor.constraint(equalTo: toolbarContainer.trailingAnchor, constant: -12),
  64. barStack.centerYAnchor.constraint(equalTo: toolbarContainer.centerYAnchor),
  65. backButton.widthAnchor.constraint(equalToConstant: 32),
  66. backButton.heightAnchor.constraint(equalToConstant: 28),
  67. forwardButton.widthAnchor.constraint(equalToConstant: 32),
  68. forwardButton.heightAnchor.constraint(equalToConstant: 28),
  69. reloadButton.widthAnchor.constraint(equalToConstant: 32),
  70. reloadButton.heightAnchor.constraint(equalToConstant: 28),
  71. webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  72. webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  73. webView.topAnchor.constraint(equalTo: toolbarContainer.bottomAnchor),
  74. webView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  75. ]
  76. if onDismissEmbedded != nil {
  77. layoutConstraints.append(dismissEmbeddedButton.heightAnchor.constraint(equalToConstant: 28))
  78. }
  79. NSLayoutConstraint.activate(layoutConstraints)
  80. updateNavigationButtons()
  81. applyCurrentAppearance()
  82. appearanceObserver = NotificationCenter.default.addObserver(
  83. forName: AppAppearanceManager.didChangeNotification,
  84. object: nil,
  85. queue: .main
  86. ) { [weak self] _ in
  87. self?.applyCurrentAppearance()
  88. }
  89. if let pendingURL {
  90. webView.load(URLRequest(url: pendingURL))
  91. self.pendingURL = nil
  92. }
  93. }
  94. deinit {
  95. if let appearanceObserver {
  96. NotificationCenter.default.removeObserver(appearanceObserver)
  97. }
  98. }
  99. func loadPage(_ url: URL) {
  100. if isViewLoaded {
  101. webView.load(URLRequest(url: url))
  102. } else {
  103. pendingURL = url
  104. }
  105. updateNavigationButtons()
  106. }
  107. /// Adds this controller as a child of `parent` and pins `view` to `host` (used by the dashboard main panel).
  108. func embed(in host: NSView, parent: NSViewController) {
  109. parent.addChild(self)
  110. view.translatesAutoresizingMaskIntoConstraints = false
  111. host.addSubview(view)
  112. NSLayoutConstraint.activate([
  113. view.leadingAnchor.constraint(equalTo: host.leadingAnchor),
  114. view.trailingAnchor.constraint(equalTo: host.trailingAnchor),
  115. view.topAnchor.constraint(equalTo: host.topAnchor),
  116. view.bottomAnchor.constraint(equalTo: host.bottomAnchor)
  117. ])
  118. }
  119. private func configureToolbarButton(_ button: NSButton, symbolName: String, action: Selector) {
  120. button.translatesAutoresizingMaskIntoConstraints = false
  121. button.bezelStyle = .texturedRounded
  122. button.isBordered = true
  123. button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
  124. button.imagePosition = .imageOnly
  125. button.target = self
  126. button.action = action
  127. }
  128. private func applyCurrentAppearance() {
  129. toolbarContainer.layer?.backgroundColor = AppDashboardTheme.chromeBackground.cgColor
  130. let accent = AppDashboardTheme.brandBlue
  131. dismissEmbeddedButton.contentTintColor = accent
  132. backButton.contentTintColor = accent
  133. forwardButton.contentTintColor = accent
  134. reloadButton.contentTintColor = accent
  135. }
  136. private func updateNavigationButtons() {
  137. backButton.isEnabled = webView.canGoBack
  138. forwardButton.isEnabled = webView.canGoForward
  139. }
  140. @objc private func goBack() {
  141. webView.goBack()
  142. }
  143. @objc private func goForward() {
  144. webView.goForward()
  145. }
  146. @objc private func reload() {
  147. webView.reload()
  148. }
  149. @objc private func dismissEmbedded() {
  150. onDismissEmbedded?()
  151. }
  152. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  153. updateNavigationButtons()
  154. }
  155. func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
  156. updateNavigationButtons()
  157. }
  158. func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
  159. guard IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else {
  160. decisionHandler(.cancel)
  161. return
  162. }
  163. let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
  164. if isMainFrame,
  165. let url = navigationAction.request.url,
  166. IndeedWebBrowsingPolicy.shouldForceGoogleAccountPicker(for: url) {
  167. let pickerURL = IndeedWebBrowsingPolicy.urlForcingGoogleAccountPicker(url)
  168. if pickerURL != url {
  169. webView.load(URLRequest(url: pickerURL))
  170. decisionHandler(.cancel)
  171. return
  172. }
  173. }
  174. decisionHandler(.allow)
  175. }
  176. /// Target=_blank / `window.open` without a frame: load in this view so apply flows stay in-app.
  177. func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
  178. guard navigationAction.targetFrame == nil,
  179. IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else {
  180. return nil
  181. }
  182. var request = navigationAction.request
  183. if let url = request.url,
  184. IndeedWebBrowsingPolicy.shouldForceGoogleAccountPicker(for: url) {
  185. request = URLRequest(url: IndeedWebBrowsingPolicy.urlForcingGoogleAccountPicker(url))
  186. }
  187. webView.load(request)
  188. return nil
  189. }
  190. /// Desktop Safari UA helps Indeed serve a full desktop apply experience.
  191. private static let desktopSafariLikeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"
  192. }
  193. // MARK: - Google sign-in only (no Gmail, Help hub, Search, etc.)
  194. /// Limits embedded browsing on Google-owned hosts to OAuth / sign-in, while leaving Indeed and third-party apply sites unrestricted.
  195. enum IndeedWebBrowsingPolicy {
  196. static func allows(navigationAction: WKNavigationAction) -> Bool {
  197. guard let url = navigationAction.request.url else { return true }
  198. let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
  199. if isMainFrame {
  200. return allowsMainFrameNavigation(to: url)
  201. }
  202. return allowsSubframeNavigation(to: url)
  203. }
  204. static func allowsMainFrameNavigation(to url: URL) -> Bool {
  205. guard let scheme = url.scheme?.lowercased() else { return false }
  206. if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
  207. guard scheme == "http" || scheme == "https" else { return false }
  208. guard let host = url.host?.lowercased() else { return false }
  209. if !isGooglePropertyHost(host) {
  210. return true
  211. }
  212. return isAllowedGoogleSignInHost(host, path: url.path)
  213. }
  214. static func allowsSubframeNavigation(to url: URL) -> Bool {
  215. guard let scheme = url.scheme?.lowercased() else { return false }
  216. if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
  217. guard scheme == "http" || scheme == "https" else { return false }
  218. guard let host = url.host?.lowercased() else { return false }
  219. if !isGooglePropertyHost(host) {
  220. return true
  221. }
  222. if isAllowedGoogleSignInHost(host, path: url.path) {
  223. return true
  224. }
  225. let path = url.path.lowercased()
  226. if host == "www.google.com", path.contains("/recaptcha") {
  227. return true
  228. }
  229. if host == "gstatic.com" || host.hasSuffix(".gstatic.com") {
  230. return true
  231. }
  232. return false
  233. }
  234. private static func isGooglePropertyHost(_ host: String) -> Bool {
  235. if host == "google.com" || host.hasSuffix(".google.com") { return true }
  236. if host == "gmail.com" || host.hasSuffix(".gmail.com") { return true }
  237. if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") { return true }
  238. if host == "gstatic.com" || host.hasSuffix(".gstatic.com") { return true }
  239. if host == "youtube.com" || host.hasSuffix(".youtube.com") { return true }
  240. if host == "blogger.com" || host.hasSuffix(".blogger.com") { return true }
  241. if host == "withgoogle.com" || host.hasSuffix(".withgoogle.com") { return true }
  242. return false
  243. }
  244. /// True for Google OAuth / sign-in navigations where we inject `prompt=select_account`.
  245. static func shouldForceGoogleAccountPicker(for url: URL) -> Bool {
  246. guard let host = url.host?.lowercased(),
  247. host == "accounts.google.com" || host.hasPrefix("accounts.google.") else {
  248. return false
  249. }
  250. let path = url.path.lowercased()
  251. if path.contains("oauth")
  252. || path.contains("accountchooser")
  253. || path.contains("/gsi/")
  254. || path.hasPrefix("/signin/") {
  255. return true
  256. }
  257. let query = url.query?.lowercased() ?? ""
  258. return query.contains("client_id=")
  259. }
  260. /// Rewrites Google sign-in URLs so the account chooser is always shown (not silent reuse of the last account).
  261. static func urlForcingGoogleAccountPicker(_ url: URL) -> URL {
  262. guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
  263. var items = components.queryItems ?? []
  264. items.removeAll { $0.name.compare("login_hint", options: .caseInsensitive) == .orderedSame }
  265. items.removeAll { $0.name.compare("hint", options: .caseInsensitive) == .orderedSame }
  266. if let promptIndex = items.firstIndex(where: { $0.name.compare("prompt", options: .caseInsensitive) == .orderedSame }) {
  267. let existing = items[promptIndex].value ?? ""
  268. if !existing.lowercased().contains("select_account") {
  269. let merged = existing.isEmpty ? "select_account" : "\(existing) select_account"
  270. items[promptIndex] = URLQueryItem(name: "prompt", value: merged)
  271. }
  272. } else {
  273. items.append(URLQueryItem(name: "prompt", value: "select_account"))
  274. }
  275. components.queryItems = items.isEmpty ? nil : items
  276. return components.url ?? url
  277. }
  278. /// Hosts used during “Sign in with Google” — not Help Center, Gmail, Drive, Search, or the apps launcher.
  279. private static func isAllowedGoogleSignInHost(_ host: String, path: String) -> Bool {
  280. if host == "accounts.google.com" || host.hasPrefix("accounts.google.") {
  281. return true
  282. }
  283. if host == "signin.google.com" {
  284. return true
  285. }
  286. if host == "oauth.googleusercontent.com" {
  287. return true
  288. }
  289. if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") {
  290. return true
  291. }
  292. if host == "policies.google.com" || host == "privacy.google.com" {
  293. return true
  294. }
  295. if host == "apis.google.com" {
  296. return true
  297. }
  298. return false
  299. }
  300. }