Ver Fonte

Add in-app browser with toolbar, sandbox media entitlements, and Meet fixes

- Embed WKWebView with back/forward, reload/stop, address bar, progress, and optional URL whitelist policy.
- Add Browse sidebar page with URL entry and quick links; open Settings, paywall footer, and Join flows in the browser.
- Add meetings_app.entitlements (network, camera, microphone) plus usage strings so sandboxed WebKit can load Meet without WebContent crashes.
- Share WKProcessPool, drop custom Safari UA for WebKit default + app suffix, recreate WKWebView after process termination (retries), and gate fullscreen preference on macOS 12.3+.

Made-with: Cursor
huzaifahayat12 há 3 meses atrás
pai
commit
af65176b99

+ 6 - 0
meetings_app.xcodeproj/project.pbxproj

@@ -248,14 +248,17 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = meetings_app/meetings_app.entitlements;
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_NSCameraUsageDescription = "Camera is used for video meetings you open inside this app.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
+				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone is used for audio in meetings you open inside this app.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
@@ -279,14 +282,17 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = meetings_app/meetings_app.entitlements;
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_NSCameraUsageDescription = "Camera is used for video meetings you open inside this app.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
+				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone is used for audio in meetings you open inside this app.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",

+ 601 - 26
meetings_app/ViewController.swift

@@ -14,6 +14,7 @@ private enum SidebarPage: Int {
     case video = 2
     case tutorials = 3
     case settings = 4
+    case browse = 5
 }
 
 private enum ZoomJoinMode: Int {
@@ -59,8 +60,12 @@ final class ViewController: NSViewController {
     private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
     private weak var paywallOfferLabel: NSTextField?
     private weak var meetLinkField: NSTextField?
+    private weak var browseAddressField: NSTextField?
     private var inAppBrowserWindowController: InAppBrowserWindowController?
 
+    /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
+    private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
+
     private let darkModeDefaultsKey = "settings.darkModeEnabled"
     private var darkModeEnabled: Bool {
         get {
@@ -184,8 +189,10 @@ private extension ViewController {
 
     @objc private func joinMeetClicked(_ sender: Any?) {
         let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
-        guard rawInput.isEmpty == false else {
-            showSimpleAlert(title: "Join Meeting", message: "Please enter a Google Meet link.")
+
+        if rawInput.isEmpty {
+            guard let url = URL(string: "https://meet.google.com/") else { return }
+            openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
             return
         }
 
@@ -197,13 +204,42 @@ private extension ViewController {
             return
         }
 
-        openInSafari(url: url)
+        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
     }
 
     @objc private func cancelMeetJoinClicked(_ sender: Any?) {
         meetLinkField?.stringValue = ""
     }
 
+    @objc private func browseOpenAddressClicked(_ sender: Any?) {
+        let raw = browseAddressField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        guard raw.isEmpty == false else {
+            showSimpleAlert(title: "Browse", message: "Enter a web address (for example meet.google.com).")
+            return
+        }
+        let normalized = normalizedURLString(from: raw)
+        guard let url = URL(string: normalized), url.scheme == "http" || url.scheme == "https" else {
+            showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.")
+            return
+        }
+        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
+    }
+
+    @objc private func browseQuickLinkMeetClicked(_ sender: Any?) {
+        guard let url = URL(string: "https://meet.google.com/") else { return }
+        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
+    }
+
+    @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) {
+        guard let url = URL(string: "https://support.google.com/meet") else { return }
+        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
+    }
+
+    @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) {
+        guard let url = URL(string: "https://support.zoom.us") else { return }
+        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
+    }
+
     private func normalizedURLString(from value: String) -> String {
         if value.lowercased().hasPrefix("http://") || value.lowercased().hasPrefix("https://") {
             return value
@@ -211,7 +247,7 @@ private extension ViewController {
         return "https://\(value)"
     }
 
-    private func openInAppBrowser(with url: URL) {
+    private func openInAppBrowser(with url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
         let browserController: InAppBrowserWindowController
         if let existing = inAppBrowserWindowController {
             browserController = existing
@@ -220,7 +256,7 @@ private extension ViewController {
             inAppBrowserWindowController = browserController
         }
 
-        browserController.load(url: url)
+        browserController.load(url: url, policy: policy)
         browserController.showWindow(nil)
         browserController.window?.makeKeyAndOrderFront(nil)
         browserController.window?.orderFrontRegardless()
@@ -306,12 +342,25 @@ private extension ViewController {
         case .restore:
             showSimpleAlert(title: "Restore", message: "Restore action tapped.")
         case .rateUs:
-            // Replace with your App Store URL when ready.
-            showSimpleAlert(title: "Rate Us", message: "Rate Us tapped (add App Store URL).")
+            settingsPopover?.performClose(nil)
+            settingsPopover = nil
+            // Replace with your App Store product URL when the app is listed.
+            if let url = URL(string: "https://apps.apple.com/app/id0000000000") {
+                openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
+            }
         case .support:
-            showSimpleAlert(title: "Support", message: "Support tapped (add support email / page).")
+            settingsPopover?.performClose(nil)
+            settingsPopover = nil
+            if let url = URL(string: "https://support.google.com/meet") {
+                openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
+            }
         case .moreApps:
-            showSimpleAlert(title: "More Apps", message: "More Apps tapped (add developer page URL).")
+            settingsPopover?.performClose(nil)
+            settingsPopover = nil
+            // Replace with your App Store developer page URL.
+            if let url = URL(string: "https://apps.apple.com/developer/id0000000000") {
+                openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
+            }
         case .shareApp:
             let urlString = "https://example.com"
             NSPasteboard.general.clearContents()
@@ -368,6 +417,15 @@ private extension ViewController {
     @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) {
         guard let view = sender.view else { return }
         let text = (view.subviews.first { $0 is NSTextField } as? NSTextField)?.stringValue ?? "Link"
+        let map: [String: String] = [
+            "Privacy Policy": "https://policies.google.com/privacy",
+            "Support": "https://support.google.com/meet",
+            "Terms of Services": "https://policies.google.com/terms"
+        ]
+        if let urlString = map[text], let url = URL(string: urlString) {
+            openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
+            return
+        }
         showSimpleAlert(title: text, message: "\(text) tapped.")
     }
 
@@ -427,6 +485,8 @@ private extension ViewController {
             built = makePlaceholderPage(title: "Tutorials", subtitle: "Learn how to use the app.")
         case .settings:
             built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
+        case .browse:
+            built = makeBrowseWebContent()
         }
         pageCache[page] = built
         return built
@@ -450,6 +510,104 @@ private extension ViewController {
         return panel
     }
 
+    func makeBrowseWebContent() -> NSView {
+        let panel = NSView()
+        panel.translatesAutoresizingMaskIntoConstraints = false
+
+        let titleLabel = textLabel("Browse the web", font: typography.pageTitle, color: palette.textPrimary)
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+        let sub = textLabel(
+            "Open sites in the in-app browser (back, forward, reload, address bar). OAuth and “Continue in browser” flows stay inside the app.",
+            font: typography.fieldLabel,
+            color: palette.textSecondary
+        )
+        sub.translatesAutoresizingMaskIntoConstraints = false
+        sub.maximumNumberOfLines = 0
+        sub.lineBreakMode = .byWordWrapping
+
+        let fieldShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
+        fieldShell.translatesAutoresizingMaskIntoConstraints = false
+        fieldShell.heightAnchor.constraint(equalToConstant: 44).isActive = true
+        styleSurface(fieldShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let field = NSTextField(string: "")
+        field.translatesAutoresizingMaskIntoConstraints = false
+        field.isEditable = true
+        field.isBordered = false
+        field.drawsBackground = false
+        field.focusRingType = .none
+        field.font = NSFont.systemFont(ofSize: 14, weight: .regular)
+        field.textColor = palette.textPrimary
+        field.placeholderString = "https://example.com or example.com"
+        field.delegate = self
+        browseAddressField = field
+        fieldShell.addSubview(field)
+
+        let openBtn = meetActionButton(
+            title: "Open in app browser",
+            color: palette.primaryBlue,
+            textColor: .white,
+            width: 220,
+            action: #selector(browseOpenAddressClicked(_:))
+        )
+
+        let quickTitle = textLabel("Quick links", font: typography.joinWithURLTitle, color: palette.textPrimary)
+        quickTitle.translatesAutoresizingMaskIntoConstraints = false
+
+        let quickRow = NSStackView()
+        quickRow.translatesAutoresizingMaskIntoConstraints = false
+        quickRow.orientation = .horizontal
+        quickRow.spacing = 10
+        quickRow.addArrangedSubview(browseQuickLinkButton(title: "Google Meet", action: #selector(browseQuickLinkMeetClicked(_:))))
+        quickRow.addArrangedSubview(browseQuickLinkButton(title: "Meet help", action: #selector(browseQuickLinkMeetHelpClicked(_:))))
+        quickRow.addArrangedSubview(browseQuickLinkButton(title: "Zoom help", action: #selector(browseQuickLinkZoomHelpClicked(_:))))
+
+        panel.addSubview(titleLabel)
+        panel.addSubview(sub)
+        panel.addSubview(fieldShell)
+        panel.addSubview(openBtn)
+        panel.addSubview(quickTitle)
+        panel.addSubview(quickRow)
+
+        NSLayoutConstraint.activate([
+            titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
+            titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
+            titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
+
+            sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
+            sub.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
+            sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
+
+            fieldShell.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
+            fieldShell.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
+            fieldShell.topAnchor.constraint(equalTo: sub.bottomAnchor, constant: 18),
+
+            field.leadingAnchor.constraint(equalTo: fieldShell.leadingAnchor, constant: 12),
+            field.trailingAnchor.constraint(equalTo: fieldShell.trailingAnchor, constant: -12),
+            field.centerYAnchor.constraint(equalTo: fieldShell.centerYAnchor),
+
+            openBtn.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
+            openBtn.topAnchor.constraint(equalTo: fieldShell.bottomAnchor, constant: 12),
+            openBtn.heightAnchor.constraint(equalToConstant: 36),
+
+            quickTitle.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
+            quickTitle.topAnchor.constraint(equalTo: openBtn.bottomAnchor, constant: 28),
+
+            quickRow.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
+            quickRow.topAnchor.constraint(equalTo: quickTitle.bottomAnchor, constant: 10)
+        ])
+
+        return panel
+    }
+
+    private func browseQuickLinkButton(title: String, action: Selector) -> NSButton {
+        let b = NSButton(title: title, target: self, action: action)
+        b.translatesAutoresizingMaskIntoConstraints = false
+        b.bezelStyle = .rounded
+        b.font = NSFont.systemFont(ofSize: 13, weight: .medium)
+        return b
+    }
+
     private func applyWindowTitle(for page: SidebarPage) {
         let title: String
         switch page {
@@ -463,6 +621,8 @@ private extension ViewController {
             title = "Tutorials"
         case .settings:
             title = "Settings"
+        case .browse:
+            title = "Browse"
         }
         view.window?.title = title
         centeredTitleLabel?.stringValue = title
@@ -502,7 +662,7 @@ private extension ViewController {
     private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
         switch page {
         case .photo, .tutorials: return false
-        case .joinMeetings, .video, .settings: return true
+        case .joinMeetings, .video, .settings, .browse: return true
         }
     }
 
@@ -549,6 +709,9 @@ private extension ViewController {
         let tutorialsRow = sidebarItem("Tutorials", icon: "􀛩", page: .tutorials, logoImageName: "SidebarTutorialsLogo", logoIconWidth: 24, logoHeightMultiplier: 50.0 / 60.0)
         menuStack.addArrangedSubview(tutorialsRow)
         sidebarRowViews[.tutorials] = tutorialsRow
+        let browseRow = sidebarItem("Browse", icon: "􀎆", page: .browse)
+        menuStack.addArrangedSubview(browseRow)
+        sidebarRowViews[.browse] = browseRow
         let settingsRow = sidebarItem("Settings", icon: "􀍟", page: .settings, logoImageName: "SidebarSettingsLogo", logoIconWidth: 28, logoHeightMultiplier: 68.0 / 62.0, showsDisclosure: true)
         menuStack.addArrangedSubview(settingsRow)
         sidebarRowViews[.settings] = settingsRow
@@ -1540,6 +1703,16 @@ private extension ViewController {
     }
 }
 
+extension ViewController: NSTextFieldDelegate {
+    func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
+        if control === browseAddressField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
+            browseOpenAddressClicked(nil)
+            return true
+        }
+        return false
+    }
+}
+
 /// Ensures `NSClickGestureRecognizer` on the row receives clicks instead of child label/image views swallowing them.
 private class RowHitTestView: NSView {
     override func hitTest(_ point: NSPoint) -> NSView? {
@@ -2090,8 +2263,53 @@ private struct Typography {
     let cardTime = NSFont.systemFont(ofSize: 12, weight: .regular)
 }
 
+// MARK: - In-app browser (macOS WKWebView + chrome)
+// Note: This target is AppKit/macOS. iOS would use WKWebView or SFSafariViewController; Android would use WebView or Custom Tabs.
+
+private enum InAppBrowserURLPolicy: Equatable {
+    case allowAll
+    case whitelist(hostSuffixes: [String])
+}
+
+private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -> Bool {
+    let scheme = (url.scheme ?? "").lowercased()
+    if scheme == "about" { return true }
+    guard scheme == "http" || scheme == "https" else { return false }
+    guard let host = url.host?.lowercased() else { return false }
+    switch policy {
+    case .allowAll:
+        return true
+    case .whitelist(let suffixes):
+        for suffix in suffixes {
+            let s = suffix.lowercased()
+            if host == s || host.hasSuffix("." + s) { return true }
+        }
+        return false
+    }
+}
+
+private enum InAppBrowserWebKitSupport {
+    static let sharedProcessPool = WKProcessPool()
+
+    static func makeWebViewConfiguration() -> WKWebViewConfiguration {
+        let config = WKWebViewConfiguration()
+        config.processPool = sharedProcessPool
+        config.websiteDataStore = .default()
+        config.preferences.javaScriptCanOpenWindowsAutomatically = true
+        if #available(macOS 12.3, *) {
+            config.preferences.isElementFullscreenEnabled = true
+        }
+        config.mediaTypesRequiringUserActionForPlayback = []
+        if #available(macOS 11.0, *) {
+            config.defaultWebpagePreferences.allowsContentJavaScript = true
+        }
+        config.applicationNameForUserAgent = "MeetingsApp/1.0"
+        return config
+    }
+}
+
 private final class InAppBrowserWindowController: NSWindowController {
-    private let browserViewController = InAppBrowserViewController()
+    private let browserViewController = InAppBrowserContainerViewController()
 
     init() {
         let browserWindow = NSWindow(
@@ -2100,7 +2318,7 @@ private final class InAppBrowserWindowController: NSWindowController {
             backing: .buffered,
             defer: false
         )
-        browserWindow.title = "Google Meet"
+        browserWindow.title = "Browser"
         browserWindow.center()
         browserWindow.contentViewController = browserViewController
         super.init(window: browserWindow)
@@ -2111,24 +2329,315 @@ private final class InAppBrowserWindowController: NSWindowController {
         nil
     }
 
-    func load(url: URL) {
+    func load(url: URL, policy: InAppBrowserURLPolicy) {
+        browserViewController.setNavigationPolicy(policy)
         browserViewController.load(url: url)
     }
 }
 
-private final class InAppBrowserViewController: NSViewController, WKNavigationDelegate {
-    private let webView = WKWebView(frame: .zero)
+private final class InAppBrowserContainerViewController: NSViewController, WKNavigationDelegate, WKUIDelegate, NSTextFieldDelegate {
+    private var webView: WKWebView!
+    private var webContainerView: NSView!
+    private weak var urlField: NSTextField?
+    private var backButton: NSButton!
+    private var forwardButton: NSButton!
+    private var reloadStopButton: NSButton!
+    private var goButton: NSButton!
+    private var progressBar: NSProgressIndicator!
+
     private var lastLoadedURL: URL?
+    private var navigationPolicy: InAppBrowserURLPolicy = .allowAll
+    private var processTerminateRetryCount = 0
+    /// Includes fresh WKWebView instances so each retry gets a new WebContent process after a crash.
+    private let maxProcessTerminateRetries = 3
+    private var kvoTokens: [NSKeyValueObservation] = []
+
+    deinit {
+        kvoTokens.removeAll()
+    }
+
+    func setNavigationPolicy(_ policy: InAppBrowserURLPolicy) {
+        navigationPolicy = policy
+    }
 
     override func loadView() {
+        let root = NSView()
+        root.translatesAutoresizingMaskIntoConstraints = false
+
+        let wv = makeWebView()
+        webView = wv
+
+        let webHost = NSView()
+        webHost.translatesAutoresizingMaskIntoConstraints = false
+        webHost.wantsLayer = true
+        webHost.addSubview(wv)
+        NSLayoutConstraint.activate([
+            wv.leadingAnchor.constraint(equalTo: webHost.leadingAnchor),
+            wv.trailingAnchor.constraint(equalTo: webHost.trailingAnchor),
+            wv.topAnchor.constraint(equalTo: webHost.topAnchor),
+            wv.bottomAnchor.constraint(equalTo: webHost.bottomAnchor)
+        ])
+        webContainerView = webHost
+
+        let toolbar = NSStackView()
+        toolbar.translatesAutoresizingMaskIntoConstraints = false
+        toolbar.orientation = .horizontal
+        toolbar.spacing = 8
+        toolbar.alignment = .centerY
+        toolbar.edgeInsets = NSEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
+
+        backButton = makeToolbarButton(title: "◀", symbolName: "chevron.backward", accessibilityDescription: "Back")
+        backButton.target = self
+        backButton.action = #selector(goBack)
+        forwardButton = makeToolbarButton(title: "▶", symbolName: "chevron.forward", accessibilityDescription: "Forward")
+        forwardButton.target = self
+        forwardButton.action = #selector(goForward)
+        reloadStopButton = makeToolbarButton(title: "Reload", symbolName: "arrow.clockwise", accessibilityDescription: "Reload")
+        reloadStopButton.target = self
+        reloadStopButton.action = #selector(reloadOrStop)
+
+        let field = NSTextField(string: "")
+        field.translatesAutoresizingMaskIntoConstraints = false
+        field.font = NSFont.systemFont(ofSize: 13, weight: .regular)
+        field.placeholderString = "Address"
+        field.cell?.sendsActionOnEndEditing = false
+        field.delegate = self
+        urlField = field
+
+        goButton = NSButton(title: "Go", target: self, action: #selector(addressFieldSubmitted))
+        goButton.translatesAutoresizingMaskIntoConstraints = false
+        goButton.bezelStyle = .rounded
+
+        toolbar.addArrangedSubview(backButton)
+        toolbar.addArrangedSubview(forwardButton)
+        toolbar.addArrangedSubview(reloadStopButton)
+        toolbar.addArrangedSubview(field)
+        toolbar.addArrangedSubview(goButton)
+        field.widthAnchor.constraint(greaterThanOrEqualToConstant: 240).isActive = true
+
+        let bar = NSProgressIndicator()
+        bar.translatesAutoresizingMaskIntoConstraints = false
+        bar.style = .bar
+        bar.isIndeterminate = false
+        bar.minValue = 0
+        bar.maxValue = 1
+        bar.doubleValue = 0
+        bar.isHidden = true
+        progressBar = bar
+
+        let separator = NSBox()
+        separator.translatesAutoresizingMaskIntoConstraints = false
+        separator.boxType = .separator
+
         webView.navigationDelegate = self
-        webView.translatesAutoresizingMaskIntoConstraints = false
-        view = webView
+        webView.uiDelegate = self
+
+        root.addSubview(toolbar)
+        root.addSubview(bar)
+        root.addSubview(separator)
+        root.addSubview(webHost)
+
+        NSLayoutConstraint.activate([
+            toolbar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
+            toolbar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
+            toolbar.topAnchor.constraint(equalTo: root.topAnchor),
+
+            bar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
+            bar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
+            bar.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
+            bar.heightAnchor.constraint(equalToConstant: 3),
+
+            separator.leadingAnchor.constraint(equalTo: root.leadingAnchor),
+            separator.trailingAnchor.constraint(equalTo: root.trailingAnchor),
+            separator.topAnchor.constraint(equalTo: bar.bottomAnchor),
+
+            webHost.leadingAnchor.constraint(equalTo: root.leadingAnchor),
+            webHost.trailingAnchor.constraint(equalTo: root.trailingAnchor),
+            webHost.topAnchor.constraint(equalTo: separator.bottomAnchor),
+            webHost.bottomAnchor.constraint(equalTo: root.bottomAnchor)
+        ])
+
+        view = root
+        installWebViewObservers()
+        syncToolbarFromWebView()
+    }
+
+    private func makeWebView() -> WKWebView {
+        let wv = WKWebView(frame: .zero, configuration: InAppBrowserWebKitSupport.makeWebViewConfiguration())
+        wv.translatesAutoresizingMaskIntoConstraints = false
+        return wv
+    }
+
+    private func teardownWebViewObservers() {
+        kvoTokens.removeAll()
+    }
+
+    /// New `WKWebView` = new WebContent process (helps after GPU/JS crashes on heavy sites like Meet).
+    private func replaceWebViewAndLoad(url: URL) {
+        teardownWebViewObservers()
+        webView.navigationDelegate = nil
+        webView.uiDelegate = nil
+        webView.removeFromSuperview()
+
+        let wv = makeWebView()
+        webView = wv
+        webContainerView.addSubview(wv)
+        NSLayoutConstraint.activate([
+            wv.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
+            wv.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
+            wv.topAnchor.constraint(equalTo: webContainerView.topAnchor),
+            wv.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor)
+        ])
+        webView.navigationDelegate = self
+        webView.uiDelegate = self
+        installWebViewObservers()
+        syncToolbarFromWebView()
+        webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
+    }
+
+    private func makeToolbarButton(title: String, symbolName: String, accessibilityDescription: String) -> NSButton {
+        let b = NSButton()
+        b.translatesAutoresizingMaskIntoConstraints = false
+        b.bezelStyle = .texturedRounded
+        b.setAccessibilityLabel(accessibilityDescription)
+        if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) {
+            b.image = img
+            b.imagePosition = .imageOnly
+        } else {
+            b.title = title
+        }
+        b.widthAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true
+        return b
+    }
+
+    private func installWebViewObservers() {
+        kvoTokens.append(webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in
+            self?.syncToolbarFromWebView()
+        })
+        kvoTokens.append(webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in
+            self?.syncToolbarFromWebView()
+        })
+        kvoTokens.append(webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in
+            self?.syncToolbarFromWebView()
+        })
+        kvoTokens.append(webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, _ in
+            self?.syncProgressFromWebView()
+        })
+        kvoTokens.append(webView.observe(\.title, options: [.new]) { [weak self] _, _ in
+            self?.syncWindowTitle()
+        })
+        kvoTokens.append(webView.observe(\.url, options: [.new]) { [weak self] _, _ in
+            self?.syncAddressFieldFromWebView()
+        })
+    }
+
+    private func syncToolbarFromWebView() {
+        backButton?.isEnabled = webView.canGoBack
+        forwardButton?.isEnabled = webView.canGoForward
+        if webView.isLoading {
+            if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Stop") {
+                reloadStopButton.image = img
+                reloadStopButton.imagePosition = .imageOnly
+                reloadStopButton.title = ""
+            } else {
+                reloadStopButton.title = "Stop"
+            }
+        } else {
+            if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reload") {
+                reloadStopButton.image = img
+                reloadStopButton.imagePosition = .imageOnly
+                reloadStopButton.title = ""
+            } else {
+                reloadStopButton.title = "Reload"
+            }
+        }
+        syncProgressFromWebView()
+    }
+
+    private func syncProgressFromWebView() {
+        guard let progressBar else { return }
+        if webView.isLoading {
+            progressBar.isHidden = false
+            progressBar.doubleValue = webView.estimatedProgress
+        } else {
+            progressBar.isHidden = true
+            progressBar.doubleValue = 0
+        }
+    }
+
+    private func syncAddressFieldFromWebView() {
+        guard let urlField, urlField.currentEditor() == nil, let url = webView.url else { return }
+        urlField.stringValue = url.absoluteString
+    }
+
+    private func syncWindowTitle() {
+        let t = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        let host = webView.url?.host ?? ""
+        view.window?.title = t.isEmpty ? (host.isEmpty ? "Browser" : host) : t
     }
 
     func load(url: URL) {
         lastLoadedURL = url
+        processTerminateRetryCount = 0
+        urlField?.stringValue = url.absoluteString
         webView.load(URLRequest(url: url))
+        syncWindowTitle()
+    }
+
+    @objc private func goBack() {
+        webView.goBack()
+    }
+
+    @objc private func goForward() {
+        webView.goForward()
+    }
+
+    @objc private func reloadOrStop() {
+        if webView.isLoading {
+            webView.stopLoading()
+        } else {
+            webView.reload()
+        }
+    }
+
+    @objc private func addressFieldSubmitted() {
+        let raw = urlField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        guard raw.isEmpty == false else { return }
+        var normalized = raw
+        if normalized.lowercased().hasPrefix("http://") == false && normalized.lowercased().hasPrefix("https://") == false {
+            normalized = "https://\(normalized)"
+        }
+        guard let url = URL(string: normalized),
+              let scheme = url.scheme?.lowercased(),
+              scheme == "http" || scheme == "https",
+              url.host != nil
+        else {
+            let alert = NSAlert()
+            alert.messageText = "Invalid address"
+            alert.informativeText = "Enter a valid web address, for example https://example.com"
+            alert.addButton(withTitle: "OK")
+            alert.runModal()
+            return
+        }
+        guard inAppBrowserURLAllowed(url, policy: navigationPolicy) else {
+            presentBlockedHostAlert()
+            return
+        }
+        load(url: url)
+    }
+
+    private func presentBlockedHostAlert() {
+        let alert = NSAlert()
+        alert.messageText = "Address not allowed"
+        alert.informativeText = "This URL is not permitted with the current in-app browser policy (whitelist)."
+        alert.addButton(withTitle: "OK")
+        alert.runModal()
+    }
+
+    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+        processTerminateRetryCount = 0
+        syncAddressFieldFromWebView()
+        syncWindowTitle()
     }
 
     func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
@@ -2137,19 +2646,85 @@ private final class InAppBrowserViewController: NSViewController, WKNavigationDe
             return
         }
         let alert = NSAlert()
-        alert.messageText = "Unable to Open Link"
-        alert.informativeText = "Could not load this page in the in-app browser.\n\n\(error.localizedDescription)\n\nOpen in default browser instead?"
-        alert.addButton(withTitle: "Open in Browser")
-        alert.addButton(withTitle: "Cancel")
-        let result = alert.runModal()
-        if result == .alertFirstButtonReturn, let url = lastLoadedURL {
-            NSWorkspace.shared.open(url)
+        alert.messageText = "Unable to load page"
+        alert.informativeText = "Could not load this page in the in-app browser.\n\n\(error.localizedDescription)"
+        alert.addButton(withTitle: "Try Again")
+        alert.addButton(withTitle: "OK")
+        if alert.runModal() == .alertFirstButtonReturn, let url = lastLoadedURL {
+            processTerminateRetryCount = 0
+            webView.load(URLRequest(url: url))
         }
     }
 
     func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
-        // WebKit process can be terminated by the OS; retry current page once.
-        webView.reload()
+        guard let url = lastLoadedURL else { return }
+
+        if processTerminateRetryCount < maxProcessTerminateRetries {
+            processTerminateRetryCount += 1
+            replaceWebViewAndLoad(url: url)
+            return
+        }
+
+        let alert = NSAlert()
+        alert.messageText = "Page stopped loading"
+        alert.informativeText =
+            "The in-app browser closed this page unexpectedly. You can try loading it again in this same window."
+        alert.addButton(withTitle: "Try Again")
+        alert.addButton(withTitle: "OK")
+        if alert.runModal() == .alertFirstButtonReturn {
+            processTerminateRetryCount = 0
+            replaceWebViewAndLoad(url: url)
+        }
+    }
+
+    func webView(
+        _ webView: WKWebView,
+        decidePolicyFor navigationAction: WKNavigationAction,
+        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
+    ) {
+        guard let url = navigationAction.request.url else {
+            decisionHandler(.allow)
+            return
+        }
+        let scheme = (url.scheme ?? "").lowercased()
+        if scheme == "mailto" || scheme == "tel" {
+            decisionHandler(.cancel)
+            return
+        }
+        if inAppBrowserURLAllowed(url, policy: navigationPolicy) == false {
+            if navigationAction.targetFrame?.isMainFrame != false {
+                DispatchQueue.main.async { [weak self] in
+                    self?.presentBlockedHostAlert()
+                }
+            }
+            decisionHandler(.cancel)
+            return
+        }
+        decisionHandler(.allow)
+    }
+
+    func webView(
+        _ webView: WKWebView,
+        createWebViewWith configuration: WKWebViewConfiguration,
+        for navigationAction: WKNavigationAction,
+        windowFeatures: WKWindowFeatures
+    ) -> WKWebView? {
+        if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
+            if inAppBrowserURLAllowed(requestURL, policy: navigationPolicy) {
+                webView.load(URLRequest(url: requestURL))
+            } else {
+                presentBlockedHostAlert()
+            }
+        }
+        return nil
+    }
+
+    func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
+        if control === urlField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
+            addressFieldSubmitted()
+            return true
+        }
+        return false
     }
 }
 

+ 16 - 0
meetings_app/meetings_app.entitlements

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>com.apple.security.app-sandbox</key>
+	<true/>
+	<key>com.apple.security.network.client</key>
+	<true/>
+	<key>com.apple.security.device.camera</key>
+	<true/>
+	<key>com.apple.security.device.audio-input</key>
+	<true/>
+	<key>com.apple.security.files.user-selected.read-only</key>
+	<true/>
+</dict>
+</plist>