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
+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
}