From e776e24388ffa3dc780b54d61026254409305f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Sobczak?= Date: Sat, 2 May 2026 03:14:45 +0200 Subject: [PATCH 01/59] feat: implement native child webview command for email rendering and enable unstable features --- TODO.md | 2 +- src-tauri/Cargo.toml | 34 ++++++++++++++++++++++++++++------ src-tauri/src/cmd/mod.rs | 1 + src-tauri/src/cmd/webview.rs | 35 +++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 5 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/cmd/webview.rs diff --git a/TODO.md b/TODO.md index 8a1e080..45d17be 100644 --- a/TODO.md +++ b/TODO.md @@ -255,7 +255,7 @@ - [ ] 26.8 - UI: Detect dates in emails and suggest "Add to Calendar" - [ ] 27 - Isolated Webview with Watchdog - - [ ] 27.1 - Rust: Replace iframe with native Tauri Child Webview for email rendering + - [x] 27.1 - Rust: Replace iframe with native Tauri Child Webview for email rendering - [ ] 27.2 - UI: Create placeholder div in MessageViewBody for Child Webview positioning - [ ] 27.3 - Rust: Position Child Webview as overlay aligned to the React placeholder bounds - [ ] 27.4 - Rust: Serve email HTML to Child Webview via existing `postail://` protocol handler diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e1217f8..4dfe923 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,7 +13,11 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["protocol-asset", "tray-icon"] } +tauri = { version = "2", features = [ + "protocol-asset", + "tray-icon", + "unstable", +] } tauri-plugin-opener = "2" tauri-plugin-os = "2" tauri-plugin-dialog = "2" @@ -45,7 +49,11 @@ hkdf = "0.12" hex = "0.4" # Database -rusqlite = { version = "0.38", features = ["bundled-sqlcipher", "unlock_notify", "backup"] } +rusqlite = { version = "0.38", features = [ + "bundled-sqlcipher", + "unlock_notify", + "backup", +] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } zip = { version = "7", default-features = false, features = ["deflate"] } @@ -67,7 +75,9 @@ ammonia = "4" utf7-imap = "0.3.2" regex = "1" async-smtp = "0.10.2" -lettre = { version = "0.11.19", default-features = false, features = ["builder"] } +lettre = { version = "0.11.19", default-features = false, features = [ + "builder", +] } tokio-native-tls = "0.3.1" kuchikiki = "0.8.8-speedreader" markup5ever = "0.14" @@ -82,15 +92,27 @@ icalendar = "0.17.10" # Linux-specific deps [target.'cfg(target_os = "linux")'.dependencies] -nix = { version = "0.29", features = ["process", "user", "socket", "fs", "inotify"] } +nix = { version = "0.29", features = [ + "process", + "user", + "socket", + "fs", + "inotify", +] } tauri-plugin-notification = { version = "2.3.3" } webkit2gtk = { version = "2.0", features = ["v2_32"] } # Windows-specific deps [target.'cfg(target_os = "windows")'.dependencies] -tauri-plugin-notification = { version = "2.3.3", features = ["windows-version"] } +tauri-plugin-notification = { version = "2.3.3", features = [ + "windows-version", +] } dunce = "1.0.5" -windows = { version = "0.62.2", features = ["ApplicationModel_Appointments", "Foundation", "ApplicationModel_Appointments_AppointmentsProvider"] } +windows = { version = "0.62.2", features = [ + "ApplicationModel_Appointments", + "Foundation", + "ApplicationModel_Appointments_AppointmentsProvider", +] } [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-autostart = "2.5.1" diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index 0775841..fab96ae 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -14,3 +14,4 @@ pub mod signatures; pub mod smtp; pub mod templates; pub mod utils; +pub mod webview; diff --git a/src-tauri/src/cmd/webview.rs b/src-tauri/src/cmd/webview.rs new file mode 100644 index 0000000..3c7675d --- /dev/null +++ b/src-tauri/src/cmd/webview.rs @@ -0,0 +1,35 @@ +use tauri::{ + AppHandle, LogicalPosition, LogicalSize, Manager, WebviewBuilder, WebviewUrl, Window, command, +}; +use tracing::{info, warn}; + +#[command] +pub async fn create_email_webview(app: AppHandle, window: Window) -> Result<(), String> { + if let Some(existing) = app.get_webview("email-webview") { + info!("Email webview already exists, closing it first."); + let _ = existing.close(); + } + + info!("Creating new child webview for email content"); + + // We use External to point to our custom protocol + let url = "postail://localhost/message/current" + .parse() + .map_err(|e| format!("Failed to parse webview URL: {}", e))?; + + let builder = WebviewBuilder::new("email-webview", WebviewUrl::External(url)); + + // Build and attach as a child to the provided window + window + .add_child( + builder, + LogicalPosition::new(0.0, 0.0), + LogicalSize::new(100.0, 100.0), + ) + .map_err(|e| { + warn!("Failed to build child webview: {}", e); + format!("Failed to build child webview: {}", e) + })?; + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3bde826..9fc81c2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -197,6 +197,7 @@ pub fn run() { cmd::mail::sync::unwatch_all_mailboxes, cmd::maintenance::clear_cache, cmd::maintenance::backfill_snippets, + cmd::webview::create_email_webview, cmd::settings::get_autostart_enabled, cmd::settings::set_autostart_enabled, cmd::maintenance::dev_reset_data, From b2d1ec825e7c3ea42ce26326cec65e603d12a510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Sobczak?= Date: Sat, 2 May 2026 19:10:57 +0200 Subject: [PATCH 02/59] ui(webview): Use native child Webview overlay Add Tauri commands to update and destroy the email webview and yield briefly when recreating to let the close propagate. Replace iframe in MessageViewBody with a placeholder div, spawn/destroy the native child webview from React, and use ResizeObserver + requestAnimationFrame to sync logical bounds via update_email_webview_bounds. Remove the iframe postMessage resize/link handling and related iframe plumbing. --- TODO.md | 2 +- src-tauri/src/cmd/webview.rs | 36 ++++- src-tauri/src/lib.rs | 2 + .../Inbox/MessageView/MessageViewBody.tsx | 127 ++++++++---------- 4 files changed, 92 insertions(+), 75 deletions(-) diff --git a/TODO.md b/TODO.md index 45d17be..c7af489 100644 --- a/TODO.md +++ b/TODO.md @@ -256,7 +256,7 @@ - [ ] 27 - Isolated Webview with Watchdog - [x] 27.1 - Rust: Replace iframe with native Tauri Child Webview for email rendering - - [ ] 27.2 - UI: Create placeholder div in MessageViewBody for Child Webview positioning + - [x] 27.2 - UI: Create placeholder div in MessageViewBody for Child Webview positioning - [ ] 27.3 - Rust: Position Child Webview as overlay aligned to the React placeholder bounds - [ ] 27.4 - Rust: Serve email HTML to Child Webview via existing `postail://` protocol handler - [ ] 27.5 - ACL: Create `email-webview` capability with zero permissions (deny all IPC) diff --git a/src-tauri/src/cmd/webview.rs b/src-tauri/src/cmd/webview.rs index 3c7675d..c589ea5 100644 --- a/src-tauri/src/cmd/webview.rs +++ b/src-tauri/src/cmd/webview.rs @@ -8,18 +8,18 @@ pub async fn create_email_webview(app: AppHandle, window: Window) -> Result<(), if let Some(existing) = app.get_webview("email-webview") { info!("Email webview already exists, closing it first."); let _ = existing.close(); + // Small yield so the close propagates before we recreate + tokio::time::sleep(std::time::Duration::from_millis(16)).await; } info!("Creating new child webview for email content"); - // We use External to point to our custom protocol let url = "postail://localhost/message/current" .parse() .map_err(|e| format!("Failed to parse webview URL: {}", e))?; let builder = WebviewBuilder::new("email-webview", WebviewUrl::External(url)); - // Build and attach as a child to the provided window window .add_child( builder, @@ -33,3 +33,35 @@ pub async fn create_email_webview(app: AppHandle, window: Window) -> Result<(), Ok(()) } + +#[command] +pub fn update_email_webview_bounds( + app: AppHandle, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), String> { + let Some(webview) = app.get_webview("email-webview") else { + // Bounds arrived before the webview was ready — silently ignore. + return Ok(()); + }; + + webview + .set_position(LogicalPosition::new(x, y)) + .map_err(|e| format!("set_position failed: {e}"))?; + + webview + .set_size(LogicalSize::new(width, height)) + .map_err(|e| format!("set_size failed: {e}"))?; + + Ok(()) +} + +#[command] +pub fn destroy_email_webview(app: AppHandle) { + if let Some(webview) = app.get_webview("email-webview") { + info!("Destroying email webview"); + let _ = webview.close(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9fc81c2..96239c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -198,6 +198,8 @@ pub fn run() { cmd::maintenance::clear_cache, cmd::maintenance::backfill_snippets, cmd::webview::create_email_webview, + cmd::webview::update_email_webview_bounds, + cmd::webview::destroy_email_webview, cmd::settings::get_autostart_enabled, cmd::settings::set_autostart_enabled, cmd::maintenance::dev_reset_data, diff --git a/src/components/Inbox/MessageView/MessageViewBody.tsx b/src/components/Inbox/MessageView/MessageViewBody.tsx index 2d60e2d..e1141a3 100644 --- a/src/components/Inbox/MessageView/MessageViewBody.tsx +++ b/src/components/Inbox/MessageView/MessageViewBody.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, useMemo } from 'react' import { invoke } from '@tauri-apps/api/core' +import { getCurrentWindow } from '@tauri-apps/api/window' import { useThemeStore } from '@/stores/themeStore' import { useTypedTranslation } from '@/hooks/useTypedTranslation' import { ConfirmationDialog } from '@/components/ui/custom/ConfirmationDialog' @@ -19,10 +20,9 @@ export const MessageViewBody = ({ const { t } = useTypedTranslation(['security', 'common']) const accentColor = useThemeStore((s) => s.accentColor) - const iframeRef = useRef(null) - const isReadyRef = useRef(false) - - const [iframeSrcDoc, setIframeSrcDoc] = useState(null) + const containerRef = useRef(null) + // Tracks whether a rAF is already scheduled to avoid stacking frames + const rafPendingRef = useRef(false) const [pendingUrl, setPendingUrl] = useState(null) const [warningOpen, setWarningOpen] = useState(false) @@ -31,7 +31,6 @@ export const MessageViewBody = ({ const inlineImagesHash = JSON.stringify(inline_images) - // Memoize mapped images to prevent unnecessary re-renders const mappedImages = useMemo(() => { return inline_images .filter((img) => img.cid && img.cached_path) @@ -43,11 +42,10 @@ export const MessageViewBody = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [inlineImagesHash]) - // 1. Process HTML Content + // 1. Push HTML content to the Rust protocol handler useEffect(() => { if (effectiveMode !== 'html') return - isReadyRef.current = false onLoadingChange?.(true) const hasDarkMode = @@ -59,7 +57,6 @@ export const MessageViewBody = ({ const iframeTextColor = hasDarkMode ? 'inherit' : '#1a1a1a' const colorScheme = hasDarkMode ? 'dark light' : 'light' - // Minimalist HTML wrapper relying on ResizeObserver const htmlTemplate = ` @@ -87,36 +84,11 @@ export const MessageViewBody = ({
${htmlContent}
@@ -130,36 +102,59 @@ export const MessageViewBody = ({ }) .then((res) => { if (res.hasExternalResources) onExternalDetected?.() - setIframeSrcDoc(res.processedHtml) }) .catch(console.error) .finally(() => onLoadingChange?.(false)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [htmlContent, effectiveMode, accentColor, allowExternalResources, mappedImages]) - // 2. Handle Iframe Messages + // 2. Spawn / destroy the child webview for HTML mode useEffect(() => { - const handler = (e: MessageEvent) => { - if (e.source !== iframeRef.current?.contentWindow) return - - if (e.data?.type === 'resize' && typeof e.data.height === 'number') { - // Bypass React state for height to prevent laggy re-renders - if (iframeRef.current) { - iframeRef.current.style.height = `${e.data.height}px` - } - } - - if (e.data?.type === 'link' && e.data.url) { - setPendingUrl(e.data.url) - setWarningOpen(true) - } + if (effectiveMode !== 'html') return + + const win = getCurrentWindow() + invoke('create_email_webview', { window: win }).catch(console.error) + + return () => { + invoke('destroy_email_webview').catch(console.error) } + }, [effectiveMode]) - window.addEventListener('message', handler) - return () => window.removeEventListener('message', handler) - }, []) + // 3. ResizeObserver — sync native webview bounds to the placeholder div + useEffect(() => { + if (effectiveMode !== 'html') return + const el = containerRef.current + if (!el) return + + const syncBounds = () => { + rafPendingRef.current = false + const rect = el.getBoundingClientRect() + const dpr = window.devicePixelRatio ?? 1 + + // Tauri 2 set_position / set_size expect logical coords, + // so we divide out the pixel ratio. + invoke('update_email_webview_bounds', { + x: rect.x / dpr, + y: rect.y / dpr, + width: rect.width / dpr, + height: rect.height / dpr, + }).catch(console.error) + } + + const ro = new ResizeObserver(() => { + if (rafPendingRef.current) return + rafPendingRef.current = true + requestAnimationFrame(syncBounds) + }) - // 3. Render Plain Text + ro.observe(el) + // Initial sync as soon as the webview is mounted + requestAnimationFrame(syncBounds) + + return () => ro.disconnect() + }, [effectiveMode]) + + // Render Plain Text if (effectiveMode === 'plain') { return (
@@ -170,26 +165,14 @@ export const MessageViewBody = ({ ) } - // 4. Render HTML + // Render HTML — blank placeholder div that the native child webview overlays return ( <> -
-
- {iframeSrcDoc && ( -