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: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<void> _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<Map<String, String>> words, bool enableCapture) {
final jsonWords = json.encode(words);
executeScript(
"WebLocalizationUpdateReplaceWords($jsonWords, $enableCapture)",
);
executeScript("WebLocalizationUpdateReplaceWords($jsonWords, $enableCapture)");
}
/// RSI

View File

@ -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<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 {
command_sender: Sender<WebViewCommand>,
event_receiver: Receiver<WebViewEvent>,
@ -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::<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 });
})
.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<Window>,
command: WebViewCommand,
state: &Arc<RwLock<WebViewNavigationState>>,
nav_history: &Arc<RwLock<NavigationHistory>>,
event_tx: &Sender<WebViewEvent>,
is_closed: &Arc<AtomicBool>,
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()");

View File

@ -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: '<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>'
};
// 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; }
}
</style>
<button id="sct-back" title="Back">${icons.back}</button>
<button id="sct-forward" title="Forward">${icons.forward}</button>
<button id="sct-back" title="Back" disabled>${icons.back}</button>
<button id="sct-forward" title="Forward" disabled>${icons.forward}</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-favicon-slot"><img id="sct-favicon" src="" alt="Page icon" /></div>
@ -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';
}
});
})();