// // WebViewLayoutController.swift // webview_universal // // Created by Bin Yang on 2021/11/18. // import Cocoa import FlutterMacOS import WebKit class WebViewLayoutController: NSViewController { private lazy var titleBarController: FlutterViewController = { let project = FlutterDartProject() project.dartEntrypointArguments = ["web_view_title_bar", "\(viewId)", "\(titleBarTopPadding)"] return FlutterViewController(project: project) }() private lazy var webView: WKWebView = { WKWebView() }() private var javaScriptHandlerNames: [String] = [] weak var webViewPlugin: WebviewUniversalPlugin? private var defaultUserAgent: String? private let methodChannel: FlutterMethodChannel private let viewId: Int64 private let titleBarHeight: Int private let titleBarTopPadding: Int public init(methodChannel: FlutterMethodChannel, viewId: Int64, titleBarHeight: Int, titleBarTopPadding: Int) { self.viewId = viewId self.methodChannel = methodChannel self.titleBarHeight = titleBarHeight self.titleBarTopPadding = titleBarTopPadding super.init(nibName: "WebViewLayoutController", bundle: Bundle(for: WebViewLayoutController.self)) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { super.loadView() addChild(titleBarController) titleBarController.view.translatesAutoresizingMaskIntoConstraints = false // Register titlebar plugins ClientMessageChannelPlugin.register(with: titleBarController.registrar(forPlugin: "WebviewUniversalPlugin")) let flutterView = titleBarController.view flutterView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(flutterView) let constraints = [ flutterView.topAnchor.constraint(equalTo: view.topAnchor), flutterView.leadingAnchor.constraint(equalTo: view.leadingAnchor), flutterView.trailingAnchor.constraint(equalTo: view.trailingAnchor), flutterView.heightAnchor.constraint(equalToConstant: CGFloat(titleBarHeight + titleBarTopPadding)), ] NSLayoutConstraint.activate(constraints) view.addSubview(webView) webView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ webView.topAnchor.constraint(equalTo: flutterView.bottomAnchor), webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) } override func viewDidLoad() { super.viewDidLoad() webView.navigationDelegate = self webView.uiDelegate = self // TODO(boyan01) Make it configuable from flutter. webView.configuration.preferences.javaEnabled = true webView.configuration.preferences.minimumFontSize = 12 webView.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true webView.configuration.allowsAirPlayForMediaPlayback = true webView.configuration.mediaTypesRequiringUserActionForPlayback = .video webView.addObserver(self, forKeyPath: "canGoBack", options: .new, context: nil) webView.addObserver(self, forKeyPath: "canGoForward", options: .new, context: nil) webView.addObserver(self, forKeyPath: "loading", options: .new, context: nil) defaultUserAgent = webView.value(forKey: "userAgent") as? String } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "canGoBack" || keyPath == "canGoForward" { methodChannel.invokeMethod("onHistoryChanged", arguments: [ "id": viewId, "canGoBack": webView.canGoBack, "canGoForward": webView.canGoForward, ] as [String: Any]) } else if keyPath == "loading" { if webView.isLoading { methodChannel.invokeMethod("onNavigationStarted", arguments: [ "id": viewId, ]) } else { methodChannel.invokeMethod("onNavigationCompleted", arguments: [ "id": viewId, ]) } } } func load(url: URL) { debugPrint("load url: \(url)") webView.load(URLRequest(url: url)) } func addJavascriptInterface(name: String) { javaScriptHandlerNames.append(name) webView.configuration.userContentController.add(self, name: name) } func removeJavascriptInterface(name: String) { if let index = javaScriptHandlerNames.firstIndex(of: name) { javaScriptHandlerNames.remove(at: index) } webView.configuration.userContentController.removeScriptMessageHandler(forName: name) } func addScriptToExecuteOnDocumentCreated(javaScript: String) { webView.configuration.userContentController.addUserScript( WKUserScript(source: javaScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) } func setApplicationNameForUserAgent(applicationName: String) { webView.customUserAgent = (defaultUserAgent ?? "") + applicationName } func destroy() { webView.removeObserver(self, forKeyPath: "canGoBack") webView.removeObserver(self, forKeyPath: "canGoForward") webView.removeObserver(self, forKeyPath: "loading") webView.uiDelegate = nil webView.navigationDelegate = nil javaScriptHandlerNames.forEach { name in webView.configuration.userContentController.removeScriptMessageHandler(forName: name) } webView.configuration.userContentController.removeAllUserScripts() } func reload() { webView.reload() } func goBack() { if webView.canGoBack { webView.goBack() } } func goForward() { if webView.canGoForward { webView.goForward() } } func stopLoading() { webView.stopLoading() } func evaluateJavaScript(javaScriptString: String, completer: @escaping FlutterResult) { webView.evaluateJavaScript(javaScriptString) { result, error in if let error = error { completer(FlutterError(code: "1", message: error.localizedDescription, details: nil)) return } completer(result) } } } extension WebViewLayoutController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard let url = navigationAction.request.url else { decisionHandler(.cancel) return } guard ["http", "https", "file"].contains(url.scheme?.lowercased() ?? "") else { decisionHandler(.cancel) return } methodChannel.invokeMethod("onUrlRequested", arguments: [ "id": viewId, "url": url.absoluteString, ] as [String: Any]) decisionHandler(.allow) } func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { decisionHandler(.allow) } } extension WebViewLayoutController: WKUIDelegate { func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) { methodChannel.invokeMethod( "runJavaScriptTextInputPanelWithPrompt", arguments: [ "id": viewId, "prompt": prompt, "defaultText": defaultText ?? "", ] as [String: Any]) { result in completionHandler((result as? String) ?? "") } } func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if !(navigationAction.targetFrame?.isMainFrame ?? false) { webView.load(navigationAction.request) } return nil } } extension WebViewLayoutController: WKScriptMessageHandler { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { methodChannel.invokeMethod( "onJavaScriptMessage", arguments: [ "id": viewId, "name": message.name, "body": message.body, ] as [String: Any]) } }