diff --git a/astrbot/core/desktop_runtime.py b/astrbot/core/desktop_runtime.py new file mode 100644 index 0000000000..196a3ec66f --- /dev/null +++ b/astrbot/core/desktop_runtime.py @@ -0,0 +1,10 @@ +import os + +DESKTOP_MANAGED_RESTART_MESSAGE = ( + "AstrBot Desktop manages this backend process. Please restart or update from " + "the desktop app instead of the core WebUI." +) + + +def is_desktop_managed_backend() -> bool: + return os.environ.get("ASTRBOT_DESKTOP_MANAGED") == "1" diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index 304b35769c..58d14bd0e2 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -8,6 +8,10 @@ from astrbot.core import logger from astrbot.core.config.default import VERSION +from astrbot.core.desktop_runtime import ( + DESKTOP_MANAGED_RESTART_MESSAGE, + is_desktop_managed_backend, +) from astrbot.core.utils.astrbot_path import get_astrbot_path from astrbot.core.utils.io import ensure_dir @@ -142,6 +146,10 @@ def _reboot(self, delay: int = 3) -> None: 在指定的延迟后,终止所有子进程并重新启动程序 这里只能使用 os.exec* 来重启程序 """ + if is_desktop_managed_backend(): + logger.error(DESKTOP_MANAGED_RESTART_MESSAGE) + raise RuntimeError(DESKTOP_MANAGED_RESTART_MESSAGE) + time.sleep(delay) self.terminate_child_processes() executable = sys.executable diff --git a/astrbot/dashboard/api/updates.py b/astrbot/dashboard/api/updates.py index ab07cd25cb..0fa503ecda 100644 --- a/astrbot/dashboard/api/updates.py +++ b/astrbot/dashboard/api/updates.py @@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse from astrbot.core import logger +from astrbot.core.desktop_runtime import DESKTOP_MANAGED_RESTART_MESSAGE from astrbot.dashboard.async_utils import run_maybe_async from astrbot.dashboard.schemas import PipInstallRequest, UpdateRequest from astrbot.dashboard.services.update_service import ( @@ -58,6 +59,15 @@ def _service_response(result: UpdateServiceResult) -> JSONResponse: def _service_error(exc: UpdateServiceError) -> JSONResponse: logger.error(f"Dashboard update operation failed: {exc}", exc_info=True) + if exc.code == "desktop_managed": + return JSONResponse( + { + "status": "error", + "message": DESKTOP_MANAGED_RESTART_MESSAGE, + "data": None, + }, + status_code=200, + ) return JSONResponse( {"status": "error", "message": "An internal error has occurred.", "data": None}, status_code=200, diff --git a/astrbot/dashboard/services/stat_service.py b/astrbot/dashboard/services/stat_service.py index aeb80cbc21..89543feb95 100644 --- a/astrbot/dashboard/services/stat_service.py +++ b/astrbot/dashboard/services/stat_service.py @@ -21,6 +21,10 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase from astrbot.core.db.po import ProviderStat +from astrbot.core.desktop_runtime import ( + DESKTOP_MANAGED_RESTART_MESSAGE, + is_desktop_managed_backend, +) from astrbot.core.utils.astrbot_path import get_astrbot_path from astrbot.core.utils.auth_password import ( is_default_dashboard_password, @@ -57,6 +61,9 @@ async def restart_core(self) -> None: raise StatServiceError( "You are not permitted to do this operation in demo mode" ) + if is_desktop_managed_backend(): + raise StatServiceError(DESKTOP_MANAGED_RESTART_MESSAGE) + await self.core_lifecycle.restart() @staticmethod diff --git a/astrbot/dashboard/services/update_service.py b/astrbot/dashboard/services/update_service.py index 388812e70c..b11c1d1425 100644 --- a/astrbot/dashboard/services/update_service.py +++ b/astrbot/dashboard/services/update_service.py @@ -15,6 +15,10 @@ from astrbot.core import pip_installer as _pip_installer from astrbot.core.config.default import VERSION from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.desktop_runtime import ( + DESKTOP_MANAGED_RESTART_MESSAGE, + is_desktop_managed_backend, +) from astrbot.core.updator import AstrBotUpdator from astrbot.core.utils.astrbot_path import ( get_astrbot_data_path, @@ -67,7 +71,9 @@ class UpdateServiceResult: class UpdateServiceError(Exception): - pass + def __init__(self, message: str, *, code: str | None = None) -> None: + super().__init__(message) + self.code = code class UpdateService: @@ -143,6 +149,12 @@ async def get_releases(self) -> UpdateServiceResult: raise UpdateServiceError(exc.__str__()) from exc async def update_project(self, data: object) -> UpdateServiceResult: + if is_desktop_managed_backend(): + raise UpdateServiceError( + DESKTOP_MANAGED_RESTART_MESSAGE, + code="desktop_managed", + ) + payload = data if isinstance(data, dict) else {} version = payload.get("version", "") reboot = payload.get("reboot", True) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 2385c0d6d5..b28c2643a6 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -20,6 +20,7 @@ from astrbot.core import LogBroker from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db.sqlite import SQLiteDatabase +from astrbot.core.desktop_runtime import DESKTOP_MANAGED_RESTART_MESSAGE from astrbot.core.star.star import StarMetadata, star_registry from astrbot.core.star.star_handler import star_handlers_registry from astrbot.core.utils.auth_password import ( @@ -2662,6 +2663,35 @@ async def mock_get_dashboard_version(*args, **kwargs): assert data["data"]["has_new_version"] is False +@pytest.mark.asyncio +async def test_restart_core_rejects_desktop_managed_backend( + app: FastAPIAppAdapter, + authenticated_header: dict, + core_lifecycle_td: AstrBotCoreLifecycle, + monkeypatch, +): + test_client = app.test_client() + restart_called = False + + async def mock_restart(): + nonlocal restart_called + restart_called = True + + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + monkeypatch.setattr(core_lifecycle_td, "restart", mock_restart) + + response = await test_client.post( + "/api/stat/restart-core", + headers=authenticated_header, + ) + + assert response.status_code == 400 + data = await response.get_json() + assert data["status"] == "error" + assert data["message"] == DESKTOP_MANAGED_RESTART_MESSAGE + assert restart_called is False + + @pytest.mark.asyncio async def test_do_update( app: FastAPIAppAdapter, @@ -2826,6 +2856,44 @@ def mock_extract_dashboard(*args, **kwargs): assert calls == ["download-dashboard", "download-core"] +@pytest.mark.asyncio +async def test_do_update_rejects_desktop_managed_backend( + app: FastAPIAppAdapter, + authenticated_header: dict, + core_lifecycle_td: AstrBotCoreLifecycle, + monkeypatch, +): + test_client = app.test_client() + calls = [] + + async def mock_download_core(*args, **kwargs): + del args, kwargs + calls.append("download-core") + + async def mock_restart(): + calls.append("restart") + + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "download_update_package", + mock_download_core, + ) + monkeypatch.setattr(core_lifecycle_td, "restart", mock_restart) + + response = await test_client.post( + "/api/update/do", + headers=authenticated_header, + json={"version": "v3.4.0", "progress_id": "desktop-progress"}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data["status"] == "error" + assert data["message"] == DESKTOP_MANAGED_RESTART_MESSAGE + assert calls == [] + + @pytest.mark.asyncio async def test_do_update_does_not_apply_files_when_package_verification_fails( app: FastAPIAppAdapter,