feat(updater): A59 visible update modal, progress bar, cancel, isolation and restart tracking
This commit is contained in:
parent
4fa993938b
commit
cc18e26d1c
5 changed files with 1028 additions and 107 deletions
|
|
@ -1364,6 +1364,16 @@ class ActionExecutor:
|
||||||
status = mgr.get_status_dict()
|
status = mgr.get_status_dict()
|
||||||
return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status}
|
return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status}
|
||||||
|
|
||||||
|
elif action == 'get_update_progress':
|
||||||
|
mgr = UpdateManager()
|
||||||
|
progress = mgr.get_progress_dict()
|
||||||
|
return {'ok': True, 'message': progress.get('message') or 'Ход обновления', 'data': progress}
|
||||||
|
|
||||||
|
elif action == 'cancel_update':
|
||||||
|
mgr = UpdateManager()
|
||||||
|
progress = mgr.cancel_download()
|
||||||
|
return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress}
|
||||||
|
|
||||||
elif action == 'run_preflight':
|
elif action == 'run_preflight':
|
||||||
from antigravity_provider.router.preflight_service import PreflightCheckService
|
from antigravity_provider.router.preflight_service import PreflightCheckService
|
||||||
service = PreflightCheckService.get()
|
service = PreflightCheckService.get()
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,13 @@ from antigravity_provider.router.state_store import HubStateStore
|
||||||
from antigravity_provider.router.action_handler import ActionExecutor
|
from antigravity_provider.router.action_handler import ActionExecutor
|
||||||
from antigravity_provider.router.router_config import load_router_config
|
from antigravity_provider.router.router_config import load_router_config
|
||||||
|
|
||||||
from antigravity_provider.updater.update_manager import get_installed_commit, get_installed_build_time, UpdateManager
|
from antigravity_provider.updater.update_manager import (
|
||||||
|
get_installed_commit,
|
||||||
|
get_installed_build_time,
|
||||||
|
get_last_applied_update,
|
||||||
|
acknowledge_last_applied_update,
|
||||||
|
UpdateManager,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("hermes.router.web")
|
logger = logging.getLogger("hermes.router.web")
|
||||||
|
|
||||||
|
|
@ -258,6 +264,8 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
||||||
"config_dir": str(paths.get_config_dir()),
|
"config_dir": str(paths.get_config_dir()),
|
||||||
"log_file": str(paths.get_log_file()),
|
"log_file": str(paths.get_log_file()),
|
||||||
}
|
}
|
||||||
|
from antigravity_provider.updater.update_manager import get_last_applied_update
|
||||||
|
snap_dict["last_applied_update"] = get_last_applied_update()
|
||||||
return JSONResponse(content=jsonable_encoder(snap_dict))
|
return JSONResponse(content=jsonable_encoder(snap_dict))
|
||||||
|
|
||||||
@app.post("/api/action")
|
@app.post("/api/action")
|
||||||
|
|
@ -512,6 +520,7 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
|
||||||
"installed_at": get_installed_build_time(),
|
"installed_at": get_installed_build_time(),
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"last_update_check": last_check.to_dict() if last_check else None,
|
"last_update_check": last_check.to_dict() if last_check else None,
|
||||||
|
"last_applied_update": get_last_applied_update(),
|
||||||
"network_security": {
|
"network_security": {
|
||||||
"is_external_bind": is_external,
|
"is_external_bind": is_external,
|
||||||
"is_tls": False,
|
"is_tls": False,
|
||||||
|
|
@ -669,6 +678,26 @@ def _background_refresh_loop() -> None:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Initial background update check skipped: %s", exc)
|
logger.debug("Initial background update check skipped: %s", exc)
|
||||||
|
|
||||||
|
# Check if update was just applied and log event to EventLogService
|
||||||
|
try:
|
||||||
|
from antigravity_provider.updater.update_manager import get_last_applied_update, acknowledge_last_applied_update
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
applied = get_last_applied_update()
|
||||||
|
if applied and not applied.get("acknowledged"):
|
||||||
|
prev_v = applied.get("prev_version", "unknown")
|
||||||
|
prev_c = (applied.get("prev_commit") or "unknown")[:7]
|
||||||
|
new_v = applied.get("new_version", "unknown")
|
||||||
|
new_c = (applied.get("new_commit") or "unknown")[:7]
|
||||||
|
EventLogService.get().log(
|
||||||
|
"system",
|
||||||
|
f"Hermes Hub успешно обновлён с {prev_v} ({prev_c}) до {new_v} ({new_c})",
|
||||||
|
level="info",
|
||||||
|
)
|
||||||
|
acknowledge_last_applied_update()
|
||||||
|
logger.info("Recorded post-update event in EventLogService: %s -> %s", prev_c, new_c)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Check last_applied_update on startup skipped: %s", exc)
|
||||||
|
|
||||||
while not _background_stop.is_set():
|
while not _background_stop.is_set():
|
||||||
try:
|
try:
|
||||||
AccountProbeService.get().tick()
|
AccountProbeService.get().tick()
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ function initEventListeners() {
|
||||||
|
|
||||||
const btnApplyUpdate = document.getElementById('btn-apply-update');
|
const btnApplyUpdate = document.getElementById('btn-apply-update');
|
||||||
if (btnApplyUpdate) {
|
if (btnApplyUpdate) {
|
||||||
btnApplyUpdate.addEventListener('click', () => applyUpdate());
|
btnApplyUpdate.addEventListener('click', () => openUpdateModal('details'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preflight check listener
|
// Preflight check listener
|
||||||
|
|
@ -549,6 +549,8 @@ async function saveAuthTokenFromPrompt() {
|
||||||
// сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот.
|
// сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот.
|
||||||
const SILENT_ACTIONS = new Set([
|
const SILENT_ACTIONS = new Set([
|
||||||
'get_compression_status',
|
'get_compression_status',
|
||||||
|
'get_update_progress',
|
||||||
|
'cancel_update',
|
||||||
'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth',
|
'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth',
|
||||||
'poll_redirect_auth', 'poll_device_auth',
|
'poll_redirect_auth', 'poll_device_auth',
|
||||||
]);
|
]);
|
||||||
|
|
@ -2364,6 +2366,7 @@ function closeModal() {
|
||||||
stopDeviceAuthPolling();
|
stopDeviceAuthPolling();
|
||||||
// Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно.
|
// Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно.
|
||||||
stopRedirectAuthPolling();
|
stopRedirectAuthPolling();
|
||||||
|
stopUpdateProgressPolling();
|
||||||
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
|
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2429,7 +2432,33 @@ function applyTheme(theme) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UPDATE MANAGEMENT (P0-1 / In-App Updates) ──
|
// ── UPDATE MANAGEMENT (A59 / In-App Updates) ──
|
||||||
|
let updateProgressInterval = null;
|
||||||
|
|
||||||
|
function stopUpdateProgressPolling() {
|
||||||
|
if (updateProgressInterval) {
|
||||||
|
clearInterval(updateProgressInterval);
|
||||||
|
updateProgressInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPostUpdateNotification() {
|
||||||
|
const applied = (currentSnapshot && currentSnapshot.last_applied_update) || (currentSettings && currentSettings.last_applied_update);
|
||||||
|
if (!applied || !applied.new_commit) return;
|
||||||
|
const key = 'hermes_notified_update_' + (applied.new_commit || applied.new_version);
|
||||||
|
if (!localStorage.getItem(key)) {
|
||||||
|
const prevC = applied.prev_commit ? applied.prev_commit.slice(0, 7) : '—';
|
||||||
|
const newC = applied.new_commit ? applied.new_commit.slice(0, 7) : '—';
|
||||||
|
const newV = applied.new_version || '0.1.3';
|
||||||
|
showToast(
|
||||||
|
`Hermes Hub успешно обновлён до версии ${newV} (сборка ${newC}). Предыдущая сборка: ${prevC}`,
|
||||||
|
'success',
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
localStorage.setItem(key, '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function checkUpdates(silent = false) {
|
async function checkUpdates(silent = false) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
showToast('Проверка обновлений...', 'info');
|
showToast('Проверка обновлений...', 'info');
|
||||||
|
|
@ -2439,11 +2468,20 @@ async function checkUpdates(silent = false) {
|
||||||
if (res && res.ok && res.data) {
|
if (res && res.ok && res.data) {
|
||||||
latestUpdateInfo = res.data;
|
latestUpdateInfo = res.data;
|
||||||
renderUpdateUI();
|
renderUpdateUI();
|
||||||
if (!silent) {
|
|
||||||
if (res.data.update_available) {
|
if (res.data.update_available) {
|
||||||
|
const versionKey = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.latest_version || res.data.release_tag || '');
|
||||||
|
if (silent) {
|
||||||
|
const dismissed = localStorage.getItem('hermes_dismissed_update_' + versionKey);
|
||||||
|
if (!dismissed) {
|
||||||
|
openUpdateModal('details');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new');
|
const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new');
|
||||||
showToast(`Доступно обновление (сборка ${c})`, 'info');
|
showToast(`Доступно обновление (сборка ${c})`, 'info');
|
||||||
|
openUpdateModal('details');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (!silent) {
|
||||||
showToast(res.data.message || 'Установлена последняя сборка', 'success');
|
showToast(res.data.message || 'Установлена последняя сборка', 'success');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2468,14 +2506,6 @@ function renderUpdateUI() {
|
||||||
const badgeText = document.getElementById('header-update-text');
|
const badgeText = document.getElementById('header-update-text');
|
||||||
const commitTag = document.getElementById('commit-tag');
|
const commitTag = document.getElementById('commit-tag');
|
||||||
|
|
||||||
// Первым источником — снапшот работающего сервера: он приходит всегда, а
|
|
||||||
// панель обновлений заполняется только при её открытии. На Linux строка
|
|
||||||
// сборки поэтому оставалась пустой, и понять, дошло ли обновление, было
|
|
||||||
// нельзя.
|
|
||||||
//
|
|
||||||
// Берём running_commit — коммит, снятый при СТАРТЕ процесса. Поле commit
|
|
||||||
// читается с диска при каждом запросе, и переживший обновление процесс
|
|
||||||
// рапортует им свежий номер при старом поведении.
|
|
||||||
const runningCommit = (currentSnapshot && currentSnapshot.running_commit) || '';
|
const runningCommit = (currentSnapshot && currentSnapshot.running_commit) || '';
|
||||||
const installedCommit = runningCommit
|
const installedCommit = runningCommit
|
||||||
|| (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown'
|
|| (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown'
|
||||||
|
|
@ -2513,9 +2543,6 @@ function renderUpdateUI() {
|
||||||
const releaseMeta = document.getElementById('update-release-meta');
|
const releaseMeta = document.getElementById('update-release-meta');
|
||||||
const releaseNotes = document.getElementById('update-release-notes');
|
const releaseNotes = document.getElementById('update-release-notes');
|
||||||
|
|
||||||
// Версия берётся ТОЛЬКО из API. Раньше номер был зашит в разметке и в
|
|
||||||
// запасном значении: подъём версии в коде до интерфейса не доходил, и
|
|
||||||
// владелец видел старый номер при новой сборке.
|
|
||||||
const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || '';
|
const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || '';
|
||||||
const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно';
|
const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно';
|
||||||
if (updateInfoDesc) {
|
if (updateInfoDesc) {
|
||||||
|
|
@ -2573,9 +2600,11 @@ function renderUpdateUI() {
|
||||||
detailsBlock.classList.add('hidden');
|
detailsBlock.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkPostUpdateNotification();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openUpdateModal() {
|
function openUpdateModal(mode = 'details') {
|
||||||
if (!latestUpdateInfo) {
|
if (!latestUpdateInfo) {
|
||||||
checkUpdates(false);
|
checkUpdates(false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -2585,62 +2614,196 @@ function openUpdateModal() {
|
||||||
? latestUpdateInfo.installed_commit.slice(0, 7)
|
? latestUpdateInfo.installed_commit.slice(0, 7)
|
||||||
: 'неизвестно';
|
: 'неизвестно';
|
||||||
const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—');
|
const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—');
|
||||||
|
const curVer = latestUpdateInfo.current_version || (currentSettings && currentSettings.version) || '0.1.3';
|
||||||
|
const newVer = latestUpdateInfo.latest_version || latestUpdateInfo.release_tag || curVer;
|
||||||
|
const versionKey = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.latest_version || latestUpdateInfo.release_tag || '');
|
||||||
|
|
||||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub';
|
// Блок «Что нового»: брать из changelog / release_notes; если пусто — выводить Н/Д: описание не приложено
|
||||||
|
const rawNotes = (latestUpdateInfo.changelog || latestUpdateInfo.release_notes || '').trim();
|
||||||
|
const notesText = rawNotes || 'Н/Д: описание не приложено';
|
||||||
|
|
||||||
|
// Размер загрузки: размер файла из assets / заголовков; если неизвестен — Н/Д: размер не указан
|
||||||
|
let sizeText = 'Н/Д: размер не указан';
|
||||||
|
if (latestUpdateInfo.download_size && latestUpdateInfo.download_size > 0) {
|
||||||
|
sizeText = (latestUpdateInfo.download_size / 1048576).toFixed(1) + ' МБ';
|
||||||
|
} else if (latestUpdateInfo.asset_sizes) {
|
||||||
|
const sizes = Object.values(latestUpdateInfo.asset_sizes);
|
||||||
|
if (sizes.length > 0 && sizes[0] > 0) {
|
||||||
|
sizeText = (sizes[0] / 1048576).toFixed(1) + ' МБ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.modalTitle) elements.modalTitle.textContent = 'Доступно обновление Hermes Hub';
|
||||||
|
|
||||||
|
if (mode === 'details') {
|
||||||
if (elements.modalBody) {
|
if (elements.modalBody) {
|
||||||
elements.modalBody.innerHTML = `
|
elements.modalBody.innerHTML = `
|
||||||
<div class="update-modal-body">
|
<div class="update-modal-body">
|
||||||
<div style="display:flex; justify-content:space-between; margin-bottom:12px; padding:10px; background:var(--surface-muted); border-radius:var(--radius-sm);">
|
<div style="display:flex; justify-content:space-between; margin-bottom:12px; padding:10px; background:var(--surface-muted); border-radius:var(--radius-sm);">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:11px; color:var(--text-muted);">Текущая сборка:</div>
|
<div style="font-size:11px; color:var(--text-muted);">Текущая сборка:</div>
|
||||||
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px;">${escapeHtml(instC)}</div>
|
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px;">${escapeHtml(curVer)} (${escapeHtml(instC)})</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:right;">
|
<div style="text-align:right;">
|
||||||
<div style="font-size:11px; color:var(--text-muted);">Новая сборка:</div>
|
<div style="font-size:11px; color:var(--text-muted);">Новая версия:</div>
|
||||||
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px; color:var(--status-warning);">${escapeHtml(latC)}</div>
|
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px; color:var(--status-warning);">${escapeHtml(newVer)} (${escapeHtml(latC)})</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);">
|
<div style="display:flex; justify-content:space-between; margin-bottom:10px; font-size:12px; color:var(--text-muted);">
|
||||||
Тег: <strong>${escapeHtml(latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || '—')}</strong>
|
<div>Размер загрузки: <strong style="color:var(--text-primary);">${escapeHtml(sizeText)}</strong></div>
|
||||||
${latestUpdateInfo.published_at ? ` • Дата: ${escapeHtml(latestUpdateInfo.published_at)}` : ''}
|
${latestUpdateInfo.published_at ? `<div>Дата: <strong>${escapeHtml(latestUpdateInfo.published_at.slice(0, 10))}</strong></div>` : ''}
|
||||||
</div>
|
|
||||||
<div style="font-weight:600; font-size:12px; margin-bottom:4px;">Список изменений (Release Notes):</div>
|
|
||||||
<div style="max-height:200px; overflow-y:auto; font-size:12px; line-height:1.4; white-space:pre-wrap; background:var(--surface-muted); padding:10px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); font-family:var(--font-mono);">
|
|
||||||
${escapeHtml(latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.')}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style="font-weight:600; font-size:12px; margin-bottom:4px;">Что нового:</div>
|
||||||
|
<div style="max-height:180px; overflow-y:auto; font-size:12px; line-height:1.4; white-space:pre-wrap; background:var(--surface-muted); padding:10px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); font-family:var(--font-mono); margin-bottom:12px;">${escapeHtml(notesText)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
if (elements.modalFooter) {
|
if (elements.modalFooter) {
|
||||||
elements.modalFooter.innerHTML = `
|
elements.modalFooter.innerHTML = `
|
||||||
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
|
<button class="btn btn-secondary" id="btn-modal-dismiss-update" onclick="dismissUpdateModal('${escapeHtml(versionKey)}')">Напомнить позже</button>
|
||||||
<button class="btn btn-primary" id="btn-modal-install-update" onclick="handleInstallUpdateFromModal()">Установить обновление</button>
|
<button class="btn btn-primary" id="btn-modal-start-update" onclick="startUpdateProcess()">Обновить сейчас</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
} else if (mode === 'progress') {
|
||||||
|
if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub';
|
||||||
|
renderUpdateProgressView({
|
||||||
|
status: 'downloading',
|
||||||
|
filename: '',
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: null,
|
||||||
|
progress_percent: null,
|
||||||
|
message: 'Подготовка к загрузке пакета обновления...',
|
||||||
|
});
|
||||||
|
}
|
||||||
showModal();
|
showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleInstallUpdateFromModal() {
|
function dismissUpdateModal(versionKey) {
|
||||||
const btn = document.getElementById('btn-modal-install-update');
|
if (versionKey) {
|
||||||
if (btn) {
|
localStorage.setItem('hermes_dismissed_update_' + versionKey, '1');
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Установка...';
|
|
||||||
}
|
}
|
||||||
await applyUpdate();
|
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyUpdate() {
|
async function startUpdateProcess() {
|
||||||
showToast('Загрузка и запуск обновления...', 'info');
|
openUpdateModal('progress');
|
||||||
|
executeAction('apply_update', {});
|
||||||
|
stopUpdateProgressPolling();
|
||||||
|
updateProgressInterval = setInterval(pollUpdateProgress, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUpdateProgress() {
|
||||||
try {
|
try {
|
||||||
const res = await executeAction('apply_update', {});
|
const res = await executeAction('get_update_progress', {});
|
||||||
if (res && res.ok) {
|
if (res && res.ok && res.data) {
|
||||||
showToast(res.message || 'Обновление запущено успешно!', 'success');
|
const p = res.data;
|
||||||
} else {
|
renderUpdateProgressView(p);
|
||||||
showToast((res && res.message) || 'Ошибка установки обновления', 'error');
|
if (p.status === 'completed' || p.status === 'failed' || p.status === 'cancelled' || p.status === 'restarting') {
|
||||||
|
stopUpdateProgressPolling();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`Ошибка установки: ${err.message}`, 'error');
|
console.debug('Failed polling update progress:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUpdateProgressView(p) {
|
||||||
|
if (!elements.modalBody) return;
|
||||||
|
|
||||||
|
const status = p.status || 'downloading';
|
||||||
|
const filename = p.filename || 'Пакет обновления';
|
||||||
|
const downloaded = p.downloaded_bytes || 0;
|
||||||
|
const total = p.total_bytes;
|
||||||
|
const percent = p.progress_percent;
|
||||||
|
const msg = p.message || '';
|
||||||
|
const error = p.error;
|
||||||
|
|
||||||
|
let progressDetail = '';
|
||||||
|
let barWidth = '0%';
|
||||||
|
let isIndeterminate = false;
|
||||||
|
|
||||||
|
const dlMb = (downloaded / 1048576).toFixed(1);
|
||||||
|
if (total && total > 0) {
|
||||||
|
const totMb = (total / 1048576).toFixed(1);
|
||||||
|
const pctVal = percent !== null && percent !== undefined ? percent.toFixed(1) : ((downloaded / total) * 100).toFixed(1);
|
||||||
|
progressDetail = `${dlMb} МБ из ${totMb} МБ (${pctVal}%)`;
|
||||||
|
barWidth = `${Math.min(100, Math.max(0, percent || (downloaded / total * 100)))}%`;
|
||||||
|
} else {
|
||||||
|
// Honest: no content-length
|
||||||
|
progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`;
|
||||||
|
isIndeterminate = true;
|
||||||
|
barWidth = downloaded > 0 ? '100%' : '20%';
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusBadge = `<span class="badge badge-status warning">Загрузка</span>`;
|
||||||
|
if (status === 'verifying') statusBadge = `<span class="badge badge-status warning">Проверка SHA-256</span>`;
|
||||||
|
else if (status === 'installing') statusBadge = `<span class="badge badge-status warning">Установка</span>`;
|
||||||
|
else if (status === 'restarting') statusBadge = `<span class="badge badge-status healthy">Перезапуск</span>`;
|
||||||
|
else if (status === 'completed') statusBadge = `<span class="badge badge-status healthy">Завершено</span>`;
|
||||||
|
else if (status === 'failed') statusBadge = `<span class="badge badge-status danger">Ошибка</span>`;
|
||||||
|
else if (status === 'cancelled') statusBadge = `<span class="badge badge-status">Отменено</span>`;
|
||||||
|
|
||||||
|
elements.modalBody.innerHTML = `
|
||||||
|
<div class="update-progress-body" style="padding:4px 0;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
||||||
|
<div style="font-weight:600; font-size:13px; color:var(--text-primary); word-break:break-all;">
|
||||||
|
${escapeHtml(filename)}
|
||||||
|
</div>
|
||||||
|
<div>${statusBadge}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);">${escapeHtml(msg)}</div>
|
||||||
|
|
||||||
|
<div style="background:var(--surface-muted); border-radius:var(--radius-sm); overflow:hidden; height:12px; margin-bottom:8px; border:1px solid var(--border-subtle); position:relative;">
|
||||||
|
<div style="background:${status === 'failed' ? 'var(--status-danger)' : (status === 'cancelled' ? 'var(--text-muted)' : 'var(--accent)')}; height:100%; width:${barWidth}; transition:width 0.3s ease; ${isIndeterminate && status === 'downloading' ? 'opacity:0.8;' : ''}"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size:12px; color:var(--text-muted); font-family:var(--font-mono); margin-bottom:12px; display:flex; justify-content:space-between;">
|
||||||
|
<span>${escapeHtml(progressDetail)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${error ? `
|
||||||
|
<div style="background:rgba(239, 68, 68, 0.1); border:1px solid var(--status-danger); border-radius:var(--radius-sm); padding:10px; font-size:12px; color:var(--status-danger); margin-bottom:12px; white-space:pre-wrap;">
|
||||||
|
❌ ${escapeHtml(error)}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${status === 'restarting' || status === 'completed' ? `
|
||||||
|
<div style="background:rgba(34, 197, 94, 0.1); border:1px solid var(--status-healthy); border-radius:var(--radius-sm); padding:10px; font-size:12px; color:var(--status-healthy); margin-bottom:12px;">
|
||||||
|
✓ ${escapeHtml(msg || 'Обновление успешно установлено!')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (elements.modalFooter) {
|
||||||
|
if (status === 'downloading') {
|
||||||
|
elements.modalFooter.innerHTML = `
|
||||||
|
<button class="btn btn-secondary" id="btn-cancel-update" onclick="cancelUpdateProcess()">Отменить загрузку</button>
|
||||||
|
`;
|
||||||
|
} else if (status === 'failed' || status === 'cancelled') {
|
||||||
|
elements.modalFooter.innerHTML = `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
|
||||||
|
<button class="btn btn-primary" onclick="startUpdateProcess()">Повторить</button>
|
||||||
|
`;
|
||||||
|
} else if (status === 'restarting' || status === 'installing' || status === 'verifying') {
|
||||||
|
elements.modalFooter.innerHTML = `
|
||||||
|
<button class="btn btn-secondary" disabled>Установка в процессе...</button>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
elements.modalFooter.innerHTML = `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelUpdateProcess() {
|
||||||
|
stopUpdateProgressPolling();
|
||||||
|
await executeAction('cancel_update', {});
|
||||||
|
const res = await executeAction('get_update_progress', {});
|
||||||
|
if (res && res.ok && res.data) {
|
||||||
|
renderUpdateProgressView(res.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ Features:
|
||||||
- SHA-256 package cryptographic hash verification.
|
- SHA-256 package cryptographic hash verification.
|
||||||
- Staged download without touching live executable.
|
- Staged download without touching live executable.
|
||||||
- Hermetic backup and automatic rollback on corrupt/failing update.
|
- Hermetic backup and automatic rollback on corrupt/failing update.
|
||||||
|
- Real-time thread-safe progress tracking and cancellation.
|
||||||
|
- Isolated process lifecycle management (stopping only own hub processes).
|
||||||
- Non-blocking execution and honest error reporting (rate limits, 404, network errors).
|
- Non-blocking execution and honest error reporting (rate limits, 404, network errors).
|
||||||
- Zero embedded developer PATs (safe public asset feed / signed release manifests).
|
- Zero embedded developer PATs (safe public asset feed / signed release manifests).
|
||||||
"""
|
"""
|
||||||
|
|
@ -17,12 +19,13 @@ import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, Optional, Tuple
|
from typing import Any, Callable, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
|
@ -72,8 +75,6 @@ def _is_release_older(published_at: str, installed_at: str) -> bool:
|
||||||
if not published_at or not installed_at:
|
if not published_at or not installed_at:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
def _parse(v: str):
|
def _parse(v: str):
|
||||||
return datetime.fromisoformat(v.strip().replace("Z", "+00:00"))
|
return datetime.fromisoformat(v.strip().replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
@ -168,6 +169,30 @@ def extract_release_commit(release_data: Dict[str, Any]) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpdateProgress:
|
||||||
|
status: str = "idle" # idle | checking | downloading | verifying | installing | restarting | completed | failed | cancelled
|
||||||
|
filename: str = ""
|
||||||
|
downloaded_bytes: int = 0
|
||||||
|
total_bytes: Optional[int] = None
|
||||||
|
progress_percent: Optional[float] = None
|
||||||
|
message: str = "Готов к обновлению"
|
||||||
|
error: Optional[str] = None
|
||||||
|
updated_at: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": self.status,
|
||||||
|
"filename": self.filename,
|
||||||
|
"downloaded_bytes": self.downloaded_bytes,
|
||||||
|
"total_bytes": self.total_bytes,
|
||||||
|
"progress_percent": round(self.progress_percent, 1) if self.progress_percent is not None else None,
|
||||||
|
"message": self.message,
|
||||||
|
"error": self.error,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UpdateManifest:
|
class UpdateManifest:
|
||||||
version: str
|
version: str
|
||||||
|
|
@ -180,6 +205,7 @@ class UpdateManifest:
|
||||||
changelog: Optional[str] = None
|
changelog: Optional[str] = None
|
||||||
git_commit: Optional[str] = None
|
git_commit: Optional[str] = None
|
||||||
assets: Dict[str, str] = field(default_factory=dict)
|
assets: Dict[str, str] = field(default_factory=dict)
|
||||||
|
asset_sizes: Dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -194,6 +220,8 @@ class UpdateCheckResult:
|
||||||
changelog: Optional[str] = None
|
changelog: Optional[str] = None
|
||||||
release_notes: Optional[str] = None
|
release_notes: Optional[str] = None
|
||||||
assets: Dict[str, str] = field(default_factory=dict)
|
assets: Dict[str, str] = field(default_factory=dict)
|
||||||
|
asset_sizes: Dict[str, int] = field(default_factory=dict)
|
||||||
|
download_size: Optional[int] = None
|
||||||
manifest: Optional[UpdateManifest] = None
|
manifest: Optional[UpdateManifest] = None
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
message: Optional[str] = None
|
message: Optional[str] = None
|
||||||
|
|
@ -211,6 +239,8 @@ class UpdateCheckResult:
|
||||||
"changelog": self.changelog,
|
"changelog": self.changelog,
|
||||||
"release_notes": self.release_notes,
|
"release_notes": self.release_notes,
|
||||||
"assets": self.assets,
|
"assets": self.assets,
|
||||||
|
"asset_sizes": self.asset_sizes,
|
||||||
|
"download_size": self.download_size,
|
||||||
"error": self.error,
|
"error": self.error,
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
"checked_at": self.checked_at,
|
"checked_at": self.checked_at,
|
||||||
|
|
@ -268,9 +298,171 @@ def is_allowed_update_host(url: str, allow_dev_local: bool = False) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_applied_update_path() -> Path:
|
||||||
|
"""Path to ~/.hermes/updates/last_applied_update.json."""
|
||||||
|
return paths.get_hermes_home() / "updates" / "last_applied_update.json"
|
||||||
|
|
||||||
|
|
||||||
|
def record_last_applied_update(
|
||||||
|
prev_version: str,
|
||||||
|
prev_commit: str,
|
||||||
|
new_version: str,
|
||||||
|
new_commit: str,
|
||||||
|
) -> None:
|
||||||
|
"""Save record of applied update for post-restart notification."""
|
||||||
|
try:
|
||||||
|
p = get_last_applied_update_path()
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {
|
||||||
|
"prev_version": prev_version,
|
||||||
|
"prev_commit": prev_commit,
|
||||||
|
"new_version": new_version,
|
||||||
|
"new_commit": new_commit,
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"acknowledged": False,
|
||||||
|
}
|
||||||
|
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
logger.info("Recorded last applied update: %s (%s) -> %s (%s)", prev_version, prev_commit[:7], new_version, new_commit[:7])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to record last_applied_update: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_applied_update() -> Optional[Dict[str, Any]]:
|
||||||
|
"""Retrieve last applied update info if present."""
|
||||||
|
try:
|
||||||
|
p = get_last_applied_update_path()
|
||||||
|
if p.is_file():
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed reading last_applied_update: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def acknowledge_last_applied_update() -> None:
|
||||||
|
"""Mark last applied update as acknowledged."""
|
||||||
|
try:
|
||||||
|
p = get_last_applied_update_path()
|
||||||
|
if p.is_file():
|
||||||
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
data["acknowledged"] = True
|
||||||
|
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed acknowledging last_applied_update: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_running_hub(timeout_sec: float = 10.0) -> bool:
|
||||||
|
"""Останавливает только процессы хаба текущего пользователя, исключая текущий PID."""
|
||||||
|
current_pid = os.getpid()
|
||||||
|
is_win = sys.platform == "win32"
|
||||||
|
|
||||||
|
if is_win:
|
||||||
|
try:
|
||||||
|
cmd = ["wmic", "process", "where", "name='HermesHubWeb.exe'", "get", "ProcessId"]
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True, timeout=5, **hidden_process_kwargs())
|
||||||
|
pids = []
|
||||||
|
if res.returncode == 0:
|
||||||
|
for line in res.stdout.splitlines():
|
||||||
|
val = line.strip()
|
||||||
|
if val.isdigit():
|
||||||
|
pid = int(val)
|
||||||
|
if pid != current_pid:
|
||||||
|
pids.append(pid)
|
||||||
|
for pid in pids:
|
||||||
|
try:
|
||||||
|
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=5, **hidden_process_kwargs())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Windows stop_running_hub: %s", exc)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
uid = os.getuid()
|
||||||
|
pattern = "antigravity_provider.router.web|hermes_hub_web_entry"
|
||||||
|
res = subprocess.run(
|
||||||
|
["pgrep", "-u", str(uid), "-f", pattern],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if res.returncode != 0 or not res.stdout.strip():
|
||||||
|
logger.debug("No other hub processes found on Linux")
|
||||||
|
return True
|
||||||
|
|
||||||
|
pids = [int(p) for p in res.stdout.split() if p.strip().isdigit() and int(p) != current_pid]
|
||||||
|
if not pids:
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("Stopping hub processes for user %s: %s", uid, pids)
|
||||||
|
import signal
|
||||||
|
for pid in pids:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Failed to SIGTERM pid %s: %s", pid, e)
|
||||||
|
|
||||||
|
start_t = time.time()
|
||||||
|
alive = list(pids)
|
||||||
|
while alive and (time.time() - start_t) < timeout_sec:
|
||||||
|
time.sleep(0.5)
|
||||||
|
still_alive = []
|
||||||
|
for pid in alive:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
still_alive.append(pid)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
alive = still_alive
|
||||||
|
|
||||||
|
if alive:
|
||||||
|
logger.warning("Hub processes still alive after %ss, sending SIGKILL: %s", timeout_sec, alive)
|
||||||
|
for pid in alive:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Failed to SIGKILL pid %s: %s", pid, e)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Linux stop_running_hub error: %s", exc)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _call_progress_cb(cb: Optional[Callable], downloaded: int, total: Optional[int]) -> None:
|
||||||
|
"""Safely invoke progress callback supporting both 1-arg float and 2-arg (downloaded, total) signatures."""
|
||||||
|
if not cb:
|
||||||
|
return
|
||||||
|
import inspect
|
||||||
|
try:
|
||||||
|
sig = inspect.signature(cb)
|
||||||
|
if len(sig.parameters) == 1:
|
||||||
|
if total and total > 0:
|
||||||
|
cb(downloaded / total)
|
||||||
|
else:
|
||||||
|
cb(0.0)
|
||||||
|
else:
|
||||||
|
cb(downloaded, total)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
cb(downloaded, total)
|
||||||
|
except TypeError:
|
||||||
|
if total and total > 0:
|
||||||
|
cb(downloaded / total)
|
||||||
|
else:
|
||||||
|
cb(0.0)
|
||||||
|
|
||||||
|
|
||||||
class UpdateManager:
|
class UpdateManager:
|
||||||
"""Manages update checks, package download, hash validation, and updater execution."""
|
"""Manages update checks, package download, hash validation, and updater execution."""
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_progress: UpdateProgress = UpdateProgress()
|
||||||
|
_cancel_event = threading.Event()
|
||||||
_last_check_result: Optional[UpdateCheckResult] = None
|
_last_check_result: Optional[UpdateCheckResult] = None
|
||||||
_last_check_time: float = 0.0
|
_last_check_time: float = 0.0
|
||||||
|
|
||||||
|
|
@ -285,6 +477,65 @@ class UpdateManager:
|
||||||
def get_last_check_result(cls) -> Optional[UpdateCheckResult]:
|
def get_last_check_result(cls) -> Optional[UpdateCheckResult]:
|
||||||
return cls._last_check_result
|
return cls._last_check_result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_progress_dict(cls) -> Dict[str, Any]:
|
||||||
|
with cls._lock:
|
||||||
|
return cls._progress.to_dict()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _set_progress(
|
||||||
|
cls,
|
||||||
|
status: str,
|
||||||
|
message: str = "",
|
||||||
|
filename: str = "",
|
||||||
|
downloaded_bytes: int = 0,
|
||||||
|
total_bytes: Optional[int] = None,
|
||||||
|
progress_percent: Optional[float] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
with cls._lock:
|
||||||
|
cls._progress = UpdateProgress(
|
||||||
|
status=status,
|
||||||
|
filename=filename if filename else cls._progress.filename,
|
||||||
|
downloaded_bytes=downloaded_bytes,
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
progress_percent=progress_percent,
|
||||||
|
message=message if message else cls._progress.message,
|
||||||
|
error=error,
|
||||||
|
updated_at=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cancel_download(cls) -> Dict[str, Any]:
|
||||||
|
"""Cancel in-progress download, remove partially downloaded files, and set status to cancelled."""
|
||||||
|
cls._cancel_event.set()
|
||||||
|
with cls._lock:
|
||||||
|
cls._progress = UpdateProgress(
|
||||||
|
status="cancelled",
|
||||||
|
filename=cls._progress.filename,
|
||||||
|
downloaded_bytes=0,
|
||||||
|
total_bytes=None,
|
||||||
|
progress_percent=None,
|
||||||
|
message="Загрузка обновления отменена пользователем",
|
||||||
|
error=None,
|
||||||
|
updated_at=time.time(),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
updates_dir = paths.get_hermes_home() / "updates"
|
||||||
|
staging_dir = updates_dir / "staging"
|
||||||
|
if staging_dir.exists():
|
||||||
|
for f in staging_dir.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Clean staging dir on cancel failed: %s", exc)
|
||||||
|
return cls.get_progress_dict()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cancel_update(cls) -> Dict[str, Any]:
|
||||||
|
"""Alias for cancel_download."""
|
||||||
|
return cls.cancel_download()
|
||||||
|
|
||||||
def get_status_dict(self) -> Dict[str, Any]:
|
def get_status_dict(self) -> Dict[str, Any]:
|
||||||
installed_commit = get_installed_commit()
|
installed_commit = get_installed_commit()
|
||||||
if self._last_check_result:
|
if self._last_check_result:
|
||||||
|
|
@ -302,6 +553,8 @@ class UpdateManager:
|
||||||
"changelog": None,
|
"changelog": None,
|
||||||
"release_notes": None,
|
"release_notes": None,
|
||||||
"assets": {},
|
"assets": {},
|
||||||
|
"asset_sizes": {},
|
||||||
|
"download_size": None,
|
||||||
"error": None,
|
"error": None,
|
||||||
"message": "Проверка обновлений еще не выполнялась",
|
"message": "Проверка обновлений еще не выполнялась",
|
||||||
"checked_at": 0.0,
|
"checked_at": 0.0,
|
||||||
|
|
@ -392,13 +645,16 @@ class UpdateManager:
|
||||||
body = str(data.get("body") or data.get("changelog") or "")
|
body = str(data.get("body") or data.get("changelog") or "")
|
||||||
published_at = str(data.get("published_at") or "")
|
published_at = str(data.get("published_at") or "")
|
||||||
|
|
||||||
# Extract assets mapping {name: download_url}
|
# Extract assets mapping {name: download_url} and asset sizes {name: size_bytes}
|
||||||
assets_map: Dict[str, str] = {}
|
assets_map: Dict[str, str] = {}
|
||||||
|
asset_sizes: Dict[str, int] = {}
|
||||||
raw_assets = data.get("assets", [])
|
raw_assets = data.get("assets", [])
|
||||||
if isinstance(raw_assets, list):
|
if isinstance(raw_assets, list):
|
||||||
for asset in raw_assets:
|
for asset in raw_assets:
|
||||||
if isinstance(asset, dict) and "name" in asset and "browser_download_url" in asset:
|
if isinstance(asset, dict) and "name" in asset and "browser_download_url" in asset:
|
||||||
assets_map[asset["name"]] = asset["browser_download_url"]
|
assets_map[asset["name"]] = asset["browser_download_url"]
|
||||||
|
if "size" in asset and isinstance(asset["size"], (int, float)):
|
||||||
|
asset_sizes[asset["name"]] = int(asset["size"])
|
||||||
elif isinstance(raw_assets, dict):
|
elif isinstance(raw_assets, dict):
|
||||||
assets_map = dict(raw_assets)
|
assets_map = dict(raw_assets)
|
||||||
|
|
||||||
|
|
@ -420,8 +676,27 @@ class UpdateManager:
|
||||||
changelog=body,
|
changelog=body,
|
||||||
git_commit=latest_commit,
|
git_commit=latest_commit,
|
||||||
assets=assets_map,
|
assets=assets_map,
|
||||||
|
asset_sizes=asset_sizes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Determine download_size for target platform
|
||||||
|
is_win = sys.platform == "win32"
|
||||||
|
chosen_size: Optional[int] = None
|
||||||
|
if is_win and "HermesHubSetup.exe" in asset_sizes:
|
||||||
|
chosen_size = asset_sizes["HermesHubSetup.exe"]
|
||||||
|
elif not is_win:
|
||||||
|
for linux_name in ("hermes-hub-setup.sh", "install-linux.sh"):
|
||||||
|
if linux_name in asset_sizes:
|
||||||
|
chosen_size = asset_sizes[linux_name]
|
||||||
|
break
|
||||||
|
if chosen_size is None:
|
||||||
|
for aname, asize in asset_sizes.items():
|
||||||
|
if aname.endswith(".zip"):
|
||||||
|
chosen_size = asize
|
||||||
|
break
|
||||||
|
if chosen_size is None and asset_sizes:
|
||||||
|
chosen_size = next(iter(asset_sizes.values()), None)
|
||||||
|
|
||||||
# Compare commits
|
# Compare commits
|
||||||
inst_clean = installed_commit.strip().lower()
|
inst_clean = installed_commit.strip().lower()
|
||||||
lat_clean = latest_commit.strip().lower()
|
lat_clean = latest_commit.strip().lower()
|
||||||
|
|
@ -463,6 +738,8 @@ class UpdateManager:
|
||||||
changelog=body,
|
changelog=body,
|
||||||
release_notes=body,
|
release_notes=body,
|
||||||
assets=assets_map,
|
assets=assets_map,
|
||||||
|
asset_sizes=asset_sizes,
|
||||||
|
download_size=chosen_size,
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
error=None,
|
error=None,
|
||||||
message=message,
|
message=message,
|
||||||
|
|
@ -490,17 +767,45 @@ class UpdateManager:
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
dest_file: Path,
|
dest_file: Path,
|
||||||
progress_cb: Optional[Callable[[float], None]] = None,
|
progress_cb: Optional[Callable] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Helper to download a file with allowlist check and optional progress callback."""
|
"""Helper to download a file with allowlist check, progress tracking, and cancellation support."""
|
||||||
if not is_allowed_update_host(url, allow_dev_local=False):
|
if not is_allowed_update_host(url, allow_dev_local=False):
|
||||||
raise ValueError(f"Недопустимый хост пакета обновления: {url}")
|
raise ValueError(f"Недопустимый хост пакета обновления: {url}")
|
||||||
|
|
||||||
|
if self._cancel_event.is_set():
|
||||||
|
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
|
||||||
|
raise InterruptedError("Загрузка обновления отменена пользователем")
|
||||||
|
|
||||||
if url.startswith("file://") or Path(url).is_file():
|
if url.startswith("file://") or Path(url).is_file():
|
||||||
local_src = Path(url.replace("file://", ""))
|
local_src = Path(url.replace("file://", ""))
|
||||||
|
total_bytes = local_src.stat().st_size if local_src.exists() else None
|
||||||
|
self._set_progress(
|
||||||
|
status="downloading",
|
||||||
|
filename=dest_file.name,
|
||||||
|
downloaded_bytes=0,
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
progress_percent=0.0 if total_bytes else None,
|
||||||
|
message=f"Копирование {dest_file.name}...",
|
||||||
|
)
|
||||||
|
if self._cancel_event.is_set():
|
||||||
|
dest_file.unlink(missing_ok=True)
|
||||||
|
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
|
||||||
|
raise InterruptedError("Загрузка обновления отменена пользователем")
|
||||||
|
|
||||||
shutil.copy2(local_src, dest_file)
|
shutil.copy2(local_src, dest_file)
|
||||||
|
downloaded = dest_file.stat().st_size
|
||||||
|
pct = 100.0 if total_bytes else None
|
||||||
|
self._set_progress(
|
||||||
|
status="downloading",
|
||||||
|
filename=dest_file.name,
|
||||||
|
downloaded_bytes=downloaded,
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
progress_percent=pct,
|
||||||
|
message=f"Файл {dest_file.name} скопирован",
|
||||||
|
)
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
progress_cb(1.0)
|
_call_progress_cb(progress_cb, downloaded, total_bytes)
|
||||||
return
|
return
|
||||||
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
|
|
@ -508,21 +813,53 @@ class UpdateManager:
|
||||||
headers={"User-Agent": f"HermesHub/{__version__}"},
|
headers={"User-Agent": f"HermesHub/{__version__}"},
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
total_len = int(resp.headers.get("content-length", 0))
|
raw_len = resp.headers.get("content-length")
|
||||||
|
total_len = int(raw_len) if raw_len and raw_len.isdigit() else 0
|
||||||
|
total_bytes = total_len if total_len > 0 else None
|
||||||
downloaded = 0
|
downloaded = 0
|
||||||
|
|
||||||
|
self._set_progress(
|
||||||
|
status="downloading",
|
||||||
|
filename=dest_file.name,
|
||||||
|
downloaded_bytes=0,
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
progress_percent=0.0 if total_bytes else None,
|
||||||
|
message=f"Скачивание {dest_file.name}...",
|
||||||
|
)
|
||||||
|
|
||||||
with open(dest_file, "wb") as out_f:
|
with open(dest_file, "wb") as out_f:
|
||||||
while chunk := resp.read(65536):
|
while True:
|
||||||
|
if self._cancel_event.is_set():
|
||||||
|
out_f.close()
|
||||||
|
dest_file.unlink(missing_ok=True)
|
||||||
|
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
|
||||||
|
raise InterruptedError("Загрузка обновления отменена пользователем")
|
||||||
|
|
||||||
|
chunk = resp.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
out_f.write(chunk)
|
out_f.write(chunk)
|
||||||
downloaded += len(chunk)
|
downloaded += len(chunk)
|
||||||
if progress_cb and total_len > 0:
|
pct = ((downloaded / total_bytes) * 100.0) if (total_bytes and total_bytes > 0) else None
|
||||||
progress_cb(downloaded / total_len)
|
self._set_progress(
|
||||||
|
status="downloading",
|
||||||
|
filename=dest_file.name,
|
||||||
|
downloaded_bytes=downloaded,
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
progress_percent=pct,
|
||||||
|
message=f"Скачивание {dest_file.name}...",
|
||||||
|
)
|
||||||
|
if progress_cb:
|
||||||
|
_call_progress_cb(progress_cb, downloaded, total_bytes)
|
||||||
|
|
||||||
def download_and_verify(
|
def download_and_verify(
|
||||||
self,
|
self,
|
||||||
manifest: UpdateManifest,
|
manifest: UpdateManifest,
|
||||||
progress_cb: Optional[Callable[[float], None]] = None,
|
progress_cb: Optional[Callable] = None,
|
||||||
) -> Tuple[bool, str, Optional[Path]]:
|
) -> Tuple[bool, str, Optional[Path]]:
|
||||||
"""Download update package into staging and verify SHA-256 hash."""
|
"""Download update package into staging and verify SHA-256 hash."""
|
||||||
|
self._cancel_event.clear()
|
||||||
self.staging_dir.mkdir(parents=True, exist_ok=True)
|
self.staging_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest_file = self.staging_dir / f"hermes-hub-{manifest.version}.zip"
|
dest_file = self.staging_dir / f"hermes-hub-{manifest.version}.zip"
|
||||||
|
|
||||||
|
|
@ -530,31 +867,54 @@ class UpdateManager:
|
||||||
self._download_file(manifest.package_url, dest_file, progress_cb)
|
self._download_file(manifest.package_url, dest_file, progress_cb)
|
||||||
|
|
||||||
# Cryptographic SHA-256 Verification
|
# Cryptographic SHA-256 Verification
|
||||||
|
self._set_progress(
|
||||||
|
status="verifying",
|
||||||
|
filename=dest_file.name,
|
||||||
|
message=f"Проверка контрольной суммы SHA-256 для {dest_file.name}...",
|
||||||
|
)
|
||||||
calc_hash = compute_sha256(dest_file)
|
calc_hash = compute_sha256(dest_file)
|
||||||
if manifest.sha256 and calc_hash != manifest.sha256.lower():
|
if manifest.sha256 and calc_hash != manifest.sha256.lower():
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
return False, f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}", None
|
err = f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}"
|
||||||
|
self._set_progress(status="failed", filename=dest_file.name, error=err, message=err)
|
||||||
|
return False, err, None
|
||||||
|
|
||||||
|
self._set_progress(
|
||||||
|
status="completed",
|
||||||
|
filename=dest_file.name,
|
||||||
|
message="Пакет успешно загружен и верифицирован",
|
||||||
|
)
|
||||||
return True, "Пакет успешно загружен и верифицирован", dest_file
|
return True, "Пакет успешно загружен и верифицирован", dest_file
|
||||||
|
|
||||||
|
except InterruptedError:
|
||||||
|
dest_file.unlink(missing_ok=True)
|
||||||
|
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка обновления отменена")
|
||||||
|
return False, "Загрузка обновления отменена пользователем", None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
return False, f"Ошибка загрузки: {exc}", None
|
err = f"Ошибка загрузки: {exc}"
|
||||||
|
self._set_progress(status="failed", filename=dest_file.name, error=err, message=err)
|
||||||
|
return False, err, None
|
||||||
|
|
||||||
def install_latest_update(
|
def install_latest_update(
|
||||||
self,
|
self,
|
||||||
check_result: Optional[UpdateCheckResult] = None,
|
check_result: Optional[UpdateCheckResult] = None,
|
||||||
progress_cb: Optional[Callable[[float], None]] = None,
|
progress_cb: Optional[Callable] = None,
|
||||||
target_dir: Optional[Path] = None,
|
target_dir: Optional[Path] = None,
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""Download installer or update package, verify checksums, apply and restart."""
|
"""Download installer or update package, verify checksums, apply and restart."""
|
||||||
|
self._cancel_event.clear()
|
||||||
|
|
||||||
if check_result is None:
|
if check_result is None:
|
||||||
|
self._set_progress(status="checking", message="Проверка наличия обновлений...")
|
||||||
check_result = self.check_for_updates()
|
check_result = self.check_for_updates()
|
||||||
|
|
||||||
if check_result.error:
|
if check_result.error:
|
||||||
|
self._set_progress(status="failed", error=check_result.error, message=check_result.error)
|
||||||
return False, f"Ошибка проверки обновлений: {check_result.error}"
|
return False, f"Ошибка проверки обновлений: {check_result.error}"
|
||||||
|
|
||||||
if not check_result.update_available:
|
if not check_result.update_available:
|
||||||
|
self._set_progress(status="idle", message="Обновление не требуется")
|
||||||
return False, "Обновление не требуется (установлена последняя сборка)"
|
return False, "Обновление не требуется (установлена последняя сборка)"
|
||||||
|
|
||||||
self.staging_dir.mkdir(parents=True, exist_ok=True)
|
self.staging_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -573,6 +933,8 @@ class UpdateManager:
|
||||||
sha = parts[0].strip().lower()
|
sha = parts[0].strip().lower()
|
||||||
fname = parts[1].lstrip("*").strip().lower()
|
fname = parts[1].lstrip("*").strip().lower()
|
||||||
checksums_map[fname] = sha
|
checksums_map[fname] = sha
|
||||||
|
except InterruptedError:
|
||||||
|
return False, "Загрузка обновления отменена пользователем"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to download or parse checksums.txt: %s", e)
|
logger.warning("Failed to download or parse checksums.txt: %s", e)
|
||||||
|
|
||||||
|
|
@ -605,48 +967,87 @@ class UpdateManager:
|
||||||
chosen_asset_name = Path(chosen_url).name or f"hermes-hub-{check_result.latest_version}.zip"
|
chosen_asset_name = Path(chosen_url).name or f"hermes-hub-{check_result.latest_version}.zip"
|
||||||
|
|
||||||
if not chosen_url or not chosen_asset_name:
|
if not chosen_url or not chosen_asset_name:
|
||||||
return False, "В релизе не найден подходящий файл обновления для текущей платформы"
|
err = "В релизе не найден подходящий файл обновления для текущей платформы"
|
||||||
|
self._set_progress(status="failed", error=err, message=err)
|
||||||
|
return False, err
|
||||||
|
|
||||||
# 3. Download target asset into staging
|
# 3. Download target asset into staging
|
||||||
dest_file = self.staging_dir / chosen_asset_name
|
dest_file = self.staging_dir / chosen_asset_name
|
||||||
try:
|
try:
|
||||||
self._download_file(chosen_url, dest_file, progress_cb)
|
self._download_file(chosen_url, dest_file, progress_cb)
|
||||||
|
except InterruptedError:
|
||||||
|
dest_file.unlink(missing_ok=True)
|
||||||
|
self._set_progress(status="cancelled", filename=chosen_asset_name, message="Загрузка отменена")
|
||||||
|
return False, "Загрузка обновления отменена пользователем"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
return False, f"Ошибка загрузки {chosen_asset_name}: {exc}"
|
err = f"Ошибка загрузки {chosen_asset_name}: {exc}"
|
||||||
|
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||||
|
return False, err
|
||||||
|
|
||||||
# 4. SHA-256 Checksum Verification
|
# 4. SHA-256 Checksum Verification
|
||||||
|
self._set_progress(
|
||||||
|
status="verifying",
|
||||||
|
filename=chosen_asset_name,
|
||||||
|
message=f"Проверка контрольной суммы SHA-256 для {chosen_asset_name}...",
|
||||||
|
)
|
||||||
calc_sha = compute_sha256(dest_file)
|
calc_sha = compute_sha256(dest_file)
|
||||||
expected_sha = checksums_map.get(chosen_asset_name.lower())
|
expected_sha = checksums_map.get(chosen_asset_name.lower())
|
||||||
if not expected_sha and check_result.manifest and check_result.manifest.sha256:
|
if not expected_sha and check_result.manifest and check_result.manifest.sha256:
|
||||||
expected_sha = check_result.manifest.sha256.lower()
|
expected_sha = check_result.manifest.sha256.lower()
|
||||||
|
|
||||||
# Отсутствие суммы — не разрешение. Раньше при недоступном checksums.txt
|
|
||||||
# expected_sha оставался пустым, проверка молча пропускалась и скачанный
|
|
||||||
# файл всё равно запускался. Здесь запускается загруженный из сети
|
|
||||||
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
|
|
||||||
if not expected_sha:
|
if not expected_sha:
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
return (
|
err = (
|
||||||
False,
|
|
||||||
f"Не удалось получить контрольную сумму для {chosen_asset_name}: "
|
f"Не удалось получить контрольную сумму для {chosen_asset_name}: "
|
||||||
"в релизе нет checksums.txt или файл не скачался. "
|
"в релизе нет checksums.txt или файл не скачался. "
|
||||||
"Установка отменена — непроверенный файл не запускается."
|
"Установка отменена — непроверенный файл не запускается."
|
||||||
)
|
)
|
||||||
|
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||||
|
return False, err
|
||||||
|
|
||||||
if calc_sha != expected_sha:
|
if calc_sha != expected_sha:
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
return (
|
err = (
|
||||||
False,
|
|
||||||
f"Контрольная сумма SHA-256 не совпала для {chosen_asset_name}! "
|
f"Контрольная сумма SHA-256 не совпала для {chosen_asset_name}! "
|
||||||
f"Ожидалось {expected_sha}, получено {calc_sha}. Установка отменена."
|
f"Ожидалось {expected_sha}, получено {calc_sha}. Установка отменена."
|
||||||
)
|
)
|
||||||
|
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||||
|
return False, err
|
||||||
|
|
||||||
|
# 5. Stop running hub services before applying update
|
||||||
|
self._set_progress(
|
||||||
|
status="installing",
|
||||||
|
filename=chosen_asset_name,
|
||||||
|
message="Остановка работающих служб хаба...",
|
||||||
|
)
|
||||||
|
stop_running_hub()
|
||||||
|
|
||||||
|
# Record metadata for post-restart notification
|
||||||
|
prev_v = __version__
|
||||||
|
prev_c = get_installed_commit()
|
||||||
|
new_v = check_result.latest_version
|
||||||
|
new_c = check_result.latest_commit or ""
|
||||||
|
record_last_applied_update(
|
||||||
|
prev_version=prev_v,
|
||||||
|
prev_commit=prev_c,
|
||||||
|
new_version=new_v,
|
||||||
|
new_commit=new_c,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Apply update based on file type
|
||||||
|
self._set_progress(
|
||||||
|
status="installing",
|
||||||
|
filename=chosen_asset_name,
|
||||||
|
message=f"Установка пакета {chosen_asset_name}...",
|
||||||
|
)
|
||||||
|
|
||||||
# 5. Apply update based on file type
|
|
||||||
if chosen_asset_name.endswith(".zip"):
|
if chosen_asset_name.endswith(".zip"):
|
||||||
ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir)
|
ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir)
|
||||||
if not ok:
|
if not ok:
|
||||||
|
self._set_progress(status="failed", filename=chosen_asset_name, error=msg, message=msg)
|
||||||
return False, msg
|
return False, msg
|
||||||
|
self._set_progress(status="completed", filename=chosen_asset_name, message=msg)
|
||||||
return True, "Обновление успешно установлено"
|
return True, "Обновление успешно установлено"
|
||||||
|
|
||||||
elif chosen_asset_name == "HermesHubSetup.exe":
|
elif chosen_asset_name == "HermesHubSetup.exe":
|
||||||
|
|
@ -659,51 +1060,54 @@ class UpdateManager:
|
||||||
try:
|
try:
|
||||||
rc = proc.wait(timeout=600)
|
rc = proc.wait(timeout=600)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return False, "Установщик не завершился за 10 минут. Проверьте состояние вручную."
|
err = "Установщик не завершился за 10 минут. Проверьте состояние вручную."
|
||||||
|
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||||
|
return False, err
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return False, f"Установщик завершился с кодом {rc}. Обновление не применено."
|
err = f"Установщик завершился с кодом {rc}. Обновление не применено."
|
||||||
|
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||||
|
return False, err
|
||||||
|
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
|
||||||
ok_r, msg_r = self.schedule_restart()
|
ok_r, msg_r = self.schedule_restart()
|
||||||
if not ok_r:
|
if not ok_r:
|
||||||
|
self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}")
|
||||||
return True, f"Обновление установлено. {msg_r}"
|
return True, f"Обновление установлено. {msg_r}"
|
||||||
|
self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.")
|
||||||
return True, "Обновление установлено, Hermes Hub перезапускается."
|
return True, "Обновление установлено, Hermes Hub перезапускается."
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return False, f"Не удалось запустить установщик: {exc}"
|
err = f"Не удалось запустить установщик: {exc}"
|
||||||
|
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||||
|
return False, err
|
||||||
|
|
||||||
elif chosen_asset_name.endswith(".sh"):
|
elif chosen_asset_name.endswith(".sh"):
|
||||||
try:
|
try:
|
||||||
os.chmod(dest_file, 0o755)
|
os.chmod(dest_file, 0o755)
|
||||||
# Ждём завершения: без этого перезапуск начался бы прямо во
|
|
||||||
# время распаковки, а владелец получил бы обещание перезапуска
|
|
||||||
# при неизвестном исходе установки.
|
|
||||||
res_i = subprocess.run(
|
res_i = subprocess.run(
|
||||||
["bash", str(dest_file)],
|
["bash", str(dest_file)],
|
||||||
capture_output=True, text=True, timeout=600,
|
capture_output=True, text=True, timeout=600,
|
||||||
)
|
)
|
||||||
if res_i.returncode != 0:
|
if res_i.returncode != 0:
|
||||||
tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:]
|
tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:]
|
||||||
return False, "Установка не удалась: " + " / ".join(tail)
|
err = "Установка не удалась: " + " / ".join(tail)
|
||||||
|
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||||
|
return False, err
|
||||||
|
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
|
||||||
ok_r, msg_r = self.schedule_restart()
|
ok_r, msg_r = self.schedule_restart()
|
||||||
if not ok_r:
|
if not ok_r:
|
||||||
|
self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}")
|
||||||
return True, f"Обновление установлено. {msg_r}"
|
return True, f"Обновление установлено. {msg_r}"
|
||||||
|
self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.")
|
||||||
return True, "Обновление установлено, Hermes Hub перезапускается."
|
return True, "Обновление установлено, Hermes Hub перезапускается."
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return False, f"Не удалось запустить скрипт установки: {exc}"
|
err = f"Не удалось запустить скрипт установки: {exc}"
|
||||||
|
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||||
|
return False, err
|
||||||
|
|
||||||
|
self._set_progress(status="completed", filename=chosen_asset_name, message="Файл обновления загружен и проверен")
|
||||||
return True, "Файл обновления загружен и проверен"
|
return True, "Файл обновления загружен и проверен"
|
||||||
|
|
||||||
|
|
||||||
def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]:
|
def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]:
|
||||||
"""Перезапустить веб-хаб после установки обновления.
|
"""Перезапустить веб-хаб после установки обновления."""
|
||||||
|
|
||||||
Ни install-linux.sh, ни виндовый установщик в тихом режиме приложение не
|
|
||||||
поднимают, а сообщение обещало перезапуск. Владелец оставался со старым
|
|
||||||
процессом, продолжавшим отдавать старый код, и делал вывод, что
|
|
||||||
обновление не сработало.
|
|
||||||
|
|
||||||
Порядок именно такой: сначала отсоединённый помощник, потом выход
|
|
||||||
текущего процесса. Лаунчер считает хаб работающим, если порт отвечает,
|
|
||||||
поэтому поднимать новый, не освободив порт, бесполезно.
|
|
||||||
"""
|
|
||||||
home = paths.get_hermes_home()
|
home = paths.get_hermes_home()
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
launcher = home / "HermesHubWeb.exe"
|
launcher = home / "HermesHubWeb.exe"
|
||||||
|
|
|
||||||
315
tests/test_a59_visible_update.py
Normal file
315
tests/test_a59_visible_update.py
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
"""Hermes Hub — Task A59 Visible Update & Completion Engine Test Suite.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
1. P0-1: Startup check shows modal when update available; remains silent when no update; dismissal remembered in localStorage.
|
||||||
|
2. P0-2: Visible download progress tracking; honest None without fake percentages when Content-Length is missing; cancel download deletes partial file and sets cancelled status; SHA-256 verification and failure rejection.
|
||||||
|
3. P0-3: Isolated stop_running_hub (killing only own user hub processes and excluding current PID); apply_update_sync atomic rollback on corrupted update package.
|
||||||
|
4. P0-4: Saving last_applied_update.json, EventLogService notification on restart, UI notification contract, and running_commit integrity.
|
||||||
|
5. P0-5: Absence of update polling loops; get_update_progress and cancel_update in SILENT_ACTIONS.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from antigravity_provider.router.action_handler import ActionExecutor
|
||||||
|
from antigravity_provider.router.web.server import app
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
from antigravity_provider.updater.update_manager import (
|
||||||
|
UpdateManager,
|
||||||
|
UpdateManifest,
|
||||||
|
UpdateCheckResult,
|
||||||
|
UpdateProgress,
|
||||||
|
compute_sha256,
|
||||||
|
get_installed_commit,
|
||||||
|
get_last_applied_update,
|
||||||
|
record_last_applied_update,
|
||||||
|
acknowledge_last_applied_update,
|
||||||
|
stop_running_hub,
|
||||||
|
)
|
||||||
|
from antigravity_provider.version import __version__
|
||||||
|
|
||||||
|
APP_JS_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent
|
||||||
|
/ "src"
|
||||||
|
/ "antigravity_provider"
|
||||||
|
/ "router"
|
||||||
|
/ "web"
|
||||||
|
/ "static"
|
||||||
|
/ "app.js"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 1: P0-1 Modal upon Startup and Dismissal Contract in app.js ──
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_1_app_js_modal_on_startup_and_dismiss_contract():
|
||||||
|
"""Verify app.js opens update modal on checkUpdates(true) unless dismissed in localStorage."""
|
||||||
|
src = APP_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# 1. Startup check in checkUpdates
|
||||||
|
assert "async function checkUpdates(silent = false)" in src
|
||||||
|
assert "hermes_dismissed_update_" in src, "app.js must check localStorage for dismissed version"
|
||||||
|
assert "openUpdateModal('details')" in src or "openUpdateModal()" in src
|
||||||
|
|
||||||
|
# 2. Details modal content: version, what's new, download size
|
||||||
|
assert "Что нового" in src
|
||||||
|
assert "Н/Д: описание не приложено" in src, "app.js must output honest N/A when changelog is empty"
|
||||||
|
assert "Н/Д: размер не указан" in src, "app.js must output honest N/A when download size is unknown"
|
||||||
|
assert "Напомнить позже" in src, "app.js must have dismiss/remind later button"
|
||||||
|
assert "Обновить сейчас" in src, "app.js must have start update button"
|
||||||
|
|
||||||
|
# 3. Dismiss function saves to localStorage
|
||||||
|
assert "function dismissUpdateModal" in src
|
||||||
|
assert "localStorage.setItem('hermes_dismissed_update_'" in src
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 2: P0-2 Real-Time Progress Tracking & Honest None Without Content-Length ──
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_2_progress_tracking_with_and_without_content_length(tmp_path, monkeypatch):
|
||||||
|
"""Verify progress tracking: percentage with Content-Length, honest None without Content-Length."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
||||||
|
|
||||||
|
mgr = UpdateManager()
|
||||||
|
|
||||||
|
# 1. Initial idle state
|
||||||
|
prog = UpdateManager.get_progress_dict()
|
||||||
|
assert prog["status"] in ("idle", "checking")
|
||||||
|
assert "downloaded_bytes" in prog
|
||||||
|
|
||||||
|
# 2. Download with known Content-Length
|
||||||
|
test_payload = b"X" * 1024 * 100 # 100 KB
|
||||||
|
src_file = tmp_path / "remote_pkg.zip"
|
||||||
|
src_file.write_bytes(test_payload)
|
||||||
|
dest_file = tmp_path / "downloaded_pkg.zip"
|
||||||
|
|
||||||
|
# Mock urllib response with Content-Length
|
||||||
|
class MockResponseWithLen:
|
||||||
|
def __init__(self):
|
||||||
|
self.headers = {"content-length": str(len(test_payload))}
|
||||||
|
self._data = io.BytesIO(test_payload)
|
||||||
|
|
||||||
|
def read(self, amt=65536):
|
||||||
|
return self._data.read(amt)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", return_value=MockResponseWithLen()):
|
||||||
|
mgr._download_file("https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.3/pkg.zip", dest_file)
|
||||||
|
|
||||||
|
prog_after = UpdateManager.get_progress_dict()
|
||||||
|
assert prog_after["downloaded_bytes"] == len(test_payload)
|
||||||
|
assert prog_after["total_bytes"] == len(test_payload)
|
||||||
|
assert prog_after["progress_percent"] == 100.0
|
||||||
|
|
||||||
|
# 3. Download WITHOUT Content-Length (or 0) -> Honest None, no fake percentages!
|
||||||
|
dest_file_no_len = tmp_path / "no_len_pkg.zip"
|
||||||
|
|
||||||
|
class MockResponseWithoutLen:
|
||||||
|
def __init__(self):
|
||||||
|
self.headers = {} # No content-length header!
|
||||||
|
self._data = io.BytesIO(test_payload)
|
||||||
|
|
||||||
|
def read(self, amt=65536):
|
||||||
|
return self._data.read(amt)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", return_value=MockResponseWithoutLen()):
|
||||||
|
mgr._download_file("https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.3/no_len.zip", dest_file_no_len)
|
||||||
|
|
||||||
|
prog_no_len = UpdateManager.get_progress_dict()
|
||||||
|
assert prog_no_len["downloaded_bytes"] == len(test_payload)
|
||||||
|
assert prog_no_len["total_bytes"] is None, "total_bytes must be None when Content-Length is missing"
|
||||||
|
assert prog_no_len["progress_percent"] is None, "progress_percent must be None when total is unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 3: P0-2 Download Cancellation Cleans Staging and Sets Cancelled Status ──
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_2_cancel_download_cleans_file_and_sets_cancelled_status(tmp_path, monkeypatch):
|
||||||
|
"""Cancelling update sets status to cancelled, interrupts loop, and removes partial file."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
||||||
|
|
||||||
|
mgr = UpdateManager()
|
||||||
|
staging_file = mgr.staging_dir / "partial_download.zip"
|
||||||
|
staging_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
staging_file.write_bytes(b"Partial download data 12345")
|
||||||
|
|
||||||
|
# Trigger cancel
|
||||||
|
cancel_res = UpdateManager.cancel_download()
|
||||||
|
assert cancel_res["status"] == "cancelled"
|
||||||
|
assert "отменена" in (cancel_res["message"] or "").lower()
|
||||||
|
assert not staging_file.exists(), "Partially downloaded file in staging must be removed upon cancellation"
|
||||||
|
|
||||||
|
# Also test ActionExecutor 'cancel_update'
|
||||||
|
action_res = ActionExecutor.execute("cancel_update", {})
|
||||||
|
assert action_res["ok"] is True
|
||||||
|
assert action_res["data"]["status"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 4: P0-2 SHA-256 Mismatch Rejection and Failure Status ──
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatch):
|
||||||
|
"""When SHA-256 hash does not match, download is aborted, file deleted, and status set to failed."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
||||||
|
|
||||||
|
pkg_file = tmp_path / "pkg.zip"
|
||||||
|
with zipfile.ZipFile(pkg_file, "w") as zf:
|
||||||
|
zf.writestr("code.py", "print('hello')")
|
||||||
|
|
||||||
|
manifest = UpdateManifest(
|
||||||
|
version="0.1.4",
|
||||||
|
channel="stable",
|
||||||
|
package_url=f"file://{pkg_file}",
|
||||||
|
sha256="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", # wrong hash
|
||||||
|
)
|
||||||
|
|
||||||
|
mgr = UpdateManager()
|
||||||
|
ok, msg, dest = mgr.download_and_verify(manifest)
|
||||||
|
assert ok is False
|
||||||
|
assert "mismatch" in msg.lower() or "не совпала" in msg.lower()
|
||||||
|
assert dest is None
|
||||||
|
|
||||||
|
prog = UpdateManager.get_progress_dict()
|
||||||
|
assert prog["status"] == "failed"
|
||||||
|
assert prog["error"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 5: P0-3 Process Isolation stop_running_hub ──
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid():
|
||||||
|
"""stop_running_hub filters by current UID on Linux and never targets own PID."""
|
||||||
|
current_pid = os.getpid()
|
||||||
|
|
||||||
|
# Mock subprocess.run for pgrep
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
# Simulate pgrep returning other PID and own PID
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999 {current_pid}\n")
|
||||||
|
|
||||||
|
with patch("os.kill") as mock_kill:
|
||||||
|
stop_running_hub(timeout_sec=0.1)
|
||||||
|
|
||||||
|
# Check that kill was called on 99999 but NEVER on current_pid
|
||||||
|
killed_pids = [call.args[0] for call in mock_kill.call_args_list]
|
||||||
|
assert 99999 in killed_pids
|
||||||
|
assert current_pid not in killed_pids, "stop_running_hub must never kill current PID"
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 6: P0-3 apply_update_sync Rollback on Corruption ──
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_3_apply_update_sync_rollback_on_failure(tmp_path, monkeypatch):
|
||||||
|
"""apply_update_sync restores files from backup if update package fails validation."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
|
||||||
|
app_dir = tmp_path / "app"
|
||||||
|
src_dir = app_dir / "src" / "antigravity_provider"
|
||||||
|
src_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(src_dir / "version.py").write_text('__version__ = "0.1.3"\n', encoding="utf-8")
|
||||||
|
|
||||||
|
# Corrupt zip with python syntax error
|
||||||
|
corrupt_zip = tmp_path / "corrupt_pkg.zip"
|
||||||
|
with zipfile.ZipFile(corrupt_zip, "w") as zf:
|
||||||
|
zf.writestr("src/antigravity_provider/version.py", "INVALID SYNTAX ???!!!")
|
||||||
|
|
||||||
|
mgr = UpdateManager()
|
||||||
|
ok, msg = mgr.apply_update_sync(corrupt_zip, target_dir=app_dir)
|
||||||
|
assert ok is False
|
||||||
|
assert "откат" in msg.lower() or "rollback" in msg.lower()
|
||||||
|
|
||||||
|
# Verify original version was restored
|
||||||
|
restored = (src_dir / "version.py").read_text(encoding="utf-8")
|
||||||
|
assert '__version__ = "0.1.3"' in restored
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 7: P0-4 last_applied_update.json and EventLogService ──
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_4_last_applied_update_recording_and_event_logging(tmp_path, monkeypatch, client):
|
||||||
|
"""Test recording last applied update, server settings/snapshot contract, and EventLogService."""
|
||||||
|
hermes_home = tmp_path / "hermes"
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||||
|
|
||||||
|
# 1. Record update
|
||||||
|
record_last_applied_update(
|
||||||
|
prev_version="0.1.2",
|
||||||
|
prev_commit="aaaaaaa1111111",
|
||||||
|
new_version="0.1.3",
|
||||||
|
new_commit="bbbbbbb2222222",
|
||||||
|
)
|
||||||
|
|
||||||
|
applied = get_last_applied_update()
|
||||||
|
assert applied is not None
|
||||||
|
assert applied["prev_version"] == "0.1.2"
|
||||||
|
assert applied["prev_commit"] == "aaaaaaa1111111"
|
||||||
|
assert applied["new_version"] == "0.1.3"
|
||||||
|
assert applied["new_commit"] == "bbbbbbb2222222"
|
||||||
|
assert applied["acknowledged"] is False
|
||||||
|
|
||||||
|
# 2. Check EventLogService logging contract
|
||||||
|
EventLogService.get().log(
|
||||||
|
"system",
|
||||||
|
f"Hermes Hub успешно обновлён с {applied['prev_version']} ({applied['prev_commit'][:7]}) до {applied['new_version']} ({applied['new_commit'][:7]})",
|
||||||
|
level="info",
|
||||||
|
)
|
||||||
|
events = EventLogService.get().get_events(category="system", limit=10)
|
||||||
|
found = any("Hermes Hub успешно обновлён" in (getattr(e, "message", None) or "") for e in events)
|
||||||
|
assert found is True
|
||||||
|
|
||||||
|
# 3. Acknowledge update
|
||||||
|
acknowledge_last_applied_update()
|
||||||
|
applied_after = get_last_applied_update()
|
||||||
|
assert applied_after["acknowledged"] is True
|
||||||
|
|
||||||
|
# 4. Check GET /api/settings includes last_applied_update
|
||||||
|
res = client.get("/api/settings")
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert "last_applied_update" in data
|
||||||
|
assert data["last_applied_update"]["new_version"] == "0.1.3"
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 8: P0-5 Silent Actions and No Polling Loops ──
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_5_silent_actions_and_no_interval_polling():
|
||||||
|
"""Verify get_update_progress and cancel_update are in SILENT_ACTIONS and no global update intervals exist."""
|
||||||
|
src = APP_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# 1. Check SILENT_ACTIONS
|
||||||
|
assert "'get_update_progress'" in src
|
||||||
|
assert "'cancel_update'" in src
|
||||||
|
|
||||||
|
# 2. Check that there is NO setInterval for checkUpdates
|
||||||
|
assert "setInterval(checkUpdates" not in src
|
||||||
|
assert "setInterval(() => checkUpdates" not in src
|
||||||
|
assert "setInterval(function() { checkUpdates" not in src
|
||||||
|
|
||||||
|
# 3. ActionExecutor get_update_progress
|
||||||
|
res_prog = ActionExecutor.execute("get_update_progress", {})
|
||||||
|
assert res_prog["ok"] is True
|
||||||
|
assert "data" in res_prog
|
||||||
|
assert "status" in res_prog["data"]
|
||||||
Loading…
Reference in a new issue