Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 60 additions & 12 deletions astrbot/core/updator.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import os
import sys
import time
from pathlib import Path

import psutil

from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import ensure_dir

from .zip_updator import ReleaseInfo, RepoZipUpdator

Expand Down Expand Up @@ -151,6 +153,41 @@ async def update(
proxy="",
progress_callback=None,
) -> None:
zip_path = await self.download_update_package(
latest=latest,
version=version,
proxy=proxy,
progress_callback=progress_callback,
)
self.apply_update_package(zip_path)

if reboot:
self._reboot()

async def download_update_package(
self,
latest=True,
version=None,
proxy="",
path: str | Path = "temp.zip",
progress_callback=None,
) -> Path:
"""Download an AstrBot core update package without applying it.

Args:
latest: Whether to download the latest release.
version: Specific release tag or commit hash to download.
proxy: Optional GitHub proxy prefix.
path: Destination zip path.
progress_callback: Optional callback for download progress payloads.

Returns:
Path to the downloaded update package.

Raises:
Exception: If update metadata cannot resolve a package URL.
"""

update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None

Expand Down Expand Up @@ -181,16 +218,27 @@ async def update(
proxy = proxy.removesuffix("/")
file_url = f"{proxy}/{file_url}"

try:
await self._download_file(
file_url,
"temp.zip",
progress_callback=progress_callback,
)
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
self.unzip_file("temp.zip", self.MAIN_PATH)
except BaseException as e:
raise e
zip_path = Path(path)
ensure_dir(zip_path.parent)
await self._download_file(
file_url,
str(zip_path),
progress_callback=progress_callback,
)
return zip_path

if reboot:
self._reboot()
def apply_update_package(self, zip_path: str | Path) -> None:
"""Apply a previously downloaded AstrBot core update package.

Args:
zip_path: Core update zip archive path.

Returns:
None.

Raises:
Exception: If the archive cannot be extracted or applied.
"""

logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
self.unzip_file(str(zip_path), self.MAIN_PATH)
42 changes: 40 additions & 2 deletions astrbot/core/utils/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,12 +426,27 @@ async def download_dashboard(
version: str | None = None,
proxy: str | None = None,
progress_callback=None,
extract: bool = True,
) -> None:
"""下载管理面板文件"""
"""Download dashboard assets and optionally extract them.

Args:
path: Destination zip path. Defaults to the AstrBot data directory.
extract_path: Directory where assets should be extracted.
latest: Whether to download the latest dashboard build.
version: Specific release tag or commit hash to download.
proxy: Optional download proxy prefix.
progress_callback: Optional callback for download progress payloads.
extract: Whether to extract the archive after download.

Returns:
None.
"""
if path is None:
zip_path = Path(get_astrbot_data_path()).absolute() / "dashboard.zip"
else:
zip_path = Path(path).absolute()
ensure_dir(zip_path.parent)

if latest or len(str(version)) != 40:
ver_name = "latest" if latest else version
Expand Down Expand Up @@ -484,5 +499,28 @@ async def download_dashboard(
show_progress=True,
progress_callback=progress_callback,
)
if extract:
extract_dashboard(zip_path, extract_path)


def extract_dashboard(zip_path: str | Path, extract_path: str | Path = "data") -> None:
"""Extract a downloaded dashboard archive.

Args:
zip_path: Dashboard zip archive path.
extract_path: Directory where the archive contents should be extracted.

Returns:
None.
"""
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

extract_root = Path(extract_path).resolve()
ensure_dir(extract_root)
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(extract_path)
for member in z.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}",
)
z.extract(member, extract_root)
2 changes: 2 additions & 0 deletions astrbot/dashboard/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
call_check_migration_needed_v4,
call_do_migration_v4,
call_download_dashboard,
call_extract_dashboard,
call_get_dashboard_version,
call_pip_install,
)
Expand Down Expand Up @@ -139,6 +140,7 @@ def create_dashboard_asgi_app(
core_lifecycle.astrbot_updator,
core_lifecycle,
download_dashboard_func=call_download_dashboard,
extract_dashboard_func=call_extract_dashboard,
get_dashboard_version_func=call_get_dashboard_version,
pip_install_func=call_pip_install,
check_migration_needed_func=call_check_migration_needed_v4,
Expand Down
119 changes: 100 additions & 19 deletions astrbot/dashboard/services/update_service.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
from __future__ import annotations

import asyncio
import inspect
import traceback
import uuid
import zipfile
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from astrbot.core import (
DEMO_MODE as _DEMO_MODE,
)
from astrbot.core import (
logger,
)
from astrbot.core import (
pip_installer as _pip_installer,
)
from astrbot.core import DEMO_MODE as _DEMO_MODE
from astrbot.core import logger
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.db.migration.helper import (
Expand All @@ -24,16 +22,24 @@
do_migration_v4 as _do_migration_v4,
)
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_system_tmp_path,
)
from astrbot.core.utils.io import (
download_dashboard as _download_dashboard,
)
from astrbot.core.utils.io import (
extract_dashboard as _extract_dashboard,
)
from astrbot.core.utils.io import (
get_dashboard_version as _get_dashboard_version,
)

DEMO_MODE = _DEMO_MODE
pip_installer = _pip_installer
download_dashboard = _download_dashboard
extract_dashboard = _extract_dashboard
get_dashboard_version = _get_dashboard_version
default_check_migration_needed_v4 = _check_migration_needed_v4
default_do_migration_v4 = _do_migration_v4
Expand All @@ -43,6 +49,15 @@ async def call_download_dashboard(*args, **kwargs):
return await download_dashboard(*args, **kwargs)


async def call_extract_dashboard(*args, **kwargs):
if inspect.iscoroutinefunction(extract_dashboard):
return await extract_dashboard(*args, **kwargs)
result = await asyncio.to_thread(extract_dashboard, *args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
Comment on lines +52 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The extract_dashboard function is synchronous and performs heavy I/O and CPU-bound zip extraction. Calling it directly inside an async def function blocks the single-threaded asyncio event loop, which can make the entire dashboard unresponsive during the extraction process.

We should run it in a separate thread using asyncio.to_thread to keep the event loop responsive, while still safely handling potential coroutine/awaitable mocks in tests.

Suggested change
async def call_extract_dashboard(*args, **kwargs):
result = extract_dashboard(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
async def call_extract_dashboard(*args, **kwargs):
import asyncio
if inspect.iscoroutinefunction(extract_dashboard):
return await extract_dashboard(*args, **kwargs)
result = await asyncio.to_thread(extract_dashboard, *args, **kwargs)
if inspect.isawaitable(result):
return await result
return result



async def call_get_dashboard_version(*args, **kwargs):
return await get_dashboard_version(*args, **kwargs)

Expand Down Expand Up @@ -78,6 +93,7 @@ def __init__(
core_lifecycle: AstrBotCoreLifecycle,
*,
download_dashboard_func: Callable[..., Awaitable[Any]],
extract_dashboard_func: Callable[..., Any],
get_dashboard_version_func: Callable[..., Awaitable[str | None]],
pip_install_func: Callable[..., Awaitable[Any]],
check_migration_needed_func: Callable[..., Awaitable[bool]],
Expand All @@ -88,6 +104,7 @@ def __init__(
self.astrbot_updator = astrbot_updator
self.core_lifecycle = core_lifecycle
self.download_dashboard = download_dashboard_func
self.extract_dashboard = extract_dashboard_func
self.get_dashboard_version = get_dashboard_version_func
self.pip_install = pip_install_func
self.check_migration_needed = check_migration_needed_func
Expand Down Expand Up @@ -177,6 +194,11 @@ async def update_project(self, data: object) -> UpdateServiceResult:
proxy = proxy.removesuffix("/")

self._init_update_progress(progress_id, version)
update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates"
update_temp_dir.mkdir(parents=True, exist_ok=True)
update_token = uuid.uuid4().hex
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
core_zip_path = update_temp_dir / f"{update_token}-core.zip"
try:
self._set_update_stage(
progress_id,
Expand All @@ -186,6 +208,7 @@ async def update_project(self, data: object) -> UpdateServiceResult:
0,
)
await self.download_dashboard(
path=str(dashboard_zip_path),
latest=latest,
version=version,
proxy=proxy or "",
Expand All @@ -195,6 +218,7 @@ async def update_project(self, data: object) -> UpdateServiceResult:
0,
45,
),
extract=False,
)
self._set_update_stage(
progress_id,
Expand All @@ -211,16 +235,19 @@ async def update_project(self, data: object) -> UpdateServiceResult:
"正在下载 AstrBot 项目代码...",
45,
)
await self.astrbot_updator.update(
latest=latest,
version=version,
proxy=proxy or "",
progress_callback=self._make_progress_callback(
progress_id,
"core",
45,
45,
),
core_zip_path = Path(
await self.astrbot_updator.download_update_package(
latest=latest,
version=version,
proxy=proxy or "",
path=core_zip_path,
progress_callback=self._make_progress_callback(
progress_id,
"core",
45,
45,
),
)
)
self._set_update_stage(
progress_id,
Expand All @@ -230,6 +257,53 @@ async def update_project(self, data: object) -> UpdateServiceResult:
90,
)

self._set_update_stage(
progress_id,
"verify",
"running",
"下载完成,正在校验更新包...",
90,
)

def _verify_update_packages() -> None:
for zip_path in (dashboard_zip_path, core_zip_path):
with zipfile.ZipFile(zip_path, "r") as archive:
corrupt_member = archive.testzip()
if corrupt_member:
raise UpdateServiceError(f"更新包校验失败: {corrupt_member}")

await asyncio.to_thread(_verify_update_packages)
self._set_update_stage(
progress_id,
"verify",
"done",
"更新包校验完成。",
91,
)

self._set_update_stage(
progress_id,
"apply",
"running",
"下载完成,正在应用更新...",
91,
)
await asyncio.to_thread(
self.astrbot_updator.apply_update_package,
core_zip_path,
)
await self.extract_dashboard(
dashboard_zip_path,
Path(get_astrbot_data_path()),
)
self._set_update_stage(
progress_id,
"apply",
"done",
"更新文件应用完成。",
92,
)

self._set_update_stage(
progress_id,
"dependencies",
Expand Down Expand Up @@ -284,6 +358,13 @@ async def update_project(self, data: object) -> UpdateServiceResult:
)
logger.error(f"/api/update_project: {traceback.format_exc()}")
raise UpdateServiceError(exc.__str__()) from exc
finally:
for zip_path in (dashboard_zip_path, core_zip_path):
try:
if zip_path.exists():
zip_path.unlink()
except Exception as cleanup_exc:
logger.warning(f"清理更新临时文件失败: {zip_path}, {cleanup_exc}")

async def update_dashboard(self) -> UpdateServiceResult:
try:
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/i18n/locales/en-US/core/header.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@
"restart": "Preparing restart...",
"restarting": "Restarting AstrBot",
"completed": "Update complete",
"successReady": "Update successful",
"autoReloadIn": "Refreshing automatically in {seconds}s",
"reloadNow": "Refresh now",
"failed": "Update failed"
},
"redirectConfirm": {
Expand Down
Loading
Loading