b53cf9d6e7
Установка обновления всегда спрашивала согласие диалогом, поэтому сквозная проверка «поверх старой версии» не автоматизировалась (в headless-прогоне кликнуть некому), а на управляемых машинах обновление ставит оператор, а не пользователь. С флагом `--update-now` обёртка проверяет обновление сразу при старте (не через 30 секунд) и ставит найденное без диалога, после чего перезапускается; при отсутствии обновления работает как обычно. По умолчанию флаг выключен — трей, страница настроек и фоновая проверка по-прежнему спрашивают согласие. Подпись артефакта проверяется в обоих режимах: флаг убирает вопрос, но не проверку. Ход тихого обновления виден в журнале, а в строку запуска добавлена версия сборки — иначе после перезапуска не понять, какая версия работает. Решение — D-077, приёмка — desktop/README.md («Локальная проверка»).
235 lines
11 KiB
Rust
235 lines
11 KiB
Rust
//! Автообновление (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<R: Runtime>(app: AppHandle<R>, 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::<AppState>().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<R: Runtime>(
|
||
app: AppHandle<R>,
|
||
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::<bool>();
|
||
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(),
|
||
"публичный ключ подписи должен быть вшит в сборку"
|
||
);
|
||
}
|
||
}
|