Files
glchat/desktop/src-tauri/src/lib.rs
T
grendervill a7b4e3318e build(desktop): цели make, README и устойчивость обёртки к правам на логи
- Makefile: `desktop-check`, `desktop-build` (с ключом подписи обновлений,
  без ключа — сборка без артефактов обновления) и `desktop-run`; кэши
  cargo и npm держатся в .cache/, как у остальных инструментов проекта.
- desktop/README.md: сборка, запуск, аргументы командной строки, настройки,
  deep links и открытые пункты Фазы 6.
- bundle.targets = all: бандлы по умолчанию для текущей ОС (macOS: .app/.dmg).
- Логирование подключается в setup и больше не роняет приложение, если
  каталог логов недоступен на запись (песочница, домашний каталог только
  для чтения).
- Разбор аргументов вынесен в Cli::from_args и покрыт тестами
  (--instance, --minimized, --settings, --autostart on|off).
- cargo fmt по модулям обёртки.
2026-09-22 21:55:19 +03:00

333 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
/// Запуск свёрнутым в трей (используется автозапуском).
minimized: bool,
/// Сразу открыть страницу настроек обёртки.
settings: bool,
/// Принудительно включить или выключить автозапуск (установка скриптом).
autostart: Option<bool>,
}
impl Cli {
fn parse() -> Self {
Self::from_args(std::env::args().skip(1))
}
/// Разбор аргументов отделён от окружения, чтобы его можно было тестировать.
fn from_args<I: IntoIterator<Item = String>>(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<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_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");
}
/// Подключает логирование: файл в каталоге логов приложения и 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::<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);
});
});
}
#[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);
}
}