//! 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::path::Path; use std::time::Duration; use tauri::{AppHandle, 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, /// Запуск свёрнутым в трей (используется автозапуском). minimized: bool, /// Сразу открыть страницу настроек обёртки. settings: bool, /// Принудительно включить или выключить автозапуск (установка скриптом). autostart: Option, } impl Cli { fn parse() -> Self { Self::from_args(std::env::args().skip(1)) } /// Разбор аргументов отделён от окружения, чтобы его можно было тестировать. fn from_args>(args: I) -> Self { let mut cli = Self::default(); let mut args = args.into_iter(); 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 { 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_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::().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"); } /// Подключает логирование: файл в каталоге логов приложения и stdout. /// /// Если каталог логов недоступен на запись (например, приложение запущено из /// песочницы или домашний каталог только для чтения), логи остаются только в /// stdout: падать из-за прав на каталог приложение не должно. fn install_logging(app: &AppHandle) { let mut builder = tauri_plugin_log::Builder::default().level(log::LevelFilter::Info); match app.path().app_log_dir() { Ok(dir) if directory_is_writable(&dir) => { builder = builder.target(tauri_plugin_log::Target::new( tauri_plugin_log::TargetKind::LogDir { file_name: None }, )); } Ok(dir) => eprintln!("каталог логов {} недоступен на запись", dir.display()), Err(error) => eprintln!("не удалось определить каталог логов: {error}"), } if let Err(error) = app.plugin(builder.build()) { eprintln!("не удалось подключить логирование: {error}"); } } /// Проверяет, можно ли писать в каталог (создаёт и удаляет пробный файл). fn directory_is_writable(dir: &Path) -> bool { if std::fs::create_dir_all(dir).is_err() { return false; } let probe = dir.join(".glchat-write-probe"); match std::fs::write(&probe, b"") { Ok(()) => { let _ = std::fs::remove_file(&probe); true } Err(_) => false, } } /// Начальная настройка: настройки, трей, окно, шорткаты, deep links. fn setup(app: &tauri::AppHandle, cli: &Cli) { install_logging(app); 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::().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::().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::().settings(); next.autostart = confirmed; next.autostart_prompted = true; handle.state::().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); }); }); } #[cfg(test)] mod tests { use super::*; fn parse(args: &[&str]) -> Cli { Cli::from_args(args.iter().map(|value| value.to_string())) } #[test] fn разбирает_аргументы_запуска() { let cli = parse(&[ "--instance", "https://example.com", "--minimized", "--settings", ]); assert_eq!(cli.instance.as_deref(), Some("https://example.com")); assert!(cli.minimized); assert!(cli.settings); assert_eq!(cli.autostart, None); } #[test] fn разбирает_аргументы_со_значением_через_равно() { let cli = parse(&["--instance=gl.example.org", "--autostart=off"]); assert_eq!(cli.instance.as_deref(), Some("gl.example.org")); assert_eq!(cli.autostart, Some(false)); } #[test] fn разбирает_переключатель_автозапуска() { assert_eq!(parse(&["--autostart", "on"]).autostart, Some(true)); assert_eq!(parse(&["--autostart", "off"]).autostart, Some(false)); assert_eq!(parse(&["--autostart", "мусор"]).autostart, None); // Аргумент без значения не должен ничего ломать. assert_eq!(parse(&["--autostart"]).autostart, None); } #[test] fn игнорирует_неизвестные_аргументы() { let cli = parse(&["--unknown", "value", "мусор"]); assert_eq!(cli.instance, None); assert!(!cli.minimized); assert!(!cli.settings); } }