|
|
@@ -8,15 +8,25 @@ 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()
|
|
|
@@ -178,6 +188,9 @@ final class IndeedJobBrowserViewController: NSViewController, WKNavigationDelega
|
|
|
}
|
|
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
|
+ if let host = webView.url?.host?.lowercased(), IndeedWebBrowsingPolicy.isIndeedHost(host) {
|
|
|
+ pendingGoogleAccountPicker = false
|
|
|
+ }
|
|
|
updateNavigationButtons()
|
|
|
}
|
|
|
|
|
|
@@ -191,44 +204,154 @@ final class IndeedJobBrowserViewController: NSViewController, WKNavigationDelega
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
|
|
|
- if isMainFrame,
|
|
|
- let url = navigationAction.request.url,
|
|
|
- IndeedWebBrowsingPolicy.shouldForceGoogleAccountPicker(for: url) {
|
|
|
- let pickerURL = IndeedWebBrowsingPolicy.urlForcingGoogleAccountPicker(url)
|
|
|
- if pickerURL != url {
|
|
|
- webView.load(URLRequest(url: pickerURL))
|
|
|
- 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: load in this view so apply flows stay in-app.
|
|
|
+ /// 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
|
|
|
}
|
|
|
- var request = navigationAction.request
|
|
|
- if let url = request.url,
|
|
|
- IndeedWebBrowsingPolicy.shouldForceGoogleAccountPicker(for: url) {
|
|
|
- request = URLRequest(url: IndeedWebBrowsingPolicy.urlForcingGoogleAccountPicker(url))
|
|
|
+ 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(request)
|
|
|
+ webView.load(navigationAction.request)
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
- /// Desktop Safari UA helps Indeed serve a full desktop apply experience.
|
|
|
- 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"
|
|
|
+ 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: NSLocalizedString("OK", comment: "Web alert dismiss"))
|
|
|
+ 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: NSLocalizedString("OK", comment: "Web confirm accept"))
|
|
|
+ alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Web confirm 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: NSLocalizedString("OK", comment: "Web prompt accept"))
|
|
|
+ alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Web prompt 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: - Google sign-in only (no Gmail, Help hub, Search, etc.)
|
|
|
+// MARK: - Embedded browsing policy
|
|
|
|
|
|
-/// Limits embedded browsing on Google-owned hosts to OAuth / sign-in, while leaving Indeed and third-party apply sites unrestricted.
|
|
|
+/// 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
|
|
|
@@ -244,10 +367,9 @@ enum IndeedWebBrowsingPolicy {
|
|
|
guard scheme == "http" || scheme == "https" else { return false }
|
|
|
guard let host = url.host?.lowercased() else { return false }
|
|
|
|
|
|
- if !isGooglePropertyHost(host) {
|
|
|
- return true
|
|
|
- }
|
|
|
- return isAllowedGoogleSignInHost(host, path: url.path)
|
|
|
+ if shouldRemainInEmbeddedWebView(url: url) { return true }
|
|
|
+ if isRestrictedPlatformHost(host) { return false }
|
|
|
+ return true
|
|
|
}
|
|
|
|
|
|
static func allowsSubframeNavigation(to url: URL) -> Bool {
|
|
|
@@ -256,14 +378,79 @@ enum IndeedWebBrowsingPolicy {
|
|
|
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
|
|
|
}
|
|
|
- let path = url.path.lowercased()
|
|
|
- if host == "www.google.com", path.contains("/recaptcha") {
|
|
|
+ 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") {
|
|
|
@@ -283,25 +470,72 @@ enum IndeedWebBrowsingPolicy {
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
- /// True for Google OAuth / sign-in navigations where we inject `prompt=select_account`.
|
|
|
- static func shouldForceGoogleAccountPicker(for url: URL) -> Bool {
|
|
|
+ /// 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("oauth")
|
|
|
- || path.contains("accountchooser")
|
|
|
- || path.contains("/gsi/")
|
|
|
- || path.hasPrefix("/signin/") {
|
|
|
- return true
|
|
|
- }
|
|
|
+ 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() ?? ""
|
|
|
- return query.contains("client_id=")
|
|
|
+ if query.contains("select_account") { return false }
|
|
|
+ if query.contains("authuser=") { return false }
|
|
|
+ return true
|
|
|
}
|
|
|
|
|
|
- /// Rewrites Google sign-in URLs so the account chooser is always shown (not silent reuse of the last account).
|
|
|
- static func urlForcingGoogleAccountPicker(_ url: URL) -> URL {
|
|
|
+ /// 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 ?? []
|
|
|
|
|
|
@@ -321,27 +555,4 @@ enum IndeedWebBrowsingPolicy {
|
|
|
components.queryItems = items.isEmpty ? nil : items
|
|
|
return components.url ?? url
|
|
|
}
|
|
|
-
|
|
|
- /// 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 == "policies.google.com" || host == "privacy.google.com" {
|
|
|
- return true
|
|
|
- }
|
|
|
- if host == "apis.google.com" {
|
|
|
- return true
|
|
|
- }
|
|
|
- return false
|
|
|
- }
|
|
|
}
|