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 },
|
||||
/// Доступно обновление — клиент показывает уведомление в интерфейсе.
|
||||
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);
|
||||
}
|
||||
|
||||
/// Веб-клиент сообщает, есть ли несохранённые изменения: от этого зависит
|
||||
/// перехват закрытия окна (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]
|
||||
pub fn desktop_open_settings<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
||||
|
||||
@@ -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| {
|
||||
let app = window.app_handle();
|
||||
match event {
|
||||
// Закрытие окна: либо сворачиваемся в трей, либо выходим целиком
|
||||
// (docs/client-tauri.md §2; открытый вопрос §9.2 решён настройкой).
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
let app = window.app_handle();
|
||||
WindowEvent::CloseRequested { api, .. } => {
|
||||
if app.state::<AppState>().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);
|
||||
}
|
||||
}
|
||||
// Окно получило фокус: если только что было показано уведомление,
|
||||
// считаем активацию кликом по нему и открываем комнату.
|
||||
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.
|
||||
|
||||
@@ -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<String>,
|
||||
/// Комната, которую открывает клик по уведомлению.
|
||||
#[serde(default)]
|
||||
pub channel_id: Option<String>,
|
||||
}
|
||||
@@ -43,7 +57,61 @@ pub fn show<R: Runtime>(app: &AppHandle<R>, 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::<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::state::AppState;
|
||||
|
||||
/// Палитра команд (docs/client-tauri.md §5): комбинация не настраивается —
|
||||
/// это системный жест «быстрый переход», одинаковый во всех сборках.
|
||||
const PALETTE_SHORTCUT: &str = "CommandOrControl+K";
|
||||
|
||||
/// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации.
|
||||
pub fn on_shortcut<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
@@ -36,6 +40,12 @@ pub fn on_shortcut<R: Runtime>(
|
||||
}
|
||||
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<R: Runtime>(app: &AppHandle<R>, 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<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(spec.as_str())
|
||||
.map_err(|error| format!("комбинация «{spec}» не зарегистрирована: {error}"))?;
|
||||
.register(trimmed)
|
||||
.map_err(|error| format!("комбинация «{trimmed}» не зарегистрирована: {error}"))?;
|
||||
registered.push(parsed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// Комната, которую нужно открыть.
|
||||
pub channel_id: String,
|
||||
/// Момент показа уведомления: активация считается кликом, только если
|
||||
/// прошло немного времени.
|
||||
pub at: Instant,
|
||||
}
|
||||
|
||||
/// Состояние, доступное командам, трею и обработчикам событий.
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
@@ -20,6 +39,12 @@ struct Inner {
|
||||
pending_deep_links: Vec<String>,
|
||||
/// Последнее переданное в трей/док число непрочитанных.
|
||||
badge: u32,
|
||||
/// Уведомление, по которому ждём активации приложения.
|
||||
pending_notification: Option<PendingNotification>,
|
||||
/// В веб-клиенте есть несохранённые изменения (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<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> {
|
||||
// Отравленный мьютекс не должен ронять приложение: состояние —
|
||||
// это только настройки и очередь событий.
|
||||
|
||||
@@ -124,7 +124,13 @@ fn on_menu_event<R: Runtime>(app: &AppHandle<R>, 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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_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<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 {
|
||||
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>);
|
||||
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