Pārlūkot izejas kodu

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 mēnesi atpakaļ
vecāks
revīzija
41da06688e
2 mainītis faili ar 255 papildinājumiem un 23 dzēšanām
  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 2
 import CryptoKit
3 3
 import AppKit
4 4
 import Network
5
+import WebKit
5 6
 
6 7
 struct GoogleOAuthTokens: Codable, Equatable {
7 8
     var accessToken: String
@@ -50,6 +51,7 @@ final class GoogleOAuthService: NSObject {
50 51
     ]
51 52
 
52 53
     private let tokenStore = KeychainTokenStore()
54
+    @MainActor private var inAppOAuthWindowController: InAppOAuthWindowController?
53 55
     private override init() {}
54 56
 
55 57
     func configuredClientId() -> String? {
@@ -127,7 +129,6 @@ final class GoogleOAuthService: NSObject {
127 129
     // MARK: - Interactive sign-in (Authorization Code + PKCE)
128 130
 
129 131
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
130
-        _ = presentingWindow
131 132
         guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
132 133
         guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
133 134
         let codeVerifier = Self.randomURLSafeString(length: 64)
@@ -153,7 +154,15 @@ final class GoogleOAuthService: NSObject {
153 154
         ]
154 155
 
155 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 166
         let callbackURL = try await loopback.waitForCallback()
158 167
 
159 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 213
     private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> GoogleOAuthTokens {
181 214
         var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
182 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 287
     private weak var meetLinkField: NSTextField?
288 288
     private weak var browseAddressField: NSTextField?
289 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 296
     private let googleOAuth = GoogleOAuthService.shared
291 297
     private let calendarClient = GoogleCalendarClient()
292 298
     private let storeKitCoordinator = StoreKitCoordinator()
@@ -712,7 +718,7 @@ private extension ViewController {
712 718
             showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.")
713 719
             return
714 720
         }
715
-        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
721
+        openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
716 722
     }
717 723
 
718 724
     @objc private func browseQuickLinkMeetClicked(_ sender: Any?) {
@@ -722,12 +728,12 @@ private extension ViewController {
722 728
 
723 729
     @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) {
724 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 734
     @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) {
729 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 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 892
     private func openRateUsDestination() {
816 893
         let configured = (Bundle.main.object(forInfoDictionaryKey: "RateUsURL") as? String)?
817 894
             .trimmingCharacters(in: .whitespacesAndNewlines)
@@ -938,21 +1015,10 @@ private extension ViewController {
938 1015
         selectedSidebarPage = page
939 1016
         updateSidebarAppearance()
940 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 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 1024
     private func showSettingsPopover() {
@@ -999,6 +1065,14 @@ private extension ViewController {
999 1065
         paywallContinueButton = nil
1000 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 1076
         googleAccountPopover?.performClose(nil)
1003 1077
         googleAccountPopover = nil
1004 1078
 
@@ -1026,7 +1100,7 @@ private extension ViewController {
1026 1100
         case .moreApps:
1027 1101
             if let moreAppsURL = Bundle.main.object(forInfoDictionaryKey: "MoreAppsURL") as? String,
1028 1102
                let url = URL(string: moreAppsURL) {
1029
-                openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
1103
+                openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
1030 1104
             }
1031 1105
         case .shareApp:
1032 1106
             shareAppFromSettingsMenu(sourceView: sourceView, clickLocationInSourceView: clickLocationInSourceView)
@@ -1039,7 +1113,7 @@ private extension ViewController {
1039 1113
         let defaultURL = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String) ?? "https://example.com/app-link-coming-soon"
1040 1114
         let urlString = (Bundle.main.object(forInfoDictionaryKey: infoKey) as? String) ?? defaultURL
1041 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 1119
     private func showSimpleAlert(title: String, message: String) {
@@ -2427,17 +2501,23 @@ private extension ViewController {
2427 2501
         let host = NSView()
2428 2502
         host.translatesAutoresizingMaskIntoConstraints = false
2429 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 2507
         NSLayoutConstraint.activate([
2431 2508
             authBar.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
2432 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 2512
             host.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
2436 2513
             host.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
2437
-            host.topAnchor.constraint(equalTo: authBar.bottomAnchor, constant: 20),
2514
+            hostTopToAuth,
2438 2515
             host.bottomAnchor.constraint(equalTo: panel.bottomAnchor)
2439 2516
         ])
2440 2517
         mainContentHost = host
2518
+        mainPanelAuthBar = authBar
2519
+        mainContentHostTopToAuthConstraint = hostTopToAuth
2520
+        mainContentHostTopToPanelConstraint = hostTopToPanel
2441 2521
 
2442 2522
         if hasGoogleSessionAvailable(), let profile = scheduleCurrentProfile {
2443 2523
             applyGoogleProfile(profile)