diff --git a/Cargo.lock b/Cargo.lock index ee6275c..3213603 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -541,7 +541,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey", + "pastey 0.1.1", "rayon", "thiserror 2.0.18", "v_frame", @@ -2541,6 +2541,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b7d306bc9d6fe1d1fd5e775837ec1fa1e41d1c8063789181b7de631bd5db41c" dependencies = [ + "async-channel", "auto_enums", "bitflags 2.13.0", "cfg-if", @@ -2554,6 +2555,7 @@ dependencies = [ "i-slint-core-macros", "icu_normalizer", "image", + "ksni", "lyon_algorithms", "lyon_extra", "lyon_geom", @@ -3133,6 +3135,23 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "ksni" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9eeb3f510b6148ae68f963af2c1fbb0de4d9e4e05f82813cfb319837c3ad2b" +dependencies = [ + "async-executor", + "async-io", + "async-lock", + "futures-channel", + "futures-lite", + "futures-util", + "pastey 0.2.3", + "serde", + "zbus", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -4241,6 +4260,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index f7878f5..efd907d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ url = { version = "2.5" } # GUI async-compat = { version = "0.2" } -i-slint-backend-winit = "1.15" +i-slint-backend-winit = "1.17" open = "5.3" reqwest = { version = "0.13", features = [ @@ -58,7 +58,7 @@ reqwest = { version = "0.13", features = [ "json", ] } -slint = { version = "1.15", default-features = false, features = [ +slint = { version = "1.17", default-features = false, features = [ "accessibility", "backend-winit", "compat-1-2", @@ -67,6 +67,7 @@ slint = { version = "1.15", default-features = false, features = [ "renderer-software", "serde", "std", + "system-tray", ] } sys-locale = "0.3" @@ -78,5 +79,5 @@ winit = "0.30" build-target = "0.8" git-version = "0.3" rustc_version = "0.4" -slint-build = "1.15" +slint-build = "1.17" winres = "0.1" diff --git a/crates/desktop/src/bridges/mod.rs b/crates/desktop/src/bridges/mod.rs index 4c0bf13..abdab99 100644 --- a/crates/desktop/src/bridges/mod.rs +++ b/crates/desktop/src/bridges/mod.rs @@ -1,5 +1,6 @@ pub mod settings; pub mod system_info; +pub mod tray; pub mod ui_state; pub mod window_control; @@ -9,4 +10,5 @@ pub fn setup(window: &MainWindow) { window_control::setup(window); system_info::setup(window); ui_state::setup(window); + tray::setup(window); } diff --git a/crates/desktop/src/bridges/settings.rs b/crates/desktop/src/bridges/settings.rs index ac9241a..7fae3c2 100644 --- a/crates/desktop/src/bridges/settings.rs +++ b/crates/desktop/src/bridges/settings.rs @@ -90,6 +90,10 @@ pub fn load_config(window: &MainWindow) { bridge.set_language(config.language.into()); bridge.set_running_in_tray(config.running_in_tray); + if config.running_in_tray { + crate::bridges::tray::ensure_created(window); + } + let window_clone = window.as_weak(); bridge.on_change_language(move |lang| { let window = window_clone.upgrade().unwrap(); @@ -97,6 +101,31 @@ pub fn load_config(window: &MainWindow) { bridge.set_language(lang.clone()); slint::select_bundled_translation(lang.as_str()).ok(); + + // Refresh the tray menu labels to match the new language. + if crate::bridges::tray::is_created() { + crate::bridges::tray::destroy(); + crate::bridges::tray::ensure_created(&window); + } + }); + + let window_clone = window.as_weak(); + bridge.on_toggle_running_in_tray(move || { + let window = match window_clone.upgrade() { + Some(w) => w, + None => return, + }; + let bridge = window.global::(); + let new_val = !bridge.get_running_in_tray(); + bridge.set_running_in_tray(new_val); + + if new_val { + crate::bridges::tray::ensure_created(&window); + } else { + crate::bridges::tray::destroy(); + } + + save_config(&window); }); } diff --git a/crates/desktop/src/bridges/tray.rs b/crates/desktop/src/bridges/tray.rs new file mode 100644 index 0000000..4a4da36 --- /dev/null +++ b/crates/desktop/src/bridges/tray.rs @@ -0,0 +1,93 @@ +use std::cell::RefCell; + +use i_slint_backend_winit::WinitWindowAccessor; +use slint::ComponentHandle; +use tracing::{debug, error}; + +use crate::ui::{MainWindow, SettingsBridge, WsrxTray}; + +thread_local! { + static TRAY: RefCell> = const { RefCell::new(None) }; +} + +/// Localised labels for the tray context menu, picked from the active UI +/// language so the tray matches the rest of the interface. +fn localized_labels(language: &str) -> (&'static str, &'static str) { + match language { + "zh_CN" => ("显示窗口", "退出"), + "zh_TW" => ("顯示視窗", "退出"), + _ => ("Show", "Quit"), + } +} + +/// Restore and focus the main window. +fn show_window(window_weak: &slint::Weak) { + if let Some(window) = window_weak.upgrade() { + window.window().with_winit_window(|winit_window| { + // Restore the window that was hidden via winit::Window::set_visible(false). + winit_window.set_visible(true); + winit_window.set_minimized(false); + winit_window.focus_window(); + }); + } +} + +/// No global setup is required when using Slint's built-in [`SystemTrayIcon`]. +/// The tray instance is created on demand in [`ensure_created`]. +pub fn setup(_window: &MainWindow) {} + +/// Create the tray icon if it does not already exist. Safe to call +/// repeatedly — subsequent calls are no-ops. +pub fn ensure_created(window: &MainWindow) { + let already_exists = TRAY.with(|t| t.borrow().is_some()); + if already_exists { + return; + } + + let language = window.global::().get_language().to_string(); + let (show_label, quit_label) = localized_labels(&language); + let window_weak = window.as_weak(); + + let tray = match WsrxTray::new() { + Ok(tray) => tray, + Err(e) => { + error!("Failed to create system tray icon: {e}"); + return; + } + }; + + tray.set_show_label(show_label.into()); + tray.set_quit_label(quit_label.into()); + + let weak = window_weak.clone(); + tray.on_show_window(move || show_window(&weak)); + + let weak = window_weak; + tray.on_quit(move || crate::launcher::shutdown(&weak)); + + if let Err(e) = tray.show() { + error!("Failed to show system tray icon: {e}"); + return; + } + + TRAY.with(|t| { + *t.borrow_mut() = Some(tray); + }); + debug!("System tray icon created"); +} + +/// Hide and drop the tray icon if it exists. Dropping the [`WsrxTray`] +/// instance removes it from the system tray. +pub fn destroy() { + TRAY.with(|t| { + if t.borrow().is_some() { + debug!("Destroying system tray icon"); + } + *t.borrow_mut() = None; + }); +} + +/// Whether a tray icon is currently active. +pub fn is_created() -> bool { + TRAY.with(|t| t.borrow().is_some()) +} diff --git a/crates/desktop/src/bridges/window_control.rs b/crates/desktop/src/bridges/window_control.rs index 8d167e7..c608aa7 100644 --- a/crates/desktop/src/bridges/window_control.rs +++ b/crates/desktop/src/bridges/window_control.rs @@ -6,9 +6,36 @@ use winit::window::ResizeDirection; use crate::{ launcher, - ui::{MainWindow, WindowControlBridge}, + ui::{MainWindow, SettingsBridge, WindowControlBridge}, }; +/// Hide the window into the system tray when the user has enabled that +/// behaviour. Returns `true` when the window was hidden (the close event +/// has been consumed), `false` when the application should proceed to shut +/// down. +fn hide_to_tray(window: &MainWindow) -> bool { + let settings = window.global::(); + if !settings.get_running_in_tray() { + return false; + } + + // Make sure the tray icon exists before hiding the window, otherwise + // the user would have no way to bring the application back. + crate::bridges::tray::ensure_created(window); + if !crate::bridges::tray::is_created() { + return false; + } + + window.window().with_winit_window(|winit_window| { + // Use the winit API directly instead of Slint's `hide()`. On some + // backends `Window::hide()` is treated as a close, which would make + // `ui.run()` return and terminate the process. + winit_window.set_visible(false); + }); + crate::bridges::settings::save_config(window); + true +} + pub fn setup(window: &MainWindow) { let mut resize_map = HashMap::new(); resize_map.insert("r".to_string(), ResizeDirection::East); @@ -36,7 +63,11 @@ pub fn setup(window: &MainWindow) { EventResult::Propagate } winit::event::WindowEvent::CloseRequested => { - launcher::shutdown(&window_weak); + if let Some(window) = window_weak.upgrade() { + if !hide_to_tray(&window) { + launcher::shutdown(&window_weak); + } + } EventResult::PreventDefault } _ => EventResult::Propagate, @@ -65,8 +96,13 @@ pub fn setup(window: &MainWindow) { let window_clone_pin = window.as_weak(); window_control_bridge.on_close(move || { - // TODO: system tray implementation - launcher::shutdown(&window_clone_pin); + let window = match window_clone_pin.upgrade() { + Some(w) => w, + None => return, + }; + if !hide_to_tray(&window) { + launcher::shutdown(&window_clone_pin); + } }); let window_clone_pin = window.as_weak(); diff --git a/crates/desktop/src/daemon/api_controller.rs b/crates/desktop/src/daemon/api_controller.rs index 3a5a6c8..bd88230 100644 --- a/crates/desktop/src/daemon/api_controller.rs +++ b/crates/desktop/src/daemon/api_controller.rs @@ -182,8 +182,10 @@ async fn launch_instance( if instance.label != instance_data.label { instance.label = instance_data.label.clone(); } - - return Ok(Json(instance.data.clone())); + let data = instance.data.clone(); + drop(instances); + super::save_instances(&state).await; + return Ok(Json(data)); } let instance = ProxyInstance::new( @@ -196,6 +198,7 @@ async fn launch_instance( let instance_resp: InstanceData = (&instance).into(); instances.push(instance); drop(instances); + super::save_instances(&state).await; let state_clone = state.clone(); let instance = instance_resp.clone(); @@ -273,6 +276,7 @@ async fn close_instance( } instances.retain(|i| i.local.as_str() != req.local); + super::save_instances(&state).await; match slint::invoke_from_event_loop(move || { let ui_handle = state.ui.upgrade().unwrap(); @@ -468,8 +472,8 @@ async fn popup_window( ) -> Result { slint::invoke_from_event_loop(move || { let ui_handle = state.ui.upgrade().unwrap(); - ui_handle.show().ok(); ui_handle.window().with_winit_window(|winit_window| { + winit_window.set_visible(true); winit_window.set_minimized(false); }); }) diff --git a/crates/desktop/src/daemon/mod.rs b/crates/desktop/src/daemon/mod.rs index f14f72b..25f81b1 100644 --- a/crates/desktop/src/daemon/mod.rs +++ b/crates/desktop/src/daemon/mod.rs @@ -2,13 +2,18 @@ use std::{process, rc::Rc, sync::Arc}; use api_controller::router; use directories::ProjectDirs; -use model::{ScopeData, ServerState}; +use latency_worker::{update_instance_latency, update_instance_state}; +use model::{InstanceData, ProxyInstance, ScopeData, ServerState}; use serde::{Deserialize, Serialize}; use slint::{ComponentHandle, Model, ToSharedString, VecModel}; use tokio::{net::TcpListener, sync::RwLock}; use tracing::{debug, error, info, warn}; +use wsrx::utils::create_tcp_listener; -use crate::ui::{Instance, InstanceBridge, MainWindow, Scope, ScopeBridge, SettingsBridge}; +use crate::{ + bridges::ui_state::sync_scoped_instance, + ui::{Instance, InstanceBridge, MainWindow, Scope, ScopeBridge, SettingsBridge}, +}; mod api_controller; mod latency_worker; @@ -20,6 +25,11 @@ struct ScopesConfig { scopes: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize)] +struct InstancesConfig { + instances: Vec, +} + pub fn setup(ui: &MainWindow) { use rustls::crypto; @@ -137,6 +147,7 @@ pub fn setup(ui: &MainWindow) { scope_host.as_str(), ) .await; + save_scopes(&handle_cloned); })) { Ok(_) => {} Err(e) => { @@ -154,6 +165,7 @@ pub fn setup(ui: &MainWindow) { match slint::spawn_local(async_compat::Compat::new(async move { ui_controller::on_scope_del(&state_cloned, handle_cloned.clone(), scope_host.as_str()) .await; + save_scopes(&handle_cloned); })) { Ok(_) => {} Err(e) => { @@ -163,6 +175,8 @@ pub fn setup(ui: &MainWindow) { }); let router = router(state_d.clone()); + let state_for_restore = state_d.clone(); + let handle_for_restore = handle.clone(); match slint::spawn_local(async_compat::Compat::new(async move { let listener = match TcpListener::bind(&format!("{}:{}", "127.0.0.1", 3307)).await { @@ -203,6 +217,9 @@ pub fn setup(ui: &MainWindow) { std::process::exit(1); }); + let config_file = proj_dirs.config_dir().join("instances.toml"); + restore_instances(state_for_restore, handle_for_restore, config_file).await; + info!( "API server is listening on [[ {} ]]", listener.local_addr().expect("failed to bind port") @@ -270,6 +287,172 @@ pub fn save_scopes(ui: &slint::Weak) { debug!("Saved scopes to: {:?}", config_file); } +/// Persist the current list of proxy instances to disk so they can be +/// restored on the next launch, even when the application has been running +/// only in the system tray. +pub async fn save_instances(state: &ServerState) { + let instances = state.instances.read().await; + let mut instances_data: Vec = instances.iter().map(|i| i.into()).collect(); + drop(instances); + for instance in &mut instances_data { + instance.latency = -1; + } + save_instances_data(&instances_data); +} + +/// Synchronous variant used during shutdown when an async runtime may no +/// longer be available. Reads the instances from the UI model. +pub fn save_instances_sync(ui: &slint::Weak) { + let window = match ui.upgrade() { + Some(w) => w, + None => return, + }; + let instance_bridge = window.global::(); + let instances = instance_bridge.get_instances(); + let instances = instances + .as_any() + .downcast_ref::>() + .unwrap(); + let instances_data: Vec = instances + .iter() + .map(|i| InstanceData { + label: i.label.to_string(), + remote: i.remote.to_string(), + local: i.local.to_string(), + latency: -1, + scope_host: i.scope_host.to_string(), + }) + .collect(); + save_instances_data(&instances_data); +} + +fn save_instances_data(instances: &[InstanceData]) { + let proj_dirs = match ProjectDirs::from("org", "xdsec", "wsrx") { + Some(dirs) => dirs, + None => { + error!("Unable to find project config directories"); + return; + } + }; + let config_file = proj_dirs.config_dir().join("instances.toml"); + let config_obj = InstancesConfig { + instances: instances.to_vec(), + }; + let config = toml::to_string(&config_obj).unwrap_or_else(|e| { + error!("Failed to serialize instances: {}", e); + String::new() + }); + if config.is_empty() { + return; + } + if let Err(e) = std::fs::create_dir_all(proj_dirs.config_dir()) { + error!("Failed to create config directory: {}", e); + return; + } + if let Err(e) = std::fs::write(&config_file, config) { + error!("Failed to write instances file: {}", e); + } else { + debug!("Saved instances to: {:?}", config_file); + } +} + +fn load_instances_from_path(path: &std::path::Path) -> Vec { + let config = match std::fs::read_to_string(path) { + Ok(config) => config, + Err(_) => return vec![], + }; + let config: InstancesConfig = match toml::from_str(&config) { + Ok(config) => config, + Err(e) => { + error!("Failed to parse instances file: {}", e); + InstancesConfig { instances: vec![] } + } + }; + config.instances +} + +async fn restore_instances( + state: ServerState, + ui: slint::Weak, + config_file: std::path::PathBuf, +) { + let instances_data = load_instances_from_path(&config_file); + if instances_data.is_empty() { + return; + } + + info!("Restoring {} persisted instance(s)...", instances_data.len()); + + let mut restored = Vec::with_capacity(instances_data.len()); + let mut ui_instances = Vec::with_capacity(instances_data.len()); + + for data in instances_data { + match create_tcp_listener(&data.local).await { + Ok(listener) => { + let instance = ProxyInstance::new( + data.label.clone(), + data.scope_host.clone(), + listener, + data.remote.clone(), + ); + let local = instance.local.clone(); + let instance_data: InstanceData = (&instance).into(); + + let state_clone = state.clone(); + tokio::spawn(async move { + let client = reqwest::Client::new(); + match update_instance_latency(&instance_data, &client).await { + Ok(elapsed) => { + update_instance_state(state_clone, &instance_data, elapsed).await + } + Err(_) => update_instance_state(state_clone, &instance_data, -1).await, + }; + }); + + ui_instances.push(Instance { + label: data.label.as_str().into(), + remote: data.remote.as_str().into(), + local: local.as_str().into(), + latency: -1, + scope_host: data.scope_host.as_str().into(), + }); + restored.push(instance); + } + Err((status, msg)) => { + warn!( + "Failed to restore instance {} -> {} (scope: {}): {} {}", + data.local, data.remote, data.scope_host, status, msg + ); + } + } + } + + { + let mut instances = state.instances.write().await; + instances.extend(restored); + } + + let restored_count = ui_instances.len(); + let _ = slint::invoke_from_event_loop(move || { + let ui_handle = match ui.upgrade() { + Some(w) => w, + None => return, + }; + let instance_bridge = ui_handle.global::(); + let instances_rc = instance_bridge.get_instances(); + let instances_rc = instances_rc + .as_any() + .downcast_ref::>() + .unwrap(); + for instance in ui_instances { + instances_rc.push(instance); + } + sync_scoped_instance(ui_handle.as_weak()); + }); + + info!("Restored {} instance(s) with active tunnels", restored_count); +} + fn default_label() -> String { format!("inst-{:06x}", rand::random::()) } diff --git a/crates/desktop/src/daemon/ui_controller.rs b/crates/desktop/src/daemon/ui_controller.rs index a7e9289..33112cb 100644 --- a/crates/desktop/src/daemon/ui_controller.rs +++ b/crates/desktop/src/daemon/ui_controller.rs @@ -53,6 +53,7 @@ pub async fn on_instance_add(state: &ServerState, remote: &str, local: &str) { let label = instance.label.clone(); state.instances.write().await.push(instance); + super::save_instances(&state).await; let state = state.clone(); @@ -89,6 +90,7 @@ pub async fn on_instance_del(state: &ServerState, local: &str) { .write() .await .retain(|instance| instance.local.as_str() != local); + super::save_instances(state).await; let state = state.clone(); let local = local.to_string(); @@ -194,6 +196,7 @@ pub async fn on_scope_del(state: &ServerState, ui: slint::Weak, scop } None => return, }; + super::save_instances(state).await; let scope_host = scope_host.to_string(); let state = state.clone(); diff --git a/crates/desktop/src/launcher.rs b/crates/desktop/src/launcher.rs index f4dfedc..c0de5e7 100644 --- a/crates/desktop/src/launcher.rs +++ b/crates/desktop/src/launcher.rs @@ -114,6 +114,7 @@ pub fn cleanup_runtime_state(ui: &slint::Weak) { if let Some(window) = ui.upgrade() { bridges::settings::save_config(&window); daemon::save_scopes(ui); + daemon::save_instances_sync(ui); } let proj_dirs = match ProjectDirs::from("org", "xdsec", "wsrx") { diff --git a/crates/desktop/ui/blocks/bridges.slint b/crates/desktop/ui/blocks/bridges.slint index 54d0349..999be57 100644 --- a/crates/desktop/ui/blocks/bridges.slint +++ b/crates/desktop/ui/blocks/bridges.slint @@ -62,4 +62,5 @@ export global SettingsBridge { in-out property api-port: 0; in-out property online: false; callback change-language(lang: string); + callback toggle-running-in-tray(); } diff --git a/crates/desktop/ui/blocks/tray.slint b/crates/desktop/ui/blocks/tray.slint new file mode 100644 index 0000000..c68f245 --- /dev/null +++ b/crates/desktop/ui/blocks/tray.slint @@ -0,0 +1,33 @@ +export component WsrxTray inherits SystemTrayIcon { + in-out property show-label: "Show"; + in-out property quit-label: "Quit"; + + icon: @image-url("../assets/logo.png"); + tooltip: "WebSocket Reflector X"; + title: "WebSocket Reflector X"; + + callback show-window(); + callback quit(); + + clicked => { + root.show-window(); + } + + Menu { + MenuItem { + title: root.show-label; + activated => { + root.show-window(); + } + } + + MenuSeparator {} + + MenuItem { + title: root.quit-label; + activated => { + root.quit(); + } + } + } +} diff --git a/crates/desktop/ui/i18n/en_US/LC_MESSAGES/wsrx-desktop.po b/crates/desktop/ui/i18n/en_US/LC_MESSAGES/wsrx-desktop.po index 9562a5a..01d794a 100644 --- a/crates/desktop/ui/i18n/en_US/LC_MESSAGES/wsrx-desktop.po +++ b/crates/desktop/ui/i18n/en_US/LC_MESSAGES/wsrx-desktop.po @@ -137,11 +137,6 @@ msgctxt "SettingsPage" msgid "Running in system tray when closed" msgstr "Running in system tray when closed" -#: crates/desktop/ui/pages/settings.slint:95 -msgctxt "SettingsPage" -msgid " (not implemented yet) " -msgstr " (not implemented yet) " - #: crates/desktop/ui/pages/settings.slint:106 msgctxt "SettingsPage" msgid "Enabled" diff --git a/crates/desktop/ui/i18n/zh_CN/LC_MESSAGES/wsrx-desktop.po b/crates/desktop/ui/i18n/zh_CN/LC_MESSAGES/wsrx-desktop.po index d5d20db..735add4 100644 --- a/crates/desktop/ui/i18n/zh_CN/LC_MESSAGES/wsrx-desktop.po +++ b/crates/desktop/ui/i18n/zh_CN/LC_MESSAGES/wsrx-desktop.po @@ -140,11 +140,6 @@ msgctxt "SettingsPage" msgid "Running in system tray when closed" msgstr "关闭窗口后继续在系统托盘中运行" -#: crates/desktop/ui/pages/settings.slint:95 -msgctxt "SettingsPage" -msgid " (not implemented yet) " -msgstr " (暂未实现) " - #: crates/desktop/ui/pages/settings.slint:106 msgctxt "SettingsPage" msgid "Enabled" diff --git a/crates/desktop/ui/i18n/zh_TW/LC_MESSAGES/wsrx-desktop.po b/crates/desktop/ui/i18n/zh_TW/LC_MESSAGES/wsrx-desktop.po index 968ed2f..d943358 100644 --- a/crates/desktop/ui/i18n/zh_TW/LC_MESSAGES/wsrx-desktop.po +++ b/crates/desktop/ui/i18n/zh_TW/LC_MESSAGES/wsrx-desktop.po @@ -140,11 +140,6 @@ msgctxt "SettingsPage" msgid "Running in system tray when closed" msgstr "關閉窗口後繼續在系統托盤中運行" -#: crates/desktop/ui/pages/settings.slint:95 -msgctxt "SettingsPage" -msgid " (not implemented yet) " -msgstr " (暫未實現) " - #: crates/desktop/ui/pages/settings.slint:106 msgctxt "SettingsPage" msgid "Enabled" diff --git a/crates/desktop/ui/main.slint b/crates/desktop/ui/main.slint index 73b559f..30b529c 100644 --- a/crates/desktop/ui/main.slint +++ b/crates/desktop/ui/main.slint @@ -2,6 +2,7 @@ import "fonts/reverier-mono-regular.ttf"; import { Styles } from "widgets/styles.slint"; import { SideBar } from "blocks/side-bar.slint"; +import { WsrxTray } from "blocks/tray.slint"; import { WindowControlBridge, Log, SystemInfoBridge, Instance, Scope, InstanceBridge, ScopeBridge, SettingsBridge } from "blocks/bridges.slint"; import { FramelessWindow } from "widgets/frameless-window.slint"; @@ -9,7 +10,7 @@ import { TitleBar } from "blocks/title-bar.slint"; import { UiState } from "blocks/globals.slint"; import { Stack } from "blocks/stack.slint"; -export { WindowControlBridge, Log, SystemInfoBridge, Instance, Scope, InstanceBridge, ScopeBridge, SettingsBridge, UiState } +export { WindowControlBridge, Log, SystemInfoBridge, Instance, Scope, InstanceBridge, ScopeBridge, SettingsBridge, UiState, WsrxTray } export component MainWindow inherits FramelessWindow { title: "WebSocket Reflector X"; diff --git a/crates/desktop/ui/pages/settings.slint b/crates/desktop/ui/pages/settings.slint index 800dbf3..d849570 100644 --- a/crates/desktop/ui/pages/settings.slint +++ b/crates/desktop/ui/pages/settings.slint @@ -92,23 +92,21 @@ export component SettingsPage inherits ScrollView { padding-left: Styles.sizes.p-lg; Text { - text: @tr("Running in system tray when closed") + @tr(" (not implemented yet) "); + text: @tr("Running in system tray when closed"); color: Styles.palette.window-fg; vertical-alignment: center; horizontal-stretch: 1; } Button { - // property toggled: SettingsBridge.running-in-tray; - property toggled: false; + property toggled: SettingsBridge.running-in-tray; icon: toggled ? @image-url("../assets/toggle-right.svg") : @image-url("../assets/toggle-left.svg"); icon-color: toggled ? Styles.palette.info-bg : Styles.palette.window-fg; text: toggled ? @tr("Enabled") : @tr("Disabled"); flat: true; - disabled: true; clicked => { - SettingsBridge.running-in-tray = !toggled; + SettingsBridge.toggle-running-in-tray(); } } } diff --git a/deployments/build-windows.ps1 b/deployments/build-windows.ps1 new file mode 100644 index 0000000..f1eda26 --- /dev/null +++ b/deployments/build-windows.ps1 @@ -0,0 +1,79 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = "Stop" + +$AppName = "WebSocketReflectorX" +$AppRoot = "dist" +$DistDir = $AppName +$PortableName = "$AppName-portable-windows-msvc-x86_64.zip" +$InstallerName = "$AppName-installer-windows-msvc-x86_64.exe" + +function Test-Command($cmd) { + return [bool](Get-Command $cmd -ErrorAction SilentlyContinue) +} + +# Validate working directory +if ((Split-Path -Leaf (Get-Location)) -ne $AppName) { + Write-Error "This script must be run from the $AppName directory." + exit 1 +} + +# Validate toolchain dependencies +if (-not (Test-Command cargo)) { + Write-Error "cargo not found. Please install Rust and add it to PATH." + exit 1 +} + +if (-not (Test-Command 7z)) { + Write-Error "7z not found. Please install 7-Zip and add it to PATH." + exit 1 +} + +if (-not (Test-Command makensis)) { + Write-Error "makensis not found. Please install NSIS and add it to PATH." + exit 1 +} + +# Clean previous build artifacts +$pathsToClean = @($AppRoot, $DistDir, "windows/$DistDir", $PortableName, $InstallerName) +foreach ($p in $pathsToClean) { + if (Test-Path $p) { + Remove-Item -Recurse -Force $p + } +} + +# Build release binaries +Write-Host "---- Building release binaries" +cargo build --release --bins + +# Prepare distribution files +Write-Host "---- Preparing distribution files" +New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null +Copy-Item "target/release/wsrx.exe" "$AppRoot/wsrx.exe" +Copy-Item "target/release/wsrx-desktop.exe" "$AppRoot/wsrx-desktop.exe" + +# Optional: include Visual C++ Redistributable installer so setup.nsi can run it. +# Download from: https://aka.ms/vs/17/release/vc_redist.x64.exe +if (Test-Path "windows/vc_redist.x64.exe") { + Copy-Item "windows/vc_redist.x64.exe" "$AppRoot/vc_redist.x64.exe" +} else { + Write-Warning "windows/vc_redist.x64.exe not found. The NSIS installer will fail to install the VC++ redistributable. You can download it from https://aka.ms/vs/17/release/vc_redist.x64.exe" +} + +Rename-Item $AppRoot $DistDir + +# Create portable package +Write-Host "---- Creating portable package" +7z a $PortableName $DistDir + +# Create installer +Write-Host "---- Creating installer" +Move-Item $DistDir "windows/$DistDir" +Copy-Item "windows/$AppName.ico" "windows/$DistDir/$AppName.ico" +makensis "windows/setup.nsi" + +# Move installer to project root +Get-ChildItem "windows/*.exe" | Move-Item -Destination "./$InstallerName" + +Write-Host "---- Done" +Write-Host "Portable package: $PortableName" +Write-Host "Installer: $InstallerName"