Files
glchat/desktop/src-tauri/src/shortcuts.rs
T
grendervill 72c5189e8f feat(desktop): переход в комнату по уведомлению, выход без потери правок, Ctrl/Cmd+K
Три пункта спецификации клиента (docs/client-tauri.md §5), которые оставались
открытыми в обёртке:

- **Клик по уведомлению → переход в канал.** У `tauri-plugin-notification` на
  desktop нет колбэка действия, поэтому реализовано лучшее доступное поведение:
  уведомление, показанное при неактивном окне, запоминается, и когда приложение
  активируется в течение 10 секунд (окно получило фокус или macOS прислала
  `RunEvent::Reopen`), окно поднимается и открывается комната из уведомления —
  `glchat://guild/<id>/channel/<id>`, для личной беседы `glchat://dm/<id>`.
  Идентификаторы проверяются (только цифры), чтобы из текста уведомления нельзя
  было собрать произвольный deep link. Ограничение: ОС не сообщает, что
  активация вызвана именно кликом, поэтому переход сработает и при обычном
  возврате в приложение в эти 10 секунд — это описано в desktop/README.md;

- **Выход при несохранённых изменениях.** Клиент сообщает о правках командой
  `desktop_set_dirty`; закрытие окна и пункт «Выход» в трее перехватываются
  (`window::request_close_confirmation`), окно best-effort покачивается
  (`Window::set_position`, на Wayland молча пропускается), а решение принимает
  пользователь в диалоге клиента «Сохранить / Выйти без сохранения»; закрывает
  приложение команда `desktop_close_window`. Раньше выход из трея терял правки
  молча;

- **Ctrl/Cmd+K.** Комбинация регистрируется глобально (`shortcuts.rs`) и при
  неактивном окне поднимает окно и просит клиент открыть палитру команд
  (`WebMessage::OpenPalette`); повторы комбинаций с настраиваемыми шорткатами
  отсекаются по разобранной комбинации, иначе вторая регистрация падала бы.

Заодно: `updates.rs` получил тест, который разбирает манифест релиза типом
самого `tauri-plugin-updater` (`RemoteRelease`) — он ловит расхождение формата
(например, дату не в RFC 3339), из-за которого обновление молча не приходило бы.
Проверки: `make desktop-check` (fmt, clippy, 10 тестов).
2026-09-22 23:17:27 +03:00

98 lines
4.4 KiB
Rust

//! Глобальные горячие клавиши (docs/client-tauri.md §5).
//!
//! Push-to-talk работает при неактивном окне: системный перехват ловит нажатие
//! и отпускание выбранной комбинации и передаёт состояние в веб-клиент, а тот
//! включает микрофон (в самом клиенте должен быть выбран режим «Push-to-talk»).
//! Дополнительно доступны переключение микрофона и «не слышу».
use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use crate::bridge::{self, WebMessage};
use crate::settings::Settings;
use crate::state::AppState;
/// Палитра команд (docs/client-tauri.md §5): комбинация не настраивается —
/// это системный жест «быстрый переход», одинаковый во всех сборках.
const PALETTE_SHORTCUT: &str = "CommandOrControl+K";
/// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации.
pub fn on_shortcut<R: Runtime>(
app: &AppHandle<R>,
shortcut: &Shortcut,
event: tauri_plugin_global_shortcut::ShortcutEvent,
) {
let settings = app.state::<AppState>().settings();
let pressed = event.state == ShortcutState::Pressed;
let ptt = parse(&settings.ptt_shortcut);
if ptt.as_ref() == Some(shortcut) {
bridge::send(app, &WebMessage::Ptt { active: pressed });
return;
}
// Остальные шорткаты срабатывают по нажатию, чтобы не дублировать действие.
if !pressed {
return;
}
if parse(&settings.mute_shortcut).as_ref() == Some(shortcut) {
bridge::send(app, &WebMessage::ToggleMic);
return;
}
if parse(&settings.deafen_shortcut).as_ref() == Some(shortcut) {
bridge::send(app, &WebMessage::ToggleDeafen);
return;
}
// Быстрый переход: окно поднимаем и просим клиент открыть палитру команд.
if parse(PALETTE_SHORTCUT).as_ref() == Some(shortcut) {
crate::window::show_main(app);
bridge::send(app, &WebMessage::OpenPalette);
}
}
/// Перерегистрирует комбинации после изменения настроек.
pub fn apply<R: Runtime>(app: &AppHandle<R>, settings: &Settings) -> Result<(), String> {
let shortcuts = app.global_shortcut();
shortcuts
.unregister_all()
.map_err(|error| format!("не удалось снять старые шорткаты: {error}"))?;
// Палитра команд регистрируется всегда и последней: если пользователь занял
// её комбинацию своим действием, второй раз её регистрировать нельзя —
// сработает то действие, чья проверка в `on_shortcut` стоит раньше.
let wanted = [
settings.ptt_shortcut.as_str(),
settings.mute_shortcut.as_str(),
settings.deafen_shortcut.as_str(),
PALETTE_SHORTCUT,
];
// Повторы отсекаются по разобранной комбинации: «Cmd+K» и «Command+K» —
// одна и та же клавиша, и вторая регистрация вернула бы ошибку.
let mut registered: Vec<Shortcut> = Vec::new();
for spec in wanted {
let trimmed = spec.trim();
if trimmed.is_empty() {
continue;
}
let Some(parsed) = parse(trimmed) else {
continue;
};
if registered.contains(&parsed) {
continue;
}
shortcuts
.register(trimmed)
.map_err(|error| format!("комбинация «{trimmed}» не зарегистрирована: {error}"))?;
registered.push(parsed);
}
Ok(())
}
/// Разбирает строку комбинации; пустая строка и мусор дают `None`.
fn parse(spec: &str) -> Option<Shortcut> {
let trimmed = spec.trim();
if trimmed.is_empty() {
return None;
}
trimmed.parse::<Shortcut>().ok()
}