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 и сессии) из параллельной волны: файл коммитится
целиком по договорённости с координатором.
This commit is contained in:
2026-09-22 21:46:01 +03:00
parent 594a5c0af5
commit cffcec72b8
52 changed files with 8659 additions and 16 deletions
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+6098
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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
+3
View File
@@ -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"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+25
View File
@@ -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}"))
}
+68
View File
@@ -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
}
}
}
+128
View File
@@ -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(),
},
);
}
+47
View File
@@ -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()
}
+59
View File
@@ -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,
}
}
+243
View File
@@ -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, &current) {
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, &current, cli.minimized) {
log::error!("{error}");
}
if let Err(error) = shortcuts::apply(app, &current) {
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);
});
});
}
+6
View File
@@ -0,0 +1,6 @@
// Отключает лишнее консольное окно на Windows в релизной сборке.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
glchat_desktop_lib::run();
}
+68
View File
@@ -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}")
}));
}
}
+94
View File
@@ -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())
}
+67
View File
@@ -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()
}
+68
View File
@@ -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())
}
}
+157
View File
@@ -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,
&notifications,
&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}");
}
}
}
+154
View File
@@ -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(|_| {});
}
}
}
+135
View File
@@ -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(&current) == 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
}
+56
View File
@@ -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"
]
}
}
}
}
+90
View File
@@ -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>
+154
View File
@@ -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;
}
+165
View File
@@ -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();
+3
View File
@@ -1,6 +1,7 @@
import { Suspense } from 'react';
import { RouterProvider } from 'react-router';
import { DesktopBridge } from '@/components/DesktopBridge';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { router } from '@/router';
@@ -20,6 +21,8 @@ export function RouteFallback() {
export function App() {
return (
<Suspense fallback={<RouteFallback />}>
{/* Мост desktop-обёртки: события платформы и счётчик непрочитанных. */}
<DesktopBridge />
<RouterProvider router={router} />
</Suspense>
);
+79
View File
@@ -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;
}
+5 -2
View File
@@ -1107,8 +1107,11 @@
"navLabel": "Settings sections",
"notifications": {
"title": "Notifications",
"description": "Sounds, mentions and web push.",
"stub": "Notification settings are coming later."
"description": "System notifications for mentions and direct messages.",
"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": {
"title": "Audio & video",
+5 -2
View File
@@ -1107,8 +1107,11 @@
"navLabel": "Пункты настроек",
"notifications": {
"title": "Уведомления",
"description": "Звуки, упоминания и веб-пуш.",
"stub": "Настройки уведомлений появятся позже."
"description": "Системные уведомления об упоминаниях и личных сообщениях.",
"desktop": "Уведомления рабочего стола",
"desktopHint": "Показывать системные уведомления, когда окно неактивно или комната не открыта. В desktop-приложении уведомления нативные, в браузере — при разрешённых уведомлениях.",
"desktopSettings": "Настройки desktop-приложения",
"unsupported": "Браузер не показывает системные уведомления: откройте клиент в desktop-приложении."
},
"voice": {
"title": "Аудио-видео",
+114
View File
@@ -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,
};
}
+199
View File
@@ -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 { notificationsEnabled, setNotificationsEnabled } from '@/lib/desktopNotifications';
import { platform } from '@/lib/platform';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* «Уведомления»: пункт уже есть в списке настроек, но сами уведомления
* (веб-пуш и звуки) появятся в следующих фазах — показываем заглушку.
* «Уведомления»: системные уведомления об упоминаниях и личных сообщениях.
*
* Предпочтение хранится на устройстве (localStorage): в desktop-обёртке
* уведомления нативные, в браузере — только при выданном разрешении, а
* веб-пуш (Фаза 7) появится отдельным пунктом.
*/
export function NotificationsSettingsSection() {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(notificationsEnabled);
// В обёртке уведомления всегда доступны, в браузере — по разрешению.
const supported = platform.kind === 'tauri' || 'Notification' in window;
return (
<SettingsSection
title={t('settings.notifications.title')}
description={t('settings.notifications.description')}
>
<label className="mt-3 flex items-start gap-3 text-sm">
<input
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.stub')}
{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>
);
}
+43 -5
View File
@@ -31,7 +31,6 @@ import {
parseUserUpdate,
} from '@/api/gatewayEvents';
import { guildSoundsQueryKey } from '@/api/sounds';
import { parseSystemMessage } from '@/lib/systemMessage';
import { useFriendsStore } from '@/stores/friends';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore, type SessionGuild } from '@/stores/session';
@@ -39,6 +38,9 @@ import { playEventSound, useSoundboardStore } from '@/stores/soundboard';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
import { parseVoiceStateEvent } from '@/api/voice';
import type { Message } from '@/api/messages';
import { notificationFor, notificationsEnabled } from '@/lib/desktopNotifications';
import { platform } from '@/lib/platform';
import { parseSystemMessage } from '@/lib/systemMessage';
/**
* Состояние соединения со шлюзом. Данные приходят в session-store,
@@ -232,6 +234,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
// В личной беседе «последнее сообщение» — источник бейджа и порядка.
session.touchDmChannel(message.channel_id, message.id, message.created_at);
playMessageSound(message);
notifyAboutMessage(message);
}
return;
}
@@ -448,6 +451,37 @@ function playMessageSound(message: Message): void {
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 {
for (const guild of guilds) {
@@ -466,6 +500,10 @@ async function addGuildFromRest(guildId: string): Promise<void> {
]);
const client = getQueryClient();
try {
// Список серверов мог быть прочитан раньше (кэш свеж 30 секунд), поэтому
// сначала помечаем его устаревшим: без этого возврат в сервер после кика
// не появлялся в рейке до перезагрузки (AGENT.md 11.6).
await client.invalidateQueries({ queryKey: users.myGuildsQueryKey });
const guilds = await client.fetchQuery({
queryKey: users.myGuildsQueryKey,
queryFn: () => users.fetchMyGuilds(),
@@ -475,8 +513,7 @@ async function addGuildFromRest(guildId: string): Promise<void> {
useSessionStore.getState().upsertGuild(guild);
}
} catch {
// Сеть подвела — просто помечаем данные устаревшими для следующего рендера.
await client.invalidateQueries({ queryKey: users.myGuildsQueryKey });
// Сеть подвела — данные уже помечены устаревшими для следующего рендера.
}
}
@@ -568,10 +605,11 @@ async function invalidateCurrentUser(): Promise<void> {
import('@/lib/queryClient'),
import('@/api/users'),
]);
const client = getQueryClient();
// Именно refetch, а не removeQueries: AuthGuard узнаёт о мёртвой сессии по
// 401 на запросе профиля и только тогда уводит на экран входа (AGENT.md 11.6).
getQueryClient().removeQueries({ queryKey: currentUserQueryKey });
await getQueryClient().refetchQueries({ queryKey: currentUserQueryKey });
client.removeQueries({ queryKey: currentUserQueryKey });
await client.refetchQueries({ queryKey: currentUserQueryKey });
}
async function refetchCurrentUser(): Promise<void> {
+126
View File
@@ -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();
});
});
+86
View File
@@ -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);
});
});
+8 -2
View File
@@ -222,14 +222,20 @@ describe('настройки: окно и навигация', () => {
expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull();
});
it('«Уведомления» пока заглушка', async () => {
it('«Уведомления» переключают уведомления рабочего стола', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
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 () => {