diff --git a/lib/common/rust/rust_webview_controller.dart b/lib/common/rust/rust_webview_controller.dart index 43fd6fb..b8ec5af 100644 --- a/lib/common/rust/rust_webview_controller.dart +++ b/lib/common/rust/rust_webview_controller.dart @@ -4,8 +4,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/services.dart'; -import 'package:starcitizen_doctor/common/rust/api/webview_api.dart' - as rust_webview; +import 'package:starcitizen_doctor/common/rust/api/webview_api.dart' as rust_webview; import 'package:starcitizen_doctor/common/utils/log.dart'; typedef OnWebMessageCallback = void Function(String message); @@ -77,9 +76,7 @@ class RustWebViewController { Future _loadScripts() async { try { _localizationScript = await rootBundle.loadString('assets/web_script.js'); - _requestInterceptorScript = await rootBundle.loadString( - 'assets/request_interceptor.js', - ); + _requestInterceptorScript = await rootBundle.loadString('assets/request_interceptor.js'); } catch (e) { dPrint("Failed to load scripts: $e"); } @@ -289,16 +286,12 @@ class RustWebViewController { } /// 添加导航完成回调(用于在页面加载完成后注入脚本) - void addOnNavigationCompletedCallback( - OnNavigationCompletedCallback callback, - ) { + void addOnNavigationCompletedCallback(OnNavigationCompletedCallback callback) { _navigationCompletedCallbacks.add(callback); } /// 移除导航完成回调 - void removeOnNavigationCompletedCallback( - OnNavigationCompletedCallback callback, - ) { + void removeOnNavigationCompletedCallback(OnNavigationCompletedCallback callback) { _navigationCompletedCallbacks.remove(callback); } @@ -327,9 +320,7 @@ class RustWebViewController { /// 更新翻译词典 void updateReplaceWords(List> words, bool enableCapture) { final jsonWords = json.encode(words); - executeScript( - "WebLocalizationUpdateReplaceWords($jsonWords, $enableCapture)", - ); + executeScript("WebLocalizationUpdateReplaceWords($jsonWords, $enableCapture)"); } /// 执行 RSI 登录脚本 diff --git a/lib/ui/webview/webview.dart b/lib/ui/webview/webview.dart index 44c3fd4..b14805e 100644 --- a/lib/ui/webview/webview.dart +++ b/lib/ui/webview/webview.dart @@ -71,8 +71,11 @@ class WebViewModel { height: loginMode ? 720 : 1080, userDataFolder: "$applicationSupportDir/webview_data", enableDevtools: kDebugMode, + userAgent: + "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); @@ -104,12 +107,6 @@ class WebViewModel { dPrint("Navigation completed: $newUrl"); url = newUrl; - // 在页面加载时注入拦截器 - if (requestInterceptorScript.isNotEmpty) { - dPrint("Injecting request interceptor for: $url"); - webview.executeScript(requestInterceptorScript); - } - if (localizationResource.isEmpty) return; dPrint("webview Navigating url === $url"); @@ -285,10 +282,19 @@ 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/webview/webview_impl.rs b/rust/src/webview/webview_impl.rs index d5c0c38..bd9ccc6 100644 --- a/rust/src/webview/webview_impl.rs +++ b/rust/src/webview/webview_impl.rs @@ -2,24 +2,24 @@ // 使用 wry + tao 实现跨平台 WebView use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use crossbeam_channel::{bounded, Receiver, Sender}; -use parking_lot::RwLock; use once_cell::sync::Lazy; +use parking_lot::RwLock; use tao::dpi::{LogicalPosition, LogicalSize}; use tao::event::{Event, WindowEvent}; use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder}; use tao::platform::run_return::EventLoopExtRunReturn; use tao::window::{Icon, Window, WindowBuilder}; -use wry::{PageLoadEvent, WebView, WebViewBuilder}; +use wry::{NewWindowResponse, PageLoadEvent, WebView, WebViewBuilder}; #[cfg(target_os = "windows")] use tao::platform::windows::EventLoopBuilderExtWindows; use crate::api::webview_api::{ - WebViewConfiguration, WebViewNavigationState, WebViewEvent, WebViewCommand, + WebViewCommand, WebViewConfiguration, WebViewEvent, WebViewNavigationState, }; // Embed the app icon at compile time @@ -28,6 +28,71 @@ static APP_ICON_DATA: &[u8] = include_bytes!("../../../windows/runner/resources/ // Embed the init script at compile time static INIT_SCRIPT: &str = include_str!("webview_init_script.js"); +// Loading page HTML for about:blank initialization +static LOADING_PAGE_HTML: &str = r#" + + + + + Loading... + + + +
+
+
Loading
+
+ +"#; + // ============ Types ============ pub type WebViewId = String; @@ -46,30 +111,32 @@ impl NavigationHistory { current_index: -1, } } - + fn push(&mut self, url: &str) { if url == "about:blank" { return; } - - if self.current_index >= 0 && (self.current_index as usize) < self.urls.len().saturating_sub(1) { + + if self.current_index >= 0 + && (self.current_index as usize) < self.urls.len().saturating_sub(1) + { self.urls.truncate((self.current_index + 1) as usize); } - + if self.urls.last().map(|s| s.as_str()) != Some(url) { self.urls.push(url.to_string()); } self.current_index = (self.urls.len() as i32) - 1; } - + fn can_go_back(&self) -> bool { self.current_index > 0 } - + fn can_go_forward(&self) -> bool { self.current_index >= 0 && (self.current_index as usize) < self.urls.len().saturating_sub(1) } - + fn go_back(&mut self) -> bool { if self.can_go_back() { self.current_index -= 1; @@ -78,7 +145,7 @@ impl NavigationHistory { false } } - + fn go_forward(&mut self) -> bool { if self.can_go_forward() { self.current_index += 1; @@ -87,7 +154,7 @@ impl NavigationHistory { false } } - + #[allow(dead_code)] fn current_url(&self) -> Option<&str> { if self.current_index >= 0 && (self.current_index as usize) < self.urls.len() { @@ -131,7 +198,14 @@ pub fn create_webview(config: WebViewConfiguration) -> Result { let is_closed_clone = Arc::clone(&is_closed); std::thread::spawn(move || { - run_webview_loop(id_clone, config, cmd_rx, event_tx, state_clone, is_closed_clone); + run_webview_loop( + id_clone, + config, + cmd_rx, + event_tx, + state_clone, + is_closed_clone, + ); }); // Wait a moment for the window to be created @@ -179,10 +253,10 @@ fn run_webview_loop( let mut event_loop: EventLoop = EventLoopBuilder::with_user_event() .with_any_thread(true) .build(); - + #[cfg(not(target_os = "windows"))] let mut event_loop: EventLoop = EventLoopBuilder::with_user_event().build(); - + let proxy = event_loop.create_proxy(); // Load window icon from embedded ICO file @@ -193,13 +267,14 @@ fn run_webview_loop( .with_title(&config.title) .with_inner_size(LogicalSize::new(config.width, config.height)) .with_visible(true); - + // Set window icon if loaded successfully if let Some(icon) = window_icon { window_builder = window_builder.with_window_icon(Some(icon)); } - - let window = window_builder.build(&event_loop) + + let window = window_builder + .build(&event_loop) .expect("Failed to create window"); let window = Arc::new(window); @@ -207,9 +282,14 @@ fn run_webview_loop( // Navigation history for tracking back/forward state let nav_history = Arc::new(RwLock::new(NavigationHistory::new())); + // Counter for CF bypass: number of page loads to skip init script 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 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); // Create web context with custom data directory if provided let mut web_context = config @@ -222,8 +302,7 @@ fn run_webview_loop( .with_url("about:blank") .with_devtools(config.enable_devtools) .with_transparent(config.transparent) - .with_background_color((26, 26, 26, 255)) // Dark background #1a1a1a - .with_initialization_script(INIT_SCRIPT); + .with_background_color((10, 29, 41, 255)); // Set user agent if provided if let Some(ref user_agent) = config.user_agent { @@ -232,11 +311,11 @@ fn run_webview_loop( // Store proxy for IPC commands let proxy_ipc = proxy.clone(); - + let webview = builder .with_ipc_handler(move |message| { let msg = message.body().to_string(); - + // Try to parse as navigation command from JS if let Ok(parsed) = serde_json::from_str::(&msg) { if let Some(msg_type) = parsed.get("type").and_then(|v| v.as_str()) { @@ -244,19 +323,22 @@ fn run_webview_loop( "nav_back" => { let can_back = nav_history_ipc.read().can_go_back(); if can_back { - let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::GoBack)); + let _ = proxy_ipc + .send_event(UserEvent::Command(WebViewCommand::GoBack)); } return; } "nav_forward" => { let can_forward = nav_history_ipc.read().can_go_forward(); if can_forward { - let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::GoForward)); + let _ = proxy_ipc + .send_event(UserEvent::Command(WebViewCommand::GoForward)); } return; } "nav_reload" => { - let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::Reload)); + let _ = + proxy_ipc.send_event(UserEvent::Command(WebViewCommand::Reload)); return; } "get_nav_state" => { @@ -269,14 +351,22 @@ fn run_webview_loop( "url": state_guard.url }); let script = format!("window._sctUpdateNavState({})", state_json); - let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::ExecuteScript(script))); + let _ = proxy_ipc.send_event(UserEvent::Command( + WebViewCommand::ExecuteScript(script), + )); + return; + } + "cf_challenge_detected" => { + // CF challenge was detected, skip next page load cycle (2 events: Started + Finished) + cf_skip_count_ipc.store(2, Ordering::SeqCst); + let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::Reload)); return; } _ => {} } } } - + // Forward other messages to Dart let _ = event_tx_clone.send(WebViewEvent::WebMessage { message: msg }); }) @@ -286,10 +376,25 @@ fn run_webview_loop( let nav_history = Arc::clone(&nav_history); let proxy = proxy.clone(); move |uri| { + // Block navigation to about:blank (except initial load) + // This prevents users from going back to the blank initial page if uri == "about:blank" { + // Check if we already have navigation history + // If yes, this is a back navigation to about:blank - block it + let has_history = !nav_history.read().urls.is_empty(); + if has_history { + // Navigate forward instead to stay on valid page + let _ = proxy.send_event(UserEvent::Command(WebViewCommand::ExecuteScript( + "history.forward()".to_string() + ))); + return false; + } return true; } - + + // Note: cf_skip_count is NOT reset here + // The counter naturally decrements to 0 after skipping, allowing normal injection + let can_back; let can_forward; { @@ -302,7 +407,7 @@ fn run_webview_loop( state_guard.can_go_back = can_back; state_guard.can_go_forward = can_forward; } - + // 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!({ @@ -311,9 +416,12 @@ fn run_webview_loop( "is_loading": true, "url": uri }); - let script = format!("window._sctUpdateNavState && window._sctUpdateNavState({})", state_json); + let script = format!( + "window._sctUpdateNavState && window._sctUpdateNavState({})", + state_json + ); let _ = proxy.send_event(UserEvent::Command(WebViewCommand::ExecuteScript(script))); - + let _ = event_tx.send(WebViewEvent::NavigationStarted { url: uri }); true } @@ -322,12 +430,25 @@ fn run_webview_loop( let event_tx = event_tx.clone(); let state = Arc::clone(&state); let nav_history = Arc::clone(&nav_history); + let cf_skip_count = Arc::clone(&cf_skip_count); let proxy = proxy.clone(); move |event, url| { if url == "about:blank" { return; } - + + // Check if we should skip injection/CF check (CF bypass active) + // Counter is set to 2 when CF detected: decrements once in Started, once in Finished + let should_skip = { + let count = cf_skip_count.load(Ordering::SeqCst); + if count > 0 { + cf_skip_count.fetch_sub(1, Ordering::SeqCst); + true + } else { + false + } + }; + match event { PageLoadEvent::Started => { let can_back; @@ -342,14 +463,32 @@ fn run_webview_loop( state_guard.can_go_back = can_back; state_guard.can_go_forward = can_forward; } - let state_json = serde_json::json!({ - "can_go_back": can_back, - "can_go_forward": can_forward, - "is_loading": true, - "url": url - }); - let script = format!("window._sctUpdateNavState && window._sctUpdateNavState({})", state_json); - let _ = proxy.send_event(UserEvent::Command(WebViewCommand::ExecuteScript(script))); + + // Inject init script at page start for fast navbar display + // Skip injection if CF bypass counter is active + if !should_skip { + let state_json = serde_json::json!({ + "can_go_back": can_back, + "can_go_forward": can_forward, + "is_loading": true, + "url": url + }); + let init_script_with_state = format!( + r#"(function() {{ + if (!window._sctInitialized) {{ + {init_script} + }} + if (window._sctUpdateNavState) {{ + window._sctUpdateNavState({state_json}); + }} + }})();"#, + init_script = INIT_SCRIPT, + state_json = state_json + ); + let _ = proxy.send_event(UserEvent::Command( + WebViewCommand::ExecuteScript(init_script_with_state), + )); + } } PageLoadEvent::Finished => { let can_back; @@ -367,23 +506,110 @@ fn run_webview_loop( state_guard.can_go_back = can_back; state_guard.can_go_forward = can_forward; } - let _ = event_tx.send(WebViewEvent::NavigationCompleted { url: url.clone() }); - + let _ = + event_tx.send(WebViewEvent::NavigationCompleted { url: url.clone() }); + + // If CF bypass is active for this URL, skip CF check + // Let the user complete the CF challenge without interference + if should_skip { + // Just update nav state if navbar somehow exists + let state_json = serde_json::json!({ + "can_go_back": can_back, + "can_go_forward": can_forward, + "is_loading": false, + "url": url + }); + let script = format!( + "window._sctUpdateNavState && window._sctUpdateNavState({})", + state_json + ); + let _ = proxy.send_event(UserEvent::Command( + WebViewCommand::ExecuteScript(script), + )); + return; + } + + // Check for CF challenge and handle accordingly let state_json = serde_json::json!({ "can_go_back": can_back, "can_go_forward": can_forward, "is_loading": false, "url": url }); - let script = format!("window._sctUpdateNavState && window._sctUpdateNavState({})", state_json); - let _ = proxy.send_event(UserEvent::Command(WebViewCommand::ExecuteScript(script))); + + // Script to check CF challenge and notify Rust via IPC if detected + let cf_check_script = format!( + r#"(function() {{ + // Check if this is a Cloudflare challenge page + var isCfChallenge = (function() {{ + // Check page title + if (document.title && ( + document.title.indexOf('Just a moment') !== -1 || + document.title.indexOf('Checking your browser') !== -1 || + document.title.indexOf('Please wait') !== -1 || + document.title.indexOf('Attention Required') !== -1 + )) return true; + // Check for CF challenge elements + if (document.getElementById('challenge-running') || + document.getElementById('challenge-form') || + document.getElementById('cf-spinner-please-wait') || + document.getElementById('cf-stage') || + document.querySelector('.cf-browser-verification') || + document.querySelector('[data-ray]')) return true; + // Check for CF challenge script + var scripts = document.getElementsByTagName('script'); + for (var i = 0; i < scripts.length; i++) {{ + if (scripts[i].src && scripts[i].src.indexOf('/cdn-cgi/challenge-platform/') !== -1) return true; + }} + return false; + }})(); + + if (isCfChallenge) {{ + console.log('[SCToolbox] CF challenge detected, will reload without navbar'); + // Remove navbar if it was injected + var navbar = document.getElementById('sct-navbar'); + if (navbar) navbar.remove(); + window._sctInitialized = false; + // Notify Rust to skip init script on next load and reload + if (window.ipc && typeof window.ipc.postMessage === 'function') {{ + window.ipc.postMessage(JSON.stringify({{ type: 'cf_challenge_detected' }})); + }} + }} else {{ + // Update nav state if navbar exists + if (window._sctUpdateNavState) {{ + window._sctUpdateNavState({state_json}); + }} + }} + }})();"#, + state_json = state_json + ); + let _ = proxy.send_event(UserEvent::Command( + WebViewCommand::ExecuteScript(cf_check_script), + )); } } } }) + .with_new_window_req_handler({ + let proxy = proxy.clone(); + move |uri, _features| { + // Intercept new window requests (e.g., target="_blank" links) + // Navigate in current window instead since we don't support multiple windows + let _ = proxy.send_event(UserEvent::Command(WebViewCommand::Navigate(uri))); + // Return Deny to prevent opening a new window + NewWindowResponse::Deny + } + }) .build(&window) .expect("Failed to create webview"); + // Show loading page while waiting for Navigate command + let loading_script = format!( + r#"document.open(); document.write({}); document.close();"#, + serde_json::to_string(LOADING_PAGE_HTML).unwrap_or_default() + ); + let _ = webview.evaluate_script(&loading_script); + let webview = Arc::new(webview); let webview_cmd = Arc::clone(&webview); @@ -412,7 +638,16 @@ fn run_webview_loop( } Event::UserEvent(user_event) => match user_event { UserEvent::Command(cmd) => { - handle_command(&webview_cmd, &window, cmd, &state, &nav_history, &event_tx, &is_closed, control_flow); + handle_command( + &webview_cmd, + &window, + cmd, + &state, + &nav_history, + &event_tx, + &is_closed, + control_flow, + ); } UserEvent::Quit => { is_closed.store(true, Ordering::SeqCst); @@ -422,7 +657,7 @@ fn run_webview_loop( _ => {} } }); - + // Explicitly drop in correct order drop(webview_cmd); drop(web_context); @@ -495,9 +730,9 @@ fn handle_command( /// Load the application icon from embedded ICO data fn load_app_icon() -> Option { - use std::io::Cursor; use image::ImageReader; - + use std::io::Cursor; + let cursor = Cursor::new(APP_ICON_DATA); let reader = match ImageReader::new(cursor).with_guessed_format() { Ok(r) => r, @@ -506,7 +741,7 @@ fn load_app_icon() -> Option { return None; } }; - + let img = match reader.decode() { Ok(img) => img, Err(e) => { @@ -514,11 +749,11 @@ fn load_app_icon() -> Option { return None; } }; - + let rgba = img.to_rgba8(); let (width, height) = rgba.dimensions(); let raw_data = rgba.into_raw(); - + match Icon::from_rgba(raw_data, width, height) { Ok(icon) => Some(icon), Err(e) => { diff --git a/rust/src/webview/webview_init_script.js b/rust/src/webview/webview_init_script.js index cf67089..891c345 100644 --- a/rust/src/webview/webview_init_script.js +++ b/rust/src/webview/webview_init_script.js @@ -1,11 +1,11 @@ // SCToolbox WebView initialization script // Uses IPC (window.ipc.postMessage) to communicate with Rust backend -(function() { +(function () { 'use strict'; - + if (window._sctInitialized) return; window._sctInitialized = true; - + // ========== IPC Communication ========== // Send message to Rust backend function sendToRust(type, payload) { @@ -13,14 +13,14 @@ window.ipc.postMessage(JSON.stringify({ type, payload })); } } - + // ========== 导航栏 UI ========== const icons = { back: '', forward: '', reload: '' }; - + // Global state from Rust window._sctNavState = { canGoBack: false, @@ -28,7 +28,7 @@ isLoading: true, url: window.location.href }; - + function createNavBar() { if (window.location.href === 'about:blank') return; if (document.getElementById('sct-navbar')) return; @@ -36,7 +36,7 @@ setTimeout(createNavBar, 50); return; } - + const nav = document.createElement('div'); nav.id = 'sct-navbar'; nav.innerHTML = ` @@ -137,7 +137,7 @@ `; document.body.insertBefore(nav, document.body.firstChild); - + // Navigation buttons - send commands to Rust document.getElementById('sct-back').onclick = () => { sendToRust('nav_back', {}); @@ -148,14 +148,14 @@ document.getElementById('sct-reload').onclick = () => { sendToRust('nav_reload', {}); }; - + // Apply initial state from Rust updateNavBarFromState(); - + // Request initial state from Rust sendToRust('get_nav_state', {}); } - + // Update navbar UI based on state from Rust function updateNavBarFromState() { const state = window._sctNavState; @@ -164,7 +164,7 @@ const urlEl = document.getElementById('sct-navbar-url'); const spinner = document.getElementById('sct-spinner'); const faviconSlot = document.getElementById('sct-favicon-slot'); - + if (backBtn) { backBtn.disabled = !state.canGoBack; } @@ -174,7 +174,7 @@ if (urlEl && state.url) { urlEl.value = state.url; } - + // Show spinner when loading, show favicon when complete if (state.isLoading) { if (spinner) { @@ -195,23 +195,23 @@ showFaviconIfAvailable(); } } - + // Extract and show favicon from page function showFaviconIfAvailable() { const faviconSlot = document.getElementById('sct-favicon-slot'); const faviconImg = document.getElementById('sct-favicon'); - + if (!faviconSlot || !faviconImg) return; - + // Try to find favicon from page let faviconUrl = null; - + // 1. Look for link[rel="icon"] or link[rel="shortcut icon"] const linkIcon = document.querySelector('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'); if (linkIcon && linkIcon.href) { faviconUrl = linkIcon.href; } - + // 2. Look for og:image in meta tags (fallback) if (!faviconUrl) { const ogImage = document.querySelector('meta[property="og:image"]'); @@ -219,7 +219,7 @@ faviconUrl = ogImage.content; } } - + // 3. Try default favicon.ico if (!faviconUrl) { try { @@ -231,7 +231,7 @@ // Ignore } } - + // Display favicon if found if (faviconUrl) { faviconImg.src = faviconUrl; @@ -245,10 +245,10 @@ faviconSlot.style.display = 'none'; } } - + // ========== Rust -> JS Message Handler ========== // Rust will call this function to update navigation state - window._sctUpdateNavState = function(state) { + window._sctUpdateNavState = function (state) { if (state) { window._sctNavState = { canGoBack: !!state.can_go_back, @@ -259,7 +259,7 @@ updateNavBarFromState(); } }; - + // 在 DOM 准备好时创建导航栏 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', createNavBar);