Procházet zdrojové kódy

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 před 1 týdnem
rodič
revize
af65176b99

+ 6 - 0
meetings_app.xcodeproj/project.pbxproj

@@ -248,14 +248,17 @@
248 248
 			buildSettings = {
249 249
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
250 250
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
251
+				CODE_SIGN_ENTITLEMENTS = meetings_app/meetings_app.entitlements;
251 252
 				CODE_SIGN_STYLE = Automatic;
252 253
 				COMBINE_HIDPI_IMAGES = YES;
253 254
 				CURRENT_PROJECT_VERSION = 1;
254 255
 				ENABLE_APP_SANDBOX = YES;
255 256
 				ENABLE_USER_SELECTED_FILES = readonly;
256 257
 				GENERATE_INFOPLIST_FILE = YES;
258
+				INFOPLIST_KEY_NSCameraUsageDescription = "Camera is used for video meetings you open inside this app.";
257 259
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
258 260
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
261
+				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone is used for audio in meetings you open inside this app.";
259 262
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
260 263
 				LD_RUNPATH_SEARCH_PATHS = (
261 264
 					"$(inherited)",
@@ -279,14 +282,17 @@
279 282
 			buildSettings = {
280 283
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
281 284
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
285
+				CODE_SIGN_ENTITLEMENTS = meetings_app/meetings_app.entitlements;
282 286
 				CODE_SIGN_STYLE = Automatic;
283 287
 				COMBINE_HIDPI_IMAGES = YES;
284 288
 				CURRENT_PROJECT_VERSION = 1;
285 289
 				ENABLE_APP_SANDBOX = YES;
286 290
 				ENABLE_USER_SELECTED_FILES = readonly;
287 291
 				GENERATE_INFOPLIST_FILE = YES;
292
+				INFOPLIST_KEY_NSCameraUsageDescription = "Camera is used for video meetings you open inside this app.";
288 293
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
289 294
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
295
+				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone is used for audio in meetings you open inside this app.";
290 296
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
291 297
 				LD_RUNPATH_SEARCH_PATHS = (
292 298
 					"$(inherited)",

+ 601 - 26
meetings_app/ViewController.swift

@@ -14,6 +14,7 @@ private enum SidebarPage: Int {
14 14
     case video = 2
15 15
     case tutorials = 3
16 16
     case settings = 4
17
+    case browse = 5
17 18
 }
18 19
 
19 20
 private enum ZoomJoinMode: Int {
@@ -59,8 +60,12 @@ final class ViewController: NSViewController {
59 60
     private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
60 61
     private weak var paywallOfferLabel: NSTextField?
61 62
     private weak var meetLinkField: NSTextField?
63
+    private weak var browseAddressField: NSTextField?
62 64
     private var inAppBrowserWindowController: InAppBrowserWindowController?
63 65
 
66
+    /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
67
+    private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
68
+
64 69
     private let darkModeDefaultsKey = "settings.darkModeEnabled"
65 70
     private var darkModeEnabled: Bool {
66 71
         get {
@@ -184,8 +189,10 @@ private extension ViewController {
184 189
 
185 190
     @objc private func joinMeetClicked(_ sender: Any?) {
186 191
         let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
187
-        guard rawInput.isEmpty == false else {
188
-            showSimpleAlert(title: "Join Meeting", message: "Please enter a Google Meet link.")
192
+
193
+        if rawInput.isEmpty {
194
+            guard let url = URL(string: "https://meet.google.com/") else { return }
195
+            openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
189 196
             return
190 197
         }
191 198
 
@@ -197,13 +204,42 @@ private extension ViewController {
197 204
             return
198 205
         }
199 206
 
200
-        openInSafari(url: url)
207
+        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
201 208
     }
202 209
 
203 210
     @objc private func cancelMeetJoinClicked(_ sender: Any?) {
204 211
         meetLinkField?.stringValue = ""
205 212
     }
206 213
 
214
+    @objc private func browseOpenAddressClicked(_ sender: Any?) {
215
+        let raw = browseAddressField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
216
+        guard raw.isEmpty == false else {
217
+            showSimpleAlert(title: "Browse", message: "Enter a web address (for example meet.google.com).")
218
+            return
219
+        }
220
+        let normalized = normalizedURLString(from: raw)
221
+        guard let url = URL(string: normalized), url.scheme == "http" || url.scheme == "https" else {
222
+            showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.")
223
+            return
224
+        }
225
+        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
226
+    }
227
+
228
+    @objc private func browseQuickLinkMeetClicked(_ sender: Any?) {
229
+        guard let url = URL(string: "https://meet.google.com/") else { return }
230
+        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
231
+    }
232
+
233
+    @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) {
234
+        guard let url = URL(string: "https://support.google.com/meet") else { return }
235
+        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
236
+    }
237
+
238
+    @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) {
239
+        guard let url = URL(string: "https://support.zoom.us") else { return }
240
+        openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
241
+    }
242
+
207 243
     private func normalizedURLString(from value: String) -> String {
208 244
         if value.lowercased().hasPrefix("http://") || value.lowercased().hasPrefix("https://") {
209 245
             return value
@@ -211,7 +247,7 @@ private extension ViewController {
211 247
         return "https://\(value)"
212 248
     }
213 249
 
214
-    private func openInAppBrowser(with url: URL) {
250
+    private func openInAppBrowser(with url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
215 251
         let browserController: InAppBrowserWindowController
216 252
         if let existing = inAppBrowserWindowController {
217 253
             browserController = existing
@@ -220,7 +256,7 @@ private extension ViewController {
220 256
             inAppBrowserWindowController = browserController
221 257
         }
222 258
 
223
-        browserController.load(url: url)
259
+        browserController.load(url: url, policy: policy)
224 260
         browserController.showWindow(nil)
225 261
         browserController.window?.makeKeyAndOrderFront(nil)
226 262
         browserController.window?.orderFrontRegardless()
@@ -306,12 +342,25 @@ private extension ViewController {
306 342
         case .restore:
307 343
             showSimpleAlert(title: "Restore", message: "Restore action tapped.")
308 344
         case .rateUs:
309
-            // Replace with your App Store URL when ready.
310
-            showSimpleAlert(title: "Rate Us", message: "Rate Us tapped (add App Store URL).")
345
+            settingsPopover?.performClose(nil)
346
+            settingsPopover = nil
347
+            // Replace with your App Store product URL when the app is listed.
348
+            if let url = URL(string: "https://apps.apple.com/app/id0000000000") {
349
+                openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
350
+            }
311 351
         case .support:
312
-            showSimpleAlert(title: "Support", message: "Support tapped (add support email / page).")
352
+            settingsPopover?.performClose(nil)
353
+            settingsPopover = nil
354
+            if let url = URL(string: "https://support.google.com/meet") {
355
+                openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
356
+            }
313 357
         case .moreApps:
314
-            showSimpleAlert(title: "More Apps", message: "More Apps tapped (add developer page URL).")
358
+            settingsPopover?.performClose(nil)
359
+            settingsPopover = nil
360
+            // Replace with your App Store developer page URL.
361
+            if let url = URL(string: "https://apps.apple.com/developer/id0000000000") {
362
+                openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
363
+            }
315 364
         case .shareApp:
316 365
             let urlString = "https://example.com"
317 366
             NSPasteboard.general.clearContents()
@@ -368,6 +417,15 @@ private extension ViewController {
368 417
     @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) {
369 418
         guard let view = sender.view else { return }
370 419
         let text = (view.subviews.first { $0 is NSTextField } as? NSTextField)?.stringValue ?? "Link"
420
+        let map: [String: String] = [
421
+            "Privacy Policy": "https://policies.google.com/privacy",
422
+            "Support": "https://support.google.com/meet",
423
+            "Terms of Services": "https://policies.google.com/terms"
424
+        ]
425
+        if let urlString = map[text], let url = URL(string: urlString) {
426
+            openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
427
+            return
428
+        }
371 429
         showSimpleAlert(title: text, message: "\(text) tapped.")
372 430
     }
373 431
 
@@ -427,6 +485,8 @@ private extension ViewController {
427 485
             built = makePlaceholderPage(title: "Tutorials", subtitle: "Learn how to use the app.")
428 486
         case .settings:
429 487
             built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
488
+        case .browse:
489
+            built = makeBrowseWebContent()
430 490
         }
431 491
         pageCache[page] = built
432 492
         return built
@@ -450,6 +510,104 @@ private extension ViewController {
450 510
         return panel
451 511
     }
452 512
 
513
+    func makeBrowseWebContent() -> NSView {
514
+        let panel = NSView()
515
+        panel.translatesAutoresizingMaskIntoConstraints = false
516
+
517
+        let titleLabel = textLabel("Browse the web", font: typography.pageTitle, color: palette.textPrimary)
518
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
519
+        let sub = textLabel(
520
+            "Open sites in the in-app browser (back, forward, reload, address bar). OAuth and “Continue in browser” flows stay inside the app.",
521
+            font: typography.fieldLabel,
522
+            color: palette.textSecondary
523
+        )
524
+        sub.translatesAutoresizingMaskIntoConstraints = false
525
+        sub.maximumNumberOfLines = 0
526
+        sub.lineBreakMode = .byWordWrapping
527
+
528
+        let fieldShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
529
+        fieldShell.translatesAutoresizingMaskIntoConstraints = false
530
+        fieldShell.heightAnchor.constraint(equalToConstant: 44).isActive = true
531
+        styleSurface(fieldShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
532
+
533
+        let field = NSTextField(string: "")
534
+        field.translatesAutoresizingMaskIntoConstraints = false
535
+        field.isEditable = true
536
+        field.isBordered = false
537
+        field.drawsBackground = false
538
+        field.focusRingType = .none
539
+        field.font = NSFont.systemFont(ofSize: 14, weight: .regular)
540
+        field.textColor = palette.textPrimary
541
+        field.placeholderString = "https://example.com or example.com"
542
+        field.delegate = self
543
+        browseAddressField = field
544
+        fieldShell.addSubview(field)
545
+
546
+        let openBtn = meetActionButton(
547
+            title: "Open in app browser",
548
+            color: palette.primaryBlue,
549
+            textColor: .white,
550
+            width: 220,
551
+            action: #selector(browseOpenAddressClicked(_:))
552
+        )
553
+
554
+        let quickTitle = textLabel("Quick links", font: typography.joinWithURLTitle, color: palette.textPrimary)
555
+        quickTitle.translatesAutoresizingMaskIntoConstraints = false
556
+
557
+        let quickRow = NSStackView()
558
+        quickRow.translatesAutoresizingMaskIntoConstraints = false
559
+        quickRow.orientation = .horizontal
560
+        quickRow.spacing = 10
561
+        quickRow.addArrangedSubview(browseQuickLinkButton(title: "Google Meet", action: #selector(browseQuickLinkMeetClicked(_:))))
562
+        quickRow.addArrangedSubview(browseQuickLinkButton(title: "Meet help", action: #selector(browseQuickLinkMeetHelpClicked(_:))))
563
+        quickRow.addArrangedSubview(browseQuickLinkButton(title: "Zoom help", action: #selector(browseQuickLinkZoomHelpClicked(_:))))
564
+
565
+        panel.addSubview(titleLabel)
566
+        panel.addSubview(sub)
567
+        panel.addSubview(fieldShell)
568
+        panel.addSubview(openBtn)
569
+        panel.addSubview(quickTitle)
570
+        panel.addSubview(quickRow)
571
+
572
+        NSLayoutConstraint.activate([
573
+            titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
574
+            titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
575
+            titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
576
+
577
+            sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
578
+            sub.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
579
+            sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
580
+
581
+            fieldShell.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
582
+            fieldShell.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
583
+            fieldShell.topAnchor.constraint(equalTo: sub.bottomAnchor, constant: 18),
584
+
585
+            field.leadingAnchor.constraint(equalTo: fieldShell.leadingAnchor, constant: 12),
586
+            field.trailingAnchor.constraint(equalTo: fieldShell.trailingAnchor, constant: -12),
587
+            field.centerYAnchor.constraint(equalTo: fieldShell.centerYAnchor),
588
+
589
+            openBtn.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
590
+            openBtn.topAnchor.constraint(equalTo: fieldShell.bottomAnchor, constant: 12),
591
+            openBtn.heightAnchor.constraint(equalToConstant: 36),
592
+
593
+            quickTitle.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
594
+            quickTitle.topAnchor.constraint(equalTo: openBtn.bottomAnchor, constant: 28),
595
+
596
+            quickRow.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
597
+            quickRow.topAnchor.constraint(equalTo: quickTitle.bottomAnchor, constant: 10)
598
+        ])
599
+
600
+        return panel
601
+    }
602
+
603
+    private func browseQuickLinkButton(title: String, action: Selector) -> NSButton {
604
+        let b = NSButton(title: title, target: self, action: action)
605
+        b.translatesAutoresizingMaskIntoConstraints = false
606
+        b.bezelStyle = .rounded
607
+        b.font = NSFont.systemFont(ofSize: 13, weight: .medium)
608
+        return b
609
+    }
610
+
453 611
     private func applyWindowTitle(for page: SidebarPage) {
454 612
         let title: String
455 613
         switch page {
@@ -463,6 +621,8 @@ private extension ViewController {
463 621
             title = "Tutorials"
464 622
         case .settings:
465 623
             title = "Settings"
624
+        case .browse:
625
+            title = "Browse"
466 626
         }
467 627
         view.window?.title = title
468 628
         centeredTitleLabel?.stringValue = title
@@ -502,7 +662,7 @@ private extension ViewController {
502 662
     private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
503 663
         switch page {
504 664
         case .photo, .tutorials: return false
505
-        case .joinMeetings, .video, .settings: return true
665
+        case .joinMeetings, .video, .settings, .browse: return true
506 666
         }
507 667
     }
508 668
 
@@ -549,6 +709,9 @@ private extension ViewController {
549 709
         let tutorialsRow = sidebarItem("Tutorials", icon: "􀛩", page: .tutorials, logoImageName: "SidebarTutorialsLogo", logoIconWidth: 24, logoHeightMultiplier: 50.0 / 60.0)
550 710
         menuStack.addArrangedSubview(tutorialsRow)
551 711
         sidebarRowViews[.tutorials] = tutorialsRow
712
+        let browseRow = sidebarItem("Browse", icon: "􀎆", page: .browse)
713
+        menuStack.addArrangedSubview(browseRow)
714
+        sidebarRowViews[.browse] = browseRow
552 715
         let settingsRow = sidebarItem("Settings", icon: "􀍟", page: .settings, logoImageName: "SidebarSettingsLogo", logoIconWidth: 28, logoHeightMultiplier: 68.0 / 62.0, showsDisclosure: true)
553 716
         menuStack.addArrangedSubview(settingsRow)
554 717
         sidebarRowViews[.settings] = settingsRow
@@ -1540,6 +1703,16 @@ private extension ViewController {
1540 1703
     }
1541 1704
 }
1542 1705
 
1706
+extension ViewController: NSTextFieldDelegate {
1707
+    func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
1708
+        if control === browseAddressField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
1709
+            browseOpenAddressClicked(nil)
1710
+            return true
1711
+        }
1712
+        return false
1713
+    }
1714
+}
1715
+
1543 1716
 /// Ensures `NSClickGestureRecognizer` on the row receives clicks instead of child label/image views swallowing them.
1544 1717
 private class RowHitTestView: NSView {
1545 1718
     override func hitTest(_ point: NSPoint) -> NSView? {
@@ -2090,8 +2263,53 @@ private struct Typography {
2090 2263
     let cardTime = NSFont.systemFont(ofSize: 12, weight: .regular)
2091 2264
 }
2092 2265
 
2266
+// MARK: - In-app browser (macOS WKWebView + chrome)
2267
+// Note: This target is AppKit/macOS. iOS would use WKWebView or SFSafariViewController; Android would use WebView or Custom Tabs.
2268
+
2269
+private enum InAppBrowserURLPolicy: Equatable {
2270
+    case allowAll
2271
+    case whitelist(hostSuffixes: [String])
2272
+}
2273
+
2274
+private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -> Bool {
2275
+    let scheme = (url.scheme ?? "").lowercased()
2276
+    if scheme == "about" { return true }
2277
+    guard scheme == "http" || scheme == "https" else { return false }
2278
+    guard let host = url.host?.lowercased() else { return false }
2279
+    switch policy {
2280
+    case .allowAll:
2281
+        return true
2282
+    case .whitelist(let suffixes):
2283
+        for suffix in suffixes {
2284
+            let s = suffix.lowercased()
2285
+            if host == s || host.hasSuffix("." + s) { return true }
2286
+        }
2287
+        return false
2288
+    }
2289
+}
2290
+
2291
+private enum InAppBrowserWebKitSupport {
2292
+    static let sharedProcessPool = WKProcessPool()
2293
+
2294
+    static func makeWebViewConfiguration() -> WKWebViewConfiguration {
2295
+        let config = WKWebViewConfiguration()
2296
+        config.processPool = sharedProcessPool
2297
+        config.websiteDataStore = .default()
2298
+        config.preferences.javaScriptCanOpenWindowsAutomatically = true
2299
+        if #available(macOS 12.3, *) {
2300
+            config.preferences.isElementFullscreenEnabled = true
2301
+        }
2302
+        config.mediaTypesRequiringUserActionForPlayback = []
2303
+        if #available(macOS 11.0, *) {
2304
+            config.defaultWebpagePreferences.allowsContentJavaScript = true
2305
+        }
2306
+        config.applicationNameForUserAgent = "MeetingsApp/1.0"
2307
+        return config
2308
+    }
2309
+}
2310
+
2093 2311
 private final class InAppBrowserWindowController: NSWindowController {
2094
-    private let browserViewController = InAppBrowserViewController()
2312
+    private let browserViewController = InAppBrowserContainerViewController()
2095 2313
 
2096 2314
     init() {
2097 2315
         let browserWindow = NSWindow(
@@ -2100,7 +2318,7 @@ private final class InAppBrowserWindowController: NSWindowController {
2100 2318
             backing: .buffered,
2101 2319
             defer: false
2102 2320
         )
2103
-        browserWindow.title = "Google Meet"
2321
+        browserWindow.title = "Browser"
2104 2322
         browserWindow.center()
2105 2323
         browserWindow.contentViewController = browserViewController
2106 2324
         super.init(window: browserWindow)
@@ -2111,24 +2329,315 @@ private final class InAppBrowserWindowController: NSWindowController {
2111 2329
         nil
2112 2330
     }
2113 2331
 
2114
-    func load(url: URL) {
2332
+    func load(url: URL, policy: InAppBrowserURLPolicy) {
2333
+        browserViewController.setNavigationPolicy(policy)
2115 2334
         browserViewController.load(url: url)
2116 2335
     }
2117 2336
 }
2118 2337
 
2119
-private final class InAppBrowserViewController: NSViewController, WKNavigationDelegate {
2120
-    private let webView = WKWebView(frame: .zero)
2338
+private final class InAppBrowserContainerViewController: NSViewController, WKNavigationDelegate, WKUIDelegate, NSTextFieldDelegate {
2339
+    private var webView: WKWebView!
2340
+    private var webContainerView: NSView!
2341
+    private weak var urlField: NSTextField?
2342
+    private var backButton: NSButton!
2343
+    private var forwardButton: NSButton!
2344
+    private var reloadStopButton: NSButton!
2345
+    private var goButton: NSButton!
2346
+    private var progressBar: NSProgressIndicator!
2347
+
2121 2348
     private var lastLoadedURL: URL?
2349
+    private var navigationPolicy: InAppBrowserURLPolicy = .allowAll
2350
+    private var processTerminateRetryCount = 0
2351
+    /// Includes fresh WKWebView instances so each retry gets a new WebContent process after a crash.
2352
+    private let maxProcessTerminateRetries = 3
2353
+    private var kvoTokens: [NSKeyValueObservation] = []
2354
+
2355
+    deinit {
2356
+        kvoTokens.removeAll()
2357
+    }
2358
+
2359
+    func setNavigationPolicy(_ policy: InAppBrowserURLPolicy) {
2360
+        navigationPolicy = policy
2361
+    }
2122 2362
 
2123 2363
     override func loadView() {
2364
+        let root = NSView()
2365
+        root.translatesAutoresizingMaskIntoConstraints = false
2366
+
2367
+        let wv = makeWebView()
2368
+        webView = wv
2369
+
2370
+        let webHost = NSView()
2371
+        webHost.translatesAutoresizingMaskIntoConstraints = false
2372
+        webHost.wantsLayer = true
2373
+        webHost.addSubview(wv)
2374
+        NSLayoutConstraint.activate([
2375
+            wv.leadingAnchor.constraint(equalTo: webHost.leadingAnchor),
2376
+            wv.trailingAnchor.constraint(equalTo: webHost.trailingAnchor),
2377
+            wv.topAnchor.constraint(equalTo: webHost.topAnchor),
2378
+            wv.bottomAnchor.constraint(equalTo: webHost.bottomAnchor)
2379
+        ])
2380
+        webContainerView = webHost
2381
+
2382
+        let toolbar = NSStackView()
2383
+        toolbar.translatesAutoresizingMaskIntoConstraints = false
2384
+        toolbar.orientation = .horizontal
2385
+        toolbar.spacing = 8
2386
+        toolbar.alignment = .centerY
2387
+        toolbar.edgeInsets = NSEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
2388
+
2389
+        backButton = makeToolbarButton(title: "◀", symbolName: "chevron.backward", accessibilityDescription: "Back")
2390
+        backButton.target = self
2391
+        backButton.action = #selector(goBack)
2392
+        forwardButton = makeToolbarButton(title: "▶", symbolName: "chevron.forward", accessibilityDescription: "Forward")
2393
+        forwardButton.target = self
2394
+        forwardButton.action = #selector(goForward)
2395
+        reloadStopButton = makeToolbarButton(title: "Reload", symbolName: "arrow.clockwise", accessibilityDescription: "Reload")
2396
+        reloadStopButton.target = self
2397
+        reloadStopButton.action = #selector(reloadOrStop)
2398
+
2399
+        let field = NSTextField(string: "")
2400
+        field.translatesAutoresizingMaskIntoConstraints = false
2401
+        field.font = NSFont.systemFont(ofSize: 13, weight: .regular)
2402
+        field.placeholderString = "Address"
2403
+        field.cell?.sendsActionOnEndEditing = false
2404
+        field.delegate = self
2405
+        urlField = field
2406
+
2407
+        goButton = NSButton(title: "Go", target: self, action: #selector(addressFieldSubmitted))
2408
+        goButton.translatesAutoresizingMaskIntoConstraints = false
2409
+        goButton.bezelStyle = .rounded
2410
+
2411
+        toolbar.addArrangedSubview(backButton)
2412
+        toolbar.addArrangedSubview(forwardButton)
2413
+        toolbar.addArrangedSubview(reloadStopButton)
2414
+        toolbar.addArrangedSubview(field)
2415
+        toolbar.addArrangedSubview(goButton)
2416
+        field.widthAnchor.constraint(greaterThanOrEqualToConstant: 240).isActive = true
2417
+
2418
+        let bar = NSProgressIndicator()
2419
+        bar.translatesAutoresizingMaskIntoConstraints = false
2420
+        bar.style = .bar
2421
+        bar.isIndeterminate = false
2422
+        bar.minValue = 0
2423
+        bar.maxValue = 1
2424
+        bar.doubleValue = 0
2425
+        bar.isHidden = true
2426
+        progressBar = bar
2427
+
2428
+        let separator = NSBox()
2429
+        separator.translatesAutoresizingMaskIntoConstraints = false
2430
+        separator.boxType = .separator
2431
+
2124 2432
         webView.navigationDelegate = self
2125
-        webView.translatesAutoresizingMaskIntoConstraints = false
2126
-        view = webView
2433
+        webView.uiDelegate = self
2434
+
2435
+        root.addSubview(toolbar)
2436
+        root.addSubview(bar)
2437
+        root.addSubview(separator)
2438
+        root.addSubview(webHost)
2439
+
2440
+        NSLayoutConstraint.activate([
2441
+            toolbar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
2442
+            toolbar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
2443
+            toolbar.topAnchor.constraint(equalTo: root.topAnchor),
2444
+
2445
+            bar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
2446
+            bar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
2447
+            bar.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
2448
+            bar.heightAnchor.constraint(equalToConstant: 3),
2449
+
2450
+            separator.leadingAnchor.constraint(equalTo: root.leadingAnchor),
2451
+            separator.trailingAnchor.constraint(equalTo: root.trailingAnchor),
2452
+            separator.topAnchor.constraint(equalTo: bar.bottomAnchor),
2453
+
2454
+            webHost.leadingAnchor.constraint(equalTo: root.leadingAnchor),
2455
+            webHost.trailingAnchor.constraint(equalTo: root.trailingAnchor),
2456
+            webHost.topAnchor.constraint(equalTo: separator.bottomAnchor),
2457
+            webHost.bottomAnchor.constraint(equalTo: root.bottomAnchor)
2458
+        ])
2459
+
2460
+        view = root
2461
+        installWebViewObservers()
2462
+        syncToolbarFromWebView()
2463
+    }
2464
+
2465
+    private func makeWebView() -> WKWebView {
2466
+        let wv = WKWebView(frame: .zero, configuration: InAppBrowserWebKitSupport.makeWebViewConfiguration())
2467
+        wv.translatesAutoresizingMaskIntoConstraints = false
2468
+        return wv
2469
+    }
2470
+
2471
+    private func teardownWebViewObservers() {
2472
+        kvoTokens.removeAll()
2473
+    }
2474
+
2475
+    /// New `WKWebView` = new WebContent process (helps after GPU/JS crashes on heavy sites like Meet).
2476
+    private func replaceWebViewAndLoad(url: URL) {
2477
+        teardownWebViewObservers()
2478
+        webView.navigationDelegate = nil
2479
+        webView.uiDelegate = nil
2480
+        webView.removeFromSuperview()
2481
+
2482
+        let wv = makeWebView()
2483
+        webView = wv
2484
+        webContainerView.addSubview(wv)
2485
+        NSLayoutConstraint.activate([
2486
+            wv.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
2487
+            wv.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
2488
+            wv.topAnchor.constraint(equalTo: webContainerView.topAnchor),
2489
+            wv.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor)
2490
+        ])
2491
+        webView.navigationDelegate = self
2492
+        webView.uiDelegate = self
2493
+        installWebViewObservers()
2494
+        syncToolbarFromWebView()
2495
+        webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
2496
+    }
2497
+
2498
+    private func makeToolbarButton(title: String, symbolName: String, accessibilityDescription: String) -> NSButton {
2499
+        let b = NSButton()
2500
+        b.translatesAutoresizingMaskIntoConstraints = false
2501
+        b.bezelStyle = .texturedRounded
2502
+        b.setAccessibilityLabel(accessibilityDescription)
2503
+        if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) {
2504
+            b.image = img
2505
+            b.imagePosition = .imageOnly
2506
+        } else {
2507
+            b.title = title
2508
+        }
2509
+        b.widthAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true
2510
+        return b
2511
+    }
2512
+
2513
+    private func installWebViewObservers() {
2514
+        kvoTokens.append(webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in
2515
+            self?.syncToolbarFromWebView()
2516
+        })
2517
+        kvoTokens.append(webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in
2518
+            self?.syncToolbarFromWebView()
2519
+        })
2520
+        kvoTokens.append(webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in
2521
+            self?.syncToolbarFromWebView()
2522
+        })
2523
+        kvoTokens.append(webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, _ in
2524
+            self?.syncProgressFromWebView()
2525
+        })
2526
+        kvoTokens.append(webView.observe(\.title, options: [.new]) { [weak self] _, _ in
2527
+            self?.syncWindowTitle()
2528
+        })
2529
+        kvoTokens.append(webView.observe(\.url, options: [.new]) { [weak self] _, _ in
2530
+            self?.syncAddressFieldFromWebView()
2531
+        })
2532
+    }
2533
+
2534
+    private func syncToolbarFromWebView() {
2535
+        backButton?.isEnabled = webView.canGoBack
2536
+        forwardButton?.isEnabled = webView.canGoForward
2537
+        if webView.isLoading {
2538
+            if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Stop") {
2539
+                reloadStopButton.image = img
2540
+                reloadStopButton.imagePosition = .imageOnly
2541
+                reloadStopButton.title = ""
2542
+            } else {
2543
+                reloadStopButton.title = "Stop"
2544
+            }
2545
+        } else {
2546
+            if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reload") {
2547
+                reloadStopButton.image = img
2548
+                reloadStopButton.imagePosition = .imageOnly
2549
+                reloadStopButton.title = ""
2550
+            } else {
2551
+                reloadStopButton.title = "Reload"
2552
+            }
2553
+        }
2554
+        syncProgressFromWebView()
2555
+    }
2556
+
2557
+    private func syncProgressFromWebView() {
2558
+        guard let progressBar else { return }
2559
+        if webView.isLoading {
2560
+            progressBar.isHidden = false
2561
+            progressBar.doubleValue = webView.estimatedProgress
2562
+        } else {
2563
+            progressBar.isHidden = true
2564
+            progressBar.doubleValue = 0
2565
+        }
2566
+    }
2567
+
2568
+    private func syncAddressFieldFromWebView() {
2569
+        guard let urlField, urlField.currentEditor() == nil, let url = webView.url else { return }
2570
+        urlField.stringValue = url.absoluteString
2571
+    }
2572
+
2573
+    private func syncWindowTitle() {
2574
+        let t = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
2575
+        let host = webView.url?.host ?? ""
2576
+        view.window?.title = t.isEmpty ? (host.isEmpty ? "Browser" : host) : t
2127 2577
     }
2128 2578
 
2129 2579
     func load(url: URL) {
2130 2580
         lastLoadedURL = url
2581
+        processTerminateRetryCount = 0
2582
+        urlField?.stringValue = url.absoluteString
2131 2583
         webView.load(URLRequest(url: url))
2584
+        syncWindowTitle()
2585
+    }
2586
+
2587
+    @objc private func goBack() {
2588
+        webView.goBack()
2589
+    }
2590
+
2591
+    @objc private func goForward() {
2592
+        webView.goForward()
2593
+    }
2594
+
2595
+    @objc private func reloadOrStop() {
2596
+        if webView.isLoading {
2597
+            webView.stopLoading()
2598
+        } else {
2599
+            webView.reload()
2600
+        }
2601
+    }
2602
+
2603
+    @objc private func addressFieldSubmitted() {
2604
+        let raw = urlField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
2605
+        guard raw.isEmpty == false else { return }
2606
+        var normalized = raw
2607
+        if normalized.lowercased().hasPrefix("http://") == false && normalized.lowercased().hasPrefix("https://") == false {
2608
+            normalized = "https://\(normalized)"
2609
+        }
2610
+        guard let url = URL(string: normalized),
2611
+              let scheme = url.scheme?.lowercased(),
2612
+              scheme == "http" || scheme == "https",
2613
+              url.host != nil
2614
+        else {
2615
+            let alert = NSAlert()
2616
+            alert.messageText = "Invalid address"
2617
+            alert.informativeText = "Enter a valid web address, for example https://example.com"
2618
+            alert.addButton(withTitle: "OK")
2619
+            alert.runModal()
2620
+            return
2621
+        }
2622
+        guard inAppBrowserURLAllowed(url, policy: navigationPolicy) else {
2623
+            presentBlockedHostAlert()
2624
+            return
2625
+        }
2626
+        load(url: url)
2627
+    }
2628
+
2629
+    private func presentBlockedHostAlert() {
2630
+        let alert = NSAlert()
2631
+        alert.messageText = "Address not allowed"
2632
+        alert.informativeText = "This URL is not permitted with the current in-app browser policy (whitelist)."
2633
+        alert.addButton(withTitle: "OK")
2634
+        alert.runModal()
2635
+    }
2636
+
2637
+    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
2638
+        processTerminateRetryCount = 0
2639
+        syncAddressFieldFromWebView()
2640
+        syncWindowTitle()
2132 2641
     }
2133 2642
 
2134 2643
     func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
@@ -2137,19 +2646,85 @@ private final class InAppBrowserViewController: NSViewController, WKNavigationDe
2137 2646
             return
2138 2647
         }
2139 2648
         let alert = NSAlert()
2140
-        alert.messageText = "Unable to Open Link"
2141
-        alert.informativeText = "Could not load this page in the in-app browser.\n\n\(error.localizedDescription)\n\nOpen in default browser instead?"
2142
-        alert.addButton(withTitle: "Open in Browser")
2143
-        alert.addButton(withTitle: "Cancel")
2144
-        let result = alert.runModal()
2145
-        if result == .alertFirstButtonReturn, let url = lastLoadedURL {
2146
-            NSWorkspace.shared.open(url)
2649
+        alert.messageText = "Unable to load page"
2650
+        alert.informativeText = "Could not load this page in the in-app browser.\n\n\(error.localizedDescription)"
2651
+        alert.addButton(withTitle: "Try Again")
2652
+        alert.addButton(withTitle: "OK")
2653
+        if alert.runModal() == .alertFirstButtonReturn, let url = lastLoadedURL {
2654
+            processTerminateRetryCount = 0
2655
+            webView.load(URLRequest(url: url))
2147 2656
         }
2148 2657
     }
2149 2658
 
2150 2659
     func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
2151
-        // WebKit process can be terminated by the OS; retry current page once.
2152
-        webView.reload()
2660
+        guard let url = lastLoadedURL else { return }
2661
+
2662
+        if processTerminateRetryCount < maxProcessTerminateRetries {
2663
+            processTerminateRetryCount += 1
2664
+            replaceWebViewAndLoad(url: url)
2665
+            return
2666
+        }
2667
+
2668
+        let alert = NSAlert()
2669
+        alert.messageText = "Page stopped loading"
2670
+        alert.informativeText =
2671
+            "The in-app browser closed this page unexpectedly. You can try loading it again in this same window."
2672
+        alert.addButton(withTitle: "Try Again")
2673
+        alert.addButton(withTitle: "OK")
2674
+        if alert.runModal() == .alertFirstButtonReturn {
2675
+            processTerminateRetryCount = 0
2676
+            replaceWebViewAndLoad(url: url)
2677
+        }
2678
+    }
2679
+
2680
+    func webView(
2681
+        _ webView: WKWebView,
2682
+        decidePolicyFor navigationAction: WKNavigationAction,
2683
+        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
2684
+    ) {
2685
+        guard let url = navigationAction.request.url else {
2686
+            decisionHandler(.allow)
2687
+            return
2688
+        }
2689
+        let scheme = (url.scheme ?? "").lowercased()
2690
+        if scheme == "mailto" || scheme == "tel" {
2691
+            decisionHandler(.cancel)
2692
+            return
2693
+        }
2694
+        if inAppBrowserURLAllowed(url, policy: navigationPolicy) == false {
2695
+            if navigationAction.targetFrame?.isMainFrame != false {
2696
+                DispatchQueue.main.async { [weak self] in
2697
+                    self?.presentBlockedHostAlert()
2698
+                }
2699
+            }
2700
+            decisionHandler(.cancel)
2701
+            return
2702
+        }
2703
+        decisionHandler(.allow)
2704
+    }
2705
+
2706
+    func webView(
2707
+        _ webView: WKWebView,
2708
+        createWebViewWith configuration: WKWebViewConfiguration,
2709
+        for navigationAction: WKNavigationAction,
2710
+        windowFeatures: WKWindowFeatures
2711
+    ) -> WKWebView? {
2712
+        if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
2713
+            if inAppBrowserURLAllowed(requestURL, policy: navigationPolicy) {
2714
+                webView.load(URLRequest(url: requestURL))
2715
+            } else {
2716
+                presentBlockedHostAlert()
2717
+            }
2718
+        }
2719
+        return nil
2720
+    }
2721
+
2722
+    func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
2723
+        if control === urlField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
2724
+            addressFieldSubmitted()
2725
+            return true
2726
+        }
2727
+        return false
2153 2728
     }
2154 2729
 }
2155 2730
 

+ 16 - 0
meetings_app/meetings_app.entitlements

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