ソースを参照

Improve embedded Indeed browser navigation for apply, sign-in, and external links.

Open company career sites in the default browser while keeping Indeed, Cloudflare, reCAPTCHA, and Google OAuth in the web view. Show the Google account chooser once per sign-in attempt without breaking the post-selection flow, block YouTube and other platform detours, and allow policy pages in-app.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 週間 前
コミット
9e7949b128
共有1 個のファイルを変更した271 個の追加60 個の削除を含む
  1. 271 60
      App for Indeed/Controllers/IndeedJobBrowserWindowController.swift

+ 271 - 60
App for Indeed/Controllers/IndeedJobBrowserWindowController.swift

@@ -8,15 +8,25 @@ import WebKit
8 8
 
9 9
 /// Indeed job listing and apply flow in a `WKWebView`, embedded in the dashboard main panel or hosted in a window.
10 10
 final class IndeedJobBrowserViewController: NSViewController, WKNavigationDelegate, WKUIDelegate {
11
+    /// Shared pool so Indeed / Cloudflare cookies persist across embedded browser sessions.
12
+    private static let sharedProcessPool = WKProcessPool()
13
+
11 14
     /// When set, a leading **Home** control calls this so the host can hide the embedded browser (same-window UX).
12 15
     var onDismissEmbedded: (() -> Void)?
13 16
     private let webView: WKWebView = {
14 17
         let configuration = WKWebViewConfiguration()
18
+        configuration.processPool = IndeedJobBrowserViewController.sharedProcessPool
19
+        configuration.websiteDataStore = .default()
15 20
         configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
21
+        if #available(macOS 11.0, *) {
22
+            configuration.defaultWebpagePreferences.allowsContentJavaScript = true
23
+        }
16 24
         return WKWebView(frame: .zero, configuration: configuration)
17 25
     }()
18 26
 
19 27
     private var pendingURL: URL?
28
+    /// Set when the user starts Google sign-in from Indeed; cleared after one `prompt=select_account` rewrite.
29
+    private var pendingGoogleAccountPicker = false
20 30
 
21 31
     private let backButton = NSButton()
22 32
     private let forwardButton = NSButton()
@@ -178,6 +188,9 @@ final class IndeedJobBrowserViewController: NSViewController, WKNavigationDelega
178 188
     }
179 189
 
180 190
     func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
191
+        if let host = webView.url?.host?.lowercased(), IndeedWebBrowsingPolicy.isIndeedHost(host) {
192
+            pendingGoogleAccountPicker = false
193
+        }
181 194
         updateNavigationButtons()
182 195
     }
183 196
 
@@ -191,44 +204,154 @@ final class IndeedJobBrowserViewController: NSViewController, WKNavigationDelega
191 204
             return
192 205
         }
193 206
 
194
-        let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
195
-        if isMainFrame,
196
-           let url = navigationAction.request.url,
197
-           IndeedWebBrowsingPolicy.shouldForceGoogleAccountPicker(for: url) {
198
-            let pickerURL = IndeedWebBrowsingPolicy.urlForcingGoogleAccountPicker(url)
199
-            if pickerURL != url {
200
-                webView.load(URLRequest(url: pickerURL))
201
-                decisionHandler(.cancel)
202
-                return
203
-            }
207
+        if let url = navigationAction.request.url,
208
+           IndeedWebBrowsingPolicy.shouldOpenInSystemBrowser(url: url) {
209
+            NSWorkspace.shared.open(url)
210
+            decisionHandler(.cancel)
211
+            return
212
+        }
213
+
214
+        noteGoogleSignInStartedFromIndeed(navigationAction)
215
+        if let url = navigationAction.request.url,
216
+           applyGoogleAccountPickerIfNeeded(to: url, in: webView) {
217
+            decisionHandler(.cancel)
218
+            return
204 219
         }
205 220
 
206 221
         decisionHandler(.allow)
207 222
     }
208 223
 
209
-    /// Target=_blank / `window.open` without a frame: load in this view so apply flows stay in-app.
224
+    /// Target=_blank / `window.open` without a frame: Indeed stays in-app; company apply sites open in the default browser.
210 225
     func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
211 226
         guard navigationAction.targetFrame == nil,
212 227
               IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else {
213 228
             return nil
214 229
         }
215
-        var request = navigationAction.request
216
-        if let url = request.url,
217
-           IndeedWebBrowsingPolicy.shouldForceGoogleAccountPicker(for: url) {
218
-            request = URLRequest(url: IndeedWebBrowsingPolicy.urlForcingGoogleAccountPicker(url))
230
+        if let url = navigationAction.request.url,
231
+           IndeedWebBrowsingPolicy.shouldOpenInSystemBrowser(url: url) {
232
+            NSWorkspace.shared.open(url)
233
+            return nil
234
+        }
235
+        noteGoogleSignInStartedFromIndeed(navigationAction)
236
+        if let url = navigationAction.request.url,
237
+           applyGoogleAccountPickerIfNeeded(to: url, in: webView) {
238
+            return nil
219 239
         }
220
-        webView.load(request)
240
+        webView.load(navigationAction.request)
221 241
         return nil
222 242
     }
223 243
 
224
-    /// Desktop Safari UA helps Indeed serve a full desktop apply experience.
225
-    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"
244
+    private func noteGoogleSignInStartedFromIndeed(_ navigationAction: WKNavigationAction) {
245
+        guard let destination = navigationAction.request.url else { return }
246
+        let sourceHost = navigationAction.sourceFrame.request.url?.host?.lowercased()
247
+            ?? navigationAction.request.mainDocumentURL?.host?.lowercased()
248
+            ?? webView.url?.host?.lowercased()
249
+        guard let sourceHost, IndeedWebBrowsingPolicy.isIndeedHost(sourceHost) else { return }
250
+        guard IndeedWebBrowsingPolicy.isGoogleSignInEntryURL(destination) else { return }
251
+        pendingGoogleAccountPicker = true
252
+    }
253
+
254
+    @discardableResult
255
+    private func applyGoogleAccountPickerIfNeeded(to url: URL, in webView: WKWebView) -> Bool {
256
+        guard pendingGoogleAccountPicker,
257
+              IndeedWebBrowsingPolicy.shouldApplyGoogleAccountPicker(to: url) else {
258
+            return false
259
+        }
260
+        pendingGoogleAccountPicker = false
261
+        let pickerURL = IndeedWebBrowsingPolicy.urlAddingGoogleAccountPickerPrompt(url)
262
+        guard pickerURL != url else { return false }
263
+        webView.load(URLRequest(url: pickerURL))
264
+        return true
265
+    }
266
+
267
+    func webView(
268
+        _ webView: WKWebView,
269
+        runJavaScriptAlertPanelWithMessage message: String,
270
+        initiatedByFrame frame: WKFrameInfo,
271
+        completionHandler: @escaping () -> Void
272
+    ) {
273
+        let alert = NSAlert()
274
+        alert.messageText = message
275
+        alert.addButton(withTitle: NSLocalizedString("OK", comment: "Web alert dismiss"))
276
+        alert.runModal()
277
+        completionHandler()
278
+    }
279
+
280
+    func webView(
281
+        _ webView: WKWebView,
282
+        runJavaScriptConfirmPanelWithMessage message: String,
283
+        initiatedByFrame frame: WKFrameInfo,
284
+        completionHandler: @escaping (Bool) -> Void
285
+    ) {
286
+        let alert = NSAlert()
287
+        alert.messageText = message
288
+        alert.addButton(withTitle: NSLocalizedString("OK", comment: "Web confirm accept"))
289
+        alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Web confirm cancel"))
290
+        completionHandler(alert.runModal() == .alertFirstButtonReturn)
291
+    }
292
+
293
+    func webView(
294
+        _ webView: WKWebView,
295
+        runJavaScriptTextInputPanelWithPrompt prompt: String,
296
+        defaultText: String?,
297
+        initiatedByFrame frame: WKFrameInfo,
298
+        completionHandler: @escaping (String?) -> Void
299
+    ) {
300
+        let alert = NSAlert()
301
+        alert.messageText = prompt
302
+        alert.addButton(withTitle: NSLocalizedString("OK", comment: "Web prompt accept"))
303
+        alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Web prompt cancel"))
304
+        let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24))
305
+        field.stringValue = defaultText ?? ""
306
+        alert.accessoryView = field
307
+        guard alert.runModal() == .alertFirstButtonReturn else {
308
+            completionHandler(nil)
309
+            return
310
+        }
311
+        completionHandler(field.stringValue)
312
+    }
313
+
314
+    /// Safari-like UA without the app name suffix — Cloudflare / Indeed bot checks reject `App for Indeed/…` in the UA string.
315
+    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"
226 316
 }
227 317
 
228
-// MARK: - Google sign-in only (no Gmail, Help hub, Search, etc.)
318
+// MARK: - Embedded browsing policy
229 319
 
230
-/// Limits embedded browsing on Google-owned hosts to OAuth / sign-in, while leaving Indeed and third-party apply sites unrestricted.
320
+/// Keeps Indeed and apply helpers (Cloudflare, Google sign-in, reCAPTCHA) in-app; opens company career sites in the system browser.
231 321
 enum IndeedWebBrowsingPolicy {
322
+    /// Company career sites and other non-Indeed apply links (e.g. “Apply on company site”).
323
+    static func shouldOpenInSystemBrowser(url: URL) -> Bool {
324
+        guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
325
+            return false
326
+        }
327
+        guard let host = url.host?.lowercased() else { return false }
328
+        if shouldRemainInEmbeddedWebView(url: url) { return false }
329
+        if isRestrictedPlatformHost(host) { return false }
330
+        return true
331
+    }
332
+
333
+    /// URLs that must load inside the embedded `WKWebView` (not the default browser).
334
+    static func shouldRemainInEmbeddedWebView(url: URL) -> Bool {
335
+        guard let scheme = url.scheme?.lowercased() else { return false }
336
+        if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
337
+        guard scheme == "http" || scheme == "https" else { return false }
338
+        guard let host = url.host?.lowercased() else { return false }
339
+
340
+        if isIndeedHost(host) { return true }
341
+        if isAllowedGoogleSignInHost(host, path: url.path) { return true }
342
+        if isGooglePolicyOrLegalPage(host: host, path: url.path) { return true }
343
+        if isGoogleRecaptchaHost(host, path: url.path) { return true }
344
+        if isCloudflareChallengeHost(host) { return true }
345
+        if isHCaptchaHost(host) { return true }
346
+        return false
347
+    }
348
+
349
+    static func isIndeedHost(_ host: String) -> Bool {
350
+        if host == "indeed.com" { return true }
351
+        if host.hasPrefix("indeed.") { return true }
352
+        return host.contains(".indeed.")
353
+    }
354
+
232 355
     static func allows(navigationAction: WKNavigationAction) -> Bool {
233 356
         guard let url = navigationAction.request.url else { return true }
234 357
         let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
@@ -244,10 +367,9 @@ enum IndeedWebBrowsingPolicy {
244 367
         guard scheme == "http" || scheme == "https" else { return false }
245 368
         guard let host = url.host?.lowercased() else { return false }
246 369
 
247
-        if !isGooglePropertyHost(host) {
248
-            return true
249
-        }
250
-        return isAllowedGoogleSignInHost(host, path: url.path)
370
+        if shouldRemainInEmbeddedWebView(url: url) { return true }
371
+        if isRestrictedPlatformHost(host) { return false }
372
+        return true
251 373
     }
252 374
 
253 375
     static func allowsSubframeNavigation(to url: URL) -> Bool {
@@ -256,14 +378,79 @@ enum IndeedWebBrowsingPolicy {
256 378
         guard scheme == "http" || scheme == "https" else { return false }
257 379
         guard let host = url.host?.lowercased() else { return false }
258 380
 
381
+        if isYouTubeRelatedHost(host) {
382
+            return false
383
+        }
259 384
         if !isGooglePropertyHost(host) {
260 385
             return true
261 386
         }
262 387
         if isAllowedGoogleSignInHost(host, path: url.path) {
263 388
             return true
264 389
         }
265
-        let path = url.path.lowercased()
266
-        if host == "www.google.com", path.contains("/recaptcha") {
390
+        if isGooglePolicyOrLegalPage(host: host, path: url.path) {
391
+            return true
392
+        }
393
+        if isGoogleRecaptchaHost(host, path: url.path) {
394
+            return true
395
+        }
396
+        if isCloudflareChallengeHost(host) {
397
+            return true
398
+        }
399
+        return isHCaptchaHost(host)
400
+    }
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
+
407
+    private static func isYouTubeRelatedHost(_ host: String) -> Bool {
408
+        if host == "youtu.be" { return true }
409
+        if host == "youtube.com" || host.hasSuffix(".youtube.com") { return true }
410
+        if host.contains("youtube") { return true }
411
+        return false
412
+    }
413
+
414
+    /// Privacy / Terms / Help links on the Google account chooser (not YouTube, Gmail, or Search).
415
+    private static func isGooglePolicyOrLegalPage(host: String, path: String) -> Bool {
416
+        if host == "policies.google.com" || host == "privacy.google.com" {
417
+            return true
418
+        }
419
+        if host == "support.google.com" {
420
+            let pathLower = path.lowercased()
421
+            return pathLower.contains("/accounts")
422
+        }
423
+        if host == "www.google.com" || host == "google.com" {
424
+            let pathLower = path.lowercased()
425
+            return pathLower.contains("/policies")
426
+                || pathLower.contains("/privacy")
427
+                || pathLower.contains("/terms")
428
+                || pathLower.contains("/intl/")
429
+        }
430
+        return false
431
+    }
432
+
433
+    /// Cloudflare Turnstile / bot check during Indeed apply (`challenges.cloudflare.com`, …).
434
+    private static func isCloudflareChallengeHost(_ host: String) -> Bool {
435
+        if host == "cloudflare.com" || host.hasSuffix(".cloudflare.com") { return true }
436
+        return false
437
+    }
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
+
444
+    /// Google reCAPTCHA during Indeed Easy Apply (`recaptcha.net`, `google.com/recaptcha`, `gstatic.com`, …).
445
+    private static func isGoogleRecaptchaHost(_ host: String, path: String) -> Bool {
446
+        if host == "recaptcha.net" || host.hasSuffix(".recaptcha.net") {
447
+            return true
448
+        }
449
+        let pathLower = path.lowercased()
450
+        if host == "recaptcha.google.com" || host.hasPrefix("recaptcha.google.") {
451
+            return true
452
+        }
453
+        if host == "google.com" || host.hasSuffix(".google.com"), pathLower.contains("recaptcha") {
267 454
             return true
268 455
         }
269 456
         if host == "gstatic.com" || host.hasSuffix(".gstatic.com") {
@@ -283,25 +470,72 @@ enum IndeedWebBrowsingPolicy {
283 470
         return false
284 471
     }
285 472
 
286
-    /// True for Google OAuth / sign-in navigations where we inject `prompt=select_account`.
287
-    static func shouldForceGoogleAccountPicker(for url: URL) -> Bool {
473
+    /// Hosts used during “Sign in with Google” — not Help Center, Gmail, Drive, Search, or the apps launcher.
474
+    private static func isAllowedGoogleSignInHost(_ host: String, path: String) -> Bool {
475
+        if host == "accounts.google.com" || host.hasPrefix("accounts.google.") {
476
+            return true
477
+        }
478
+        if host == "signin.google.com" {
479
+            return true
480
+        }
481
+        if host == "oauth.googleusercontent.com" {
482
+            return true
483
+        }
484
+        if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") {
485
+            return true
486
+        }
487
+        if host == "apis.google.com" {
488
+            return true
489
+        }
490
+        if isGooglePolicyOrLegalPage(host: host, path: path) {
491
+            return true
492
+        }
493
+        if host == "myaccount.google.com" {
494
+            return isGoogleSignInRelatedPath(path)
495
+        }
496
+        if host == "www.google.com" || host == "google.com" {
497
+            return isGoogleSignInRelatedPath(path)
498
+        }
499
+        return false
500
+    }
501
+
502
+    private static func isGoogleSignInRelatedPath(_ path: String) -> Bool {
503
+        let pathLower = path.lowercased()
504
+        return pathLower.contains("/signin")
505
+            || pathLower.contains("/accounts")
506
+            || pathLower.contains("servicelogin")
507
+            || pathLower.contains("/oauth")
508
+            || pathLower.contains("/gsi")
509
+            || pathLower.contains("/device")
510
+            || pathLower.contains("/security")
511
+    }
512
+
513
+    /// First Google OAuth hop after leaving Indeed (`/o/oauth2/.../auth`, account chooser, …).
514
+    static func isGoogleSignInEntryURL(_ url: URL) -> Bool {
288 515
         guard let host = url.host?.lowercased(),
289 516
               host == "accounts.google.com" || host.hasPrefix("accounts.google.") else {
290 517
             return false
291 518
         }
292 519
         let path = url.path.lowercased()
293
-        if path.contains("oauth")
294
-            || path.contains("accountchooser")
295
-            || path.contains("/gsi/")
296
-            || path.hasPrefix("/signin/") {
297
-            return true
298
-        }
520
+        if path.contains("/oauth2/") && path.contains("auth") { return true }
521
+        if path.contains("accountchooser") { return true }
522
+        return false
523
+    }
524
+
525
+    /// Whether to inject `prompt=select_account` once for this OAuth attempt.
526
+    static func shouldApplyGoogleAccountPicker(to url: URL) -> Bool {
527
+        guard isGoogleSignInEntryURL(url) else { return false }
528
+        let path = url.path.lowercased()
529
+        if path.contains("/signin/oauth/") || path.contains("/signin/v2/") { return false }
530
+
299 531
         let query = url.query?.lowercased() ?? ""
300
-        return query.contains("client_id=")
532
+        if query.contains("select_account") { return false }
533
+        if query.contains("authuser=") { return false }
534
+        return true
301 535
     }
302 536
 
303
-    /// Rewrites Google sign-in URLs so the account chooser is always shown (not silent reuse of the last account).
304
-    static func urlForcingGoogleAccountPicker(_ url: URL) -> URL {
537
+    /// Adds `prompt=select_account` so Google shows the account chooser instead of silent sign-in.
538
+    static func urlAddingGoogleAccountPickerPrompt(_ url: URL) -> URL {
305 539
         guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
306 540
         var items = components.queryItems ?? []
307 541
 
@@ -321,27 +555,4 @@ enum IndeedWebBrowsingPolicy {
321 555
         components.queryItems = items.isEmpty ? nil : items
322 556
         return components.url ?? url
323 557
     }
324
-
325
-    /// Hosts used during “Sign in with Google” — not Help Center, Gmail, Drive, Search, or the apps launcher.
326
-    private static func isAllowedGoogleSignInHost(_ host: String, path: String) -> Bool {
327
-        if host == "accounts.google.com" || host.hasPrefix("accounts.google.") {
328
-            return true
329
-        }
330
-        if host == "signin.google.com" {
331
-            return true
332
-        }
333
-        if host == "oauth.googleusercontent.com" {
334
-            return true
335
-        }
336
-        if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") {
337
-            return true
338
-        }
339
-        if host == "policies.google.com" || host == "privacy.google.com" {
340
-            return true
341
-        }
342
-        if host == "apis.google.com" {
343
-            return true
344
-        }
345
-        return false
346
-    }
347 558
 }