// // IndeedJobBrowserWindowController.swift // App for Indeed // import Cocoa import WebKit /// Indeed job listing and apply flow in a `WKWebView`, embedded in the dashboard main panel or hosted in a window. final class IndeedJobBrowserViewController: NSViewController, WKNavigationDelegate, WKUIDelegate { /// Shared pool so Indeed / Cloudflare cookies persist across embedded browser sessions. private static let sharedProcessPool = WKProcessPool() /// When set, a leading **Home** control calls this so the host can hide the embedded browser (same-window UX). var onDismissEmbedded: (() -> Void)? private let webView: WKWebView = { let configuration = WKWebViewConfiguration() configuration.processPool = IndeedJobBrowserViewController.sharedProcessPool configuration.websiteDataStore = .default() configuration.preferences.javaScriptCanOpenWindowsAutomatically = true if #available(macOS 11.0, *) { configuration.defaultWebpagePreferences.allowsContentJavaScript = true } return WKWebView(frame: .zero, configuration: configuration) }() private var pendingURL: URL? /// Set when the user starts Google sign-in from Indeed; cleared after one `prompt=select_account` rewrite. private var pendingGoogleAccountPicker = false private let backButton = NSButton() private let forwardButton = NSButton() private let reloadButton = NSButton() private let dismissEmbeddedButton = NSButton() private let toolbarContainer = NSView() private var appearanceObserver: NSObjectProtocol? private var languageObserver: NSObjectProtocol? override func loadView() { view = NSView(frame: NSRect(x: 0, y: 0, width: 920, height: 720)) } override func viewDidLoad() { super.viewDidLoad() webView.translatesAutoresizingMaskIntoConstraints = false webView.navigationDelegate = self webView.uiDelegate = self webView.customUserAgent = Self.desktopSafariLikeUserAgent configureSymbolToolbarButton(backButton, symbolName: "chevron.backward", action: #selector(goBack)) configureSymbolToolbarButton(forwardButton, symbolName: "chevron.forward", action: #selector(goForward)) configureSymbolToolbarButton(reloadButton, symbolName: "arrow.clockwise", action: #selector(reload)) configureSymbolToolbarButton( dismissEmbeddedButton, symbolName: "house.fill", action: #selector(dismissEmbedded), toolTipKey: "Return to the previous screen" ) toolbarContainer.translatesAutoresizingMaskIntoConstraints = false toolbarContainer.wantsLayer = true let barStack: NSStackView if onDismissEmbedded != nil { barStack = NSStackView(views: [dismissEmbeddedButton, backButton, forwardButton, reloadButton, NSView()]) } else { barStack = NSStackView(views: [backButton, forwardButton, reloadButton, NSView()]) } barStack.orientation = .horizontal barStack.spacing = 8 barStack.alignment = .centerY barStack.distribution = .fill barStack.translatesAutoresizingMaskIntoConstraints = false toolbarContainer.addSubview(barStack) view.addSubview(toolbarContainer) view.addSubview(webView) var layoutConstraints: [NSLayoutConstraint] = [ toolbarContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), toolbarContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), toolbarContainer.topAnchor.constraint(equalTo: view.topAnchor), toolbarContainer.heightAnchor.constraint(equalToConstant: 48), barStack.leadingAnchor.constraint(equalTo: toolbarContainer.leadingAnchor, constant: 12), barStack.trailingAnchor.constraint(equalTo: toolbarContainer.trailingAnchor, constant: -12), barStack.centerYAnchor.constraint(equalTo: toolbarContainer.centerYAnchor), backButton.widthAnchor.constraint(equalToConstant: 32), backButton.heightAnchor.constraint(equalToConstant: 28), forwardButton.widthAnchor.constraint(equalToConstant: 32), forwardButton.heightAnchor.constraint(equalToConstant: 28), reloadButton.widthAnchor.constraint(equalToConstant: 32), reloadButton.heightAnchor.constraint(equalToConstant: 28), webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.topAnchor.constraint(equalTo: toolbarContainer.bottomAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ] if onDismissEmbedded != nil { layoutConstraints.append(contentsOf: [ dismissEmbeddedButton.widthAnchor.constraint(equalToConstant: 32), dismissEmbeddedButton.heightAnchor.constraint(equalToConstant: 28) ]) } NSLayoutConstraint.activate(layoutConstraints) applyLocalizedStrings() updateNavigationButtons() applyCurrentAppearance() appearanceObserver = NotificationCenter.default.addObserver( forName: AppAppearanceManager.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in self?.applyCurrentAppearance() } languageObserver = NotificationCenter.default.addObserver( forName: AppLanguageManager.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in self?.applyLocalizedStrings() } if let pendingURL { webView.load(URLRequest(url: pendingURL)) self.pendingURL = nil } } deinit { if let appearanceObserver { NotificationCenter.default.removeObserver(appearanceObserver) } if let languageObserver { NotificationCenter.default.removeObserver(languageObserver) } } func loadPage(_ url: URL) { if isViewLoaded { webView.load(URLRequest(url: url)) } else { pendingURL = url } updateNavigationButtons() } /// Adds this controller as a child of `parent` and pins `view` to `host` (used by the dashboard main panel). func embed(in host: NSView, parent: NSViewController) { parent.addChild(self) view.translatesAutoresizingMaskIntoConstraints = false host.addSubview(view) NSLayoutConstraint.activate([ view.leadingAnchor.constraint(equalTo: host.leadingAnchor), view.trailingAnchor.constraint(equalTo: host.trailingAnchor), view.topAnchor.constraint(equalTo: host.topAnchor), view.bottomAnchor.constraint(equalTo: host.bottomAnchor) ]) } private func applyHomeToolbarButtonStyle(to button: NSButton) { button.bezelStyle = .rounded button.isBordered = true } private func configureSymbolToolbarButton( _ button: NSButton, symbolName: String, action: Selector, toolTipKey: String? = nil ) { button.translatesAutoresizingMaskIntoConstraints = false applyHomeToolbarButtonStyle(to: button) button.imagePosition = .imageOnly button.title = "" button.target = self button.action = action if let toolTipKey { button.toolTip = L(toolTipKey) } updateSymbolToolbarButtonImage(button, symbolName: symbolName, accessibilityLabelKey: toolTipKey) } private func updateSymbolToolbarButtonImage( _ button: NSButton, symbolName: String, accessibilityLabelKey: String? = nil ) { let label = accessibilityLabelKey.map { L($0) } button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: label) } private func applyCurrentAppearance() { toolbarContainer.layer?.backgroundColor = AppDashboardTheme.chromeBackground.cgColor let labelColor = AppDashboardTheme.primaryText dismissEmbeddedButton.contentTintColor = labelColor reloadButton.contentTintColor = labelColor updateNavigationButtons() } private func applyLocalizedStrings() { dismissEmbeddedButton.toolTip = L("Return to the previous screen") updateSymbolToolbarButtonImage( dismissEmbeddedButton, symbolName: "house.fill", accessibilityLabelKey: "Return to the previous screen" ) updateSymbolToolbarButtonImage(backButton, symbolName: "chevron.backward") updateSymbolToolbarButtonImage(forwardButton, symbolName: "chevron.forward") updateSymbolToolbarButtonImage(reloadButton, symbolName: "arrow.clockwise") } private func updateNavigationButtons() { // Keep buttons enabled so the rounded chrome matches Home; dim only the chevrons when unavailable. backButton.isEnabled = true forwardButton.isEnabled = true let active = AppDashboardTheme.primaryText let inactive = AppDashboardTheme.secondaryText backButton.contentTintColor = webView.canGoBack ? active : inactive forwardButton.contentTintColor = webView.canGoForward ? active : inactive } @objc private func goBack() { guard webView.canGoBack else { return } webView.goBack() } @objc private func goForward() { guard webView.canGoForward else { return } webView.goForward() } @objc private func reload() { webView.reload() } @objc private func dismissEmbedded() { onDismissEmbedded?() } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { if let host = webView.url?.host?.lowercased(), IndeedWebBrowsingPolicy.isIndeedHost(host) { pendingGoogleAccountPicker = false } updateNavigationButtons() } func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { updateNavigationButtons() } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else { decisionHandler(.cancel) return } if let url = navigationAction.request.url, IndeedWebBrowsingPolicy.shouldOpenInSystemBrowser(url: url) { NSWorkspace.shared.open(url) decisionHandler(.cancel) return } noteGoogleSignInStartedFromIndeed(navigationAction) if let url = navigationAction.request.url, applyGoogleAccountPickerIfNeeded(to: url, in: webView) { decisionHandler(.cancel) return } decisionHandler(.allow) } /// Target=_blank / `window.open` without a frame: Indeed stays in-app; company apply sites open in the default browser. func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { guard navigationAction.targetFrame == nil, IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else { return nil } if let url = navigationAction.request.url, IndeedWebBrowsingPolicy.shouldOpenInSystemBrowser(url: url) { NSWorkspace.shared.open(url) return nil } noteGoogleSignInStartedFromIndeed(navigationAction) if let url = navigationAction.request.url, applyGoogleAccountPickerIfNeeded(to: url, in: webView) { return nil } webView.load(navigationAction.request) return nil } private func noteGoogleSignInStartedFromIndeed(_ navigationAction: WKNavigationAction) { guard let destination = navigationAction.request.url else { return } let sourceHost = navigationAction.sourceFrame.request.url?.host?.lowercased() ?? navigationAction.request.mainDocumentURL?.host?.lowercased() ?? webView.url?.host?.lowercased() guard let sourceHost, IndeedWebBrowsingPolicy.isIndeedHost(sourceHost) else { return } guard IndeedWebBrowsingPolicy.isGoogleSignInEntryURL(destination) else { return } pendingGoogleAccountPicker = true } @discardableResult private func applyGoogleAccountPickerIfNeeded(to url: URL, in webView: WKWebView) -> Bool { guard pendingGoogleAccountPicker, IndeedWebBrowsingPolicy.shouldApplyGoogleAccountPicker(to: url) else { return false } pendingGoogleAccountPicker = false let pickerURL = IndeedWebBrowsingPolicy.urlAddingGoogleAccountPickerPrompt(url) guard pickerURL != url else { return false } webView.load(URLRequest(url: pickerURL)) return true } func webView( _ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void ) { let alert = NSAlert() alert.messageText = message alert.addButton(withTitle: L("OK")) alert.runModal() completionHandler() } func webView( _ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void ) { let alert = NSAlert() alert.messageText = message alert.addButton(withTitle: L("OK")) alert.addButton(withTitle: L("Cancel")) completionHandler(alert.runModal() == .alertFirstButtonReturn) } func webView( _ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void ) { let alert = NSAlert() alert.messageText = prompt alert.addButton(withTitle: L("OK")) alert.addButton(withTitle: L("Cancel")) let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) field.stringValue = defaultText ?? "" alert.accessoryView = field guard alert.runModal() == .alertFirstButtonReturn else { completionHandler(nil) return } completionHandler(field.stringValue) } /// Safari-like UA without the app name suffix — Cloudflare / Indeed bot checks reject `App for Indeed/…` in the UA string. 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" } // MARK: - Embedded browsing policy /// Keeps Indeed and apply helpers (Cloudflare, Google sign-in, reCAPTCHA) in-app; opens company career sites in the system browser. enum IndeedWebBrowsingPolicy { /// Company career sites and other non-Indeed apply links (e.g. “Apply on company site”). static func shouldOpenInSystemBrowser(url: URL) -> Bool { guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return false } guard let host = url.host?.lowercased() else { return false } if shouldRemainInEmbeddedWebView(url: url) { return false } if isRestrictedPlatformHost(host) { return false } return true } /// URLs that must load inside the embedded `WKWebView` (not the default browser). static func shouldRemainInEmbeddedWebView(url: URL) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } if scheme == "about" || scheme == "blob" || scheme == "data" { return true } guard scheme == "http" || scheme == "https" else { return false } guard let host = url.host?.lowercased() else { return false } if isIndeedHost(host) { return true } if isAllowedGoogleSignInHost(host, path: url.path) { return true } if isGooglePolicyOrLegalPage(host: host, path: url.path) { return true } if isGoogleRecaptchaHost(host, path: url.path) { return true } if isCloudflareChallengeHost(host) { return true } if isHCaptchaHost(host) { return true } return false } static func isIndeedHost(_ host: String) -> Bool { if host == "indeed.com" { return true } if host.hasPrefix("indeed.") { return true } return host.contains(".indeed.") } static func allows(navigationAction: WKNavigationAction) -> Bool { guard let url = navigationAction.request.url else { return true } let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true if isMainFrame { return allowsMainFrameNavigation(to: url) } return allowsSubframeNavigation(to: url) } static func allowsMainFrameNavigation(to url: URL) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } if scheme == "about" || scheme == "blob" || scheme == "data" { return true } guard scheme == "http" || scheme == "https" else { return false } guard let host = url.host?.lowercased() else { return false } if shouldRemainInEmbeddedWebView(url: url) { return true } if isRestrictedPlatformHost(host) { return false } return true } static func allowsSubframeNavigation(to url: URL) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } if scheme == "about" || scheme == "blob" || scheme == "data" { return true } guard scheme == "http" || scheme == "https" else { return false } guard let host = url.host?.lowercased() else { return false } if isYouTubeRelatedHost(host) { return false } if !isGooglePropertyHost(host) { return true } if isAllowedGoogleSignInHost(host, path: url.path) { return true } if isGooglePolicyOrLegalPage(host: host, path: url.path) { return true } if isGoogleRecaptchaHost(host, path: url.path) { return true } if isCloudflareChallengeHost(host) { return true } return isHCaptchaHost(host) } /// Google, YouTube, and similar — stay in the sign-in flow; never open in the system browser. private static func isRestrictedPlatformHost(_ host: String) -> Bool { isGooglePropertyHost(host) || isYouTubeRelatedHost(host) } private static func isYouTubeRelatedHost(_ host: String) -> Bool { if host == "youtu.be" { return true } if host == "youtube.com" || host.hasSuffix(".youtube.com") { return true } if host.contains("youtube") { return true } return false } /// Privacy / Terms / Help links on the Google account chooser (not YouTube, Gmail, or Search). private static func isGooglePolicyOrLegalPage(host: String, path: String) -> Bool { if host == "policies.google.com" || host == "privacy.google.com" { return true } if host == "support.google.com" { let pathLower = path.lowercased() return pathLower.contains("/accounts") } if host == "www.google.com" || host == "google.com" { let pathLower = path.lowercased() return pathLower.contains("/policies") || pathLower.contains("/privacy") || pathLower.contains("/terms") || pathLower.contains("/intl/") } return false } /// Cloudflare Turnstile / bot check during Indeed apply (`challenges.cloudflare.com`, …). private static func isCloudflareChallengeHost(_ host: String) -> Bool { if host == "cloudflare.com" || host.hasSuffix(".cloudflare.com") { return true } return false } /// hCaptcha widget hosts used on some apply flows. private static func isHCaptchaHost(_ host: String) -> Bool { host == "hcaptcha.com" || host.hasSuffix(".hcaptcha.com") } /// Google reCAPTCHA during Indeed Easy Apply (`recaptcha.net`, `google.com/recaptcha`, `gstatic.com`, …). private static func isGoogleRecaptchaHost(_ host: String, path: String) -> Bool { if host == "recaptcha.net" || host.hasSuffix(".recaptcha.net") { return true } let pathLower = path.lowercased() if host == "recaptcha.google.com" || host.hasPrefix("recaptcha.google.") { return true } if host == "google.com" || host.hasSuffix(".google.com"), pathLower.contains("recaptcha") { return true } if host == "gstatic.com" || host.hasSuffix(".gstatic.com") { return true } return false } private static func isGooglePropertyHost(_ host: String) -> Bool { if host == "google.com" || host.hasSuffix(".google.com") { return true } if host == "gmail.com" || host.hasSuffix(".gmail.com") { return true } if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") { return true } if host == "gstatic.com" || host.hasSuffix(".gstatic.com") { return true } if host == "youtube.com" || host.hasSuffix(".youtube.com") { return true } if host == "blogger.com" || host.hasSuffix(".blogger.com") { return true } if host == "withgoogle.com" || host.hasSuffix(".withgoogle.com") { return true } return false } /// Hosts used during “Sign in with Google” — not Help Center, Gmail, Drive, Search, or the apps launcher. private static func isAllowedGoogleSignInHost(_ host: String, path: String) -> Bool { if host == "accounts.google.com" || host.hasPrefix("accounts.google.") { return true } if host == "signin.google.com" { return true } if host == "oauth.googleusercontent.com" { return true } if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") { return true } if host == "apis.google.com" { return true } if isGooglePolicyOrLegalPage(host: host, path: path) { return true } if host == "myaccount.google.com" { return isGoogleSignInRelatedPath(path) } if host == "www.google.com" || host == "google.com" { return isGoogleSignInRelatedPath(path) } return false } private static func isGoogleSignInRelatedPath(_ path: String) -> Bool { let pathLower = path.lowercased() return pathLower.contains("/signin") || pathLower.contains("/accounts") || pathLower.contains("servicelogin") || pathLower.contains("/oauth") || pathLower.contains("/gsi") || pathLower.contains("/device") || pathLower.contains("/security") } /// First Google OAuth hop after leaving Indeed (`/o/oauth2/.../auth`, account chooser, …). static func isGoogleSignInEntryURL(_ url: URL) -> Bool { guard let host = url.host?.lowercased(), host == "accounts.google.com" || host.hasPrefix("accounts.google.") else { return false } let path = url.path.lowercased() if path.contains("/oauth2/") && path.contains("auth") { return true } if path.contains("accountchooser") { return true } return false } /// Whether to inject `prompt=select_account` once for this OAuth attempt. static func shouldApplyGoogleAccountPicker(to url: URL) -> Bool { guard isGoogleSignInEntryURL(url) else { return false } let path = url.path.lowercased() if path.contains("/signin/oauth/") || path.contains("/signin/v2/") { return false } let query = url.query?.lowercased() ?? "" if query.contains("select_account") { return false } if query.contains("authuser=") { return false } return true } /// Adds `prompt=select_account` so Google shows the account chooser instead of silent sign-in. static func urlAddingGoogleAccountPickerPrompt(_ url: URL) -> URL { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } var items = components.queryItems ?? [] items.removeAll { $0.name.compare("login_hint", options: .caseInsensitive) == .orderedSame } items.removeAll { $0.name.compare("hint", options: .caseInsensitive) == .orderedSame } if let promptIndex = items.firstIndex(where: { $0.name.compare("prompt", options: .caseInsensitive) == .orderedSame }) { let existing = items[promptIndex].value ?? "" if !existing.lowercased().contains("select_account") { let merged = existing.isEmpty ? "select_account" : "\(existing) select_account" items[promptIndex] = URLQueryItem(name: "prompt", value: merged) } } else { items.append(URLQueryItem(name: "prompt", value: "select_account")) } components.queryItems = items.isEmpty ? nil : items return components.url ?? url } }