AI models shared registry + Code cleanup + Better async handling + Some fixes, etc
This commit is contained in:
parent
b427eacf35
commit
5c3031c977
62 changed files with 1683 additions and 1239 deletions
|
|
@ -13,12 +13,11 @@ enum VadState {
|
|||
VoiceActive,
|
||||
}
|
||||
|
||||
pub fn start(text_cmd_rx: Receiver<String>) -> Result<(), ()> {
|
||||
main_loop(text_cmd_rx)
|
||||
pub fn start(text_cmd_rx: Receiver<String>, rt: &tokio::runtime::Runtime) -> Result<(), ()> {
|
||||
main_loop(text_cmd_rx, rt)
|
||||
}
|
||||
|
||||
fn main_loop(text_cmd_rx: Receiver<String>) -> Result<(), ()> {
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
fn main_loop(text_cmd_rx: Receiver<String>, rt: &tokio::runtime::Runtime) -> Result<(), ()> {
|
||||
let frame_length: usize = 512;
|
||||
let sample_rate: usize = 16000;
|
||||
let mut frame_buffer: Vec<i16> = vec![0; frame_length];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use jarvis_core::slots;
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
|
|
@ -8,7 +7,7 @@ use std::sync::mpsc;
|
|||
use jarvis_core::{
|
||||
audio, audio_processing, commands, config, db, listener, recorder, stt, intent,
|
||||
ipc::{self, IpcAction},
|
||||
i18n, voices,
|
||||
i18n, voices, models,
|
||||
APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB,
|
||||
};
|
||||
|
||||
|
|
@ -39,41 +38,39 @@ fn main() -> Result<(), String> {
|
|||
info!("Config directory is: {}", APP_CONFIG_DIR.get().unwrap().display());
|
||||
info!("Log directory is: {}", APP_LOG_DIR.get().unwrap().display());
|
||||
|
||||
// initialize database (settings)
|
||||
DB.set(Arc::new(RwLock::new(db::init_settings())))
|
||||
// initialize settings
|
||||
let settings = db::init();
|
||||
|
||||
// set global DB (for core modules that read settings at init time)
|
||||
DB.set(settings.arc().clone())
|
||||
.expect("DB already initialized");
|
||||
|
||||
// init voices
|
||||
let voice_id = DB.get().unwrap().read().voice.clone();
|
||||
if let Err(e) = voices::init(&voice_id) {
|
||||
let voice_id = settings.lock().voice.clone();
|
||||
let language = settings.lock().language.clone();
|
||||
if let Err(e) = voices::init(&voice_id, &language) {
|
||||
warn!("Failed to init voices: {}", e);
|
||||
}
|
||||
|
||||
// init i18n
|
||||
i18n::init(&DB.get().unwrap().read().language);
|
||||
|
||||
// initialize tray
|
||||
// @TODO. macOS currently not supported for tray functionality,
|
||||
// due to the separate thread in which tray processing works,
|
||||
// but macOS requires it to be processed in the main thread only
|
||||
// The solution may be to include wake-word detection etc. in the winit event loop. (only for MacOS, though?)
|
||||
//#[cfg(not(target_os = "macos"))]
|
||||
//tray::init();
|
||||
i18n::init(&settings.lock().language);
|
||||
|
||||
// init recorder
|
||||
if recorder::init().is_err() {
|
||||
app::close(1);
|
||||
}
|
||||
|
||||
// init models registry (scans available AI models)
|
||||
if let Err(e) = models::init() {
|
||||
warn!("Models registry init failed: {}", e);
|
||||
}
|
||||
|
||||
// init stt engine
|
||||
if stt::init().is_err() {
|
||||
// @TODO. Allow continuing even without STT, if commands is using keywords or smthng?
|
||||
app::close(1); // cannot continue without stt
|
||||
}
|
||||
|
||||
// init tts engine
|
||||
// none for now (Silero-rs coming)
|
||||
|
||||
// init commands
|
||||
info!("Initializing commands.");
|
||||
let cmds = match commands::parse_commands() {
|
||||
|
|
@ -93,12 +90,17 @@ fn main() -> Result<(), String> {
|
|||
}
|
||||
|
||||
// init wake-word engine
|
||||
if listener::init().is_err() {
|
||||
app::close(1); // cannot continue without wake-word engine
|
||||
if let Err(e) = listener::init() {
|
||||
error!("Wake-word engine init failed: {}", e);
|
||||
app::close(1);
|
||||
}
|
||||
|
||||
// shared async runtime for intent classification, IPC, etc.
|
||||
let rt = Arc::new(
|
||||
tokio::runtime::Runtime::new().expect("Failed to create tokio runtime")
|
||||
);
|
||||
|
||||
// init intent-recognition engine
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
rt.block_on(async {
|
||||
if let Err(e) = intent::init(COMMANDS_LIST.get().unwrap()).await {
|
||||
error!("Failed to initialize intent classifier: {}", e);
|
||||
|
|
@ -149,22 +151,23 @@ fn main() -> Result<(), String> {
|
|||
}
|
||||
});
|
||||
|
||||
// start WebSocket server for ipc
|
||||
std::thread::spawn(|| {
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime for IPC");
|
||||
rt.block_on(ipc::start_server());
|
||||
// start WebSocket server on the shared runtime
|
||||
let ipc_rt = Arc::clone(&rt);
|
||||
std::thread::spawn(move || {
|
||||
ipc_rt.block_on(ipc::start_server());
|
||||
});
|
||||
|
||||
// start the app (in the background thread)
|
||||
std::thread::spawn(|| {
|
||||
let _ = app::start(text_cmd_rx);
|
||||
let app_rt = Arc::clone(&rt);
|
||||
std::thread::spawn(move || {
|
||||
let _ = app::start(text_cmd_rx, &app_rt);
|
||||
});
|
||||
|
||||
tray::init_blocking();
|
||||
tray::init_blocking(settings);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_stop() -> bool {
|
||||
SHOULD_STOP.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,84 +1,64 @@
|
|||
mod menu;
|
||||
|
||||
use tray_icon::{
|
||||
menu::{AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem},
|
||||
TrayIconBuilder, TrayIconEvent,
|
||||
menu::MenuEvent,
|
||||
TrayIconBuilder,
|
||||
};
|
||||
use winit::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use image;
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(target_os="windows")]
|
||||
use winit::platform::windows::EventLoopBuilderExtWindows;
|
||||
|
||||
use jarvis_core::{config, i18n, ipc::{self, IpcEvent}};
|
||||
use jarvis_core::{config, i18n, voices, ipc::{self, IpcEvent}, SettingsManager};
|
||||
|
||||
const TRAY_ICON_BYTES: &[u8] = include_bytes!("../../../resources/icons/32x32.png");
|
||||
|
||||
pub fn init_blocking() {
|
||||
// load tray icon
|
||||
//let icon_path = format!("{}/../../resources/icons/{}", env!("CARGO_MANIFEST_DIR"), config::TRAY_ICON);
|
||||
//let icon = load_icon(std::path::Path::new(&icon_path));
|
||||
pub fn init_blocking(settings: SettingsManager) {
|
||||
let icon = load_icon_from_bytes(TRAY_ICON_BYTES);
|
||||
|
||||
// form tray menu
|
||||
// let tray_menu = Menu::with_items(&[
|
||||
// &MenuItem::new("Перезапуск", true, None),
|
||||
// &MenuItem::new("Настройки", true, None),
|
||||
// &MenuItem::new("Выход", true, None),
|
||||
// ])
|
||||
// .unwrap();
|
||||
|
||||
let tray_menu = Menu::with_items(&[
|
||||
&MenuItem::with_id("restart", i18n::t("tray-restart"), true, None),
|
||||
&MenuItem::with_id("settings", i18n::t("tray-settings"), true, None),
|
||||
&MenuItem::with_id("exit", i18n::t("tray-exit"), true, None),
|
||||
]).unwrap();
|
||||
// build menu with settings submenus
|
||||
let tray_menu = menu::build(&settings);
|
||||
let menu::TrayMenu { menu, state: tray_state } = tray_menu;
|
||||
|
||||
let _tray_icon = TrayIconBuilder::new()
|
||||
.with_menu(Box::new(tray_menu))
|
||||
.with_menu(Box::new(menu))
|
||||
.with_tooltip(i18n::t("tray-tooltip"))
|
||||
.with_icon(icon)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let menu_channel = MenuEvent::receiver();
|
||||
// let tray_channel = TrayIconEvent::receiver();
|
||||
|
||||
// @TODO: Test on Linux
|
||||
// We need gtk for the tray icon to show up, we need to initialize gtk and create the tray_icon
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
gtk::init().unwrap();
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||
if let Ok(event) = menu_channel.try_recv() {
|
||||
handle_menu_event(&event);
|
||||
handle_menu_event(&event, &settings, &tray_state);
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
gtk::main();
|
||||
}
|
||||
|
||||
// @TODO: Test on MacOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// macOS needs proper run loop - tao or winit on main thread
|
||||
use winit::event_loop::{EventLoop, ControlFlow};
|
||||
let event_loop = EventLoop::new().unwrap();
|
||||
event_loop.run(move |_event, elwt| {
|
||||
elwt.set_control_flow(ControlFlow::Wait);
|
||||
if let Ok(event) = menu_channel.try_recv() {
|
||||
handle_menu_event(&event);
|
||||
handle_menu_event(&event, &settings, &tray_state);
|
||||
}
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// simple polling works on Windows
|
||||
loop {
|
||||
if let Ok(event) = menu_channel.try_recv() {
|
||||
handle_menu_event(&event);
|
||||
handle_menu_event(&event, &settings, &tray_state);
|
||||
}
|
||||
|
||||
// pump Windows messages
|
||||
|
|
@ -101,8 +81,65 @@ pub fn init_blocking() {
|
|||
info!("Tray initialized.");
|
||||
}
|
||||
|
||||
fn handle_menu_event(event: &MenuEvent) {
|
||||
match event.id.0.as_str() {
|
||||
fn handle_menu_event(event: &MenuEvent, settings: &SettingsManager, tray_state: &menu::TrayState) {
|
||||
let id = event.id.0.as_str();
|
||||
|
||||
// -- radio group: "set:key:value"
|
||||
if let Some(rest) = id.strip_prefix("set:") {
|
||||
if let Some((key, value)) = rest.split_once(':') {
|
||||
match settings.write(key, value) {
|
||||
Ok(()) => {
|
||||
info!("Tray: {} = {}", key, value);
|
||||
|
||||
// update check marks in the radio group
|
||||
for group in &tray_state.radio_groups {
|
||||
if group.setting_key == key {
|
||||
group.select(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// apply side effects
|
||||
match key {
|
||||
"language" => {
|
||||
i18n::set_language(value);
|
||||
}
|
||||
"assistant_voice" => {
|
||||
voices::set_current_voice(value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Tray: failed to set {} = {}: {}", key, value, e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// -- toggle: "toggle:key"
|
||||
if let Some(key) = id.strip_prefix("toggle:") {
|
||||
match key {
|
||||
"gain_normalizer" => {
|
||||
// CheckMenuItem auto-toggles on click, just read the new state
|
||||
let new_val = tray_state.gain_toggle.is_checked();
|
||||
let val_str = if new_val { "true" } else { "false" };
|
||||
if let Err(e) = settings.write(key, val_str) {
|
||||
warn!("Tray: failed to toggle {}: {}", key, e);
|
||||
// revert visual state on error
|
||||
tray_state.gain_toggle.set_checked(!new_val);
|
||||
} else {
|
||||
info!("Tray: {} = {}", key, val_str);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// -- action items
|
||||
match id {
|
||||
"exit" => std::process::exit(0),
|
||||
"restart" => {
|
||||
info!("Restarting from tray menu...");
|
||||
|
|
@ -116,6 +153,8 @@ fn handle_menu_event(event: &MenuEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
// HELPERS
|
||||
|
||||
fn load_icon_from_bytes(bytes: &[u8]) -> tray_icon::Icon {
|
||||
let image = image::load_from_memory(bytes)
|
||||
.expect("Failed to load icon")
|
||||
|
|
@ -125,20 +164,7 @@ fn load_icon_from_bytes(bytes: &[u8]) -> tray_icon::Icon {
|
|||
tray_icon::Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
|
||||
}
|
||||
|
||||
fn load_icon(path: &std::path::Path) -> tray_icon::Icon {
|
||||
let (icon_rgba, icon_width, icon_height) = {
|
||||
let image = image::open(path)
|
||||
.expect("Failed to open icon path")
|
||||
.into_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
let rgba = image.into_raw();
|
||||
(rgba, width, height)
|
||||
};
|
||||
tray_icon::Icon::from_rgba(icon_rgba, icon_width, icon_height).expect("Failed to open icon")
|
||||
}
|
||||
|
||||
fn restart_app() {
|
||||
// get current executable path
|
||||
let exe_path = match std::env::current_exe() {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
|
|
@ -147,7 +173,6 @@ fn restart_app() {
|
|||
}
|
||||
};
|
||||
|
||||
// spawn new instance
|
||||
match Command::new(&exe_path).spawn() {
|
||||
Ok(_) => {
|
||||
info!("Spawned new instance, exiting current...");
|
||||
|
|
@ -160,13 +185,10 @@ fn restart_app() {
|
|||
}
|
||||
|
||||
fn open_settings() {
|
||||
// check if jarvis-gui is connected via IPC
|
||||
if ipc::has_clients() {
|
||||
// gui is running, send reveal event
|
||||
info!("GUI is connected, sending reveal event");
|
||||
ipc::send(IpcEvent::RevealWindow);
|
||||
} else {
|
||||
// gui not running, launch it
|
||||
info!("GUI not connected, launching jarvis-gui");
|
||||
launch_gui();
|
||||
}
|
||||
|
|
@ -181,7 +203,6 @@ fn launch_gui() {
|
|||
}
|
||||
};
|
||||
|
||||
// jarvis-gui should be in same directory as jarvis-app
|
||||
let gui_path = exe_path.parent()
|
||||
.map(|p| p.join(get_gui_executable_name()))
|
||||
.unwrap_or_else(|| get_gui_executable_name().into());
|
||||
|
|
@ -189,12 +210,8 @@ fn launch_gui() {
|
|||
info!("Launching GUI: {:?}", gui_path);
|
||||
|
||||
match Command::new(&gui_path).spawn() {
|
||||
Ok(_) => {
|
||||
info!("Launched jarvis-gui");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to launch jarvis-gui: {}", e);
|
||||
}
|
||||
Ok(_) => info!("Launched jarvis-gui"),
|
||||
Err(e) => error!("Failed to launch jarvis-gui: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,4 +223,4 @@ fn get_gui_executable_name() -> &'static str {
|
|||
#[cfg(not(target_os = "windows"))]
|
||||
fn get_gui_executable_name() -> &'static str {
|
||||
"jarvis-gui"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,182 @@
|
|||
pub enum TrayMenuItem {
|
||||
Restart,
|
||||
Settings,
|
||||
Exit,
|
||||
use tray_icon::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
use jarvis_core::{i18n, voices, SettingsManager};
|
||||
use jarvis_core::config::structs::{WakeWordEngine, NoiseSuppressionBackend};
|
||||
|
||||
// RADIO GROUP
|
||||
|
||||
// a group of check menu items where only one can be active at a time.
|
||||
// stores (menu_item, setting_value) pairs.
|
||||
pub struct RadioGroup {
|
||||
pub setting_key: String,
|
||||
pub items: Vec<(CheckMenuItem, String)>,
|
||||
}
|
||||
|
||||
impl TrayMenuItem {
|
||||
pub fn label(&self) -> &str {
|
||||
match *self {
|
||||
TrayMenuItem::Restart => "Перезапустить",
|
||||
TrayMenuItem::Settings => "Настройки",
|
||||
TrayMenuItem::Exit => "Выход",
|
||||
impl RadioGroup {
|
||||
pub fn select(&self, value: &str) {
|
||||
for (item, val) in &self.items {
|
||||
item.set_checked(val == value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TRAY MENU STATE
|
||||
|
||||
pub struct TrayMenu {
|
||||
pub menu: Menu,
|
||||
pub state: TrayState,
|
||||
}
|
||||
|
||||
// holds references to menu items for updating check marks after build
|
||||
pub struct TrayState {
|
||||
pub radio_groups: Vec<RadioGroup>,
|
||||
pub gain_toggle: CheckMenuItem,
|
||||
}
|
||||
|
||||
// BUILD
|
||||
|
||||
pub fn build(settings: &SettingsManager) -> TrayMenu {
|
||||
let menu = Menu::new();
|
||||
|
||||
let mut radio_groups = Vec::new();
|
||||
|
||||
// -- language submenu
|
||||
let lang_sub = Submenu::new(i18n::t("tray-language"), true);
|
||||
let current_lang = settings.read("language").unwrap_or_default();
|
||||
let mut lang_items = Vec::new();
|
||||
for &lang in i18n::SUPPORTED_LANGUAGES {
|
||||
let label = match lang {
|
||||
"ru" => "Русский",
|
||||
"en" => "English",
|
||||
"ua" => "Українська",
|
||||
_ => lang,
|
||||
};
|
||||
let item = CheckMenuItem::with_id(
|
||||
format!("set:language:{}", lang),
|
||||
label,
|
||||
true,
|
||||
lang == current_lang,
|
||||
None,
|
||||
);
|
||||
let _ = lang_sub.append(&item);
|
||||
lang_items.push((item, lang.to_string()));
|
||||
}
|
||||
radio_groups.push(RadioGroup {
|
||||
setting_key: "language".to_string(),
|
||||
items: lang_items,
|
||||
});
|
||||
|
||||
// -- voice submenu
|
||||
let voice_sub = Submenu::new(i18n::t("tray-voice"), true);
|
||||
let current_voice = voices::get_current_voice()
|
||||
.map(|v| v.voice.id.clone())
|
||||
.unwrap_or_default();
|
||||
let mut voice_items = Vec::new();
|
||||
for voice in voices::list_voices() {
|
||||
let item = CheckMenuItem::with_id(
|
||||
format!("set:assistant_voice:{}", voice.voice.id),
|
||||
&voice.voice.name,
|
||||
true,
|
||||
voice.voice.id == current_voice,
|
||||
None,
|
||||
);
|
||||
let _ = voice_sub.append(&item);
|
||||
voice_items.push((item, voice.voice.id.clone()));
|
||||
}
|
||||
radio_groups.push(RadioGroup {
|
||||
setting_key: "assistant_voice".to_string(),
|
||||
items: voice_items,
|
||||
});
|
||||
|
||||
// -- wake word engine submenu
|
||||
let ww_sub = Submenu::new(i18n::t("tray-wake-word"), true);
|
||||
let current_ww = settings.read("selected_wake_word_engine").unwrap_or_default();
|
||||
let mut ww_items = Vec::new();
|
||||
for (label, value) in &[("Rustpotter", "Rustpotter"), ("Vosk", "Vosk")] {
|
||||
let item = CheckMenuItem::with_id(
|
||||
format!("set:selected_wake_word_engine:{}", value.to_lowercase()),
|
||||
*label,
|
||||
true,
|
||||
current_ww == *label,
|
||||
None,
|
||||
);
|
||||
let _ = ww_sub.append(&item);
|
||||
ww_items.push((item, value.to_lowercase()));
|
||||
}
|
||||
radio_groups.push(RadioGroup {
|
||||
setting_key: "selected_wake_word_engine".to_string(),
|
||||
items: ww_items,
|
||||
});
|
||||
|
||||
// -- noise suppression submenu
|
||||
let ns_sub = Submenu::new(i18n::t("tray-noise-suppression"), true);
|
||||
let current_ns = settings.read("noise_suppression").unwrap_or_default();
|
||||
let mut ns_items = Vec::new();
|
||||
for (label, value) in &[("None", "none"), ("Nnnoiseless", "nnnoiseless")] {
|
||||
let item = CheckMenuItem::with_id(
|
||||
format!("set:noise_suppression:{}", value),
|
||||
*label,
|
||||
true,
|
||||
current_ns.to_lowercase() == *value,
|
||||
None,
|
||||
);
|
||||
let _ = ns_sub.append(&item);
|
||||
ns_items.push((item, value.to_string()));
|
||||
}
|
||||
radio_groups.push(RadioGroup {
|
||||
setting_key: "noise_suppression".to_string(),
|
||||
items: ns_items,
|
||||
});
|
||||
|
||||
// -- vad submenu
|
||||
let vad_sub = Submenu::new(i18n::t("tray-vad"), true);
|
||||
let current_vad = settings.read("vad_backend").unwrap_or_default();
|
||||
let mut vad_items = Vec::new();
|
||||
for (label, value) in &[("None", "none"), ("Energy", "energy"), ("Nnnoiseless", "nnnoiseless")] {
|
||||
let item = CheckMenuItem::with_id(
|
||||
format!("set:vad_backend:{}", value),
|
||||
*label,
|
||||
true,
|
||||
current_vad == *value,
|
||||
None,
|
||||
);
|
||||
let _ = vad_sub.append(&item);
|
||||
vad_items.push((item, value.to_string()));
|
||||
}
|
||||
radio_groups.push(RadioGroup {
|
||||
setting_key: "vad_backend".to_string(),
|
||||
items: vad_items,
|
||||
});
|
||||
|
||||
// -- gain normalizer toggle
|
||||
let gain_on = settings.read("gain_normalizer")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(true);
|
||||
let gain_toggle = CheckMenuItem::with_id(
|
||||
"toggle:gain_normalizer",
|
||||
i18n::t("tray-gain-normalizer"),
|
||||
true,
|
||||
gain_on,
|
||||
None,
|
||||
);
|
||||
|
||||
// -- assemble main menu
|
||||
let _ = menu.append(&lang_sub);
|
||||
let _ = menu.append(&voice_sub);
|
||||
let _ = menu.append(&ww_sub);
|
||||
let _ = menu.append(&ns_sub);
|
||||
let _ = menu.append(&vad_sub);
|
||||
let _ = menu.append(&gain_toggle);
|
||||
let _ = menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = menu.append(&MenuItem::with_id("restart", i18n::t("tray-restart"), true, None));
|
||||
let _ = menu.append(&MenuItem::with_id("settings", i18n::t("tray-settings"), true, None));
|
||||
let _ = menu.append(&MenuItem::with_id("exit", i18n::t("tray-exit"), true, None));
|
||||
|
||||
TrayMenu {
|
||||
menu,
|
||||
state: TrayState {
|
||||
radio_groups,
|
||||
gain_toggle,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue