feat: web nav update

This commit is contained in:
xkeyC 2025-12-05 09:59:05 +08:00
parent 6f0c760ab4
commit b11603d68c
3 changed files with 332 additions and 140 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

@ -111,6 +111,82 @@ pub enum WebViewCommand {
type WebViewId = String; 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<String>,
/// 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 { struct WebViewInstance {
command_sender: Sender<WebViewCommand>, command_sender: Sender<WebViewCommand>,
event_receiver: Receiver<WebViewEvent>, event_receiver: Receiver<WebViewEvent>,
@ -333,7 +409,12 @@ fn run_webview_loop(
let window = Arc::new(window); 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 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 // Create web context with custom data directory if provided
let mut web_context = config let mut web_context = config
@ -354,20 +435,76 @@ fn run_webview_loop(
builder = builder.with_user_agent(user_agent); builder = builder.with_user_agent(user_agent);
} }
// Store proxy for IPC commands
let proxy_ipc = proxy.clone();
let webview = builder let webview = builder
.with_ipc_handler(move |message| { .with_ipc_handler(move |message| {
let msg = message.body().to_string(); 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::<serde_json::Value>(&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 }); let _ = event_tx_clone.send(WebViewEvent::WebMessage { message: msg });
}) })
.with_navigation_handler({ .with_navigation_handler({
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);
move |uri| { move |uri| {
// Skip about:blank for navigation events
if uri == "about:blank" {
return true;
}
{ {
let mut state_guard = state.write(); let mut state_guard = state.write();
state_guard.url = uri.clone(); state_guard.url = uri.clone();
state_guard.is_loading = true; 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 }); let _ = event_tx.send(WebViewEvent::NavigationStarted { url: uri });
true // Allow navigation true // Allow navigation
@ -376,20 +513,66 @@ fn run_webview_loop(
.with_on_page_load_handler({ .with_on_page_load_handler({
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 proxy = proxy.clone();
move |event, url| { move |event, url| {
// Skip about:blank
if url == "about:blank" {
return;
}
match event { match event {
PageLoadEvent::Started => { PageLoadEvent::Started => {
let mut state_guard = state.write(); let can_back;
state_guard.url = url.clone(); let can_forward;
state_guard.is_loading = true; {
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 => { 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(); let mut state_guard = state.write();
state_guard.url = url.clone(); state_guard.url = url.clone();
state_guard.is_loading = false; 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 { Event::UserEvent(user_event) => match user_event {
UserEvent::Command(cmd) => { 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 => { UserEvent::Quit => {
is_closed.store(true, Ordering::SeqCst); is_closed.store(true, Ordering::SeqCst);
@ -447,6 +630,7 @@ fn handle_command(
window: &Arc<Window>, window: &Arc<Window>,
command: WebViewCommand, command: WebViewCommand,
state: &Arc<RwLock<WebViewNavigationState>>, state: &Arc<RwLock<WebViewNavigationState>>,
nav_history: &Arc<RwLock<NavigationHistory>>,
event_tx: &Sender<WebViewEvent>, event_tx: &Sender<WebViewEvent>,
is_closed: &Arc<AtomicBool>, is_closed: &Arc<AtomicBool>,
control_flow: &mut ControlFlow, control_flow: &mut ControlFlow,
@ -462,10 +646,24 @@ fn handle_command(
let _ = event_tx.send(WebViewEvent::NavigationStarted { url }); let _ = event_tx.send(WebViewEvent::NavigationStarted { url });
} }
WebViewCommand::GoBack => { 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 => { 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 => { WebViewCommand::Reload => {
let _ = webview.evaluate_script("location.reload()"); let _ = webview.evaluate_script("location.reload()");

View File

@ -1,10 +1,19 @@
// SCToolbox WebView initialization script // SCToolbox WebView initialization script
// Uses IPC (window.ipc.postMessage) to communicate with Rust backend
(function() { (function() {
'use strict'; 'use strict';
if (window._sctInitialized) return; if (window._sctInitialized) return;
window._sctInitialized = true; 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 ========== // ========== 导航栏 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>',
@ -12,6 +21,14 @@
reload: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.5 8C13.5 11.0376 11.0376 13.5 8 13.5C4.96243 13.5 2.5 11.0376 2.5 8C2.5 4.96243 4.96243 2.5 8 2.5C10.1012 2.5 11.9254 3.67022 12.8169 5.4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10.5 5.5H13V3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>' reload: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.5 8C13.5 11.0376 11.0376 13.5 8 13.5C4.96243 13.5 2.5 11.0376 2.5 8C2.5 4.96243 4.96243 2.5 8 2.5C10.1012 2.5 11.9254 3.67022 12.8169 5.4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10.5 5.5H13V3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
}; };
// Global state from Rust
window._sctNavState = {
canGoBack: false,
canGoForward: false,
isLoading: true,
url: window.location.href
};
function createNavBar() { function createNavBar() {
if (window.location.href === 'about:blank') return; if (window.location.href === 'about:blank') return;
if (document.getElementById('sct-navbar')) return; if (document.getElementById('sct-navbar')) return;
@ -57,8 +74,8 @@
transition: all 0.15s ease; transition: all 0.15s ease;
padding: 0; padding: 0;
} }
#sct-navbar button:hover { background: rgba(10, 49, 66, 0.9); color: #fff; } #sct-navbar button:hover:not(:disabled) { 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: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:disabled { opacity: 0.35; cursor: not-allowed; }
#sct-navbar button svg { display: block; } #sct-navbar button svg { display: block; }
#sct-navbar-url { #sct-navbar-url {
@ -112,8 +129,8 @@
#sct-spinner { -webkit-animation: none; animation: none; } #sct-spinner { -webkit-animation: none; animation: none; }
} }
</style> </style>
<button id="sct-back" title="Back">${icons.back}</button> <button id="sct-back" title="Back" disabled>${icons.back}</button>
<button id="sct-forward" title="Forward">${icons.forward}</button> <button id="sct-forward" title="Forward" disabled>${icons.forward}</button>
<button id="sct-reload" title="Reload">${icons.reload}</button> <button id="sct-reload" title="Reload">${icons.reload}</button>
<div id="sct-spinner" role="progressbar" aria-hidden="false" aria-valuetext="Loading" title="Loading"></div> <div id="sct-spinner" role="progressbar" aria-hidden="false" aria-valuetext="Loading" title="Loading"></div>
<div id="sct-favicon-slot"><img id="sct-favicon" src="" alt="Page icon" /></div> <div id="sct-favicon-slot"><img id="sct-favicon" src="" alt="Page icon" /></div>
@ -121,51 +138,45 @@
`; `;
document.body.insertBefore(nav, document.body.firstChild); document.body.insertBefore(nav, document.body.firstChild);
// Navigation buttons - send commands to Rust
document.getElementById('sct-back').onclick = () => { document.getElementById('sct-back').onclick = () => {
// Check if going back would result in about:blank sendToRust('nav_back', {});
// If so, skip this entry and go back further };
const beforeBackUrl = window.location.href; document.getElementById('sct-forward').onclick = () => {
history.back(); sendToRust('nav_forward', {});
};
// After a short delay, if we landed on about:blank, go back again document.getElementById('sct-reload').onclick = () => {
setTimeout(() => { sendToRust('nav_reload', {});
if (window.location.href === 'about:blank' && beforeBackUrl !== 'about:blank') {
history.back();
}
}, 100);
}; };
document.getElementById('sct-forward').onclick = () => history.forward();
document.getElementById('sct-reload').onclick = () => location.reload();
// Update back button state and URL display on navigation // Apply initial state from Rust
function updateNavBarState() { updateNavBarFromState();
const backBtn = document.getElementById('sct-back');
const urlEl = document.getElementById('sct-navbar-url');
const currentUrl = window.location.href;
if (backBtn) { // Request initial state from Rust
// Disable back button if at start of history or at about:blank sendToRust('get_nav_state', {});
backBtn.disabled = window.history.length <= 1 || currentUrl === 'about:blank'; }
}
if (urlEl) { // Update navbar UI based on state from Rust
urlEl.value = currentUrl; function updateNavBarFromState() {
} const state = window._sctNavState;
} const backBtn = document.getElementById('sct-back');
const forwardBtn = document.getElementById('sct-forward');
// Listen to popstate and hashchange to update nav bar const urlEl = document.getElementById('sct-navbar-url');
window.addEventListener('popstate', updateNavBarState);
window.addEventListener('hashchange', updateNavBarState);
// Initial state
updateNavBarState();
// Spinner and favicon show/hide helpers
const spinner = document.getElementById('sct-spinner'); const spinner = document.getElementById('sct-spinner');
const faviconSlot = document.getElementById('sct-favicon-slot'); 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) { if (spinner) {
spinner.style.display = 'block'; spinner.style.display = 'block';
spinner.setAttribute('aria-hidden', 'false'); spinner.setAttribute('aria-hidden', 'false');
@ -174,93 +185,85 @@
if (faviconSlot) { if (faviconSlot) {
faviconSlot.style.display = 'none'; faviconSlot.style.display = 'none';
} }
} } else {
function hideSpin() {
if (spinner) { if (spinner) {
spinner.style.display = 'none'; spinner.style.display = 'none';
spinner.setAttribute('aria-hidden', 'true'); spinner.setAttribute('aria-hidden', 'true');
spinner.setAttribute('aria-busy', 'false'); 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 准备好时创建导航栏 // 在 DOM 准备好时创建导航栏
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createNavBar); document.addEventListener('DOMContentLoaded', createNavBar);
} else { } else {
createNavBar(); 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';
}
});
})(); })();