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/rust/src/api/webview_api.rs b/rust/src/api/webview_api.rs index 1070277..5a9dd58 100644 --- a/rust/src/api/webview_api.rs +++ b/rust/src/api/webview_api.rs @@ -111,6 +111,82 @@ pub enum WebViewCommand { type WebViewId = String; +/// Navigation history manager to track back/forward capability +#[derive(Debug, Clone, Default)] +struct NavigationHistory { + /// List of URLs in history (excluding about:blank) + urls: Vec, + /// Current position in history (0-based index) + current_index: i32, +} + +impl NavigationHistory { + fn new() -> Self { + Self { + urls: Vec::new(), + current_index: -1, + } + } + + /// Push a new URL to history (when navigating to a new page) + fn push(&mut self, url: &str) { + // Skip about:blank + if url == "about:blank" { + return; + } + + // If we're not at the end of history, truncate forward history + 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); + } + + // Don't add duplicate consecutive URLs + 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; + } + + /// Check if we can go back (not at first real page) + fn can_go_back(&self) -> bool { + self.current_index > 0 + } + + /// Check if we can go forward + fn can_go_forward(&self) -> bool { + self.current_index >= 0 && (self.current_index as usize) < self.urls.len().saturating_sub(1) + } + + /// Go back in history, returns true if successful + fn go_back(&mut self) -> bool { + if self.can_go_back() { + self.current_index -= 1; + true + } else { + false + } + } + + /// Go forward in history, returns true if successful + fn go_forward(&mut self) -> bool { + if self.can_go_forward() { + self.current_index += 1; + true + } else { + false + } + } + + /// Get current URL + fn current_url(&self) -> Option<&str> { + if self.current_index >= 0 && (self.current_index as usize) < self.urls.len() { + Some(&self.urls[self.current_index as usize]) + } else { + None + } + } +} + struct WebViewInstance { command_sender: Sender, event_receiver: Receiver, @@ -333,7 +409,12 @@ fn run_webview_loop( let window = Arc::new(window); + // Navigation history for tracking back/forward state + let nav_history = Arc::new(RwLock::new(NavigationHistory::new())); + let event_tx_clone = event_tx.clone(); + let nav_history_ipc = Arc::clone(&nav_history); + let state_ipc = Arc::clone(&state); // Create web context with custom data directory if provided let mut web_context = config @@ -354,20 +435,76 @@ fn run_webview_loop( builder = builder.with_user_agent(user_agent); } + // Store proxy for IPC commands + let proxy_ipc = proxy.clone(); + let webview = builder .with_ipc_handler(move |message| { let msg = message.body().to_string(); - // Forward all messages to Dart + + // 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()) { + match msg_type { + "nav_back" => { + // Check if we can go back (avoid about:blank) + let can_back = nav_history_ipc.read().can_go_back(); + if can_back { + 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)); + } + return; + } + "nav_reload" => { + let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::Reload)); + return; + } + "get_nav_state" => { + // Send current state to JS + let state_guard = state_ipc.read(); + let history = nav_history_ipc.read(); + let state_json = serde_json::json!({ + "can_go_back": history.can_go_back(), + "can_go_forward": history.can_go_forward(), + "is_loading": state_guard.is_loading, + "url": state_guard.url + }); + let script = format!("window._sctUpdateNavState({})", state_json); + let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::ExecuteScript(script))); + return; + } + _ => {} + } + } + } + + // Forward other messages to Dart let _ = event_tx_clone.send(WebViewEvent::WebMessage { message: msg }); }) .with_navigation_handler({ let event_tx = event_tx.clone(); let state = Arc::clone(&state); + let nav_history = Arc::clone(&nav_history); move |uri| { + // Skip about:blank for navigation events + if uri == "about:blank" { + return true; + } + { let mut state_guard = state.write(); state_guard.url = uri.clone(); state_guard.is_loading = true; + // Update can_go_back/can_go_forward from history + let history = nav_history.read(); + state_guard.can_go_back = history.can_go_back(); + state_guard.can_go_forward = history.can_go_forward(); } let _ = event_tx.send(WebViewEvent::NavigationStarted { url: uri }); true // Allow navigation @@ -376,20 +513,66 @@ fn run_webview_loop( .with_on_page_load_handler({ let event_tx = event_tx.clone(); let state = Arc::clone(&state); + let nav_history = Arc::clone(&nav_history); + let proxy = proxy.clone(); move |event, url| { + // Skip about:blank + if url == "about:blank" { + return; + } + match event { PageLoadEvent::Started => { - let mut state_guard = state.write(); - state_guard.url = url.clone(); - state_guard.is_loading = true; + let can_back; + let can_forward; + { + let mut state_guard = state.write(); + state_guard.url = url.clone(); + state_guard.is_loading = true; + let history = nav_history.read(); + can_back = history.can_go_back(); + can_forward = history.can_go_forward(); + state_guard.can_go_back = can_back; + state_guard.can_go_forward = can_forward; + } + // Send loading state to JS + 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))); } PageLoadEvent::Finished => { + let can_back; + let can_forward; + { + // Add to history when page finishes loading + let mut history = nav_history.write(); + history.push(&url); + can_back = history.can_go_back(); + can_forward = history.can_go_forward(); + } { let mut state_guard = state.write(); state_guard.url = url.clone(); state_guard.is_loading = false; + state_guard.can_go_back = can_back; + state_guard.can_go_forward = can_forward; } - let _ = event_tx.send(WebViewEvent::NavigationCompleted { url }); + let _ = event_tx.send(WebViewEvent::NavigationCompleted { url: url.clone() }); + + // Send completed state to JS + 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))); } } } @@ -425,7 +608,7 @@ fn run_webview_loop( } Event::UserEvent(user_event) => match user_event { UserEvent::Command(cmd) => { - handle_command(&webview_cmd, &window, cmd, &state, &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); @@ -447,6 +630,7 @@ fn handle_command( window: &Arc, command: WebViewCommand, state: &Arc>, + nav_history: &Arc>, event_tx: &Sender, is_closed: &Arc, control_flow: &mut ControlFlow, @@ -462,10 +646,24 @@ fn handle_command( let _ = event_tx.send(WebViewEvent::NavigationStarted { url }); } WebViewCommand::GoBack => { - let _ = webview.evaluate_script("history.back()"); + // Update history index before navigation + let can_go = { + let mut history = nav_history.write(); + history.go_back() + }; + if can_go { + let _ = webview.evaluate_script("history.back()"); + } } WebViewCommand::GoForward => { - let _ = webview.evaluate_script("history.forward()"); + // Update history index before navigation + let can_go = { + let mut history = nav_history.write(); + history.go_forward() + }; + if can_go { + let _ = webview.evaluate_script("history.forward()"); + } } WebViewCommand::Reload => { let _ = webview.evaluate_script("location.reload()"); diff --git a/rust/src/assets/webview_init_script.js b/rust/src/assets/webview_init_script.js index 2ccb910..cf67089 100644 --- a/rust/src/assets/webview_init_script.js +++ b/rust/src/assets/webview_init_script.js @@ -1,10 +1,19 @@ // SCToolbox WebView initialization script +// Uses IPC (window.ipc.postMessage) to communicate with Rust backend (function() { 'use strict'; if (window._sctInitialized) return; window._sctInitialized = true; + // ========== IPC Communication ========== + // Send message to Rust backend + function sendToRust(type, payload) { + if (window.ipc && typeof window.ipc.postMessage === 'function') { + window.ipc.postMessage(JSON.stringify({ type, payload })); + } + } + // ========== 导航栏 UI ========== const icons = { back: '', @@ -12,6 +21,14 @@ reload: '' }; + // Global state from Rust + window._sctNavState = { + canGoBack: false, + canGoForward: false, + isLoading: true, + url: window.location.href + }; + function createNavBar() { if (window.location.href === 'about:blank') return; if (document.getElementById('sct-navbar')) return; @@ -57,8 +74,8 @@ transition: all 0.15s ease; padding: 0; } - #sct-navbar button:hover { background: rgba(10, 49, 66, 0.9); color: #fff; } - #sct-navbar button:active { background: rgba(10, 49, 66, 1); transform: scale(0.95); } + #sct-navbar button:hover:not(:disabled) { background: rgba(10, 49, 66, 0.9); color: #fff; } + #sct-navbar button:active:not(:disabled) { background: rgba(10, 49, 66, 1); transform: scale(0.95); } #sct-navbar button:disabled { opacity: 0.35; cursor: not-allowed; } #sct-navbar button svg { display: block; } #sct-navbar-url { @@ -112,8 +129,8 @@ #sct-spinner { -webkit-animation: none; animation: none; } } - - + +
Page icon
@@ -121,51 +138,45 @@ `; document.body.insertBefore(nav, document.body.firstChild); + // Navigation buttons - send commands to Rust document.getElementById('sct-back').onclick = () => { - // Check if going back would result in about:blank - // If so, skip this entry and go back further - const beforeBackUrl = window.location.href; - history.back(); - - // After a short delay, if we landed on about:blank, go back again - setTimeout(() => { - if (window.location.href === 'about:blank' && beforeBackUrl !== 'about:blank') { - history.back(); - } - }, 100); + sendToRust('nav_back', {}); + }; + document.getElementById('sct-forward').onclick = () => { + sendToRust('nav_forward', {}); + }; + document.getElementById('sct-reload').onclick = () => { + sendToRust('nav_reload', {}); }; - document.getElementById('sct-forward').onclick = () => history.forward(); - document.getElementById('sct-reload').onclick = () => location.reload(); - // Update back button state and URL display on navigation - function updateNavBarState() { - const backBtn = document.getElementById('sct-back'); - const urlEl = document.getElementById('sct-navbar-url'); - const currentUrl = window.location.href; - - if (backBtn) { - // Disable back button if at start of history or at about:blank - backBtn.disabled = window.history.length <= 1 || currentUrl === 'about:blank'; - } - - if (urlEl) { - urlEl.value = currentUrl; - } - } + // Apply initial state from Rust + updateNavBarFromState(); - // Listen to popstate and hashchange to update nav bar - window.addEventListener('popstate', updateNavBarState); - window.addEventListener('hashchange', updateNavBarState); - - // Initial state - updateNavBarState(); - - // Spinner and favicon show/hide helpers + // Request initial state from Rust + sendToRust('get_nav_state', {}); + } + + // Update navbar UI based on state from Rust + function updateNavBarFromState() { + const state = window._sctNavState; + const backBtn = document.getElementById('sct-back'); + const forwardBtn = document.getElementById('sct-forward'); + const urlEl = document.getElementById('sct-navbar-url'); const spinner = document.getElementById('sct-spinner'); const faviconSlot = document.getElementById('sct-favicon-slot'); - const faviconImg = document.getElementById('sct-favicon'); - - function showSpinner() { + + if (backBtn) { + backBtn.disabled = !state.canGoBack; + } + if (forwardBtn) { + forwardBtn.disabled = !state.canGoForward; + } + if (urlEl && state.url) { + urlEl.value = state.url; + } + + // Show spinner when loading, show favicon when complete + if (state.isLoading) { if (spinner) { spinner.style.display = 'block'; spinner.setAttribute('aria-hidden', 'false'); @@ -174,93 +185,85 @@ if (faviconSlot) { faviconSlot.style.display = 'none'; } - } - - function hideSpin() { + } else { if (spinner) { spinner.style.display = 'none'; spinner.setAttribute('aria-hidden', 'true'); spinner.setAttribute('aria-busy', 'false'); } + // Show favicon when page is loaded + showFaviconIfAvailable(); } - - // Extract favicon from page and show it when ready - function showFaviconWhenReady() { - hideSpin(); - - // 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"]'); - if (ogImage && ogImage.content) { - faviconUrl = ogImage.content; - } - } - - // 3. Check page's existing favicon from document.head or body elements - if (!faviconUrl) { - const existingFavicon = document.querySelector('img[src*="favicon"], img[src*="icon"]'); - if (existingFavicon && existingFavicon.src) { - faviconUrl = existingFavicon.src; - } - } - - // Display favicon if found, otherwise hide slot - if (faviconUrl) { - if (faviconImg) { - faviconImg.src = faviconUrl; - faviconImg.onerror = () => { - if (faviconSlot) faviconSlot.style.display = 'none'; - }; - } - if (faviconSlot) { - faviconSlot.style.display = 'flex'; - } - } else if (faviconSlot) { - faviconSlot.style.display = 'none'; - } - } - - // Monitor document readyState to show favicon when page is ready - document.addEventListener('readystatechange', function () { - if (document.readyState === 'interactive' || document.readyState === 'complete') { - setTimeout(showFaviconWhenReady, 150); - } - }); - - // Also trigger favicon display on load event - window.addEventListener('load', function () { - setTimeout(showFaviconWhenReady, 150); - }); } + // 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"]'); + if (ogImage && ogImage.content) { + faviconUrl = ogImage.content; + } + } + + // 3. Try default favicon.ico + if (!faviconUrl) { + try { + const origin = window.location.origin; + if (origin && origin !== 'null') { + faviconUrl = origin + '/favicon.ico'; + } + } catch (e) { + // Ignore + } + } + + // Display favicon if found + if (faviconUrl) { + faviconImg.src = faviconUrl; + faviconImg.onerror = () => { + faviconSlot.style.display = 'none'; + }; + faviconImg.onload = () => { + faviconSlot.style.display = 'flex'; + }; + } else { + faviconSlot.style.display = 'none'; + } + } + + // ========== Rust -> JS Message Handler ========== + // Rust will call this function to update navigation state + window._sctUpdateNavState = function(state) { + if (state) { + window._sctNavState = { + canGoBack: !!state.can_go_back, + canGoForward: !!state.can_go_forward, + isLoading: !!state.is_loading, + url: state.url || window.location.href + }; + updateNavBarFromState(); + } + }; + // 在 DOM 准备好时创建导航栏 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', createNavBar); } else { createNavBar(); } - - // URL 变化时:进入加载状态,显示 spinner - window.addEventListener('popstate', () => { - // Show spinner when navigating via popstate (URL change) - const spinner = document.getElementById('sct-spinner'); - const faviconSlot = document.getElementById('sct-favicon-slot'); - if (spinner) { - spinner.style.display = 'block'; - spinner.setAttribute('aria-hidden', 'false'); - spinner.setAttribute('aria-busy', 'true'); - } - if (faviconSlot) { - faviconSlot.style.display = 'none'; - } - }); })();