@@ -19,7 +19,7 @@ use tauri_specta::{Builder, Event, collect_commands, collect_events};
1919
2020use kasumi_backend:: platform:: Platform ;
2121use kasumi_backend:: { Command , Response , Service } ;
22- use kasumi_core:: contract:: { PushFrame , ServiceStatus , SubAppliedEvent } ;
22+ use kasumi_core:: contract:: { PushFrame , RunState , ServiceStatus , SubAppliedEvent } ;
2323
2424pub mod defaults;
2525pub mod desktop;
@@ -35,8 +35,9 @@ pub struct StatusChanged(pub ServiceStatus);
3535#[ derive( Debug , Clone , Serialize , Deserialize , specta:: Type , Event ) ]
3636pub struct SubscriptionApplied ( pub SubAppliedEvent ) ;
3737
38- /// A tray menu action for the webview to handle: `"restart"` / `"start"` / `"stop"` or
39- /// `"activate:<id>"`. `show`/`quit` never reach here — they're handled in Rust directly.
38+ /// A tray menu action for the webview to handle: `"restart"` / `"start"` / `"stop"`,
39+ /// `"activate:<id>"`, or `"routing:<mode>"`. `show`/`quit` never reach here — they're
40+ /// handled in Rust directly.
4041#[ derive( Debug , Clone , Serialize , Deserialize , specta:: Type , Event ) ]
4142pub struct TrayAction ( pub String ) ;
4243
@@ -59,6 +60,11 @@ pub struct TrayLabels {
5960 pub stop : String ,
6061 pub restart : String ,
6162 pub recent : String ,
63+ /// "Routing mode" submenu title, then its three radio entries.
64+ pub routing : String ,
65+ pub routing_global : String ,
66+ pub routing_custom : String ,
67+ pub routing_rules : String ,
6268}
6369
6470/// Rebuild the tray menu from the UI's current profiles + active selection. The UI
@@ -72,14 +78,44 @@ fn update_tray(
7278 labels : TrayLabels ,
7379 running : bool ,
7480 connected : bool ,
81+ routing_mode : String ,
7582) -> Result < ( ) , String > {
7683 #[ cfg( desktop) ]
7784 {
78- rebuild_tray_menu ( & app, & profiles, & labels, running, connected)
85+ rebuild_tray_menu ( & app, & profiles, & labels, running, connected, & routing_mode )
7986 . map_err ( |e| e. to_string ( ) ) ?;
8087 }
8188 #[ cfg( not( desktop) ) ]
82- let _ = ( app, profiles, labels, running, connected) ;
89+ let _ = ( app, profiles, labels, running, connected, routing_mode) ;
90+ Ok ( ( ) )
91+ }
92+
93+ /// Update only the tray tooltip + state icon (not the menu). Called on every status
94+ /// tick, so it stays cheap: the menu is rebuilt separately via [`update_tray`] only
95+ /// when its own contents change. (Tooltips are honoured on Windows/macOS; the Linux
96+ /// app-indicator ignores them, but the state icon still updates there.)
97+ #[ tauri:: command]
98+ #[ specta:: specta]
99+ fn set_tray_status ( app : tauri:: AppHandle , tooltip : String , state : RunState ) -> Result < ( ) , String > {
100+ #[ cfg( desktop) ]
101+ if let Some ( tray) = app. tray_by_id ( "main" ) {
102+ // Tooltip is cheap, refresh it every tick (live traffic). The icon only
103+ // changes on a state transition — swapping it each tick would needlessly
104+ // rewrite the Linux app-indicator's temp icon file.
105+ tray. set_tooltip ( Some ( & tooltip) )
106+ . map_err ( |e| e. to_string ( ) ) ?;
107+ use std:: sync:: atomic:: { AtomicU8 , Ordering } ;
108+ static LAST_ICON : AtomicU8 = AtomicU8 :: new ( u8:: MAX ) ;
109+ let idx = state as u8 ;
110+ if LAST_ICON . load ( Ordering :: Relaxed ) != idx
111+ && let Some ( icon) = tray_icon ( state)
112+ {
113+ tray. set_icon ( Some ( icon) ) . map_err ( |e| e. to_string ( ) ) ?;
114+ LAST_ICON . store ( idx, Ordering :: Relaxed ) ;
115+ }
116+ }
117+ #[ cfg( not( desktop) ) ]
118+ let _ = ( app, tooltip, state) ;
83119 Ok ( ( ) )
84120}
85121
@@ -186,15 +222,17 @@ fn setup_tray(app: &tauri::App) -> tauri::Result<()> {
186222 Ok ( ( ) )
187223}
188224
189- /// Replace the tray menu: Restart, the recent-profile quick-switch list (active one
190- /// checked), then Show / Quit. Item ids drive [`setup_tray`]'s `on_menu_event`.
225+ /// Replace the tray menu: state action(s), the recent-profile quick-switch list
226+ /// (active one checked), the routing-mode radio (current one checked), then
227+ /// Show / Quit. Item ids drive [`setup_tray`]'s `on_menu_event`.
191228#[ cfg( desktop) ]
192229fn rebuild_tray_menu (
193230 app : & tauri:: AppHandle ,
194231 profiles : & [ TrayProfile ] ,
195232 labels : & TrayLabels ,
196233 running : bool ,
197234 connected : bool ,
235+ routing_mode : & str ,
198236) -> tauri:: Result < ( ) > {
199237 use tauri:: menu:: { CheckMenuItem , Menu , MenuItem , PredefinedMenuItem , Submenu } ;
200238
@@ -221,8 +259,9 @@ fn rebuild_tray_menu(
221259 menu. append ( & start) ?;
222260 }
223261
262+ // Quick-switch + routing group.
263+ menu. append ( & sep1) ?;
224264 if !profiles. is_empty ( ) {
225- menu. append ( & sep1) ?;
226265 // A submenu keeps the root tidy when there are many profiles.
227266 let recent = Submenu :: with_id ( app, "recent" , & labels. recent , true ) ?;
228267 for p in profiles {
@@ -239,13 +278,84 @@ fn rebuild_tray_menu(
239278 menu. append ( & recent) ?;
240279 }
241280
281+ // Routing-mode radio: exactly the current mode is checked. Tauri has no native
282+ // radio item, so — like v2rayN's tray — we use check marks; clicks come back as
283+ // `routing:<mode>` TrayActions the webview applies to `settings.routingMode`.
284+ let routing = Submenu :: with_id ( app, "routing" , & labels. routing , true ) ?;
285+ for ( mode, label) in [
286+ ( "rules" , & labels. routing_rules ) ,
287+ ( "global" , & labels. routing_global ) ,
288+ ( "custom" , & labels. routing_custom ) ,
289+ ] {
290+ let item = CheckMenuItem :: with_id (
291+ app,
292+ format ! ( "routing:{mode}" ) ,
293+ label,
294+ true ,
295+ routing_mode == mode,
296+ None :: < & str > ,
297+ ) ?;
298+ routing. append ( & item) ?;
299+ }
300+ menu. append ( & routing) ?;
301+
242302 menu. append ( & sep2) ?;
243303 menu. append ( & show) ?;
244304 menu. append ( & quit) ?;
245305 tray. set_menu ( Some ( menu) ) ?;
246306 Ok ( ( ) )
247307}
248308
309+ /// A tray icon for the current state, derived at runtime from the bundled app icon so
310+ /// we ship no extra art: full colour when connected, desaturated + dimmed when off,
311+ /// and a muted tone while connecting / no-internet. Each variant is decoded and
312+ /// converted once, then cached. `None` if the bundled icon won't decode — the caller
313+ /// keeps whatever icon the tray already has rather than taking the process down.
314+ #[ cfg( desktop) ]
315+ fn tray_icon ( state : RunState ) -> Option < tauri:: image:: Image < ' static > > {
316+ use std:: sync:: OnceLock ;
317+ use tauri:: image:: Image ;
318+
319+ const BASE_PNG : & [ u8 ] =
320+ include_bytes ! ( concat!( env!( "CARGO_MANIFEST_DIR" ) , "/icons/128x128.png" ) ) ;
321+
322+ fn base ( ) -> Option < & ' static Image < ' static > > {
323+ static ICON : OnceLock < Option < Image < ' static > > > = OnceLock :: new ( ) ;
324+ ICON . get_or_init ( || Image :: from_bytes ( BASE_PNG ) . ok ( ) )
325+ . as_ref ( )
326+ }
327+ // Blend each pixel toward its luminance by `1 - sat` (0 = greyscale) and scale
328+ // alpha by `alpha` (dim it). The bundled icon is always decoded to RGBA8.
329+ fn recolour ( sat : f32 , alpha : f32 ) -> Option < Image < ' static > > {
330+ let src = base ( ) ?;
331+ let ( w, h) = ( src. width ( ) , src. height ( ) ) ;
332+ let mut rgba = src. rgba ( ) . to_vec ( ) ;
333+ for px in rgba. chunks_exact_mut ( 4 ) {
334+ let ( r, g, b) = ( px[ 0 ] as f32 , px[ 1 ] as f32 , px[ 2 ] as f32 ) ;
335+ let lum = 0.299 * r + 0.587 * g + 0.114 * b;
336+ px[ 0 ] = ( r * sat + lum * ( 1.0 - sat) ) . round ( ) as u8 ;
337+ px[ 1 ] = ( g * sat + lum * ( 1.0 - sat) ) . round ( ) as u8 ;
338+ px[ 2 ] = ( b * sat + lum * ( 1.0 - sat) ) . round ( ) as u8 ;
339+ px[ 3 ] = ( f32:: from ( px[ 3 ] ) * alpha) . round ( ) as u8 ;
340+ }
341+ Some ( Image :: new_owned ( rgba, w, h) )
342+ }
343+ fn off ( ) -> Option < & ' static Image < ' static > > {
344+ static ICON : OnceLock < Option < Image < ' static > > > = OnceLock :: new ( ) ;
345+ ICON . get_or_init ( || recolour ( 0.0 , 0.55 ) ) . as_ref ( )
346+ }
347+ fn pending ( ) -> Option < & ' static Image < ' static > > {
348+ static ICON : OnceLock < Option < Image < ' static > > > = OnceLock :: new ( ) ;
349+ ICON . get_or_init ( || recolour ( 0.4 , 0.9 ) ) . as_ref ( )
350+ }
351+
352+ match state {
353+ RunState :: Connected => base ( ) . cloned ( ) ,
354+ RunState :: Connecting | RunState :: NoInternet => pending ( ) . cloned ( ) ,
355+ RunState :: Stopped | RunState :: Failed => off ( ) . cloned ( ) ,
356+ }
357+ }
358+
249359/// Build the desktop [`Service`] (boot init → probe cores → background loops). Run
250360/// on the app's async runtime during setup.
251361async fn build_service ( platform : Arc < dyn Platform > ) -> Arc < Service > {
@@ -306,7 +416,12 @@ async fn build_platform() -> anyhow::Result<Arc<dyn Platform>> {
306416/// export so the generated TS always matches what's mounted.
307417fn specta_builder ( ) -> Builder < tauri:: Wry > {
308418 Builder :: < tauri:: Wry > :: new ( )
309- . commands ( collect_commands ! [ app_version, dispatch, update_tray] )
419+ . commands ( collect_commands ! [
420+ app_version,
421+ dispatch,
422+ update_tray,
423+ set_tray_status
424+ ] )
310425 . events ( collect_events ! [
311426 StatusChanged ,
312427 SubscriptionApplied ,
0 commit comments