@@ -2755,7 +2796,11 @@ function renderUpdateProgressView(p) {
${escapeHtml(msg)}
-
+ ${isIndeterminate && INDETERMINATE_ACTIVE_STATUSES.has(status) ? `
+
+ ` : `
+
+ `}
@@ -2800,7 +2845,17 @@ function renderUpdateProgressView(p) {
async function cancelUpdateProcess() {
stopUpdateProgressPolling();
- await executeAction('cancel_update', {});
+ const cancelRes = await executeAction('cancel_update', {});
+
+ // Отмену могли не принять: после начала установки отменять уже нечего.
+ // Тогда возвращаем опрос обратно, иначе окно замрёт на последнем кадре и
+ // владелец решит, что установка встала.
+ const accepted = cancelRes && cancelRes.data && cancelRes.data.cancel_accepted;
+ if (!accepted) {
+ showToast((cancelRes && cancelRes.message) || 'Отменять уже нечего: установка идёт', 'warning');
+ updateProgressInterval = setInterval(pollUpdateProgress, 500);
+ }
+
const res = await executeAction('get_update_progress', {});
if (res && res.ok && res.data) {
renderUpdateProgressView(res.data);
diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py
index 2802821..fd962b4 100644
--- a/src/antigravity_provider/updater/update_manager.py
+++ b/src/antigravity_provider/updater/update_manager.py
@@ -13,6 +13,7 @@ Features:
from __future__ import annotations
import hashlib
+import inspect
import json
import logging
import os
@@ -433,28 +434,46 @@ def stop_running_hub(timeout_sec: float = 10.0) -> bool:
return True
+# Почему отмена отклонена — своими словами для владельца, а не кодом состояния.
+_CANCEL_REFUSAL = {
+ "installing": "Отмена невозможна: установка уже началась",
+ "restarting": "Отмена невозможна: Hermes Hub уже перезапускается",
+ "completed": "Отменять нечего: обновление уже установлено",
+ "failed": "Отменять нечего: обновление уже завершилось ошибкой",
+ "cancelled": "Загрузка уже отменена",
+ "idle": "Отменять нечего: обновление не запускалось",
+}
+
+
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."""
+ """Вызвать обработчик хода загрузки, поддержав обе его формы.
+
+ Старая форма принимает одну долю (0..1), новая — (скачано, всего).
+
+ Доли при неизвестном общем размере не существует, и подставлять вместо неё
+ ноль нельзя: обработчик получал бы «0%» на каждом чанке всю загрузку.
+ Старую форму в этом случае просто не зовём — молчание честнее выдуманного
+ числа, а сам ход всё равно виден через _set_progress.
+ """
if not cb:
return
- import inspect
+ known_total = bool(total and total > 0)
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)
+ single_arg = len(sig.parameters) == 1
+ except (TypeError, ValueError):
+ single_arg = False
+
+ if single_arg:
+ if known_total:
+ cb(downloaded / total)
+ return
+
+ try:
+ cb(downloaded, total)
+ except TypeError:
+ if known_total:
+ cb(downloaded / total)
class UpdateManager:
@@ -507,7 +526,25 @@ class UpdateManager:
@classmethod
def cancel_download(cls) -> Dict[str, Any]:
- """Cancel in-progress download, remove partially downloaded files, and set status to cancelled."""
+ """Отменить загрузку обновления и удалить недокачанные файлы.
+
+ Отмена допустима только пока идёт проверка или загрузка. Действие
+ `cancel_update` открыто в HTTP-API, и без этой проверки вызов во время
+ установки вычищал каталог staging вместе с файлом установщика, который
+ в этот момент исполняет bash: установка ломалась на середине, а ответ
+ «отменено» сообщал владельцу неправду о том, что происходит с машиной.
+ """
+ with cls._lock:
+ current_status = cls._progress.status
+ if current_status not in ("checking", "downloading"):
+ refused = cls._progress.to_dict()
+ refused["cancel_accepted"] = False
+ refused["cancel_refused_reason"] = _CANCEL_REFUSAL.get(
+ current_status,
+ f"Отмена невозможна на этапе «{current_status}»",
+ )
+ return refused
+
cls._cancel_event.set()
with cls._lock:
cls._progress = UpdateProgress(
@@ -529,7 +566,9 @@ class UpdateManager:
f.unlink(missing_ok=True)
except Exception as exc:
logger.debug("Clean staging dir on cancel failed: %s", exc)
- return cls.get_progress_dict()
+ accepted = cls.get_progress_dict()
+ accepted["cancel_accepted"] = True
+ return accepted
@classmethod
def cancel_update(cls) -> Dict[str, Any]:
@@ -996,6 +1035,10 @@ class UpdateManager:
if not expected_sha and check_result.manifest and check_result.manifest.sha256:
expected_sha = check_result.manifest.sha256.lower()
+ # Отсутствие суммы — не разрешение. Раньше при недоступном checksums.txt
+ # expected_sha оставался пустым, проверка молча пропускалась и скачанный
+ # файл всё равно запускался. Здесь запускается загруженный из сети
+ # исполняемый код, поэтому непроверенный файл не запускаем вовсе.
if not expected_sha:
dest_file.unlink(missing_ok=True)
err = (
@@ -1028,12 +1071,6 @@ class UpdateManager:
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(
@@ -1042,11 +1079,20 @@ class UpdateManager:
message=f"Установка пакета {chosen_asset_name}...",
)
+ def _record_success():
+ record_last_applied_update(
+ prev_version=prev_v,
+ prev_commit=prev_c,
+ new_version=new_v,
+ new_commit=new_c,
+ )
+
if chosen_asset_name.endswith(".zip"):
ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir)
if not ok:
self._set_progress(status="failed", filename=chosen_asset_name, error=msg, message=msg)
return False, msg
+ _record_success()
self._set_progress(status="completed", filename=chosen_asset_name, message=msg)
return True, "Обновление успешно установлено"
@@ -1067,6 +1113,7 @@ class UpdateManager:
err = f"Установщик завершился с кодом {rc}. Обновление не применено."
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
+ _record_success()
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
ok_r, msg_r = self.schedule_restart()
if not ok_r:
@@ -1082,15 +1129,25 @@ class UpdateManager:
elif chosen_asset_name.endswith(".sh"):
try:
os.chmod(dest_file, 0o755)
+ # Ждём завершения: без этого перезапуск начался бы прямо во
+ # время распаковки, а владелец получил бы обещание перезапуска
+ # при неизвестном исходе установки.
res_i = subprocess.run(
["bash", str(dest_file)],
capture_output=True, text=True, timeout=600,
)
if res_i.returncode != 0:
+ # Код возврата в сообщении обязателен: установщик может
+ # завершиться, не сказав ни слова, и владелец получал
+ # «Установка не удалась: » без единого признака причины.
tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:]
- err = "Установка не удалась: " + " / ".join(tail)
+ if tail:
+ err = f"Установка не удалась (код {res_i.returncode}): " + " / ".join(tail)
+ else:
+ err = f"Установка не удалась (код {res_i.returncode}): установщик ничего не сообщил"
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
+ _record_success()
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
ok_r, msg_r = self.schedule_restart()
if not ok_r:
@@ -1107,7 +1164,17 @@ class UpdateManager:
return True, "Файл обновления загружен и проверен"
def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]:
- """Перезапустить веб-хаб после установки обновления."""
+ """Перезапустить веб-хаб после установки обновления.
+
+ Ни install-linux.sh, ни виндовый установщик в тихом режиме приложение не
+ поднимают, а сообщение обещало перезапуск. Владелец оставался со старым
+ процессом, продолжавшим отдавать старый код, и делал вывод, что
+ обновление не сработало.
+
+ Порядок именно такой: сначала отсоединённый помощник, потом выход
+ текущего процесса. Лаунчер считает хаб работающим, если порт отвечает,
+ поэтому поднимать новый, не освободив порт, бесполезно.
+ """
home = paths.get_hermes_home()
if sys.platform == "win32":
launcher = home / "HermesHubWeb.exe"
diff --git a/tests/test_a59_visible_update.py b/tests/test_a59_visible_update.py
index d3f5586..7e80c72 100644
--- a/tests/test_a59_visible_update.py
+++ b/tests/test_a59_visible_update.py
@@ -160,18 +160,65 @@ def test_p0_2_cancel_download_cleans_file_and_sets_cancelled_status(tmp_path, mo
staging_file.parent.mkdir(parents=True, exist_ok=True)
staging_file.write_bytes(b"Partial download data 12345")
+ # Отмена осмысленна только пока идёт загрузка — ставим это состояние явно,
+ # иначе тест проверял бы отмену того, чего не происходит.
+ UpdateManager._set_progress(
+ status="downloading",
+ filename="partial_download.zip",
+ downloaded_bytes=27,
+ message="Скачивание partial_download.zip...",
+ )
+
# Trigger cancel
cancel_res = UpdateManager.cancel_download()
assert cancel_res["status"] == "cancelled"
+ assert cancel_res["cancel_accepted"] is True
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'
+ UpdateManager._set_progress(status="downloading", filename="partial_download.zip")
action_res = ActionExecutor.execute("cancel_update", {})
assert action_res["ok"] is True
assert action_res["data"]["status"] == "cancelled"
+# ── TEST 3b: отмена после начала установки отклоняется, а не врёт ──
+@pytest.mark.unit
+def test_p0_2_cancel_refused_after_install_started(tmp_path, monkeypatch):
+ """Отмена во время установки не трогает staging и честно сообщает отказ.
+
+ Действие cancel_update открыто в HTTP-API. Раньше вызов на этапе installing
+ чистил каталог staging вместе с исполняемым в этот момент установщиком и
+ отвечал «отменено», хотя установка продолжалась.
+ """
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
+ monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
+
+ mgr = UpdateManager()
+ installer = mgr.staging_dir / "hermes-hub-setup.sh"
+ installer.parent.mkdir(parents=True, exist_ok=True)
+ installer.write_bytes(b"#!/bin/bash\necho installing\n")
+
+ UpdateManager._set_progress(
+ status="installing",
+ filename="hermes-hub-setup.sh",
+ message="Установка пакета hermes-hub-setup.sh...",
+ )
+
+ res = UpdateManager.cancel_download()
+ assert res["cancel_accepted"] is False
+ assert res["status"] == "installing", "Статус не должен подменяться на cancelled"
+ assert "установка уже началась" in res["cancel_refused_reason"].lower()
+ assert installer.exists(), "Файл исполняемого установщика удалять нельзя"
+
+ action_res = ActionExecutor.execute("cancel_update", {})
+ assert action_res["ok"] is False
+ assert "отмена невозможна" in action_res["message"].lower()
+
+ UpdateManager._set_progress(status="idle", message="Готов к обновлению")
+
+
# ── 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):
@@ -313,3 +360,149 @@ def test_p0_5_silent_actions_and_no_interval_polling():
assert res_prog["ok"] is True
assert "data" in res_prog
assert "status" in res_prog["data"]
+
+
+# ── TEST 9: P0-4 провалившаяся установка не выдаёт себя за успешную ──
+@pytest.mark.unit
+def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monkeypatch):
+ """Установщик упал — записи о применённом обновлении быть не должно.
+
+ record_last_applied_update вызывался ДО запуска установщика. При падении
+ запись оставалась на диске, и при следующем старте хаб писал в журнал
+ «успешно обновлён», а интерфейс показывал тост об успехе — владельцу
+ сообщали о версии, которая не установилась.
+
+ Заодно проверяется, что причина отказа не пустая: установщик может
+ завершиться, не сказав ни слова, и сообщение «Установка не удалась: »
+ не давало ни одного признака причины.
+ """
+ import hashlib
+
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
+ monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
+
+ installer = tmp_path / "hermes-hub-setup.sh"
+ installer.write_bytes(b"#!/bin/bash\nexit 3\n")
+ sha = hashlib.sha256(installer.read_bytes()).hexdigest()
+
+ check_result = UpdateCheckResult(
+ update_available=True,
+ current_version="0.1.3",
+ latest_version="0.1.4",
+ latest_commit="deadbeefdeadbeef",
+ installed_commit="0000000aaaa",
+ assets={
+ "hermes-hub-setup.sh": f"file://{installer}",
+ "checksums.txt": "file:///nonexistent",
+ },
+ )
+
+ mgr = UpdateManager()
+ real_download = UpdateManager._download_file
+
+ def fake_download(self, url, dest, progress_cb=None):
+ if dest.name == "checksums.txt":
+ dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8")
+ return
+ return real_download(self, url, dest, progress_cb)
+
+ with patch.object(UpdateManager, "_download_file", fake_download):
+ with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
+ ok, msg = mgr.install_latest_update(check_result=check_result)
+
+ assert ok is False
+ assert get_last_applied_update() is None, (
+ "После провалившейся установки записи о применённом обновлении быть не должно"
+ )
+ assert "код 3" in msg, f"Причина отказа должна называть код возврата, получено: {msg!r}"
+
+ prog = UpdateManager.get_progress_dict()
+ assert prog["status"] == "failed"
+
+ UpdateManager._set_progress(status="idle", message="Готов к обновлению")
+
+
+# ── TEST 10: P0-4 успешная установка запись всё-таки делает ──
+@pytest.mark.unit
+def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkeypatch):
+ """Успех записывает и «было», и «стало», причём «было» снято до установки."""
+ import hashlib
+
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
+ monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
+
+ installer = tmp_path / "hermes-hub-setup.sh"
+ installer.write_bytes(b"#!/bin/bash\nexit 0\n")
+ sha = hashlib.sha256(installer.read_bytes()).hexdigest()
+
+ check_result = UpdateCheckResult(
+ update_available=True,
+ current_version="0.1.3",
+ latest_version="0.1.4",
+ latest_commit="deadbeefdeadbeef",
+ installed_commit="0000000aaaa",
+ assets={
+ "hermes-hub-setup.sh": f"file://{installer}",
+ "checksums.txt": "file:///nonexistent",
+ },
+ )
+
+ mgr = UpdateManager()
+ real_download = UpdateManager._download_file
+
+ def fake_download(self, url, dest, progress_cb=None):
+ if dest.name == "checksums.txt":
+ dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8")
+ return
+ return real_download(self, url, dest, progress_cb)
+
+ with patch.object(UpdateManager, "_download_file", fake_download):
+ with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
+ with patch.object(UpdateManager, "schedule_restart", return_value=(True, "перезапуск запущен")):
+ ok, msg = mgr.install_latest_update(check_result=check_result)
+
+ assert ok is True
+ rec = get_last_applied_update()
+ assert rec is not None
+ assert rec["new_version"] == "0.1.4"
+ assert rec["new_commit"] == "deadbeefdeadbeef"
+ assert rec["prev_commit"] != rec["new_commit"], (
+ "«Было» снимается до установки, иначе прежняя сборка совпадёт с новой"
+ )
+ assert rec["acknowledged"] is False
+
+ UpdateManager._set_progress(status="idle", message="Готов к обновлению")
+
+
+# ── TEST 11: P0-2 полоса не изображает процент при неизвестном размере ──
+@pytest.mark.unit
+def test_p0_2_app_js_indeterminate_bar_when_size_unknown():
+ """При неизвестном размере полоса бежит, а не заполняется целиком.
+
+ Текст рядом был честным («Н/Д: сервер не сообщил размер»), а полоса при этом
+ рисовалась на всю ширину: `downloaded > 0 ? '100%' : '20%'`. Полная полоса
+ читается как «готово» — тот же выдуманный процент, только нарисованный.
+ """
+ src = APP_JS_PATH.read_text(encoding="utf-8")
+
+ assert "downloaded > 0 ? '100%' : '20%'" not in src, (
+ "Полоса не должна заполняться на всю ширину при неизвестном размере"
+ )
+ assert "Н/Д: сервер не сообщил размер" in src
+ assert "INDETERMINATE_ACTIVE_STATUSES" in src, "нужен список этапов с неопределённой полосой"
+ assert "@keyframes indeterminate-bar" in src, "нужна анимация бегущего отрезка"
+ assert "indeterminate-bar-style" in src, "стиль вставляется один раз по id"
+
+
+# ── TEST 12: P0-2 интерфейс не принимает отказ в отмене за отмену ──
+@pytest.mark.unit
+def test_p0_2_app_js_handles_refused_cancel():
+ """Отказ в отмене возвращает опрос хода, а не оставляет окно замершим."""
+ src = APP_JS_PATH.read_text(encoding="utf-8")
+
+ assert "cancel_accepted" in src, "app.js обязан различать принятую и отклонённую отмену"
+ idx = src.index("async function cancelUpdateProcess()")
+ tail = src[idx:idx + 1200]
+ assert "setInterval(pollUpdateProgress" in tail, (
+ "после отклонённой отмены опрос хода загрузки должен возобновляться"
+ )