Skip to content

Commit d05f7a8

Browse files
Merge pull request #188 from loss-and-quick/feat/tray-context-menu-ux
feat(tray): routing switch, live tooltip/icon, ping in recent list
2 parents 007e896 + d09b349 commit d05f7a8

3 files changed

Lines changed: 203 additions & 26 deletions

File tree

frontend/src/generated/bindings.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ export const commands = {
2525
* calls this on hydrate and whenever the active/recent profiles or language
2626
* change; clicks come back as [`TrayAction`] events (or `show`/`quit`).
2727
*/
28-
updateTray: (profiles: TrayProfile[], labels: TrayLabels, running: boolean, connected: boolean) => typedError<null, string>(__TAURI_INVOKE("update_tray", { profiles, labels, running, connected })),
28+
updateTray: (profiles: TrayProfile[], labels: TrayLabels, running: boolean, connected: boolean, routingMode: string) => typedError<null, string>(__TAURI_INVOKE("update_tray", { profiles, labels, running, connected, routingMode })),
29+
/**
30+
* Update only the tray tooltip + state icon (not the menu). Called on every status
31+
* tick, so it stays cheap: the menu is rebuilt separately via [`update_tray`] only
32+
* when its own contents change. (Tooltips are honoured on Windows/macOS; the Linux
33+
* app-indicator ignores them, but the state icon still updates there.)
34+
*/
35+
setTrayStatus: (tooltip: string, state: RunState) => typedError<null, string>(__TAURI_INVOKE("set_tray_status", { tooltip, state })),
2936
};
3037

3138
/** Events */
@@ -921,8 +928,9 @@ export type Transport = {
921928
} & QuicTransport;
922929

923930
/**
924-
* A tray menu action for the webview to handle: `"restart"` / `"start"` / `"stop"` or
925-
* `"activate:<id>"`. `show`/`quit` never reach here — they're handled in Rust directly.
931+
* A tray menu action for the webview to handle: `"restart"` / `"start"` / `"stop"`,
932+
* `"activate:<id>"`, or `"routing:<mode>"`. `show`/`quit` never reach here — they're
933+
* handled in Rust directly.
926934
*/
927935
export type TrayAction = string;
928936

@@ -934,6 +942,11 @@ export type TrayLabels = {
934942
stop: string,
935943
restart: string,
936944
recent: string,
945+
/** "Routing mode" submenu title, then its three radio entries. */
946+
routing: string,
947+
routingGlobal: string,
948+
routingCustom: string,
949+
routingRules: string,
937950
};
938951

939952
/** One profile entry the UI wants in the tray's quick-switch list. */

frontend/src/lib/useTraySync.ts

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,73 @@
11
// ============================================================
22
// src/lib/useTraySync.ts
3-
// Keeps the native tray menu in sync with the UI: pushes the recent-profile
4-
// quick-switch list + localized labels to Rust (`update_tray`), and routes menu
5-
// clicks (`tray-action` events) back to the store. Desktop-only — a no-op in the
6-
// Android / browser shells.
3+
// Keeps the native tray in sync with the UI. Pushes the recent-profile quick-switch
4+
// list, the routing-mode radio state + localized labels to Rust (`update_tray`), and
5+
// separately pushes a live tooltip + state icon (`set_tray_status`) on every status
6+
// tick. Menu clicks come back as `tray-action` events and are routed to the store.
7+
// Desktop-only — a no-op in the Android / browser shells.
78
// ============================================================
89

910
import { useEffect } from "react";
10-
import { commands, events } from "../generated/bindings";
11+
import { commands, events, type RoutingMode_Serialize } from "../generated/bindings";
1112
import { useT } from "../i18n";
1213
import { isServiceUp } from "../lib/bridge";
14+
import { formatRate } from "../lib/format";
1315
import { useAppStore } from "../store/useAppStore";
1416

1517
function isTauri(): boolean {
1618
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
1719
}
1820

21+
// service.state -> the same label key the Overview header shows.
22+
function stateLabelKey(state: string) {
23+
switch (state) {
24+
case "connected":
25+
return "overview.running" as const;
26+
case "noInternet":
27+
return "overview.noInternet" as const;
28+
case "connecting":
29+
return "overview.connecting" as const;
30+
case "failed":
31+
return "overview.failed" as const;
32+
default:
33+
return "overview.stopped" as const;
34+
}
35+
}
36+
1937
export function useTraySync(): void {
2038
const t = useT();
2139
const profiles = useAppStore((s) => s.profiles);
2240
const activeId = useAppStore((s) => s.activeId);
2341
const recentIds = useAppStore((s) => s.recentProfileIds);
2442
const service = useAppStore((s) => s.service);
43+
const uploadRate = useAppStore((s) => s.uploadRate);
44+
const downloadRate = useAppStore((s) => s.downloadRate);
45+
const testResults = useAppStore((s) => s.testResults);
46+
const routingMode = useAppStore((s) => s.settings.routingMode);
2547
const setActive = useAppStore((s) => s.setActive);
48+
const setSetting = useAppStore((s) => s.setSetting);
2649
const toggleService = useAppStore((s) => s.toggleService);
2750
const restart = useAppStore((s) => s.restart);
2851

2952
const running = isServiceUp(service.state);
3053
const connected = service.state === "connected";
3154

32-
// Rebuild the menu whenever the quick-switch list, active profile, service
33-
// state, or language changes.
55+
// Rebuild the menu whenever the quick-switch list, active profile, per-profile
56+
// ping, routing mode, service state, or language changes.
3457
useEffect(() => {
3558
if (!isTauri()) return;
3659
const items = recentIds
3760
.map((id) => profiles.find((p) => p.meta.id === id))
3861
.filter((p): p is NonNullable<typeof p> => !!p)
39-
.map((p) => ({
40-
id: p.meta.id,
41-
name: p.meta.remarks || p.meta.id,
42-
active: p.meta.id === activeId,
43-
}));
62+
.map((p) => {
63+
const name = p.meta.remarks || p.meta.id;
64+
const ping = testResults[p.meta.id]?.ping;
65+
return {
66+
id: p.meta.id,
67+
name: ping != null && ping >= 0 ? `${name} · ${ping} ms` : name,
68+
active: p.meta.id === activeId,
69+
};
70+
});
4471
void commands.updateTray(
4572
items,
4673
{
@@ -50,11 +77,31 @@ export function useTraySync(): void {
5077
stop: t("overview.stop"),
5178
restart: t("overview.restart"),
5279
recent: t("tray.recent"),
80+
routing: t("settings.routingMode"),
81+
routingGlobal: t("settings.routingGlobal"),
82+
routingCustom: t("settings.routingCustom"),
83+
routingRules: t("settings.routingRulesEditor"),
5384
},
5485
running,
5586
connected,
87+
routingMode,
5688
);
57-
}, [profiles, activeId, recentIds, running, connected, t]);
89+
}, [profiles, activeId, recentIds, testResults, routingMode, running, connected, t]);
90+
91+
// Push a live tooltip + state icon on every status tick (cheap — no menu rebuild).
92+
useEffect(() => {
93+
if (!isTauri()) return;
94+
const active = profiles.find((p) => p.meta.id === activeId);
95+
const activeName = active ? active.meta.remarks || active.meta.id : null;
96+
const stateLabel = t(stateLabelKey(service.state));
97+
let tooltip = `Kasumi Proxy — ${stateLabel}`;
98+
if (activeName) tooltip += ` · ${activeName}`;
99+
if (running) tooltip += `\n↓ ${formatRate(downloadRate)}${formatRate(uploadRate)}`;
100+
// The routing submenu writes a setting the running core won't pick up on its own,
101+
// so the tooltip carries the same restart cue the Overview banner shows.
102+
if (running && service.pendingRestart) tooltip += `\n${t("overview.pendingRestart")}`;
103+
void commands.setTrayStatus(tooltip, service.state);
104+
}, [service, uploadRate, downloadRate, activeId, profiles, running, t]);
58105

59106
// Route native menu clicks back to the store.
60107
useEffect(() => {
@@ -64,7 +111,9 @@ export function useTraySync(): void {
64111
if (action === "start" || action === "stop") void toggleService();
65112
else if (action === "restart") void restart();
66113
else if (action.startsWith("activate:")) void setActive(action.slice("activate:".length));
114+
else if (action.startsWith("routing:"))
115+
void setSetting("routingMode", action.slice("routing:".length) as RoutingMode_Serialize);
67116
});
68117
return () => void pending.then((un) => un()).catch(() => {});
69-
}, [toggleService, restart, setActive]);
118+
}, [toggleService, restart, setActive, setSetting]);
70119
}

src-tauri/src/lib.rs

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use tauri_specta::{Builder, Event, collect_commands, collect_events};
1919

2020
use kasumi_backend::platform::Platform;
2121
use kasumi_backend::{Command, Response, Service};
22-
use kasumi_core::contract::{PushFrame, ServiceStatus, SubAppliedEvent};
22+
use kasumi_core::contract::{PushFrame, RunState, ServiceStatus, SubAppliedEvent};
2323

2424
pub mod defaults;
2525
pub mod desktop;
@@ -35,8 +35,9 @@ pub struct StatusChanged(pub ServiceStatus);
3535
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, Event)]
3636
pub 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)]
4142
pub 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)]
192229
fn 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.
251361
async 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.
307417
fn 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

Comments
 (0)