Просмотр исходного кода

Route web links through embedded panel and move Google OAuth into an in-app sign-in window.

This mirrors classroom-style navigation so http(s) links stay inside the app while preserving external handling for non-web schemes.

Made-with: Cursor
huzaifahayat12 1 месяц назад
Родитель
Сommit
41da06688e
2 измененных файлов с 255 добавлено и 23 удалено
  1. 154 2
      meetings_app/Auth/GoogleOAuthService.swift
  2. 101 21
      meetings_app/ViewController.swift

+ 154 - 2
meetings_app/Auth/GoogleOAuthService.swift

@@ -2,6 +2,7 @@ import Foundation
2
 import CryptoKit
2
 import CryptoKit
3
 import AppKit
3
 import AppKit
4
 import Network
4
 import Network
5
+import WebKit
5
 
6
 
6
 struct GoogleOAuthTokens: Codable, Equatable {
7
 struct GoogleOAuthTokens: Codable, Equatable {
7
     var accessToken: String
8
     var accessToken: String
@@ -50,6 +51,7 @@ final class GoogleOAuthService: NSObject {
50
     ]
51
     ]
51
 
52
 
52
     private let tokenStore = KeychainTokenStore()
53
     private let tokenStore = KeychainTokenStore()
54
+    @MainActor private var inAppOAuthWindowController: InAppOAuthWindowController?
53
     private override init() {}
55
     private override init() {}
54
 
56
 
55
     func configuredClientId() -> String? {
57
     func configuredClientId() -> String? {
@@ -127,7 +129,6 @@ final class GoogleOAuthService: NSObject {
127
     // MARK: - Interactive sign-in (Authorization Code + PKCE)
129
     // MARK: - Interactive sign-in (Authorization Code + PKCE)
128
 
130
 
129
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
131
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
130
-        _ = presentingWindow
131
         guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
132
         guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
132
         guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
133
         guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
133
         let codeVerifier = Self.randomURLSafeString(length: 64)
134
         let codeVerifier = Self.randomURLSafeString(length: 64)
@@ -153,7 +154,15 @@ final class GoogleOAuthService: NSObject {
153
         ]
154
         ]
154
 
155
 
155
         guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
156
         guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
156
-        guard NSWorkspace.shared.open(authURL) else { throw GoogleOAuthError.unableToOpenBrowser }
157
+        let opened = await MainActor.run { [self] in
158
+            openAuthURLInApp(authURL, presentingWindow: presentingWindow)
159
+        }
160
+        guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
161
+        defer {
162
+            Task { @MainActor [weak self] in
163
+                self?.closeInAppOAuthWindow()
164
+            }
165
+        }
157
         let callbackURL = try await loopback.waitForCallback()
166
         let callbackURL = try await loopback.waitForCallback()
158
 
167
 
159
         guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
168
         guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
@@ -177,6 +186,30 @@ final class GoogleOAuthService: NSObject {
177
         )
186
         )
178
     }
187
     }
179
 
188
 
189
+    @MainActor
190
+    private func openAuthURLInApp(_ url: URL, presentingWindow: NSWindow?) -> Bool {
191
+        let controller: InAppOAuthWindowController
192
+        if let existing = inAppOAuthWindowController {
193
+            controller = existing
194
+        } else {
195
+            controller = InAppOAuthWindowController()
196
+            inAppOAuthWindowController = controller
197
+        }
198
+        controller.alignWithPresentingWindow(presentingWindow)
199
+        controller.load(url: url)
200
+        controller.showWindow(nil)
201
+        controller.window?.makeKeyAndOrderFront(nil)
202
+        controller.window?.orderFrontRegardless()
203
+        NSApp.activate(ignoringOtherApps: true)
204
+        return true
205
+    }
206
+
207
+    @MainActor
208
+    private func closeInAppOAuthWindow() {
209
+        inAppOAuthWindowController?.close()
210
+        inAppOAuthWindowController = nil
211
+    }
212
+
180
     private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> GoogleOAuthTokens {
213
     private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> GoogleOAuthTokens {
181
         var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
214
         var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
182
         request.httpMethod = "POST"
215
         request.httpMethod = "POST"
@@ -481,3 +514,122 @@ extension GoogleOAuthError: LocalizedError {
481
     }
514
     }
482
 }
515
 }
483
 
516
 
517
+@MainActor
518
+private final class OAuthWebViewContainerView: NSView {
519
+    private let webView: WKWebView
520
+
521
+    init(webView: WKWebView) {
522
+        self.webView = webView
523
+        super.init(frame: .zero)
524
+        autoresizingMask = [.width, .height]
525
+        addSubview(webView)
526
+    }
527
+
528
+    @available(*, unavailable)
529
+    required init?(coder: NSCoder) {
530
+        nil
531
+    }
532
+
533
+    override func layout() {
534
+        super.layout()
535
+        webView.frame = bounds
536
+    }
537
+}
538
+
539
+@MainActor
540
+private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate {
541
+    private let webView: WKWebView
542
+    private let defaultWindowSize = NSSize(width: 980, height: 760)
543
+
544
+    init() {
545
+        let config = WKWebViewConfiguration()
546
+        if #available(macOS 11.0, *) {
547
+            config.defaultWebpagePreferences.allowsContentJavaScript = true
548
+        }
549
+        self.webView = WKWebView(frame: .zero, configuration: config)
550
+        let container = OAuthWebViewContainerView(webView: webView)
551
+
552
+        let window = NSWindow(
553
+            contentRect: NSRect(origin: .zero, size: defaultWindowSize),
554
+            styleMask: [.titled, .closable, .miniaturizable, .resizable],
555
+            backing: .buffered,
556
+            defer: false
557
+        )
558
+        window.title = "Google Sign-In"
559
+        window.contentView = container
560
+        window.center()
561
+        super.init(window: window)
562
+        webView.navigationDelegate = self
563
+        webView.uiDelegate = self
564
+    }
565
+
566
+    @available(*, unavailable)
567
+    required init?(coder: NSCoder) {
568
+        nil
569
+    }
570
+
571
+    func load(url: URL) {
572
+        webView.load(URLRequest(url: url))
573
+    }
574
+
575
+    func alignWithPresentingWindow(_ presentingWindow: NSWindow?) {
576
+        guard let window else { return }
577
+        if let presentingWindow {
578
+            window.setFrame(presentingWindow.frame, display: false)
579
+            return
580
+        }
581
+        if let screenFrame = NSScreen.main?.visibleFrame {
582
+            let origin = NSPoint(
583
+                x: screenFrame.midX - (defaultWindowSize.width / 2),
584
+                y: screenFrame.midY - (defaultWindowSize.height / 2)
585
+            )
586
+            window.setFrame(NSRect(origin: origin, size: defaultWindowSize), display: false)
587
+        } else {
588
+            window.center()
589
+        }
590
+    }
591
+
592
+    private func shouldOpenURLExternally(_ url: URL) -> Bool {
593
+        let scheme = (url.scheme ?? "").lowercased()
594
+        guard !scheme.isEmpty else { return false }
595
+        return scheme != "about" && scheme != "javascript"
596
+    }
597
+
598
+    func webView(
599
+        _ webView: WKWebView,
600
+        decidePolicyFor navigationAction: WKNavigationAction,
601
+        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
602
+    ) {
603
+        guard let url = navigationAction.request.url else {
604
+            decisionHandler(.allow)
605
+            return
606
+        }
607
+        let scheme = (url.scheme ?? "").lowercased()
608
+        if scheme == "http" || scheme == "https" {
609
+            decisionHandler(.allow)
610
+            return
611
+        }
612
+        if shouldOpenURLExternally(url) {
613
+            NSWorkspace.shared.open(url)
614
+        }
615
+        decisionHandler(.cancel)
616
+    }
617
+
618
+    func webView(
619
+        _ webView: WKWebView,
620
+        createWebViewWith configuration: WKWebViewConfiguration,
621
+        for navigationAction: WKNavigationAction,
622
+        windowFeatures: WKWindowFeatures
623
+    ) -> WKWebView? {
624
+        if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
625
+            let scheme = (requestURL.scheme ?? "").lowercased()
626
+            if scheme == "http" || scheme == "https" {
627
+                webView.load(URLRequest(url: requestURL))
628
+            } else if shouldOpenURLExternally(requestURL) {
629
+                NSWorkspace.shared.open(requestURL)
630
+            }
631
+        }
632
+        return nil
633
+    }
634
+}
635
+

+ 101 - 21
meetings_app/ViewController.swift

@@ -287,6 +287,12 @@ final class ViewController: NSViewController {
287
     private weak var meetLinkField: NSTextField?
287
     private weak var meetLinkField: NSTextField?
288
     private weak var browseAddressField: NSTextField?
288
     private weak var browseAddressField: NSTextField?
289
     private var inAppBrowserWindowController: InAppBrowserWindowController?
289
     private var inAppBrowserWindowController: InAppBrowserWindowController?
290
+    private var embeddedBrowserViewController: InAppBrowserContainerViewController?
291
+    private var embeddedBrowserURL: URL?
292
+    private var embeddedBrowserPolicy: InAppBrowserURLPolicy = .allowAll
293
+    private weak var mainPanelAuthBar: NSView?
294
+    private var mainContentHostTopToAuthConstraint: NSLayoutConstraint?
295
+    private var mainContentHostTopToPanelConstraint: NSLayoutConstraint?
290
     private let googleOAuth = GoogleOAuthService.shared
296
     private let googleOAuth = GoogleOAuthService.shared
291
     private let calendarClient = GoogleCalendarClient()
297
     private let calendarClient = GoogleCalendarClient()
292
     private let storeKitCoordinator = StoreKitCoordinator()
298
     private let storeKitCoordinator = StoreKitCoordinator()
@@ -712,7 +718,7 @@ private extension ViewController {
712
             showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.")
718
             showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.")
713
             return
719
             return
714
         }
720
         }
715
-        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
721
+        openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
716
     }
722
     }
717
 
723
 
718
     @objc private func browseQuickLinkMeetClicked(_ sender: Any?) {
724
     @objc private func browseQuickLinkMeetClicked(_ sender: Any?) {
@@ -722,12 +728,12 @@ private extension ViewController {
722
 
728
 
723
     @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) {
729
     @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) {
724
         guard let url = URL(string: "https://support.google.com/meet") else { return }
730
         guard let url = URL(string: "https://support.google.com/meet") else { return }
725
-        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
731
+        openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
726
     }
732
     }
727
 
733
 
728
     @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) {
734
     @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) {
729
         guard let url = URL(string: "https://support.zoom.us") else { return }
735
         guard let url = URL(string: "https://support.zoom.us") else { return }
730
-        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
736
+        openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
731
     }
737
     }
732
 
738
 
733
     @objc private func instantMeetClicked(_ sender: NSClickGestureRecognizer) {
739
     @objc private func instantMeetClicked(_ sender: NSClickGestureRecognizer) {
@@ -812,6 +818,77 @@ private extension ViewController {
812
         }
818
         }
813
     }
819
     }
814
 
820
 
821
+    private func openURLWithRouting(_ url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
822
+        let scheme = (url.scheme ?? "").lowercased()
823
+        if scheme == "http" || scheme == "https" {
824
+            showEmbeddedWebPage(url, policy: policy)
825
+            return
826
+        }
827
+        openInDefaultBrowser(url: url)
828
+    }
829
+
830
+    private func embeddedBrowserController() -> InAppBrowserContainerViewController {
831
+        if let existing = embeddedBrowserViewController {
832
+            return existing
833
+        }
834
+        let controller = InAppBrowserContainerViewController()
835
+        embeddedBrowserViewController = controller
836
+        return controller
837
+    }
838
+
839
+    private func detachEmbeddedBrowserIfNeeded() {
840
+        guard let controller = embeddedBrowserViewController, controller.parent === self else { return }
841
+        controller.view.removeFromSuperview()
842
+        controller.removeFromParent()
843
+    }
844
+
845
+    private func mountMainContentView(_ child: NSView) {
846
+        guard let host = mainContentHost else { return }
847
+        NSLayoutConstraint.deactivate(mainContentHostPinConstraints)
848
+        mainContentHostPinConstraints.removeAll()
849
+        host.subviews.forEach { $0.removeFromSuperview() }
850
+        child.translatesAutoresizingMaskIntoConstraints = false
851
+        host.addSubview(child)
852
+        mainContentHostPinConstraints = [
853
+            child.leadingAnchor.constraint(equalTo: host.leadingAnchor),
854
+            child.trailingAnchor.constraint(equalTo: host.trailingAnchor),
855
+            child.topAnchor.constraint(equalTo: host.topAnchor),
856
+            child.bottomAnchor.constraint(equalTo: host.bottomAnchor)
857
+        ]
858
+        NSLayoutConstraint.activate(mainContentHostPinConstraints)
859
+    }
860
+
861
+    private func showEmbeddedWebPage(_ url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
862
+        embeddedBrowserURL = url
863
+        embeddedBrowserPolicy = policy
864
+
865
+        let controller = embeddedBrowserController()
866
+        _ = controller.view
867
+        if controller.parent !== self {
868
+            addChild(controller)
869
+        }
870
+        controller.setNavigationPolicy(policy)
871
+        controller.load(url: url)
872
+
873
+        setEmbeddedBrowserLayoutMode(isEmbedded: true)
874
+        applyEmbeddedBrowserWindowTitle(url: url)
875
+        mountMainContentView(controller.view)
876
+    }
877
+
878
+    private func setEmbeddedBrowserLayoutMode(isEmbedded: Bool) {
879
+        mainPanelAuthBar?.isHidden = isEmbedded
880
+        mainContentHostTopToAuthConstraint?.isActive = !isEmbedded
881
+        mainContentHostTopToPanelConstraint?.isActive = isEmbedded
882
+    }
883
+
884
+    private func applyEmbeddedBrowserWindowTitle(url: URL) {
885
+        if let host = url.host, host.isEmpty == false {
886
+            view.window?.title = host
887
+        } else {
888
+            view.window?.title = "Browser"
889
+        }
890
+    }
891
+
815
     private func openRateUsDestination() {
892
     private func openRateUsDestination() {
816
         let configured = (Bundle.main.object(forInfoDictionaryKey: "RateUsURL") as? String)?
893
         let configured = (Bundle.main.object(forInfoDictionaryKey: "RateUsURL") as? String)?
817
             .trimmingCharacters(in: .whitespacesAndNewlines)
894
             .trimmingCharacters(in: .whitespacesAndNewlines)
@@ -938,21 +1015,10 @@ private extension ViewController {
938
         selectedSidebarPage = page
1015
         selectedSidebarPage = page
939
         updateSidebarAppearance()
1016
         updateSidebarAppearance()
940
         applyWindowTitle(for: page)
1017
         applyWindowTitle(for: page)
941
-
942
-        guard let host = mainContentHost else { return }
943
-        NSLayoutConstraint.deactivate(mainContentHostPinConstraints)
944
-        mainContentHostPinConstraints.removeAll()
945
-        host.subviews.forEach { $0.removeFromSuperview() }
1018
+        setEmbeddedBrowserLayoutMode(isEmbedded: false)
1019
+        detachEmbeddedBrowserIfNeeded()
946
         let child = viewForPage(page)
1020
         let child = viewForPage(page)
947
-        child.translatesAutoresizingMaskIntoConstraints = false
948
-        host.addSubview(child)
949
-        mainContentHostPinConstraints = [
950
-            child.leadingAnchor.constraint(equalTo: host.leadingAnchor),
951
-            child.trailingAnchor.constraint(equalTo: host.trailingAnchor),
952
-            child.topAnchor.constraint(equalTo: host.topAnchor),
953
-            child.bottomAnchor.constraint(equalTo: host.bottomAnchor)
954
-        ]
955
-        NSLayoutConstraint.activate(mainContentHostPinConstraints)
1021
+        mountMainContentView(child)
956
     }
1022
     }
957
 
1023
 
958
     private func showSettingsPopover() {
1024
     private func showSettingsPopover() {
@@ -999,6 +1065,14 @@ private extension ViewController {
999
         paywallContinueButton = nil
1065
         paywallContinueButton = nil
1000
         paywallContinueEnabled = true
1066
         paywallContinueEnabled = true
1001
 
1067
 
1068
+        detachEmbeddedBrowserIfNeeded()
1069
+        embeddedBrowserViewController = nil
1070
+        embeddedBrowserURL = nil
1071
+        embeddedBrowserPolicy = .allowAll
1072
+        mainPanelAuthBar = nil
1073
+        mainContentHostTopToAuthConstraint = nil
1074
+        mainContentHostTopToPanelConstraint = nil
1075
+
1002
         googleAccountPopover?.performClose(nil)
1076
         googleAccountPopover?.performClose(nil)
1003
         googleAccountPopover = nil
1077
         googleAccountPopover = nil
1004
 
1078
 
@@ -1026,7 +1100,7 @@ private extension ViewController {
1026
         case .moreApps:
1100
         case .moreApps:
1027
             if let moreAppsURL = Bundle.main.object(forInfoDictionaryKey: "MoreAppsURL") as? String,
1101
             if let moreAppsURL = Bundle.main.object(forInfoDictionaryKey: "MoreAppsURL") as? String,
1028
                let url = URL(string: moreAppsURL) {
1102
                let url = URL(string: moreAppsURL) {
1029
-                openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
1103
+                openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
1030
             }
1104
             }
1031
         case .shareApp:
1105
         case .shareApp:
1032
             shareAppFromSettingsMenu(sourceView: sourceView, clickLocationInSourceView: clickLocationInSourceView)
1106
             shareAppFromSettingsMenu(sourceView: sourceView, clickLocationInSourceView: clickLocationInSourceView)
@@ -1039,7 +1113,7 @@ private extension ViewController {
1039
         let defaultURL = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String) ?? "https://example.com/app-link-coming-soon"
1113
         let defaultURL = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String) ?? "https://example.com/app-link-coming-soon"
1040
         let urlString = (Bundle.main.object(forInfoDictionaryKey: infoKey) as? String) ?? defaultURL
1114
         let urlString = (Bundle.main.object(forInfoDictionaryKey: infoKey) as? String) ?? defaultURL
1041
         guard let url = URL(string: urlString) else { return }
1115
         guard let url = URL(string: urlString) else { return }
1042
-        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
1116
+        openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
1043
     }
1117
     }
1044
 
1118
 
1045
     private func showSimpleAlert(title: String, message: String) {
1119
     private func showSimpleAlert(title: String, message: String) {
@@ -2427,17 +2501,23 @@ private extension ViewController {
2427
         let host = NSView()
2501
         let host = NSView()
2428
         host.translatesAutoresizingMaskIntoConstraints = false
2502
         host.translatesAutoresizingMaskIntoConstraints = false
2429
         panel.addSubview(host)
2503
         panel.addSubview(host)
2504
+        let hostTopToAuth = host.topAnchor.constraint(equalTo: authBar.bottomAnchor, constant: 20)
2505
+        let hostTopToPanel = host.topAnchor.constraint(equalTo: panel.topAnchor)
2506
+        hostTopToPanel.isActive = false
2430
         NSLayoutConstraint.activate([
2507
         NSLayoutConstraint.activate([
2431
             authBar.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
2508
             authBar.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
2432
             authBar.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
2509
             authBar.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
2433
-            authBar.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
2510
+            authBar.topAnchor.constraint(equalTo: panel.safeAreaLayoutGuide.topAnchor, constant: 26),
2434
 
2511
 
2435
             host.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
2512
             host.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
2436
             host.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
2513
             host.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
2437
-            host.topAnchor.constraint(equalTo: authBar.bottomAnchor, constant: 20),
2514
+            hostTopToAuth,
2438
             host.bottomAnchor.constraint(equalTo: panel.bottomAnchor)
2515
             host.bottomAnchor.constraint(equalTo: panel.bottomAnchor)
2439
         ])
2516
         ])
2440
         mainContentHost = host
2517
         mainContentHost = host
2518
+        mainPanelAuthBar = authBar
2519
+        mainContentHostTopToAuthConstraint = hostTopToAuth
2520
+        mainContentHostTopToPanelConstraint = hostTopToPanel
2441
 
2521
 
2442
         if hasGoogleSessionAvailable(), let profile = scheduleCurrentProfile {
2522
         if hasGoogleSessionAvailable(), let profile = scheduleCurrentProfile {
2443
             applyGoogleProfile(profile)
2523
             applyGoogleProfile(profile)