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:
@@ -0,0 +1,25 @@
|
||||
//! Автозапуск приложения (docs/client-tauri.md §5).
|
||||
//!
|
||||
//! Запуск идёт с аргументом `--minimized`: приложение поднимается свёрнутым в
|
||||
//! трей и не перебивает работу пользователя окном.
|
||||
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
/// Аргумент командной строки для запуска свёрнутым в трей.
|
||||
pub const MINIMIZED_ARG: &str = "--minimized";
|
||||
|
||||
/// Приводит системный автозапуск в соответствие с настройкой.
|
||||
pub fn apply<R: Runtime>(app: &AppHandle<R>, enabled: bool) -> Result<(), String> {
|
||||
let manager = app.autolaunch();
|
||||
let current = manager.is_enabled().unwrap_or(false);
|
||||
if current == enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let result = if enabled {
|
||||
manager.enable()
|
||||
} else {
|
||||
manager.disable()
|
||||
};
|
||||
result.map_err(|error| format!("не удалось изменить автозапуск: {error}"))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Мост «Rust → веб-клиент».
|
||||
//!
|
||||
//! Обратное направление (страница → обёртка) идёт через типизированные команды
|
||||
//! (`commands.rs`), а события платформы передаются вызовом единственной функции
|
||||
//! `window.__glchatDesktop.receive(...)` в самой странице. Отдельного канала
|
||||
//! событий нет намеренно: сообщений мало, а типы у них общие с `web/src/lib/platform.ts`.
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
|
||||
use crate::settings::Settings;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Метка главного окна (единственное окно приложения).
|
||||
pub const WEB_WINDOW: &str = "main";
|
||||
|
||||
/// Сообщение веб-клиенту: то, что платформа сообщает странице.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum WebMessage {
|
||||
/// Нажат или отпущен глобальный push-to-talk.
|
||||
Ptt { active: bool },
|
||||
/// Переключить микрофон (глобальный шорткат).
|
||||
ToggleMic,
|
||||
/// Переключить «не слышу» (глобальный шорткат).
|
||||
ToggleDeafen,
|
||||
/// Открыть deep link: приглашение или сервер с комнатой.
|
||||
DeepLink { url: String },
|
||||
/// Настройки обёртки изменились (обновлена страница настроек).
|
||||
Settings { settings: Settings },
|
||||
/// Доступно обновление — клиент показывает уведомление в интерфейсе.
|
||||
UpdateAvailable { version: String },
|
||||
}
|
||||
|
||||
/// Отправляет сообщение в веб-клиент.
|
||||
///
|
||||
/// Если клиент ещё не загрузился, deep link ставится в очередь и будет отдан
|
||||
/// после `desktop_ready` (docs/client-tauri.md §5). Остальные сообщения в этот
|
||||
/// момент терять не страшно: они относятся к активному взаимодействию.
|
||||
pub fn send<R: Runtime>(app: &AppHandle<R>, message: &WebMessage) -> bool {
|
||||
let state = app.state::<AppState>();
|
||||
if !state.web_ready() {
|
||||
if let WebMessage::DeepLink { url } = message {
|
||||
state.push_deep_link(url.clone());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
dispatch(app, message)
|
||||
}
|
||||
|
||||
/// Немедленная доставка без проверки готовности клиента.
|
||||
pub fn dispatch<R: Runtime>(app: &AppHandle<R>, message: &WebMessage) -> bool {
|
||||
let Some(window) = app.get_webview_window(WEB_WINDOW) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(payload) = serde_json::to_string(message) else {
|
||||
log::warn!("не удалось сериализовать сообщение веб-клиенту");
|
||||
return false;
|
||||
};
|
||||
let script = format!("window.__glchatDesktop && window.__glchatDesktop.receive({payload});");
|
||||
match window.eval(&script) {
|
||||
Ok(()) => true,
|
||||
Err(error) => {
|
||||
log::debug!("веб-клиент не принял сообщение: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Команды обёртки: типизированный мост «веб-клиент → Rust» (docs/client-tauri.md §2).
|
||||
//!
|
||||
//! Команд ровно столько, сколько нужно странице: прочитать и сохранить
|
||||
//! настройки, сообщить о готовности, показать уведомление, обновить счётчик
|
||||
//! непрочитанных, открыть настройки/клиент и проверить обновления. Никакой
|
||||
//! бизнес-логики здесь нет — только платформенные действия.
|
||||
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
|
||||
use crate::bridge::{self, WebMessage};
|
||||
use crate::settings::Settings;
|
||||
use crate::state::AppState;
|
||||
use crate::{autostart, instance, notifications, shortcuts, updates, window};
|
||||
|
||||
/// Текущие настройки обёртки.
|
||||
#[tauri::command]
|
||||
pub fn desktop_settings<R: Runtime>(app: AppHandle<R>) -> Settings {
|
||||
app.state::<AppState>().settings()
|
||||
}
|
||||
|
||||
/// Версия приложения — показывается на странице настроек.
|
||||
#[tauri::command]
|
||||
pub fn desktop_version<R: Runtime>(app: AppHandle<R>) -> String {
|
||||
app.package_info().version.to_string()
|
||||
}
|
||||
|
||||
/// Сохраняет настройки и применяет их: автозапуск, шорткаты, адрес инстанса.
|
||||
#[tauri::command]
|
||||
pub fn desktop_save_settings<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
settings: Settings,
|
||||
) -> Result<Settings, String> {
|
||||
// Адрес проверяем до применения, чтобы не оставить обёртку без клиента.
|
||||
let url = instance::normalize(&settings.instance_url)?;
|
||||
let mut next = settings;
|
||||
next.instance_url = instance::display(&url);
|
||||
|
||||
let previous = app.state::<AppState>().settings();
|
||||
// Шорткаты применяем первыми: если комбинация занята, настройки не сохраняем.
|
||||
if next.ptt_shortcut != previous.ptt_shortcut
|
||||
|| next.mute_shortcut != previous.mute_shortcut
|
||||
|| next.deafen_shortcut != previous.deafen_shortcut
|
||||
{
|
||||
if let Err(error) = shortcuts::apply(&app, &next) {
|
||||
let _ = shortcuts::apply(&app, &previous);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
if let Err(error) = autostart::apply(&app, next.autostart) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
|
||||
app.state::<AppState>().set_settings(next.clone());
|
||||
crate::settings::save(&app, &next)?;
|
||||
crate::tray::refresh(&app);
|
||||
announce_settings(&app, &next);
|
||||
if next.instance_url != previous.instance_url {
|
||||
window::open_instance_page(&app)?;
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Веб-клиент загрузился: отдаём накопленные deep link.
|
||||
#[tauri::command]
|
||||
pub fn desktop_ready<R: Runtime>(app: AppHandle<R>) {
|
||||
let pending = app.state::<AppState>().mark_web_ready();
|
||||
for url in pending {
|
||||
bridge::dispatch(&app, &WebMessage::DeepLink { url });
|
||||
}
|
||||
}
|
||||
|
||||
/// Показывает системное уведомление.
|
||||
#[tauri::command]
|
||||
pub fn desktop_notify<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: notifications::NotifyPayload,
|
||||
) -> Result<(), String> {
|
||||
notifications::show(&app, &payload)
|
||||
}
|
||||
|
||||
/// Обновляет счётчик непрочитанных в доке и трее.
|
||||
#[tauri::command]
|
||||
pub fn desktop_set_badge<R: Runtime>(app: AppHandle<R>, count: u32) {
|
||||
notifications::set_badge(&app, count);
|
||||
}
|
||||
|
||||
/// Открывает локальную страницу настроек обёртки.
|
||||
#[tauri::command]
|
||||
pub fn desktop_open_settings<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
||||
window::open_settings_page(&app)
|
||||
}
|
||||
|
||||
/// Возвращает окно к клиенту инстанса.
|
||||
#[tauri::command]
|
||||
pub fn desktop_open_instance<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
||||
window::open_instance_page(&app)
|
||||
}
|
||||
|
||||
/// Перезагружает клиент инстанса.
|
||||
#[tauri::command]
|
||||
pub fn desktop_reload<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
||||
window::reload_instance(&app)
|
||||
}
|
||||
|
||||
/// Проверяет обновления (кнопка на странице настроек).
|
||||
#[tauri::command]
|
||||
pub async fn desktop_check_updates<R: Runtime>(app: AppHandle<R>) {
|
||||
updates::check(app, true).await;
|
||||
}
|
||||
|
||||
/// Открывает ссылку в системном браузере.
|
||||
#[tauri::command]
|
||||
pub fn desktop_open_external<R: Runtime>(app: AppHandle<R>, url: String) -> Result<(), String> {
|
||||
let parsed = instance::normalize(&url)?;
|
||||
tauri_plugin_opener::OpenerExt::opener(&app)
|
||||
.open_url(parsed.as_str(), None::<&str>)
|
||||
.map_err(|error| format!("не удалось открыть ссылку: {error}"))
|
||||
}
|
||||
|
||||
/// Сообщает веб-клиенту, что настройки обёртки изменились.
|
||||
pub fn announce_settings<R: Runtime>(app: &AppHandle<R>, settings: &Settings) {
|
||||
bridge::send(
|
||||
app,
|
||||
&WebMessage::Settings {
|
||||
settings: settings.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Deep links `glchat://…` (docs/client-tauri.md §5).
|
||||
//!
|
||||
//! Поддерживаются `glchat://invite/<code>` и `glchat://guild/<id>/channel/<id>`.
|
||||
//! Ссылка приходит и при холодном старте (приложение ещё поднимается), и при
|
||||
//! тёплом: в первом случае она ждёт в очереди до `desktop_ready` веб-клиента.
|
||||
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
use crate::bridge::{self, WebMessage};
|
||||
|
||||
/// Схема deep link, зарегистрированная в `tauri.conf.json`.
|
||||
pub const SCHEME: &str = "glchat";
|
||||
|
||||
/// Подписывается на ссылки, пришедшие из системы.
|
||||
pub fn install<R: Runtime>(app: &AppHandle<R>) {
|
||||
let handle = app.clone();
|
||||
app.deep_link().on_open_url(move |event| {
|
||||
for url in event.urls() {
|
||||
log::info!("deep link: {url}");
|
||||
bridge::send(&handle, &WebMessage::DeepLink { url: url.to_string() });
|
||||
}
|
||||
});
|
||||
|
||||
// В разработке и в AppImage схема может быть не прописана в системе:
|
||||
// регистрируем её на лету (в собранном .app/установщике это делает бандл).
|
||||
if let Err(error) = app.deep_link().register_all() {
|
||||
log::debug!("схема {SCHEME}:// не зарегистрирована автоматически: {error}");
|
||||
}
|
||||
|
||||
// Ссылка могла быть передана аргументом командной строки (Windows/Linux).
|
||||
if let Ok(Some(urls)) = app.deep_link().get_current() {
|
||||
for url in urls {
|
||||
bridge::send(app, &WebMessage::DeepLink { url: url.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Достаёт deep link из аргументов повторного запуска приложения.
|
||||
///
|
||||
/// Второй экземпляр не поднимается (single-instance): его аргументы приходят
|
||||
/// первому, и ссылку нужно доставить в уже открытый клиент.
|
||||
pub fn from_args(args: &[String]) -> Option<String> {
|
||||
args.iter()
|
||||
.find(|arg| arg.starts_with(&format!("{SCHEME}://")))
|
||||
.cloned()
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Адрес инстанса и границы навигации.
|
||||
//!
|
||||
//! Desktop-обёртка загружает тот же клиент, что и браузер, но только с одного
|
||||
//! адреса: переходы на сторонние сайты уходят в системный браузер
|
||||
//! (docs/client-tauri.md §6).
|
||||
|
||||
use url::Url;
|
||||
|
||||
/// Приводит введённый пользователем адрес к абсолютному URL.
|
||||
///
|
||||
/// Допускаются `http` и `https`; схема по умолчанию — `https`, чтобы в поле
|
||||
/// настроек можно было написать просто `gl.mhspx.su`.
|
||||
pub fn normalize(input: &str) -> Result<Url, String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("адрес инстанса не заполнен".to_string());
|
||||
}
|
||||
let candidate = if trimmed.contains("://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("https://{trimmed}")
|
||||
};
|
||||
let url = Url::parse(&candidate).map_err(|error| format!("некорректный адрес: {error}"))?;
|
||||
match url.scheme() {
|
||||
"http" | "https" => {}
|
||||
other => return Err(format!("поддерживаются только http и https, а не «{other}»")),
|
||||
}
|
||||
if url.host_str().unwrap_or_default().is_empty() {
|
||||
return Err("в адресе нет имени хоста".to_string());
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// Строка адреса для загрузки в окно: без завершающего слэша.
|
||||
pub fn display(url: &Url) -> String {
|
||||
url.as_str().trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// Хост адреса — граница, внутри которой разрешена навигация webview.
|
||||
pub fn host(url: &Url) -> String {
|
||||
url.host_str().unwrap_or_default().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Разрешена ли навигация внутри окна.
|
||||
///
|
||||
/// Свой инстанс и локальные страницы обёртки (`tauri://localhost`) — да,
|
||||
/// всё остальное открывается в системном браузере.
|
||||
pub fn navigation_allowed(target: &Url, instance_host: &str) -> bool {
|
||||
match target.scheme() {
|
||||
// Локальные ресурсы обёртки: страница настроек.
|
||||
"tauri" | "asset" => true,
|
||||
"http" | "https" => {
|
||||
let host = target.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||
// На Windows/Linux локальная страница отдаётся как http://tauri.localhost.
|
||||
host == "tauri.localhost" || (!instance_host.is_empty() && host == instance_host)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! glchat desktop — обёртка Tauri 2 вокруг веб-клиента (docs/client-tauri.md).
|
||||
//!
|
||||
//! Принцип: тот же веб-код, что и в браузере, плюс системная интеграция —
|
||||
//! трей, нативные уведомления, глобальный push-to-talk, автозапуск,
|
||||
//! автообновление и deep links. Бизнес-логики в Rust нет: обёртка знает только
|
||||
//! адрес инстанса, свои переключатели и границы навигации.
|
||||
|
||||
mod autostart;
|
||||
mod bridge;
|
||||
mod commands;
|
||||
mod deeplink;
|
||||
mod instance;
|
||||
mod notifications;
|
||||
mod settings;
|
||||
mod shortcuts;
|
||||
mod state;
|
||||
mod tray;
|
||||
mod updates;
|
||||
mod window;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{Manager, WindowEvent};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
|
||||
|
||||
use state::AppState;
|
||||
|
||||
/// Задержка перед фоновой проверкой обновлений: окно должно успеть открыться.
|
||||
const UPDATE_CHECK_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Задержка перед вопросом об автозапуске при первом запуске.
|
||||
const AUTOSTART_PROMPT_DELAY: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Аргументы командной строки обёртки.
|
||||
#[derive(Debug, Default)]
|
||||
struct Cli {
|
||||
/// Переопределение адреса инстанса (`--instance https://…`).
|
||||
instance: Option<String>,
|
||||
/// Запуск свёрнутым в трей (используется автозапуском).
|
||||
minimized: bool,
|
||||
/// Сразу открыть страницу настроек обёртки.
|
||||
settings: bool,
|
||||
/// Принудительно включить или выключить автозапуск (установка скриптом).
|
||||
autostart: Option<bool>,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
fn parse() -> Self {
|
||||
let mut cli = Self::default();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--instance" => cli.instance = args.next(),
|
||||
"--minimized" => cli.minimized = true,
|
||||
"--settings" => cli.settings = true,
|
||||
"--autostart" => cli.autostart = args.next().as_deref().and_then(parse_switch),
|
||||
other => {
|
||||
if let Some(value) = other.strip_prefix("--instance=") {
|
||||
cli.instance = Some(value.to_string());
|
||||
} else if let Some(value) = other.strip_prefix("--autostart=") {
|
||||
cli.autostart = parse_switch(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cli
|
||||
}
|
||||
}
|
||||
|
||||
/// Разбирает `on/off` (а также `true/false`, `1/0`) из аргументов командной строки.
|
||||
fn parse_switch(value: &str) -> Option<bool> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"on" | "true" | "1" | "yes" => Some(true),
|
||||
"off" | "false" | "0" | "no" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Точка входа приложения.
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
tauri::Builder::default()
|
||||
// Single-instance регистрируется первым: повторный запуск передаёт
|
||||
// аргументы (в том числе deep link) первому экземпляру и завершается.
|
||||
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
window::show_main(app);
|
||||
if let Some(url) = deeplink::from_args(&argv) {
|
||||
bridge::send(app, &bridge::WebMessage::DeepLink { url });
|
||||
}
|
||||
}))
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::LaunchAgent,
|
||||
Some(vec![autostart::MINIMIZED_ARG]),
|
||||
))
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(shortcuts::on_shortcut)
|
||||
.build(),
|
||||
)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::desktop_settings,
|
||||
commands::desktop_save_settings,
|
||||
commands::desktop_version,
|
||||
commands::desktop_ready,
|
||||
commands::desktop_notify,
|
||||
commands::desktop_set_badge,
|
||||
commands::desktop_open_settings,
|
||||
commands::desktop_open_instance,
|
||||
commands::desktop_reload,
|
||||
commands::desktop_check_updates,
|
||||
commands::desktop_open_external,
|
||||
])
|
||||
.setup(move |app| {
|
||||
setup(app.handle(), &cli);
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// Закрытие окна: либо сворачиваемся в трей, либо выходим целиком
|
||||
// (docs/client-tauri.md §2; открытый вопрос §9.2 решён настройкой).
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
let app = window.app_handle();
|
||||
if app.state::<AppState>().settings().close_to_tray {
|
||||
api.prevent_close();
|
||||
if let Err(error) = window.hide() {
|
||||
log::warn!("не удалось свернуть окно в трей: {error}");
|
||||
}
|
||||
} else {
|
||||
app.exit(0);
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("не удалось запустить glchat desktop");
|
||||
}
|
||||
|
||||
/// Начальная настройка: настройки, трей, окно, шорткаты, deep links.
|
||||
fn setup(app: &tauri::AppHandle, cli: &Cli) {
|
||||
let mut current = settings::load(app);
|
||||
if let Some(instance) = &cli.instance {
|
||||
match instance::normalize(instance) {
|
||||
Ok(url) => current.instance_url = instance::display(&url),
|
||||
Err(error) => log::warn!("адрес из командной строки не принят: {error}"),
|
||||
}
|
||||
}
|
||||
// Аргумент `--autostart` перекрывает сохранённое значение и снимает
|
||||
// необходимость спрашивать пользователя при первом запуске.
|
||||
if let Some(autostart) = cli.autostart {
|
||||
current.autostart = autostart;
|
||||
current.autostart_prompted = true;
|
||||
}
|
||||
app.manage(AppState::default());
|
||||
app.state::<AppState>().set_settings(current.clone());
|
||||
if let Err(error) = settings::save(app, ¤t) {
|
||||
log::warn!("не удалось сохранить настройки при старте: {error}");
|
||||
}
|
||||
|
||||
if let Err(error) = autostart::apply(app, current.autostart) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
if let Err(error) = tray::build(app) {
|
||||
log::error!("{error}");
|
||||
}
|
||||
if let Err(error) = window::create_main(app, ¤t, cli.minimized) {
|
||||
log::error!("{error}");
|
||||
}
|
||||
if let Err(error) = shortcuts::apply(app, ¤t) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
|
||||
deeplink::install(app);
|
||||
|
||||
log::info!(
|
||||
"обёртка запущена: адрес={}, окно={}, автозапуск={}",
|
||||
current.instance_url,
|
||||
if cli.minimized { "свёрнуто" } else { "видимо" },
|
||||
current.autostart
|
||||
);
|
||||
|
||||
if cli.settings {
|
||||
if let Err(error) = window::open_settings_page(app) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
|
||||
spawn_update_check(app.clone());
|
||||
spawn_autostart_prompt(app.clone());
|
||||
}
|
||||
|
||||
/// Фоновая проверка обновлений при старте (без диалогов).
|
||||
fn spawn_update_check(app: tauri::AppHandle) {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(UPDATE_CHECK_DELAY);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
updates::check(app, false).await;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Вопрос об автозапуске при первом запуске (docs/client-tauri.md §5).
|
||||
fn spawn_autostart_prompt(app: tauri::AppHandle) {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(AUTOSTART_PROMPT_DELAY);
|
||||
if app.state::<AppState>().settings().autostart_prompted {
|
||||
return;
|
||||
}
|
||||
let handle = app.clone();
|
||||
app.dialog()
|
||||
.message(
|
||||
"Запускать glchat при входе в систему? Приложение будет стартовать свёрнутым в трей.",
|
||||
)
|
||||
.title("Автозапуск glchat")
|
||||
.buttons(MessageDialogButtons::YesNo)
|
||||
.show(move |confirmed| {
|
||||
log::info!("ответ на вопрос об автозапуске: {confirmed}");
|
||||
let mut next = handle.state::<AppState>().settings();
|
||||
next.autostart = confirmed;
|
||||
next.autostart_prompted = true;
|
||||
handle.state::<AppState>().set_settings(next.clone());
|
||||
if let Err(error) = autostart::apply(&handle, next.autostart) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
if let Err(error) = settings::save(&handle, &next) {
|
||||
log::warn!("не удалось сохранить настройки: {error}");
|
||||
}
|
||||
tray::refresh(&handle);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Отключает лишнее консольное окно на Windows в релизной сборке.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
glchat_desktop_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Нативные уведомления и счётчик непрочитанных (docs/client-tauri.md §5).
|
||||
//!
|
||||
//! Решение о том, показывать ли уведомление, принимает веб-клиент (он знает про
|
||||
//! упоминания, личные сообщения и настройки пользователя), а обёртка только
|
||||
//! показывает системное уведомление и обновляет бейдж.
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
use crate::bridge::WEB_WINDOW;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Уведомление, пришедшее из веб-клиента.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotifyPayload {
|
||||
/// Заголовок: имя автора или название сервера.
|
||||
pub title: String,
|
||||
/// Текст: начало сообщения.
|
||||
pub body: String,
|
||||
/// Сервер и комната — нужны для перехода по клику (в текущем инкременте
|
||||
/// только сохраняются в логе: клик по уведомлению обрабатывается системой).
|
||||
#[serde(default)]
|
||||
pub guild_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub channel_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Показывает системное уведомление, если они включены в настройках обёртки.
|
||||
pub fn show<R: Runtime>(app: &AppHandle<R>, payload: &NotifyPayload) -> Result<(), String> {
|
||||
if !app.state::<AppState>().settings().notifications {
|
||||
return Ok(());
|
||||
}
|
||||
// В лог не попадают ни текст сообщения, ни имя автора (docs/client-tauri.md §6).
|
||||
log::debug!(
|
||||
"показываем уведомление (сервер {:?}, комната {:?})",
|
||||
payload.guild_id,
|
||||
payload.channel_id
|
||||
);
|
||||
app.notification()
|
||||
.builder()
|
||||
.title(payload.title.clone())
|
||||
.body(payload.body.clone())
|
||||
.show()
|
||||
.map_err(|error| format!("не удалось показать уведомление: {error}"))
|
||||
}
|
||||
|
||||
/// Обновляет число непрочитанных: значок приложения и подпись у иконки в трее.
|
||||
pub fn set_badge<R: Runtime>(app: &AppHandle<R>, count: u32) {
|
||||
let count = app.state::<AppState>().set_badge(count);
|
||||
if let Some(window) = app.get_webview_window(WEB_WINDOW) {
|
||||
// На части платформ бейдж не поддерживается — это не ошибка.
|
||||
let _ = window.set_badge_count(if count == 0 { None } else { Some(i64::from(count)) });
|
||||
}
|
||||
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
|
||||
let _ = tray.set_title(if count == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(count.to_string())
|
||||
});
|
||||
let _ = tray.set_tooltip(Some(if count == 0 {
|
||||
"glchat".to_string()
|
||||
} else {
|
||||
format!("glchat — непрочитанных: {count}")
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Настройки desktop-обёртки.
|
||||
//!
|
||||
//! Лежат в `tauri-plugin-store` (`settings.json` в каталоге конфигурации
|
||||
//! приложения). Обёртка хранит только то, что относится к платформе: адрес
|
||||
//! инстанса и переключатели поведения. Сессия целиком живёт в cookie webview
|
||||
//! (docs/client-tauri.md §2): токенов и паролей здесь нет.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
/// Файл хранилища в каталоге конфигурации приложения.
|
||||
const STORE_FILE: &str = "settings.json";
|
||||
/// Ключ, под которым лежит объект настроек.
|
||||
const STORE_KEY: &str = "settings";
|
||||
|
||||
/// Адрес стенда по умолчанию: тот же клиент, что открывается в браузере.
|
||||
pub const DEFAULT_INSTANCE: &str = "https://gl.mhspx.su";
|
||||
|
||||
/// Комбинация push-to-talk по умолчанию.
|
||||
///
|
||||
/// Голых клавиш (как `V` в браузерном PTT) в глобальном шорткате быть не может:
|
||||
/// системный перехват занял бы клавишу у всех приложений, поэтому по умолчанию
|
||||
/// комбинация с модификаторами, а пустая строка означает «выключено».
|
||||
pub const DEFAULT_PTT_SHORTCUT: &str = "CommandOrControl+Shift+Space";
|
||||
|
||||
/// Комбинация «выключить/включить микрофон» по умолчанию.
|
||||
pub const DEFAULT_MUTE_SHORTCUT: &str = "CommandOrControl+Shift+M";
|
||||
|
||||
/// Комбинация «не слышу» (deafen) по умолчанию.
|
||||
pub const DEFAULT_DEAFEN_SHORTCUT: &str = "CommandOrControl+Shift+D";
|
||||
|
||||
/// Настройки обёртки. Поля в camelCase — их читает и пишет страница настроек.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
/// Адрес инстанса, который загружается в главное окно.
|
||||
pub instance_url: String,
|
||||
/// Закрытие окна сворачивает приложение в трей.
|
||||
pub close_to_tray: bool,
|
||||
/// Запускать приложение при входе в систему (свёрнутым в трей).
|
||||
pub autostart: bool,
|
||||
/// Пользователю уже задавали вопрос про автозапуск при первом запуске.
|
||||
pub autostart_prompted: bool,
|
||||
/// Глобальный push-to-talk (пустая строка — выключен).
|
||||
pub ptt_shortcut: String,
|
||||
/// Глобальное переключение микрофона (пустая строка — выключено).
|
||||
pub mute_shortcut: String,
|
||||
/// Глобальное переключение «не слышу» (пустая строка — выключено).
|
||||
pub deafen_shortcut: String,
|
||||
/// Показывать нативные уведомления об упоминаниях и личных сообщениях.
|
||||
pub notifications: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
instance_url: DEFAULT_INSTANCE.to_string(),
|
||||
close_to_tray: true,
|
||||
autostart: false,
|
||||
autostart_prompted: false,
|
||||
ptt_shortcut: DEFAULT_PTT_SHORTCUT.to_string(),
|
||||
mute_shortcut: DEFAULT_MUTE_SHORTCUT.to_string(),
|
||||
deafen_shortcut: DEFAULT_DEAFEN_SHORTCUT.to_string(),
|
||||
notifications: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Читает настройки из хранилища; при отсутствии или порче — значения по умолчанию.
|
||||
pub fn load<R: Runtime>(app: &AppHandle<R>) -> Settings {
|
||||
let Ok(store) = app.store(STORE_FILE) else {
|
||||
log::warn!("не удалось открыть хранилище настроек, используются значения по умолчанию");
|
||||
return Settings::default();
|
||||
};
|
||||
let Some(value) = store.get(STORE_KEY) else {
|
||||
return Settings::default();
|
||||
};
|
||||
match serde_json::from_value::<Settings>(value) {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => {
|
||||
log::warn!("настройки в хранилище повреждены ({error}), берём значения по умолчанию");
|
||||
Settings::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохраняет настройки в хранилище.
|
||||
pub fn save<R: Runtime>(app: &AppHandle<R>, settings: &Settings) -> Result<(), String> {
|
||||
let store = app.store(STORE_FILE).map_err(|error| error.to_string())?;
|
||||
let value = serde_json::to_value(settings).map_err(|error| error.to_string())?;
|
||||
store.set(STORE_KEY, value);
|
||||
store.save().map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Глобальные горячие клавиши (docs/client-tauri.md §5).
|
||||
//!
|
||||
//! Push-to-talk работает при неактивном окне: системный перехват ловит нажатие
|
||||
//! и отпускание выбранной комбинации и передаёт состояние в веб-клиент, а тот
|
||||
//! включает микрофон (в самом клиенте должен быть выбран режим «Push-to-talk»).
|
||||
//! Дополнительно доступны переключение микрофона и «не слышу».
|
||||
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
|
||||
use crate::bridge::{self, WebMessage};
|
||||
use crate::settings::Settings;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации.
|
||||
pub fn on_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: &Shortcut, event: tauri_plugin_global_shortcut::ShortcutEvent) {
|
||||
let settings = app.state::<AppState>().settings();
|
||||
let pressed = event.state == ShortcutState::Pressed;
|
||||
|
||||
let ptt = parse(&settings.ptt_shortcut);
|
||||
if ptt.as_ref() == Some(shortcut) {
|
||||
bridge::send(app, &WebMessage::Ptt { active: pressed });
|
||||
return;
|
||||
}
|
||||
// Остальные шорткаты срабатывают по нажатию, чтобы не дублировать действие.
|
||||
if !pressed {
|
||||
return;
|
||||
}
|
||||
if parse(&settings.mute_shortcut).as_ref() == Some(shortcut) {
|
||||
bridge::send(app, &WebMessage::ToggleMic);
|
||||
return;
|
||||
}
|
||||
if parse(&settings.deafen_shortcut).as_ref() == Some(shortcut) {
|
||||
bridge::send(app, &WebMessage::ToggleDeafen);
|
||||
}
|
||||
}
|
||||
|
||||
/// Перерегистрирует комбинации после изменения настроек.
|
||||
pub fn apply<R: Runtime>(app: &AppHandle<R>, settings: &Settings) -> Result<(), String> {
|
||||
let shortcuts = app.global_shortcut();
|
||||
shortcuts
|
||||
.unregister_all()
|
||||
.map_err(|error| format!("не удалось снять старые шорткаты: {error}"))?;
|
||||
|
||||
for spec in [
|
||||
&settings.ptt_shortcut,
|
||||
&settings.mute_shortcut,
|
||||
&settings.deafen_shortcut,
|
||||
] {
|
||||
if spec.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
shortcuts
|
||||
.register(spec.as_str())
|
||||
.map_err(|error| format!("комбинация «{spec}» не зарегистрирована: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Разбирает строку комбинации; пустая строка и мусор дают `None`.
|
||||
fn parse(spec: &str) -> Option<Shortcut> {
|
||||
let trimmed = spec.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
trimmed.parse::<Shortcut>().ok()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Общее состояние процесса: настройки, готовность веб-клиента и очередь
|
||||
//! сообщений, которые нужно передать в страницу.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Состояние, доступное командам, трею и обработчикам событий.
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
settings: Settings,
|
||||
/// Веб-клиент сообщил, что загрузился и готов принимать события.
|
||||
web_ready: bool,
|
||||
/// Ссылки, пришедшие до готовности клиента (холодный старт deep link).
|
||||
pending_deep_links: Vec<String>,
|
||||
/// Последнее переданное в трей/док число непрочитанных.
|
||||
badge: u32,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Текущие настройки (копия).
|
||||
pub fn settings(&self) -> Settings {
|
||||
self.lock().settings.clone()
|
||||
}
|
||||
|
||||
/// Заменяет настройки в памяти (запись в хранилище — отдельно).
|
||||
pub fn set_settings(&self, settings: Settings) {
|
||||
self.lock().settings = settings;
|
||||
}
|
||||
|
||||
/// Отмечает веб-клиент готовым и отдаёт накопленные deep link.
|
||||
pub fn mark_web_ready(&self) -> Vec<String> {
|
||||
let mut inner = self.lock();
|
||||
inner.web_ready = true;
|
||||
std::mem::take(&mut inner.pending_deep_links)
|
||||
}
|
||||
|
||||
/// Готов ли веб-клиент принимать сообщения.
|
||||
pub fn web_ready(&self) -> bool {
|
||||
self.lock().web_ready
|
||||
}
|
||||
|
||||
/// Ставит deep link в очередь (если клиент ещё не готов).
|
||||
pub fn push_deep_link(&self, url: String) {
|
||||
let mut inner = self.lock();
|
||||
if inner.pending_deep_links.len() < 16 {
|
||||
inner.pending_deep_links.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
/// Запоминает число непрочитанных и возвращает его.
|
||||
pub fn set_badge(&self, count: u32) -> u32 {
|
||||
let mut inner = self.lock();
|
||||
inner.badge = count;
|
||||
inner.badge
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||
// Отравленный мьютекс не должен ронять приложение: состояние —
|
||||
// это только настройки и очередь событий.
|
||||
self.inner.lock().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Иконка в трее: индикатор непрочитанных и меню управления обёрткой
|
||||
//! (docs/client-tauri.md §2 — «открыть, mute, статус, выйти»).
|
||||
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::{autostart, commands, settings, shortcuts, updates, window};
|
||||
|
||||
/// Идентификатор иконки в трее.
|
||||
pub const TRAY_ID: &str = "main";
|
||||
|
||||
/// Создаёт иконку в трее с меню по текущим настройкам.
|
||||
pub fn build<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||
let icon = app
|
||||
.default_window_icon()
|
||||
.cloned()
|
||||
.ok_or_else(|| "в сборке нет иконки приложения".to_string())?;
|
||||
TrayIconBuilder::with_id(TRAY_ID)
|
||||
.icon(icon)
|
||||
.icon_as_template(true)
|
||||
.tooltip("glchat")
|
||||
.menu(&menu(app)?)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(on_menu_event)
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// Левый клик по иконке — показать окно, меню остаётся на правом.
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
window::show_main(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
.map_err(|error| format!("не удалось создать иконку в трее: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Пересобирает меню: галочки должны совпадать с текущими настройками.
|
||||
pub fn refresh<R: Runtime>(app: &AppHandle<R>) {
|
||||
let Some(tray) = app.tray_by_id(TRAY_ID) else {
|
||||
return;
|
||||
};
|
||||
match menu(app) {
|
||||
Ok(menu) => {
|
||||
if let Err(error) = tray.set_menu(Some(menu)) {
|
||||
log::warn!("не удалось обновить меню трея: {error}");
|
||||
}
|
||||
}
|
||||
Err(error) => log::warn!("не удалось собрать меню трея: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Собирает меню трея по текущим настройкам.
|
||||
fn menu<R: Runtime>(app: &AppHandle<R>) -> Result<Menu<R>, String> {
|
||||
let current = app.state::<AppState>().settings();
|
||||
let item = |id: &str, title: &str| -> Result<MenuItem<R>, String> {
|
||||
MenuItem::with_id(app, id, title, true, None::<&str>)
|
||||
.map_err(|error| format!("пункт меню «{title}»: {error}"))
|
||||
};
|
||||
let check = |id: &str, title: &str, checked: bool| -> Result<CheckMenuItem<R>, String> {
|
||||
CheckMenuItem::with_id(app, id, title, true, checked, None::<&str>)
|
||||
.map_err(|error| format!("пункт меню «{title}»: {error}"))
|
||||
};
|
||||
|
||||
let open = item("open", "Открыть glchat")?;
|
||||
let client = item("open-settings", "Настройки обёртки…")?;
|
||||
let first_sep = PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?;
|
||||
let autostart_item = check("autostart", "Запускать при входе", current.autostart)?;
|
||||
let close_to_tray = check("close-to-tray", "Закрывать в трей", current.close_to_tray)?;
|
||||
let notifications = check("notifications", "Уведомления", current.notifications)?;
|
||||
let update_item = item("updates", "Проверить обновления…")?;
|
||||
let second_sep = PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?;
|
||||
let quit = item("quit", "Выход")?;
|
||||
|
||||
Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&open,
|
||||
&client,
|
||||
&first_sep,
|
||||
&autostart_item,
|
||||
&close_to_tray,
|
||||
¬ifications,
|
||||
&update_item,
|
||||
&second_sep,
|
||||
&quit,
|
||||
],
|
||||
)
|
||||
.map_err(|error| format!("не удалось собрать меню трея: {error}"))
|
||||
}
|
||||
|
||||
/// Обработчик пунктов меню трея.
|
||||
fn on_menu_event<R: Runtime>(app: &AppHandle<R>, event: tauri::menu::MenuEvent) {
|
||||
match event.id().as_ref() {
|
||||
"open" => window::show_main(app),
|
||||
"open-settings" => {
|
||||
if let Err(error) = window::open_settings_page(app) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
"autostart" => {
|
||||
let mut current = app.state::<AppState>().settings();
|
||||
current.autostart = !current.autostart;
|
||||
persist(app, current);
|
||||
}
|
||||
"close-to-tray" => {
|
||||
let mut current = app.state::<AppState>().settings();
|
||||
current.close_to_tray = !current.close_to_tray;
|
||||
persist(app, current);
|
||||
}
|
||||
"notifications" => {
|
||||
let mut current = app.state::<AppState>().settings();
|
||||
current.notifications = !current.notifications;
|
||||
persist(app, current);
|
||||
}
|
||||
"updates" => {
|
||||
let handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
updates::check(handle, true).await;
|
||||
});
|
||||
}
|
||||
"quit" => app.exit(0),
|
||||
other => log::debug!("неизвестный пункт меню трея: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Применяет и сохраняет настройки, меняя только один переключатель.
|
||||
fn persist<R: Runtime>(app: &AppHandle<R>, next: settings::Settings) {
|
||||
let previous = app.state::<AppState>().settings();
|
||||
app.state::<AppState>().set_settings(next.clone());
|
||||
if let Err(error) = autostart::apply(app, next.autostart) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
if next.ptt_shortcut != previous.ptt_shortcut
|
||||
|| next.mute_shortcut != previous.mute_shortcut
|
||||
|| next.deafen_shortcut != previous.deafen_shortcut
|
||||
{
|
||||
if let Err(error) = shortcuts::apply(app, &next) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
if let Err(error) = settings::save(app, &next) {
|
||||
log::warn!("не удалось сохранить настройки: {error}");
|
||||
}
|
||||
refresh(app);
|
||||
commands::announce_settings(app, &next);
|
||||
if next.instance_url != previous.instance_url {
|
||||
if let Err(error) = window::open_instance_page(app) {
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Автообновление (docs/client-tauri.md §4).
|
||||
//!
|
||||
//! Манифест обновлений отдаёт сам инстанс: `GET /updates/{target}/{arch}/{current_version}`
|
||||
//! (статика через Caddy). Артефакты подписаны ключом ed25519, подпись
|
||||
//! проверяется до установки — публичный ключ задаётся в `UPDATER_PUBKEY`.
|
||||
//!
|
||||
//! Пока ключ не заведён (сборка без релизного контура), проверка обновлений
|
||||
//! честно сообщает, что канал не настроен, вместо «суррогатного» обновления.
|
||||
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
use crate::bridge::{self, WebMessage};
|
||||
use crate::instance;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Публичный ключ подписи обновлений (ed25519, формат minisign).
|
||||
///
|
||||
/// Приватный ключ в репозиторий не попадает: он живёт в CI-секретах
|
||||
/// (`TAURI_SIGNING_PRIVATE_KEY`), а локально — в `build/desktop-keys/`
|
||||
/// (каталог `build/` в .gitignore). Подпись обязательна: манифест без неё
|
||||
/// плагин отклоняет, downgrade запрещён (docs/client-tauri.md §4, §6).
|
||||
pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCQkQ1NjkwN0EyQzZBOEEKUldTS2FpeDZrRmE5aXhqSThPWXA3am5YMnB5dmVWZWFEcVY4NGI3cG8yOTU1MjdtMW9sR09BNGIK";
|
||||
|
||||
/// Путь манифеста обновлений на инстансе.
|
||||
const UPDATE_PATH: &str = "/updates/{{target}}/{{arch}}/{{current_version}}";
|
||||
|
||||
/// Проверяет обновления. `interactive` — вызов из меню трея или настроек,
|
||||
/// тогда пользователь видит результат в диалоге; фоновая проверка молчит.
|
||||
pub async fn check<R: Runtime>(app: AppHandle<R>, interactive: bool) {
|
||||
if UPDATER_PUBKEY.is_empty() {
|
||||
log::info!("автообновление не настроено: в сборке нет публичного ключа подписи");
|
||||
if interactive {
|
||||
app.dialog()
|
||||
.message(
|
||||
"В этой сборке нет публичного ключа подписи обновлений, поэтому проверка \
|
||||
недоступна. Ключ и манифест обновлений появятся вместе с релизным контуром \
|
||||
(docs/client-tauri.md §4).",
|
||||
)
|
||||
.title("Обновления не настроены")
|
||||
.buttons(MessageDialogButtons::Ok)
|
||||
.show(|_| {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let settings = app.state::<AppState>().settings();
|
||||
let endpoint = match instance::normalize(&settings.instance_url) {
|
||||
Ok(url) => format!("{}{UPDATE_PATH}", instance::display(&url)),
|
||||
Err(error) => {
|
||||
log::warn!("не удалось определить адрес инстанса для обновлений: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Ok(endpoint) = url::Url::parse(&endpoint) else {
|
||||
log::warn!("некорректный адрес манифеста обновлений: {endpoint}");
|
||||
return;
|
||||
};
|
||||
|
||||
let updater = match app
|
||||
.updater_builder()
|
||||
.endpoints(vec![endpoint])
|
||||
.and_then(|builder| builder.pubkey(UPDATER_PUBKEY).build())
|
||||
{
|
||||
Ok(updater) => updater,
|
||||
Err(error) => {
|
||||
log::warn!("не удалось собрать клиент обновлений: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match updater.check().await {
|
||||
Ok(Some(update)) => {
|
||||
let version = update.version.clone();
|
||||
log::info!("доступно обновление {version}");
|
||||
// Обновление показываем в интерфейсе, установку спрашиваем отдельно:
|
||||
// во время звонка перезапуск приложения недопустим без согласия.
|
||||
bridge::send(&app, &WebMessage::UpdateAvailable { version: version.clone() });
|
||||
prompt_install(app, update, version).await;
|
||||
}
|
||||
Ok(None) => {
|
||||
log::info!("обновлений нет");
|
||||
if interactive {
|
||||
app.dialog()
|
||||
.message("Установлена последняя версия.")
|
||||
.title("Обновления")
|
||||
.buttons(MessageDialogButtons::Ok)
|
||||
.show(|_| {});
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("проверка обновлений не удалась: {error}");
|
||||
if interactive {
|
||||
let text = format!("Не удалось проверить обновления: {error}");
|
||||
app.dialog()
|
||||
.message(text)
|
||||
.title("Обновления")
|
||||
.buttons(MessageDialogButtons::Ok)
|
||||
.show(|_| {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Спрашивает согласие и устанавливает обновление.
|
||||
async fn prompt_install<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
update: tauri_plugin_updater::Update,
|
||||
version: String,
|
||||
) {
|
||||
let handle = app.clone();
|
||||
let question = format!(
|
||||
"Доступна версия {version}. Скачать и установить сейчас? Приложение будет перезапущено, \
|
||||
активный голосовой звонок прервётся."
|
||||
);
|
||||
let (tx, rx) = std::sync::mpsc::channel::<bool>();
|
||||
app.dialog()
|
||||
.message(question)
|
||||
.title("Обновление glchat")
|
||||
.buttons(MessageDialogButtons::YesNo)
|
||||
.show(move |confirmed| {
|
||||
let _ = tx.send(confirmed);
|
||||
});
|
||||
|
||||
// Диалог асинхронный: ждём ответ, не блокируя основной поток.
|
||||
let confirmed = tauri::async_runtime::spawn_blocking(move || rx.recv().unwrap_or(false))
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !confirmed {
|
||||
return;
|
||||
}
|
||||
|
||||
match update
|
||||
.download_and_install(|_chunk, _total| {}, || {})
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
log::info!("обновление {version} установлено, перезапуск");
|
||||
handle.restart();
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("установка обновления не удалась: {error}");
|
||||
app.dialog()
|
||||
.message(format!(
|
||||
"Обновление не установлено: {error}. Скачать установщик вручную можно на \
|
||||
странице инстанса."
|
||||
))
|
||||
.title("Обновление glchat")
|
||||
.buttons(MessageDialogButtons::Ok)
|
||||
.show(|_| {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Главное окно приложения.
|
||||
//!
|
||||
//! Окно одно (docs/client-tauri.md §2): в нём либо веб-клиент инстанса, либо
|
||||
//! локальная страница настроек обёртки. Вторых окон не заводим, повторный
|
||||
//! запуск приложения фокусирует уже открытое окно.
|
||||
|
||||
use tauri::webview::PageLoadEvent;
|
||||
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tauri_plugin_window_state::{StateFlags, WindowExt};
|
||||
|
||||
use crate::bridge::WEB_WINDOW;
|
||||
use crate::instance;
|
||||
use crate::settings::Settings;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Создаёт главное окно с клиентом инстанса.
|
||||
pub fn create_main<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
settings: &Settings,
|
||||
minimized: bool,
|
||||
) -> Result<WebviewWindow<R>, String> {
|
||||
let url = instance::normalize(&settings.instance_url)?;
|
||||
let handle = app.clone();
|
||||
let window = WebviewWindowBuilder::new(app, WEB_WINDOW, WebviewUrl::External(url))
|
||||
.title("glchat")
|
||||
.inner_size(1280.0, 800.0)
|
||||
.min_inner_size(900.0, 600.0)
|
||||
.visible(!minimized)
|
||||
.on_navigation(move |target| navigation_guard(&handle, target))
|
||||
.on_page_load(|_window, payload| {
|
||||
// В логе видно, что именно открыто в окне: это первое, что нужно
|
||||
// при разборе «приложение открылось, но клиента нет».
|
||||
if payload.event() == PageLoadEvent::Finished {
|
||||
log::info!("загружена страница: {}", payload.url());
|
||||
}
|
||||
})
|
||||
.build()
|
||||
.map_err(|error| format!("не удалось создать окно: {error}"))?;
|
||||
// Геометрия окна переживает перезапуск (tauri-plugin-window-state).
|
||||
let _ = window.restore_state(StateFlags::all());
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
/// Показывает и фокусирует главное окно (повторный запуск, клик по трею).
|
||||
pub fn show_main<R: Runtime>(app: &AppHandle<R>) {
|
||||
if let Some(window) = app.get_webview_window(WEB_WINDOW) {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверяет, загружен ли сейчас в окне клиент инстанса (а не страница настроек).
|
||||
pub fn is_instance_page<R: Runtime>(app: &AppHandle<R>) -> bool {
|
||||
let Some(window) = app.get_webview_window(WEB_WINDOW) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(current) = window.url() else {
|
||||
return false;
|
||||
};
|
||||
let settings = app.state::<AppState>().settings();
|
||||
let Ok(instance_url) = instance::normalize(&settings.instance_url) else {
|
||||
return false;
|
||||
};
|
||||
instance::host(¤t) == instance::host(&instance_url)
|
||||
}
|
||||
|
||||
/// Открывает локальную страницу настроек обёртки.
|
||||
pub fn open_settings_page<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||
let window = app
|
||||
.get_webview_window(WEB_WINDOW)
|
||||
.ok_or_else(|| "окно не создано".to_string())?;
|
||||
window
|
||||
.navigate(local_page_url("index.html"))
|
||||
.map_err(|error| format!("не удалось открыть настройки: {error}"))?;
|
||||
show_main(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Возвращает окно к клиенту инстанса.
|
||||
pub fn open_instance_page<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||
let settings = app.state::<AppState>().settings();
|
||||
let url = instance::normalize(&settings.instance_url)?;
|
||||
let window = app
|
||||
.get_webview_window(WEB_WINDOW)
|
||||
.ok_or_else(|| "окно не создано".to_string())?;
|
||||
window
|
||||
.navigate(url)
|
||||
.map_err(|error| format!("не удалось открыть клиент: {error}"))?;
|
||||
show_main(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Адрес локальной страницы обёртки.
|
||||
///
|
||||
/// `WebviewUrl::App` резолвится Tauri во внутренний источник (`tauri://localhost`
|
||||
/// на macOS/Linux, `http://tauri.localhost` на Windows), а `navigate` принимает
|
||||
/// уже готовый URL — собираем его тем же правилом, что и сам Tauri.
|
||||
fn local_page_url(path: &str) -> url::Url {
|
||||
let base = if cfg!(any(windows, target_os = "android")) {
|
||||
url::Url::parse("http://tauri.localhost").expect("базовый адрес локальной страницы")
|
||||
} else {
|
||||
url::Url::parse("tauri://localhost").expect("базовый адрес локальной страницы")
|
||||
};
|
||||
base.join(path).unwrap_or(base)
|
||||
}
|
||||
|
||||
/// Перезагружает клиент инстанса (после смены адреса или по запросу).
|
||||
pub fn reload_instance<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||
let window = app
|
||||
.get_webview_window(WEB_WINDOW)
|
||||
.ok_or_else(|| "окно не создано".to_string())?;
|
||||
if is_instance_page(app) {
|
||||
window
|
||||
.eval("window.location.reload();")
|
||||
.map_err(|error| error.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
open_instance_page(app)
|
||||
}
|
||||
|
||||
/// Граница навигации: чужой адрес открывается в системном браузере.
|
||||
fn navigation_guard<R: Runtime>(app: &AppHandle<R>, target: &url::Url) -> bool {
|
||||
let settings = app.state::<AppState>().settings();
|
||||
let host = instance::normalize(&settings.instance_url)
|
||||
.map(|url| instance::host(&url))
|
||||
.unwrap_or_default();
|
||||
if instance::navigation_allowed(target, &host) {
|
||||
return true;
|
||||
}
|
||||
log::info!("внешняя ссылка уходит в системный браузер: {target}");
|
||||
let _ = app.opener().open_url(target.as_str(), None::<&str>);
|
||||
false
|
||||
}
|
||||
Reference in New Issue
Block a user