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 тестов).
This commit is contained in:
@@ -30,6 +30,12 @@ pub enum WebMessage {
|
|||||||
Settings { settings: Settings },
|
Settings { settings: Settings },
|
||||||
/// Доступно обновление — клиент показывает уведомление в интерфейсе.
|
/// Доступно обновление — клиент показывает уведомление в интерфейсе.
|
||||||
UpdateAvailable { version: String },
|
UpdateAvailable { version: String },
|
||||||
|
/// Обёртка перехватила закрытие окна: в клиенте есть несохранённые
|
||||||
|
/// изменения, он показывает тряску и диалог «Сохранить / Выйти без
|
||||||
|
/// сохранения» (docs/client-tauri.md §5).
|
||||||
|
CloseRequested,
|
||||||
|
/// Открыть палитру команд: глобальный Ctrl/Cmd+K при неактивном окне.
|
||||||
|
OpenPalette,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Отправляет сообщение в веб-клиент.
|
/// Отправляет сообщение в веб-клиент.
|
||||||
|
|||||||
@@ -84,6 +84,21 @@ pub fn desktop_set_badge<R: Runtime>(app: AppHandle<R>, count: u32) {
|
|||||||
notifications::set_badge(&app, count);
|
notifications::set_badge(&app, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Веб-клиент сообщает, есть ли несохранённые изменения: от этого зависит
|
||||||
|
/// перехват закрытия окна (docs/client-tauri.md §5).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_set_dirty<R: Runtime>(app: AppHandle<R>, dirty: bool) {
|
||||||
|
app.state::<AppState>().set_dirty(dirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Закрывает приложение по решению пользователя в диалоге клиента
|
||||||
|
/// («Сохранить» после успешного сохранения или «Выйти без сохранения»).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_close_window<R: Runtime>(app: AppHandle<R>) {
|
||||||
|
log::info!("закрываем приложение по решению пользователя");
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
/// Открывает локальную страницу настроек обёртки.
|
/// Открывает локальную страницу настроек обёртки.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn desktop_open_settings<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
pub fn desktop_open_settings<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ pub fn run() {
|
|||||||
commands::desktop_ready,
|
commands::desktop_ready,
|
||||||
commands::desktop_notify,
|
commands::desktop_notify,
|
||||||
commands::desktop_set_badge,
|
commands::desktop_set_badge,
|
||||||
|
commands::desktop_set_dirty,
|
||||||
|
commands::desktop_close_window,
|
||||||
commands::desktop_open_settings,
|
commands::desktop_open_settings,
|
||||||
commands::desktop_open_instance,
|
commands::desktop_open_instance,
|
||||||
commands::desktop_reload,
|
commands::desktop_reload,
|
||||||
@@ -131,22 +133,48 @@ pub fn run() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
// Закрытие окна: либо сворачиваемся в трей, либо выходим целиком
|
let app = window.app_handle();
|
||||||
// (docs/client-tauri.md §2; открытый вопрос §9.2 решён настройкой).
|
match event {
|
||||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
// Закрытие окна: либо сворачиваемся в трей, либо выходим целиком
|
||||||
let app = window.app_handle();
|
// (docs/client-tauri.md §2; открытый вопрос §9.2 решён настройкой).
|
||||||
if app.state::<AppState>().settings().close_to_tray {
|
WindowEvent::CloseRequested { api, .. } => {
|
||||||
api.prevent_close();
|
if app.state::<AppState>().settings().close_to_tray {
|
||||||
if let Err(error) = window.hide() {
|
api.prevent_close();
|
||||||
log::warn!("не удалось свернуть окно в трей: {error}");
|
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!())
|
.build(tauri::generate_context!())
|
||||||
.expect("не удалось запустить glchat desktop");
|
.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.
|
/// Подключает логирование: файл в каталоге логов приложения и stdout.
|
||||||
|
|||||||
@@ -3,14 +3,28 @@
|
|||||||
//! Решение о том, показывать ли уведомление, принимает веб-клиент (он знает про
|
//! Решение о том, показывать ли уведомление, принимает веб-клиент (он знает про
|
||||||
//! упоминания, личные сообщения и настройки пользователя), а обёртка только
|
//! упоминания, личные сообщения и настройки пользователя), а обёртка только
|
||||||
//! показывает системное уведомление и обновляет бейдж.
|
//! показывает системное уведомление и обновляет бейдж.
|
||||||
|
//!
|
||||||
|
//! Клик по уведомлению: `tauri-plugin-notification` на desktop не даёт колбэка
|
||||||
|
//! действия, поэтому используется лучшее доступное поведение — обёртка
|
||||||
|
//! запоминает уведомление, показанное при неактивном окне, и когда приложение
|
||||||
|
//! активируется (окно получило фокус, macOS прислала `Reopen`), открывает канал
|
||||||
|
//! из этого уведомления. Ограничение честно описано в `desktop/README.md`:
|
||||||
|
//! отличить клик по уведомлению от обычного возврата в приложение ОС не даёт,
|
||||||
|
//! поэтому переход делается только для «свежего» уведомления (см.
|
||||||
|
//! [`ACTIVATION_WINDOW`]).
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tauri::{AppHandle, Manager, Runtime};
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
use tauri_plugin_notification::NotificationExt;
|
use tauri_plugin_notification::NotificationExt;
|
||||||
|
|
||||||
use crate::bridge::WEB_WINDOW;
|
use crate::bridge::{self, WebMessage, WEB_WINDOW};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Сколько после показа уведомления активация приложения считается кликом по нему.
|
||||||
|
const ACTIVATION_WINDOW: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
/// Уведомление, пришедшее из веб-клиента.
|
/// Уведомление, пришедшее из веб-клиента.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -19,10 +33,10 @@ pub struct NotifyPayload {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
/// Текст: начало сообщения.
|
/// Текст: начало сообщения.
|
||||||
pub body: String,
|
pub body: String,
|
||||||
/// Сервер и комната — нужны для перехода по клику (в текущем инкременте
|
/// Сервер комнаты: нужен для перехода по клику (`None` — личная беседа).
|
||||||
/// только сохраняются в логе: клик по уведомлению обрабатывается системой).
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub guild_id: Option<String>,
|
pub guild_id: Option<String>,
|
||||||
|
/// Комната, которую открывает клик по уведомлению.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub channel_id: Option<String>,
|
pub channel_id: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -43,7 +57,61 @@ pub fn show<R: Runtime>(app: &AppHandle<R>, payload: &NotifyPayload) -> Result<(
|
|||||||
.title(payload.title.clone())
|
.title(payload.title.clone())
|
||||||
.body(payload.body.clone())
|
.body(payload.body.clone())
|
||||||
.show()
|
.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::<AppState>()
|
||||||
|
.remember_notification(payload.guild_id.clone(), channel_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открывает канал последнего уведомления при активации приложения.
|
||||||
|
///
|
||||||
|
/// Возвращает `true`, если переход был отправлен в веб-клиент.
|
||||||
|
pub fn open_pending<R: Runtime>(app: &AppHandle<R>) -> bool {
|
||||||
|
let Some(pending) = app.state::<AppState>().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<String> {
|
||||||
|
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<R: Runtime>(app: &AppHandle<R>) -> 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<R: Runtime>(app: &AppHandle<R>, 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ use crate::bridge::{self, WebMessage};
|
|||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Палитра команд (docs/client-tauri.md §5): комбинация не настраивается —
|
||||||
|
/// это системный жест «быстрый переход», одинаковый во всех сборках.
|
||||||
|
const PALETTE_SHORTCUT: &str = "CommandOrControl+K";
|
||||||
|
|
||||||
/// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации.
|
/// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации.
|
||||||
pub fn on_shortcut<R: Runtime>(
|
pub fn on_shortcut<R: Runtime>(
|
||||||
app: &AppHandle<R>,
|
app: &AppHandle<R>,
|
||||||
@@ -36,6 +40,12 @@ pub fn on_shortcut<R: Runtime>(
|
|||||||
}
|
}
|
||||||
if parse(&settings.deafen_shortcut).as_ref() == Some(shortcut) {
|
if parse(&settings.deafen_shortcut).as_ref() == Some(shortcut) {
|
||||||
bridge::send(app, &WebMessage::ToggleDeafen);
|
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<R: Runtime>(app: &AppHandle<R>, settings: &Settings) -> Result<(),
|
|||||||
.unregister_all()
|
.unregister_all()
|
||||||
.map_err(|error| format!("не удалось снять старые шорткаты: {error}"))?;
|
.map_err(|error| format!("не удалось снять старые шорткаты: {error}"))?;
|
||||||
|
|
||||||
for spec in [
|
// Палитра команд регистрируется всегда и последней: если пользователь занял
|
||||||
&settings.ptt_shortcut,
|
// её комбинацию своим действием, второй раз её регистрировать нельзя —
|
||||||
&settings.mute_shortcut,
|
// сработает то действие, чья проверка в `on_shortcut` стоит раньше.
|
||||||
&settings.deafen_shortcut,
|
let wanted = [
|
||||||
] {
|
settings.ptt_shortcut.as_str(),
|
||||||
if spec.trim().is_empty() {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
shortcuts
|
shortcuts
|
||||||
.register(spec.as_str())
|
.register(trimmed)
|
||||||
.map_err(|error| format!("комбинация «{spec}» не зарегистрирована: {error}"))?;
|
.map_err(|error| format!("комбинация «{trimmed}» не зарегистрирована: {error}"))?;
|
||||||
|
registered.push(parsed);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,28 @@
|
|||||||
//! сообщений, которые нужно передать в страницу.
|
//! сообщений, которые нужно передать в страницу.
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
|
/// Уведомление, ожидающее активации приложения.
|
||||||
|
///
|
||||||
|
/// Клик по системному уведомлению на desktop не даёт колбэка
|
||||||
|
/// (`tauri-plugin-notification` его не предоставляет), поэтому обёртка
|
||||||
|
/// запоминает последнее уведомление, показанное при неактивном окне, и
|
||||||
|
/// открывает его канал, когда приложение активируется (см.
|
||||||
|
/// [`crate::notifications::open_pending`]).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PendingNotification {
|
||||||
|
/// Сервер комнаты: `None` — личная беседа.
|
||||||
|
pub guild_id: Option<String>,
|
||||||
|
/// Комната, которую нужно открыть.
|
||||||
|
pub channel_id: String,
|
||||||
|
/// Момент показа уведомления: активация считается кликом, только если
|
||||||
|
/// прошло немного времени.
|
||||||
|
pub at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
/// Состояние, доступное командам, трею и обработчикам событий.
|
/// Состояние, доступное командам, трею и обработчикам событий.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -20,6 +39,12 @@ struct Inner {
|
|||||||
pending_deep_links: Vec<String>,
|
pending_deep_links: Vec<String>,
|
||||||
/// Последнее переданное в трей/док число непрочитанных.
|
/// Последнее переданное в трей/док число непрочитанных.
|
||||||
badge: u32,
|
badge: u32,
|
||||||
|
/// Уведомление, по которому ждём активации приложения.
|
||||||
|
pending_notification: Option<PendingNotification>,
|
||||||
|
/// В веб-клиенте есть несохранённые изменения (docs/client-tauri.md §5):
|
||||||
|
/// закрытие окна перехватывается и превращается в диалог «Сохранить /
|
||||||
|
/// Выйти без сохранения».
|
||||||
|
dirty: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -60,6 +85,36 @@ impl AppState {
|
|||||||
inner.badge
|
inner.badge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Запоминает уведомление, показанное при неактивном окне.
|
||||||
|
pub fn remember_notification(&self, guild_id: Option<String>, 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<PendingNotification> {
|
||||||
|
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> {
|
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||||
// Отравленный мьютекс не должен ронять приложение: состояние —
|
// Отравленный мьютекс не должен ронять приложение: состояние —
|
||||||
// это только настройки и очередь событий.
|
// это только настройки и очередь событий.
|
||||||
|
|||||||
@@ -124,7 +124,13 @@ fn on_menu_event<R: Runtime>(app: &AppHandle<R>, event: tauri::menu::MenuEvent)
|
|||||||
updates::check(handle, true).await;
|
updates::check(handle, true).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"quit" => app.exit(0),
|
// Выход из трея — такой же выход, как закрытие окна: если в клиенте
|
||||||
|
// есть несохранённые правки, спрашиваем пользователя, а не теряем их.
|
||||||
|
"quit" => {
|
||||||
|
if !window::request_close_confirmation(app) {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
other => log::debug!("неизвестный пункт меню трея: {other}"),
|
other => log::debug!("неизвестный пункт меню трея: {other}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,3 +157,64 @@ async fn prompt_install<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(),
|
||||||
|
"публичный ключ подписи должен быть вшит в сборку"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindo
|
|||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use tauri_plugin_window_state::{StateFlags, WindowExt};
|
use tauri_plugin_window_state::{StateFlags, WindowExt};
|
||||||
|
|
||||||
use crate::bridge::WEB_WINDOW;
|
use crate::bridge::{self, WebMessage, WEB_WINDOW};
|
||||||
use crate::instance;
|
use crate::instance;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -51,6 +51,61 @@ pub fn show_main<R: Runtime>(app: &AppHandle<R>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Смещения нативной тряски окна: три колебания по 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<R: Runtime>(app: &AppHandle<R>) {
|
||||||
|
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<R: Runtime>(app: &AppHandle<R>) -> bool {
|
||||||
|
if !app.state::<AppState>().dirty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
log::info!("выход отложен: в клиенте есть несохранённые изменения");
|
||||||
|
show_main(app);
|
||||||
|
shake(app);
|
||||||
|
bridge::send(app, &WebMessage::CloseRequested);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Проверяет, загружен ли сейчас в окне клиент инстанса (а не страница настроек).
|
/// Проверяет, загружен ли сейчас в окне клиент инстанса (а не страница настроек).
|
||||||
pub fn is_instance_page<R: Runtime>(app: &AppHandle<R>) -> bool {
|
pub fn is_instance_page<R: Runtime>(app: &AppHandle<R>) -> bool {
|
||||||
let Some(window) = app.get_webview_window(WEB_WINDOW) else {
|
let Some(window) = app.get_webview_window(WEB_WINDOW) else {
|
||||||
@@ -133,3 +188,26 @@ fn navigation_guard<R: Runtime>(app: &AppHandle<R>, target: &url::Url) -> bool {
|
|||||||
let _ = app.opener().open_url(target.as_str(), None::<&str>);
|
let _ = app.opener().open_url(target.as_str(), None::<&str>);
|
||||||
false
|
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),
|
||||||
|
"тряска без смещений ничего не показывает"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user