fix: webview

This commit is contained in:
xkeyC 2025-12-13 15:56:52 +08:00
parent f68b7a4380
commit a132c85b8c
4 changed files with 327 additions and 95 deletions

View File

@ -4,8 +4,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:starcitizen_doctor/common/rust/api/webview_api.dart' import 'package:starcitizen_doctor/common/rust/api/webview_api.dart' as rust_webview;
as rust_webview;
import 'package:starcitizen_doctor/common/utils/log.dart'; import 'package:starcitizen_doctor/common/utils/log.dart';
typedef OnWebMessageCallback = void Function(String message); typedef OnWebMessageCallback = void Function(String message);
@ -77,9 +76,7 @@ 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( _requestInterceptorScript = await rootBundle.loadString('assets/request_interceptor.js');
'assets/request_interceptor.js',
);
} catch (e) { } catch (e) {
dPrint("Failed to load scripts: $e"); dPrint("Failed to load scripts: $e");
} }
@ -289,16 +286,12 @@ class RustWebViewController {
} }
/// ///
void addOnNavigationCompletedCallback( void addOnNavigationCompletedCallback(OnNavigationCompletedCallback callback) {
OnNavigationCompletedCallback callback,
) {
_navigationCompletedCallbacks.add(callback); _navigationCompletedCallbacks.add(callback);
} }
/// ///
void removeOnNavigationCompletedCallback( void removeOnNavigationCompletedCallback(OnNavigationCompletedCallback callback) {
OnNavigationCompletedCallback callback,
) {
_navigationCompletedCallbacks.remove(callback); _navigationCompletedCallbacks.remove(callback);
} }
@ -327,9 +320,7 @@ class RustWebViewController {
/// ///
void updateReplaceWords(List<Map<String, String>> words, bool enableCapture) { void updateReplaceWords(List<Map<String, String>> words, bool enableCapture) {
final jsonWords = json.encode(words); final jsonWords = json.encode(words);
executeScript( executeScript("WebLocalizationUpdateReplaceWords($jsonWords, $enableCapture)");
"WebLocalizationUpdateReplaceWords($jsonWords, $enableCapture)",
);
} }
/// RSI /// RSI

View File

@ -71,8 +71,11 @@ class WebViewModel {
height: loginMode ? 720 : 1080, height: loginMode ? 720 : 1080,
userDataFolder: "$applicationSupportDir/webview_data", userDataFolder: "$applicationSupportDir/webview_data",
enableDevtools: kDebugMode, 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); webview.addOnNavigationCompletedCallback(_onNavigationCompleted);
@ -104,12 +107,6 @@ class WebViewModel {
dPrint("Navigation completed: $newUrl"); dPrint("Navigation completed: $newUrl");
url = newUrl; url = newUrl;
//
if (requestInterceptorScript.isNotEmpty) {
dPrint("Injecting request interceptor for: $url");
webview.executeScript(requestInterceptorScript);
}
if (localizationResource.isEmpty) return; if (localizationResource.isEmpty) return;
dPrint("webview Navigating url === $url"); dPrint("webview Navigating url === $url");
@ -285,10 +282,19 @@ 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);
}
}
} }

View File

@ -2,24 +2,24 @@
// 使用 wry + tao 实现跨平台 WebView // 使用 wry + tao 实现跨平台 WebView
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use crossbeam_channel::{bounded, Receiver, Sender}; use crossbeam_channel::{bounded, Receiver, Sender};
use parking_lot::RwLock;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use parking_lot::RwLock;
use tao::dpi::{LogicalPosition, LogicalSize}; use tao::dpi::{LogicalPosition, LogicalSize};
use tao::event::{Event, WindowEvent}; use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder}; use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder};
use tao::platform::run_return::EventLoopExtRunReturn; use tao::platform::run_return::EventLoopExtRunReturn;
use tao::window::{Icon, Window, WindowBuilder}; use tao::window::{Icon, Window, WindowBuilder};
use wry::{PageLoadEvent, WebView, WebViewBuilder}; use wry::{NewWindowResponse, PageLoadEvent, WebView, WebViewBuilder};
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use tao::platform::windows::EventLoopBuilderExtWindows; use tao::platform::windows::EventLoopBuilderExtWindows;
use crate::api::webview_api::{ use crate::api::webview_api::{
WebViewConfiguration, WebViewNavigationState, WebViewEvent, WebViewCommand, WebViewCommand, WebViewConfiguration, WebViewEvent, WebViewNavigationState,
}; };
// Embed the app icon at compile time // 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 // 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");
// Loading page HTML for about:blank initialization
static LOADING_PAGE_HTML: &str = r#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Loading...</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: #0A1D29;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Segoe UI', system-ui, sans-serif;
overflow: hidden;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.16);
border-top-color: rgba(255, 255, 255, 0.92);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
color: rgba(255, 255, 255, 0.6);
font-size: 0.875rem;
letter-spacing: 0.05em;
}
.loading-dots::after {
content: '';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%, 20% { content: ''; }
40% { content: '.'; }
60% { content: '..'; }
80%, 100% { content: '...'; }
}
</style>
</head>
<body>
<div class="loading-container">
<div class="spinner"></div>
<div class="loading-text">Loading<span class="loading-dots"></span></div>
</div>
</body>
</html>"#;
// ============ Types ============ // ============ Types ============
pub type WebViewId = String; pub type WebViewId = String;
@ -52,7 +117,9 @@ impl NavigationHistory {
return; 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); self.urls.truncate((self.current_index + 1) as usize);
} }
@ -131,7 +198,14 @@ pub fn create_webview(config: WebViewConfiguration) -> Result<String, String> {
let is_closed_clone = Arc::clone(&is_closed); let is_closed_clone = Arc::clone(&is_closed);
std::thread::spawn(move || { 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 // Wait a moment for the window to be created
@ -199,7 +273,8 @@ fn run_webview_loop(
window_builder = window_builder.with_window_icon(Some(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"); .expect("Failed to create window");
let window = Arc::new(window); let window = Arc::new(window);
@ -207,9 +282,14 @@ fn run_webview_loop(
// Navigation history for tracking back/forward state // Navigation history for tracking back/forward state
let nav_history = Arc::new(RwLock::new(NavigationHistory::new())); 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 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);
// 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
@ -222,8 +302,7 @@ 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((26, 26, 26, 255)) // Dark background #1a1a1a .with_background_color((10, 29, 41, 255));
.with_initialization_script(INIT_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 {
@ -244,19 +323,22 @@ fn run_webview_loop(
"nav_back" => { "nav_back" => {
let can_back = nav_history_ipc.read().can_go_back(); let can_back = nav_history_ipc.read().can_go_back();
if can_back { if can_back {
let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::GoBack)); let _ = proxy_ipc
.send_event(UserEvent::Command(WebViewCommand::GoBack));
} }
return; return;
} }
"nav_forward" => { "nav_forward" => {
let can_forward = nav_history_ipc.read().can_go_forward(); let can_forward = nav_history_ipc.read().can_go_forward();
if can_forward { if can_forward {
let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::GoForward)); let _ = proxy_ipc
.send_event(UserEvent::Command(WebViewCommand::GoForward));
} }
return; return;
} }
"nav_reload" => { "nav_reload" => {
let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::Reload)); let _ =
proxy_ipc.send_event(UserEvent::Command(WebViewCommand::Reload));
return; return;
} }
"get_nav_state" => { "get_nav_state" => {
@ -269,7 +351,15 @@ fn run_webview_loop(
"url": state_guard.url "url": state_guard.url
}); });
let script = format!("window._sctUpdateNavState({})", state_json); 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; return;
} }
_ => {} _ => {}
@ -286,10 +376,25 @@ fn run_webview_loop(
let nav_history = Arc::clone(&nav_history); let nav_history = Arc::clone(&nav_history);
let proxy = proxy.clone(); let proxy = proxy.clone();
move |uri| { 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" { 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; 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_back;
let can_forward; let can_forward;
{ {
@ -311,7 +416,10 @@ fn run_webview_loop(
"is_loading": true, "is_loading": true,
"url": uri "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 _ = proxy.send_event(UserEvent::Command(WebViewCommand::ExecuteScript(script)));
let _ = event_tx.send(WebViewEvent::NavigationStarted { url: uri }); let _ = event_tx.send(WebViewEvent::NavigationStarted { url: uri });
@ -322,12 +430,25 @@ fn run_webview_loop(
let event_tx = event_tx.clone(); let event_tx = event_tx.clone();
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 cf_skip_count = Arc::clone(&cf_skip_count);
let proxy = proxy.clone(); let proxy = proxy.clone();
move |event, url| { move |event, url| {
if url == "about:blank" { if url == "about:blank" {
return; 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 { match event {
PageLoadEvent::Started => { PageLoadEvent::Started => {
let can_back; let can_back;
@ -342,14 +463,32 @@ fn run_webview_loop(
state_guard.can_go_back = can_back; state_guard.can_go_back = can_back;
state_guard.can_go_forward = can_forward; state_guard.can_go_forward = can_forward;
} }
let state_json = serde_json::json!({
"can_go_back": can_back, // Inject init script at page start for fast navbar display
"can_go_forward": can_forward, // Skip injection if CF bypass counter is active
"is_loading": true, if !should_skip {
"url": url let state_json = serde_json::json!({
}); "can_go_back": can_back,
let script = format!("window._sctUpdateNavState && window._sctUpdateNavState({})", state_json); "can_go_forward": can_forward,
let _ = proxy.send_event(UserEvent::Command(WebViewCommand::ExecuteScript(script))); "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 => { PageLoadEvent::Finished => {
let can_back; let can_back;
@ -367,23 +506,110 @@ fn run_webview_loop(
state_guard.can_go_back = can_back; state_guard.can_go_back = can_back;
state_guard.can_go_forward = can_forward; 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!({ let state_json = serde_json::json!({
"can_go_back": can_back, "can_go_back": can_back,
"can_go_forward": can_forward, "can_go_forward": can_forward,
"is_loading": false, "is_loading": false,
"url": url "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) .build(&window)
.expect("Failed to create webview"); .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 = Arc::new(webview);
let webview_cmd = Arc::clone(&webview); let webview_cmd = Arc::clone(&webview);
@ -412,7 +638,16 @@ fn run_webview_loop(
} }
Event::UserEvent(user_event) => match user_event { Event::UserEvent(user_event) => match user_event {
UserEvent::Command(cmd) => { 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 => { UserEvent::Quit => {
is_closed.store(true, Ordering::SeqCst); is_closed.store(true, Ordering::SeqCst);
@ -495,8 +730,8 @@ fn handle_command(
/// Load the application icon from embedded ICO data /// Load the application icon from embedded ICO data
fn load_app_icon() -> Option<Icon> { fn load_app_icon() -> Option<Icon> {
use std::io::Cursor;
use image::ImageReader; use image::ImageReader;
use std::io::Cursor;
let cursor = Cursor::new(APP_ICON_DATA); let cursor = Cursor::new(APP_ICON_DATA);
let reader = match ImageReader::new(cursor).with_guessed_format() { let reader = match ImageReader::new(cursor).with_guessed_format() {

View File

@ -1,6 +1,6 @@
// SCToolbox WebView initialization script // SCToolbox WebView initialization script
// Uses IPC (window.ipc.postMessage) to communicate with Rust backend // Uses IPC (window.ipc.postMessage) to communicate with Rust backend
(function() { (function () {
'use strict'; 'use strict';
if (window._sctInitialized) return; if (window._sctInitialized) return;
@ -248,7 +248,7 @@
// ========== Rust -> JS Message Handler ========== // ========== Rust -> JS Message Handler ==========
// Rust will call this function to update navigation state // Rust will call this function to update navigation state
window._sctUpdateNavState = function(state) { window._sctUpdateNavState = function (state) {
if (state) { if (state) {
window._sctNavState = { window._sctNavState = {
canGoBack: !!state.can_go_back, canGoBack: !!state.can_go_back,