diff --git a/desktop/src-tauri/src/bridge.rs b/desktop/src-tauri/src/bridge.rs index 9bb80f0..8062b6c 100644 --- a/desktop/src-tauri/src/bridge.rs +++ b/desktop/src-tauri/src/bridge.rs @@ -30,6 +30,12 @@ pub enum WebMessage { Settings { settings: Settings }, /// Доступно обновление — клиент показывает уведомление в интерфейсе. UpdateAvailable { version: String }, + /// Обёртка перехватила закрытие окна: в клиенте есть несохранённые + /// изменения, он показывает тряску и диалог «Сохранить / Выйти без + /// сохранения» (docs/client-tauri.md §5). + CloseRequested, + /// Открыть палитру команд: глобальный Ctrl/Cmd+K при неактивном окне. + OpenPalette, } /// Отправляет сообщение в веб-клиент. diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index a6c79e4..77e5da2 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -84,6 +84,21 @@ pub fn desktop_set_badge(app: AppHandle, count: u32) { notifications::set_badge(&app, count); } +/// Веб-клиент сообщает, есть ли несохранённые изменения: от этого зависит +/// перехват закрытия окна (docs/client-tauri.md §5). +#[tauri::command] +pub fn desktop_set_dirty(app: AppHandle, dirty: bool) { + app.state::().set_dirty(dirty); +} + +/// Закрывает приложение по решению пользователя в диалоге клиента +/// («Сохранить» после успешного сохранения или «Выйти без сохранения»). +#[tauri::command] +pub fn desktop_close_window(app: AppHandle) { + log::info!("закрываем приложение по решению пользователя"); + app.exit(0); +} + /// Открывает локальную страницу настроек обёртки. #[tauri::command] pub fn desktop_open_settings(app: AppHandle) -> Result<(), String> { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a7f6269..74bf398 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -120,6 +120,8 @@ pub fn run() { commands::desktop_ready, commands::desktop_notify, commands::desktop_set_badge, + commands::desktop_set_dirty, + commands::desktop_close_window, commands::desktop_open_settings, commands::desktop_open_instance, commands::desktop_reload, @@ -131,22 +133,48 @@ pub fn run() { Ok(()) }) .on_window_event(|window, event| { - // Закрытие окна: либо сворачиваемся в трей, либо выходим целиком - // (docs/client-tauri.md §2; открытый вопрос §9.2 решён настройкой). - if let WindowEvent::CloseRequested { api, .. } = event { - let app = window.app_handle(); - if app.state::().settings().close_to_tray { - api.prevent_close(); - if let Err(error) = window.hide() { - log::warn!("не удалось свернуть окно в трей: {error}"); + let app = window.app_handle(); + match event { + // Закрытие окна: либо сворачиваемся в трей, либо выходим целиком + // (docs/client-tauri.md §2; открытый вопрос §9.2 решён настройкой). + WindowEvent::CloseRequested { api, .. } => { + if app.state::().settings().close_to_tray { + api.prevent_close(); + if let Err(error) = window.hide() { + log::warn!("не удалось свернуть окно в трей: {error}"); + } + } else if window::request_close_confirmation(app) { + // В веб-клиенте есть несохранённые изменения: закрытие + // перехватываем, окно покачиваем, а решение принимает + // пользователь в диалоге клиента — «Сохранить / Выйти + // без сохранения» (docs/client-tauri.md §5). + api.prevent_close(); + } else { + app.exit(0); } - } else { - app.exit(0); } + // Окно получило фокус: если только что было показано уведомление, + // считаем активацию кликом по нему и открываем комнату. + WindowEvent::Focused(true) => { + notifications::open_pending(app); + } + _ => {} } }) - .run(tauri::generate_context!()) - .expect("не удалось запустить glchat desktop"); + .build(tauri::generate_context!()) + .expect("не удалось запустить glchat desktop") + .run(|app, event| { + // macOS: applicationShouldHandleReopen — клик по иконке в доке или + // активация приложения без видимых окон (например, окно свёрнуто в + // трей). Тот же путь использует клик по уведомлению. + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Reopen { .. } = event { + window::show_main(app); + notifications::open_pending(app); + } + #[cfg(not(target_os = "macos"))] + let _ = (app, event); + }); } /// Подключает логирование: файл в каталоге логов приложения и stdout. diff --git a/desktop/src-tauri/src/notifications.rs b/desktop/src-tauri/src/notifications.rs index 04c96fe..f89732f 100644 --- a/desktop/src-tauri/src/notifications.rs +++ b/desktop/src-tauri/src/notifications.rs @@ -3,14 +3,28 @@ //! Решение о том, показывать ли уведомление, принимает веб-клиент (он знает про //! упоминания, личные сообщения и настройки пользователя), а обёртка только //! показывает системное уведомление и обновляет бейдж. +//! +//! Клик по уведомлению: `tauri-plugin-notification` на desktop не даёт колбэка +//! действия, поэтому используется лучшее доступное поведение — обёртка +//! запоминает уведомление, показанное при неактивном окне, и когда приложение +//! активируется (окно получило фокус, macOS прислала `Reopen`), открывает канал +//! из этого уведомления. Ограничение честно описано в `desktop/README.md`: +//! отличить клик по уведомлению от обычного возврата в приложение ОС не даёт, +//! поэтому переход делается только для «свежего» уведомления (см. +//! [`ACTIVATION_WINDOW`]). + +use std::time::Duration; use serde::Deserialize; use tauri::{AppHandle, Manager, Runtime}; use tauri_plugin_notification::NotificationExt; -use crate::bridge::WEB_WINDOW; +use crate::bridge::{self, WebMessage, WEB_WINDOW}; use crate::state::AppState; +/// Сколько после показа уведомления активация приложения считается кликом по нему. +const ACTIVATION_WINDOW: Duration = Duration::from_secs(10); + /// Уведомление, пришедшее из веб-клиента. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -19,10 +33,10 @@ pub struct NotifyPayload { pub title: String, /// Текст: начало сообщения. pub body: String, - /// Сервер и комната — нужны для перехода по клику (в текущем инкременте - /// только сохраняются в логе: клик по уведомлению обрабатывается системой). + /// Сервер комнаты: нужен для перехода по клику (`None` — личная беседа). #[serde(default)] pub guild_id: Option, + /// Комната, которую открывает клик по уведомлению. #[serde(default)] pub channel_id: Option, } @@ -43,7 +57,61 @@ pub fn show(app: &AppHandle, payload: &NotifyPayload) -> Result<( .title(payload.title.clone()) .body(payload.body.clone()) .show() - .map_err(|error| format!("не удалось показать уведомление: {error}")) + .map_err(|error| format!("не удалось показать уведомление: {error}"))?; + + // Запоминаем уведомление только для неактивного окна: если окно в фокусе, + // пользователь и так видит клиента, и переход по «активации» был бы ложным. + if let Some(channel_id) = payload.channel_id.clone() { + if !window_focused(app) { + app.state::() + .remember_notification(payload.guild_id.clone(), channel_id); + } + } + Ok(()) +} + +/// Открывает канал последнего уведомления при активации приложения. +/// +/// Возвращает `true`, если переход был отправлен в веб-клиент. +pub fn open_pending(app: &AppHandle) -> bool { + let Some(pending) = app.state::().take_notification(ACTIVATION_WINDOW) else { + return false; + }; + let Some(url) = channel_link(pending.guild_id.as_deref(), &pending.channel_id) else { + log::warn!("уведомление пришло с непригодным идентификатором комнаты"); + return false; + }; + log::info!("активация приложения после уведомления: открываем комнату"); + // Окно могло быть свёрнуто в трей: показываем его до перехода. + crate::window::show_main(app); + bridge::send(app, &WebMessage::DeepLink { url }) +} + +/// Ссылка на комнату из уведомления: сервер с комнатой или личная беседа. +/// +/// Форма ссылок — как у внешних deep links (docs/client-tauri.md §5); разбор +/// на стороне клиента общий, поэтому переход работает и для личных бесед. +fn channel_link(guild_id: Option<&str>, channel_id: &str) -> Option { + if !is_safe_id(channel_id) { + return None; + } + Some(match guild_id { + Some(guild) if is_safe_id(guild) => format!("glchat://guild/{guild}/channel/{channel_id}"), + _ => format!("glchat://dm/{channel_id}"), + }) +} + +/// Идентификаторы приходят из веб-клиента: в ссылку попадают только «снежинки» +/// (цифры), чтобы из чужого текста нельзя было собрать произвольный deep link. +fn is_safe_id(value: &str) -> bool { + !value.is_empty() && value.len() <= 32 && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// В фокусе ли главное окно (нет окна — считаем, что нет). +fn window_focused(app: &AppHandle) -> bool { + app.get_webview_window(WEB_WINDOW) + .and_then(|window| window.is_focused().ok()) + .unwrap_or(false) } /// Обновляет число непрочитанных: значок приложения и подпись у иконки в трее. @@ -70,3 +138,35 @@ pub fn set_badge(app: &AppHandle, count: u32) { })); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn строит_ссылку_на_комнату_сервера() { + assert_eq!( + channel_link(Some("42"), "77").as_deref(), + Some("glchat://guild/42/channel/77") + ); + } + + #[test] + fn строит_ссылку_на_личную_беседу() { + assert_eq!(channel_link(None, "77").as_deref(), Some("glchat://dm/77")); + } + + #[test] + fn отвергает_непригодные_идентификаторы() { + // Пустой, нечисловой и слишком длинный идентификатор в ссылку не попадают. + assert_eq!(channel_link(Some("42"), ""), None); + assert_eq!(channel_link(Some("42"), "77/../../etc"), None); + assert_eq!(channel_link(Some("42"), "канал"), None); + assert_eq!(channel_link(None, &"7".repeat(40)), None); + // Плохой сервер не мешает открыть личную беседу по тому же каналу. + assert_eq!( + channel_link(Some("сервер"), "77").as_deref(), + Some("glchat://dm/77") + ); + } +} diff --git a/desktop/src-tauri/src/shortcuts.rs b/desktop/src-tauri/src/shortcuts.rs index ef5407d..8219472 100644 --- a/desktop/src-tauri/src/shortcuts.rs +++ b/desktop/src-tauri/src/shortcuts.rs @@ -12,6 +12,10 @@ 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( app: &AppHandle, @@ -36,6 +40,12 @@ pub fn on_shortcut( } 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); } } @@ -46,17 +56,33 @@ pub fn apply(app: &AppHandle, settings: &Settings) -> Result<(), .unregister_all() .map_err(|error| format!("не удалось снять старые шорткаты: {error}"))?; - for spec in [ - &settings.ptt_shortcut, - &settings.mute_shortcut, - &settings.deafen_shortcut, - ] { - if spec.trim().is_empty() { + // Палитра команд регистрируется всегда и последней: если пользователь занял + // её комбинацию своим действием, второй раз её регистрировать нельзя — + // сработает то действие, чья проверка в `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 = 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(spec.as_str()) - .map_err(|error| format!("комбинация «{spec}» не зарегистрирована: {error}"))?; + .register(trimmed) + .map_err(|error| format!("комбинация «{trimmed}» не зарегистрирована: {error}"))?; + registered.push(parsed); } Ok(()) } diff --git a/desktop/src-tauri/src/state.rs b/desktop/src-tauri/src/state.rs index 348993f..ad8cd09 100644 --- a/desktop/src-tauri/src/state.rs +++ b/desktop/src-tauri/src/state.rs @@ -2,9 +2,28 @@ //! сообщений, которые нужно передать в страницу. use std::sync::Mutex; +use std::time::{Duration, Instant}; use crate::settings::Settings; +/// Уведомление, ожидающее активации приложения. +/// +/// Клик по системному уведомлению на desktop не даёт колбэка +/// (`tauri-plugin-notification` его не предоставляет), поэтому обёртка +/// запоминает последнее уведомление, показанное при неактивном окне, и +/// открывает его канал, когда приложение активируется (см. +/// [`crate::notifications::open_pending`]). +#[derive(Debug, Clone)] +pub struct PendingNotification { + /// Сервер комнаты: `None` — личная беседа. + pub guild_id: Option, + /// Комната, которую нужно открыть. + pub channel_id: String, + /// Момент показа уведомления: активация считается кликом, только если + /// прошло немного времени. + pub at: Instant, +} + /// Состояние, доступное командам, трею и обработчикам событий. #[derive(Default)] pub struct AppState { @@ -20,6 +39,12 @@ struct Inner { pending_deep_links: Vec, /// Последнее переданное в трей/док число непрочитанных. badge: u32, + /// Уведомление, по которому ждём активации приложения. + pending_notification: Option, + /// В веб-клиенте есть несохранённые изменения (docs/client-tauri.md §5): + /// закрытие окна перехватывается и превращается в диалог «Сохранить / + /// Выйти без сохранения». + dirty: bool, } impl AppState { @@ -60,6 +85,36 @@ impl AppState { inner.badge } + /// Запоминает уведомление, показанное при неактивном окне. + pub fn remember_notification(&self, guild_id: Option, channel_id: String) { + self.lock().pending_notification = Some(PendingNotification { + guild_id, + channel_id, + at: Instant::now(), + }); + } + + /// Забирает уведомление, если с момента показа прошло не больше `max_age`. + /// + /// Устаревшее уведомление отбрасывается: активация приложения через + /// несколько минут — это уже не клик по уведомлению. + pub fn take_notification(&self, max_age: Duration) -> Option { + match self.lock().pending_notification.take() { + Some(pending) if pending.at.elapsed() <= max_age => Some(pending), + _ => None, + } + } + + /// Сообщает обёртке, есть ли в веб-клиенте несохранённые изменения. + pub fn set_dirty(&self, dirty: bool) { + self.lock().dirty = dirty; + } + + /// Есть ли несохранённые изменения (нужно при перехвате закрытия окна). + pub fn dirty(&self) -> bool { + self.lock().dirty + } + fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { // Отравленный мьютекс не должен ронять приложение: состояние — // это только настройки и очередь событий. diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index 1fc91c8..c6f2dd9 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -124,7 +124,13 @@ fn on_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) updates::check(handle, true).await; }); } - "quit" => app.exit(0), + // Выход из трея — такой же выход, как закрытие окна: если в клиенте + // есть несохранённые правки, спрашиваем пользователя, а не теряем их. + "quit" => { + if !window::request_close_confirmation(app) { + app.exit(0); + } + } other => log::debug!("неизвестный пункт меню трея: {other}"), } } diff --git a/desktop/src-tauri/src/updates.rs b/desktop/src-tauri/src/updates.rs index df6ee42..e1f493f 100644 --- a/desktop/src-tauri/src/updates.rs +++ b/desktop/src-tauri/src/updates.rs @@ -157,3 +157,64 @@ async fn prompt_install( } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Манифест ровно в том виде, в каком его раскладывает + /// `scripts/desktop-release.sh` (проверяется вместе с серверной частью: + /// internal/server/updates.go). + const MANIFEST: &str = r#"{ + "version": "0.2.0", + "notes": "Релиз glchat 0.2.0", + "pub_date": "2026-09-22T12:00:00Z", + "platforms": { + "darwin-aarch64": { + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo=", + "url": "https://gl.mhspx.su/updates/darwin/aarch64/glchat.app.tar.gz" + } + } + }"#; + + /// Разбор манифеста идёт тем же типом, что использует плагин обновлений: + /// тест ловит расхождение формата (например, дату не в RFC 3339), из-за + /// которого обновление молча не приходило бы. + #[test] + fn манифест_разбирается_плагином_обновлений() { + let release: tauri_plugin_updater::RemoteRelease = + serde_json::from_str(MANIFEST).expect("манифест должен разбираться плагином"); + assert_eq!(release.version.to_string(), "0.2.0"); + assert!(release.pub_date.is_some(), "дата публикации не разобрана"); + assert_eq!( + release + .download_url("darwin-aarch64") + .expect("ссылка на артефакт") + .as_str(), + "https://gl.mhspx.su/updates/darwin/aarch64/glchat.app.tar.gz" + ); + assert_eq!( + release + .signature("darwin-aarch64") + .expect("подпись платформы"), + "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo=" + ); + // Другая платформа в манифесте отсутствует — плагин сообщит об этом. + assert!(release.signature("windows-x86_64").is_err()); + } + + /// Адрес манифеста собирается по адресу инстанса и не содержит двойных слэшей. + #[test] + fn адрес_манифеста_собирается_из_адреса_инстанса() { + let url = instance::normalize("https://gl.mhspx.su/").expect("адрес инстанса"); + let endpoint = format!("{}{UPDATE_PATH}", instance::display(&url)); + assert_eq!( + endpoint, + "https://gl.mhspx.su/updates/{{target}}/{{arch}}/{{current_version}}" + ); + assert!( + !UPDATER_PUBKEY.is_empty(), + "публичный ключ подписи должен быть вшит в сборку" + ); + } +} diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index 466177b..b8766f8 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -9,7 +9,7 @@ use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindo use tauri_plugin_opener::OpenerExt; use tauri_plugin_window_state::{StateFlags, WindowExt}; -use crate::bridge::WEB_WINDOW; +use crate::bridge::{self, WebMessage, WEB_WINDOW}; use crate::instance; use crate::settings::Settings; use crate::state::AppState; @@ -51,6 +51,61 @@ pub fn show_main(app: &AppHandle) { } } +/// Смещения нативной тряски окна: три колебания по 6 px и возврат в исходную +/// точку (docs/client-tauri.md §5 — «нативная тряска best-effort»). +const SHAKE_OFFSETS: [(i32, i32); 8] = [ + (6, 0), + (-6, 0), + (6, 0), + (-6, 0), + (4, 0), + (-4, 0), + (2, 0), + (0, 0), +]; + +/// Покачивает окно при попытке закрыть приложение с несохранёнными изменениями. +/// +/// Best-effort: на Wayland композитор запрещает программе двигать своё окно, +/// тогда остаётся только CSS-тряска контента в веб-клиенте. Движение идёт в +/// отдельном потоке с паузами, чтобы не блокировать обработчик события окна. +pub fn shake(app: &AppHandle) { + let Some(window) = app.get_webview_window(WEB_WINDOW) else { + return; + }; + let Ok(start) = window.outer_position() else { + return; + }; + std::thread::spawn(move || { + for (dx, dy) in SHAKE_OFFSETS { + let position = tauri::PhysicalPosition::new(start.x + dx, start.y + dy); + if window.set_position(position).is_err() { + // Платформа не даёт двигать окно программно — молча выходим. + return; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let _ = window.set_position(start); + }); +} + +/// Просит веб-клиент подтвердить выход, если есть несохранённые изменения. +/// +/// Возвращает `true`, если выход отложен: клиент покажет тряску и диалог +/// «Сохранить / Выйти без сохранения», а окно останется открытым +/// (docs/client-tauri.md §5). Общий путь для закрытия окна и пункта «Выход» в +/// меню трея — иначе правки терялись бы при выходе из трея. +pub fn request_close_confirmation(app: &AppHandle) -> bool { + if !app.state::().dirty() { + return false; + } + log::info!("выход отложен: в клиенте есть несохранённые изменения"); + show_main(app); + shake(app); + bridge::send(app, &WebMessage::CloseRequested); + true +} + /// Проверяет, загружен ли сейчас в окне клиент инстанса (а не страница настроек). pub fn is_instance_page(app: &AppHandle) -> bool { let Some(window) = app.get_webview_window(WEB_WINDOW) else { @@ -133,3 +188,26 @@ fn navigation_guard(app: &AppHandle, target: &url::Url) -> bool { let _ = app.opener().open_url(target.as_str(), None::<&str>); false } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn тряска_возвращает_окно_на_место() { + // Смещения должны быть короткими и заканчиваться нулём: иначе окно + // «уезжает» от исходной позиции. + let (last_x, last_y) = SHAKE_OFFSETS[SHAKE_OFFSETS.len() - 1]; + assert_eq!((last_x, last_y), (0, 0)); + for (dx, dy) in SHAKE_OFFSETS { + assert!( + dx.abs() <= 8 && dy.abs() <= 8, + "слишком большое смещение: {dx},{dy}" + ); + } + assert!( + SHAKE_OFFSETS.iter().any(|(dx, _)| *dx != 0), + "тряска без смещений ничего не показывает" + ); + } +}