use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::ptr::NonNull;
use std::rc::Rc;
use block2::RcBlock;
use cookie::time::OffsetDateTime;
use futures::channel::oneshot;
use nami::Signal;
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObjectProtocol, ProtocolObject};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};
use objc2_foundation::{
NSArray, NSDictionary, NSError, NSHTTPCookie, NSInteger, NSJSONSerialization,
NSJSONWritingOptions, NSObject, NSRect, NSString, NSURL,
NSURLErrorAppTransportSecurityRequiresSecureConnection, NSURLErrorCancelled,
NSURLErrorCannotConnectToHost, NSURLErrorCannotFindHost, NSURLErrorClientCertificateRejected,
NSURLErrorClientCertificateRequired, NSURLErrorDNSLookupFailed, NSURLErrorDomain,
NSURLErrorNetworkConnectionLost, NSURLErrorNotConnectedToInternet,
NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateNotYetValid,
NSURLErrorServerCertificateUntrusted, NSURLErrorTimedOut, NSURLRequest,
};
use objc2_web_kit::{
WKContentWorld, WKNavigation, WKNavigationAction, WKNavigationActionPolicy,
WKNavigationDelegate, WKScriptMessage, WKScriptMessageHandler, WKSecurityOrigin,
WKUserContentController, WKUserScript, WKUserScriptInjectionTime, WKWebView,
WKWebViewConfiguration,
};
use waterui_core::{Computed, Environment, Str};
use waterui_webview::{
BackendEvent, Cookie, CustomWebViewController, OriginPolicy, ScriptInjectionTime, Url,
WatcherGuard, WatcherSet, WebViewController, WebViewError, WebViewEvent, WebViewHandle, bridge,
};
const TRANSPORT_SCRIPT: &str = concat!(
"globalThis.__wateruiSend = function (envelope) {",
"window.webkit.messageHandlers.__wateruiSend.postMessage(envelope);",
"};"
);
type JavaScriptHandler = Rc<waterui_webview::ScriptMessageHandler>;
const TLS_ERROR_CODES: [NSInteger; 8] = [
NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid,
NSURLErrorClientCertificateRejected,
NSURLErrorClientCertificateRequired,
NSURLErrorAppTransportSecurityRequiresSecureConnection,
];
const TRANSPORT_ERROR_CODES: [NSInteger; 6] = [
NSURLErrorTimedOut,
NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorNetworkConnectionLost,
NSURLErrorDNSLookupFailed,
NSURLErrorNotConnectedToInternet,
];
#[derive(Clone)]
struct InjectedScript {
key: String,
source: String,
time: ScriptInjectionTime,
}
struct SharedState {
watchers: WatcherSet<BackendEvent>,
redirects_enabled: RefCell<Computed<bool>>,
handlers: RefCell<HashMap<String, JavaScriptHandler>>,
scripts: RefCell<Vec<InjectedScript>>,
last_navigation_url: RefCell<Option<String>>,
bridge_origins: RefCell<Option<OriginPolicy>>,
bridge_admitted: Cell<bool>,
transport_registered: Cell<bool>,
}
impl Default for SharedState {
fn default() -> Self {
Self {
watchers: WatcherSet::new(),
redirects_enabled: RefCell::new(Computed::new(true)),
handlers: RefCell::new(HashMap::new()),
scripts: RefCell::new(Vec::new()),
last_navigation_url: RefCell::new(None),
bridge_origins: RefCell::new(None),
bridge_admitted: Cell::new(false),
transport_registered: Cell::new(false),
}
}
}
impl SharedState {
fn emit(&self, event: impl Into<BackendEvent>) {
self.watchers.emit(&event.into());
}
fn admits(&self, url: Option<&str>) -> bool {
let policy = self.bridge_origins.borrow();
let (Some(policy), Some(url)) = (policy.as_ref(), url) else {
return false;
};
Url::parse(url).is_some_and(|url| policy.allows(&url))
}
fn admit_document(&self, url: Option<&str>, controller: &WKUserContentController) {
self.bridge_admitted.set(self.admits(url));
install_user_scripts(controller, self);
}
fn emit_navigation_error(&self, error: &NSError) {
let is_url_error = {
let domain = unsafe { NSURLErrorDomain };
*error.domain() == *domain
};
let code = error.code();
if is_url_error && code == NSURLErrorCancelled {
return;
}
let message = Str::from(error.localizedDescription().to_string());
let error = if !is_url_error {
WebViewError::LoadFailed(message)
} else if TLS_ERROR_CODES.contains(&code) {
WebViewError::Ssl {
url: Self::parse_url(
self.last_navigation_url
.borrow()
.clone()
.unwrap_or_default(),
),
message,
}
} else if TRANSPORT_ERROR_CODES.contains(&code) {
WebViewError::Network(message)
} else {
WebViewError::LoadFailed(message)
};
self.emit(WebViewEvent::Error(error));
}
fn emit_navigation_state(&self, web_view: &WKWebView) {
self.emit(BackendEvent::NavigationState {
can_go_back: unsafe { web_view.canGoBack() },
can_go_forward: unsafe { web_view.canGoForward() },
});
}
fn current_url(web_view: &WKWebView) -> Option<String> {
unsafe {
web_view
.URL()
.and_then(|url| url.absoluteString())
.map(|url| url.to_string())
}
}
fn parse_url(raw: String) -> Url {
raw.parse()
.unwrap_or_else(|error| panic!("WebKit emitted an invalid URL {raw:?}: {error}"))
}
}
struct WebViewDelegateIvars {
shared: Rc<SharedState>,
}
define_class!(
#[unsafe(super(NSObject))]
#[name = "WuiHydrolysisWebViewDelegate"]
#[thread_kind = MainThreadOnly]
#[ivars = WebViewDelegateIvars]
struct WebViewDelegate;
unsafe impl NSObjectProtocol for WebViewDelegate {}
unsafe impl WKNavigationDelegate for WebViewDelegate {
#[unsafe(method(webView:decidePolicyForNavigationAction:decisionHandler:))]
#[allow(non_snake_case)]
unsafe fn webView_decidePolicyForNavigationAction_decisionHandler(
&self,
web_view: &WKWebView,
navigation_action: &WKNavigationAction,
decision_handler: &block2::DynBlock<dyn Fn(WKNavigationActionPolicy)>,
) {
let targets_main_frame = unsafe {
navigation_action
.targetFrame()
.is_some_and(|frame| frame.isMainFrame())
};
if targets_main_frame {
let url = unsafe {
navigation_action
.request()
.URL()
.and_then(|url| url.absoluteString())
}
.map(|url| url.to_string());
let controller = unsafe { web_view.configuration().userContentController() };
self.ivars()
.shared
.admit_document(url.as_deref(), &controller);
}
decision_handler.call((WKNavigationActionPolicy::Allow,));
}
#[unsafe(method(webView:didStartProvisionalNavigation:))]
#[allow(non_snake_case)]
unsafe fn webView_didStartProvisionalNavigation(
&self,
web_view: &WKWebView,
_navigation: Option<&WKNavigation>,
) {
if let Some(url) = SharedState::current_url(web_view) {
self.ivars()
.shared
.last_navigation_url
.replace(Some(url.clone()));
self.ivars().shared.emit(WebViewEvent::WillNavigate {
url: SharedState::parse_url(url),
});
}
self.ivars()
.shared
.emit(WebViewEvent::Loading { progress: 0.0 });
self.ivars().shared.emit_navigation_state(web_view);
}
#[unsafe(method(webView:didReceiveServerRedirectForProvisionalNavigation:))]
#[allow(non_snake_case)]
unsafe fn webView_didReceiveServerRedirectForProvisionalNavigation(
&self,
web_view: &WKWebView,
_navigation: Option<&WKNavigation>,
) {
let Some(to) = SharedState::current_url(web_view) else {
return;
};
let from = self
.ivars()
.shared
.last_navigation_url
.borrow()
.clone()
.unwrap_or_else(|| to.clone());
self.ivars().shared.emit(WebViewEvent::Redirect {
from: SharedState::parse_url(from),
to: SharedState::parse_url(to.clone()),
});
if self.ivars().shared.redirects_enabled.borrow().get() {
self.ivars()
.shared
.last_navigation_url
.replace(Some(to.clone()));
self.ivars().shared.emit(WebViewEvent::WillNavigate {
url: SharedState::parse_url(to),
});
} else {
unsafe {
web_view.stopLoading();
}
}
}
#[unsafe(method(webView:didCommitNavigation:))]
#[allow(non_snake_case)]
unsafe fn webView_didCommitNavigation(
&self,
web_view: &WKWebView,
_navigation: Option<&WKNavigation>,
) {
self.ivars().shared.emit(WebViewEvent::Loading {
progress: unsafe { web_view.estimatedProgress() } as f32,
});
}
#[unsafe(method(webView:didFinishNavigation:))]
#[allow(non_snake_case)]
unsafe fn webView_didFinishNavigation(
&self,
web_view: &WKWebView,
_navigation: Option<&WKNavigation>,
) {
self.ivars()
.shared
.emit(WebViewEvent::Loading { progress: 1.0 });
self.ivars().shared.emit(WebViewEvent::Loaded);
self.ivars().shared.emit_navigation_state(web_view);
}
#[unsafe(method(webView:didFailProvisionalNavigation:withError:))]
#[allow(non_snake_case)]
unsafe fn webView_didFailProvisionalNavigation_withError(
&self,
_web_view: &WKWebView,
_navigation: Option<&WKNavigation>,
error: &NSError,
) {
self.ivars().shared.emit_navigation_error(error);
}
#[unsafe(method(webView:didFailNavigation:withError:))]
#[allow(non_snake_case)]
unsafe fn webView_didFailNavigation_withError(
&self,
_web_view: &WKWebView,
_navigation: Option<&WKNavigation>,
error: &NSError,
) {
self.ivars().shared.emit_navigation_error(error);
}
}
unsafe impl WKScriptMessageHandler for WebViewDelegate {
#[unsafe(method(userContentController:didReceiveScriptMessage:))]
#[allow(non_snake_case)]
unsafe fn userContentController_didReceiveScriptMessage(
&self,
_user_content_controller: &WKUserContentController,
message: &WKScriptMessage,
) {
let body = unsafe { message.body() };
let Some(body) = body.downcast_ref::<NSString>() else {
tracing::warn!("WaterUI bridge received a non-string message body; ignoring");
return;
};
let allowed = {
let policy = self.ivars().shared.bridge_origins.borrow().clone();
let frame = unsafe { message.frameInfo() };
let is_main_frame = unsafe { frame.isMainFrame() };
let origin = unsafe { frame.securityOrigin() };
let origin = unsafe { security_origin_string(&origin) };
is_main_frame && policy.is_some_and(|policy| policy.allows_origin(&origin))
};
if !allowed {
tracing::warn!(
"a document outside the bridge origin policy tried to call a WaterUI handler"
);
return;
}
let request = match bridge::Request::parse(&body.to_string()) {
Ok(request) => request,
Err(error) => {
tracing::warn!(%error, "page script sent a malformed WaterUI bridge request");
return;
}
};
let handler = self
.ivars()
.shared
.handlers
.borrow()
.get(&request.name)
.map(Rc::clone);
let Some(handler) = handler else {
tracing::warn!(
handler = %request.name,
"page script called a WaterUI handler that is not registered"
);
let reply =
bridge::Reply::failure(&format!("no WaterUI handler named `{}`", request.name));
let web_view = unsafe { message.webView() }
.expect("a bridge message must have a source web view");
let script = NSString::from_str(&reply.resolve_script(request.id));
unsafe {
web_view.evaluateJavaScript_completionHandler(&script, None);
}
return;
};
let future = handler(&request.payload);
let web_view =
unsafe { message.webView() }.expect("a bridge message must have a source web view");
executor_core::spawn_local(async move {
let reply = match future.await {
Ok(reply) => bridge::Reply::from(reply),
Err(message) => bridge::Reply::Failure(message),
};
let script = NSString::from_str(&reply.resolve_script(request.id));
unsafe {
web_view.evaluateJavaScript_completionHandler(&script, None);
}
})
.detach();
}
}
);
unsafe fn security_origin_string(origin: &WKSecurityOrigin) -> String {
let (protocol, host, port) = unsafe {
(
origin.protocol().to_string(),
origin.host().to_string(),
origin.port(),
)
};
if port == 0 {
format!("{protocol}://{host}")
} else {
format!("{protocol}://{host}:{port}")
}
}
fn add_user_script(controller: &WKUserContentController, source: &str, time: ScriptInjectionTime) {
let source = NSString::from_str(source);
let injection_time = match time {
ScriptInjectionTime::DocumentStart => WKUserScriptInjectionTime::AtDocumentStart,
ScriptInjectionTime::DocumentEnd => WKUserScriptInjectionTime::AtDocumentEnd,
};
let mtm = MainThreadMarker::new()
.expect("Hydrolysis WKWebView scripts must be installed on the macOS main thread");
let script = unsafe {
WKUserScript::initWithSource_injectionTime_forMainFrameOnly(
WKUserScript::alloc(mtm),
&source,
injection_time,
true,
)
};
unsafe {
controller.addUserScript(&script);
}
}
fn install_user_scripts(controller: &WKUserContentController, shared: &SharedState) {
unsafe {
controller.removeAllUserScripts();
}
if !shared.bridge_admitted.get() {
return;
}
add_user_script(
controller,
TRANSPORT_SCRIPT,
ScriptInjectionTime::DocumentStart,
);
add_user_script(
controller,
waterui_webview::DOCUMENT_START_SCRIPT,
ScriptInjectionTime::DocumentStart,
);
for script in shared.scripts.borrow().iter() {
add_user_script(controller, &script.source, script.time);
}
}
fn marshal_javascript_result(value: *mut AnyObject) -> Result<Str, Str> {
let Some(value) = (unsafe { value.as_ref() }) else {
return Ok(Str::from_static(""));
};
if let Some(string) = value.downcast_ref::<NSString>() {
return Ok(Str::from(string.to_string()));
}
let data = unsafe {
NSJSONSerialization::dataWithJSONObject_options_error(
value,
NSJSONWritingOptions::FragmentsAllowed,
)
}
.map_err(|error| Str::from(error.localizedDescription().to_string()))?;
String::from_utf8(data.to_vec())
.map(Str::from)
.map_err(|error| Str::from(error.to_string()))
}
impl WebViewDelegate {
fn new(mtm: MainThreadMarker, shared: Rc<SharedState>) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(WebViewDelegateIvars { shared });
unsafe { msg_send![super(this), init] }
}
}
struct MacSystemWebViewInner {
web_view: Retained<WKWebView>,
delegate: Retained<WebViewDelegate>,
shared: Rc<SharedState>,
}
#[derive(Clone)]
pub(crate) struct MacSystemWebViewHandle {
inner: Rc<MacSystemWebViewInner>,
}
impl core::fmt::Debug for MacSystemWebViewHandle {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("MacSystemWebViewHandle")
.finish_non_exhaustive()
}
}
impl MacSystemWebViewHandle {
fn new() -> Self {
let mtm = MainThreadMarker::new()
.expect("Hydrolysis WKWebView must be created on the macOS main thread");
let shared = Rc::new(SharedState::default());
let delegate = WebViewDelegate::new(mtm, Rc::clone(&shared));
let configuration = unsafe { WKWebViewConfiguration::new(mtm) };
let web_view = unsafe {
WKWebView::initWithFrame_configuration(
WKWebView::alloc(mtm),
NSRect::ZERO,
&configuration,
)
};
unsafe {
web_view.setNavigationDelegate(Some(ProtocolObject::from_ref(&*delegate)));
}
let handle = Self {
inner: Rc::new(MacSystemWebViewInner {
web_view,
delegate,
shared,
}),
};
handle.rebuild_user_scripts();
handle
}
pub(crate) fn native_view(&self) -> Retained<WKWebView> {
self.inner.web_view.clone()
}
fn user_content_controller(&self) -> Retained<WKUserContentController> {
unsafe { self.inner.web_view.configuration().userContentController() }
}
fn ensure_transport_registered(&self) {
if self.inner.shared.transport_registered.replace(true) {
return;
}
let controller = self.user_content_controller();
let name = NSString::from_str(bridge::SEND_FUNCTION);
unsafe {
controller.addScriptMessageHandler_name(
ProtocolObject::from_ref(&*self.inner.delegate),
&name,
);
}
self.rebuild_user_scripts();
}
fn rebuild_user_scripts(&self) {
install_user_scripts(&self.user_content_controller(), &self.inner.shared);
}
fn native_cookie(cookie: &Cookie<'static>, web_view: &WKWebView) -> Retained<NSHTTPCookie> {
let value = cookie.to_string();
let source_url = SharedState::current_url(web_view).unwrap_or_else(|| {
let domain = cookie.domain().unwrap_or_else(|| {
panic!("A cookie set before navigation requires a Domain attribute")
});
let domain = domain.trim_start_matches('.');
let scheme = if cookie.secure() == Some(true) {
"https"
} else {
"http"
};
format!("{scheme}://{domain}/")
});
let source_url = NSURL::URLWithString(&NSString::from_str(&source_url))
.expect("Hydrolysis WKWebView cookie source URL must be valid");
let header_name = NSString::from_str("Set-Cookie");
let header_value = NSString::from_str(&value);
let headers = NSDictionary::from_slices(&[&*header_name], &[&*header_value]);
let cookies = NSHTTPCookie::cookiesWithResponseHeaderFields_forURL(&headers, &source_url);
assert!(
cookies.count() == 1,
"Hydrolysis WKWebView received an invalid Set-Cookie value: {value}"
);
cookies.objectAtIndex(0)
}
fn cookie_from_native(cookie: &NSHTTPCookie) -> Cookie<'static> {
let mut builder = Cookie::build((cookie.name().to_string(), cookie.value().to_string()))
.domain(cookie.domain().to_string())
.path(cookie.path().to_string())
.secure(cookie.isSecure())
.http_only(cookie.isHTTPOnly());
if let Some(expires) = cookie.expiresDate() {
let seconds = expires.timeIntervalSince1970();
if seconds.is_finite() {
let timestamp = seconds as i64;
let expires = OffsetDateTime::from_unix_timestamp(timestamp)
.expect("Hydrolysis WKWebView cookie expiration must fit OffsetDateTime");
builder = builder.expires(expires);
}
}
builder.build()
}
}
impl WebViewHandle for MacSystemWebViewHandle {
fn go_back(&self) {
unsafe {
self.inner.web_view.goBack();
}
}
fn go_forward(&self) {
unsafe {
self.inner.web_view.goForward();
}
}
fn go_to(&self, url: &Url) {
let url = NSURL::URLWithString(&NSString::from_str(url.as_str()))
.unwrap_or_else(|| panic!("WKWebView rejected the URL: {url}"));
let request = NSURLRequest::requestWithURL(&url);
unsafe {
self.inner.web_view.loadRequest(&request);
}
}
fn inject_script(&self, key: &str, script: &str, time: ScriptInjectionTime) {
{
let mut scripts = self.inner.shared.scripts.borrow_mut();
let entry = InjectedScript {
key: key.to_owned(),
source: script.to_owned(),
time,
};
match scripts.iter_mut().find(|script| script.key == key) {
Some(existing) => *existing = entry,
None => scripts.push(entry),
}
}
self.rebuild_user_scripts();
}
fn add_handler(&self, name: &str, handler: Box<waterui_webview::ScriptMessageHandler>) {
assert!(
!name.is_empty(),
"Hydrolysis WKWebView handler name must not be empty"
);
self.inner
.shared
.handlers
.borrow_mut()
.insert(name.to_owned(), Rc::from(handler));
self.ensure_transport_registered();
}
fn set_bridge_origins(&self, policy: OriginPolicy) {
self.inner.shared.bridge_origins.replace(Some(policy));
let url = SharedState::current_url(&self.inner.web_view);
self.inner
.shared
.admit_document(url.as_deref(), &self.user_content_controller());
}
fn remove_handler(&self, name: &str) {
self.inner.shared.handlers.borrow_mut().remove(name);
}
fn stop(&self) {
unsafe {
self.inner.web_view.stopLoading();
}
}
fn refresh(&self) {
unsafe {
self.inner.web_view.reload();
}
}
fn set_user_agent(&self, user_agent: &str) {
let user_agent = (!user_agent.trim().is_empty()).then(|| NSString::from_str(user_agent));
unsafe {
self.inner
.web_view
.setCustomUserAgent(user_agent.as_deref());
}
}
fn set_redirects_enabled(&self, enabled: impl Signal<Output = bool>) {
self.inner
.shared
.redirects_enabled
.replace(Computed::new(enabled));
}
fn watch(&self, watcher: impl Fn(BackendEvent) + 'static) -> WatcherGuard {
self.inner.shared.watchers.insert(watcher)
}
fn can_go_back(&self) -> bool {
unsafe { self.inner.web_view.canGoBack() }
}
fn can_go_forward(&self) -> bool {
unsafe { self.inner.web_view.canGoForward() }
}
fn set_cookie(&self, cookie: Cookie<'static>) {
let cookie = Self::native_cookie(&cookie, &self.inner.web_view);
let store = unsafe {
self.inner
.web_view
.configuration()
.websiteDataStore()
.httpCookieStore()
};
unsafe {
store.setCookie_completionHandler(&cookie, None);
}
}
async fn get_cookies(&self) -> Vec<Cookie<'static>> {
let store = unsafe {
self.inner
.web_view
.configuration()
.websiteDataStore()
.httpCookieStore()
};
let (sender, receiver) = oneshot::channel();
let sender = RefCell::new(Some(sender));
let completion = RcBlock::new(move |cookies: NonNull<NSArray<NSHTTPCookie>>| {
let cookies = unsafe { cookies.as_ref() };
let values = (0..cookies.count())
.map(|index| {
let cookie = cookies.objectAtIndex(index);
Self::cookie_from_native(&cookie)
})
.collect();
let sender = sender
.borrow_mut()
.take()
.expect("Hydrolysis WKWebView cookie callback invoked twice");
let _ = sender.send(values);
});
unsafe {
store.getAllCookies(&completion);
}
receiver
.await
.expect("Hydrolysis WKWebView cookie query was cancelled")
}
async fn run_javascript(&self, script: &str) -> Result<Str, Str> {
let (sender, receiver) = oneshot::channel();
let completion = Self::javascript_completion(sender);
unsafe {
self.inner.web_view.evaluateJavaScript_completionHandler(
&NSString::from_str(script),
Some(&completion),
);
}
receiver
.await
.expect("Hydrolysis WKWebView JavaScript evaluation was cancelled")
}
async fn call_async_javascript(&self, body: &str) -> Result<Str, Str> {
let mtm = MainThreadMarker::new()
.expect("Hydrolysis WKWebView JavaScript must be evaluated on the macOS main thread");
let (sender, receiver) = oneshot::channel();
let completion = Self::javascript_completion(sender);
unsafe {
self.inner
.web_view
.callAsyncJavaScript_arguments_inFrame_inContentWorld_completionHandler(
&NSString::from_str(body),
None,
None,
&WKContentWorld::pageWorld(mtm),
Some(&completion),
);
}
receiver
.await
.expect("Hydrolysis WKWebView JavaScript evaluation was cancelled")
}
}
impl MacSystemWebViewHandle {
fn javascript_completion(
sender: oneshot::Sender<Result<Str, Str>>,
) -> RcBlock<dyn Fn(*mut AnyObject, *mut NSError)> {
let sender = RefCell::new(Some(sender));
RcBlock::new(move |value: *mut AnyObject, error: *mut NSError| {
let result = if let Some(error) = unsafe { error.as_ref() } {
Err(Str::from(error.localizedDescription().to_string()))
} else {
marshal_javascript_result(value)
};
let sender = sender
.borrow_mut()
.take()
.expect("Hydrolysis WKWebView JavaScript callback invoked twice");
let _ = sender.send(result);
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct MacSystemWebViewController;
impl CustomWebViewController for MacSystemWebViewController {
fn open(&self) -> impl WebViewHandle {
MacSystemWebViewHandle::new()
}
}
pub(crate) fn install(env: &mut Environment) {
if env.get::<WebViewController>().is_some() {
return;
}
env.insert(WebViewController::new(MacSystemWebViewController));
}