a7b4e3318e
- 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 по модулям обёртки.
333 lines
13 KiB
Rust
333 lines
13 KiB
Rust
//! 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, ¤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);
|
||
});
|
||
});
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|