//! Автообновление (docs/client-tauri.md §4). //! //! Манифест обновлений отдаёт сам инстанс: `GET /updates/{target}/{arch}/{current_version}` //! (статика через Caddy). Артефакты подписаны ключом ed25519, подпись //! проверяется до установки — публичный ключ задаётся в `UPDATER_PUBKEY`. //! //! Пока ключ не заведён (сборка без релизного контура), проверка обновлений //! честно сообщает, что канал не настроен, вместо «суррогатного» обновления. use tauri::{AppHandle, Manager, Runtime}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; use tauri_plugin_updater::UpdaterExt; use crate::bridge::{self, WebMessage}; use crate::instance; use crate::state::AppState; /// Публичный ключ подписи обновлений (ed25519, формат minisign). /// /// Приватный ключ в репозиторий не попадает: он живёт в CI-секретах /// (`TAURI_SIGNING_PRIVATE_KEY`), а локально — в `build/desktop-keys/` /// (каталог `build/` в .gitignore). Подпись обязательна: манифест без неё /// плагин отклоняет, downgrade запрещён (docs/client-tauri.md §4, §6). pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCQkQ1NjkwN0EyQzZBOEEKUldTS2FpeDZrRmE5aXhqSThPWXA3am5YMnB5dmVWZWFEcVY4NGI3cG8yOTU1MjdtMW9sR09BNGIK"; /// Путь манифеста обновлений на инстансе. const UPDATE_PATH: &str = "/updates/{{target}}/{{arch}}/{{current_version}}"; /// Проверяет обновления. `interactive` — вызов из меню трея или настроек, /// тогда пользователь видит результат в диалоге; фоновая проверка молчит. /// `auto_install` — тихий режим (`--update-now`): найденное обновление ставится /// без вопроса, диалогов нет вовсе. pub async fn check(app: AppHandle, interactive: bool, auto_install: bool) { if UPDATER_PUBKEY.is_empty() { log::info!("автообновление не настроено: в сборке нет публичного ключа подписи"); if interactive { app.dialog() .message( "В этой сборке нет публичного ключа подписи обновлений, поэтому проверка \ недоступна. Ключ и манифест обновлений появятся вместе с релизным контуром \ (docs/client-tauri.md §4).", ) .title("Обновления не настроены") .buttons(MessageDialogButtons::Ok) .show(|_| {}); } return; } let settings = app.state::().settings(); let endpoint = match instance::normalize(&settings.instance_url) { Ok(url) => format!("{}{UPDATE_PATH}", instance::display(&url)), Err(error) => { log::warn!("не удалось определить адрес инстанса для обновлений: {error}"); return; } }; let Ok(endpoint) = url::Url::parse(&endpoint) else { log::warn!("некорректный адрес манифеста обновлений: {endpoint}"); return; }; let updater = match app .updater_builder() .endpoints(vec![endpoint]) .and_then(|builder| builder.pubkey(UPDATER_PUBKEY).build()) { Ok(updater) => updater, Err(error) => { log::warn!("не удалось собрать клиент обновлений: {error}"); return; } }; match updater.check().await { Ok(Some(update)) => { let version = update.version.clone(); log::info!("доступно обновление {version}"); // Обновление показываем в интерфейсе, установку спрашиваем отдельно: // во время звонка перезапуск приложения недопустим без согласия. bridge::send( &app, &WebMessage::UpdateAvailable { version: version.clone(), }, ); prompt_install(app, update, version, auto_install).await; } Ok(None) => { log::info!("обновлений нет"); if interactive { app.dialog() .message("Установлена последняя версия.") .title("Обновления") .buttons(MessageDialogButtons::Ok) .show(|_| {}); } } Err(error) => { log::warn!("проверка обновлений не удалась: {error}"); if interactive { let text = format!("Не удалось проверить обновления: {error}"); app.dialog() .message(text) .title("Обновления") .buttons(MessageDialogButtons::Ok) .show(|_| {}); } } } } /// Спрашивает согласие и устанавливает обновление (в тихом режиме — сразу). async fn prompt_install( app: AppHandle, update: tauri_plugin_updater::Update, version: String, auto_install: bool, ) { let handle = app.clone(); if auto_install { // Тихая установка: подпись артефакта проверяет плагин до распаковки — // флаг убирает вопрос пользователю, но не проверку релиза. log::info!( "тихое обновление по флагу: {} → {version}", app.package_info().version ); } else { let question = format!( "Доступна версия {version}. Скачать и установить сейчас? Приложение будет перезапущено, \ активный голосовой звонок прервётся." ); let (tx, rx) = std::sync::mpsc::channel::(); app.dialog() .message(question) .title("Обновление glchat") .buttons(MessageDialogButtons::YesNo) .show(move |confirmed| { let _ = tx.send(confirmed); }); // Диалог асинхронный: ждём ответ, не блокируя основной поток. let confirmed = tauri::async_runtime::spawn_blocking(move || rx.recv().unwrap_or(false)) .await .unwrap_or(false); if !confirmed { return; } } match update .download_and_install(|_chunk, _total| {}, || {}) .await { Ok(()) => { log::info!("обновление {version} установлено, перезапуск"); handle.restart(); } Err(error) => { log::error!("установка обновления не удалась: {error}"); if !auto_install { app.dialog() .message(format!( "Обновление не установлено: {error}. Скачать установщик вручную можно на \ странице инстанса." )) .title("Обновление glchat") .buttons(MessageDialogButtons::Ok) .show(|_| {}); } } } } #[cfg(test)] mod tests { use super::*; /// Манифест ровно в том виде, в каком его раскладывает /// `scripts/desktop-release.sh` (проверяется вместе с серверной частью: /// internal/server/updates.go). const MANIFEST: &str = r#"{ "version": "0.2.0", "notes": "Релиз glchat 0.2.0", "pub_date": "2026-09-22T12:00:00Z", "platforms": { "darwin-aarch64": { "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo=", "url": "https://gl.mhspx.su/updates/darwin/aarch64/glchat.app.tar.gz" } } }"#; /// Разбор манифеста идёт тем же типом, что использует плагин обновлений: /// тест ловит расхождение формата (например, дату не в RFC 3339), из-за /// которого обновление молча не приходило бы. #[test] fn манифест_разбирается_плагином_обновлений() { let release: tauri_plugin_updater::RemoteRelease = serde_json::from_str(MANIFEST).expect("манифест должен разбираться плагином"); assert_eq!(release.version.to_string(), "0.2.0"); assert!(release.pub_date.is_some(), "дата публикации не разобрана"); assert_eq!( release .download_url("darwin-aarch64") .expect("ссылка на артефакт") .as_str(), "https://gl.mhspx.su/updates/darwin/aarch64/glchat.app.tar.gz" ); assert_eq!( release .signature("darwin-aarch64") .expect("подпись платформы"), "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo=" ); // Другая платформа в манифесте отсутствует — плагин сообщит об этом. assert!(release.signature("windows-x86_64").is_err()); } /// Адрес манифеста собирается по адресу инстанса и не содержит двойных слэшей. #[test] fn адрес_манифеста_собирается_из_адреса_инстанса() { let url = instance::normalize("https://gl.mhspx.su/").expect("адрес инстанса"); let endpoint = format!("{}{UPDATE_PATH}", instance::display(&url)); assert_eq!( endpoint, "https://gl.mhspx.su/updates/{{target}}/{{arch}}/{{current_version}}" ); assert!( !UPDATER_PUBKEY.is_empty(), "публичный ключ подписи должен быть вшит в сборку" ); } }