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:
2026-09-22 23:17:27 +03:00
parent 498201b413
commit 72c5189e8f
9 changed files with 401 additions and 26 deletions
+6
View File
@@ -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,
} }
/// Отправляет сообщение в веб-клиент. /// Отправляет сообщение в веб-клиент.
+15
View File
@@ -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> {
+40 -12
View File
@@ -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.
+104 -4
View File
@@ -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")
);
}
}
+34 -8
View File
@@ -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(())
} }
+55
View File
@@ -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> {
// Отравленный мьютекс не должен ронять приложение: состояние — // Отравленный мьютекс не должен ронять приложение: состояние —
// это только настройки и очередь событий. // это только настройки и очередь событий.
+7 -1
View File
@@ -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}"),
} }
} }
+61
View File
@@ -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(),
"публичный ключ подписи должен быть вшит в сборку"
);
}
}
+79 -1
View File
@@ -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),
"тряска без смещений ничего не показывает"
);
}
}