mirror of
https://github.com/StarCitizenToolBox/app.git
synced 2026-01-13 19:50:28 +00:00
feat: WebView optimization
This commit is contained in:
parent
eb42c7101d
commit
dd762d53b2
@ -27,9 +27,6 @@ class RustWebViewController {
|
|||||||
/// 本地化脚本(从 assets 加载)
|
/// 本地化脚本(从 assets 加载)
|
||||||
String _localizationScript = "";
|
String _localizationScript = "";
|
||||||
|
|
||||||
/// 请求拦截器脚本
|
|
||||||
String _requestInterceptorScript = "";
|
|
||||||
|
|
||||||
/// 当前 URL
|
/// 当前 URL
|
||||||
String _currentUrl = "";
|
String _currentUrl = "";
|
||||||
String get currentUrl => _currentUrl;
|
String get currentUrl => _currentUrl;
|
||||||
@ -76,7 +73,6 @@ class RustWebViewController {
|
|||||||
Future<void> _loadScripts() async {
|
Future<void> _loadScripts() async {
|
||||||
try {
|
try {
|
||||||
_localizationScript = await rootBundle.loadString('assets/web_script.js');
|
_localizationScript = await rootBundle.loadString('assets/web_script.js');
|
||||||
_requestInterceptorScript = await rootBundle.loadString('assets/request_interceptor.js');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dPrint("Failed to load scripts: $e");
|
dPrint("Failed to load scripts: $e");
|
||||||
}
|
}
|
||||||
@ -120,10 +116,6 @@ class RustWebViewController {
|
|||||||
case rust_webview.WebViewEvent_NavigationCompleted(:final url):
|
case rust_webview.WebViewEvent_NavigationCompleted(:final url):
|
||||||
dPrint("Navigation completed: $url");
|
dPrint("Navigation completed: $url");
|
||||||
_currentUrl = url;
|
_currentUrl = url;
|
||||||
// 注入请求拦截器
|
|
||||||
if (_requestInterceptorScript.isNotEmpty) {
|
|
||||||
executeScript(_requestInterceptorScript);
|
|
||||||
}
|
|
||||||
// 导航完成回调(用于注入脚本)
|
// 导航完成回调(用于注入脚本)
|
||||||
for (final callback in _navigationCompletedCallbacks) {
|
for (final callback in _navigationCompletedCallbacks) {
|
||||||
callback(url);
|
callback(url);
|
||||||
|
|||||||
@ -36,7 +36,6 @@ class WebViewModel {
|
|||||||
final localizationResource = <String, dynamic>{};
|
final localizationResource = <String, dynamic>{};
|
||||||
|
|
||||||
var localizationScript = "";
|
var localizationScript = "";
|
||||||
var requestInterceptorScript = "";
|
|
||||||
|
|
||||||
bool enableCapture = false;
|
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",
|
"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);
|
webview.addOnNavigationCompletedCallback(_onNavigationCompleted);
|
||||||
|
|
||||||
@ -208,7 +206,6 @@ class WebViewModel {
|
|||||||
|
|
||||||
Future<void> initLocalization(AppWebLocalizationVersionsData v) async {
|
Future<void> initLocalization(AppWebLocalizationVersionsData v) async {
|
||||||
localizationScript = await rootBundle.loadString('assets/web_script.js');
|
localizationScript = await rootBundle.loadString('assets/web_script.js');
|
||||||
requestInterceptorScript = await rootBundle.loadString('assets/request_interceptor.js');
|
|
||||||
|
|
||||||
/// https://github.com/CxJuice/Uex_Chinese_Translate
|
/// https://github.com/CxJuice/Uex_Chinese_Translate
|
||||||
// get versions
|
// get versions
|
||||||
@ -282,19 +279,10 @@ class WebViewModel {
|
|||||||
|
|
||||||
FutureOr<void> dispose() {
|
FutureOr<void> dispose() {
|
||||||
webview.removeOnNavigationCompletedCallback(_onNavigationCompleted);
|
webview.removeOnNavigationCompletedCallback(_onNavigationCompleted);
|
||||||
webview.removeOnNavigationCallback(_onNavigation);
|
|
||||||
if (loginMode && !_loginModeSuccess) {
|
if (loginMode && !_loginModeSuccess) {
|
||||||
loginCallback?.call(null, false);
|
loginCallback?.call(null, false);
|
||||||
}
|
}
|
||||||
_isClosed = true;
|
_isClosed = true;
|
||||||
webview.dispose();
|
webview.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onNavigation(String url) {
|
|
||||||
// 在页面加载时注入拦截器
|
|
||||||
if (requestInterceptorScript.isNotEmpty) {
|
|
||||||
dPrint("Injecting request interceptor for: $url");
|
|
||||||
webview.executeScript(requestInterceptorScript);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use std::sync::atomic::Ordering;
|
|||||||
use flutter_rust_bridge::frb;
|
use flutter_rust_bridge::frb;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::webview::{WEBVIEW_INSTANCES, send_command};
|
use crate::webview::{send_command, WEBVIEW_INSTANCES};
|
||||||
|
|
||||||
// ============ Data Structures ============
|
// ============ Data Structures ============
|
||||||
|
|
||||||
@ -84,6 +84,7 @@ pub enum WebViewCommand {
|
|||||||
Close,
|
Close,
|
||||||
SetWindowSize(u32, u32),
|
SetWindowSize(u32, u32),
|
||||||
SetWindowPosition(i32, i32),
|
SetWindowPosition(i32, i32),
|
||||||
|
SetWindowTitle(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Public API ============
|
// ============ Public API ============
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
/// ------- Request Interceptor Script --------------
|
/// ------- Request Interceptor Script --------------
|
||||||
/// 轻量级网络请求拦截器,不破坏网页正常功能
|
/// 轻量级网络请求拦截器,不破坏网页正常功能
|
||||||
(function() {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
if (window._sctRequestInterceptorInstalled) {
|
if (window._sctRequestInterceptorInstalled) {
|
||||||
console.log('[SCToolbox] Request interceptor already installed');
|
console.log('[SCToolbox] Request interceptor already installed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window._sctRequestInterceptorInstalled = true;
|
window._sctRequestInterceptorInstalled = true;
|
||||||
|
|
||||||
// 被屏蔽的域名和路径
|
// 被屏蔽的域名和路径
|
||||||
const blockedPatterns = [
|
const blockedPatterns = [
|
||||||
'google-analytics.com',
|
'google-analytics.com',
|
||||||
@ -28,24 +28,24 @@
|
|||||||
'facebook.net',
|
'facebook.net',
|
||||||
'gstatic.com/firebasejs'
|
'gstatic.com/firebasejs'
|
||||||
];
|
];
|
||||||
|
|
||||||
// 判断 URL 是否应该被屏蔽
|
// 判断 URL 是否应该被屏蔽
|
||||||
const shouldBlock = (url) => {
|
const shouldBlock = (url) => {
|
||||||
if (!url || typeof url !== 'string') return false;
|
if (!url || typeof url !== 'string') return false;
|
||||||
const urlLower = url.toLowerCase();
|
const urlLower = url.toLowerCase();
|
||||||
return blockedPatterns.some(pattern => urlLower.includes(pattern.toLowerCase()));
|
return blockedPatterns.some(pattern => urlLower.includes(pattern.toLowerCase()));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 记录被拦截的请求
|
// 记录被拦截的请求
|
||||||
const logBlocked = (type, url) => {
|
const logBlocked = (type, url) => {
|
||||||
console.log(`[SCToolbox] ❌ Blocked ${type}:`, url);
|
console.log(`[SCToolbox] ❌ Blocked ${type}:`, url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
|
|
||||||
// ============ 1. 拦截 Fetch API ============
|
// ============ 1. 拦截 Fetch API ============
|
||||||
const originalFetch = window.fetch;
|
const originalFetch = window.fetch;
|
||||||
window.fetch = function(...args) {
|
window.fetch = function (...args) {
|
||||||
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
||||||
if (shouldBlock(url)) {
|
if (shouldBlock(url)) {
|
||||||
logBlocked('fetch', url);
|
logBlocked('fetch', url);
|
||||||
@ -53,13 +53,13 @@
|
|||||||
}
|
}
|
||||||
return originalFetch.apply(this, args);
|
return originalFetch.apply(this, args);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ 2. 拦截 XMLHttpRequest ============
|
// ============ 2. 拦截 XMLHttpRequest ============
|
||||||
const OriginalXHR = window.XMLHttpRequest;
|
const OriginalXHR = window.XMLHttpRequest;
|
||||||
const originalXHROpen = OriginalXHR.prototype.open;
|
const originalXHROpen = OriginalXHR.prototype.open;
|
||||||
const originalXHRSend = OriginalXHR.prototype.send;
|
const originalXHRSend = OriginalXHR.prototype.send;
|
||||||
|
|
||||||
OriginalXHR.prototype.open = function(method, url, ...rest) {
|
OriginalXHR.prototype.open = function (method, url, ...rest) {
|
||||||
this._url = url;
|
this._url = url;
|
||||||
if (shouldBlock(url)) {
|
if (shouldBlock(url)) {
|
||||||
logBlocked('XHR', url);
|
logBlocked('XHR', url);
|
||||||
@ -67,8 +67,8 @@
|
|||||||
}
|
}
|
||||||
return originalXHROpen.apply(this, [method, url, ...rest]);
|
return originalXHROpen.apply(this, [method, url, ...rest]);
|
||||||
};
|
};
|
||||||
|
|
||||||
OriginalXHR.prototype.send = function(...args) {
|
OriginalXHR.prototype.send = function (...args) {
|
||||||
if (this._blocked) {
|
if (this._blocked) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const errorEvent = new Event('error');
|
const errorEvent = new Event('error');
|
||||||
@ -78,13 +78,13 @@
|
|||||||
}
|
}
|
||||||
return originalXHRSend.apply(this, args);
|
return originalXHRSend.apply(this, args);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ 3. 拦截 Image 元素的 src 属性 ============
|
// ============ 3. 拦截 Image 元素的 src 属性 ============
|
||||||
const imgSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
|
const imgSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
|
||||||
if (imgSrcDescriptor && imgSrcDescriptor.set) {
|
if (imgSrcDescriptor && imgSrcDescriptor.set) {
|
||||||
Object.defineProperty(HTMLImageElement.prototype, 'src', {
|
Object.defineProperty(HTMLImageElement.prototype, 'src', {
|
||||||
get: imgSrcDescriptor.get,
|
get: imgSrcDescriptor.get,
|
||||||
set: function(value) {
|
set: function (value) {
|
||||||
if (shouldBlock(value)) {
|
if (shouldBlock(value)) {
|
||||||
logBlocked('IMG.src', value);
|
logBlocked('IMG.src', value);
|
||||||
// 设置为透明 GIF,避免请求
|
// 设置为透明 GIF,避免请求
|
||||||
@ -98,13 +98,13 @@
|
|||||||
enumerable: true
|
enumerable: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 3.5. 拦截 Script 元素的 src 属性 ============
|
// ============ 3.5. 拦截 Script 元素的 src 属性 ============
|
||||||
const scriptSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src');
|
const scriptSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src');
|
||||||
if (scriptSrcDescriptor && scriptSrcDescriptor.set) {
|
if (scriptSrcDescriptor && scriptSrcDescriptor.set) {
|
||||||
Object.defineProperty(HTMLScriptElement.prototype, 'src', {
|
Object.defineProperty(HTMLScriptElement.prototype, 'src', {
|
||||||
get: scriptSrcDescriptor.get,
|
get: scriptSrcDescriptor.get,
|
||||||
set: function(value) {
|
set: function (value) {
|
||||||
if (shouldBlock(value)) {
|
if (shouldBlock(value)) {
|
||||||
logBlocked('SCRIPT.src', value);
|
logBlocked('SCRIPT.src', value);
|
||||||
// 阻止加载,不设置 src
|
// 阻止加载,不设置 src
|
||||||
@ -117,10 +117,10 @@
|
|||||||
enumerable: true
|
enumerable: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 4. 拦截 setAttribute(用于 img.setAttribute('src', ...))============
|
// ============ 4. 拦截 setAttribute(用于 img.setAttribute('src', ...))============
|
||||||
const originalSetAttribute = Element.prototype.setAttribute;
|
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)) {
|
if (name.toLowerCase() === 'src' && this.tagName === 'IMG' && shouldBlock(value)) {
|
||||||
logBlocked('IMG setAttribute', value);
|
logBlocked('IMG setAttribute', value);
|
||||||
originalSetAttribute.call(this, name, TRANSPARENT_GIF);
|
originalSetAttribute.call(this, name, TRANSPARENT_GIF);
|
||||||
@ -133,11 +133,11 @@
|
|||||||
}
|
}
|
||||||
return originalSetAttribute.call(this, name, value);
|
return originalSetAttribute.call(this, name, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ 5. 拦截 navigator.sendBeacon ============
|
// ============ 5. 拦截 navigator.sendBeacon ============
|
||||||
if (navigator.sendBeacon) {
|
if (navigator.sendBeacon) {
|
||||||
const originalSendBeacon = navigator.sendBeacon.bind(navigator);
|
const originalSendBeacon = navigator.sendBeacon.bind(navigator);
|
||||||
navigator.sendBeacon = function(url, data) {
|
navigator.sendBeacon = function (url, data) {
|
||||||
if (shouldBlock(url)) {
|
if (shouldBlock(url)) {
|
||||||
logBlocked('sendBeacon', url);
|
logBlocked('sendBeacon', url);
|
||||||
return true; // 假装成功
|
return true; // 假装成功
|
||||||
@ -145,13 +145,13 @@
|
|||||||
return originalSendBeacon(url, data);
|
return originalSendBeacon(url, data);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 6. 使用 MutationObserver 监听动态添加的元素 ============
|
// ============ 6. 使用 MutationObserver 监听动态添加的元素 ============
|
||||||
const observer = new MutationObserver((mutations) => {
|
const observer = new MutationObserver((mutations) => {
|
||||||
mutations.forEach((mutation) => {
|
mutations.forEach((mutation) => {
|
||||||
mutation.addedNodes.forEach((node) => {
|
mutation.addedNodes.forEach((node) => {
|
||||||
if (node.nodeType !== 1) return; // 只处理元素节点
|
if (node.nodeType !== 1) return; // 只处理元素节点
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查 IMG 元素
|
// 检查 IMG 元素
|
||||||
if (node.tagName === 'IMG') {
|
if (node.tagName === 'IMG') {
|
||||||
@ -180,7 +180,7 @@
|
|||||||
node.style.cssText += 'display:none !important;';
|
node.style.cssText += 'display:none !important;';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 递归检查子元素
|
// 递归检查子元素
|
||||||
if (node.querySelectorAll) {
|
if (node.querySelectorAll) {
|
||||||
node.querySelectorAll('img').forEach(img => {
|
node.querySelectorAll('img').forEach(img => {
|
||||||
@ -191,7 +191,7 @@
|
|||||||
img.style.cssText += 'display:none !important;width:0;height:0;';
|
img.style.cssText += 'display:none !important;width:0;height:0;';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
node.querySelectorAll('script[src]').forEach(script => {
|
node.querySelectorAll('script[src]').forEach(script => {
|
||||||
const src = script.getAttribute('src');
|
const src = script.getAttribute('src');
|
||||||
if (src && shouldBlock(src)) {
|
if (src && shouldBlock(src)) {
|
||||||
@ -207,7 +207,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 延迟启动 observer,等待页面初始化完成
|
// 延迟启动 observer,等待页面初始化完成
|
||||||
const startObserver = () => {
|
const startObserver = () => {
|
||||||
if (document.body) {
|
if (document.body) {
|
||||||
@ -220,13 +220,13 @@
|
|||||||
setTimeout(startObserver, 50);
|
setTimeout(startObserver, 50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', startObserver);
|
document.addEventListener('DOMContentLoaded', startObserver);
|
||||||
} else {
|
} else {
|
||||||
startObserver();
|
startObserver();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[SCToolbox] ✅ Request interceptor installed');
|
console.log('[SCToolbox] ✅ Request interceptor installed');
|
||||||
console.log('[SCToolbox] 🛡️ Blocking', blockedPatterns.length, 'patterns');
|
console.log('[SCToolbox] 🛡️ Blocking', blockedPatterns.length, 'patterns');
|
||||||
})();
|
})();
|
||||||
@ -22,12 +22,26 @@ use crate::api::webview_api::{
|
|||||||
WebViewCommand, WebViewConfiguration, WebViewEvent, WebViewNavigationState,
|
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
|
// Embed the app icon at compile time
|
||||||
static APP_ICON_DATA: &[u8] = include_bytes!("../../../windows/runner/resources/app_icon.ico");
|
static APP_ICON_DATA: &[u8] = include_bytes!("../../../windows/runner/resources/app_icon.ico");
|
||||||
|
|
||||||
// Embed the init script at compile time
|
// Embed the init script at compile time
|
||||||
static INIT_SCRIPT: &str = include_str!("webview_init_script.js");
|
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
|
// Loading page HTML for about:blank initialization
|
||||||
static LOADING_PAGE_HTML: &str = r#"<!DOCTYPE html>
|
static LOADING_PAGE_HTML: &str = r#"<!DOCTYPE html>
|
||||||
<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
|
// 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));
|
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 event_tx_clone = event_tx.clone();
|
||||||
let nav_history_ipc = Arc::clone(&nav_history);
|
let nav_history_ipc = Arc::clone(&nav_history);
|
||||||
let state_ipc = Arc::clone(&state);
|
let state_ipc = Arc::clone(&state);
|
||||||
let cf_skip_count_ipc = Arc::clone(&cf_skip_count);
|
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
|
// Create web context with custom data directory if provided
|
||||||
let mut web_context = config
|
let mut web_context = config
|
||||||
@ -302,7 +320,8 @@ fn run_webview_loop(
|
|||||||
.with_url("about:blank")
|
.with_url("about:blank")
|
||||||
.with_devtools(config.enable_devtools)
|
.with_devtools(config.enable_devtools)
|
||||||
.with_transparent(config.transparent)
|
.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
|
// Set user agent if provided
|
||||||
if let Some(ref user_agent) = config.user_agent {
|
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));
|
let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::Reload));
|
||||||
return;
|
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 state = Arc::clone(&state);
|
||||||
let nav_history = Arc::clone(&nav_history);
|
let nav_history = Arc::clone(&nav_history);
|
||||||
let proxy = proxy.clone();
|
let proxy = proxy.clone();
|
||||||
|
let original_title = Arc::clone(&original_title);
|
||||||
move |uri| {
|
move |uri| {
|
||||||
// Block navigation to about:blank (except initial load)
|
// Block navigation to about:blank (except initial load)
|
||||||
// This prevents users from going back to the blank initial page
|
// 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;
|
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
|
// Send loading state to JS immediately when navigation starts
|
||||||
// This makes the spinner appear on the current page before navigating away
|
// This makes the spinner appear on the current page before navigating away
|
||||||
let state_json = serde_json::json!({
|
let state_json = serde_json::json!({
|
||||||
@ -436,8 +497,15 @@ fn run_webview_loop(
|
|||||||
if url == "about:blank" {
|
if url == "about:blank" {
|
||||||
if matches!(event, PageLoadEvent::Finished) {
|
if matches!(event, PageLoadEvent::Finished) {
|
||||||
// if url is about:blank, show loading
|
// 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!(
|
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()
|
serde_json::to_string(LOADING_PAGE_HTML).unwrap_or_default()
|
||||||
);
|
);
|
||||||
let _ = proxy.send_event(UserEvent::Command(
|
let _ = proxy.send_event(UserEvent::Command(
|
||||||
@ -728,6 +796,9 @@ fn handle_command(
|
|||||||
WebViewCommand::SetWindowPosition(x, y) => {
|
WebViewCommand::SetWindowPosition(x, y) => {
|
||||||
window.set_outer_position(LogicalPosition::new(x, y));
|
window.set_outer_position(LogicalPosition::new(x, y));
|
||||||
}
|
}
|
||||||
|
WebViewCommand::SetWindowTitle(title) => {
|
||||||
|
window.set_title(&title);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 ==========
|
// ========== 导航栏 UI ==========
|
||||||
const icons = {
|
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>',
|
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>',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user