//! Автообновление (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` — вызов из меню трея или настроек, /// тогда пользователь видит результат в диалоге; фоновая проверка молчит. pub async fn check(app: AppHandle, interactive: 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).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, ) { let handle = app.clone(); 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}"); app.dialog() .message(format!( "Обновление не установлено: {error}. Скачать установщик вручную можно на \ странице инстанса." )) .title("Обновление glchat") .buttons(MessageDialogButtons::Ok) .show(|_| {}); } } }