diff --git a/astrbot/core/dashboard_assets.py b/astrbot/core/dashboard_assets.py
index 36163a2318..9c45ba56da 100644
--- a/astrbot/core/dashboard_assets.py
+++ b/astrbot/core/dashboard_assets.py
@@ -1,11 +1,15 @@
"""Dashboard asset discovery, compatibility, and package handling."""
import re
+import shutil
+import tempfile
import zipfile
from pathlib import Path
+from urllib.parse import unquote, urlsplit
from astrbot.core import logger
from astrbot.core.config.default import VERSION
+from astrbot.core.desktop_runtime import is_desktop_managed_backend
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_path
from astrbot.core.utils.io import download_file, ensure_dir
from astrbot.core.utils.version_comparator import VersionComparator
@@ -102,12 +106,56 @@ def _is_dist_compatible(dist_dir: str | Path, current_version: str) -> bool:
Whether the dist contains an index and a matching version.
"""
dist_path = Path(dist_dir)
- return (dist_path / "index.html").is_file() and _is_version_compatible(
- _read_dashboard_version(dist_path),
- current_version,
+ return _is_dist_complete(dist_path) and _is_version_compatible(
+ _read_dashboard_version(dist_path), current_version
)
+def _is_dist_complete(dist_dir: str | Path) -> bool:
+ """Check whether a Dashboard dist has a usable local entry bundle.
+
+ Args:
+ dist_dir: Dashboard dist directory path.
+
+ Returns:
+ Whether the index references a local JavaScript entry and every local
+ JavaScript or stylesheet entry exists inside the dist directory.
+ """
+ dist_path = Path(dist_dir)
+ index_path = dist_path / "index.html"
+ try:
+ index_html = index_path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError):
+ return False
+
+ entry_paths: list[Path] = []
+ for match in re.finditer(
+ r"\b(?:src|href)\s*=\s*[\"']([^\"']+)[\"']",
+ index_html,
+ flags=re.IGNORECASE,
+ ):
+ reference = match.group(1).strip()
+ if not reference or reference.startswith(("#", "//")):
+ continue
+ try:
+ parsed = urlsplit(reference)
+ if parsed.scheme or parsed.netloc:
+ continue
+ decoded_path = unquote(parsed.path).replace("\\", "/").lstrip("/")
+ except (UnicodeError, ValueError):
+ return False
+ if not decoded_path.lower().endswith((".js", ".css")):
+ continue
+ entry_path = Path(decoded_path)
+ if entry_path.is_absolute() or ".." in entry_path.parts:
+ return False
+ entry_paths.append(entry_path)
+
+ if not any(path.suffix.lower() == ".js" for path in entry_paths):
+ return False
+ return all((dist_path / path).is_file() for path in entry_paths)
+
+
def _should_use_bundled_dist(user_dist: str | Path, current_version: str) -> bool:
"""Check whether bundled assets should replace a managed user dist.
@@ -119,16 +167,13 @@ def _should_use_bundled_dist(user_dist: str | Path, current_version: str) -> boo
Whether the user dist is stale or incomplete and bundled assets match.
"""
user_dist = Path(user_dist)
- user_version = _read_dashboard_version(user_dist)
bundled_dist = _get_bundled_dist_path()
if not user_dist.exists() or not _is_dist_compatible(
bundled_dist,
current_version,
):
return False
- if user_version is None or not (user_dist / "index.html").is_file():
- return True
- return not _is_version_compatible(user_version, current_version)
+ return not _is_dist_compatible(user_dist, current_version)
def resolve_dashboard_dist(webui_dir: str | Path | None = None) -> Path | None:
@@ -138,23 +183,36 @@ def resolve_dashboard_dist(webui_dir: str | Path | None = None) -> Path | None:
webui_dir: Optional explicitly configured Dashboard directory.
Returns:
- Explicit, managed, bundled, or stale fallback dist in priority order;
- None when an existing managed dist is incomplete.
+ Explicit, managed, bundled, or stale fallback dist in priority order.
+ A managed desktop backend never receives assets with a known version
+ mismatch, and None is returned when no compatible fallback exists.
"""
explicit_dist = Path(webui_dir).absolute() if webui_dir else None
if explicit_dist is not None and explicit_dist.exists():
- if not _is_dist_compatible(explicit_dist, VERSION):
- explicit_version = _read_dashboard_version(explicit_dist) or "unknown"
+ if _is_dist_compatible(explicit_dist, VERSION):
+ return explicit_dist
+
+ explicit_version = _read_dashboard_version(explicit_dist)
+ if is_desktop_managed_backend():
+ logger.warning(
+ "Refusing the explicitly configured WebUI directory in "
+ "desktop-managed mode because it is incomplete or its version "
+ "does not match core: %s, expected v%s (%s).",
+ explicit_version or "unknown",
+ VERSION,
+ explicit_dist,
+ )
+ else:
logger.warning(
"Serving the explicitly configured WebUI directory even though it "
"does not declare a version matching core: %s, expected v%s (%s). "
"Some dashboard features may not work until matching assets are "
"available.",
- explicit_version,
+ explicit_version or "unknown",
VERSION,
explicit_dist,
)
- return explicit_dist
+ return explicit_dist
user_dist = Path(get_astrbot_data_path()) / "dist"
bundled_dist = _get_bundled_dist_path()
@@ -167,6 +225,15 @@ def resolve_dashboard_dist(webui_dir: str | Path | None = None) -> Path | None:
):
logger.info("Using bundled dashboard dist: %s", bundled_dist)
return bundled_dist
+ if is_desktop_managed_backend():
+ if user_dist.exists():
+ logger.warning(
+ "Refusing data/dist in desktop-managed mode because WebUI version "
+ "does not match core: %s, expected v%s.",
+ user_version or "unknown",
+ VERSION,
+ )
+ return None
if user_dist.exists() and (user_dist / "index.html").is_file():
logger.warning(
"Using existing data/dist as a fallback even though WebUI version "
@@ -303,27 +370,86 @@ async def _download_package(
_extract_package(
zip_path,
extract_path or Path(get_astrbot_data_path()),
+ expected_version=None if len(version) == 40 else version,
)
def _extract_package(
zip_path: str | Path,
extract_path: str | Path,
+ expected_version: str | None = None,
) -> None:
- """Safely extract a Dashboard package.
+ """Safely stage, validate, and replace a Dashboard package.
Args:
zip_path: Dashboard ZIP archive path.
extract_path: Directory where package contents should be extracted.
+ expected_version: Optional Core version the Dashboard must match.
Raises:
- ValueError: If an archive member escapes the extraction root.
+ ValueError: If an archive member escapes the staging root.
+ RuntimeError: If the staged Dashboard is incomplete or mismatched.
"""
extract_root = Path(extract_path).resolve()
ensure_dir(extract_root)
- with zipfile.ZipFile(zip_path, "r") as archive:
- for member in archive.infolist():
- target_path = (extract_root / member.filename).resolve()
- if not target_path.is_relative_to(extract_root):
- raise ValueError(f"Unsafe dashboard archive path: {member.filename}")
- archive.extract(member, extract_root)
+ staging_root = Path(
+ tempfile.mkdtemp(prefix=".dashboard-stage-", dir=extract_root)
+ ).resolve()
+ backup_dist = staging_root / "previous-dist"
+ target_dist = extract_root / "dist"
+ moved_existing = False
+ cleanup_staging = True
+ try:
+ with zipfile.ZipFile(zip_path, "r") as archive:
+ for member in archive.infolist():
+ target_path = (staging_root / member.filename).resolve()
+ if not target_path.is_relative_to(staging_root):
+ raise ValueError(
+ f"Unsafe dashboard archive path: {member.filename}"
+ )
+ archive.extract(member, staging_root)
+
+ staged_dist = staging_root / "dist"
+ if not _is_dist_complete(staged_dist):
+ raise RuntimeError("Downloaded Dashboard package is incomplete")
+ if expected_version is not None and not _is_version_compatible(
+ _read_dashboard_version(staged_dist), expected_version
+ ):
+ raise RuntimeError(
+ "Downloaded Dashboard version does not match "
+ f"AstrBot {expected_version}"
+ )
+
+ if target_dist.exists() or target_dist.is_symlink():
+ target_dist.replace(backup_dist)
+ moved_existing = True
+ try:
+ staged_dist.replace(target_dist)
+ except BaseException as apply_error:
+ if moved_existing:
+ cleanup_staging = False
+ rollback_error: BaseException | str | None = None
+ if target_dist.exists() or target_dist.is_symlink():
+ rollback_error = "the target path reappeared before rollback"
+ else:
+ try:
+ backup_dist.replace(target_dist)
+ moved_existing = False
+ cleanup_staging = True
+ except BaseException as exc:
+ rollback_error = exc
+ if moved_existing:
+ logger.critical(
+ "Dashboard replacement and rollback both failed. The "
+ "previous Dashboard remains at %s: %s",
+ backup_dist,
+ rollback_error,
+ )
+ raise RuntimeError(
+ "Dashboard replacement failed and the previous Dashboard "
+ f"must be restored from {backup_dist}"
+ ) from apply_error
+ raise
+ finally:
+ if cleanup_staging:
+ shutil.rmtree(staging_root, ignore_errors=True)
diff --git a/astrbot/core/updater.py b/astrbot/core/updater.py
index 89c727d4df..8d56ca98a4 100644
--- a/astrbot/core/updater.py
+++ b/astrbot/core/updater.py
@@ -15,9 +15,11 @@
_extract_package,
_get_bundled_dist_path,
_is_dist_compatible,
+ _is_dist_complete,
_read_dashboard_version,
_should_use_bundled_dist,
)
+from astrbot.core.desktop_runtime import is_desktop_managed_backend
from astrbot.core.repository import GitHubRepository
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
@@ -313,6 +315,7 @@ def verify_packages() -> None:
_extract_package,
dashboard_zip_path,
Path(get_astrbot_data_path()),
+ target_version if len(target_version) != 40 else None,
)
await emit_progress(
"apply",
@@ -322,13 +325,17 @@ def verify_packages() -> None:
)
async def ensure_dashboard(self) -> Path:
- """Ensure a complete Dashboard matching the running Core version exists.
+ """Ensure acceptable Dashboard assets exist for the active runtime.
+
+ Desktop-managed runtimes only receive assets matching the running Core.
+ Standalone runtimes retain the legacy offline fallback to a structurally
+ complete ``data/dist`` when a matching package cannot be prepared.
Returns:
Directory containing the Dashboard assets to serve.
Raises:
- Exception: If no compatible Dashboard can be prepared.
+ Exception: If no acceptable Dashboard can be prepared.
"""
data_dist_path = Path(get_astrbot_data_path()) / "dist"
bundled_dist = _get_bundled_dist_path()
@@ -359,10 +366,10 @@ async def ensure_dashboard(self) -> Path:
allow_insecure_ssl_fallback=False,
)
except Exception:
- if (data_dist_path / "index.html").is_file():
+ if not is_desktop_managed_backend() and _is_dist_complete(data_dist_path):
logger.warning(
- "Using existing Dashboard %s because a compatible package "
- "could not be prepared for v%s.",
+ "Using existing standalone Dashboard %s because a compatible "
+ "package could not be prepared for v%s.",
existing_version or "unknown",
VERSION,
)
diff --git a/astrbot/dashboard/api/static_files.py b/astrbot/dashboard/api/static_files.py
index 8f873359d2..2a7e5d84d6 100644
--- a/astrbot/dashboard/api/static_files.py
+++ b/astrbot/dashboard/api/static_files.py
@@ -3,6 +3,7 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse
+from astrbot.core.desktop_runtime import is_desktop_managed_backend
from astrbot.dashboard.services.static_file_service import StaticFileService
router = APIRouter(include_in_schema=False)
@@ -14,14 +15,24 @@ def _static_folder(request: Request) -> str | None:
def _not_found_response() -> HTMLResponse:
- return HTMLResponse(service.get_not_found_message(), status_code=404)
+ return HTMLResponse(
+ service.get_not_found_message(),
+ status_code=404,
+ headers={"Cache-Control": "no-store"},
+ )
async def serve_index(request: Request):
index_file = service.resolve_index_file(_static_folder(request))
if index_file is None:
return _not_found_response()
- return FileResponse(index_file)
+ headers = {"Cache-Control": "no-store"}
+ if is_desktop_managed_backend() and request.query_params.get("astrbot_bundle"):
+ # The desktop app adds a bundle-identity query whenever its packaged
+ # resources change. That makes this request bypass an old URL cache
+ # entry and evicts legacy subresources without clearing cookies or storage.
+ headers["Clear-Site-Data"] = '"cache"'
+ return FileResponse(index_file, headers=headers)
async def serve_static_file(request: Request, static_path: str):
@@ -31,7 +42,23 @@ async def serve_static_file(request: Request, static_path: str):
file_path = service.resolve_static_file(_static_folder(request), static_path)
if file_path is None:
return _not_found_response()
- return FileResponse(file_path)
+
+ normalized_path = static_path.replace("\\", "/").strip("/")
+ is_entry_document = file_path.suffix.lower() == ".html"
+ headers = {
+ "Cache-Control": (
+ "no-store"
+ if is_entry_document or normalized_path == "assets/version"
+ else "no-cache"
+ )
+ }
+ if (
+ is_entry_document
+ and is_desktop_managed_backend()
+ and request.query_params.get("astrbot_bundle")
+ ):
+ headers["Clear-Site-Data"] = '"cache"'
+ return FileResponse(file_path, headers=headers)
for index_route in service.list_index_routes():
diff --git a/main.py b/main.py
index 07e6d3fda4..80f1f53f49 100644
--- a/main.py
+++ b/main.py
@@ -32,6 +32,7 @@ def _apply_startup_env_flags(argv: list[str]) -> None:
_apply_startup_env_flags(sys.argv[1:])
from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402
+from astrbot.core.dashboard_assets import resolve_dashboard_dist # noqa: E402
from astrbot.core.initial_loader import InitialLoader # noqa: E402
from astrbot.core.updater import AstrBotUpdater # noqa: E402
from astrbot.core.utils.astrbot_path import ( # noqa: E402
@@ -96,16 +97,36 @@ async def check_dashboard_files(webui_dir: str | None = None):
# 指定webui目录
if webui_dir:
if os.path.exists(webui_dir):
- logger.info("Using WebUI directory: %s", webui_dir)
- return webui_dir
- logger.warning("WebUI directory not found: %s. Using default.", webui_dir)
+ resolved_dist = resolve_dashboard_dist(webui_dir)
+ if resolved_dist is not None:
+ logger.info("Using WebUI directory: %s", resolved_dist)
+ return str(resolved_dist)
+ logger.warning(
+ "WebUI directory is incompatible with this desktop-managed core: "
+ "%s. Attempting repair.",
+ webui_dir,
+ )
+ else:
+ logger.warning("WebUI directory not found: %s. Using default.", webui_dir)
try:
- return str(await AstrBotUpdater().ensure_dashboard())
+ prepared_dist = await AstrBotUpdater().ensure_dashboard()
except Exception as e:
logger.critical(f"Failed to download dashboard files: {e}.")
return None
+ # The updater only prepares managed or bundled assets. Resolve without an
+ # explicit path so the legacy Desktop package exception cannot admit an
+ # unversioned data/dist returned after a failed repair.
+ resolved_dist = resolve_dashboard_dist()
+ if resolved_dist is None:
+ logger.critical(
+ "Prepared dashboard files are not compatible with the running core: %s.",
+ prepared_dist,
+ )
+ return None
+ return str(resolved_dist)
+
async def main_async(webui_dir_arg: str | None) -> None:
"""主异步入口"""
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index 24ebac1d8d..c2acbb3e3d 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -338,8 +338,15 @@ def test_dashboard_uses_bundled_dist_when_data_dist_is_stale(
bundled_dist = tmp_path / "bundled-dist"
user_dist.mkdir(parents=True)
bundled_dist.mkdir()
- (bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
+ (bundled_dist / "index.html").write_text(
+ '',
+ encoding="utf-8",
+ )
(bundled_dist / "assets").mkdir()
+ (bundled_dist / "assets" / "app.js").write_text(
+ "export {};",
+ encoding="utf-8",
+ )
(bundled_dist / "assets" / "version").write_text(
f"v{VERSION}",
encoding="utf-8",
@@ -517,9 +524,10 @@ async def test_desktop_session_issues_jwt_without_password(
assert response.status_code == 200
assert data["status"] == "ok"
- assert data["data"]["username"] == core_lifecycle_td.astrbot_config[
- "dashboard"
- ]["username"]
+ assert (
+ data["data"]["username"]
+ == core_lifecycle_td.astrbot_config["dashboard"]["username"]
+ )
token = data["data"]["token"]
payload = jwt.decode(
token,
@@ -3215,6 +3223,183 @@ def test_extract_dashboard_rejects_zip_path_traversal(tmp_path: Path):
assert not (tmp_path / "evil.txt").exists()
+def test_extract_dashboard_replaces_dist_only_after_validation(tmp_path: Path):
+ from astrbot.core.dashboard_assets import _extract_package
+
+ archive_path = tmp_path / "dashboard.zip"
+ extract_path = tmp_path / "data"
+ old_dist = extract_path / "dist"
+ (old_dist / "assets").mkdir(parents=True)
+ (old_dist / "index.html").write_text("old", encoding="utf-8")
+ (old_dist / "assets" / "old.js").write_text("old", encoding="utf-8")
+ with zipfile.ZipFile(archive_path, "w") as archive:
+ archive.writestr(
+ "dist/index.html",
+ '',
+ )
+ archive.writestr("dist/assets/new.js", "export {};")
+ archive.writestr("dist/assets/version", "v9.9.9")
+
+ _extract_package(archive_path, extract_path, expected_version="v9.9.9")
+
+ assert "new.js" in (old_dist / "index.html").read_text(encoding="utf-8")
+ assert (old_dist / "assets" / "new.js").is_file()
+ assert not (old_dist / "assets" / "old.js").exists()
+
+
+@pytest.mark.parametrize(
+ ("index_html", "version", "error"),
+ [
+ (
+ '',
+ "v9.9.9",
+ "incomplete",
+ ),
+ (
+ '',
+ "v9.9.8",
+ "does not match",
+ ),
+ ],
+)
+def test_extract_dashboard_keeps_existing_dist_when_validation_fails(
+ tmp_path: Path,
+ index_html: str,
+ version: str,
+ error: str,
+):
+ from astrbot.core.dashboard_assets import _extract_package
+
+ archive_path = tmp_path / "dashboard.zip"
+ extract_path = tmp_path / "data"
+ old_dist = extract_path / "dist"
+ old_dist.mkdir(parents=True)
+ (old_dist / "index.html").write_text("old", encoding="utf-8")
+ with zipfile.ZipFile(archive_path, "w") as archive:
+ archive.writestr("dist/index.html", index_html)
+ if "new.js" in index_html:
+ archive.writestr("dist/assets/new.js", "export {};")
+ archive.writestr("dist/assets/version", version)
+
+ with pytest.raises(RuntimeError, match=error):
+ _extract_package(archive_path, extract_path, expected_version="v9.9.9")
+
+ assert (old_dist / "index.html").read_text(encoding="utf-8") == "old"
+
+
+def test_extract_dashboard_rolls_back_when_replacement_fails(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+):
+ from astrbot.core.dashboard_assets import _extract_package
+
+ archive_path = tmp_path / "dashboard.zip"
+ extract_path = tmp_path / "data"
+ old_dist = extract_path / "dist"
+ old_dist.mkdir(parents=True)
+ (old_dist / "index.html").write_text("old", encoding="utf-8")
+ with zipfile.ZipFile(archive_path, "w") as archive:
+ archive.writestr(
+ "dist/index.html",
+ '',
+ )
+ archive.writestr("dist/assets/new.js", "export {};")
+ archive.writestr("dist/assets/version", "v9.9.9")
+
+ original_replace = Path.replace
+
+ def fail_staged_replace(path: Path, target: Path):
+ if path.name == "dist" and path.parent.name.startswith(".dashboard-stage-"):
+ raise OSError("simulated replacement failure")
+ return original_replace(path, target)
+
+ monkeypatch.setattr(Path, "replace", fail_staged_replace)
+
+ with pytest.raises(OSError, match="simulated replacement failure"):
+ _extract_package(archive_path, extract_path, expected_version="v9.9.9")
+
+ assert (old_dist / "index.html").read_text(encoding="utf-8") == "old"
+
+
+def test_extract_dashboard_preserves_backup_when_rollback_fails(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+):
+ from astrbot.core.dashboard_assets import _extract_package
+
+ archive_path = tmp_path / "dashboard.zip"
+ extract_path = tmp_path / "data"
+ old_dist = extract_path / "dist"
+ old_dist.mkdir(parents=True)
+ (old_dist / "index.html").write_text("old", encoding="utf-8")
+ with zipfile.ZipFile(archive_path, "w") as archive:
+ archive.writestr(
+ "dist/index.html",
+ '',
+ )
+ archive.writestr("dist/assets/new.js", "export {};")
+ archive.writestr("dist/assets/version", "v9.9.9")
+
+ original_replace = Path.replace
+
+ def fail_replacement_and_rollback(path: Path, target: Path):
+ if path.name == "dist" and path.parent.name.startswith(".dashboard-stage-"):
+ raise OSError("simulated replacement failure")
+ if path.name == "previous-dist":
+ raise OSError("simulated rollback failure")
+ return original_replace(path, target)
+
+ monkeypatch.setattr(Path, "replace", fail_replacement_and_rollback)
+
+ with pytest.raises(RuntimeError, match="must be restored from"):
+ _extract_package(archive_path, extract_path, expected_version="v9.9.9")
+
+ staging_dirs = list(extract_path.glob(".dashboard-stage-*"))
+ assert len(staging_dirs) == 1
+ preserved_backup = staging_dirs[0] / "previous-dist" / "index.html"
+ assert preserved_backup.read_text(encoding="utf-8") == "old"
+
+
+def test_extract_dashboard_preserves_backup_when_target_reappears(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+):
+ from astrbot.core.dashboard_assets import _extract_package
+
+ archive_path = tmp_path / "dashboard.zip"
+ extract_path = tmp_path / "data"
+ target_dist = extract_path / "dist"
+ target_dist.mkdir(parents=True)
+ (target_dist / "index.html").write_text("old", encoding="utf-8")
+ with zipfile.ZipFile(archive_path, "w") as archive:
+ archive.writestr(
+ "dist/index.html",
+ '',
+ )
+ archive.writestr("dist/assets/new.js", "export {};")
+ archive.writestr("dist/assets/version", "v9.9.9")
+
+ original_replace = Path.replace
+
+ def recreate_target_before_failure(path: Path, target: Path):
+ if path.name == "dist" and path.parent.name.startswith(".dashboard-stage-"):
+ target.mkdir(parents=True)
+ (target / "index.html").write_text("concurrent", encoding="utf-8")
+ raise OSError("simulated replacement failure")
+ return original_replace(path, target)
+
+ monkeypatch.setattr(Path, "replace", recreate_target_before_failure)
+
+ with pytest.raises(RuntimeError, match="must be restored from"):
+ _extract_package(archive_path, extract_path, expected_version="v9.9.9")
+
+ staging_dirs = list(extract_path.glob(".dashboard-stage-*"))
+ assert len(staging_dirs) == 1
+ preserved_backup = staging_dirs[0] / "previous-dist" / "index.html"
+ assert preserved_backup.read_text(encoding="utf-8") == "old"
+ assert (target_dist / "index.html").read_text(encoding="utf-8") == "concurrent"
+
+
@pytest.mark.asyncio
async def test_do_update_hides_internal_error_message_in_response_and_progress(
app: FastAPIAppAdapter,
diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py
index fb0b9087ae..6199f37486 100644
--- a/tests/test_fastapi_v1_dashboard.py
+++ b/tests/test_fastapi_v1_dashboard.py
@@ -1190,6 +1190,18 @@ async def test_dashboard_static_dist_files_are_served(
"window.__astrbotStaticTest = true;",
encoding="utf-8",
)
+ (assets_folder / "index-AbCd1234.js").write_text(
+ "window.__astrbotHashedStaticTest = true;",
+ encoding="utf-8",
+ )
+ (assets_folder / "config-metadata.json").write_text("{}", encoding="utf-8")
+ (assets_folder / "version").write_text("v4.27.4", encoding="utf-8")
+ t2i_folder = static_folder / "t2i"
+ t2i_folder.mkdir()
+ (t2i_folder / "shiki_runtime.iife.js").write_text(
+ "window.__astrbotShikiRuntimeTest = true;",
+ encoding="utf-8",
+ )
(tmp_path / "secret.txt").write_text("outside static root", encoding="utf-8")
app = create_dashboard_asgi_app(
@@ -1204,7 +1216,13 @@ async def test_dashboard_static_dist_files_are_served(
base_url="http://testserver",
) as client:
asset_response = await client.get("/assets/index-demo.js")
+ hashed_asset_response = await client.get("/assets/index-AbCd1234.js")
+ word_suffix_asset_response = await client.get("/assets/config-metadata.json")
+ version_response = await client.get("/assets/version")
+ unversioned_asset_response = await client.get("/t2i/shiki_runtime.iife.js")
favicon_response = await client.get("/favicon.svg")
+ root_response = await client.get("/")
+ index_response = await client.get("/index.html")
page_response = await client.get("/config")
missing_response = await client.get("/assets/missing.js")
traversal_response = await client.get("/assets/%2E%2E/%2E%2E/secret.txt")
@@ -1212,9 +1230,21 @@ async def test_dashboard_static_dist_files_are_served(
assert asset_response.status_code == 200
assert "window.__astrbotStaticTest" in asset_response.text
+ assert asset_response.headers["cache-control"] == "no-cache"
+ assert hashed_asset_response.status_code == 200
+ assert hashed_asset_response.headers["cache-control"] == "no-cache"
+ assert word_suffix_asset_response.status_code == 200
+ assert word_suffix_asset_response.headers["cache-control"] == "no-cache"
+ assert version_response.status_code == 200
+ assert version_response.headers["cache-control"] == "no-store"
+ assert unversioned_asset_response.status_code == 200
+ assert unversioned_asset_response.headers["cache-control"] == "no-cache"
assert favicon_response.status_code == 200
assert favicon_response.text == ""
+ assert root_response.headers["cache-control"] == "no-store"
+ assert index_response.headers["cache-control"] == "no-store"
assert page_response.status_code == 200
+ assert page_response.headers["cache-control"] == "no-store"
assert "/assets/index-demo.js" in page_response.text
assert missing_response.status_code == 404
assert missing_response.headers["content-type"].startswith("text/html")
@@ -1228,6 +1258,60 @@ async def test_dashboard_static_dist_files_are_served(
assert api_response.status_code == 404
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("desktop_managed", "query", "should_clear_cache"),
+ [
+ (True, "?astrbot_bundle=desktop-4.27.5-core-4.27.5-webui-deadbeef", True),
+ (True, "", False),
+ (False, "?astrbot_bundle=desktop-4.27.5-core-4.27.5-webui-deadbeef", False),
+ ],
+)
+async def test_dashboard_index_clears_legacy_cache_only_for_desktop_bundle(
+ fake_core_lifecycle,
+ fake_db: FakeDb,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ desktop_managed: bool,
+ query: str,
+ should_clear_cache: bool,
+):
+ static_folder = tmp_path / "dist"
+ static_folder.mkdir()
+ (static_folder / "index.html").write_text(
+ "",
+ encoding="utf-8",
+ )
+ if desktop_managed:
+ monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
+ else:
+ monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False)
+
+ app = create_dashboard_asgi_app(
+ core_lifecycle=fake_core_lifecycle,
+ db=fake_db,
+ jwt_secret=JWT_SECRET,
+ static_folder=str(static_folder),
+ )
+ transport = httpx.ASGITransport(app=app)
+ async with httpx.AsyncClient(
+ transport=transport,
+ base_url="http://testserver",
+ ) as client:
+ responses = [
+ await client.get(f"/{query}"),
+ await client.get(f"/index.html{query}"),
+ ]
+
+ for response in responses:
+ assert response.status_code == 200
+ assert response.headers["cache-control"] == "no-store"
+ if should_clear_cache:
+ assert response.headers["clear-site-data"] == '"cache"'
+ else:
+ assert "clear-site-data" not in response.headers
+
+
@pytest.mark.asyncio
async def test_v1_backup_download_accepts_bearer_token(
tmp_path: Path,
diff --git a/tests/test_main.py b/tests/test_main.py
index a9b96a43ee..371527aa1e 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -11,6 +11,7 @@
from astrbot.core.dashboard_assets import (
_should_use_bundled_dist,
get_dashboard_version,
+ resolve_dashboard_dist,
)
from main import (
DASHBOARD_RESET_PASSWORD_ENV,
@@ -51,6 +52,18 @@ def __lt__(self, other):
return (self.major, self.minor) < (other.major, other.minor)
+def _write_dashboard_dist(dist_path, version: str | None = None) -> None:
+ assets = dist_path / "assets"
+ assets.mkdir(parents=True, exist_ok=True)
+ (dist_path / "index.html").write_text(
+ '',
+ encoding="utf-8",
+ )
+ (assets / "app.js").write_text("export {};", encoding="utf-8")
+ if version is not None:
+ (assets / "version").write_text(version, encoding="utf-8")
+
+
def test_check_env(monkeypatch):
version_info_correct = _version_info(3, 10)
version_info_wrong = _version_info(3, 9)
@@ -178,6 +191,13 @@ def test_version_info_comparisons():
async def test_check_dashboard_files_delegates_to_updater(monkeypatch, tmp_path):
"""Startup should depend only on the updater's Dashboard contract."""
dashboard_path = tmp_path / "dist"
+ from astrbot.core.config.default import VERSION
+
+ _write_dashboard_dist(dashboard_path, f"v{VERSION}")
+ monkeypatch.setattr(
+ "astrbot.core.dashboard_assets.get_astrbot_data_path",
+ lambda: str(tmp_path),
+ )
ensure_dashboard = mock.AsyncMock(return_value=dashboard_path)
monkeypatch.setattr(
"main.AstrBotUpdater",
@@ -208,8 +228,7 @@ def test_should_use_bundled_dashboard_dist_when_data_dist_is_stale(tmp_path):
(user_dist / "assets").mkdir(parents=True)
(bundled_dist / "assets").mkdir(parents=True)
(user_dist / "assets" / "version").write_text("v4.24.2", encoding="utf-8")
- (bundled_dist / "assets" / "version").write_text("v4.24.4", encoding="utf-8")
- (bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
+ _write_dashboard_dist(bundled_dist, "v4.24.4")
with mock.patch(
"astrbot.core.dashboard_assets._get_bundled_dist_path",
@@ -224,8 +243,7 @@ def test_should_use_bundled_dashboard_dist_when_version_file_is_malformed(tmp_pa
(user_dist / "assets").mkdir(parents=True)
(bundled_dist / "assets").mkdir(parents=True)
(user_dist / "assets" / "version").write_text("not-a-version", encoding="utf-8")
- (bundled_dist / "assets" / "version").write_text("v4.24.4", encoding="utf-8")
- (bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
+ _write_dashboard_dist(bundled_dist, "v4.24.4")
with mock.patch(
"astrbot.core.dashboard_assets._get_bundled_dist_path",
@@ -238,9 +256,21 @@ def test_should_use_bundled_dashboard_dist_when_data_version_file_is_missing(tmp
user_dist = tmp_path / "user-dist"
bundled_dist = tmp_path / "bundled-dist"
(user_dist / "assets").mkdir(parents=True)
- (bundled_dist / "assets").mkdir(parents=True)
- (bundled_dist / "assets" / "version").write_text("v4.24.4", encoding="utf-8")
- (bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
+ _write_dashboard_dist(bundled_dist, "v4.24.4")
+
+ with mock.patch(
+ "astrbot.core.dashboard_assets._get_bundled_dist_path",
+ return_value=bundled_dist,
+ ):
+ assert _should_use_bundled_dist(user_dist, "4.24.4") is True
+
+
+def test_should_use_bundled_dashboard_dist_when_data_entries_are_incomplete(tmp_path):
+ user_dist = tmp_path / "user-dist"
+ bundled_dist = tmp_path / "bundled-dist"
+ _write_dashboard_dist(user_dist, "v4.24.4")
+ (user_dist / "assets" / "app.js").unlink()
+ _write_dashboard_dist(bundled_dist, "v4.24.4")
with mock.patch(
"astrbot.core.dashboard_assets._get_bundled_dist_path",
@@ -258,9 +288,7 @@ async def test_get_dashboard_version_uses_bundled_dist_when_data_dist_is_missing
data_dir = tmp_path / "data"
bundled_dist = tmp_path / "bundled-dist"
- (bundled_dist / "assets").mkdir(parents=True)
- (bundled_dist / "assets" / "version").write_text(f"v{VERSION}", encoding="utf-8")
- (bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
+ _write_dashboard_dist(bundled_dist, f"v{VERSION}")
with mock.patch(
"astrbot.core.dashboard_assets.get_astrbot_data_path",
@@ -274,14 +302,99 @@ async def test_get_dashboard_version_uses_bundled_dist_when_data_dist_is_missing
@pytest.mark.asyncio
-async def test_check_dashboard_files_with_webui_dir_arg(monkeypatch):
- """Tests that providing a valid webui_dir skips all checks."""
- valid_dir = "/tmp/my-custom-webui"
- monkeypatch.setattr(os.path, "exists", lambda path: path == valid_dir)
+async def test_check_dashboard_files_with_non_desktop_custom_webui_dir(
+ monkeypatch, tmp_path
+):
+ """A custom explicit WebUI remains unchanged outside Desktop."""
+ monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False)
+ valid_dir = tmp_path / "my-custom-webui"
+ (valid_dir / "assets").mkdir(parents=True)
+ (valid_dir / "index.html").write_text("custom", encoding="utf-8")
updater = mock.Mock()
monkeypatch.setattr("main.AstrBotUpdater", updater)
- result = await check_dashboard_files(webui_dir=valid_dir)
+ result = await check_dashboard_files(webui_dir=str(valid_dir))
- assert result == valid_dir
+ assert result == str(valid_dir.absolute())
updater.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_check_dashboard_files_repairs_stale_desktop_webui(monkeypatch, tmp_path):
+ """Desktop startup verifies the repaired dist before serving it."""
+ monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
+ explicit_dist = tmp_path / "webui"
+ (explicit_dist / "assets").mkdir(parents=True)
+ (explicit_dist / "index.html").write_text("stale", encoding="utf-8")
+ (explicit_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8")
+ data_dir = tmp_path / "data"
+ repaired_dist = data_dir / "dist"
+ monkeypatch.setattr(
+ "astrbot.core.dashboard_assets.get_astrbot_data_path",
+ lambda: str(data_dir),
+ )
+ monkeypatch.setattr(
+ "astrbot.core.dashboard_assets._get_bundled_dist_path",
+ lambda: tmp_path / "missing-bundled-dist",
+ )
+
+ async def repair_dashboard():
+ from astrbot.core.config.default import VERSION
+
+ _write_dashboard_dist(repaired_dist, f"v{VERSION}")
+ return repaired_dist
+
+ ensure_dashboard = mock.AsyncMock(side_effect=repair_dashboard)
+ monkeypatch.setattr(
+ "main.AstrBotUpdater",
+ lambda: mock.Mock(ensure_dashboard=ensure_dashboard),
+ )
+
+ result = await check_dashboard_files(str(explicit_dist))
+
+ assert result == str(repaired_dist.absolute())
+ ensure_dashboard.assert_awaited_once_with()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "failed_version",
+ ["v0.0.2", None],
+ ids=["known-stale", "unversioned"],
+)
+async def test_check_dashboard_files_does_not_serve_stale_desktop_webui_when_repair_fails(
+ monkeypatch, tmp_path, failed_version
+):
+ """A failed desktop repair cannot fall back to unverified managed assets."""
+ monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
+ explicit_dist = tmp_path / "webui"
+ (explicit_dist / "assets").mkdir(parents=True)
+ (explicit_dist / "index.html").write_text("stale", encoding="utf-8")
+ (explicit_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8")
+ data_dir = tmp_path / "data"
+ failed_repair_dist = data_dir / "dist"
+ (failed_repair_dist / "assets").mkdir(parents=True)
+ (failed_repair_dist / "index.html").write_text("also stale", encoding="utf-8")
+ if failed_version is not None:
+ (failed_repair_dist / "assets" / "version").write_text(
+ failed_version, encoding="utf-8"
+ )
+ monkeypatch.setattr(
+ "astrbot.core.dashboard_assets.get_astrbot_data_path",
+ lambda: str(data_dir),
+ )
+ monkeypatch.setattr(
+ "astrbot.core.dashboard_assets._get_bundled_dist_path",
+ lambda: tmp_path / "missing-bundled-dist",
+ )
+ # ensure_dashboard historically returns a usable stale dist when its download
+ # fails. Startup must validate that fallback rather than trusting the path.
+ ensure_dashboard = mock.AsyncMock(return_value=failed_repair_dist)
+ monkeypatch.setattr(
+ "main.AstrBotUpdater",
+ lambda: mock.Mock(ensure_dashboard=ensure_dashboard),
+ )
+
+ assert await check_dashboard_files(str(explicit_dist)) is None
+ assert resolve_dashboard_dist(str(explicit_dist)) is None
+ ensure_dashboard.assert_awaited_once_with()
diff --git a/tests/test_updater_socks.py b/tests/test_updater_socks.py
index 8dd3bf6daa..15bc1f168c 100644
--- a/tests/test_updater_socks.py
+++ b/tests/test_updater_socks.py
@@ -559,7 +559,12 @@ async def fake_download_core(*, path: Path, **kwargs) -> Path:
def fake_apply_core(_path: Path) -> None:
calls.append("apply-core")
- def fake_extract_dashboard(_path: Path, _data_path: Path) -> None:
+ def fake_extract_dashboard(
+ _path: Path,
+ _data_path: Path,
+ _expected_version: str | None = None,
+ ) -> None:
+ assert _expected_version == "v99.0.0"
calls.append("apply-dashboard")
async def record_progress(event: UpdateProgress) -> None:
@@ -770,19 +775,34 @@ async def fake_download_dashboard(**kwargs) -> None:
@pytest.mark.asyncio
-async def test_astrbot_updater_ensure_dashboard_keeps_usable_stale_assets_on_failure(
+@pytest.mark.parametrize(
+ "desktop_managed",
+ [False, True],
+ ids=["standalone", "desktop-managed"],
+)
+async def test_astrbot_updater_stale_fallback_is_standalone_only(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+ desktop_managed: bool,
) -> None:
updater = AstrBotUpdater()
data_path = tmp_path / "data"
data_dist = data_path / "dist"
- data_dist.mkdir(parents=True)
- (data_dist / "index.html").write_text("stale", encoding="utf-8")
+ (data_dist / "assets").mkdir(parents=True)
+ (data_dist / "index.html").write_text(
+ '',
+ encoding="utf-8",
+ )
+ (data_dist / "assets" / "old.js").write_text("old", encoding="utf-8")
+ (data_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8")
async def fail_download(**_kwargs) -> None:
raise RuntimeError("unavailable")
+ if desktop_managed:
+ monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
+ else:
+ monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False)
monkeypatch.setattr(core_updater, "get_astrbot_data_path", lambda: str(data_path))
monkeypatch.setattr(
core_updater,
@@ -791,26 +811,74 @@ async def fail_download(**_kwargs) -> None:
)
monkeypatch.setattr(
core_updater,
- "_is_dist_compatible",
- lambda *_args: False,
+ "_download_package",
+ fail_download,
)
+
+ if desktop_managed:
+ with pytest.raises(RuntimeError, match="unavailable"):
+ await updater.ensure_dashboard()
+ else:
+ assert await updater.ensure_dashboard() == data_dist
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("desktop_managed", [False, True])
+async def test_astrbot_updater_first_install_fails_when_offline_without_assets(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ desktop_managed: bool,
+) -> None:
+ updater = AstrBotUpdater()
+ data_path = tmp_path / "data"
+
+ async def fail_download(**_kwargs) -> None:
+ raise RuntimeError("offline")
+
+ if desktop_managed:
+ monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
+ else:
+ monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False)
+ monkeypatch.setattr(core_updater, "get_astrbot_data_path", lambda: str(data_path))
monkeypatch.setattr(
core_updater,
- "_should_use_bundled_dist",
- lambda *_args: False,
+ "_get_bundled_dist_path",
+ lambda: tmp_path / "missing-bundled-dist",
)
- monkeypatch.setattr(
- core_updater,
- "_read_dashboard_version",
- lambda _path: "v0.0.1",
+ monkeypatch.setattr(core_updater, "_download_package", fail_download)
+
+ with pytest.raises(RuntimeError, match="offline"):
+ await updater.ensure_dashboard()
+
+
+@pytest.mark.asyncio
+async def test_astrbot_updater_does_not_return_incomplete_assets_on_failure(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ updater = AstrBotUpdater()
+ data_path = tmp_path / "data"
+ data_dist = data_path / "dist"
+ (data_dist / "assets").mkdir(parents=True)
+ (data_dist / "index.html").write_text(
+ '',
+ encoding="utf-8",
)
+
+ async def fail_download(**_kwargs) -> None:
+ raise RuntimeError("unavailable")
+
+ monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False)
+ monkeypatch.setattr(core_updater, "get_astrbot_data_path", lambda: str(data_path))
monkeypatch.setattr(
core_updater,
- "_download_package",
- fail_download,
+ "_get_bundled_dist_path",
+ lambda: tmp_path / "missing-bundled-dist",
)
+ monkeypatch.setattr(core_updater, "_download_package", fail_download)
- assert await updater.ensure_dashboard() == data_dist
+ with pytest.raises(RuntimeError, match="unavailable"):
+ await updater.ensure_dashboard()
@pytest.mark.asyncio
@@ -992,6 +1060,59 @@ async def fake_download_file(
]
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("version", "expected_version"),
+ [
+ ("v99.0.0", "v99.0.0"),
+ ("a" * 40, None),
+ ],
+)
+async def test_download_dashboard_propagates_expected_version_to_extractor(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ version: str,
+ expected_version: str | None,
+) -> None:
+ captured: dict[str, object] = {}
+
+ async def fake_download_file(_url: str, path: str, **_kwargs) -> None:
+ with zipfile.ZipFile(path, "w") as archive:
+ archive.writestr("dist/index.html", "dashboard")
+
+ def fake_extract_package(
+ zip_path: Path,
+ extract_path: Path,
+ expected_version: str | None = None,
+ ) -> None:
+ captured.update(
+ zip_path=zip_path,
+ extract_path=extract_path,
+ expected_version=expected_version,
+ )
+
+ monkeypatch.setattr(dashboard_assets, "download_file", fake_download_file)
+ monkeypatch.setattr(
+ dashboard_assets,
+ "_extract_package",
+ fake_extract_package,
+ )
+
+ zip_path = tmp_path / "dashboard.zip"
+ extract_path = tmp_path / "extract"
+ await dashboard_assets._download_package(
+ path=zip_path,
+ extract_path=extract_path,
+ version=version,
+ )
+
+ assert captured == {
+ "zip_path": zip_path,
+ "extract_path": extract_path,
+ "expected_version": expected_version,
+ }
+
+
@pytest.mark.asyncio
async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support(
monkeypatch: pytest.MonkeyPatch,
diff --git a/tests/unit/test_dashboard_dist_resolution.py b/tests/unit/test_dashboard_dist_resolution.py
index 52096d8ed6..94e1b7e606 100644
--- a/tests/unit/test_dashboard_dist_resolution.py
+++ b/tests/unit/test_dashboard_dist_resolution.py
@@ -13,7 +13,11 @@
def _make_dist(root, version: str | None) -> str:
assets = root / "assets"
assets.mkdir(parents=True)
- (root / "index.html").write_text("", encoding="utf-8")
+ (root / "index.html").write_text(
+ '',
+ encoding="utf-8",
+ )
+ (assets / "app.js").write_text("export {};", encoding="utf-8")
if version is not None:
(assets / "version").write_text(version, encoding="utf-8")
return str(root)
@@ -31,8 +35,11 @@ def test_matching_version_is_served_quietly(self, tmp_path, caplog):
assert str(resolved) == str(tmp_path / "webui")
assert WARNING_FRAGMENT not in caplog.text
- def test_mismatched_version_warns_but_is_still_served(self, tmp_path, caplog):
- """A stale packaged WebUI must not be swapped in silently."""
+ def test_non_desktop_mismatched_version_warns_but_is_still_served(
+ self, monkeypatch, tmp_path, caplog
+ ):
+ """A custom WebUI remains supported outside the managed desktop app."""
+ monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False)
dist = _make_dist(tmp_path / "webui", "v0.0.1")
with caplog.at_level(logging.WARNING):
@@ -43,17 +50,99 @@ def test_mismatched_version_warns_but_is_still_served(self, tmp_path, caplog):
assert "v0.0.1" in caplog.text
assert VERSION in caplog.text
- def test_missing_version_marker_warns_as_unknown(self, tmp_path, caplog):
- """Assets without a version marker cannot be verified, so say so."""
+ def test_desktop_missing_version_marker_is_rejected(
+ self, monkeypatch, tmp_path, caplog
+ ):
+ """Desktop assets without a verifiable version must not be served."""
+ monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
dist = _make_dist(tmp_path / "webui", None)
with caplog.at_level(logging.WARNING):
resolved = resolve_dashboard_dist(dist)
- assert resolved is not None
- assert WARNING_FRAGMENT in caplog.text
+ assert resolved is None
+ assert "refusing" in caplog.text.lower()
assert "unknown" in caplog.text
+ def test_desktop_missing_entry_asset_is_rejected(
+ self, monkeypatch, tmp_path, caplog
+ ):
+ """A marker cannot make a partially installed bundle compatible."""
+ monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
+ dist = _make_dist(tmp_path / "webui", f"v{VERSION}")
+ (tmp_path / "webui" / "assets" / "app.js").unlink()
+
+ with caplog.at_level(logging.WARNING):
+ resolved = resolve_dashboard_dist(dist)
+
+ assert resolved is None
+ assert "incomplete" in caplog.text.lower()
+
+ def test_desktop_malformed_entry_url_is_rejected(
+ self, monkeypatch, tmp_path, caplog
+ ):
+ """A malformed asset URL must not abort backend startup."""
+ monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
+ dist = _make_dist(tmp_path / "webui", f"v{VERSION}")
+ (tmp_path / "webui" / "index.html").write_text(
+ '',
+ encoding="utf-8",
+ )
+
+ with caplog.at_level(logging.WARNING):
+ resolved = resolve_dashboard_dist(dist)
+
+ assert resolved is None
+ assert "incomplete" in caplog.text.lower()
+
+ def test_desktop_mismatched_version_uses_matching_managed_dist(
+ self, monkeypatch, tmp_path, caplog
+ ):
+ """A managed desktop backend must replace a known stale explicit dist."""
+ monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
+ explicit_dist = _make_dist(tmp_path / "webui", "v0.0.1")
+ data_dir = tmp_path / "data"
+ managed_dist = _make_dist(data_dir / "dist", f"v{VERSION}")
+ monkeypatch.setattr(
+ "astrbot.core.dashboard_assets.get_astrbot_data_path",
+ lambda: str(data_dir),
+ )
+ monkeypatch.setattr(
+ "astrbot.core.dashboard_assets._get_bundled_dist_path",
+ lambda: tmp_path / "missing-bundled-dist",
+ )
+
+ with caplog.at_level(logging.WARNING):
+ resolved = resolve_dashboard_dist(explicit_dist)
+
+ assert resolved == (tmp_path / "data" / "dist").absolute()
+ assert str(managed_dist) == str(resolved)
+ assert "v0.0.1" in caplog.text
+ assert "refusing" in caplog.text.lower()
+
+ def test_desktop_mismatched_version_without_fallback_is_not_served(
+ self, monkeypatch, tmp_path, caplog
+ ):
+ """Known stale desktop assets must never become the final fallback."""
+ monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
+ explicit_dist = _make_dist(tmp_path / "webui", "v0.0.1")
+ data_dir = tmp_path / "data"
+ _make_dist(data_dir / "dist", "v0.0.2")
+ monkeypatch.setattr(
+ "astrbot.core.dashboard_assets.get_astrbot_data_path",
+ lambda: str(data_dir),
+ )
+ monkeypatch.setattr(
+ "astrbot.core.dashboard_assets._get_bundled_dist_path",
+ lambda: tmp_path / "missing-bundled-dist",
+ )
+
+ with caplog.at_level(logging.WARNING):
+ resolved = resolve_dashboard_dist(explicit_dist)
+
+ assert resolved is None
+ assert "refusing" in caplog.text.lower()
+
def test_nonexistent_dir_falls_through(self, tmp_path, caplog):
"""A path that does not exist must not be reported as a stale dist."""
with caplog.at_level(logging.WARNING):