Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
684 changes: 606 additions & 78 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ slint = { version = "1.15", default-features = false, features = [
sys-locale = "0.3"
sysinfo = "0.38"
toml = "1.0"
tray-icon = "0.24"
image = { version = "0.25", default-features = false, features = ["png"] }
winit = "0.30"

# build dependencies
Expand Down
2 changes: 2 additions & 0 deletions crates/desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ slint = { workspace = true }
winit = { workspace = true }

directories = { workspace = true }
image = { workspace = true }
local-ip-address = { workspace = true }
open = { workspace = true }
sys-locale = { workspace = true }
sysinfo = { workspace = true }
tray-icon = { workspace = true }

async-compat = { workspace = true }
bitflags = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions crates/desktop/src/bridges/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod settings;
pub mod system_info;
pub mod tray;
pub mod ui_state;
pub mod window_control;

Expand All @@ -9,4 +10,5 @@ pub fn setup(window: &MainWindow) {
window_control::setup(window);
system_info::setup(window);
ui_state::setup(window);
tray::setup(window);
}
29 changes: 29 additions & 0 deletions crates/desktop/src/bridges/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,42 @@ 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();
let bridge = window.global::<SettingsBridge>();
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::<SettingsBridge>();
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);
});
}

Expand Down
190 changes: 190 additions & 0 deletions crates/desktop/src/bridges/tray.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
use std::cell::RefCell;

use i_slint_backend_winit::WinitWindowAccessor;
use slint::ComponentHandle;
use tray_icon::{
Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent,
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
};
use tracing::{debug, error};

use crate::ui::{MainWindow, SettingsBridge};

/// Holds the live tray icon together with the menu item ids we need to
/// match incoming [`MenuEvent`]s against. The icon is not `Send` (it has
/// thread affinity on Windows / macOS), so we keep it in a `thread_local`
/// that is only ever touched from the UI thread.
struct TrayState {
_icon: TrayIcon,
show_item_id: tray_icon::menu::MenuId,
quit_item_id: tray_icon::menu::MenuId,
}

thread_local! {
static TRAY: RefCell<Option<TrayState>> = const { RefCell::new(None) };
}

/// Decode the bundled application logo into the RGBA buffer expected by
/// [`tray_icon::Icon::from_rgba`]. Returns `None` on failure — the tray
/// will still be created, just without a custom icon.
fn load_icon() -> Option<Icon> {
let bytes: &[u8] = include_bytes!("../../ui/assets/logo.png");
match image::load_from_memory(bytes) {
Ok(img) => {
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
match Icon::from_rgba(rgba.into_raw(), width, height) {
Ok(icon) => Some(icon),
Err(e) => {
error!("Failed to build tray icon from RGBA data: {e}");
None
}
}
}
Err(e) => {
error!("Failed to decode embedded logo.png for tray icon: {e}");
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"),
}
}

/// Register the global tray / menu event handlers. Must be called once
/// during startup, before any tray icon is created.
pub fn setup(window: &MainWindow) {
let window_weak = window.as_weak();
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
let id = event.id;
let window_weak = window_weak.clone();
let _ = slint::invoke_from_event_loop(move || {
handle_menu_event(&window_weak, &id);
});
}));

let window_weak = window.as_weak();
TrayIconEvent::set_event_handler(Some(move |event: TrayIconEvent| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let window_weak = window_weak.clone();
let _ = slint::invoke_from_event_loop(move || {
show_window(&window_weak);
});
}
}));
}

fn handle_menu_event(window_weak: &slint::Weak<MainWindow>, id: &tray_icon::menu::MenuId) {
// 0 = show, 1 = quit
let action = TRAY.with(|t| {
t.borrow().as_ref().and_then(|state| {
if id == &state.show_item_id {
Some(0u8)
} else if id == &state.quit_item_id {
Some(1u8)
} else {
None
}
})
});

match action {
Some(0) => show_window(window_weak),
Some(1) => crate::launcher::shutdown(window_weak),
_ => {}
}
}

fn show_window(window_weak: &slint::Weak<MainWindow>) {
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();
});
}
}

/// 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::<SettingsBridge>().get_language().to_string();
let (show_label, quit_label) = localized_labels(&language);

let show_item = MenuItem::new(show_label, true, None);
let quit_item = MenuItem::new(quit_label, true, None);
let show_item_id = show_item.id().clone();
let quit_item_id = quit_item.id().clone();

let menu = Menu::new();
if let Err(e) = menu.append(&show_item) {
error!("Failed to append show item to tray menu: {e}");
}
if let Err(e) = menu.append(&PredefinedMenuItem::separator()) {
error!("Failed to append separator to tray menu: {e}");
}
if let Err(e) = menu.append(&quit_item) {
error!("Failed to append quit item to tray menu: {e}");
}

let mut builder = TrayIconBuilder::new()
.with_tooltip("WebSocket Reflector X")
.with_menu(Box::new(menu))
// Left-click should bring the window back instead of opening the menu;
// the menu is still reachable via right-click.
.with_menu_on_left_click(false);

if let Some(icon) = load_icon() {
builder = builder.with_icon(icon);
}

match builder.build() {
Ok(tray) => {
TRAY.with(|t| {
*t.borrow_mut() = Some(TrayState {
_icon: tray,
show_item_id,
quit_item_id,
});
});
debug!("System tray icon created");
}
Err(e) => {
error!("Failed to create system tray icon: {e}");
}
}
}

/// Remove the tray icon if it exists. Dropping the [`TrayIcon`] 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())
}
44 changes: 40 additions & 4 deletions crates/desktop/src/bridges/window_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<SettingsBridge>();
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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 7 additions & 3 deletions crates/desktop/src/daemon/api_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -468,8 +472,8 @@ async fn popup_window(
) -> Result<impl IntoResponse, (StatusCode, String)> {
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);
});
})
Expand Down
Loading
Loading