瀏覽代碼

Embed Indeed job apply flow in dashboard with WKWebView

Add IndeedJobBrowserViewController with navigation toolbar, open-in-browser,
and Done for embedded mode. Job Apply opens the listing in the main panel
instead of the default browser; dismiss on dashboard re-render.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 月之前
父節點
當前提交
a8f55c5476

+ 188 - 0
App for Indeed/Controllers/IndeedJobBrowserWindowController.swift

@@ -0,0 +1,188 @@
+//
+//  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 {
+    /// When set, a leading **Done** 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.preferences.javaScriptCanOpenWindowsAutomatically = true
+        return WKWebView(frame: .zero, configuration: configuration)
+    }()
+
+    private var pendingURL: URL?
+
+    private let backButton = NSButton()
+    private let forwardButton = NSButton()
+    private let reloadButton = NSButton()
+    private let openExternallyButton = NSButton(title: "Open in Browser", target: nil, action: nil)
+    private let dismissEmbeddedButton = NSButton(title: "Done", target: nil, action: nil)
+
+    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
+
+        configureToolbarButton(backButton, symbolName: "chevron.backward", action: #selector(goBack))
+        configureToolbarButton(forwardButton, symbolName: "chevron.forward", action: #selector(goForward))
+        configureToolbarButton(reloadButton, symbolName: "arrow.clockwise", action: #selector(reload))
+
+        openExternallyButton.translatesAutoresizingMaskIntoConstraints = false
+        openExternallyButton.bezelStyle = .rounded
+        openExternallyButton.isBordered = true
+        openExternallyButton.target = self
+        openExternallyButton.action = #selector(openInDefaultBrowser)
+        openExternallyButton.toolTip = "Open this page in your default web browser"
+
+        dismissEmbeddedButton.translatesAutoresizingMaskIntoConstraints = false
+        dismissEmbeddedButton.bezelStyle = .rounded
+        dismissEmbeddedButton.isBordered = true
+        dismissEmbeddedButton.target = self
+        dismissEmbeddedButton.action = #selector(dismissEmbedded)
+        dismissEmbeddedButton.toolTip = "Return to the previous screen"
+
+        let toolbar = NSView()
+        toolbar.translatesAutoresizingMaskIntoConstraints = false
+        toolbar.wantsLayer = true
+        toolbar.layer?.backgroundColor = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1).cgColor
+
+        let barStack: NSStackView
+        if onDismissEmbedded != nil {
+            barStack = NSStackView(views: [dismissEmbeddedButton, backButton, forwardButton, reloadButton, NSView(), openExternallyButton])
+        } else {
+            barStack = NSStackView(views: [backButton, forwardButton, reloadButton, NSView(), openExternallyButton])
+        }
+        barStack.orientation = .horizontal
+        barStack.spacing = 8
+        barStack.alignment = .centerY
+        barStack.distribution = .fill
+        barStack.translatesAutoresizingMaskIntoConstraints = false
+
+        toolbar.addSubview(barStack)
+        view.addSubview(toolbar)
+        view.addSubview(webView)
+
+        var layoutConstraints: [NSLayoutConstraint] = [
+            toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+            toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+            toolbar.topAnchor.constraint(equalTo: view.topAnchor),
+            toolbar.heightAnchor.constraint(equalToConstant: 48),
+
+            barStack.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor, constant: 12),
+            barStack.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor, constant: -12),
+            barStack.centerYAnchor.constraint(equalTo: toolbar.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: toolbar.bottomAnchor),
+            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+        ]
+        if onDismissEmbedded != nil {
+            layoutConstraints.append(dismissEmbeddedButton.heightAnchor.constraint(equalToConstant: 28))
+        }
+        NSLayoutConstraint.activate(layoutConstraints)
+
+        updateNavigationButtons()
+
+        if let pendingURL {
+            webView.load(URLRequest(url: pendingURL))
+            self.pendingURL = nil
+        }
+    }
+
+    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 configureToolbarButton(_ button: NSButton, symbolName: String, action: Selector) {
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.bezelStyle = .texturedRounded
+        button.isBordered = true
+        button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
+        button.imagePosition = .imageOnly
+        button.target = self
+        button.action = action
+    }
+
+    private func updateNavigationButtons() {
+        backButton.isEnabled = webView.canGoBack
+        forwardButton.isEnabled = webView.canGoForward
+    }
+
+    @objc private func goBack() {
+        webView.goBack()
+    }
+
+    @objc private func goForward() {
+        webView.goForward()
+    }
+
+    @objc private func reload() {
+        webView.reload()
+    }
+
+    @objc private func openInDefaultBrowser() {
+        guard let url = webView.url else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    @objc private func dismissEmbedded() {
+        onDismissEmbedded?()
+    }
+
+    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+        updateNavigationButtons()
+    }
+
+    func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
+        updateNavigationButtons()
+    }
+
+    /// Target=_blank / `window.open` without a frame: load in this view so apply flows stay in-app.
+    func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
+        if navigationAction.targetFrame == nil {
+            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"
+}

+ 66 - 7
App for Indeed/Views/DashboardView.swift

@@ -106,6 +106,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private let chatStack = NSStackView()
     /// Shown when a sidebar item other than Home is selected.
     private let nonHomeHost = NSView()
+    /// Full-bleed Indeed apply / listing web view inside the main panel (same window as the dashboard).
+    private let indeedJobBrowserHost = NSView()
     private let nonHomeGenericContainer = NSView()
     private let nonHomeTitleLabel = NSTextField(labelWithString: "")
     private let nonHomeSubtitleLabel = NSTextField(wrappingLabelWithString: "")
@@ -137,6 +139,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private var chatThinkingRowHost: NSView?
     private let jobSearchService = OpenAIJobSearchService()
     private var premiumPlansWindowController: PremiumPlansWindowController?
+    private var indeedJobBrowserViewController: IndeedJobBrowserViewController?
+    private var isIndeedJobBrowserPresented = false
     private weak var sidebarUpgradeCard: NSView?
     private weak var sidebarUpgradeHeadline: NSTextField?
     private weak var sidebarUpgradeDescription: NSTextField?
@@ -187,6 +191,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }
 
     func render(_ data: DashboardData) {
+        dismissIndeedJobBrowserEmbedded()
         greetingLabel.stringValue = "Welcome"
         subtitleLabel.stringValue = data.subtitle
         currentSidebarItems = data.sidebarItems
@@ -241,6 +246,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         configureNonHomePlaceholder()
         mainHost.addSubview(nonHomeHost)
 
+        indeedJobBrowserHost.translatesAutoresizingMaskIntoConstraints = false
+        indeedJobBrowserHost.wantsLayer = true
+        indeedJobBrowserHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
+        indeedJobBrowserHost.isHidden = true
+        mainHost.addSubview(indeedJobBrowserHost)
+
         mainOverlay.orientation = .vertical
         mainOverlay.spacing = 0
         mainOverlay.alignment = .centerX
@@ -347,6 +358,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             nonHomeHost.topAnchor.constraint(equalTo: mainHost.topAnchor),
             nonHomeHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
 
+            indeedJobBrowserHost.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor),
+            indeedJobBrowserHost.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor),
+            indeedJobBrowserHost.topAnchor.constraint(equalTo: mainHost.topAnchor),
+            indeedJobBrowserHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
+
             searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
             featureCardsRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
             chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
@@ -824,14 +840,49 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
     @objc private func didTapJobApply(_ sender: NSButton) {
         guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
-        if let rawURL = job.url, let url = URL(string: rawURL), !rawURL.isEmpty {
-            NSWorkspace.shared.open(url)
-            return
+        let url: URL
+        if let rawURL = job.url, let resolved = URL(string: rawURL), !rawURL.isEmpty {
+            url = resolved
+        } else {
+            let allowed = CharacterSet.urlQueryAllowed
+            let q = job.title.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
+            guard let fallback = URL(string: "https://www.indeed.com/jobs?q=\(q)") else { return }
+            url = fallback
+        }
+        presentIndeedJobBrowser(url: url)
+    }
+
+    private func presentIndeedJobBrowser(url: URL) {
+        guard let parentVC = hostingViewController else { return }
+
+        if indeedJobBrowserViewController == nil {
+            let vc = IndeedJobBrowserViewController()
+            vc.onDismissEmbedded = { [weak self] in
+                self?.dismissIndeedJobBrowserEmbedded()
+            }
+            vc.embed(in: indeedJobBrowserHost, parent: parentVC)
+            indeedJobBrowserViewController = vc
+        }
+        indeedJobBrowserViewController?.loadPage(url)
+        isIndeedJobBrowserPresented = true
+        updateMainContentVisibility()
+    }
+
+    private func dismissIndeedJobBrowserEmbedded() {
+        guard isIndeedJobBrowserPresented else { return }
+        isIndeedJobBrowserPresented = false
+        updateMainContentVisibility()
+    }
+
+    private var hostingViewController: NSViewController? {
+        var responder: NSResponder? = self
+        while let current = responder {
+            if let viewController = current as? NSViewController {
+                return viewController
+            }
+            responder = current.nextResponder
         }
-        let allowed = CharacterSet.urlQueryAllowed
-        let q = job.title.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
-        guard let url = URL(string: "https://www.indeed.com/jobs?q=\(q)") else { return }
-        NSWorkspace.shared.open(url)
+        return nil
     }
 
     @objc private func didTapJobSaved(_ sender: NSButton) {
@@ -1448,6 +1499,13 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }
 
     private func updateMainContentVisibility() {
+        if isIndeedJobBrowserPresented {
+            mainOverlay.isHidden = true
+            nonHomeHost.isHidden = true
+            indeedJobBrowserHost.isHidden = false
+            return
+        }
+        indeedJobBrowserHost.isHidden = true
         let home = isHomeSidebarIndex(selectedSidebarIndex)
         let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex)
         let settings = isSettingsSidebarIndex(selectedSidebarIndex)
@@ -2208,6 +2266,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }
 
     private func selectSidebarItem(at index: Int) {
+        dismissIndeedJobBrowserEmbedded()
         guard index >= 0, index < currentSidebarItems.count else { return }
         let selectingHome = isHomeSidebarIndex(index)
         if index == selectedSidebarIndex {