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

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

@@ -0,0 +1,232 @@
/// ------- Request Interceptor Script --------------
/// 轻量级网络请求拦截器,不破坏网页正常功能
(function () {
'use strict';
if (window._sctRequestInterceptorInstalled) {
console.log('[SCToolbox] Request interceptor already installed');
return;
}
window._sctRequestInterceptorInstalled = true;
// 被屏蔽的域名和路径
const blockedPatterns = [
'google-analytics.com',
'www.google.com/ccm/collect',
'www.google.com/pagead',
'www.google.com/ads',
'googleapis.com',
'doubleclick.net',
'reddit.com/rp.gif',
'alb.reddit.com',
'pixel-config.reddit.com',
'conversions-config.reddit.com',
'redditstatic.com/ads',
'analytics.tiktok.com',
'googletagmanager.com',
'facebook.com',
'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) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
if (shouldBlock(url)) {
logBlocked('fetch', url);
return Promise.reject(new Error('Blocked by SCToolbox'));
}
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) {
this._url = url;
if (shouldBlock(url)) {
logBlocked('XHR', url);
this._blocked = true;
}
return originalXHROpen.apply(this, [method, url, ...rest]);
};
OriginalXHR.prototype.send = function (...args) {
if (this._blocked) {
setTimeout(() => {
const errorEvent = new Event('error');
this.dispatchEvent(errorEvent);
}, 0);
return;
}
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) {
if (shouldBlock(value)) {
logBlocked('IMG.src', value);
// 设置为透明 GIF避免请求
imgSrcDescriptor.set.call(this, TRANSPARENT_GIF);
this.style.cssText += 'display:none !important;width:0;height:0;';
return;
}
return imgSrcDescriptor.set.call(this, value);
},
configurable: true,
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) {
if (shouldBlock(value)) {
logBlocked('SCRIPT.src', value);
// 阻止加载,不设置 src
this.type = 'javascript/blocked';
return;
}
return scriptSrcDescriptor.set.call(this, value);
},
configurable: true,
enumerable: true
});
}
// ============ 4. 拦截 setAttribute用于 img.setAttribute('src', ...)============
const originalSetAttribute = Element.prototype.setAttribute;
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);
this.style.cssText += 'display:none !important;width:0;height:0;';
return;
}
if (name.toLowerCase() === 'src' && this.tagName === 'SCRIPT' && shouldBlock(value)) {
logBlocked('SCRIPT setAttribute', value);
return; // 阻止设置
}
return originalSetAttribute.call(this, name, value);
};
// ============ 5. 拦截 navigator.sendBeacon ============
if (navigator.sendBeacon) {
const originalSendBeacon = navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function (url, data) {
if (shouldBlock(url)) {
logBlocked('sendBeacon', url);
return true; // 假装成功
}
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') {
const src = node.getAttribute('src') || node.src;
if (src && shouldBlock(src)) {
logBlocked('Dynamic IMG', src);
node.src = TRANSPARENT_GIF;
node.style.cssText += 'display:none !important;width:0;height:0;';
}
}
// 检查 SCRIPT 元素
else if (node.tagName === 'SCRIPT') {
const src = node.getAttribute('src');
if (src && shouldBlock(src)) {
logBlocked('Dynamic SCRIPT', src);
node.type = 'javascript/blocked';
node.removeAttribute('src');
}
}
// 检查 IFRAME 元素
else if (node.tagName === 'IFRAME') {
const src = node.getAttribute('src');
if (src && shouldBlock(src)) {
logBlocked('Dynamic IFRAME', src);
node.src = 'about:blank';
node.style.cssText += 'display:none !important;';
}
}
// 递归检查子元素
if (node.querySelectorAll) {
node.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('src') || img.src;
if (src && shouldBlock(src)) {
logBlocked('Child IMG', src);
img.src = TRANSPARENT_GIF;
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)) {
logBlocked('Child SCRIPT', src);
script.type = 'javascript/blocked';
script.removeAttribute('src');
}
});
}
} catch (e) {
// 忽略错误
}
});
});
});
// 延迟启动 observer等待页面初始化完成
const startObserver = () => {
if (document.body) {
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
console.log('[SCToolbox] ✅ MutationObserver started');
} else {
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>',