diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index a117da3..be08342 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -18,7 +18,7 @@ namespace HermesHubSetup // Подставляется сборщиком из фактического git-коммита. Раньше здесь // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // какого кода собран установщик. - public const string BuildCommit = "28f35f8"; + public const string BuildCommit = "fa7bbef"; public const string MIN_HERMES_VERSION = "0.20.0"; public const string MAX_TESTED_HERMES = "0.20.4"; diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe index f950cd7..7cef02a 100644 Binary files a/launcher/HermesHub.exe and b/launcher/HermesHub.exe differ diff --git a/launcher/HermesHubWeb.exe b/launcher/HermesHubWeb.exe index 98beb15..38e0dfd 100644 Binary files a/launcher/HermesHubWeb.exe and b/launcher/HermesHubWeb.exe differ diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index b54e177..945c94d 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -117,6 +117,10 @@ function switchView(viewName) { if (viewName === 'settings') { fetchSettings(); + // Состояние сжатия спрашиваем при открытии экрана, а не при каждой + // отрисовке настроек: раньше опрос запускался на каждом обновлении + // снапшота и замыкал круг сам на себя. + checkCompressionStatus(); } if (currentSnapshot) { @@ -535,8 +539,23 @@ async function saveAuthTokenFromPrompt() { } // ── ACTIONS EXECUTION (POST /api/action) ── + +// Действия-опросы: их запускает сам интерфейс, а не владелец. +// +// Показывать их тостами нельзя: опрос состояния сжатия шёл при каждом +// обновлении снапшота, и владелец не видел за уведомлениями самой программы. +// Хуже того, успешное действие вызывало fetchSnapshot, тот перерисовывал +// настройки, а перерисовка снова запускала опрос — круг замыкался и кормил +// сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот. +const SILENT_ACTIONS = new Set([ + 'get_compression_status', + 'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth', + 'poll_redirect_auth', 'poll_device_auth', +]); + async function executeAction(actionName, actionData = {}) { - showToast(`Выполняется «${actionName}»...`, 'info'); + const silent = SILENT_ACTIONS.has(actionName); + if (!silent) showToast(`Выполняется «${actionName}»...`, 'info'); try { const headers = { 'Content-Type': 'application/json' }; if (authToken) headers['X-Hub-Token'] = authToken; @@ -555,16 +574,20 @@ async function executeAction(actionName, actionData = {}) { const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` })); if (result.ok) { - showToast(result.message || 'Действие выполнено успешно', 'success'); - fetchSnapshot(); + if (!silent) { + showToast(result.message || 'Действие выполнено успешно', 'success'); + fetchSnapshot(); + } return result; } else { - showToast(result.message || 'Отказ выполнения действия', 'warning'); + // Отказ опроса показываем в том месте, которое его запросило: у мастера + // входа для этого своя область сообщений. Тостом заливать нельзя. + if (!silent) showToast(result.message || 'Отказ выполнения действия', 'warning'); return result; } } catch (err) { console.error(`Action ${actionName} failed:`, err); - showToast(`Ошибка сети: ${err.message}`, 'error'); + if (!silent) showToast(`Ошибка сети: ${err.message}`, 'error'); return { ok: false, message: `Ошибка сети: ${err.message}` }; } } @@ -1801,7 +1824,9 @@ function renderSettingsView() { if (compKeepRecentSel && s.compression_keep_recent_messages !== undefined) { compKeepRecentSel.value = String(s.compression_keep_recent_messages); } - checkCompressionStatus(); + // checkCompressionStatus() отсюда убран: отрисовка настроек происходит на + // каждом обновлении снапшота, и опрос замыкал круг сам на себя. Состояние + // запрашивается при открытии экрана настроек и по кнопке. } function populateCompressorProfiles(s) { diff --git a/tests/test_ui_polling_is_silent.py b/tests/test_ui_polling_is_silent.py new file mode 100644 index 0000000..10040af --- /dev/null +++ b/tests/test_ui_polling_is_silent.py @@ -0,0 +1,82 @@ +"""Опросы интерфейса не должны заливать владельца уведомлениями и кормить себя. + +Владелец: «справа постоянно выходят статусы, прям без остановки. я вообще +ничего не вижу за ними». + +Это был не таймер, а замкнутый круг: отрисовка настроек запускала опрос +состояния сжатия, executeAction на успехе вызывал fetchSnapshot, тот снова +перерисовывал настройки — и так без конца. Заодно каждый оборот показывал два +тоста: на запрос и на ответ. + +Тем же путём заливали «poll_native_auth» во время входа. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +APP_JS = ( + Path(__file__).resolve().parent.parent + / "src" / "antigravity_provider" / "router" / "web" / "static" / "app.js" +) + + +@pytest.fixture(scope="module") +def source() -> str: + return APP_JS.read_text(encoding="utf-8") + + +def test_polling_actions_are_listed_as_silent(source): + assert "SILENT_ACTIONS" in source + for action in ( + "get_compression_status", + "poll_native_auth", + "poll_redirect_auth", + "poll_device_auth", + ): + assert f"'{action}'" in source, f"{action} обязан быть среди молчаливых" + + +def test_silent_actions_show_no_toasts(source): + """Все четыре места, где executeAction показывает тост, под охраной silent.""" + body = source.split("async function executeAction")[1].split("// ── GLOBAL HEADER")[0] + + start, success, failure, network = ( + body.split("try {")[0], + body.split("if (result.ok)")[1].split("} else {")[0], + body.split("} else {")[1].split("} catch")[0], + body.split("} catch")[1], + ) + for part, where in ( + (start, "начало действия"), + (success, "успех"), + (failure, "отказ"), + (network, "сбой сети"), + ): + assert "showToast(" in part, f"место не найдено: {where}" + assert part.index("if (!silent)") < part.index("showToast("), ( + f"тост без проверки молчаливости: {where}" + ) + + +def test_silent_actions_do_not_refetch_the_snapshot(source): + """Круг замыкался именно здесь: успех опроса тянул за собой снапшот.""" + body = source.split("async function executeAction")[1].split("// ── GLOBAL HEADER")[0] + success_block = body.split("if (result.ok)")[1].split("} else {")[0] + assert "if (!silent)" in success_block + assert success_block.index("if (!silent)") < success_block.index("fetchSnapshot(") + + +def test_settings_render_does_not_start_a_poll(source): + """Опрос из отрисовки убран — она идёт на каждом обновлении снапшота.""" + render = source.split("function populateCompressorProfiles")[0] + render_tail = render[-2000:] + calls = re.findall(r"^\s*checkCompressionStatus\(\);", render_tail, re.MULTILINE) + assert not calls, "опрос из отрисовки настроек запускать нельзя" + + +def test_status_is_requested_when_the_screen_opens(source): + opening = source.split("if (viewName === 'settings')")[1][:400] + assert "checkCompressionStatus()" in opening