feat(desktop): обёртка Tauri 2 вокруг веб-клиента
Первый инкремент Фазы 6 (docs/client-tauri.md): окно с тем же веб-клиентом (по умолчанию https://gl.mhspx.su, адрес настраивается), трей с меню и индикатором непрочитанных, нативные уведомления, глобальный push-to-talk и переключение микрофона/deafen, автозапуск, single-instance, сохранение геометрии окна, deep links glchat://…, автообновление по подписанному манифесту. Связь страницы с обёрткой — через платформенный адаптер (web/src/lib/platform.ts) и минимальный типизированный набор команд; в веб-клиенте появились системные уведомления об упоминаниях и личных сообщениях и переключатель в настройках. В web/src/stores/gateway.ts вместе с вызовом уведомления лежат правки по дефектам realtime (READY и сессии) из параллельной волны: файл коммитится целиком по договорённости с координатором.
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[package]
|
||||||
|
name = "glchat-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "glchat — desktop-обёртка вокруг веб-клиента (Tauri 2)"
|
||||||
|
authors = ["glchat"]
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
|
repository = "https://gt.mhspx.su/grendervill/glchat"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "glchat_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
|
tauri-plugin-autostart = "2"
|
||||||
|
tauri-plugin-deep-link = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-global-shortcut = "2"
|
||||||
|
tauri-plugin-log = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
tauri-plugin-store = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
tauri-plugin-window-state = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
log = "0.4"
|
||||||
|
url = "2"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
|
lto = true
|
||||||
|
opt-level = "s"
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "instance-page",
|
||||||
|
"description": "Веб-клиент инстанса. IPC разрешён только для домена инстанса (docs/client-tauri.md §6): команды обёртки — минимальные и типизированные, плагины напрямую странице не доступны.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"remote": {
|
||||||
|
"urls": ["https://gl.mhspx.su"]
|
||||||
|
},
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "local-settings",
|
||||||
|
"description": "Локальная страница настроек обёртки (tauri://localhost) в главном окне: команды обёртки, работа с окном и хранилищем.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default", "store:default", "dialog:default", "opener:default"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
//! Автозапуск приложения (docs/client-tauri.md §5).
|
||||||
|
//!
|
||||||
|
//! Запуск идёт с аргументом `--minimized`: приложение поднимается свёрнутым в
|
||||||
|
//! трей и не перебивает работу пользователя окном.
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Runtime};
|
||||||
|
use tauri_plugin_autostart::ManagerExt;
|
||||||
|
|
||||||
|
/// Аргумент командной строки для запуска свёрнутым в трей.
|
||||||
|
pub const MINIMIZED_ARG: &str = "--minimized";
|
||||||
|
|
||||||
|
/// Приводит системный автозапуск в соответствие с настройкой.
|
||||||
|
pub fn apply<R: Runtime>(app: &AppHandle<R>, enabled: bool) -> Result<(), String> {
|
||||||
|
let manager = app.autolaunch();
|
||||||
|
let current = manager.is_enabled().unwrap_or(false);
|
||||||
|
if current == enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let result = if enabled {
|
||||||
|
manager.enable()
|
||||||
|
} else {
|
||||||
|
manager.disable()
|
||||||
|
};
|
||||||
|
result.map_err(|error| format!("не удалось изменить автозапуск: {error}"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! Мост «Rust → веб-клиент».
|
||||||
|
//!
|
||||||
|
//! Обратное направление (страница → обёртка) идёт через типизированные команды
|
||||||
|
//! (`commands.rs`), а события платформы передаются вызовом единственной функции
|
||||||
|
//! `window.__glchatDesktop.receive(...)` в самой странице. Отдельного канала
|
||||||
|
//! событий нет намеренно: сообщений мало, а типы у них общие с `web/src/lib/platform.ts`.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
|
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Метка главного окна (единственное окно приложения).
|
||||||
|
pub const WEB_WINDOW: &str = "main";
|
||||||
|
|
||||||
|
/// Сообщение веб-клиенту: то, что платформа сообщает странице.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum WebMessage {
|
||||||
|
/// Нажат или отпущен глобальный push-to-talk.
|
||||||
|
Ptt { active: bool },
|
||||||
|
/// Переключить микрофон (глобальный шорткат).
|
||||||
|
ToggleMic,
|
||||||
|
/// Переключить «не слышу» (глобальный шорткат).
|
||||||
|
ToggleDeafen,
|
||||||
|
/// Открыть deep link: приглашение или сервер с комнатой.
|
||||||
|
DeepLink { url: String },
|
||||||
|
/// Настройки обёртки изменились (обновлена страница настроек).
|
||||||
|
Settings { settings: Settings },
|
||||||
|
/// Доступно обновление — клиент показывает уведомление в интерфейсе.
|
||||||
|
UpdateAvailable { version: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отправляет сообщение в веб-клиент.
|
||||||
|
///
|
||||||
|
/// Если клиент ещё не загрузился, deep link ставится в очередь и будет отдан
|
||||||
|
/// после `desktop_ready` (docs/client-tauri.md §5). Остальные сообщения в этот
|
||||||
|
/// момент терять не страшно: они относятся к активному взаимодействию.
|
||||||
|
pub fn send<R: Runtime>(app: &AppHandle<R>, message: &WebMessage) -> bool {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
if !state.web_ready() {
|
||||||
|
if let WebMessage::DeepLink { url } = message {
|
||||||
|
state.push_deep_link(url.clone());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dispatch(app, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Немедленная доставка без проверки готовности клиента.
|
||||||
|
pub fn dispatch<R: Runtime>(app: &AppHandle<R>, message: &WebMessage) -> bool {
|
||||||
|
let Some(window) = app.get_webview_window(WEB_WINDOW) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(payload) = serde_json::to_string(message) else {
|
||||||
|
log::warn!("не удалось сериализовать сообщение веб-клиенту");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let script = format!("window.__glchatDesktop && window.__glchatDesktop.receive({payload});");
|
||||||
|
match window.eval(&script) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(error) => {
|
||||||
|
log::debug!("веб-клиент не принял сообщение: {error}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! Команды обёртки: типизированный мост «веб-клиент → Rust» (docs/client-tauri.md §2).
|
||||||
|
//!
|
||||||
|
//! Команд ровно столько, сколько нужно странице: прочитать и сохранить
|
||||||
|
//! настройки, сообщить о готовности, показать уведомление, обновить счётчик
|
||||||
|
//! непрочитанных, открыть настройки/клиент и проверить обновления. Никакой
|
||||||
|
//! бизнес-логики здесь нет — только платформенные действия.
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
|
|
||||||
|
use crate::bridge::{self, WebMessage};
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::{autostart, instance, notifications, shortcuts, updates, window};
|
||||||
|
|
||||||
|
/// Текущие настройки обёртки.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_settings<R: Runtime>(app: AppHandle<R>) -> Settings {
|
||||||
|
app.state::<AppState>().settings()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Версия приложения — показывается на странице настроек.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_version<R: Runtime>(app: AppHandle<R>) -> String {
|
||||||
|
app.package_info().version.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сохраняет настройки и применяет их: автозапуск, шорткаты, адрес инстанса.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_save_settings<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
settings: Settings,
|
||||||
|
) -> Result<Settings, String> {
|
||||||
|
// Адрес проверяем до применения, чтобы не оставить обёртку без клиента.
|
||||||
|
let url = instance::normalize(&settings.instance_url)?;
|
||||||
|
let mut next = settings;
|
||||||
|
next.instance_url = instance::display(&url);
|
||||||
|
|
||||||
|
let previous = app.state::<AppState>().settings();
|
||||||
|
// Шорткаты применяем первыми: если комбинация занята, настройки не сохраняем.
|
||||||
|
if next.ptt_shortcut != previous.ptt_shortcut
|
||||||
|
|| next.mute_shortcut != previous.mute_shortcut
|
||||||
|
|| next.deafen_shortcut != previous.deafen_shortcut
|
||||||
|
{
|
||||||
|
if let Err(error) = shortcuts::apply(&app, &next) {
|
||||||
|
let _ = shortcuts::apply(&app, &previous);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(error) = autostart::apply(&app, next.autostart) {
|
||||||
|
log::warn!("{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
app.state::<AppState>().set_settings(next.clone());
|
||||||
|
crate::settings::save(&app, &next)?;
|
||||||
|
crate::tray::refresh(&app);
|
||||||
|
announce_settings(&app, &next);
|
||||||
|
if next.instance_url != previous.instance_url {
|
||||||
|
window::open_instance_page(&app)?;
|
||||||
|
}
|
||||||
|
Ok(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Веб-клиент загрузился: отдаём накопленные deep link.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_ready<R: Runtime>(app: AppHandle<R>) {
|
||||||
|
let pending = app.state::<AppState>().mark_web_ready();
|
||||||
|
for url in pending {
|
||||||
|
bridge::dispatch(&app, &WebMessage::DeepLink { url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Показывает системное уведомление.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_notify<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
payload: notifications::NotifyPayload,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
notifications::show(&app, &payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обновляет счётчик непрочитанных в доке и трее.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_set_badge<R: Runtime>(app: AppHandle<R>, count: u32) {
|
||||||
|
notifications::set_badge(&app, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открывает локальную страницу настроек обёртки.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_open_settings<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
||||||
|
window::open_settings_page(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает окно к клиенту инстанса.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_open_instance<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
||||||
|
window::open_instance_page(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Перезагружает клиент инстанса.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_reload<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
||||||
|
window::reload_instance(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет обновления (кнопка на странице настроек).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn desktop_check_updates<R: Runtime>(app: AppHandle<R>) {
|
||||||
|
updates::check(app, true).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открывает ссылку в системном браузере.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn desktop_open_external<R: Runtime>(app: AppHandle<R>, url: String) -> Result<(), String> {
|
||||||
|
let parsed = instance::normalize(&url)?;
|
||||||
|
tauri_plugin_opener::OpenerExt::opener(&app)
|
||||||
|
.open_url(parsed.as_str(), None::<&str>)
|
||||||
|
.map_err(|error| format!("не удалось открыть ссылку: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сообщает веб-клиенту, что настройки обёртки изменились.
|
||||||
|
pub fn announce_settings<R: Runtime>(app: &AppHandle<R>, settings: &Settings) {
|
||||||
|
bridge::send(
|
||||||
|
app,
|
||||||
|
&WebMessage::Settings {
|
||||||
|
settings: settings.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! Deep links `glchat://…` (docs/client-tauri.md §5).
|
||||||
|
//!
|
||||||
|
//! Поддерживаются `glchat://invite/<code>` и `glchat://guild/<id>/channel/<id>`.
|
||||||
|
//! Ссылка приходит и при холодном старте (приложение ещё поднимается), и при
|
||||||
|
//! тёплом: в первом случае она ждёт в очереди до `desktop_ready` веб-клиента.
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Runtime};
|
||||||
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
|
|
||||||
|
use crate::bridge::{self, WebMessage};
|
||||||
|
|
||||||
|
/// Схема deep link, зарегистрированная в `tauri.conf.json`.
|
||||||
|
pub const SCHEME: &str = "glchat";
|
||||||
|
|
||||||
|
/// Подписывается на ссылки, пришедшие из системы.
|
||||||
|
pub fn install<R: Runtime>(app: &AppHandle<R>) {
|
||||||
|
let handle = app.clone();
|
||||||
|
app.deep_link().on_open_url(move |event| {
|
||||||
|
for url in event.urls() {
|
||||||
|
log::info!("deep link: {url}");
|
||||||
|
bridge::send(&handle, &WebMessage::DeepLink { url: url.to_string() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// В разработке и в AppImage схема может быть не прописана в системе:
|
||||||
|
// регистрируем её на лету (в собранном .app/установщике это делает бандл).
|
||||||
|
if let Err(error) = app.deep_link().register_all() {
|
||||||
|
log::debug!("схема {SCHEME}:// не зарегистрирована автоматически: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылка могла быть передана аргументом командной строки (Windows/Linux).
|
||||||
|
if let Ok(Some(urls)) = app.deep_link().get_current() {
|
||||||
|
for url in urls {
|
||||||
|
bridge::send(app, &WebMessage::DeepLink { url: url.to_string() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Достаёт deep link из аргументов повторного запуска приложения.
|
||||||
|
///
|
||||||
|
/// Второй экземпляр не поднимается (single-instance): его аргументы приходят
|
||||||
|
/// первому, и ссылку нужно доставить в уже открытый клиент.
|
||||||
|
pub fn from_args(args: &[String]) -> Option<String> {
|
||||||
|
args.iter()
|
||||||
|
.find(|arg| arg.starts_with(&format!("{SCHEME}://")))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
//! Адрес инстанса и границы навигации.
|
||||||
|
//!
|
||||||
|
//! Desktop-обёртка загружает тот же клиент, что и браузер, но только с одного
|
||||||
|
//! адреса: переходы на сторонние сайты уходят в системный браузер
|
||||||
|
//! (docs/client-tauri.md §6).
|
||||||
|
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Приводит введённый пользователем адрес к абсолютному URL.
|
||||||
|
///
|
||||||
|
/// Допускаются `http` и `https`; схема по умолчанию — `https`, чтобы в поле
|
||||||
|
/// настроек можно было написать просто `gl.mhspx.su`.
|
||||||
|
pub fn normalize(input: &str) -> Result<Url, String> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("адрес инстанса не заполнен".to_string());
|
||||||
|
}
|
||||||
|
let candidate = if trimmed.contains("://") {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("https://{trimmed}")
|
||||||
|
};
|
||||||
|
let url = Url::parse(&candidate).map_err(|error| format!("некорректный адрес: {error}"))?;
|
||||||
|
match url.scheme() {
|
||||||
|
"http" | "https" => {}
|
||||||
|
other => return Err(format!("поддерживаются только http и https, а не «{other}»")),
|
||||||
|
}
|
||||||
|
if url.host_str().unwrap_or_default().is_empty() {
|
||||||
|
return Err("в адресе нет имени хоста".to_string());
|
||||||
|
}
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Строка адреса для загрузки в окно: без завершающего слэша.
|
||||||
|
pub fn display(url: &Url) -> String {
|
||||||
|
url.as_str().trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Хост адреса — граница, внутри которой разрешена навигация webview.
|
||||||
|
pub fn host(url: &Url) -> String {
|
||||||
|
url.host_str().unwrap_or_default().to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разрешена ли навигация внутри окна.
|
||||||
|
///
|
||||||
|
/// Свой инстанс и локальные страницы обёртки (`tauri://localhost`) — да,
|
||||||
|
/// всё остальное открывается в системном браузере.
|
||||||
|
pub fn navigation_allowed(target: &Url, instance_host: &str) -> bool {
|
||||||
|
match target.scheme() {
|
||||||
|
// Локальные ресурсы обёртки: страница настроек.
|
||||||
|
"tauri" | "asset" => true,
|
||||||
|
"http" | "https" => {
|
||||||
|
let host = target.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||||
|
// На Windows/Linux локальная страница отдаётся как http://tauri.localhost.
|
||||||
|
host == "tauri.localhost" || (!instance_host.is_empty() && host == instance_host)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
//! glchat desktop — обёртка Tauri 2 вокруг веб-клиента (docs/client-tauri.md).
|
||||||
|
//!
|
||||||
|
//! Принцип: тот же веб-код, что и в браузере, плюс системная интеграция —
|
||||||
|
//! трей, нативные уведомления, глобальный push-to-talk, автозапуск,
|
||||||
|
//! автообновление и deep links. Бизнес-логики в Rust нет: обёртка знает только
|
||||||
|
//! адрес инстанса, свои переключатели и границы навигации.
|
||||||
|
|
||||||
|
mod autostart;
|
||||||
|
mod bridge;
|
||||||
|
mod commands;
|
||||||
|
mod deeplink;
|
||||||
|
mod instance;
|
||||||
|
mod notifications;
|
||||||
|
mod settings;
|
||||||
|
mod shortcuts;
|
||||||
|
mod state;
|
||||||
|
mod tray;
|
||||||
|
mod updates;
|
||||||
|
mod window;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tauri::{Manager, WindowEvent};
|
||||||
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
|
||||||
|
|
||||||
|
use state::AppState;
|
||||||
|
|
||||||
|
/// Задержка перед фоновой проверкой обновлений: окно должно успеть открыться.
|
||||||
|
const UPDATE_CHECK_DELAY: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Задержка перед вопросом об автозапуске при первом запуске.
|
||||||
|
const AUTOSTART_PROMPT_DELAY: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Аргументы командной строки обёртки.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Cli {
|
||||||
|
/// Переопределение адреса инстанса (`--instance https://…`).
|
||||||
|
instance: Option<String>,
|
||||||
|
/// Запуск свёрнутым в трей (используется автозапуском).
|
||||||
|
minimized: bool,
|
||||||
|
/// Сразу открыть страницу настроек обёртки.
|
||||||
|
settings: bool,
|
||||||
|
/// Принудительно включить или выключить автозапуск (установка скриптом).
|
||||||
|
autostart: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cli {
|
||||||
|
fn parse() -> Self {
|
||||||
|
let mut cli = Self::default();
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--instance" => cli.instance = args.next(),
|
||||||
|
"--minimized" => cli.minimized = true,
|
||||||
|
"--settings" => cli.settings = true,
|
||||||
|
"--autostart" => cli.autostart = args.next().as_deref().and_then(parse_switch),
|
||||||
|
other => {
|
||||||
|
if let Some(value) = other.strip_prefix("--instance=") {
|
||||||
|
cli.instance = Some(value.to_string());
|
||||||
|
} else if let Some(value) = other.strip_prefix("--autostart=") {
|
||||||
|
cli.autostart = parse_switch(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает `on/off` (а также `true/false`, `1/0`) из аргументов командной строки.
|
||||||
|
fn parse_switch(value: &str) -> Option<bool> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"on" | "true" | "1" | "yes" => Some(true),
|
||||||
|
"off" | "false" | "0" | "no" => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Точка входа приложения.
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
// Single-instance регистрируется первым: повторный запуск передаёт
|
||||||
|
// аргументы (в том числе deep link) первому экземпляру и завершается.
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||||
|
window::show_main(app);
|
||||||
|
if let Some(url) = deeplink::from_args(&argv) {
|
||||||
|
bridge::send(app, &bridge::WebMessage::DeepLink { url });
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.plugin(
|
||||||
|
tauri_plugin_log::Builder::default()
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
|
.plugin(tauri_plugin_autostart::init(
|
||||||
|
MacosLauncher::LaunchAgent,
|
||||||
|
Some(vec![autostart::MINIMIZED_ARG]),
|
||||||
|
))
|
||||||
|
.plugin(
|
||||||
|
tauri_plugin_global_shortcut::Builder::new()
|
||||||
|
.with_handler(shortcuts::on_shortcut)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::desktop_settings,
|
||||||
|
commands::desktop_save_settings,
|
||||||
|
commands::desktop_version,
|
||||||
|
commands::desktop_ready,
|
||||||
|
commands::desktop_notify,
|
||||||
|
commands::desktop_set_badge,
|
||||||
|
commands::desktop_open_settings,
|
||||||
|
commands::desktop_open_instance,
|
||||||
|
commands::desktop_reload,
|
||||||
|
commands::desktop_check_updates,
|
||||||
|
commands::desktop_open_external,
|
||||||
|
])
|
||||||
|
.setup(move |app| {
|
||||||
|
setup(app.handle(), &cli);
|
||||||
|
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::<AppState>().settings().close_to_tray {
|
||||||
|
api.prevent_close();
|
||||||
|
if let Err(error) = window.hide() {
|
||||||
|
log::warn!("не удалось свернуть окно в трей: {error}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("не удалось запустить glchat desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Начальная настройка: настройки, трей, окно, шорткаты, deep links.
|
||||||
|
fn setup(app: &tauri::AppHandle, cli: &Cli) {
|
||||||
|
let mut current = settings::load(app);
|
||||||
|
if let Some(instance) = &cli.instance {
|
||||||
|
match instance::normalize(instance) {
|
||||||
|
Ok(url) => current.instance_url = instance::display(&url),
|
||||||
|
Err(error) => log::warn!("адрес из командной строки не принят: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Аргумент `--autostart` перекрывает сохранённое значение и снимает
|
||||||
|
// необходимость спрашивать пользователя при первом запуске.
|
||||||
|
if let Some(autostart) = cli.autostart {
|
||||||
|
current.autostart = autostart;
|
||||||
|
current.autostart_prompted = true;
|
||||||
|
}
|
||||||
|
app.manage(AppState::default());
|
||||||
|
app.state::<AppState>().set_settings(current.clone());
|
||||||
|
if let Err(error) = settings::save(app, ¤t) {
|
||||||
|
log::warn!("не удалось сохранить настройки при старте: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = autostart::apply(app, current.autostart) {
|
||||||
|
log::warn!("{error}");
|
||||||
|
}
|
||||||
|
if let Err(error) = tray::build(app) {
|
||||||
|
log::error!("{error}");
|
||||||
|
}
|
||||||
|
if let Err(error) = window::create_main(app, ¤t, cli.minimized) {
|
||||||
|
log::error!("{error}");
|
||||||
|
}
|
||||||
|
if let Err(error) = shortcuts::apply(app, ¤t) {
|
||||||
|
log::warn!("{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
deeplink::install(app);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"обёртка запущена: адрес={}, окно={}, автозапуск={}",
|
||||||
|
current.instance_url,
|
||||||
|
if cli.minimized { "свёрнуто" } else { "видимо" },
|
||||||
|
current.autostart
|
||||||
|
);
|
||||||
|
|
||||||
|
if cli.settings {
|
||||||
|
if let Err(error) = window::open_settings_page(app) {
|
||||||
|
log::warn!("{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_update_check(app.clone());
|
||||||
|
spawn_autostart_prompt(app.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Фоновая проверка обновлений при старте (без диалогов).
|
||||||
|
fn spawn_update_check(app: tauri::AppHandle) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(UPDATE_CHECK_DELAY);
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
updates::check(app, false).await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вопрос об автозапуске при первом запуске (docs/client-tauri.md §5).
|
||||||
|
fn spawn_autostart_prompt(app: tauri::AppHandle) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(AUTOSTART_PROMPT_DELAY);
|
||||||
|
if app.state::<AppState>().settings().autostart_prompted {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let handle = app.clone();
|
||||||
|
app.dialog()
|
||||||
|
.message(
|
||||||
|
"Запускать glchat при входе в систему? Приложение будет стартовать свёрнутым в трей.",
|
||||||
|
)
|
||||||
|
.title("Автозапуск glchat")
|
||||||
|
.buttons(MessageDialogButtons::YesNo)
|
||||||
|
.show(move |confirmed| {
|
||||||
|
log::info!("ответ на вопрос об автозапуске: {confirmed}");
|
||||||
|
let mut next = handle.state::<AppState>().settings();
|
||||||
|
next.autostart = confirmed;
|
||||||
|
next.autostart_prompted = true;
|
||||||
|
handle.state::<AppState>().set_settings(next.clone());
|
||||||
|
if let Err(error) = autostart::apply(&handle, next.autostart) {
|
||||||
|
log::warn!("{error}");
|
||||||
|
}
|
||||||
|
if let Err(error) = settings::save(&handle, &next) {
|
||||||
|
log::warn!("не удалось сохранить настройки: {error}");
|
||||||
|
}
|
||||||
|
tray::refresh(&handle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Отключает лишнее консольное окно на Windows в релизной сборке.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
glchat_desktop_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! Нативные уведомления и счётчик непрочитанных (docs/client-tauri.md §5).
|
||||||
|
//!
|
||||||
|
//! Решение о том, показывать ли уведомление, принимает веб-клиент (он знает про
|
||||||
|
//! упоминания, личные сообщения и настройки пользователя), а обёртка только
|
||||||
|
//! показывает системное уведомление и обновляет бейдж.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
|
use tauri_plugin_notification::NotificationExt;
|
||||||
|
|
||||||
|
use crate::bridge::WEB_WINDOW;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Уведомление, пришедшее из веб-клиента.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct NotifyPayload {
|
||||||
|
/// Заголовок: имя автора или название сервера.
|
||||||
|
pub title: String,
|
||||||
|
/// Текст: начало сообщения.
|
||||||
|
pub body: String,
|
||||||
|
/// Сервер и комната — нужны для перехода по клику (в текущем инкременте
|
||||||
|
/// только сохраняются в логе: клик по уведомлению обрабатывается системой).
|
||||||
|
#[serde(default)]
|
||||||
|
pub guild_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub channel_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Показывает системное уведомление, если они включены в настройках обёртки.
|
||||||
|
pub fn show<R: Runtime>(app: &AppHandle<R>, payload: &NotifyPayload) -> Result<(), String> {
|
||||||
|
if !app.state::<AppState>().settings().notifications {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// В лог не попадают ни текст сообщения, ни имя автора (docs/client-tauri.md §6).
|
||||||
|
log::debug!(
|
||||||
|
"показываем уведомление (сервер {:?}, комната {:?})",
|
||||||
|
payload.guild_id,
|
||||||
|
payload.channel_id
|
||||||
|
);
|
||||||
|
app.notification()
|
||||||
|
.builder()
|
||||||
|
.title(payload.title.clone())
|
||||||
|
.body(payload.body.clone())
|
||||||
|
.show()
|
||||||
|
.map_err(|error| format!("не удалось показать уведомление: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обновляет число непрочитанных: значок приложения и подпись у иконки в трее.
|
||||||
|
pub fn set_badge<R: Runtime>(app: &AppHandle<R>, count: u32) {
|
||||||
|
let count = app.state::<AppState>().set_badge(count);
|
||||||
|
if let Some(window) = app.get_webview_window(WEB_WINDOW) {
|
||||||
|
// На части платформ бейдж не поддерживается — это не ошибка.
|
||||||
|
let _ = window.set_badge_count(if count == 0 { None } else { Some(i64::from(count)) });
|
||||||
|
}
|
||||||
|
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
|
||||||
|
let _ = tray.set_title(if count == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(count.to_string())
|
||||||
|
});
|
||||||
|
let _ = tray.set_tooltip(Some(if count == 0 {
|
||||||
|
"glchat".to_string()
|
||||||
|
} else {
|
||||||
|
format!("glchat — непрочитанных: {count}")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! Настройки desktop-обёртки.
|
||||||
|
//!
|
||||||
|
//! Лежат в `tauri-plugin-store` (`settings.json` в каталоге конфигурации
|
||||||
|
//! приложения). Обёртка хранит только то, что относится к платформе: адрес
|
||||||
|
//! инстанса и переключатели поведения. Сессия целиком живёт в cookie webview
|
||||||
|
//! (docs/client-tauri.md §2): токенов и паролей здесь нет.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Runtime};
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
/// Файл хранилища в каталоге конфигурации приложения.
|
||||||
|
const STORE_FILE: &str = "settings.json";
|
||||||
|
/// Ключ, под которым лежит объект настроек.
|
||||||
|
const STORE_KEY: &str = "settings";
|
||||||
|
|
||||||
|
/// Адрес стенда по умолчанию: тот же клиент, что открывается в браузере.
|
||||||
|
pub const DEFAULT_INSTANCE: &str = "https://gl.mhspx.su";
|
||||||
|
|
||||||
|
/// Комбинация push-to-talk по умолчанию.
|
||||||
|
///
|
||||||
|
/// Голых клавиш (как `V` в браузерном PTT) в глобальном шорткате быть не может:
|
||||||
|
/// системный перехват занял бы клавишу у всех приложений, поэтому по умолчанию
|
||||||
|
/// комбинация с модификаторами, а пустая строка означает «выключено».
|
||||||
|
pub const DEFAULT_PTT_SHORTCUT: &str = "CommandOrControl+Shift+Space";
|
||||||
|
|
||||||
|
/// Комбинация «выключить/включить микрофон» по умолчанию.
|
||||||
|
pub const DEFAULT_MUTE_SHORTCUT: &str = "CommandOrControl+Shift+M";
|
||||||
|
|
||||||
|
/// Комбинация «не слышу» (deafen) по умолчанию.
|
||||||
|
pub const DEFAULT_DEAFEN_SHORTCUT: &str = "CommandOrControl+Shift+D";
|
||||||
|
|
||||||
|
/// Настройки обёртки. Поля в camelCase — их читает и пишет страница настроек.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
pub struct Settings {
|
||||||
|
/// Адрес инстанса, который загружается в главное окно.
|
||||||
|
pub instance_url: String,
|
||||||
|
/// Закрытие окна сворачивает приложение в трей.
|
||||||
|
pub close_to_tray: bool,
|
||||||
|
/// Запускать приложение при входе в систему (свёрнутым в трей).
|
||||||
|
pub autostart: bool,
|
||||||
|
/// Пользователю уже задавали вопрос про автозапуск при первом запуске.
|
||||||
|
pub autostart_prompted: bool,
|
||||||
|
/// Глобальный push-to-talk (пустая строка — выключен).
|
||||||
|
pub ptt_shortcut: String,
|
||||||
|
/// Глобальное переключение микрофона (пустая строка — выключено).
|
||||||
|
pub mute_shortcut: String,
|
||||||
|
/// Глобальное переключение «не слышу» (пустая строка — выключено).
|
||||||
|
pub deafen_shortcut: String,
|
||||||
|
/// Показывать нативные уведомления об упоминаниях и личных сообщениях.
|
||||||
|
pub notifications: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
instance_url: DEFAULT_INSTANCE.to_string(),
|
||||||
|
close_to_tray: true,
|
||||||
|
autostart: false,
|
||||||
|
autostart_prompted: false,
|
||||||
|
ptt_shortcut: DEFAULT_PTT_SHORTCUT.to_string(),
|
||||||
|
mute_shortcut: DEFAULT_MUTE_SHORTCUT.to_string(),
|
||||||
|
deafen_shortcut: DEFAULT_DEAFEN_SHORTCUT.to_string(),
|
||||||
|
notifications: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Читает настройки из хранилища; при отсутствии или порче — значения по умолчанию.
|
||||||
|
pub fn load<R: Runtime>(app: &AppHandle<R>) -> Settings {
|
||||||
|
let Ok(store) = app.store(STORE_FILE) else {
|
||||||
|
log::warn!("не удалось открыть хранилище настроек, используются значения по умолчанию");
|
||||||
|
return Settings::default();
|
||||||
|
};
|
||||||
|
let Some(value) = store.get(STORE_KEY) else {
|
||||||
|
return Settings::default();
|
||||||
|
};
|
||||||
|
match serde_json::from_value::<Settings>(value) {
|
||||||
|
Ok(settings) => settings,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("настройки в хранилище повреждены ({error}), берём значения по умолчанию");
|
||||||
|
Settings::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сохраняет настройки в хранилище.
|
||||||
|
pub fn save<R: Runtime>(app: &AppHandle<R>, settings: &Settings) -> Result<(), String> {
|
||||||
|
let store = app.store(STORE_FILE).map_err(|error| error.to_string())?;
|
||||||
|
let value = serde_json::to_value(settings).map_err(|error| error.to_string())?;
|
||||||
|
store.set(STORE_KEY, value);
|
||||||
|
store.save().map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//! Глобальные горячие клавиши (docs/client-tauri.md §5).
|
||||||
|
//!
|
||||||
|
//! Push-to-talk работает при неактивном окне: системный перехват ловит нажатие
|
||||||
|
//! и отпускание выбранной комбинации и передаёт состояние в веб-клиент, а тот
|
||||||
|
//! включает микрофон (в самом клиенте должен быть выбран режим «Push-to-talk»).
|
||||||
|
//! Дополнительно доступны переключение микрофона и «не слышу».
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
|
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||||
|
|
||||||
|
use crate::bridge::{self, WebMessage};
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации.
|
||||||
|
pub fn on_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: &Shortcut, event: tauri_plugin_global_shortcut::ShortcutEvent) {
|
||||||
|
let settings = app.state::<AppState>().settings();
|
||||||
|
let pressed = event.state == ShortcutState::Pressed;
|
||||||
|
|
||||||
|
let ptt = parse(&settings.ptt_shortcut);
|
||||||
|
if ptt.as_ref() == Some(shortcut) {
|
||||||
|
bridge::send(app, &WebMessage::Ptt { active: pressed });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Остальные шорткаты срабатывают по нажатию, чтобы не дублировать действие.
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if parse(&settings.mute_shortcut).as_ref() == Some(shortcut) {
|
||||||
|
bridge::send(app, &WebMessage::ToggleMic);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if parse(&settings.deafen_shortcut).as_ref() == Some(shortcut) {
|
||||||
|
bridge::send(app, &WebMessage::ToggleDeafen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Перерегистрирует комбинации после изменения настроек.
|
||||||
|
pub fn apply<R: Runtime>(app: &AppHandle<R>, settings: &Settings) -> Result<(), String> {
|
||||||
|
let shortcuts = app.global_shortcut();
|
||||||
|
shortcuts
|
||||||
|
.unregister_all()
|
||||||
|
.map_err(|error| format!("не удалось снять старые шорткаты: {error}"))?;
|
||||||
|
|
||||||
|
for spec in [
|
||||||
|
&settings.ptt_shortcut,
|
||||||
|
&settings.mute_shortcut,
|
||||||
|
&settings.deafen_shortcut,
|
||||||
|
] {
|
||||||
|
if spec.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
shortcuts
|
||||||
|
.register(spec.as_str())
|
||||||
|
.map_err(|error| format!("комбинация «{spec}» не зарегистрирована: {error}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает строку комбинации; пустая строка и мусор дают `None`.
|
||||||
|
fn parse(spec: &str) -> Option<Shortcut> {
|
||||||
|
let trimmed = spec.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
trimmed.parse::<Shortcut>().ok()
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! Общее состояние процесса: настройки, готовность веб-клиента и очередь
|
||||||
|
//! сообщений, которые нужно передать в страницу.
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::settings::Settings;
|
||||||
|
|
||||||
|
/// Состояние, доступное командам, трею и обработчикам событий.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AppState {
|
||||||
|
inner: Mutex<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Inner {
|
||||||
|
settings: Settings,
|
||||||
|
/// Веб-клиент сообщил, что загрузился и готов принимать события.
|
||||||
|
web_ready: bool,
|
||||||
|
/// Ссылки, пришедшие до готовности клиента (холодный старт deep link).
|
||||||
|
pending_deep_links: Vec<String>,
|
||||||
|
/// Последнее переданное в трей/док число непрочитанных.
|
||||||
|
badge: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// Текущие настройки (копия).
|
||||||
|
pub fn settings(&self) -> Settings {
|
||||||
|
self.lock().settings.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Заменяет настройки в памяти (запись в хранилище — отдельно).
|
||||||
|
pub fn set_settings(&self, settings: Settings) {
|
||||||
|
self.lock().settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отмечает веб-клиент готовым и отдаёт накопленные deep link.
|
||||||
|
pub fn mark_web_ready(&self) -> Vec<String> {
|
||||||
|
let mut inner = self.lock();
|
||||||
|
inner.web_ready = true;
|
||||||
|
std::mem::take(&mut inner.pending_deep_links)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Готов ли веб-клиент принимать сообщения.
|
||||||
|
pub fn web_ready(&self) -> bool {
|
||||||
|
self.lock().web_ready
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ставит deep link в очередь (если клиент ещё не готов).
|
||||||
|
pub fn push_deep_link(&self, url: String) {
|
||||||
|
let mut inner = self.lock();
|
||||||
|
if inner.pending_deep_links.len() < 16 {
|
||||||
|
inner.pending_deep_links.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запоминает число непрочитанных и возвращает его.
|
||||||
|
pub fn set_badge(&self, count: u32) -> u32 {
|
||||||
|
let mut inner = self.lock();
|
||||||
|
inner.badge = count;
|
||||||
|
inner.badge
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||||
|
// Отравленный мьютекс не должен ронять приложение: состояние —
|
||||||
|
// это только настройки и очередь событий.
|
||||||
|
self.inner.lock().unwrap_or_else(|error| error.into_inner())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
//! Иконка в трее: индикатор непрочитанных и меню управления обёрткой
|
||||||
|
//! (docs/client-tauri.md §2 — «открыть, mute, статус, выйти»).
|
||||||
|
|
||||||
|
use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem};
|
||||||
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||||
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::{autostart, commands, settings, shortcuts, updates, window};
|
||||||
|
|
||||||
|
/// Идентификатор иконки в трее.
|
||||||
|
pub const TRAY_ID: &str = "main";
|
||||||
|
|
||||||
|
/// Создаёт иконку в трее с меню по текущим настройкам.
|
||||||
|
pub fn build<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||||
|
let icon = app
|
||||||
|
.default_window_icon()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "в сборке нет иконки приложения".to_string())?;
|
||||||
|
TrayIconBuilder::with_id(TRAY_ID)
|
||||||
|
.icon(icon)
|
||||||
|
.icon_as_template(true)
|
||||||
|
.tooltip("glchat")
|
||||||
|
.menu(&menu(app)?)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.on_menu_event(on_menu_event)
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
// Левый клик по иконке — показать окно, меню остаётся на правом.
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
window::show_main(tray.app_handle());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)
|
||||||
|
.map_err(|error| format!("не удалось создать иконку в трее: {error}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Пересобирает меню: галочки должны совпадать с текущими настройками.
|
||||||
|
pub fn refresh<R: Runtime>(app: &AppHandle<R>) {
|
||||||
|
let Some(tray) = app.tray_by_id(TRAY_ID) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match menu(app) {
|
||||||
|
Ok(menu) => {
|
||||||
|
if let Err(error) = tray.set_menu(Some(menu)) {
|
||||||
|
log::warn!("не удалось обновить меню трея: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => log::warn!("не удалось собрать меню трея: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Собирает меню трея по текущим настройкам.
|
||||||
|
fn menu<R: Runtime>(app: &AppHandle<R>) -> Result<Menu<R>, String> {
|
||||||
|
let current = app.state::<AppState>().settings();
|
||||||
|
let item = |id: &str, title: &str| -> Result<MenuItem<R>, String> {
|
||||||
|
MenuItem::with_id(app, id, title, true, None::<&str>)
|
||||||
|
.map_err(|error| format!("пункт меню «{title}»: {error}"))
|
||||||
|
};
|
||||||
|
let check = |id: &str, title: &str, checked: bool| -> Result<CheckMenuItem<R>, String> {
|
||||||
|
CheckMenuItem::with_id(app, id, title, true, checked, None::<&str>)
|
||||||
|
.map_err(|error| format!("пункт меню «{title}»: {error}"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let open = item("open", "Открыть glchat")?;
|
||||||
|
let client = item("open-settings", "Настройки обёртки…")?;
|
||||||
|
let first_sep = PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?;
|
||||||
|
let autostart_item = check("autostart", "Запускать при входе", current.autostart)?;
|
||||||
|
let close_to_tray = check("close-to-tray", "Закрывать в трей", current.close_to_tray)?;
|
||||||
|
let notifications = check("notifications", "Уведомления", current.notifications)?;
|
||||||
|
let update_item = item("updates", "Проверить обновления…")?;
|
||||||
|
let second_sep = PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?;
|
||||||
|
let quit = item("quit", "Выход")?;
|
||||||
|
|
||||||
|
Menu::with_items(
|
||||||
|
app,
|
||||||
|
&[
|
||||||
|
&open,
|
||||||
|
&client,
|
||||||
|
&first_sep,
|
||||||
|
&autostart_item,
|
||||||
|
&close_to_tray,
|
||||||
|
¬ifications,
|
||||||
|
&update_item,
|
||||||
|
&second_sep,
|
||||||
|
&quit,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("не удалось собрать меню трея: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработчик пунктов меню трея.
|
||||||
|
fn on_menu_event<R: Runtime>(app: &AppHandle<R>, event: tauri::menu::MenuEvent) {
|
||||||
|
match event.id().as_ref() {
|
||||||
|
"open" => window::show_main(app),
|
||||||
|
"open-settings" => {
|
||||||
|
if let Err(error) = window::open_settings_page(app) {
|
||||||
|
log::warn!("{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"autostart" => {
|
||||||
|
let mut current = app.state::<AppState>().settings();
|
||||||
|
current.autostart = !current.autostart;
|
||||||
|
persist(app, current);
|
||||||
|
}
|
||||||
|
"close-to-tray" => {
|
||||||
|
let mut current = app.state::<AppState>().settings();
|
||||||
|
current.close_to_tray = !current.close_to_tray;
|
||||||
|
persist(app, current);
|
||||||
|
}
|
||||||
|
"notifications" => {
|
||||||
|
let mut current = app.state::<AppState>().settings();
|
||||||
|
current.notifications = !current.notifications;
|
||||||
|
persist(app, current);
|
||||||
|
}
|
||||||
|
"updates" => {
|
||||||
|
let handle = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
updates::check(handle, true).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
other => log::debug!("неизвестный пункт меню трея: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Применяет и сохраняет настройки, меняя только один переключатель.
|
||||||
|
fn persist<R: Runtime>(app: &AppHandle<R>, next: settings::Settings) {
|
||||||
|
let previous = app.state::<AppState>().settings();
|
||||||
|
app.state::<AppState>().set_settings(next.clone());
|
||||||
|
if let Err(error) = autostart::apply(app, next.autostart) {
|
||||||
|
log::warn!("{error}");
|
||||||
|
}
|
||||||
|
if next.ptt_shortcut != previous.ptt_shortcut
|
||||||
|
|| next.mute_shortcut != previous.mute_shortcut
|
||||||
|
|| next.deafen_shortcut != previous.deafen_shortcut
|
||||||
|
{
|
||||||
|
if let Err(error) = shortcuts::apply(app, &next) {
|
||||||
|
log::warn!("{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(error) = settings::save(app, &next) {
|
||||||
|
log::warn!("не удалось сохранить настройки: {error}");
|
||||||
|
}
|
||||||
|
refresh(app);
|
||||||
|
commands::announce_settings(app, &next);
|
||||||
|
if next.instance_url != previous.instance_url {
|
||||||
|
if let Err(error) = window::open_instance_page(app) {
|
||||||
|
log::warn!("{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
//! Автообновление (docs/client-tauri.md §4).
|
||||||
|
//!
|
||||||
|
//! Манифест обновлений отдаёт сам инстанс: `GET /updates/{target}/{arch}/{current_version}`
|
||||||
|
//! (статика через Caddy). Артефакты подписаны ключом ed25519, подпись
|
||||||
|
//! проверяется до установки — публичный ключ задаётся в `UPDATER_PUBKEY`.
|
||||||
|
//!
|
||||||
|
//! Пока ключ не заведён (сборка без релизного контура), проверка обновлений
|
||||||
|
//! честно сообщает, что канал не настроен, вместо «суррогатного» обновления.
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
|
||||||
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
|
||||||
|
use crate::bridge::{self, WebMessage};
|
||||||
|
use crate::instance;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Публичный ключ подписи обновлений (ed25519, формат minisign).
|
||||||
|
///
|
||||||
|
/// Приватный ключ в репозиторий не попадает: он живёт в CI-секретах
|
||||||
|
/// (`TAURI_SIGNING_PRIVATE_KEY`), а локально — в `build/desktop-keys/`
|
||||||
|
/// (каталог `build/` в .gitignore). Подпись обязательна: манифест без неё
|
||||||
|
/// плагин отклоняет, downgrade запрещён (docs/client-tauri.md §4, §6).
|
||||||
|
pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCQkQ1NjkwN0EyQzZBOEEKUldTS2FpeDZrRmE5aXhqSThPWXA3am5YMnB5dmVWZWFEcVY4NGI3cG8yOTU1MjdtMW9sR09BNGIK";
|
||||||
|
|
||||||
|
/// Путь манифеста обновлений на инстансе.
|
||||||
|
const UPDATE_PATH: &str = "/updates/{{target}}/{{arch}}/{{current_version}}";
|
||||||
|
|
||||||
|
/// Проверяет обновления. `interactive` — вызов из меню трея или настроек,
|
||||||
|
/// тогда пользователь видит результат в диалоге; фоновая проверка молчит.
|
||||||
|
pub async fn check<R: Runtime>(app: AppHandle<R>, interactive: bool) {
|
||||||
|
if UPDATER_PUBKEY.is_empty() {
|
||||||
|
log::info!("автообновление не настроено: в сборке нет публичного ключа подписи");
|
||||||
|
if interactive {
|
||||||
|
app.dialog()
|
||||||
|
.message(
|
||||||
|
"В этой сборке нет публичного ключа подписи обновлений, поэтому проверка \
|
||||||
|
недоступна. Ключ и манифест обновлений появятся вместе с релизным контуром \
|
||||||
|
(docs/client-tauri.md §4).",
|
||||||
|
)
|
||||||
|
.title("Обновления не настроены")
|
||||||
|
.buttons(MessageDialogButtons::Ok)
|
||||||
|
.show(|_| {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = app.state::<AppState>().settings();
|
||||||
|
let endpoint = match instance::normalize(&settings.instance_url) {
|
||||||
|
Ok(url) => format!("{}{UPDATE_PATH}", instance::display(&url)),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("не удалось определить адрес инстанса для обновлений: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Ok(endpoint) = url::Url::parse(&endpoint) else {
|
||||||
|
log::warn!("некорректный адрес манифеста обновлений: {endpoint}");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let updater = match app
|
||||||
|
.updater_builder()
|
||||||
|
.endpoints(vec![endpoint])
|
||||||
|
.and_then(|builder| builder.pubkey(UPDATER_PUBKEY).build())
|
||||||
|
{
|
||||||
|
Ok(updater) => updater,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("не удалось собрать клиент обновлений: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match updater.check().await {
|
||||||
|
Ok(Some(update)) => {
|
||||||
|
let version = update.version.clone();
|
||||||
|
log::info!("доступно обновление {version}");
|
||||||
|
// Обновление показываем в интерфейсе, установку спрашиваем отдельно:
|
||||||
|
// во время звонка перезапуск приложения недопустим без согласия.
|
||||||
|
bridge::send(&app, &WebMessage::UpdateAvailable { version: version.clone() });
|
||||||
|
prompt_install(app, update, version).await;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log::info!("обновлений нет");
|
||||||
|
if interactive {
|
||||||
|
app.dialog()
|
||||||
|
.message("Установлена последняя версия.")
|
||||||
|
.title("Обновления")
|
||||||
|
.buttons(MessageDialogButtons::Ok)
|
||||||
|
.show(|_| {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("проверка обновлений не удалась: {error}");
|
||||||
|
if interactive {
|
||||||
|
let text = format!("Не удалось проверить обновления: {error}");
|
||||||
|
app.dialog()
|
||||||
|
.message(text)
|
||||||
|
.title("Обновления")
|
||||||
|
.buttons(MessageDialogButtons::Ok)
|
||||||
|
.show(|_| {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Спрашивает согласие и устанавливает обновление.
|
||||||
|
async fn prompt_install<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
update: tauri_plugin_updater::Update,
|
||||||
|
version: String,
|
||||||
|
) {
|
||||||
|
let handle = app.clone();
|
||||||
|
let question = format!(
|
||||||
|
"Доступна версия {version}. Скачать и установить сейчас? Приложение будет перезапущено, \
|
||||||
|
активный голосовой звонок прервётся."
|
||||||
|
);
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<bool>();
|
||||||
|
app.dialog()
|
||||||
|
.message(question)
|
||||||
|
.title("Обновление glchat")
|
||||||
|
.buttons(MessageDialogButtons::YesNo)
|
||||||
|
.show(move |confirmed| {
|
||||||
|
let _ = tx.send(confirmed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Диалог асинхронный: ждём ответ, не блокируя основной поток.
|
||||||
|
let confirmed = tauri::async_runtime::spawn_blocking(move || rx.recv().unwrap_or(false))
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !confirmed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match update
|
||||||
|
.download_and_install(|_chunk, _total| {}, || {})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
log::info!("обновление {version} установлено, перезапуск");
|
||||||
|
handle.restart();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("установка обновления не удалась: {error}");
|
||||||
|
app.dialog()
|
||||||
|
.message(format!(
|
||||||
|
"Обновление не установлено: {error}. Скачать установщик вручную можно на \
|
||||||
|
странице инстанса."
|
||||||
|
))
|
||||||
|
.title("Обновление glchat")
|
||||||
|
.buttons(MessageDialogButtons::Ok)
|
||||||
|
.show(|_| {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//! Главное окно приложения.
|
||||||
|
//!
|
||||||
|
//! Окно одно (docs/client-tauri.md §2): в нём либо веб-клиент инстанса, либо
|
||||||
|
//! локальная страница настроек обёртки. Вторых окон не заводим, повторный
|
||||||
|
//! запуск приложения фокусирует уже открытое окно.
|
||||||
|
|
||||||
|
use tauri::webview::PageLoadEvent;
|
||||||
|
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
use tauri_plugin_window_state::{StateFlags, WindowExt};
|
||||||
|
|
||||||
|
use crate::bridge::WEB_WINDOW;
|
||||||
|
use crate::instance;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Создаёт главное окно с клиентом инстанса.
|
||||||
|
pub fn create_main<R: Runtime>(
|
||||||
|
app: &AppHandle<R>,
|
||||||
|
settings: &Settings,
|
||||||
|
minimized: bool,
|
||||||
|
) -> Result<WebviewWindow<R>, String> {
|
||||||
|
let url = instance::normalize(&settings.instance_url)?;
|
||||||
|
let handle = app.clone();
|
||||||
|
let window = WebviewWindowBuilder::new(app, WEB_WINDOW, WebviewUrl::External(url))
|
||||||
|
.title("glchat")
|
||||||
|
.inner_size(1280.0, 800.0)
|
||||||
|
.min_inner_size(900.0, 600.0)
|
||||||
|
.visible(!minimized)
|
||||||
|
.on_navigation(move |target| navigation_guard(&handle, target))
|
||||||
|
.on_page_load(|_window, payload| {
|
||||||
|
// В логе видно, что именно открыто в окне: это первое, что нужно
|
||||||
|
// при разборе «приложение открылось, но клиента нет».
|
||||||
|
if payload.event() == PageLoadEvent::Finished {
|
||||||
|
log::info!("загружена страница: {}", payload.url());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.map_err(|error| format!("не удалось создать окно: {error}"))?;
|
||||||
|
// Геометрия окна переживает перезапуск (tauri-plugin-window-state).
|
||||||
|
let _ = window.restore_state(StateFlags::all());
|
||||||
|
Ok(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Показывает и фокусирует главное окно (повторный запуск, клик по трею).
|
||||||
|
pub fn show_main<R: Runtime>(app: &AppHandle<R>) {
|
||||||
|
if let Some(window) = app.get_webview_window(WEB_WINDOW) {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет, загружен ли сейчас в окне клиент инстанса (а не страница настроек).
|
||||||
|
pub fn is_instance_page<R: Runtime>(app: &AppHandle<R>) -> bool {
|
||||||
|
let Some(window) = app.get_webview_window(WEB_WINDOW) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(current) = window.url() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let settings = app.state::<AppState>().settings();
|
||||||
|
let Ok(instance_url) = instance::normalize(&settings.instance_url) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
instance::host(¤t) == instance::host(&instance_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открывает локальную страницу настроек обёртки.
|
||||||
|
pub fn open_settings_page<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||||
|
let window = app
|
||||||
|
.get_webview_window(WEB_WINDOW)
|
||||||
|
.ok_or_else(|| "окно не создано".to_string())?;
|
||||||
|
window
|
||||||
|
.navigate(local_page_url("index.html"))
|
||||||
|
.map_err(|error| format!("не удалось открыть настройки: {error}"))?;
|
||||||
|
show_main(app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает окно к клиенту инстанса.
|
||||||
|
pub fn open_instance_page<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||||
|
let settings = app.state::<AppState>().settings();
|
||||||
|
let url = instance::normalize(&settings.instance_url)?;
|
||||||
|
let window = app
|
||||||
|
.get_webview_window(WEB_WINDOW)
|
||||||
|
.ok_or_else(|| "окно не создано".to_string())?;
|
||||||
|
window
|
||||||
|
.navigate(url)
|
||||||
|
.map_err(|error| format!("не удалось открыть клиент: {error}"))?;
|
||||||
|
show_main(app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Адрес локальной страницы обёртки.
|
||||||
|
///
|
||||||
|
/// `WebviewUrl::App` резолвится Tauri во внутренний источник (`tauri://localhost`
|
||||||
|
/// на macOS/Linux, `http://tauri.localhost` на Windows), а `navigate` принимает
|
||||||
|
/// уже готовый URL — собираем его тем же правилом, что и сам Tauri.
|
||||||
|
fn local_page_url(path: &str) -> url::Url {
|
||||||
|
let base = if cfg!(any(windows, target_os = "android")) {
|
||||||
|
url::Url::parse("http://tauri.localhost").expect("базовый адрес локальной страницы")
|
||||||
|
} else {
|
||||||
|
url::Url::parse("tauri://localhost").expect("базовый адрес локальной страницы")
|
||||||
|
};
|
||||||
|
base.join(path).unwrap_or(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Перезагружает клиент инстанса (после смены адреса или по запросу).
|
||||||
|
pub fn reload_instance<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||||
|
let window = app
|
||||||
|
.get_webview_window(WEB_WINDOW)
|
||||||
|
.ok_or_else(|| "окно не создано".to_string())?;
|
||||||
|
if is_instance_page(app) {
|
||||||
|
window
|
||||||
|
.eval("window.location.reload();")
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
open_instance_page(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Граница навигации: чужой адрес открывается в системном браузере.
|
||||||
|
fn navigation_guard<R: Runtime>(app: &AppHandle<R>, target: &url::Url) -> bool {
|
||||||
|
let settings = app.state::<AppState>().settings();
|
||||||
|
let host = instance::normalize(&settings.instance_url)
|
||||||
|
.map(|url| instance::host(&url))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if instance::navigation_allowed(target, &host) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
log::info!("внешняя ссылка уходит в системный браузер: {target}");
|
||||||
|
let _ = app.opener().open_url(target.as_str(), None::<&str>);
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "glchat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "su.mhspx.glchat",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "./ui"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": false,
|
||||||
|
"windows": [],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": [
|
||||||
|
"app",
|
||||||
|
"dmg"
|
||||||
|
],
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
|
"category": "SocialNetworking",
|
||||||
|
"shortDescription": "glchat — self-hosted платформа общения",
|
||||||
|
"longDescription": "Desktop-клиент glchat: тот же веб-клиент в обёртке Tauri с нативными уведомлениями, треем, глобальным push-to-talk и автообновлением.",
|
||||||
|
"copyright": "glchat, AGPL-3.0-or-later",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "10.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"endpoints": [
|
||||||
|
"https://gl.mhspx.su/updates/{{target}}/{{arch}}/{{current_version}}"
|
||||||
|
],
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCQkQ1NjkwN0EyQzZBOEEKUldTS2FpeDZrRmE5aXhqSThPWXA3am5YMnB5dmVWZWFEcVY4NGI3cG8yOTU1MjdtMW9sR09BNGIK",
|
||||||
|
"windows": {
|
||||||
|
"installMode": "passive"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deep-link": {
|
||||||
|
"desktop": {
|
||||||
|
"schemes": [
|
||||||
|
"glchat"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
<title>glchat — настройки приложения</title>
|
||||||
|
<link rel="stylesheet" href="settings.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<header class="head">
|
||||||
|
<h1>Настройки приложения</h1>
|
||||||
|
<p class="muted">
|
||||||
|
Это настройки desktop-обёртки. Настройки аккаунта, серверов и голоса живут внутри самого
|
||||||
|
клиента.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Инстанс</h2>
|
||||||
|
<label class="field">
|
||||||
|
<span>Адрес клиента</span>
|
||||||
|
<input id="instance-url" type="text" spellcheck="false" placeholder="https://gl.mhspx.su" />
|
||||||
|
</label>
|
||||||
|
<p class="hint">
|
||||||
|
Тот же адрес, что открывается в браузере. Сторонние ссылки уходят в системный браузер, а
|
||||||
|
не открываются внутри окна.
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<button id="open-client" class="primary" type="button">Сохранить и открыть клиент</button>
|
||||||
|
<button id="reload-client" type="button">Перезагрузить клиент</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Поведение</h2>
|
||||||
|
<label class="check">
|
||||||
|
<input id="autostart" type="checkbox" />
|
||||||
|
<span>Запускать при входе в систему (свёрнутым в трей)</span>
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input id="close-to-tray" type="checkbox" />
|
||||||
|
<span>Закрытие окна сворачивает приложение в трей</span>
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input id="notifications" type="checkbox" />
|
||||||
|
<span>Показывать системные уведомления об упоминаниях и личных сообщениях</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Глобальные горячие клавиши</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Работают, когда окно неактивно. Push-to-talk включает микрофон, пока клавиша удерживается:
|
||||||
|
в клиенте должен быть выбран режим микрофона «Push-to-talk» (Настройки → Голос).
|
||||||
|
</p>
|
||||||
|
<label class="field">
|
||||||
|
<span>Push-to-talk</span>
|
||||||
|
<select id="ptt-shortcut"></select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Микрофон вкл/выкл</span>
|
||||||
|
<select id="mute-shortcut"></select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Не слышу (deafen)</span>
|
||||||
|
<select id="deafen-shortcut"></select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Обновления</h2>
|
||||||
|
<p class="hint" id="updates-hint">
|
||||||
|
Обновления проверяются при запуске и вручную. Манифест отдаёт сам инстанс.
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<button id="check-updates" type="button">Проверить обновления</button>
|
||||||
|
<span class="muted" id="version"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<button id="save" class="primary" type="button">Сохранить</button>
|
||||||
|
<span id="status" class="status" role="status"></span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="settings.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #1a1b1e;
|
||||||
|
--panel: #232428;
|
||||||
|
--border: #33353b;
|
||||||
|
--fg: #e6e7ea;
|
||||||
|
--muted: #9a9ca3;
|
||||||
|
--accent: #4c5bd4;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f5f6f8;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--border: #dcdee4;
|
||||||
|
--fg: #1c1d21;
|
||||||
|
--muted: #5f626b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font:
|
||||||
|
14px/1.5 -apple-system,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head h1 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > span {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'],
|
||||||
|
select {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text']:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 14px 0 0;
|
||||||
|
background: linear-gradient(transparent, var(--bg) 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
color: #e06c75;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Страница настроек desktop-обёртки.
|
||||||
|
//
|
||||||
|
// Локальная страница (tauri://localhost) внутри главного окна: команды обёртки
|
||||||
|
// вызываются через встроенный IPC Tauri. Ничего, кроме настроек платформы,
|
||||||
|
// здесь нет — настройки клиента живут в самом клиенте.
|
||||||
|
|
||||||
|
/** Вызов команды обёртки: тонкая обёртка над IPC Tauri v2. */
|
||||||
|
const invoke = (command, args) => window.__TAURI_INTERNALS__.invoke(command, args);
|
||||||
|
|
||||||
|
/** Варианты комбинаций: пустая строка — «выключено». */
|
||||||
|
const OFF = { value: '', label: 'Выключено' };
|
||||||
|
|
||||||
|
const PTT_OPTIONS = [
|
||||||
|
OFF,
|
||||||
|
{ value: 'CommandOrControl+Shift+Space', label: 'Cmd/Ctrl + Shift + Пробел' },
|
||||||
|
{ value: 'CommandOrControl+Shift+M', label: 'Cmd/Ctrl + Shift + M' },
|
||||||
|
{ value: 'CommandOrControl+Shift+V', label: 'Cmd/Ctrl + Shift + V' },
|
||||||
|
{ value: 'CommandOrControl+Shift+B', label: 'Cmd/Ctrl + Shift + B' },
|
||||||
|
{ value: 'Control+Shift+Space', label: 'Ctrl + Shift + Пробел' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MUTE_OPTIONS = [
|
||||||
|
OFF,
|
||||||
|
{ value: 'CommandOrControl+Shift+M', label: 'Cmd/Ctrl + Shift + M' },
|
||||||
|
{ value: 'CommandOrControl+Shift+U', label: 'Cmd/Ctrl + Shift + U' },
|
||||||
|
{ value: 'Control+Shift+M', label: 'Ctrl + Shift + M' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEAFEN_OPTIONS = [
|
||||||
|
OFF,
|
||||||
|
{ value: 'CommandOrControl+Shift+D', label: 'Cmd/Ctrl + Shift + D' },
|
||||||
|
{ value: 'CommandOrControl+Shift+F', label: 'Cmd/Ctrl + Shift + F' },
|
||||||
|
{ value: 'Control+Shift+D', label: 'Ctrl + Shift + D' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Поля формы и соответствующие настройки. */
|
||||||
|
const fields = {
|
||||||
|
instanceUrl: document.getElementById('instance-url'),
|
||||||
|
autostart: document.getElementById('autostart'),
|
||||||
|
closeToTray: document.getElementById('close-to-tray'),
|
||||||
|
notifications: document.getElementById('notifications'),
|
||||||
|
pttShortcut: document.getElementById('ptt-shortcut'),
|
||||||
|
muteShortcut: document.getElementById('mute-shortcut'),
|
||||||
|
deafenShortcut: document.getElementById('deafen-shortcut'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const version = document.getElementById('version');
|
||||||
|
const updatesHint = document.getElementById('updates-hint');
|
||||||
|
|
||||||
|
/** Заполняет выпадающий список комбинаций. */
|
||||||
|
function fillSelect(select, options, current) {
|
||||||
|
select.replaceChildren();
|
||||||
|
for (const option of options) {
|
||||||
|
const node = document.createElement('option');
|
||||||
|
node.value = option.value;
|
||||||
|
node.textContent = option.label;
|
||||||
|
select.append(node);
|
||||||
|
}
|
||||||
|
// Комбинация могла быть задана вручную (например, прежней версией) — не теряем её.
|
||||||
|
if (current && !options.some((option) => option.value === current)) {
|
||||||
|
const node = document.createElement('option');
|
||||||
|
node.value = current;
|
||||||
|
node.textContent = current;
|
||||||
|
select.append(node);
|
||||||
|
}
|
||||||
|
select.value = current ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Показывает состояние сохранения. */
|
||||||
|
function setStatus(text, isError = false) {
|
||||||
|
status.textContent = text;
|
||||||
|
status.classList.toggle('error', isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переносит настройки в форму. */
|
||||||
|
function render(settings) {
|
||||||
|
fields.instanceUrl.value = settings.instanceUrl ?? '';
|
||||||
|
fields.autostart.checked = Boolean(settings.autostart);
|
||||||
|
fields.closeToTray.checked = Boolean(settings.closeToTray);
|
||||||
|
fields.notifications.checked = Boolean(settings.notifications);
|
||||||
|
fillSelect(fields.pttShortcut, PTT_OPTIONS, settings.pttShortcut);
|
||||||
|
fillSelect(fields.muteShortcut, MUTE_OPTIONS, settings.muteShortcut);
|
||||||
|
fillSelect(fields.deafenShortcut, DEAFEN_OPTIONS, settings.deafenShortcut);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Собирает настройки из формы. */
|
||||||
|
function collect() {
|
||||||
|
return {
|
||||||
|
instanceUrl: fields.instanceUrl.value.trim(),
|
||||||
|
autostart: fields.autostart.checked,
|
||||||
|
closeToTray: fields.closeToTray.checked,
|
||||||
|
notifications: fields.notifications.checked,
|
||||||
|
pttShortcut: fields.pttShortcut.value,
|
||||||
|
muteShortcut: fields.muteShortcut.value,
|
||||||
|
deafenShortcut: fields.deafenShortcut.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сохраняет настройки и, если нужно, открывает клиент. */
|
||||||
|
async function save({ openClient = false } = {}) {
|
||||||
|
setStatus('Сохраняем…');
|
||||||
|
try {
|
||||||
|
const saved = await invoke('desktop_save_settings', { settings: collect() });
|
||||||
|
render(saved);
|
||||||
|
setStatus('Сохранено');
|
||||||
|
if (openClient) {
|
||||||
|
await invoke('desktop_open_instance');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(String(error), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('save').addEventListener('click', () => {
|
||||||
|
void save();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('open-client').addEventListener('click', () => {
|
||||||
|
void save({ openClient: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('reload-client').addEventListener('click', async () => {
|
||||||
|
await save();
|
||||||
|
try {
|
||||||
|
await invoke('desktop_reload');
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(String(error), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('check-updates').addEventListener('click', async () => {
|
||||||
|
updatesHint.textContent = 'Проверяем обновления…';
|
||||||
|
try {
|
||||||
|
await invoke('desktop_check_updates');
|
||||||
|
updatesHint.textContent = 'Проверка завершена: результат показан в системном диалоге.';
|
||||||
|
} catch (error) {
|
||||||
|
updatesHint.textContent = String(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Автосохранение переключателей и списков: отдельная кнопка нужна только для адреса.
|
||||||
|
for (const node of [
|
||||||
|
fields.autostart,
|
||||||
|
fields.closeToTray,
|
||||||
|
fields.notifications,
|
||||||
|
fields.pttShortcut,
|
||||||
|
fields.muteShortcut,
|
||||||
|
fields.deafenShortcut,
|
||||||
|
]) {
|
||||||
|
node.addEventListener('change', () => {
|
||||||
|
void save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
render(await invoke('desktop_settings'));
|
||||||
|
version.textContent = `Версия обёртки: ${await invoke('desktop_version')}`;
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Не удалось прочитать настройки: ${error}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void start();
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { RouterProvider } from 'react-router';
|
import { RouterProvider } from 'react-router';
|
||||||
|
|
||||||
|
import { DesktopBridge } from '@/components/DesktopBridge';
|
||||||
import { Card } from '@/components/ui/primitives';
|
import { Card } from '@/components/ui/primitives';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { router } from '@/router';
|
import { router } from '@/router';
|
||||||
@@ -20,6 +21,8 @@ export function RouteFallback() {
|
|||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<RouteFallback />}>
|
<Suspense fallback={<RouteFallback />}>
|
||||||
|
{/* Мост desktop-обёртки: события платформы и счётчик непрочитанных. */}
|
||||||
|
<DesktopBridge />
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { unreadCount, useMessagesStore } from '@/stores/messages';
|
||||||
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
|
import { platform, routeFromDeepLink } from '@/lib/platform';
|
||||||
|
import { router } from '@/router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мост desktop-обёртки (docs/client-tauri.md §2).
|
||||||
|
*
|
||||||
|
* Обёртка присылает события платформы (глобальный push-to-talk, mute/deafen,
|
||||||
|
* deep link, готовность обновления), а клиент отдаёт ей счётчик непрочитанных.
|
||||||
|
* В браузере компонент безвреден: реализация платформы там пустая.
|
||||||
|
*/
|
||||||
|
export function DesktopBridge() {
|
||||||
|
// События обёртки: глобальные шорткаты, deep links, обновления.
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
platform.onMessage((message) => {
|
||||||
|
const voice = useVoiceConnectionStore.getState();
|
||||||
|
switch (message.type) {
|
||||||
|
case 'ptt':
|
||||||
|
// Удержание клавиши: микрофон включается, пока она нажата.
|
||||||
|
voice.setPushToTalkActive(message.active);
|
||||||
|
break;
|
||||||
|
case 'toggleMic':
|
||||||
|
void voice.setMicEnabled(!voice.micEnabled);
|
||||||
|
break;
|
||||||
|
case 'toggleDeafen':
|
||||||
|
void voice.setDeafened(!voice.deafened);
|
||||||
|
break;
|
||||||
|
case 'deepLink': {
|
||||||
|
const route = routeFromDeepLink(message.url);
|
||||||
|
if (route === null) {
|
||||||
|
console.warn('[desktop] неизвестная ссылка', message.url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
void router.navigate(route);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'updateAvailable':
|
||||||
|
platform.notify({
|
||||||
|
title: 'Доступно обновление glchat',
|
||||||
|
body: `Версия ${message.version}. Установить можно из настроек приложения.`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
// Настройки обёртки клиенту не нужны: он читает только свои.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обёртка отдаёт накопленные deep links после этого сигнала.
|
||||||
|
useEffect(() => {
|
||||||
|
platform.ready();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Счётчик непрочитанных для значка приложения и иконки в трее.
|
||||||
|
useEffect(() => {
|
||||||
|
let last = -1;
|
||||||
|
const sync = (): void => {
|
||||||
|
const channels = useMessagesStore.getState().channels;
|
||||||
|
let total = 0;
|
||||||
|
for (const record of Object.values(channels)) {
|
||||||
|
total += unreadCount(record);
|
||||||
|
}
|
||||||
|
if (total !== last) {
|
||||||
|
last = total;
|
||||||
|
platform.setBadge(total);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
return useMessagesStore.subscribe(sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1107,8 +1107,11 @@
|
|||||||
"navLabel": "Settings sections",
|
"navLabel": "Settings sections",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"description": "Sounds, mentions and web push.",
|
"description": "System notifications for mentions and direct messages.",
|
||||||
"stub": "Notification settings are coming later."
|
"desktop": "Desktop notifications",
|
||||||
|
"desktopHint": "Show system notifications when the window is inactive or the channel is not open. Native in the desktop app, permission-based in the browser.",
|
||||||
|
"desktopSettings": "Desktop app settings",
|
||||||
|
"unsupported": "This browser does not show system notifications: open the client in the desktop app."
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"title": "Audio & video",
|
"title": "Audio & video",
|
||||||
|
|||||||
@@ -1107,8 +1107,11 @@
|
|||||||
"navLabel": "Пункты настроек",
|
"navLabel": "Пункты настроек",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Уведомления",
|
"title": "Уведомления",
|
||||||
"description": "Звуки, упоминания и веб-пуш.",
|
"description": "Системные уведомления об упоминаниях и личных сообщениях.",
|
||||||
"stub": "Настройки уведомлений появятся позже."
|
"desktop": "Уведомления рабочего стола",
|
||||||
|
"desktopHint": "Показывать системные уведомления, когда окно неактивно или комната не открыта. В desktop-приложении уведомления нативные, в браузере — при разрешённых уведомлениях.",
|
||||||
|
"desktopSettings": "Настройки desktop-приложения",
|
||||||
|
"unsupported": "Браузер не показывает системные уведомления: откройте клиент в desktop-приложении."
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"title": "Аудио-видео",
|
"title": "Аудио-видео",
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Нативные уведомления desktop-обёртки (docs/client-tauri.md §5).
|
||||||
|
*
|
||||||
|
* Решение «показывать или нет» принимает клиент: только упоминания и личные
|
||||||
|
* сообщения, только когда окно неактивно или комната не открыта, и только если
|
||||||
|
* пользователь не выключил уведомления в настройках. Текст уведомления готовит
|
||||||
|
* этот модуль, показывает — платформенный адаптер.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Message } from '@/api/messages';
|
||||||
|
import type { DesktopNotification } from '@/lib/platform';
|
||||||
|
|
||||||
|
/** Ключ предпочтения в localStorage (настройка устройства, не аккаунта). */
|
||||||
|
const STORAGE_KEY = 'glchat.desktop-notifications';
|
||||||
|
|
||||||
|
/** Сколько символов сообщения показываем в уведомлении. */
|
||||||
|
const PREVIEW_LIMIT = 140;
|
||||||
|
|
||||||
|
/** Включены ли уведомления рабочего стола (по умолчанию — да). */
|
||||||
|
export function notificationsEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(STORAGE_KEY) !== 'off';
|
||||||
|
} catch {
|
||||||
|
// Приватный режим браузера может запретить localStorage — считаем включёнными.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переключает уведомления рабочего стола. */
|
||||||
|
export function setNotificationsEnabled(enabled: boolean): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, enabled ? 'on' : 'off');
|
||||||
|
} catch {
|
||||||
|
// Без localStorage предпочтение живёт до перезагрузки страницы.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Данные, от которых зависит решение об уведомлении. */
|
||||||
|
export interface NotificationInput {
|
||||||
|
message: Pick<Message, 'type' | 'content' | 'mentions' | 'channel_id' | 'author_id'>;
|
||||||
|
/** Мой идентификатор: свои сообщения не уведомляют. */
|
||||||
|
meId: string | null;
|
||||||
|
/** Комната — личная беседа. */
|
||||||
|
isDirect: boolean;
|
||||||
|
/** Имя комнаты для заголовка. */
|
||||||
|
channelName: string | null;
|
||||||
|
/** Сервер комнаты (в личной беседе — null). */
|
||||||
|
guildId: string | null;
|
||||||
|
/** Комната открыта в клиенте. */
|
||||||
|
isChannelOpen: boolean;
|
||||||
|
/** Окно клиента в фокусе. */
|
||||||
|
windowFocused: boolean;
|
||||||
|
/** Уведомления включены пользователем. */
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Убирает Markdown-разметку и лишние переводы строк из текста сообщения. */
|
||||||
|
export function previewText(content: string): string {
|
||||||
|
const plain = content
|
||||||
|
.replace(/```[\s\S]*?```/g, ' [код] ')
|
||||||
|
.replace(/`([^`]*)`/g, '$1')
|
||||||
|
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||||
|
.replace(/[*_~>#]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (plain.length <= PREVIEW_LIMIT) {
|
||||||
|
return plain;
|
||||||
|
}
|
||||||
|
return `${plain.slice(0, PREVIEW_LIMIT - 1).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст уведомления или `null`, если показывать не нужно.
|
||||||
|
*
|
||||||
|
* Чистая функция: её поведение проверяется тестами без DOM и без обёртки.
|
||||||
|
*/
|
||||||
|
export function notificationFor(input: NotificationInput): DesktopNotification | null {
|
||||||
|
if (!input.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { message } = input;
|
||||||
|
// Системные сообщения (входы, баны) уведомлений не требуют.
|
||||||
|
if (message.type === 'system') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (input.meId !== null && message.author_id === input.meId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mentionsMe = input.meId !== null && message.mentions.includes(input.meId);
|
||||||
|
if (!mentionsMe && !input.isDirect) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Комната открыта и окно активно — пользователь и так видит сообщение.
|
||||||
|
if (input.isChannelOpen && input.windowFocused) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const body = previewText(message.content);
|
||||||
|
if (body === '' && message.mentions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const title = input.isDirect
|
||||||
|
? input.channelName === null || input.channelName === ''
|
||||||
|
? 'Личное сообщение'
|
||||||
|
: `Личное сообщение — ${input.channelName}`
|
||||||
|
: input.channelName === null || input.channelName === ''
|
||||||
|
? 'Упоминание'
|
||||||
|
: `Упоминание в #${input.channelName}`;
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
body: body === '' ? 'Вложение' : body,
|
||||||
|
guildId: input.guildId,
|
||||||
|
channelId: message.channel_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Платформенный адаптер клиента (docs/client-tauri.md §2).
|
||||||
|
*
|
||||||
|
* Один и тот же веб-код работает и в браузере, и внутри desktop-обёртки Tauri:
|
||||||
|
* реализация выбирается при старте по наличию моста Tauri, а вызовы платформы
|
||||||
|
* идут только через этот модуль. В браузере доступно не всё (нативные
|
||||||
|
* уведомления — по разрешению браузера, трея и бейджа нет), поэтому у каждой
|
||||||
|
* реализации свои возможности, а не заглушки «на будущее».
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Настройки обёртки: поля совпадают с Rust-структурой `Settings`. */
|
||||||
|
export interface DesktopSettings {
|
||||||
|
/** Адрес инстанса, загруженного в окно. */
|
||||||
|
instanceUrl: string;
|
||||||
|
/** Закрытие окна сворачивает приложение в трей. */
|
||||||
|
closeToTray: boolean;
|
||||||
|
/** Автозапуск при входе в систему. */
|
||||||
|
autostart: boolean;
|
||||||
|
/** Вопрос об автозапуске уже задавали. */
|
||||||
|
autostartPrompted: boolean;
|
||||||
|
pttShortcut: string;
|
||||||
|
muteShortcut: string;
|
||||||
|
deafenShortcut: string;
|
||||||
|
/** Нативные уведомления включены. */
|
||||||
|
notifications: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сообщение из обёртки (см. `WebMessage` в desktop/src-tauri/src/bridge.rs). */
|
||||||
|
export type DesktopMessage =
|
||||||
|
| { type: 'ptt'; active: boolean }
|
||||||
|
| { type: 'toggleMic' }
|
||||||
|
| { type: 'toggleDeafen' }
|
||||||
|
| { type: 'deepLink'; url: string }
|
||||||
|
| { type: 'settings'; settings: DesktopSettings }
|
||||||
|
| { type: 'updateAvailable'; version: string };
|
||||||
|
|
||||||
|
/** Уведомление для системы. Текст готовит клиент, показывает — платформа. */
|
||||||
|
export interface DesktopNotification {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
guildId?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlatformKind = 'web' | 'tauri';
|
||||||
|
|
||||||
|
export interface Platform {
|
||||||
|
readonly kind: PlatformKind;
|
||||||
|
/** Показать системное уведомление (если платформа это умеет). */
|
||||||
|
notify(notification: DesktopNotification): void;
|
||||||
|
/** Число непрочитанных для значка приложения и трея. */
|
||||||
|
setBadge(count: number): void;
|
||||||
|
/** Сообщить обёртке, что клиент загрузился (deep links ждут этого). */
|
||||||
|
ready(): void;
|
||||||
|
/** Открыть страницу настроек обёртки. */
|
||||||
|
openSettings(): void;
|
||||||
|
/** Подписка на сообщения обёртки; возвращает функцию отписки. */
|
||||||
|
onMessage(handler: (message: DesktopMessage) => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Мост Tauri v2: этого объекта в браузере нет. */
|
||||||
|
interface TauriInternals {
|
||||||
|
invoke(command: string, args?: Record<string, unknown>): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopBridgeGlobal {
|
||||||
|
__TAURI_INTERNALS__?: TauriInternals;
|
||||||
|
__glchatDesktop?: { receive: (message: DesktopMessage) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tauri(): TauriInternals | null {
|
||||||
|
const bridge = window as unknown as DesktopBridgeGlobal;
|
||||||
|
const internals = bridge.__TAURI_INTERNALS__;
|
||||||
|
return internals !== undefined && typeof internals.invoke === 'function' ? internals : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Открыт ли клиент внутри desktop-обёртки. */
|
||||||
|
export function isTauri(): boolean {
|
||||||
|
return tauri() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реализация для браузера: системных возможностей почти нет. */
|
||||||
|
const webPlatform: Platform = {
|
||||||
|
kind: 'web',
|
||||||
|
notify(notification) {
|
||||||
|
// В браузере показываем только при уже выданном разрешении: запрашивать
|
||||||
|
// его без действия пользователя нельзя, а веб-пуш — отдельная фаза.
|
||||||
|
if (!('Notification' in window) || Notification.permission !== 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new Notification(notification.title, { body: notification.body });
|
||||||
|
} catch {
|
||||||
|
// Браузер может запретить уведомление (например, в фоновой вкладке) —
|
||||||
|
// это не ошибка клиента.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setBadge(count) {
|
||||||
|
// В браузере счётчик виден в заголовке вкладки: это делает сам клиент.
|
||||||
|
void count;
|
||||||
|
},
|
||||||
|
ready() {},
|
||||||
|
openSettings() {},
|
||||||
|
onMessage() {
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Реализация для desktop-обёртки: тонкие вызовы команд Rust. */
|
||||||
|
function tauriPlatform(internals: TauriInternals): Platform {
|
||||||
|
const handlers = new Set<(message: DesktopMessage) => void>();
|
||||||
|
const bridge = window as unknown as DesktopBridgeGlobal;
|
||||||
|
bridge.__glchatDesktop = {
|
||||||
|
receive(message) {
|
||||||
|
for (const handler of handlers) {
|
||||||
|
handler(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Команды обёртки не должны ронять клиент: ошибку только логируем. */
|
||||||
|
const call = (command: string, args?: Record<string, unknown>): void => {
|
||||||
|
void internals.invoke(command, args).catch((error: unknown) => {
|
||||||
|
console.warn(`[desktop] команда ${command} не выполнена`, error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'tauri',
|
||||||
|
notify(notification) {
|
||||||
|
call('desktop_notify', {
|
||||||
|
payload: {
|
||||||
|
title: notification.title,
|
||||||
|
body: notification.body,
|
||||||
|
guildId: notification.guildId ?? null,
|
||||||
|
channelId: notification.channelId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setBadge(count) {
|
||||||
|
call('desktop_set_badge', { count });
|
||||||
|
},
|
||||||
|
ready() {
|
||||||
|
call('desktop_ready');
|
||||||
|
},
|
||||||
|
openSettings() {
|
||||||
|
call('desktop_open_settings');
|
||||||
|
},
|
||||||
|
onMessage(handler) {
|
||||||
|
handlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
handlers.delete(handler);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const internals = tauri();
|
||||||
|
|
||||||
|
/** Платформа текущего запуска: браузер или desktop-обёртка. */
|
||||||
|
export const platform: Platform = internals === null ? webPlatform : tauriPlatform(internals);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маршрут клиента для deep link обёртки (docs/client-tauri.md §5).
|
||||||
|
*
|
||||||
|
* Поддерживаются `glchat://invite/<code>` и
|
||||||
|
* `glchat://guild/<guildId>/channel/<channelId>`; неизвестная ссылка даёт `null`.
|
||||||
|
*/
|
||||||
|
export function routeFromDeepLink(link: string): string | null {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(link);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'glchat:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segments = url.pathname.split('/').filter((segment) => segment !== '');
|
||||||
|
// В `glchat://invite/ABC` хост — это первый сегмент пути.
|
||||||
|
const [kind, ...rest] = [url.hostname, ...segments];
|
||||||
|
if (kind === 'invite') {
|
||||||
|
const code = rest[0];
|
||||||
|
return rest.length === 1 && code !== undefined ? `/invite/${encodeURIComponent(code)}` : null;
|
||||||
|
}
|
||||||
|
if (kind === 'guild') {
|
||||||
|
const [guildId, marker, channelId] = rest;
|
||||||
|
if (
|
||||||
|
rest.length !== 3 ||
|
||||||
|
marker !== 'channel' ||
|
||||||
|
guildId === undefined ||
|
||||||
|
channelId === undefined
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `/app/${encodeURIComponent(guildId)}/${encodeURIComponent(channelId)}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,22 +1,61 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { notificationsEnabled, setNotificationsEnabled } from '@/lib/desktopNotifications';
|
||||||
|
import { platform } from '@/lib/platform';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* «Уведомления»: пункт уже есть в списке настроек, но сами уведомления
|
* «Уведомления»: системные уведомления об упоминаниях и личных сообщениях.
|
||||||
* (веб-пуш и звуки) появятся в следующих фазах — показываем заглушку.
|
*
|
||||||
|
* Предпочтение хранится на устройстве (localStorage): в desktop-обёртке
|
||||||
|
* уведомления нативные, в браузере — только при выданном разрешении, а
|
||||||
|
* веб-пуш (Фаза 7) появится отдельным пунктом.
|
||||||
*/
|
*/
|
||||||
export function NotificationsSettingsSection() {
|
export function NotificationsSettingsSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [enabled, setEnabled] = useState(notificationsEnabled);
|
||||||
|
// В обёртке уведомления всегда доступны, в браузере — по разрешению.
|
||||||
|
const supported = platform.kind === 'tauri' || 'Notification' in window;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t('settings.notifications.title')}
|
title={t('settings.notifications.title')}
|
||||||
description={t('settings.notifications.description')}
|
description={t('settings.notifications.description')}
|
||||||
>
|
>
|
||||||
<p className="mt-3 text-sm text-fg-muted" role="status">
|
<label className="mt-3 flex items-start gap-3 text-sm">
|
||||||
{t('settings.notifications.stub')}
|
<input
|
||||||
</p>
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
setEnabled(event.target.checked);
|
||||||
|
setNotificationsEnabled(event.target.checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block font-medium">{t('settings.notifications.desktop')}</span>
|
||||||
|
<span className="block text-fg-muted">{t('settings.notifications.desktopHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{supported ? null : (
|
||||||
|
<p className="mt-3 text-sm text-fg-muted" role="status">
|
||||||
|
{t('settings.notifications.unsupported')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{platform.kind === 'tauri' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-4 rounded-md border border-border px-3 py-1.5 text-sm hover:border-accent"
|
||||||
|
onClick={() => {
|
||||||
|
platform.openSettings();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.notifications.desktopSettings')}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
parseUserUpdate,
|
parseUserUpdate,
|
||||||
} from '@/api/gatewayEvents';
|
} from '@/api/gatewayEvents';
|
||||||
import { guildSoundsQueryKey } from '@/api/sounds';
|
import { guildSoundsQueryKey } from '@/api/sounds';
|
||||||
import { parseSystemMessage } from '@/lib/systemMessage';
|
|
||||||
import { useFriendsStore } from '@/stores/friends';
|
import { useFriendsStore } from '@/stores/friends';
|
||||||
import { useMessagesStore } from '@/stores/messages';
|
import { useMessagesStore } from '@/stores/messages';
|
||||||
import { useSessionStore, type SessionGuild } from '@/stores/session';
|
import { useSessionStore, type SessionGuild } from '@/stores/session';
|
||||||
@@ -39,6 +38,9 @@ import { playEventSound, useSoundboardStore } from '@/stores/soundboard';
|
|||||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
import { parseVoiceStateEvent } from '@/api/voice';
|
import { parseVoiceStateEvent } from '@/api/voice';
|
||||||
import type { Message } from '@/api/messages';
|
import type { Message } from '@/api/messages';
|
||||||
|
import { notificationFor, notificationsEnabled } from '@/lib/desktopNotifications';
|
||||||
|
import { platform } from '@/lib/platform';
|
||||||
|
import { parseSystemMessage } from '@/lib/systemMessage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Состояние соединения со шлюзом. Данные приходят в session-store,
|
* Состояние соединения со шлюзом. Данные приходят в session-store,
|
||||||
@@ -232,6 +234,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
// В личной беседе «последнее сообщение» — источник бейджа и порядка.
|
// В личной беседе «последнее сообщение» — источник бейджа и порядка.
|
||||||
session.touchDmChannel(message.channel_id, message.id, message.created_at);
|
session.touchDmChannel(message.channel_id, message.id, message.created_at);
|
||||||
playMessageSound(message);
|
playMessageSound(message);
|
||||||
|
notifyAboutMessage(message);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -448,6 +451,37 @@ function playMessageSound(message: Message): void {
|
|||||||
playEventSound(guildId, me !== null && message.mentions.includes(me) ? 'mention' : 'message');
|
playEventSound(guildId, me !== null && message.mentions.includes(me) ? 'mention' : 'message');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Системное уведомление об упоминании или личном сообщении
|
||||||
|
* (docs/client-tauri.md §5). Решение принимает `notificationFor`,
|
||||||
|
* а показывает его платформенный адаптер.
|
||||||
|
*/
|
||||||
|
function notifyAboutMessage(message: Message): void {
|
||||||
|
const session = useSessionStore.getState();
|
||||||
|
const me = session.user?.id ?? null;
|
||||||
|
const dmChannel = session.dmChannels.find((channel) => channel.id === message.channel_id);
|
||||||
|
const guildId = guildIdOfChannel(session.guilds, message.channel_id);
|
||||||
|
const guildChannel =
|
||||||
|
guildId === null
|
||||||
|
? undefined
|
||||||
|
: session.guilds
|
||||||
|
.find((guild) => guild.id === guildId)
|
||||||
|
?.channels.find((channel) => channel.id === message.channel_id);
|
||||||
|
const notification = notificationFor({
|
||||||
|
message,
|
||||||
|
meId: me,
|
||||||
|
isDirect: dmChannel !== undefined,
|
||||||
|
channelName: dmChannel?.name ?? guildChannel?.name ?? null,
|
||||||
|
guildId,
|
||||||
|
isChannelOpen: session.selectedChannelId === message.channel_id,
|
||||||
|
windowFocused: document.hasFocus(),
|
||||||
|
enabled: notificationsEnabled(),
|
||||||
|
});
|
||||||
|
if (notification !== null) {
|
||||||
|
platform.notify(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Сервер, которому принадлежит комната: в личной беседе его нет. */
|
/** Сервер, которому принадлежит комната: в личной беседе его нет. */
|
||||||
function guildIdOfChannel(guilds: SessionGuild[], channelId: string): string | null {
|
function guildIdOfChannel(guilds: SessionGuild[], channelId: string): string | null {
|
||||||
for (const guild of guilds) {
|
for (const guild of guilds) {
|
||||||
@@ -466,6 +500,10 @@ async function addGuildFromRest(guildId: string): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
const client = getQueryClient();
|
const client = getQueryClient();
|
||||||
try {
|
try {
|
||||||
|
// Список серверов мог быть прочитан раньше (кэш свеж 30 секунд), поэтому
|
||||||
|
// сначала помечаем его устаревшим: без этого возврат в сервер после кика
|
||||||
|
// не появлялся в рейке до перезагрузки (AGENT.md 11.6).
|
||||||
|
await client.invalidateQueries({ queryKey: users.myGuildsQueryKey });
|
||||||
const guilds = await client.fetchQuery({
|
const guilds = await client.fetchQuery({
|
||||||
queryKey: users.myGuildsQueryKey,
|
queryKey: users.myGuildsQueryKey,
|
||||||
queryFn: () => users.fetchMyGuilds(),
|
queryFn: () => users.fetchMyGuilds(),
|
||||||
@@ -475,8 +513,7 @@ async function addGuildFromRest(guildId: string): Promise<void> {
|
|||||||
useSessionStore.getState().upsertGuild(guild);
|
useSessionStore.getState().upsertGuild(guild);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Сеть подвела — просто помечаем данные устаревшими для следующего рендера.
|
// Сеть подвела — данные уже помечены устаревшими для следующего рендера.
|
||||||
await client.invalidateQueries({ queryKey: users.myGuildsQueryKey });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,10 +605,11 @@ async function invalidateCurrentUser(): Promise<void> {
|
|||||||
import('@/lib/queryClient'),
|
import('@/lib/queryClient'),
|
||||||
import('@/api/users'),
|
import('@/api/users'),
|
||||||
]);
|
]);
|
||||||
|
const client = getQueryClient();
|
||||||
// Именно refetch, а не removeQueries: AuthGuard узнаёт о мёртвой сессии по
|
// Именно refetch, а не removeQueries: AuthGuard узнаёт о мёртвой сессии по
|
||||||
// 401 на запросе профиля и только тогда уводит на экран входа (AGENT.md 11.6).
|
// 401 на запросе профиля и только тогда уводит на экран входа (AGENT.md 11.6).
|
||||||
getQueryClient().removeQueries({ queryKey: currentUserQueryKey });
|
client.removeQueries({ queryKey: currentUserQueryKey });
|
||||||
await getQueryClient().refetchQueries({ queryKey: currentUserQueryKey });
|
await client.refetchQueries({ queryKey: currentUserQueryKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refetchCurrentUser(): Promise<void> {
|
async function refetchCurrentUser(): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
notificationFor,
|
||||||
|
notificationsEnabled,
|
||||||
|
previewText,
|
||||||
|
setNotificationsEnabled,
|
||||||
|
} from '@/lib/desktopNotifications';
|
||||||
|
import type { NotificationInput } from '@/lib/desktopNotifications';
|
||||||
|
|
||||||
|
/** Сообщение по умолчанию: обычное сообщение в канале сервера. */
|
||||||
|
function input(overrides: Partial<NotificationInput> = {}): NotificationInput {
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
type: 'default',
|
||||||
|
content: 'привет',
|
||||||
|
mentions: [],
|
||||||
|
channel_id: 'c1',
|
||||||
|
author_id: 'u2',
|
||||||
|
},
|
||||||
|
meId: 'u1',
|
||||||
|
isDirect: false,
|
||||||
|
channelName: 'общий',
|
||||||
|
guildId: 'g1',
|
||||||
|
isChannelOpen: false,
|
||||||
|
windowFocused: false,
|
||||||
|
enabled: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('notificationFor', () => {
|
||||||
|
it('уведомляет об упоминании в неактивной комнате', () => {
|
||||||
|
const result = notificationFor(input({ message: { ...input().message, mentions: ['u1'] } }));
|
||||||
|
expect(result).toMatchObject({ title: 'Упоминание в #общий', body: 'привет', channelId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('уведомляет о личном сообщении', () => {
|
||||||
|
const result = notificationFor(input({ isDirect: true, channelName: 'Аня', guildId: null }));
|
||||||
|
expect(result).toMatchObject({ title: 'Личное сообщение — Аня', guildId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('молчит об обычном сообщении в канале', () => {
|
||||||
|
expect(notificationFor(input())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('молчит о собственных сообщениях', () => {
|
||||||
|
const result = notificationFor(
|
||||||
|
input({
|
||||||
|
isDirect: true,
|
||||||
|
message: { ...input().message, author_id: 'u1' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('молчит, когда открытая комната в фокусе', () => {
|
||||||
|
const result = notificationFor(
|
||||||
|
input({ isDirect: true, isChannelOpen: true, windowFocused: true }),
|
||||||
|
);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('уведомляет об открытой комнате, если окно не в фокусе', () => {
|
||||||
|
const result = notificationFor(
|
||||||
|
input({ isDirect: true, isChannelOpen: true, windowFocused: false }),
|
||||||
|
);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('молчит при выключенных уведомлениях', () => {
|
||||||
|
expect(notificationFor(input({ isDirect: true, enabled: false }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пропускает системные сообщения', () => {
|
||||||
|
const result = notificationFor(
|
||||||
|
input({
|
||||||
|
isDirect: true,
|
||||||
|
message: { ...input().message, type: 'system' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('подставляет «Вложение» для сообщения без текста', () => {
|
||||||
|
const result = notificationFor(
|
||||||
|
input({
|
||||||
|
isDirect: true,
|
||||||
|
message: { ...input().message, content: '', mentions: ['u1'] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result?.body).toBe('Вложение');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('previewText', () => {
|
||||||
|
it('убирает разметку и лишние переводы строк', () => {
|
||||||
|
expect(previewText('**жирный**\n\n[ссылка](https://example.com) `код`')).toBe(
|
||||||
|
'жирный ссылка код',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('обрезает длинный текст', () => {
|
||||||
|
const result = previewText('а'.repeat(400));
|
||||||
|
expect(result.length).toBeLessThanOrEqual(140);
|
||||||
|
expect(result.endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('предпочтение уведомлений', () => {
|
||||||
|
it('включено по умолчанию и выключается переключателем', () => {
|
||||||
|
expect(notificationsEnabled()).toBe(true);
|
||||||
|
setNotificationsEnabled(false);
|
||||||
|
expect(notificationsEnabled()).toBe(false);
|
||||||
|
setNotificationsEnabled(true);
|
||||||
|
expect(notificationsEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не падает, если localStorage недоступен', () => {
|
||||||
|
const stub = vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
||||||
|
throw new Error('нет доступа');
|
||||||
|
});
|
||||||
|
expect(notificationsEnabled()).toBe(true);
|
||||||
|
stub.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { isTauri, platform, routeFromDeepLink, type DesktopMessage } from '@/lib/platform';
|
||||||
|
|
||||||
|
/** Ставит в window фальшивый мост Tauri v2 и возвращает список вызовов. */
|
||||||
|
function stubTauri(): Array<{ command: string; args: unknown }> {
|
||||||
|
const calls: Array<{ command: string; args: unknown }> = [];
|
||||||
|
vi.stubGlobal('__TAURI_INTERNALS__', {
|
||||||
|
invoke: (command: string, args?: unknown) => {
|
||||||
|
calls.push({ command, args });
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('routeFromDeepLink', () => {
|
||||||
|
it('разбирает приглашение', () => {
|
||||||
|
expect(routeFromDeepLink('glchat://invite/AbC123')).toBe('/invite/AbC123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('разбирает сервер и комнату', () => {
|
||||||
|
expect(routeFromDeepLink('glchat://guild/10/channel/20')).toBe('/app/10/20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('игнорирует чужие схемы и неизвестные пути', () => {
|
||||||
|
expect(routeFromDeepLink('https://gl.mhspx.su/invite/abc')).toBeNull();
|
||||||
|
expect(routeFromDeepLink('glchat://unknown/1')).toBeNull();
|
||||||
|
expect(routeFromDeepLink('glchat://guild/10')).toBeNull();
|
||||||
|
expect(routeFromDeepLink('не ссылка')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('платформа в браузере', () => {
|
||||||
|
it('определяется как web и не падает на вызовах', () => {
|
||||||
|
expect(isTauri()).toBe(false);
|
||||||
|
expect(platform.kind).toBe('web');
|
||||||
|
expect(() => {
|
||||||
|
platform.notify({ title: 'a', body: 'b' });
|
||||||
|
platform.setBadge(3);
|
||||||
|
platform.ready();
|
||||||
|
platform.openSettings();
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(platform.onMessage(() => {})()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('платформа в desktop-обёртке', () => {
|
||||||
|
it('вызывает команды обёртки и принимает её сообщения', async () => {
|
||||||
|
const calls = stubTauri();
|
||||||
|
vi.resetModules();
|
||||||
|
const desktop = await import('@/lib/platform');
|
||||||
|
|
||||||
|
expect(desktop.isTauri()).toBe(true);
|
||||||
|
expect(desktop.platform.kind).toBe('tauri');
|
||||||
|
|
||||||
|
desktop.platform.notify({ title: 'Упоминание', body: 'привет', channelId: 'c1' });
|
||||||
|
desktop.platform.setBadge(2);
|
||||||
|
desktop.platform.ready();
|
||||||
|
desktop.platform.openSettings();
|
||||||
|
|
||||||
|
expect(calls.map((call) => call.command)).toEqual([
|
||||||
|
'desktop_notify',
|
||||||
|
'desktop_set_badge',
|
||||||
|
'desktop_ready',
|
||||||
|
'desktop_open_settings',
|
||||||
|
]);
|
||||||
|
expect(calls[0]?.args).toEqual({
|
||||||
|
payload: { title: 'Упоминание', body: 'привет', guildId: null, channelId: 'c1' },
|
||||||
|
});
|
||||||
|
expect(calls[1]?.args).toEqual({ count: 2 });
|
||||||
|
|
||||||
|
// Обёртка зовёт страницу через window.__glchatDesktop.receive.
|
||||||
|
const received: DesktopMessage[] = [];
|
||||||
|
const unsubscribe = desktop.platform.onMessage((message) => received.push(message));
|
||||||
|
const bridge = window as unknown as {
|
||||||
|
__glchatDesktop?: { receive: (m: DesktopMessage) => void };
|
||||||
|
};
|
||||||
|
bridge.__glchatDesktop?.receive({ type: 'ptt', active: true });
|
||||||
|
expect(received).toEqual([{ type: 'ptt', active: true }]);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
bridge.__glchatDesktop?.receive({ type: 'toggleMic' });
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -222,14 +222,20 @@ describe('настройки: окно и навигация', () => {
|
|||||||
expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull();
|
expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('«Уведомления» пока заглушка', async () => {
|
it('«Уведомления» переключают уведомления рабочего стола', async () => {
|
||||||
installFetch(appRoutes());
|
installFetch(appRoutes());
|
||||||
|
|
||||||
renderApp('/app/empty');
|
renderApp('/app/empty');
|
||||||
const dialog = await openSettings();
|
const dialog = await openSettings();
|
||||||
await goToSection(dialog, 'Уведомления');
|
await goToSection(dialog, 'Уведомления');
|
||||||
|
|
||||||
expect(within(dialog).getByText('Настройки уведомлений появятся позже.')).toBeVisible();
|
const toggle = within(dialog).getByRole('checkbox', { name: /Уведомления рабочего стола/ });
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(toggle).not.toBeChecked();
|
||||||
|
expect(window.localStorage.getItem('glchat.desktop-notifications')).toBe('off');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('«Аудио-видео» без mediaDevices честно сообщает о недоступности', async () => {
|
it('«Аудио-видео» без mediaDevices честно сообщает о недоступности', async () => {
|
||||||
|
|||||||