From dd762d53b2bc6eddb6779279542c2be9138c1a6f Mon Sep 17 00:00:00 2001 From: xkeyC <3334969096@qq.com> Date: Mon, 22 Dec 2025 20:55:34 +0800 Subject: [PATCH] feat: WebView optimization --- lib/common/rust/rust_webview_controller.dart | 8 - lib/ui/webview/webview.dart | 12 -- rust/src/api/webview_api.rs | 3 +- .../src/webview}/request_interceptor.js | 56 +++---- rust/src/webview/webview_impl.rs | 75 +++++++++- rust/src/webview/webview_init_script.js | 138 ++++++++++++++++++ 6 files changed, 241 insertions(+), 51 deletions(-) rename {assets => rust/src/webview}/request_interceptor.js (94%) diff --git a/lib/common/rust/rust_webview_controller.dart b/lib/common/rust/rust_webview_controller.dart index b8ec5af..942a3e9 100644 --- a/lib/common/rust/rust_webview_controller.dart +++ b/lib/common/rust/rust_webview_controller.dart @@ -27,9 +27,6 @@ class RustWebViewController { /// 本地化脚本(从 assets 加载) String _localizationScript = ""; - /// 请求拦截器脚本 - String _requestInterceptorScript = ""; - /// 当前 URL String _currentUrl = ""; String get currentUrl => _currentUrl; @@ -76,7 +73,6 @@ class RustWebViewController { Future _loadScripts() async { try { _localizationScript = await rootBundle.loadString('assets/web_script.js'); - _requestInterceptorScript = await rootBundle.loadString('assets/request_interceptor.js'); } catch (e) { dPrint("Failed to load scripts: $e"); } @@ -120,10 +116,6 @@ class RustWebViewController { case rust_webview.WebViewEvent_NavigationCompleted(:final url): dPrint("Navigation completed: $url"); _currentUrl = url; - // 注入请求拦截器 - if (_requestInterceptorScript.isNotEmpty) { - executeScript(_requestInterceptorScript); - } // 导航完成回调(用于注入脚本) for (final callback in _navigationCompletedCallbacks) { callback(url); diff --git a/lib/ui/webview/webview.dart b/lib/ui/webview/webview.dart index b14805e..6f8b124 100644 --- a/lib/ui/webview/webview.dart +++ b/lib/ui/webview/webview.dart @@ -36,7 +36,6 @@ class WebViewModel { final localizationResource = {}; var localizationScript = ""; - var requestInterceptorScript = ""; bool enableCapture = false; @@ -75,7 +74,6 @@ class WebViewModel { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0", ); - webview.addOnNavigationCallback(_onNavigation); // 添加导航完成回调(用于注入脚本) webview.addOnNavigationCompletedCallback(_onNavigationCompleted); @@ -208,7 +206,6 @@ class WebViewModel { Future initLocalization(AppWebLocalizationVersionsData v) async { localizationScript = await rootBundle.loadString('assets/web_script.js'); - requestInterceptorScript = await rootBundle.loadString('assets/request_interceptor.js'); /// https://github.com/CxJuice/Uex_Chinese_Translate // get versions @@ -282,19 +279,10 @@ class WebViewModel { FutureOr dispose() { webview.removeOnNavigationCompletedCallback(_onNavigationCompleted); - webview.removeOnNavigationCallback(_onNavigation); if (loginMode && !_loginModeSuccess) { loginCallback?.call(null, false); } _isClosed = true; webview.dispose(); } - - void _onNavigation(String url) { - // 在页面加载时注入拦截器 - if (requestInterceptorScript.isNotEmpty) { - dPrint("Injecting request interceptor for: $url"); - webview.executeScript(requestInterceptorScript); - } - } } diff --git a/rust/src/api/webview_api.rs b/rust/src/api/webview_api.rs index 3a1c538..65f0005 100644 --- a/rust/src/api/webview_api.rs +++ b/rust/src/api/webview_api.rs @@ -5,7 +5,7 @@ use std::sync::atomic::Ordering; use flutter_rust_bridge::frb; use serde::{Deserialize, Serialize}; -use crate::webview::{WEBVIEW_INSTANCES, send_command}; +use crate::webview::{send_command, WEBVIEW_INSTANCES}; // ============ Data Structures ============ @@ -84,6 +84,7 @@ pub enum WebViewCommand { Close, SetWindowSize(u32, u32), SetWindowPosition(i32, i32), + SetWindowTitle(String), } // ============ Public API ============ diff --git a/assets/request_interceptor.js b/rust/src/webview/request_interceptor.js similarity index 94% rename from assets/request_interceptor.js rename to rust/src/webview/request_interceptor.js index 3fb8f92..8304326 100644 --- a/assets/request_interceptor.js +++ b/rust/src/webview/request_interceptor.js @@ -1,14 +1,14 @@ /// ------- Request Interceptor Script -------------- /// 轻量级网络请求拦截器,不破坏网页正常功能 -(function() { +(function () { 'use strict'; - + if (window._sctRequestInterceptorInstalled) { console.log('[SCToolbox] Request interceptor already installed'); return; } window._sctRequestInterceptorInstalled = true; - + // 被屏蔽的域名和路径 const blockedPatterns = [ 'google-analytics.com', @@ -28,24 +28,24 @@ 'facebook.net', 'gstatic.com/firebasejs' ]; - + // 判断 URL 是否应该被屏蔽 const shouldBlock = (url) => { if (!url || typeof url !== 'string') return false; const urlLower = url.toLowerCase(); return blockedPatterns.some(pattern => urlLower.includes(pattern.toLowerCase())); }; - + // 记录被拦截的请求 const logBlocked = (type, url) => { console.log(`[SCToolbox] ❌ Blocked ${type}:`, url); }; - + const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; - + // ============ 1. 拦截 Fetch API ============ const originalFetch = window.fetch; - window.fetch = function(...args) { + window.fetch = function (...args) { const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; if (shouldBlock(url)) { logBlocked('fetch', url); @@ -53,13 +53,13 @@ } return originalFetch.apply(this, args); }; - + // ============ 2. 拦截 XMLHttpRequest ============ const OriginalXHR = window.XMLHttpRequest; const originalXHROpen = OriginalXHR.prototype.open; const originalXHRSend = OriginalXHR.prototype.send; - - OriginalXHR.prototype.open = function(method, url, ...rest) { + + OriginalXHR.prototype.open = function (method, url, ...rest) { this._url = url; if (shouldBlock(url)) { logBlocked('XHR', url); @@ -67,8 +67,8 @@ } return originalXHROpen.apply(this, [method, url, ...rest]); }; - - OriginalXHR.prototype.send = function(...args) { + + OriginalXHR.prototype.send = function (...args) { if (this._blocked) { setTimeout(() => { const errorEvent = new Event('error'); @@ -78,13 +78,13 @@ } return originalXHRSend.apply(this, args); }; - + // ============ 3. 拦截 Image 元素的 src 属性 ============ const imgSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); if (imgSrcDescriptor && imgSrcDescriptor.set) { Object.defineProperty(HTMLImageElement.prototype, 'src', { get: imgSrcDescriptor.get, - set: function(value) { + set: function (value) { if (shouldBlock(value)) { logBlocked('IMG.src', value); // 设置为透明 GIF,避免请求 @@ -98,13 +98,13 @@ enumerable: true }); } - + // ============ 3.5. 拦截 Script 元素的 src 属性 ============ const scriptSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src'); if (scriptSrcDescriptor && scriptSrcDescriptor.set) { Object.defineProperty(HTMLScriptElement.prototype, 'src', { get: scriptSrcDescriptor.get, - set: function(value) { + set: function (value) { if (shouldBlock(value)) { logBlocked('SCRIPT.src', value); // 阻止加载,不设置 src @@ -117,10 +117,10 @@ enumerable: true }); } - + // ============ 4. 拦截 setAttribute(用于 img.setAttribute('src', ...))============ const originalSetAttribute = Element.prototype.setAttribute; - Element.prototype.setAttribute = function(name, value) { + Element.prototype.setAttribute = function (name, value) { if (name.toLowerCase() === 'src' && this.tagName === 'IMG' && shouldBlock(value)) { logBlocked('IMG setAttribute', value); originalSetAttribute.call(this, name, TRANSPARENT_GIF); @@ -133,11 +133,11 @@ } return originalSetAttribute.call(this, name, value); }; - + // ============ 5. 拦截 navigator.sendBeacon ============ if (navigator.sendBeacon) { const originalSendBeacon = navigator.sendBeacon.bind(navigator); - navigator.sendBeacon = function(url, data) { + navigator.sendBeacon = function (url, data) { if (shouldBlock(url)) { logBlocked('sendBeacon', url); return true; // 假装成功 @@ -145,13 +145,13 @@ return originalSendBeacon(url, data); }; } - + // ============ 6. 使用 MutationObserver 监听动态添加的元素 ============ const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType !== 1) return; // 只处理元素节点 - + try { // 检查 IMG 元素 if (node.tagName === 'IMG') { @@ -180,7 +180,7 @@ node.style.cssText += 'display:none !important;'; } } - + // 递归检查子元素 if (node.querySelectorAll) { node.querySelectorAll('img').forEach(img => { @@ -191,7 +191,7 @@ img.style.cssText += 'display:none !important;width:0;height:0;'; } }); - + node.querySelectorAll('script[src]').forEach(script => { const src = script.getAttribute('src'); if (src && shouldBlock(src)) { @@ -207,7 +207,7 @@ }); }); }); - + // 延迟启动 observer,等待页面初始化完成 const startObserver = () => { if (document.body) { @@ -220,13 +220,13 @@ setTimeout(startObserver, 50); } }; - + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startObserver); } else { startObserver(); } - + console.log('[SCToolbox] ✅ Request interceptor installed'); console.log('[SCToolbox] 🛡️ Blocking', blockedPatterns.length, 'patterns'); })(); diff --git a/rust/src/webview/webview_impl.rs b/rust/src/webview/webview_impl.rs index 7871a53..525c688 100644 --- a/rust/src/webview/webview_impl.rs +++ b/rust/src/webview/webview_impl.rs @@ -22,12 +22,26 @@ use crate::api::webview_api::{ WebViewCommand, WebViewConfiguration, WebViewEvent, WebViewNavigationState, }; +// ============ Loading Progress Animation ============ + +/// Braille spinner characters for loading animation +/// These are standard Unicode characters that work across all platforms +const SPINNER_CHARS: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/// Get the progress animation character for the current frame +fn get_progress_char(frame: usize) -> char { + SPINNER_CHARS[frame % SPINNER_CHARS.len()] +} + // Embed the app icon at compile time static APP_ICON_DATA: &[u8] = include_bytes!("../../../windows/runner/resources/app_icon.ico"); // Embed the init script at compile time static INIT_SCRIPT: &str = include_str!("webview_init_script.js"); +// Embed the request interceptor script at compile time +static REQUEST_INTERCEPTOR_SCRIPT: &str = include_str!("request_interceptor.js"); + // Loading page HTML for about:blank initialization static LOADING_PAGE_HTML: &str = r#" @@ -286,10 +300,14 @@ fn run_webview_loop( // When CF challenge is detected, we set this to 1, skip next load, then resume normal injection let cf_skip_count = Arc::new(AtomicU32::new(0)); + // Store original title for restoration after loading + let original_title = Arc::new(RwLock::new(config.title.clone())); + let event_tx_clone = event_tx.clone(); let nav_history_ipc = Arc::clone(&nav_history); let state_ipc = Arc::clone(&state); let cf_skip_count_ipc = Arc::clone(&cf_skip_count); + let original_title_ipc = Arc::clone(&original_title); // Create web context with custom data directory if provided let mut web_context = config @@ -302,7 +320,8 @@ fn run_webview_loop( .with_url("about:blank") .with_devtools(config.enable_devtools) .with_transparent(config.transparent) - .with_background_color((10, 29, 41, 255)); + .with_background_color((10, 29, 41, 255)) + .with_initialization_script(REQUEST_INTERCEPTOR_SCRIPT); // Set user agent if provided if let Some(ref user_agent) = config.user_agent { @@ -362,6 +381,36 @@ fn run_webview_loop( let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::Reload)); return; } + "loading_progress" => { + // Update window title with loading progress + if let Some(payload) = parsed.get("payload") { + let progress = payload.get("progress").and_then(|v| v.as_f64()); + let is_loading = payload.get("is_loading").and_then(|v| v.as_bool()).unwrap_or(false); + let dot_frame = payload.get("dot_frame").and_then(|v| v.as_i64()).unwrap_or(0) as usize; + + let base_title = original_title_ipc.read().clone(); + + let new_title = if is_loading { + // Get Windows progress animation character + let progress_char = get_progress_char(dot_frame); + + if let Some(p) = progress { + let percent = (p * 100.0).round() as i32; + format!("{} {} {}%", base_title, progress_char, percent) + } else { + format!("{} {} Loading", base_title, progress_char) + } + } else { + // Loading complete, restore original title + base_title + }; + + let _ = proxy_ipc.send_event(UserEvent::Command( + WebViewCommand::SetWindowTitle(new_title) + )); + } + return; + } _ => {} } } @@ -375,6 +424,7 @@ fn run_webview_loop( let state = Arc::clone(&state); let nav_history = Arc::clone(&nav_history); let proxy = proxy.clone(); + let original_title = Arc::clone(&original_title); move |uri| { // Block navigation to about:blank (except initial load) // This prevents users from going back to the blank initial page @@ -408,6 +458,17 @@ fn run_webview_loop( state_guard.can_go_forward = can_forward; } + // Update window title immediately to show loading state + // This provides instant feedback before JS is injected + { + let base_title = original_title.read().clone(); + let progress_char = get_progress_char(0); + let loading_title = format!("{} {} 0%", base_title, progress_char); + let _ = proxy.send_event(UserEvent::Command( + WebViewCommand::SetWindowTitle(loading_title) + )); + } + // Send loading state to JS immediately when navigation starts // This makes the spinner appear on the current page before navigating away let state_json = serde_json::json!({ @@ -436,8 +497,15 @@ fn run_webview_loop( if url == "about:blank" { if matches!(event, PageLoadEvent::Finished) { // if url is about:blank, show loading + // Add URL check in JS to prevent injection if page has already navigated away let loading_script = format!( - r#"document.open(); document.write({}); document.close();"#, + r#"(function() {{ + if (window.location.href === 'about:blank') {{ + document.open(); + document.write({}); + document.close(); + }} + }})();"#, serde_json::to_string(LOADING_PAGE_HTML).unwrap_or_default() ); let _ = proxy.send_event(UserEvent::Command( @@ -728,6 +796,9 @@ fn handle_command( WebViewCommand::SetWindowPosition(x, y) => { window.set_outer_position(LogicalPosition::new(x, y)); } + WebViewCommand::SetWindowTitle(title) => { + window.set_title(&title); + } } } diff --git a/rust/src/webview/webview_init_script.js b/rust/src/webview/webview_init_script.js index 891c345..6bdac22 100644 --- a/rust/src/webview/webview_init_script.js +++ b/rust/src/webview/webview_init_script.js @@ -14,6 +14,144 @@ } } + // ========== Loading Progress Tracker ========== + // Tracks page loading progress and updates window title via IPC + // Uses Braille spinner characters: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ (10 frames) + (function initProgressTracker() { + var dotFrame = 0; + var progressInterval = null; + var estimatedProgress = 0; + var isPageLoading = true; + var lastProgressSent = -1; + + // Send progress update to Rust (for window title) + function sendProgressUpdate(progress, loading) { + // Always send update for animation frames + lastProgressSent = (loading ? 1 : 0) * 1000 + Math.round((progress || 0) * 100); + + sendToRust('loading_progress', { + progress: progress, + is_loading: loading, + dot_frame: dotFrame + }); + } + + // Start the dot animation and progress estimation + function startProgressAnimation() { + if (progressInterval) return; + isPageLoading = true; + estimatedProgress = 0; + dotFrame = 0; + + // Animation interval: update every 80ms for smooth Braille spinner animation + // 10 frames at 80ms = 800ms per full cycle + progressInterval = setInterval(function () { + if (!isPageLoading) { + clearInterval(progressInterval); + progressInterval = null; + return; + } + + // Increment frame counter (will be modulo'd by Rust to 10 frames) + dotFrame = dotFrame + 1; + + // Estimate progress based on document state + var progress = estimateProgress(); + sendProgressUpdate(progress, true); + }, 80); + + // Send initial progress + sendProgressUpdate(0, true); + } + + // Estimate loading progress based on various signals + function estimateProgress() { + // Start with a base progress based on document ready state + var baseProgress = 0; + switch (document.readyState) { + case 'loading': + baseProgress = 0.15; + break; + case 'interactive': + baseProgress = 0.6; + break; + case 'complete': + baseProgress = 1.0; + break; + } + + // Use Performance API if available for more accurate estimation + if (window.performance && window.performance.timing) { + var timing = window.performance.timing; + var now = Date.now(); + + if (timing.loadEventEnd > 0) { + return 1.0; + } + + if (timing.navigationStart > 0) { + // Estimate based on time elapsed (assuming 3-5 second load) + var elapsed = now - timing.navigationStart; + var timeProgress = Math.min(elapsed / 4000, 0.95); + baseProgress = Math.max(baseProgress, timeProgress); + } + } + + // Use PerformanceObserver data if available + if (window.performance && window.performance.getEntriesByType) { + var resources = window.performance.getEntriesByType('resource'); + if (resources.length > 0) { + // Count completed resources vs estimated total + var completedCount = resources.filter(function (r) { + return r.responseEnd > 0; + }).length; + // Assume average page has ~30 resources + var resourceProgress = Math.min(completedCount / 30, 0.9); + baseProgress = Math.max(baseProgress, resourceProgress * 0.9); + } + } + + // Smooth progress (never go backwards, approach target gradually) + if (baseProgress > estimatedProgress) { + estimatedProgress = estimatedProgress + (baseProgress - estimatedProgress) * 0.3; + } + + return Math.min(estimatedProgress, 0.99); + } + + // Stop progress tracking and notify completion + function stopProgressAnimation() { + isPageLoading = false; + if (progressInterval) { + clearInterval(progressInterval); + progressInterval = null; + } + // Send final update with 100% and loading=false + sendProgressUpdate(1.0, false); + } + + // Start tracking when page starts loading + startProgressAnimation(); + + // Update on ready state changes + document.addEventListener('readystatechange', function () { + if (document.readyState === 'complete') { + setTimeout(stopProgressAnimation, 100); + } + }); + + // Fallback: stop on window load + window.addEventListener('load', function () { + setTimeout(stopProgressAnimation, 200); + }); + + // Expose for external control + window._sctProgressTracker = { + start: startProgressAnimation, + stop: stopProgressAnimation + }; + })(); + // ========== 导航栏 UI ========== const icons = { back: '',