feat: WebView optimization

This commit is contained in:
xkeyC 2025-12-22 20:55:34 +08:00
parent eb42c7101d
commit dd762d53b2
6 changed files with 241 additions and 51 deletions

View File

@ -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<void> _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);

View File

@ -36,7 +36,6 @@ class WebViewModel {
final localizationResource = <String, dynamic>{};
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<void> 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<void> 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);
}
}
}

View File

@ -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 ============

View File

@ -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');
})();

View File

@ -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#"<!DOCTYPE html>
<html>
@ -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);
}
}
}

View File

@ -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: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',