mirror of
https://github.com/StarCitizenToolBox/app.git
synced 2026-02-06 15:10:20 +00:00
feat: Replace desktop_webview_window with tao&wry , from tauri
This commit is contained in:
1765
rust/Cargo.lock
generated
1765
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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)'] }
|
||||
|
||||
@@ -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
533
rust/src/api/webview_api.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
266
rust/src/assets/webview_init_script.js
Normal file
266
rust/src/assets/webview_init_script.js
Normal 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
Reference in New Issue
Block a user