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 по модулям обёртки.
This commit is contained in:
2026-09-22 21:55:19 +03:00
parent 4a299155e7
commit a7b4e3318e
10 changed files with 255 additions and 19 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build()
}
+12 -2
View File
@@ -18,7 +18,12 @@ pub fn install<R: Runtime>(app: &AppHandle<R>) {
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() });
bridge::send(
&handle,
&WebMessage::DeepLink {
url: url.to_string(),
},
);
}
});
@@ -31,7 +36,12 @@ pub fn install<R: Runtime>(app: &AppHandle<R>) {
// Ссылка могла быть передана аргументом командной строки (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() });
bridge::send(
app,
&WebMessage::DeepLink {
url: url.to_string(),
},
);
}
}
}
+5 -1
View File
@@ -23,7 +23,11 @@ pub fn normalize(input: &str) -> Result<Url, String> {
let url = Url::parse(&candidate).map_err(|error| format!("некорректный адрес: {error}"))?;
match url.scheme() {
"http" | "https" => {}
other => return Err(format!("поддерживаются только http и https, а не «{other}»")),
other => {
return Err(format!(
"поддерживаются только http и https, а не «{other}»"
))
}
}
if url.host_str().unwrap_or_default().is_empty() {
return Err("в адресе нет имени хоста".to_string());
+97 -8
View File
@@ -18,9 +18,10 @@ mod tray;
mod updates;
mod window;
use std::path::Path;
use std::time::Duration;
use tauri::{Manager, WindowEvent};
use tauri::{AppHandle, Manager, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
@@ -47,8 +48,13 @@ struct Cli {
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 = std::env::args().skip(1);
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--instance" => cli.instance = args.next(),
@@ -91,11 +97,6 @@ pub fn run() {
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())
@@ -148,8 +149,45 @@ pub fn run() {
.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) {
@@ -187,7 +225,11 @@ fn setup(app: &tauri::AppHandle, cli: &Cli) {
log::info!(
"обёртка запущена: адрес={}, окно={}, автозапуск={}",
current.instance_url,
if cli.minimized { "свёрнуто" } else { "видимо" },
if cli.minimized {
"свёрнуто"
} else {
"видимо"
},
current.autostart
);
@@ -241,3 +283,50 @@ fn spawn_autostart_prompt(app: tauri::AppHandle) {
});
});
}
#[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);
}
}
+5 -1
View File
@@ -51,7 +51,11 @@ 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)) });
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 {
+5 -1
View File
@@ -13,7 +13,11 @@ 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) {
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;
+6 -1
View File
@@ -76,7 +76,12 @@ pub async fn check<R: Runtime>(app: AppHandle<R>, interactive: bool) {
log::info!("доступно обновление {version}");
// Обновление показываем в интерфейсе, установку спрашиваем отдельно:
// во время звонка перезапуск приложения недопустим без согласия.
bridge::send(&app, &WebMessage::UpdateAvailable { version: version.clone() });
bridge::send(
&app,
&WebMessage::UpdateAvailable {
version: version.clone(),
},
);
prompt_install(app, update, version).await;
}
Ok(None) => {
+1 -4
View File
@@ -15,10 +15,7 @@
},
"bundle": {
"active": true,
"targets": [
"app",
"dmg"
],
"targets": "all",
"createUpdaterArtifacts": true,
"category": "SocialNetworking",
"shortDescription": "glchat — self-hosted платформа общения",