From 656d8fc6aa3c987bce0901857fbc52c5b761a1b1 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Mon, 7 Sep 2026 11:11:20 +0800 Subject: [PATCH] feat(preview): report failed navigations to the MCP tools A navigation that fails (untrusted certificate, dead port) leaves the previous document on screen, so the JavaScript status probe cannot see it and the agent is left guessing. Observe the platform's own navigation callbacks instead: on macOS add didStart/didFail methods to wry's navigation delegate class at runtime, on Windows hook WebView2's NavigationStarting/NavigationCompleted. Keep the last failure per native webview; preview_status reports it as load_error {url, code, message} and preview_wait_for fails with it instead of waiting out its timeout. Reporting only. No TLS bypass, no settings. --- Cargo.lock | 2 + crates/app/src/preview_smoke.rs | 8 +- crates/preview-mcp/src/tools.rs | 3 +- crates/ui/Cargo.toml | 11 +- crates/ui/src/preview_panel.rs | 44 ++- crates/ui/src/preview_panel/lifecycle.rs | 45 ++- crates/ui/src/preview_panel/load_error.rs | 365 ++++++++++++++++++++++ 7 files changed, 452 insertions(+), 26 deletions(-) create mode 100644 crates/ui/src/preview_panel/load_error.rs 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.", + }) + ); + } +}