feat(desktop): обёртка Tauri 2 вокруг веб-клиента
Первый инкремент Фазы 6 (docs/client-tauri.md): окно с тем же веб-клиентом (по умолчанию https://gl.mhspx.su, адрес настраивается), трей с меню и индикатором непрочитанных, нативные уведомления, глобальный push-to-talk и переключение микрофона/deafen, автозапуск, single-instance, сохранение геометрии окна, deep links glchat://…, автообновление по подписанному манифесту. Связь страницы с обёрткой — через платформенный адаптер (web/src/lib/platform.ts) и минимальный типизированный набор команд; в веб-клиенте появились системные уведомления об упоминаниях и личных сообщениях и переключатель в настройках. В web/src/stores/gateway.ts вместе с вызовом уведомления лежат правки по дефектам realtime (READY и сессии) из параллельной волны: файл коммитится целиком по договорённости с координатором.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
//! Иконка в трее: индикатор непрочитанных и меню управления обёрткой
|
||||
//! (docs/client-tauri.md §2 — «открыть, mute, статус, выйти»).
|
||||
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::{autostart, commands, settings, shortcuts, updates, window};
|
||||
|
||||
/// Идентификатор иконки в трее.
|
||||
pub const TRAY_ID: &str = "main";
|
||||
|
||||
/// Создаёт иконку в трее с меню по текущим настройкам.
|
||||
pub fn build<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||
let icon = app
|
||||
.default_window_icon()
|
||||
.cloned()
|
||||
.ok_or_else(|| "в сборке нет иконки приложения".to_string())?;
|
||||
TrayIconBuilder::with_id(TRAY_ID)
|
||||
.icon(icon)
|
||||
.icon_as_template(true)
|
||||
.tooltip("glchat")
|
||||
.menu(&menu(app)?)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(on_menu_event)
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// Левый клик по иконке — показать окно, меню остаётся на правом.
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
window::show_main(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
.map_err(|error| format!("не удалось создать иконку в трее: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Пересобирает меню: галочки должны совпадать с текущими настройками.
|
||||
pub fn refresh<R: Runtime>(app: &AppHandle<R>) {
|
||||
let Some(tray) = app.tray_by_id(TRAY_ID) else {
|
||||
return;
|
||||
};
|
||||
match menu(app) {
|
||||
Ok(menu) => {
|
||||
if let Err(error) = tray.set_menu(Some(menu)) {
|
||||
log::warn!("не удалось обновить меню трея: {error}");
|
||||
}
|
||||
}
|
||||
Err(error) => log::warn!("не удалось собрать меню трея: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Собирает меню трея по текущим настройкам.
|
||||
fn menu<R: Runtime>(app: &AppHandle<R>) -> Result<Menu<R>, String> {
|
||||
let current = app.state::<AppState>().settings();
|
||||
let item = |id: &str, title: &str| -> Result<MenuItem<R>, String> {
|
||||
MenuItem::with_id(app, id, title, true, None::<&str>)
|
||||
.map_err(|error| format!("пункт меню «{title}»: {error}"))
|
||||
};
|
||||
let check = |id: &str, title: &str, checked: bool| -> Result<CheckMenuItem<R>, String> {
|
||||
CheckMenuItem::with_id(app, id, title, true, checked, None::<&str>)
|
||||
.map_err(|error| format!("пункт меню «{title}»: {error}"))
|
||||
};
|
||||
|
||||
let open = item("open", "Открыть glchat")?;
|
||||
let client = item("open-settings", "Настройки обёртки…")?;
|
||||
let first_sep = PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?;
|
||||
let autostart_item = check("autostart", "Запускать при входе", current.autostart)?;
|
||||
let close_to_tray = check("close-to-tray", "Закрывать в трей", current.close_to_tray)?;
|
||||
let notifications = check("notifications", "Уведомления", current.notifications)?;
|
||||
let update_item = item("updates", "Проверить обновления…")?;
|
||||
let second_sep = PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?;
|
||||
let quit = item("quit", "Выход")?;
|
||||
|
||||
Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&open,
|
||||
&client,
|
||||
&first_sep,
|
||||
&autostart_item,
|
||||
&close_to_tray,
|
||||
¬ifications,
|
||||
&update_item,
|
||||
&second_sep,
|
||||
&quit,
|
||||
],
|
||||
)
|
||||
.map_err(|error| format!("не удалось собрать меню трея: {error}"))
|
||||
}
|
||||
|
||||
/// Обработчик пунктов меню трея.
|
||||
fn on_menu_event<R: Runtime>(app: &AppHandle<R>, event: tauri::menu::MenuEvent) {
|
||||
match event.id().as_ref() {
|
||||
"open" => window::show_main(app),
|
||||
"open-settings" => {
|
||||
if let Err(error) = window::open_settings_page(app) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
"autostart" => {
|
||||
let mut current = app.state::<AppState>().settings();
|
||||
current.autostart = !current.autostart;
|
||||
persist(app, current);
|
||||
}
|
||||
"close-to-tray" => {
|
||||
let mut current = app.state::<AppState>().settings();
|
||||
current.close_to_tray = !current.close_to_tray;
|
||||
persist(app, current);
|
||||
}
|
||||
"notifications" => {
|
||||
let mut current = app.state::<AppState>().settings();
|
||||
current.notifications = !current.notifications;
|
||||
persist(app, current);
|
||||
}
|
||||
"updates" => {
|
||||
let handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
updates::check(handle, true).await;
|
||||
});
|
||||
}
|
||||
"quit" => app.exit(0),
|
||||
other => log::debug!("неизвестный пункт меню трея: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Применяет и сохраняет настройки, меняя только один переключатель.
|
||||
fn persist<R: Runtime>(app: &AppHandle<R>, next: settings::Settings) {
|
||||
let previous = app.state::<AppState>().settings();
|
||||
app.state::<AppState>().set_settings(next.clone());
|
||||
if let Err(error) = autostart::apply(app, next.autostart) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
if next.ptt_shortcut != previous.ptt_shortcut
|
||||
|| next.mute_shortcut != previous.mute_shortcut
|
||||
|| next.deafen_shortcut != previous.deafen_shortcut
|
||||
{
|
||||
if let Err(error) = shortcuts::apply(app, &next) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
if let Err(error) = settings::save(app, &next) {
|
||||
log::warn!("не удалось сохранить настройки: {error}");
|
||||
}
|
||||
refresh(app);
|
||||
commands::announce_settings(app, &next);
|
||||
if next.instance_url != previous.instance_url {
|
||||
if let Err(error) = window::open_instance_page(app) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user