feat: Replace desktop_webview_window with tao&wry , from tauri

This commit is contained in:
xkeyC
2025-12-05 01:29:48 +08:00
parent 125fedbc84
commit 6f0c760ab4
31 changed files with 6894 additions and 539 deletions

1765
rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,22 +15,29 @@ crate-type = ["cdylib", "staticlib"]
[dependencies]
flutter_rust_bridge = "=2.11.1"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "process"] }
futures = { version = "0.3", default-features = false, features = ["executor"] }
url = "2.5"
once_cell = "1.21"
reqwest = { version = "0.12", features = ["rustls-tls-webpki-roots", "cookies", "gzip", "json", "stream"] }
hickory-resolver = { version = "0.25" }
anyhow = "1.0"
scopeguard = "1.2"
notify-rust = "4"
tokio = { version = "1.48.0", features = ["rt", "rt-multi-thread", "macros", "process", "sync"] }
futures = { version = "0.3.31", default-features = false, features = ["executor"] }
url = "2.5.7"
once_cell = "1.21.3"
reqwest = { version = "0.12.24", features = ["rustls-tls-webpki-roots", "cookies", "gzip", "json", "stream"] }
hickory-resolver = { version = "0.25.2" }
anyhow = "1.0.100"
scopeguard = "1.2.0"
notify-rust = "4.11.7"
asar = "0.3.0"
walkdir = "2.5.0"
ort = { version = "2.0.0-rc.10", features = ["xnnpack", "download-binaries", "ndarray"] }
tokenizers = { version = "0.22", default-features = false, features = ["onig"] }
ndarray = "0.17"
serde_json = "1.0"
tokenizers = { version = "0.22.2", default-features = false, features = ["onig"] }
ndarray = "0.17.1"
serde_json = "1.0.145"
serde = { version = "1.0.228", features = ["derive"] }
unp4k_rs = { git = "https://github.com/StarCitizenToolBox/unp4k_rs", tag = "V0.0.2" }
wry = "0.53.5"
tao = { version = "0.34.5", features = ["serde"] }
uuid = { version = "1.19.0", features = ["v4"] }
parking_lot = "0.12.5"
crossbeam-channel = "0.5.15"
image = { version = "0.25.9", default-features = false, features = ["ico"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62.2", features = [
@@ -39,7 +46,7 @@ windows = { version = "0.62.2", features = [
"Win32_System_Threading",
"Win32_Foundation"
] }
win32job = "2"
win32job = "2.0.3"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }

View File

@@ -7,3 +7,4 @@ pub mod win32_api;
pub mod asar_api;
pub mod ort_api;
pub mod unp4k_api;
pub mod webview_api;

533
rust/src/api/webview_api.rs Normal file
View File

@@ -0,0 +1,533 @@
// WebView API using wry + tao
// This module provides a cross-platform WebView implementation for Flutter
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crossbeam_channel::{bounded, Receiver, Sender};
use flutter_rust_bridge::frb;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
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};
// Platform-specific imports for running event loop on any thread
#[cfg(target_os = "windows")]
use tao::platform::windows::EventLoopBuilderExtWindows;
use once_cell::sync::Lazy;
// Embed the app icon at compile time
static APP_ICON_DATA: &[u8] = include_bytes!("../../../windows/runner/resources/app_icon.ico");
// Embed the init script at compile time
static INIT_SCRIPT: &str = include_str!("../assets/webview_init_script.js");
// ============ Data Structures ============
/// WebView window configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[frb(dart_metadata = ("freezed"))]
pub struct WebViewConfiguration {
pub title: String,
pub width: u32,
pub height: u32,
pub user_data_folder: Option<String>,
pub enable_devtools: bool,
pub transparent: bool,
pub user_agent: Option<String>,
}
impl Default for WebViewConfiguration {
fn default() -> Self {
Self {
title: "WebView".to_string(),
width: 1280,
height: 720,
user_data_folder: None,
enable_devtools: false,
transparent: false,
user_agent: None,
}
}
}
/// Navigation state of the WebView
#[derive(Debug, Clone, Serialize, Deserialize)]
#[frb(dart_metadata = ("freezed"))]
pub struct WebViewNavigationState {
pub url: String,
pub title: String,
pub can_go_back: bool,
pub can_go_forward: bool,
pub is_loading: bool,
}
impl Default for WebViewNavigationState {
fn default() -> Self {
Self {
url: String::new(),
title: String::new(),
can_go_back: false,
can_go_forward: false,
is_loading: false,
}
}
}
/// Events from WebView to Dart
#[derive(Debug, Clone, Serialize, Deserialize)]
#[frb(dart_metadata = ("freezed"))]
pub enum WebViewEvent {
NavigationStarted { url: String },
NavigationCompleted { url: String },
TitleChanged { title: String },
WebMessage { message: String },
WindowClosed,
Error { message: String },
}
/// Commands from Dart to WebView
#[derive(Debug, Clone)]
pub enum WebViewCommand {
Navigate(String),
GoBack,
GoForward,
Reload,
Stop,
ExecuteScript(String),
SetVisibility(bool),
Close,
SetWindowSize(u32, u32),
SetWindowPosition(i32, i32),
}
// ============ Global State ============
type WebViewId = String;
struct WebViewInstance {
command_sender: Sender<WebViewCommand>,
event_receiver: Receiver<WebViewEvent>,
state: Arc<RwLock<WebViewNavigationState>>,
is_closed: Arc<AtomicBool>,
}
static WEBVIEW_INSTANCES: Lazy<RwLock<HashMap<WebViewId, WebViewInstance>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
// Custom event type for the event loop
#[derive(Debug, Clone)]
#[allow(dead_code)]
enum UserEvent {
Command(WebViewCommand),
Quit,
}
// ============ Public API ============
/// Create a new WebView window and return its ID
#[frb(sync)]
pub fn webview_create(config: WebViewConfiguration) -> Result<String, String> {
let id = uuid::Uuid::new_v4().to_string();
let id_clone = id.clone();
let (cmd_tx, cmd_rx) = bounded::<WebViewCommand>(100);
let (event_tx, event_rx) = bounded::<WebViewEvent>(100);
let state = Arc::new(RwLock::new(WebViewNavigationState::default()));
let state_clone = Arc::clone(&state);
let is_closed = Arc::new(AtomicBool::new(false));
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);
});
// Wait a moment for the window to be created
std::thread::sleep(std::time::Duration::from_millis(100));
let instance = WebViewInstance {
command_sender: cmd_tx,
event_receiver: event_rx,
state,
is_closed,
};
WEBVIEW_INSTANCES.write().insert(id.clone(), instance);
Ok(id)
}
/// Navigate to a URL
#[frb(sync)]
pub fn webview_navigate(id: String, url: String) -> Result<(), String> {
send_command(&id, WebViewCommand::Navigate(url))
}
/// Go back in history
#[frb(sync)]
pub fn webview_go_back(id: String) -> Result<(), String> {
send_command(&id, WebViewCommand::GoBack)
}
/// Go forward in history
#[frb(sync)]
pub fn webview_go_forward(id: String) -> Result<(), String> {
send_command(&id, WebViewCommand::GoForward)
}
/// Reload the current page
#[frb(sync)]
pub fn webview_reload(id: String) -> Result<(), String> {
send_command(&id, WebViewCommand::Reload)
}
/// Stop loading
#[frb(sync)]
pub fn webview_stop(id: String) -> Result<(), String> {
send_command(&id, WebViewCommand::Stop)
}
/// Execute JavaScript in the WebView
#[frb(sync)]
pub fn webview_execute_script(id: String, script: String) -> Result<(), String> {
send_command(&id, WebViewCommand::ExecuteScript(script))
}
/// Set window visibility
#[frb(sync)]
pub fn webview_set_visibility(id: String, visible: bool) -> Result<(), String> {
send_command(&id, WebViewCommand::SetVisibility(visible))
}
/// Close the WebView window
#[frb(sync)]
pub fn webview_close(id: String) -> Result<(), String> {
send_command(&id, WebViewCommand::Close)?;
// Remove from instances after a short delay
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
WEBVIEW_INSTANCES.write().remove(&id);
});
Ok(())
}
/// Set window size
#[frb(sync)]
pub fn webview_set_window_size(id: String, width: u32, height: u32) -> Result<(), String> {
send_command(&id, WebViewCommand::SetWindowSize(width, height))
}
/// Set window position
#[frb(sync)]
pub fn webview_set_window_position(id: String, x: i32, y: i32) -> Result<(), String> {
send_command(&id, WebViewCommand::SetWindowPosition(x, y))
}
/// Get the current navigation state
#[frb(sync)]
pub fn webview_get_state(id: String) -> Result<WebViewNavigationState, String> {
let instances = WEBVIEW_INSTANCES.read();
let instance = instances
.get(&id)
.ok_or_else(|| format!("WebView instance not found: {}", id))?;
let state = instance.state.read().clone();
Ok(state)
}
/// Check if the WebView is closed
#[frb(sync)]
pub fn webview_is_closed(id: String) -> bool {
let instances = WEBVIEW_INSTANCES.read();
let result = instances
.get(&id)
.map(|i| i.is_closed.load(Ordering::SeqCst))
.unwrap_or(true);
result
}
/// Poll for events from the WebView (non-blocking)
#[frb(sync)]
pub fn webview_poll_events(id: String) -> Vec<WebViewEvent> {
let instances = WEBVIEW_INSTANCES.read();
if let Some(instance) = instances.get(&id) {
let mut events = Vec::new();
while let Ok(event) = instance.event_receiver.try_recv() {
events.push(event);
}
drop(instances);
events
} else {
drop(instances);
vec![]
}
}
/// Get a list of all active WebView IDs
#[frb(sync)]
pub fn webview_list_all() -> Vec<String> {
let instances = WEBVIEW_INSTANCES.read();
let keys: Vec<String> = instances.keys().cloned().collect();
drop(instances);
keys
}
// ============ Internal Implementation ============
fn send_command(id: &str, command: WebViewCommand) -> Result<(), String> {
let instances = WEBVIEW_INSTANCES.read();
let instance = instances
.get(id)
.ok_or_else(|| format!("WebView instance not found: {}", id))?;
if instance.is_closed.load(Ordering::SeqCst) {
return Err("WebView is closed".to_string());
}
instance
.command_sender
.send(command)
.map_err(|e| e.to_string())
}
fn run_webview_loop(
_id: String,
config: WebViewConfiguration,
cmd_rx: Receiver<WebViewCommand>,
event_tx: Sender<WebViewEvent>,
state: Arc<RwLock<WebViewNavigationState>>,
is_closed: Arc<AtomicBool>,
) {
// Create event loop with any_thread support for non-main thread execution
#[cfg(target_os = "windows")]
let mut event_loop: EventLoop<UserEvent> = EventLoopBuilder::with_user_event()
.with_any_thread(true)
.build();
#[cfg(not(target_os = "windows"))]
let mut event_loop: EventLoop<UserEvent> = EventLoopBuilder::with_user_event().build();
let proxy = event_loop.create_proxy();
// Load window icon from embedded ICO file
let window_icon = load_app_icon();
// Build the window with decorations (title bar provided by OS)
// Set dark background color matching app theme (#1a1a1a)
let mut window_builder = WindowBuilder::new()
.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)
.expect("Failed to create window");
let window = Arc::new(window);
let event_tx_clone = event_tx.clone();
// Create web context with custom data directory if provided
let mut web_context = config
.user_data_folder
.as_ref()
.map(|path| wry::WebContext::new(Some(std::path::PathBuf::from(path))))
.unwrap_or_default();
let mut builder = WebViewBuilder::new_with_web_context(&mut web_context)
.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);
// Set user agent if provided
if let Some(ref user_agent) = config.user_agent {
builder = builder.with_user_agent(user_agent);
}
let webview = builder
.with_ipc_handler(move |message| {
let msg = message.body().to_string();
// Forward all 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);
move |uri| {
{
let mut state_guard = state.write();
state_guard.url = uri.clone();
state_guard.is_loading = true;
}
let _ = event_tx.send(WebViewEvent::NavigationStarted { url: uri });
true // Allow navigation
}
})
.with_on_page_load_handler({
let event_tx = event_tx.clone();
let state = Arc::clone(&state);
move |event, url| {
match event {
PageLoadEvent::Started => {
let mut state_guard = state.write();
state_guard.url = url.clone();
state_guard.is_loading = true;
}
PageLoadEvent::Finished => {
{
let mut state_guard = state.write();
state_guard.url = url.clone();
state_guard.is_loading = false;
}
let _ = event_tx.send(WebViewEvent::NavigationCompleted { url });
}
}
}
})
.build(&window)
.expect("Failed to create webview");
let webview = Arc::new(webview);
let webview_cmd = Arc::clone(&webview);
// Spawn command handler thread
let proxy_cmd = proxy.clone();
std::thread::spawn(move || {
while let Ok(cmd) = cmd_rx.recv() {
if proxy_cmd.send_event(UserEvent::Command(cmd)).is_err() {
break;
}
}
});
// Run the event loop with run_return so we can properly cleanup
event_loop.run_return(|event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => {
is_closed.store(true, Ordering::SeqCst);
let _ = event_tx.send(WebViewEvent::WindowClosed);
*control_flow = ControlFlow::Exit;
}
Event::UserEvent(user_event) => match user_event {
UserEvent::Command(cmd) => {
handle_command(&webview_cmd, &window, cmd, &state, &event_tx, &is_closed, control_flow);
}
UserEvent::Quit => {
is_closed.store(true, Ordering::SeqCst);
*control_flow = ControlFlow::Exit;
}
},
_ => {}
}
});
// Explicitly drop in correct order: webview first, then web_context, then window
drop(webview_cmd);
drop(web_context);
drop(window);
}
fn handle_command(
webview: &Arc<WebView>,
window: &Arc<Window>,
command: WebViewCommand,
state: &Arc<RwLock<WebViewNavigationState>>,
event_tx: &Sender<WebViewEvent>,
is_closed: &Arc<AtomicBool>,
control_flow: &mut ControlFlow,
) {
match command {
WebViewCommand::Navigate(url) => {
{
let mut s = state.write();
s.url = url.clone();
s.is_loading = true;
}
let _ = webview.load_url(&url);
let _ = event_tx.send(WebViewEvent::NavigationStarted { url });
}
WebViewCommand::GoBack => {
let _ = webview.evaluate_script("history.back()");
}
WebViewCommand::GoForward => {
let _ = webview.evaluate_script("history.forward()");
}
WebViewCommand::Reload => {
let _ = webview.evaluate_script("location.reload()");
}
WebViewCommand::Stop => {
let _ = webview.evaluate_script("window.stop()");
}
WebViewCommand::ExecuteScript(script) => {
let _ = webview.evaluate_script(&script);
}
WebViewCommand::SetVisibility(visible) => {
window.set_visible(visible);
}
WebViewCommand::Close => {
// Properly close the window and exit the event loop
is_closed.store(true, Ordering::SeqCst);
let _ = event_tx.send(WebViewEvent::WindowClosed);
// Exit the event loop - this will cause cleanup
*control_flow = ControlFlow::Exit;
}
WebViewCommand::SetWindowSize(width, height) => {
let _ = window.set_inner_size(LogicalSize::new(width, height));
}
WebViewCommand::SetWindowPosition(x, y) => {
window.set_outer_position(LogicalPosition::new(x, y));
}
}
}
/// Load the application icon from embedded ICO data
fn load_app_icon() -> Option<Icon> {
use std::io::Cursor;
use image::ImageReader;
// Parse the ICO file from embedded bytes
let cursor = Cursor::new(APP_ICON_DATA);
let reader = match ImageReader::new(cursor).with_guessed_format() {
Ok(r) => r,
Err(e) => {
eprintln!("[SCToolbox] Failed to create image reader: {}", e);
return None;
}
};
let img = match reader.decode() {
Ok(img) => img,
Err(e) => {
eprintln!("[SCToolbox] Failed to decode icon: {}", e);
return None;
}
};
// Convert to RGBA8
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) => {
eprintln!("[SCToolbox] Failed to create icon: {:?}", e);
None
}
}
}

View File

@@ -0,0 +1,266 @@
// SCToolbox WebView initialization script
(function() {
'use strict';
if (window._sctInitialized) return;
window._sctInitialized = true;
// ========== 导航栏 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>',
forward: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6 4L10 8L6 12" 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>'
};
function createNavBar() {
if (window.location.href === 'about:blank') return;
if (document.getElementById('sct-navbar')) return;
if (!document.body) {
setTimeout(createNavBar, 50);
return;
}
const nav = document.createElement('div');
nav.id = 'sct-navbar';
nav.innerHTML = `
<style>
#sct-navbar {
position: fixed;
top: 0.5rem; /* 8px */
left: 50%;
transform: translateX(-50%);
height: 2.25rem; /* 36px */
background: rgba(19, 36, 49, 0.95);
backdrop-filter: blur(0.75rem); /* 12px */
display: flex;
align-items: center;
padding: 0 0.5rem; /* 0 8px */
z-index: 2147483647;
font-family: 'Segoe UI', system-ui, sans-serif;
box-shadow: 0 0.125rem 0.75rem rgba(0,0,0,0.4); /* 0 2px 12px */
user-select: none;
border-radius: 0.5rem; /* 8px */
gap: 0.125rem; /* 2px */
border: 0.0625rem solid rgba(10, 49, 66, 0.8); /* 1px */
}
#sct-navbar button {
background: transparent;
border: none;
color: rgba(255,255,255,0.85);
width: 2rem; /* 32px */
height: 1.75rem; /* 28px */
border-radius: 0.25rem; /* 4px */
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
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:disabled { opacity: 0.35; cursor: not-allowed; }
#sct-navbar button svg { display: block; }
#sct-navbar-url {
width: 16.25rem; /* 260px */
height: 1.625rem; /* 26px */
padding: 0 0.625rem; /* 0 10px */
border: 0.0625rem solid rgba(10, 49, 66, 0.6); /* 1px */
border-radius: 0.25rem; /* 4px */
background: rgba(0,0,0,0.25);
color: rgba(255,255,255,0.9);
font-size: 0.75rem; /* 12px */
outline: none;
text-overflow: ellipsis;
margin-left: 0.25rem; /* 4px */
}
#sct-navbar-url:focus { border-color: rgba(10, 49, 66, 1); background: rgba(0,0,0,0.35); }
/* ---------- Spinner & Favicon Slot ---------- */
#sct-spinner {
display: block; /* default: show spinner during loading */
width: 1.125rem; /* 18px */
height: 1.125rem; /* 18px */
border: 0.125rem solid rgba(255, 255, 255, 0.16); /* ~2px */
border-top-color: rgba(255, 255, 255, 0.92);
border-radius: 50%;
margin-left: 0.5rem;
-webkit-animation: sct-spin 0.8s linear infinite;
animation: sct-spin 0.8s linear infinite;
pointer-events: none;
align-self: center;
flex-shrink: 0;
}
#sct-favicon-slot {
display: none; /* hidden until page ready */
align-items: center;
justify-content: center;
width: 1.125rem; /* 18px - same as spinner */
height: 1.125rem; /* 18px - same as spinner */
margin-left: 0.5rem;
pointer-events: none;
flex-shrink: 0;
}
#sct-favicon {
width: 1.125rem; /* 18px */
height: 1.125rem; /* 18px */
object-fit: cover;
border-radius: 0.25rem; /* 4px */
}
@-webkit-keyframes sct-spin { to { -webkit-transform: rotate(360deg); } }
@keyframes sct-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
#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-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>
<input type="text" id="sct-navbar-url" readonly value="${window.location.href}" />
`;
document.body.insertBefore(nav, document.body.firstChild);
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);
};
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;
}
}
// 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
const spinner = document.getElementById('sct-spinner');
const faviconSlot = document.getElementById('sct-favicon-slot');
const faviconImg = document.getElementById('sct-favicon');
function showSpinner() {
if (spinner) {
spinner.style.display = 'block';
spinner.setAttribute('aria-hidden', 'false');
spinner.setAttribute('aria-busy', 'true');
}
if (faviconSlot) {
faviconSlot.style.display = 'none';
}
}
function hideSpin() {
if (spinner) {
spinner.style.display = 'none';
spinner.setAttribute('aria-hidden', 'true');
spinner.setAttribute('aria-busy', 'false');
}
}
// 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);
});
}
// 在 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';
}
});
})();

File diff suppressed because it is too large Load Diff