Преглед изворни кода

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 пре 3 недеља
родитељ
комит
a8f55c5476

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

@@ -0,0 +1,188 @@
1
+//
2
+//  IndeedJobBrowserWindowController.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Cocoa
7
+import WebKit
8
+
9
+/// Indeed job listing and apply flow in a `WKWebView`, embedded in the dashboard main panel or hosted in a window.
10
+final class IndeedJobBrowserViewController: NSViewController, WKNavigationDelegate, WKUIDelegate {
11
+    /// When set, a leading **Done** control calls this so the host can hide the embedded browser (same-window UX).
12
+    var onDismissEmbedded: (() -> Void)?
13
+    private let webView: WKWebView = {
14
+        let configuration = WKWebViewConfiguration()
15
+        configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
16
+        return WKWebView(frame: .zero, configuration: configuration)
17
+    }()
18
+
19
+    private var pendingURL: URL?
20
+
21
+    private let backButton = NSButton()
22
+    private let forwardButton = NSButton()
23
+    private let reloadButton = NSButton()
24
+    private let openExternallyButton = NSButton(title: "Open in Browser", target: nil, action: nil)
25
+    private let dismissEmbeddedButton = NSButton(title: "Done", target: nil, action: nil)
26
+
27
+    override func loadView() {
28
+        view = NSView(frame: NSRect(x: 0, y: 0, width: 920, height: 720))
29
+    }
30
+
31
+    override func viewDidLoad() {
32
+        super.viewDidLoad()
33
+        webView.translatesAutoresizingMaskIntoConstraints = false
34
+        webView.navigationDelegate = self
35
+        webView.uiDelegate = self
36
+        webView.customUserAgent = Self.desktopSafariLikeUserAgent
37
+
38
+        configureToolbarButton(backButton, symbolName: "chevron.backward", action: #selector(goBack))
39
+        configureToolbarButton(forwardButton, symbolName: "chevron.forward", action: #selector(goForward))
40
+        configureToolbarButton(reloadButton, symbolName: "arrow.clockwise", action: #selector(reload))
41
+
42
+        openExternallyButton.translatesAutoresizingMaskIntoConstraints = false
43
+        openExternallyButton.bezelStyle = .rounded
44
+        openExternallyButton.isBordered = true
45
+        openExternallyButton.target = self
46
+        openExternallyButton.action = #selector(openInDefaultBrowser)
47
+        openExternallyButton.toolTip = "Open this page in your default web browser"
48
+
49
+        dismissEmbeddedButton.translatesAutoresizingMaskIntoConstraints = false
50
+        dismissEmbeddedButton.bezelStyle = .rounded
51
+        dismissEmbeddedButton.isBordered = true
52
+        dismissEmbeddedButton.target = self
53
+        dismissEmbeddedButton.action = #selector(dismissEmbedded)
54
+        dismissEmbeddedButton.toolTip = "Return to the previous screen"
55
+
56
+        let toolbar = NSView()
57
+        toolbar.translatesAutoresizingMaskIntoConstraints = false
58
+        toolbar.wantsLayer = true
59
+        toolbar.layer?.backgroundColor = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1).cgColor
60
+
61
+        let barStack: NSStackView
62
+        if onDismissEmbedded != nil {
63
+            barStack = NSStackView(views: [dismissEmbeddedButton, backButton, forwardButton, reloadButton, NSView(), openExternallyButton])
64
+        } else {
65
+            barStack = NSStackView(views: [backButton, forwardButton, reloadButton, NSView(), openExternallyButton])
66
+        }
67
+        barStack.orientation = .horizontal
68
+        barStack.spacing = 8
69
+        barStack.alignment = .centerY
70
+        barStack.distribution = .fill
71
+        barStack.translatesAutoresizingMaskIntoConstraints = false
72
+
73
+        toolbar.addSubview(barStack)
74
+        view.addSubview(toolbar)
75
+        view.addSubview(webView)
76
+
77
+        var layoutConstraints: [NSLayoutConstraint] = [
78
+            toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
79
+            toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
80
+            toolbar.topAnchor.constraint(equalTo: view.topAnchor),
81
+            toolbar.heightAnchor.constraint(equalToConstant: 48),
82
+
83
+            barStack.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor, constant: 12),
84
+            barStack.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor, constant: -12),
85
+            barStack.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor),
86
+
87
+            backButton.widthAnchor.constraint(equalToConstant: 32),
88
+            backButton.heightAnchor.constraint(equalToConstant: 28),
89
+            forwardButton.widthAnchor.constraint(equalToConstant: 32),
90
+            forwardButton.heightAnchor.constraint(equalToConstant: 28),
91
+            reloadButton.widthAnchor.constraint(equalToConstant: 32),
92
+            reloadButton.heightAnchor.constraint(equalToConstant: 28),
93
+
94
+            webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
95
+            webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
96
+            webView.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
97
+            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
98
+        ]
99
+        if onDismissEmbedded != nil {
100
+            layoutConstraints.append(dismissEmbeddedButton.heightAnchor.constraint(equalToConstant: 28))
101
+        }
102
+        NSLayoutConstraint.activate(layoutConstraints)
103
+
104
+        updateNavigationButtons()
105
+
106
+        if let pendingURL {
107
+            webView.load(URLRequest(url: pendingURL))
108
+            self.pendingURL = nil
109
+        }
110
+    }
111
+
112
+    func loadPage(_ url: URL) {
113
+        if isViewLoaded {
114
+            webView.load(URLRequest(url: url))
115
+        } else {
116
+            pendingURL = url
117
+        }
118
+        updateNavigationButtons()
119
+    }
120
+
121
+    /// Adds this controller as a child of `parent` and pins `view` to `host` (used by the dashboard main panel).
122
+    func embed(in host: NSView, parent: NSViewController) {
123
+        parent.addChild(self)
124
+        view.translatesAutoresizingMaskIntoConstraints = false
125
+        host.addSubview(view)
126
+        NSLayoutConstraint.activate([
127
+            view.leadingAnchor.constraint(equalTo: host.leadingAnchor),
128
+            view.trailingAnchor.constraint(equalTo: host.trailingAnchor),
129
+            view.topAnchor.constraint(equalTo: host.topAnchor),
130
+            view.bottomAnchor.constraint(equalTo: host.bottomAnchor)
131
+        ])
132
+    }
133
+
134
+    private func configureToolbarButton(_ button: NSButton, symbolName: String, action: Selector) {
135
+        button.translatesAutoresizingMaskIntoConstraints = false
136
+        button.bezelStyle = .texturedRounded
137
+        button.isBordered = true
138
+        button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
139
+        button.imagePosition = .imageOnly
140
+        button.target = self
141
+        button.action = action
142
+    }
143
+
144
+    private func updateNavigationButtons() {
145
+        backButton.isEnabled = webView.canGoBack
146
+        forwardButton.isEnabled = webView.canGoForward
147
+    }
148
+
149
+    @objc private func goBack() {
150
+        webView.goBack()
151
+    }
152
+
153
+    @objc private func goForward() {
154
+        webView.goForward()
155
+    }
156
+
157
+    @objc private func reload() {
158
+        webView.reload()
159
+    }
160
+
161
+    @objc private func openInDefaultBrowser() {
162
+        guard let url = webView.url else { return }
163
+        NSWorkspace.shared.open(url)
164
+    }
165
+
166
+    @objc private func dismissEmbedded() {
167
+        onDismissEmbedded?()
168
+    }
169
+
170
+    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
171
+        updateNavigationButtons()
172
+    }
173
+
174
+    func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
175
+        updateNavigationButtons()
176
+    }
177
+
178
+    /// Target=_blank / `window.open` without a frame: load in this view so apply flows stay in-app.
179
+    func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
180
+        if navigationAction.targetFrame == nil {
181
+            webView.load(navigationAction.request)
182
+        }
183
+        return nil
184
+    }
185
+
186
+    /// Desktop Safari UA helps Indeed serve a full desktop apply experience.
187
+    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"
188
+}

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

@@ -106,6 +106,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
106 106
     private let chatStack = NSStackView()
107 107
     /// Shown when a sidebar item other than Home is selected.
108 108
     private let nonHomeHost = NSView()
109
+    /// Full-bleed Indeed apply / listing web view inside the main panel (same window as the dashboard).
110
+    private let indeedJobBrowserHost = NSView()
109 111
     private let nonHomeGenericContainer = NSView()
110 112
     private let nonHomeTitleLabel = NSTextField(labelWithString: "")
111 113
     private let nonHomeSubtitleLabel = NSTextField(wrappingLabelWithString: "")
@@ -137,6 +139,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
137 139
     private var chatThinkingRowHost: NSView?
138 140
     private let jobSearchService = OpenAIJobSearchService()
139 141
     private var premiumPlansWindowController: PremiumPlansWindowController?
142
+    private var indeedJobBrowserViewController: IndeedJobBrowserViewController?
143
+    private var isIndeedJobBrowserPresented = false
140 144
     private weak var sidebarUpgradeCard: NSView?
141 145
     private weak var sidebarUpgradeHeadline: NSTextField?
142 146
     private weak var sidebarUpgradeDescription: NSTextField?
@@ -187,6 +191,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
187 191
     }
188 192
 
189 193
     func render(_ data: DashboardData) {
194
+        dismissIndeedJobBrowserEmbedded()
190 195
         greetingLabel.stringValue = "Welcome"
191 196
         subtitleLabel.stringValue = data.subtitle
192 197
         currentSidebarItems = data.sidebarItems
@@ -241,6 +246,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
241 246
         configureNonHomePlaceholder()
242 247
         mainHost.addSubview(nonHomeHost)
243 248
 
249
+        indeedJobBrowserHost.translatesAutoresizingMaskIntoConstraints = false
250
+        indeedJobBrowserHost.wantsLayer = true
251
+        indeedJobBrowserHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
252
+        indeedJobBrowserHost.isHidden = true
253
+        mainHost.addSubview(indeedJobBrowserHost)
254
+
244 255
         mainOverlay.orientation = .vertical
245 256
         mainOverlay.spacing = 0
246 257
         mainOverlay.alignment = .centerX
@@ -347,6 +358,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
347 358
             nonHomeHost.topAnchor.constraint(equalTo: mainHost.topAnchor),
348 359
             nonHomeHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
349 360
 
361
+            indeedJobBrowserHost.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor),
362
+            indeedJobBrowserHost.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor),
363
+            indeedJobBrowserHost.topAnchor.constraint(equalTo: mainHost.topAnchor),
364
+            indeedJobBrowserHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
365
+
350 366
             searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
351 367
             featureCardsRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
352 368
             chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
@@ -824,14 +840,49 @@ final class DashboardView: NSView, NSTextFieldDelegate {
824 840
 
825 841
     @objc private func didTapJobApply(_ sender: NSButton) {
826 842
         guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
827
-        if let rawURL = job.url, let url = URL(string: rawURL), !rawURL.isEmpty {
828
-            NSWorkspace.shared.open(url)
829
-            return
843
+        let url: URL
844
+        if let rawURL = job.url, let resolved = URL(string: rawURL), !rawURL.isEmpty {
845
+            url = resolved
846
+        } else {
847
+            let allowed = CharacterSet.urlQueryAllowed
848
+            let q = job.title.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
849
+            guard let fallback = URL(string: "https://www.indeed.com/jobs?q=\(q)") else { return }
850
+            url = fallback
851
+        }
852
+        presentIndeedJobBrowser(url: url)
853
+    }
854
+
855
+    private func presentIndeedJobBrowser(url: URL) {
856
+        guard let parentVC = hostingViewController else { return }
857
+
858
+        if indeedJobBrowserViewController == nil {
859
+            let vc = IndeedJobBrowserViewController()
860
+            vc.onDismissEmbedded = { [weak self] in
861
+                self?.dismissIndeedJobBrowserEmbedded()
862
+            }
863
+            vc.embed(in: indeedJobBrowserHost, parent: parentVC)
864
+            indeedJobBrowserViewController = vc
865
+        }
866
+        indeedJobBrowserViewController?.loadPage(url)
867
+        isIndeedJobBrowserPresented = true
868
+        updateMainContentVisibility()
869
+    }
870
+
871
+    private func dismissIndeedJobBrowserEmbedded() {
872
+        guard isIndeedJobBrowserPresented else { return }
873
+        isIndeedJobBrowserPresented = false
874
+        updateMainContentVisibility()
875
+    }
876
+
877
+    private var hostingViewController: NSViewController? {
878
+        var responder: NSResponder? = self
879
+        while let current = responder {
880
+            if let viewController = current as? NSViewController {
881
+                return viewController
882
+            }
883
+            responder = current.nextResponder
830 884
         }
831
-        let allowed = CharacterSet.urlQueryAllowed
832
-        let q = job.title.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
833
-        guard let url = URL(string: "https://www.indeed.com/jobs?q=\(q)") else { return }
834
-        NSWorkspace.shared.open(url)
885
+        return nil
835 886
     }
836 887
 
837 888
     @objc private func didTapJobSaved(_ sender: NSButton) {
@@ -1448,6 +1499,13 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1448 1499
     }
1449 1500
 
1450 1501
     private func updateMainContentVisibility() {
1502
+        if isIndeedJobBrowserPresented {
1503
+            mainOverlay.isHidden = true
1504
+            nonHomeHost.isHidden = true
1505
+            indeedJobBrowserHost.isHidden = false
1506
+            return
1507
+        }
1508
+        indeedJobBrowserHost.isHidden = true
1451 1509
         let home = isHomeSidebarIndex(selectedSidebarIndex)
1452 1510
         let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex)
1453 1511
         let settings = isSettingsSidebarIndex(selectedSidebarIndex)
@@ -2208,6 +2266,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
2208 2266
     }
2209 2267
 
2210 2268
     private func selectSidebarItem(at index: Int) {
2269
+        dismissIndeedJobBrowserEmbedded()
2211 2270
         guard index >= 0, index < currentSidebarItems.count else { return }
2212 2271
         let selectingHome = isHomeSidebarIndex(index)
2213 2272
         if index == selectedSidebarIndex {