diff --git a/Cargo.lock b/Cargo.lock index a6fd21c7..6495fa64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9030,6 +9030,8 @@ dependencies = [ "tcode-voice", "term", "web-time", + "webview2-com", + "windows-core 0.61.2", ] [[package]] diff --git a/crates/app/src/preview_smoke.rs b/crates/app/src/preview_smoke.rs index 2723375f..da14566c 100644 --- a/crates/app/src/preview_smoke.rs +++ b/crates/app/src/preview_smoke.rs @@ -240,7 +240,7 @@ pub async fn run( shell.update(cx, |shell, cx| { shell.preview_lifecycle(cx).update(cx, |lifecycle, cx| { lifecycle.set_visible(Some(KEYS[1]), cx); - lifecycle.drop_view(KEYS[0]); + lifecycle.drop_view(KEYS[0], cx); }); }); yield_for(cx, STEP_DELAY).await; @@ -271,7 +271,7 @@ pub async fn run( shell.update(cx, |shell, cx| { shell.preview_lifecycle(cx).update(cx, |lifecycle, cx| { lifecycle.set_visible(Some(KEYS[1]), cx); - lifecycle.drop_view(DROP_DURING_CREATE_KEY); + lifecycle.drop_view(DROP_DURING_CREATE_KEY, cx); }); }); yield_for(cx, STEP_DELAY).await; @@ -290,8 +290,8 @@ pub async fn run( ) .await; shell.update(cx, |shell, cx| { - shell.preview_lifecycle(cx).update(cx, |lifecycle, _| { - lifecycle.drop_view(DROP_DURING_CREATE_KEY) + shell.preview_lifecycle(cx).update(cx, |lifecycle, cx| { + lifecycle.drop_view(DROP_DURING_CREATE_KEY, cx) }); }); watchdog.finish_phase("recreate-after-inflight-drop"); diff --git a/crates/preview-mcp/src/tools.rs b/crates/preview-mcp/src/tools.rs index f2140c56..0154c9f2 100644 --- a/crates/preview-mcp/src/tools.rs +++ b/crates/preview-mcp/src/tools.rs @@ -217,7 +217,8 @@ impl PreviewTools { #[tool( description = "Report the preview browser's current URL, title, and loading state; call this first for browser work. \ If no automation-capable preview is attached, call preview_open before concluding the browser is unavailable. \ - Do not fall back to Chrome, Playwright, or another browser merely because the preview is initially closed or a first call fails; fall back only when preview_open explicitly reports unsupported or unavailable." + Do not fall back to Chrome, Playwright, or another browser merely because the preview is initially closed or a first call fails; fall back only when preview_open explicitly reports unsupported or unavailable. \ + Includes load_error with the platform error when the last navigation failed, e.g. an untrusted certificate." )] async fn preview_status(&self) -> CallToolResult { self.run(PreviewOp::Status).await diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 3820dfbb..7113aab4 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -30,6 +30,8 @@ desktop = [ "dep:objc2-foundation", "dep:objc2-image-io", "dep:objc2-web-kit", + "dep:webview2-com", + "dep:windows-core", ] [dependencies] @@ -77,9 +79,14 @@ block2 = { version = "0.6.2", optional = true } objc2 = { version = "0.6.4", optional = true } objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSGraphicsContext", "NSImage", "NSImageRep", "NSPasteboard", "objc2-core-graphics"], optional = true } objc2-core-foundation = { version = "0.3.2", features = ["CFData", "CFString"], optional = true } -objc2-foundation = { version = "0.3.2", features = ["NSError", "NSNotification", "NSOperation", "NSString", "block2"], optional = true } +objc2-foundation = { version = "0.3.2", features = ["NSDictionary", "NSError", "NSNotification", "NSOperation", "NSString", "NSURLError", "block2"], optional = true } objc2-image-io = { version = "0.3.2", features = ["CGImageDestination"], optional = true } -objc2-web-kit = { version = "0.3.2", features = ["WKSnapshotConfiguration", "WKWebView"], optional = true } +objc2-web-kit = { version = "0.3.2", features = ["WKNavigationDelegate", "WKSnapshotConfiguration", "WKWebView"], optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +# Pinned to the version lb-wry resolves, so both drive the same WebView2 objects. +webview2-com = { version = "0.38.2", optional = true } +windows-core = { version = "0.61", optional = true } [dev-dependencies] criterion = "0.8" diff --git a/crates/ui/src/preview_panel.rs b/crates/ui/src/preview_panel.rs index 54b5a96c..69dda057 100644 --- a/crates/ui/src/preview_panel.rs +++ b/crates/ui/src/preview_panel.rs @@ -27,6 +27,15 @@ //! or allowing a stale completion to replace a newer preview. macOS keeps the //! proven synchronous child-view path. //! +//! ## Load errors +//! +//! A navigation that fails — an untrusted certificate, a dead port — leaves the +//! previous document on screen, so the JavaScript status probe cannot see it. +//! [`load_error`] observes the platform's own navigation callbacks (WKWebView's +//! delegate, WebView2's `NavigationCompleted`) and keeps the last failure per +//! webview; `preview_status` reports it as `load_error` and `preview_wait_for` +//! fails with it instead of waiting out its timeout. +//! //! ## Known caveat — native overlay //! //! A `gpui-wry` WebView is a **native child view drawn over** the gpui window, @@ -73,6 +82,8 @@ type ReplyTx = async_channel::Sender>; #[cfg(not(target_os = "linux"))] pub(crate) mod lifecycle; +#[cfg(not(target_os = "linux"))] +mod load_error; #[cfg(not(target_os = "linux"))] pub use native::PreviewPanel; @@ -249,7 +260,7 @@ mod native { fn drop_webview(&mut self, key: &str, cx: &mut Context) { self.lifecycle - .update(cx, |lifecycle, _| lifecycle.drop_view(key)); + .update(cx, |lifecycle, cx| lifecycle.drop_view(key, cx)); if self.mirrored.as_deref() == Some(key) { self.mirrored = None; } @@ -265,7 +276,7 @@ mod native { self.mirrored = None; } self.lifecycle - .update(cx, |lifecycle, _| lifecycle.prune(&live)); + .update(cx, |lifecycle, cx| lifecycle.prune(&live, cx)); } /// Mirror a URL into the store, then navigate through the lifecycle. @@ -439,7 +450,8 @@ mod native { let payload = serde_json::json!({ "ok": true, "url": self.store.read(cx).preview_url(&key), - "note": "call preview_status for live page state once loaded", + "note": "call preview_status for live page state once loaded; \ + it reports load_error when the page failed to load", }); let _ = reply.try_send(Ok(PreviewReply::Json(payload))); } @@ -460,7 +472,8 @@ mod native { let payload = serde_json::json!({ "ok": true, "url": self.store.read(cx).preview_url(&key), - "note": "page is loading; call preview_status for live state", + "note": "page is loading; call preview_status for live state, \ + which reports load_error when the page failed to load", }); let _ = reply.try_send(Ok(PreviewReply::Json(payload))); } @@ -564,12 +577,18 @@ mod native { }) }) .unwrap_or_else(|| serde_json::json!({ "mode": "fill" })); + // The page cannot see a failed navigation, so the JS probe would + // keep describing whatever was on screen before it. + let load_error = self.lifecycle.read(cx).load_error(key, cx); let (status_reply, status_result) = async_channel::bounded(1); cx.spawn(async move |_, _| { let result = match status_result.recv().await { Ok(Ok(PreviewReply::Json(mut value))) => { if let Some(object) = value.as_object_mut() { object.insert("canvas".into(), canvas); + if let Some(load_error) = load_error { + object.insert("load_error".into(), load_error.to_json()); + } Ok(PreviewReply::Json(value)) } else { Err("preview status returned a non-object value".into()) @@ -660,18 +679,25 @@ mod native { return; } let (probe_reply, probe_result) = async_channel::bounded(1); - if this - .update(cx, |panel, cx| { + // A failed navigation never changes the page, so waiting on + // it can only time out; report the platform error instead. + let Ok(failure) = this.update(cx, |panel, cx| { + let failure = panel.lifecycle.read(cx).load_error(&key, cx); + if failure.is_none() { panel.lifecycle.update(cx, |lifecycle, cx| { lifecycle.evaluate_ready(&key, &probe, probe_reply.clone(), cx); }); - }) - .is_err() - { + } + failure + }) else { let _ = reply .send(Err("preview panel was dropped while waiting".into())) .await; return; + }; + if let Some(failure) = failure { + let _ = reply.send(Err(failure.describe())).await; + return; } let watchdog_delay = remaining.min(Duration::from_secs(5)); diff --git a/crates/ui/src/preview_panel/lifecycle.rs b/crates/ui/src/preview_panel/lifecycle.rs index f0fa654e..4dfe65fb 100644 --- a/crates/ui/src/preview_panel/lifecycle.rs +++ b/crates/ui/src/preview_panel/lifecycle.rs @@ -20,10 +20,11 @@ use std::collections::{HashMap, HashSet}; use std::rc::Weak; use std::time::Duration; -use gpui::{AppContext as _, Context, Entity, Window}; +use gpui::{App, AppContext as _, Context, Entity, Window}; use gpui_wry::WebView; use preview_mcp::{PreviewReply, js}; +use super::load_error::{self, LoadError}; use super::{ReplyTx, unavailable_message}; const STARTING_MESSAGE: &str = "preview is starting; retry the operation shortly"; @@ -177,14 +178,21 @@ impl BrowserLifecycle { ) -> Availability { let availability = self.ensure(key, Some(url), window, cx); match &availability { - Availability::Ready(webview) => match webview.read(cx).raw().load_url(url) { - Ok(()) => { - self.warm.insert(key.to_string()); - } - Err(error) => { - log::warn!("preview: failed to navigate {key}: {error}"); + Availability::Ready(webview) => { + let raw = webview.read(cx).raw(); + // Clear the previous failure now rather than on WebKit's + // asynchronous didStart, so a wait_for issued right after + // this navigation cannot fail on the old record. + load_error::forget(raw); + match raw.load_url(url) { + Ok(()) => { + self.warm.insert(key.to_string()); + } + Err(error) => { + log::warn!("preview: failed to navigate {key}: {error}"); + } } - }, + } #[cfg(target_os = "windows")] Availability::Starting(_) => { if let Some(WebViewSlot::Creating { pending_url, .. }) = self.slots.get_mut(key) { @@ -264,13 +272,14 @@ impl BrowserLifecycle { } /// Tear down one ready or in-progress browser generation. - pub fn drop_view(&mut self, key: &str) { + pub fn drop_view(&mut self, key: &str, cx: &App) { + self.forget_load_error(key, cx); self.slots.remove(key); self.warm.remove(key); } /// Tear down every browser whose session key is no longer live. - pub fn prune(&mut self, live_keys: &HashSet) { + pub fn prune(&mut self, live_keys: &HashSet, cx: &App) { let deleted = self .slots .keys() @@ -278,11 +287,24 @@ impl BrowserLifecycle { .cloned() .collect::>(); for key in deleted { + self.forget_load_error(&key, cx); self.slots.remove(&key); self.warm.remove(&key); } } + /// The last navigation failure the platform reported for this browser, if + /// the current page is still the one it left behind. + pub(super) fn load_error(&self, key: &str, cx: &App) -> Option { + load_error::get(self.ready_view(key)?.read(cx).raw()) + } + + fn forget_load_error(&self, key: &str, cx: &App) { + if let Some(view) = self.ready_view(key) { + load_error::forget(view.read(cx).raw()); + } + } + pub fn unavailable_error(&self) -> Option<&str> { match &self.creator { Creator::Available(_) => None, @@ -441,6 +463,7 @@ impl BrowserLifecycle { window: &mut Window, cx: &mut Context, ) { + load_error::install(&raw); let warm = if let Some(url) = &pending_url { match raw.load_url(url) { Ok(()) => true, @@ -532,6 +555,7 @@ mod platform { return Availability::Unavailable; } }; + load_error::install(&raw); let webview = cx.new(|cx| { let mut view = WebView::new(raw, window, cx); set_webview_visible(&mut view, false); @@ -747,6 +771,7 @@ mod platform { } fn drop_raw_webview(raw: wry::WebView, key: &str, reason: &str) { + load_error::forget(&raw); if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(raw))).is_err() { log::error!("preview: raw webview drop panicked for {key} after {reason}"); } diff --git a/crates/ui/src/preview_panel/load_error.rs b/crates/ui/src/preview_panel/load_error.rs new file mode 100644 index 00000000..6b1fa7de --- /dev/null +++ b/crates/ui/src/preview_panel/load_error.rs @@ -0,0 +1,365 @@ +//! The last navigation failure reported by the native webview. +//! +//! A failed navigation is invisible to JavaScript: WebKit and WebView2 keep the +//! previous document loaded, so the status probe keeps reporting the old URL and +//! an agent cannot tell an untrusted certificate from a slow dev server. Both +//! engines *do* report the failure to their navigation delegate, so we keep the +//! last one per native webview and surface it from `preview_status` and +//! `preview_wait_for`. +//! +//! The map is keyed by the native webview pointer because the macOS callback is +//! a plain Objective-C IMP that cannot carry Rust state. Everything here runs on +//! the UI thread that owns the webviews, hence `thread_local!`. + +use std::cell::RefCell; +use std::collections::HashMap; + +/// One failed navigation, as the platform described it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LoadError { + pub(crate) url: String, + /// `NSURLErrorDomain -1202` on macOS, `CertificateIsInvalid` on Windows. + pub(crate) code: String, + pub(crate) message: String, +} + +impl LoadError { + pub(crate) fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "url": self.url, + "code": self.code, + "message": self.message, + }) + } + + /// The one-line form used where a tool can only answer with an error. + pub(crate) fn describe(&self) -> String { + format!( + "navigation to {} failed: {} ({})", + self.url, self.message, self.code + ) + } +} + +thread_local! { + static LAST: RefCell> = RefCell::new(HashMap::new()); +} + +fn clear(webview: usize) { + LAST.with_borrow_mut(|last| last.remove(&webview)); +} + +fn record(webview: usize, error: LoadError) { + log::info!("preview: {}", error.describe()); + LAST.with_borrow_mut(|last| last.insert(webview, error)); +} + +/// Start reporting failed navigations for a freshly created webview. +pub(crate) fn install(raw: &wry::WebView) { + imp::install(raw); +} + +pub(crate) fn get(raw: &wry::WebView) -> Option { + let webview = imp::key(raw); + LAST.with_borrow(|last| last.get(&webview).cloned()) +} + +/// Drop the record for a webview that is going away, so a later allocation at +/// the same address cannot inherit it. +pub(crate) fn forget(raw: &wry::WebView) { + clear(imp::key(raw)); +} + +#[cfg(target_os = "macos")] +mod imp { + use objc2::rc::Retained; + use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; + use objc2::{ffi, sel}; + use objc2_foundation::{NSError, NSURL, NSURLErrorFailingURLErrorKey}; + use objc2_web_kit::WKWebView; + use wry::WebViewExtMacOS as _; + + use super::LoadError; + + pub(super) fn key(raw: &wry::WebView) -> usize { + Retained::as_ptr(&raw.webview()) as usize + } + + /// wry's `WryNavigationDelegate` implements navigation policy, `didCommit` + /// and `didFinish` only, so nothing observes a failed load. Add the three + /// missing callbacks to its class — once for the process, and only where a + /// future wry does not define them itself. + pub(super) fn install(raw: &wry::WebView) { + static PATCHED: std::sync::Once = std::sync::Once::new(); + + let webview = raw.webview(); + let Some(delegate) = (unsafe { webview.navigationDelegate() }) else { + log::debug!("preview: webview has no navigation delegate to observe"); + return; + }; + // SAFETY: any Objective-C object may be viewed as an `AnyObject`. + let class = unsafe { &*Retained::as_ptr(&delegate).cast::() }.class(); + PATCHED.call_once(|| { + let start: unsafe extern "C-unwind" fn( + *mut AnyObject, + Sel, + *mut AnyObject, + *mut AnyObject, + ) = did_start; + let fail: unsafe extern "C-unwind" fn( + *mut AnyObject, + Sel, + *mut AnyObject, + *mut AnyObject, + *mut NSError, + ) = did_fail; + // SAFETY: both functions use the Objective-C calling convention and + // match the encodings below. + unsafe { + let start: Imp = std::mem::transmute(start); + let fail: Imp = std::mem::transmute(fail); + add_method( + class, + sel!(webView:didStartProvisionalNavigation:), + c"v@:@@", + start, + ); + add_method( + class, + sel!(webView:didFailProvisionalNavigation:withError:), + c"v@:@@@", + fail, + ); + add_method( + class, + sel!(webView:didFailNavigation:withError:), + c"v@:@@@", + fail, + ); + } + }); + // WKWebView caches which delegate methods exist when the delegate is + // assigned, so re-assign it: without this the very first webview (whose + // delegate was installed before the patch above) never calls them. + unsafe { + webview.setNavigationDelegate(None); + webview.setNavigationDelegate(Some(&delegate)); + } + } + + /// # Safety + /// + /// `imp` must be an `extern "C"` function whose signature matches `types`. + unsafe fn add_method(class: &AnyClass, selector: Sel, types: &std::ffi::CStr, imp: Imp) { + if class.responds_to(selector) { + return; + } + let class = std::ptr::from_ref(class).cast_mut(); + let added = unsafe { ffi::class_addMethod(class, selector, imp, types.as_ptr()) }; + if !added.as_bool() { + log::debug!("preview: failed to observe {selector} on the navigation delegate"); + } + } + + unsafe extern "C-unwind" fn did_start( + _this: *mut AnyObject, + _cmd: Sel, + webview: *mut AnyObject, + _navigation: *mut AnyObject, + ) { + super::clear(webview as usize); + } + + unsafe extern "C-unwind" fn did_fail( + _this: *mut AnyObject, + _cmd: Sel, + webview: *mut AnyObject, + _navigation: *mut AnyObject, + error: *mut NSError, + ) { + // SAFETY: WebKit hands us a live error for the failing web view. + let Some(error) = (unsafe { error.as_ref() }) else { + return; + }; + super::record( + webview as usize, + LoadError { + url: failing_url(error) + .or_else(|| current_url(webview)) + .unwrap_or_default(), + code: format!("{} {}", error.domain(), error.code()), + message: error.localizedDescription().to_string(), + }, + ); + } + + /// The URL WebKit was loading, which is *not* `WKWebView.URL` for a + /// provisional failure — that still points at the page left on screen. + fn failing_url(error: &NSError) -> Option { + let info = error.userInfo(); + let failing = unsafe { info.objectForKey(NSURLErrorFailingURLErrorKey) }?; + let url = failing.downcast::().ok()?; + url.absoluteString().map(|url| url.to_string()) + } + + fn current_url(webview: *mut AnyObject) -> Option { + // SAFETY: WebKit passes the live web view that failed. + let webview: &WKWebView = unsafe { webview.cast::().as_ref() }?; + let url = unsafe { webview.URL() }?; + url.absoluteString().map(|url| url.to_string()) + } +} + +#[cfg(target_os = "windows")] +mod imp { + use webview2_com::Microsoft::Web::WebView2::Win32::{ + COREWEBVIEW2_WEB_ERROR_STATUS, COREWEBVIEW2_WEB_ERROR_STATUS_CANNOT_CONNECT, + COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_COMMON_NAME_IS_INCORRECT, + COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_EXPIRED, + COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_IS_INVALID, + COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_REVOKED, + COREWEBVIEW2_WEB_ERROR_STATUS_CLIENT_CERTIFICATE_CONTAINS_ERRORS, + COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_ABORTED, + COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_RESET, COREWEBVIEW2_WEB_ERROR_STATUS_DISCONNECTED, + COREWEBVIEW2_WEB_ERROR_STATUS_ERROR_HTTP_INVALID_SERVER_RESPONSE, + COREWEBVIEW2_WEB_ERROR_STATUS_HOST_NAME_NOT_RESOLVED, + COREWEBVIEW2_WEB_ERROR_STATUS_OPERATION_CANCELED, + COREWEBVIEW2_WEB_ERROR_STATUS_REDIRECT_FAILED, + COREWEBVIEW2_WEB_ERROR_STATUS_SERVER_UNREACHABLE, COREWEBVIEW2_WEB_ERROR_STATUS_TIMEOUT, + COREWEBVIEW2_WEB_ERROR_STATUS_UNEXPECTED_ERROR, + COREWEBVIEW2_WEB_ERROR_STATUS_VALID_AUTHENTICATION_CREDENTIALS_REQUIRED, + COREWEBVIEW2_WEB_ERROR_STATUS_VALID_PROXY_AUTHENTICATION_REQUIRED, ICoreWebView2, + }; + use webview2_com::{ + NavigationCompletedEventHandler, NavigationStartingEventHandler, take_pwstr, + }; + use windows_core::Interface as _; + use wry::WebViewExtWindows as _; + + use super::LoadError; + + pub(super) fn key(raw: &wry::WebView) -> usize { + raw.webview().as_raw() as usize + } + + /// WebView2 reports a failed navigation only through `NavigationCompleted`; + /// the page itself keeps showing the previous document. + pub(super) fn install(raw: &wry::WebView) { + let webview = raw.webview(); + let mut token = 0i64; + let started = NavigationStartingEventHandler::create(Box::new(move |webview, _| { + if let Some(webview) = webview { + super::clear(webview.as_raw() as usize); + } + Ok(()) + })); + if let Err(error) = unsafe { webview.add_NavigationStarting(&started, &mut token) } { + log::debug!("preview: failed to observe navigation start: {error}"); + } + let completed = NavigationCompletedEventHandler::create(Box::new(move |webview, args| { + let (Some(webview), Some(args)) = (webview, args) else { + return Ok(()); + }; + let mut succeeded = windows_core::BOOL::default(); + unsafe { args.IsSuccess(&mut succeeded)? }; + if succeeded.as_bool() { + return Ok(()); + } + let mut status = COREWEBVIEW2_WEB_ERROR_STATUS::default(); + unsafe { args.WebErrorStatus(&mut status)? }; + super::record( + webview.as_raw() as usize, + LoadError { + url: source_url(&webview), + code: status_name(status).to_string(), + message: format!("WebView2 error status {}", status.0), + }, + ); + Ok(()) + })); + if let Err(error) = unsafe { webview.add_NavigationCompleted(&completed, &mut token) } { + log::debug!("preview: failed to observe navigation completion: {error}"); + } + } + + fn source_url(webview: &ICoreWebView2) -> String { + let mut source = windows_core::PWSTR::null(); + match unsafe { webview.Source(&mut source) } { + Ok(()) => take_pwstr(source), + Err(error) => { + log::debug!("preview: failed to read the failing URL: {error}"); + String::new() + } + } + } + + /// WebView2 statuses are a plain integer newtype; report the SDK name so the + /// agent (and the user) can recognise e.g. a self-signed certificate. + fn status_name(status: COREWEBVIEW2_WEB_ERROR_STATUS) -> &'static str { + match status { + COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_COMMON_NAME_IS_INCORRECT => { + "CertificateCommonNameIsIncorrect" + } + COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_EXPIRED => "CertificateExpired", + COREWEBVIEW2_WEB_ERROR_STATUS_CLIENT_CERTIFICATE_CONTAINS_ERRORS => { + "ClientCertificateContainsErrors" + } + COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_REVOKED => "CertificateRevoked", + COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_IS_INVALID => "CertificateIsInvalid", + COREWEBVIEW2_WEB_ERROR_STATUS_SERVER_UNREACHABLE => "ServerUnreachable", + COREWEBVIEW2_WEB_ERROR_STATUS_TIMEOUT => "Timeout", + COREWEBVIEW2_WEB_ERROR_STATUS_ERROR_HTTP_INVALID_SERVER_RESPONSE => { + "ErrorHttpInvalidServerResponse" + } + COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_ABORTED => "ConnectionAborted", + COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_RESET => "ConnectionReset", + COREWEBVIEW2_WEB_ERROR_STATUS_DISCONNECTED => "Disconnected", + COREWEBVIEW2_WEB_ERROR_STATUS_CANNOT_CONNECT => "CannotConnect", + COREWEBVIEW2_WEB_ERROR_STATUS_HOST_NAME_NOT_RESOLVED => "HostNameNotResolved", + COREWEBVIEW2_WEB_ERROR_STATUS_OPERATION_CANCELED => "OperationCanceled", + COREWEBVIEW2_WEB_ERROR_STATUS_REDIRECT_FAILED => "RedirectFailed", + COREWEBVIEW2_WEB_ERROR_STATUS_UNEXPECTED_ERROR => "UnexpectedError", + COREWEBVIEW2_WEB_ERROR_STATUS_VALID_AUTHENTICATION_CREDENTIALS_REQUIRED => { + "ValidAuthenticationCredentialsRequired" + } + COREWEBVIEW2_WEB_ERROR_STATUS_VALID_PROXY_AUTHENTICATION_REQUIRED => { + "ValidProxyAuthenticationRequired" + } + _ => "Unknown", + } + } +} + +#[cfg(test)] +mod tests { + use super::LoadError; + + fn certificate_failure() -> LoadError { + LoadError { + url: "https://localhost:8443/".into(), + code: "NSURLErrorDomain -1202".into(), + message: "The certificate for this server is invalid.".into(), + } + } + + #[test] + fn load_error_reports_the_platform_error_verbatim() { + assert_eq!( + certificate_failure().describe(), + "navigation to https://localhost:8443/ failed: \ + The certificate for this server is invalid. (NSURLErrorDomain -1202)" + ); + } + + #[test] + fn load_error_json_is_url_code_message() { + assert_eq!( + certificate_failure().to_json(), + serde_json::json!({ + "url": "https://localhost:8443/", + "code": "NSURLErrorDomain -1202", + "message": "The certificate for this server is invalid.", + }) + ); + } +}