From da00a94f666e3cc8d25133fe3c0bf4d6a9ed1b85 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 6 Jul 2026 04:12:21 +0900 Subject: [PATCH] feat(sentry): tag events with the WebView engine and version (#4952) Forwarded browser events carry os/rust/device context but no browser context, so crashes couldn't be correlated with the WebView version. The app now reports its User-Agent at startup via a set_webview_info command; the parsed engine (Chromium/WebKit) and major version are stored and attached as webview.engine and webview.version tags in before_send, covering both Rust panics and forwarded browser events. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/readest-app/src-tauri/src/lib.rs | 21 +++++ .../src-tauri/src/sentry_config.rs | 76 ++++++++++++++++++- .../src/services/nativeAppService.ts | 7 ++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 6ab1481c..111fc265 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -294,6 +294,16 @@ fn is_updater_disabled() -> bool { updater_disabled() } +// Record the WebView engine/version (parsed from the app's User-Agent) so Sentry +// events can be correlated with WebView version. Called once from +// `NativeAppService.init()`; no-op when Sentry is disabled. +#[tauri::command] +fn set_webview_info(user_agent: String) { + if let Some((engine, version)) = sentry_config::parse_webview_info(&user_agent) { + sentry_config::set_webview_info(engine, version); + } +} + #[derive(Clone, serde::Serialize)] #[allow(dead_code)] struct SingleInstancePayload { @@ -345,6 +355,16 @@ pub fn run() { } } } + // Tag the WebView engine/version (reported by the app at + // startup) so crashes can be correlated with it. + if let Some((engine, version)) = sentry_config::webview_info() { + event + .tags + .insert("webview.engine".to_string(), engine.clone()); + event + .tags + .insert("webview.version".to_string(), version.clone()); + } Some(event) })), ..Default::default() @@ -374,6 +394,7 @@ pub fn run() { upload_file, get_environment_variable, get_executable_dir, + set_webview_info, #[cfg(desktop)] is_updater_disabled, allow_paths_in_scopes, diff --git a/apps/readest-app/src-tauri/src/sentry_config.rs b/apps/readest-app/src-tauri/src/sentry_config.rs index 285c6ad6..ff2b2802 100644 --- a/apps/readest-app/src-tauri/src/sentry_config.rs +++ b/apps/readest-app/src-tauri/src/sentry_config.rs @@ -85,6 +85,46 @@ pub fn is_ignored_browser_error(value: &str) -> bool { value.contains("View transition was skipped because document visibility state is hidden") } +/// The WebView (engine, major-version), set once at startup when the app reports +/// its User-Agent. Stored globally so `before_send` can tag every event — the +/// browser context integration doesn't run for events forwarded from the webview. +static WEBVIEW_INFO: std::sync::OnceLock<(String, String)> = std::sync::OnceLock::new(); + +/// Record the WebView engine + version. No-op if already set. +pub fn set_webview_info(engine: String, version: String) { + let _ = WEBVIEW_INFO.set((engine, version)); +} + +/// The recorded WebView `(engine, version)`, if the app has reported it yet. +pub fn webview_info() -> Option<&'static (String, String)> { + WEBVIEW_INFO.get() +} + +/// Parse the WebView engine and major version from a User-Agent string. Chromium +/// WebViews (Android System WebView, Windows WebView2, Linux Chrome) carry a +/// `Chrome/` token; WebKit ones (iOS/macOS WKWebView, Linux WebKitGTK) carry +/// `Version/` and no `Chrome/`. Chrome is checked first because Android +/// WebViews also include a legacy `Version/4.0`. `None` if neither is present. +pub fn parse_webview_info(user_agent: &str) -> Option<(String, String)> { + if let Some(v) = ua_major_version(user_agent, "Chrome/") { + return Some(("Chromium".to_string(), v)); + } + if let Some(v) = ua_major_version(user_agent, "Version/") { + return Some(("WebKit".to_string(), v)); + } + None +} + +fn ua_major_version(user_agent: &str, token: &str) -> Option { + let rest = &user_agent[user_agent.find(token)? + token.len()..]; + let major: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if major.is_empty() { + None + } else { + Some(major) + } +} + /// C-ABI accessor for the compile-time Sentry DSN, used by the iOS native /// bootstrap (sentry-cocoa) so it starts with the same DSN as the Rust client /// without a second env read or fragile Info.plist / preprocessor plumbing. @@ -106,7 +146,7 @@ pub extern "C" fn readest_sentry_dsn() -> *const std::os::raw::c_char { mod tests { use super::{ android_version_from_uname, corrected_os_name, dsn_from_env, environment_for_version, - is_ignored_browser_error, release_name, + is_ignored_browser_error, parse_webview_info, release_name, }; #[test] @@ -192,4 +232,38 @@ mod tests { assert!(!is_ignored_browser_error("concurrent use forbidden")); assert!(!is_ignored_browser_error("")); } + + #[test] + fn parses_chromium_webview_version() { + // Android System WebView carries a legacy `Version/4.0` AND `Chrome/140`; + // Chrome must win. + let ua = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) \ + Version/4.0 Chrome/140.0.0.0 Mobile Safari/537.36"; + assert_eq!( + parse_webview_info(ua), + Some(("Chromium".to_string(), "140".to_string())) + ); + } + + #[test] + fn parses_webkit_webview_version() { + let ios = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) \ + AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1"; + assert_eq!( + parse_webview_info(ios), + Some(("WebKit".to_string(), "17".to_string())) + ); + let gtk = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) \ + Version/2.44.0 Safari/605.1.15"; + assert_eq!( + parse_webview_info(gtk), + Some(("WebKit".to_string(), "2".to_string())) + ); + } + + #[test] + fn webview_info_is_none_for_unrecognized_ua() { + assert_eq!(parse_webview_info("curl/8.0"), None); + assert_eq!(parse_webview_info(""), None); + } } diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 36f20de4..be97d14b 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -595,6 +595,13 @@ export class NativeAppService extends BaseAppService { override async init() { const execDir = await invoke('get_executable_dir'); this.execDir = execDir; + // Report the WebView User-Agent so Sentry can tag crashes with the + // engine/version (the injected browser SDK's UA context isn't forwarded). + try { + await invoke('set_webview_info', { userAgent: navigator.userAgent }); + } catch (err) { + console.warn('[nativeAppService] set_webview_info failed:', err); + } // Ask Rust whether the in-app updater must stay hidden (READEST_DISABLE_UPDATER, // Flatpak, or a Linux deb/rpm/pacman install that Tauri can't self-update). The // command is the reliable source of truth; the `__READEST_UPDATER_DISABLED`