diff --git a/Makefile b/Makefile index 7896f08..455ec5f 100644 --- a/Makefile +++ b/Makefile @@ -186,6 +186,39 @@ web-test: ## Тесты web-клиента (vitest) web-build: ## Сборка web-клиента в web/dist cd $(WEB_DIR) && npm run build +# Desktop-обёртка (Фаза 6, docs/client-tauri.md). Кэши инструментов — в .cache, +# адрес инстанса по умолчанию задаёт сама обёртка (https://gl.mhspx.su). +DESKTOP_DIR := $(ROOT)/desktop +DESKTOP_TAURI_DIR := $(DESKTOP_DIR)/src-tauri +DESKTOP_KEY ?= $(BUILD_DIR)/desktop-keys/glchat-updater.key +TAURI_CLI ?= npx --yes @tauri-apps/cli@^2 +export CARGO_HOME ?= $(CACHE_DIR)/cargo +export NPM_CONFIG_CACHE ?= $(CACHE_DIR)/npm + +.PHONY: desktop-check +desktop-check: ## Проверки Rust-обёртки: fmt, clippy, тесты + cd $(DESKTOP_TAURI_DIR) && cargo fmt --check + cd $(DESKTOP_TAURI_DIR) && cargo clippy --release --all-targets + cd $(DESKTOP_TAURI_DIR) && cargo test --release + +.PHONY: desktop-build +desktop-build: ## Собрать desktop-приложение (бандлы по умолчанию для текущей ОС) + @if [ -f "$(DESKTOP_KEY)" ]; then \ + echo " ключ подписи обновлений: $(DESKTOP_KEY)"; \ + cd $(DESKTOP_TAURI_DIR) && \ + TAURI_SIGNING_PRIVATE_KEY="$$(cat $(DESKTOP_KEY))" \ + TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-glchat-local-dev}" \ + $(TAURI_CLI) build; \ + else \ + echo " ключа $(DESKTOP_KEY) нет: сборка без артефактов автообновления"; \ + cd $(DESKTOP_TAURI_DIR) && \ + $(TAURI_CLI) build --config '{"bundle":{"createUpdaterArtifacts":false}}'; \ + fi + +.PHONY: desktop-run +desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=… + open -a "$(DESKTOP_TAURI_DIR)/target/release/bundle/macos/glchat.app" $(ARGS) + .PHONY: docker-build docker-build: ## Собрать образ приложения docker build \ diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..bfcf0c6 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,90 @@ +# desktop — desktop-обёртка glchat (Tauri 2) + +Тот же веб-клиент из `web/`, обёрнутый в Tauri 2 (docs/client-tauri.md). Отдельного +UI-кода нет: страница инстанса загружается в системный webview, а обёртка даёт +платформенную интеграцию — трей, нативные уведомления, глобальный push-to-talk, +автозапуск, deep links и автообновление. + +``` +desktop/ +└── src-tauri/ # Rust-проект обёртки + ├── src/ # окно, трей, шорткаты, уведомления, deep links, обновления + ├── capabilities/ # allowlist IPC: локальная страница настроек и домен инстанса + ├── ui/ # локальная страница настроек обёртки (tauri://localhost) + ├── icons/ # иконки приложения (сгенерированы из web/public/icons) + └── tauri.conf.json # конфигурация сборки и плагинов +``` + +## Сборка и запуск + +```bash +make desktop-check # cargo fmt --check, clippy, тесты (кэш в .cache/) +make desktop-build # бандлы текущей ОС (macOS: .app и .dmg) +open desktop/src-tauri/target/release/bundle/macos/glchat.app +``` + +Требуется Rust 1.77+ и Node 22 (Tauri CLI ставится через `npx`). Кэши `cargo` и +`npm` держатся в `.cache/` — как у остальных инструментов проекта. + +Сборка релиза с артефактами автообновления использует ключ подписи: + +```bash +# ключ и пароль не коммитятся: приватный ключ живёт в CI-секретах, +# локально — в build/desktop-keys/ (каталог build/ в .gitignore) +npx @tauri-apps/cli@^2 signer generate -w build/desktop-keys/glchat-updater.key -p '…' +make desktop-build +``` + +Публичный ключ уже вписан в `src-tauri/tauri.conf.json` и в `UPDATER_PUBKEY` +(`src/updates.rs`). Без локального ключа `make desktop-build` собирает бандл без +артефактов обновления, чтобы сборка не падала у тех, у кого ключа нет. + +## Аргументы командной строки + +| Аргумент | Что делает | +|---|---| +| `--instance ` | адрес инстанса вместо сохранённого (`--instance=https://…` тоже работает) | +| `--minimized` | запуск свёрнутым в трей (используется автозапуском) | +| `--settings` | сразу открыть страницу настроек обёртки | +| `--autostart on\|off` | включить или выключить автозапуск при установке скриптом | + +## Настройки + +Хранятся в `settings.json` каталога конфигурации приложения +(`~/Library/Application Support/su.mhspx.glchat/` на macOS): + +| Ключ | Смысл | +|---|---| +| `instanceUrl` | адрес клиента, по умолчанию `https://gl.mhspx.su` | +| `closeToTray` | закрытие окна сворачивает приложение в трей | +| `autostart` | запуск при входе в систему (`--minimized`) | +| `notifications` | показывать системные уведомления | +| `pttShortcut`, `muteShortcut`, `deafenShortcut` | глобальные комбинации (пусто — выключено) | + +Меняются из меню трея и со страницы настроек обёртки (трей → «Настройки обёртки…»). +Сессия и токены живут только в cookie webview: обёртка их не хранит. + +## Deep links + +Схема `glchat://` (регистрируется бандлом): + +- `glchat://invite/` → страница приглашения; +- `glchat://guild//channel/` → сервер и комната. + +Ссылка, пришедшая до загрузки клиента (холодный старт), ждёт в очереди и +доставляется после `desktop_ready`. + +## Что не сделано (открытые пункты Фазы 6) + +- **Манифест обновлений на сервере**: `GET /updates/{target}/{arch}/{current_version}` + ещё не публикуется — нужна статика на стороне инстанса (Caddy) и релизный + контур с приватным ключом в CI. Клиентская часть готова: подпись проверяется, + downgrade отклоняется, при недоступном манифесте ошибка только логируется. +- **Клик по уведомлению** → открытие канала: у `tauri-plugin-notification` нет + колбэка действия на desktop, нужен свой мост к `UNUserNotificationCenter`. +- **Аватары в уведомлениях** (thumb/буква) и группировка — сейчас иконка приложения. +- **Захват экрана и микрофон**: разрешения macOS выдаёт webview, отдельных + экранов-инструкций у обёртки нет. +- **Подпись и нотаризация сборки** macOS/Windows: Developer ID/EV только в CI. +- **Windows/Linux-бандлы**: конфигурация кросс-платформенная, но собирались и + проверялись только macOS-arm64. diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 795b9b7..d860e1e 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/desktop/src-tauri/src/deeplink.rs b/desktop/src-tauri/src/deeplink.rs index 8fe9b68..2a4177f 100644 --- a/desktop/src-tauri/src/deeplink.rs +++ b/desktop/src-tauri/src/deeplink.rs @@ -18,7 +18,12 @@ pub fn install(app: &AppHandle) { 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(app: &AppHandle) { // Ссылка могла быть передана аргументом командной строки (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(), + }, + ); } } } diff --git a/desktop/src-tauri/src/instance.rs b/desktop/src-tauri/src/instance.rs index 9ebf54a..26b8bf8 100644 --- a/desktop/src-tauri/src/instance.rs +++ b/desktop/src-tauri/src/instance.rs @@ -23,7 +23,11 @@ pub fn normalize(input: &str) -> Result { 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()); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1d8e9ca..a7f6269 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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>(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); + } +} diff --git a/desktop/src-tauri/src/notifications.rs b/desktop/src-tauri/src/notifications.rs index fb52cb8..04c96fe 100644 --- a/desktop/src-tauri/src/notifications.rs +++ b/desktop/src-tauri/src/notifications.rs @@ -51,7 +51,11 @@ pub fn set_badge(app: &AppHandle, count: u32) { let count = app.state::().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 { diff --git a/desktop/src-tauri/src/shortcuts.rs b/desktop/src-tauri/src/shortcuts.rs index fbd147d..ef5407d 100644 --- a/desktop/src-tauri/src/shortcuts.rs +++ b/desktop/src-tauri/src/shortcuts.rs @@ -13,7 +13,11 @@ use crate::settings::Settings; use crate::state::AppState; /// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации. -pub fn on_shortcut(app: &AppHandle, shortcut: &Shortcut, event: tauri_plugin_global_shortcut::ShortcutEvent) { +pub fn on_shortcut( + app: &AppHandle, + shortcut: &Shortcut, + event: tauri_plugin_global_shortcut::ShortcutEvent, +) { let settings = app.state::().settings(); let pressed = event.state == ShortcutState::Pressed; diff --git a/desktop/src-tauri/src/updates.rs b/desktop/src-tauri/src/updates.rs index ed04099..df6ee42 100644 --- a/desktop/src-tauri/src/updates.rs +++ b/desktop/src-tauri/src/updates.rs @@ -76,7 +76,12 @@ pub async fn check(app: AppHandle, 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) => { diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 18bfcb2..40f5074 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -15,10 +15,7 @@ }, "bundle": { "active": true, - "targets": [ - "app", - "dmg" - ], + "targets": "all", "createUpdaterArtifacts": true, "category": "SocialNetworking", "shortDescription": "glchat — self-hosted платформа общения",