From 593a3d8da3251fd4c9a6c4b70aa4fe8bb4dfed71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 17 Feb 2026 20:13:24 +0900 Subject: [PATCH 01/49] fix(desktop): make frozen pip installs independent of system python --- astrbot/core/utils/pip_installer.py | 208 +++++++++++++++++++++++++++- main.py | 40 +++++- 2 files changed, 244 insertions(+), 4 deletions(-) diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 1c8da23c1..fecef2683 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -7,6 +7,8 @@ import logging import os import re +import shutil +import subprocess import sys import threading from collections import deque @@ -18,6 +20,7 @@ _DISTLIB_FINDER_PATCH_ATTEMPTED = False _SITE_PACKAGES_IMPORT_LOCK = threading.RLock() +_PIP_EXECUTABLE_PATCH_LOCK = threading.RLock() def _canonicalize_distribution_name(name: str) -> str: @@ -43,11 +46,189 @@ def _get_pip_main(): def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]: stream = io.StringIO() - with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream): + with ( + _patch_python_executable_for_pip_subprocesses(), + contextlib.redirect_stdout(stream), + contextlib.redirect_stderr(stream), + ): result_code = pip_main(args) return result_code, stream.getvalue() +def _normalize_executable_path(candidate: str | None) -> str | None: + if not candidate: + return None + + normalized = candidate.strip().strip('"').strip("'") + if not normalized: + return None + + if os.path.sep not in normalized and ( + os.path.altsep is None or os.path.altsep not in normalized + ): + resolved = shutil.which(normalized) + if not resolved: + return None + normalized = resolved + + normalized = os.path.realpath(os.path.expanduser(normalized)) + if not os.path.exists(normalized): + return None + if not os.access(normalized, os.X_OK): + return None + return normalized + + +def _get_python_version_for_executable(executable: str) -> tuple[int, int] | None: + try: + completed = subprocess.run( + [ + executable, + "-c", + "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}')", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=3, + check=False, + ) + except Exception: + return None + + if completed.returncode != 0: + return None + + output = completed.stdout.strip() + parts = output.split(".", maxsplit=1) + if len(parts) != 2: + return None + + try: + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + +def _resolve_python_executable_for_pip_subprocesses() -> str | None: + requested_version = (sys.version_info.major, sys.version_info.minor) + + candidate_names = [ + f"python{requested_version[0]}.{requested_version[1]}", + f"python{requested_version[0]}", + "python3", + "python", + ] + candidates = [ + os.environ.get("ASTRBOT_PIP_PYTHON"), + sys.executable, + os.environ.get("PYTHON"), + os.environ.get("PYTHONEXECUTABLE"), + getattr(sys, "_base_executable", None), + *candidate_names, + ] + + same_version_fallback: str | None = None + any_version_fallback: str | None = None + seen: set[str] = set() + + for candidate in candidates: + normalized = _normalize_executable_path(candidate) + if not normalized or normalized in seen: + continue + seen.add(normalized) + + detected_version = _get_python_version_for_executable(normalized) + if detected_version is None: + continue + + if detected_version == requested_version: + if normalized == os.path.realpath(sys.executable): + return normalized + if same_version_fallback is None: + same_version_fallback = normalized + continue + + if any_version_fallback is None: + any_version_fallback = normalized + + return same_version_fallback or any_version_fallback + + +def _is_pip_feature_enabled_in_args(args: list[str], feature: str) -> bool: + for index, token in enumerate(args): + if token == "--use-feature": + if index + 1 < len(args) and args[index + 1] == feature: + return True + continue + + if token.startswith("--use-feature="): + enabled_features = token.split("=", maxsplit=1)[1] + if feature in {item.strip() for item in enabled_features.split(",")}: + return True + + return False + + +def _pip_supports_feature(feature: str) -> bool: + try: + from pip._internal.cli import cmdoptions as pip_cmdoptions + except Exception: + return False + + use_new_feature = getattr(pip_cmdoptions, "use_new_feature", None) + choices = getattr(use_new_feature, "keywords", {}).get("choices", []) + if not isinstance(choices, (list, tuple, set)): + return False + return feature in choices + + +@contextlib.contextmanager +def _patch_python_executable_for_pip_subprocesses(): + if not getattr(sys, "frozen", False): + yield + return + + with _PIP_EXECUTABLE_PATCH_LOCK: + patched_executable = _resolve_python_executable_for_pip_subprocesses() + if not patched_executable: + logger.warning( + "Cannot find a runnable Python interpreter for pip subprocesses in " + "frozen runtime. Source-built dependencies may fail. " + "You can set ASTRBOT_PIP_PYTHON to a Python executable path." + ) + yield + return + + original_executable = sys.executable + original_python = os.environ.get("PYTHON") + original_python_executable = os.environ.get("PYTHONEXECUTABLE") + + sys.executable = patched_executable + os.environ["PYTHON"] = patched_executable + os.environ["PYTHONEXECUTABLE"] = patched_executable + + if os.path.realpath(original_executable) != patched_executable: + logger.info( + "Patched pip subprocess interpreter in frozen runtime: %s -> %s", + original_executable, + patched_executable, + ) + + try: + yield + finally: + sys.executable = original_executable + if original_python is None: + os.environ.pop("PYTHON", None) + else: + os.environ["PYTHON"] = original_python + if original_python_executable is None: + os.environ.pop("PYTHONEXECUTABLE", None) + else: + os.environ["PYTHONEXECUTABLE"] = original_python_executable + + def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None: root_logger = logging.getLogger() original_handler_ids = {id(handler) for handler in original_handlers} @@ -542,6 +723,7 @@ async def install( mirror: str | None = None, ) -> None: args = ["install"] + pip_install_args = self.pip_install_arg.split() if self.pip_install_arg else [] requested_requirements: set[str] = set() if package_name: args.append(package_name) @@ -563,8 +745,28 @@ async def install( args.extend(["--target", target_site_packages]) args.extend(["--upgrade", "--force-reinstall"]) - if self.pip_install_arg: - args.extend(self.pip_install_arg.split()) + feature_name = "inprocess-build-deps" + feature_configured = _is_pip_feature_enabled_in_args( + [*args, *pip_install_args], + feature_name, + ) + if feature_configured: + logger.info("Frozen runtime pip feature enabled: %s", feature_name) + elif _pip_supports_feature(feature_name): + args.extend(["--use-feature", feature_name]) + logger.info( + "Enabled pip feature for frozen runtime: %s", + feature_name, + ) + else: + logger.warning( + "Current pip does not support --use-feature=%s. " + "Build dependency installation may require subprocess fallback.", + feature_name, + ) + + if pip_install_args: + args.extend(pip_install_args) logger.info(f"Pip 包管理器: pip {' '.join(args)}") result_code = await self._run_pip_in_process(args) diff --git a/main.py b/main.py index be188140c..055c759d1 100644 --- a/main.py +++ b/main.py @@ -2,10 +2,48 @@ import asyncio import mimetypes import os +import runpy import sys from pathlib import Path -import runtime_bootstrap + +def _run_python_compat_mode_if_needed() -> None: + """Support subprocess calls that expect a Python interpreter in frozen mode.""" + if not getattr(sys, "frozen", False): + return + + if len(sys.argv) < 2: + return + + command = sys.argv[1] + + if command == "-c": + if len(sys.argv) < 3: + raise SystemExit("astrbot-backend: argument expected for -c") + code = sys.argv[2] + sys.argv = ["-c", *sys.argv[3:]] + exec(code, {"__name__": "__main__", "__package__": None, "__spec__": None}) # noqa: S102 + raise SystemExit(0) + + if command == "-m": + if len(sys.argv) < 3: + raise SystemExit("astrbot-backend: argument expected for -m") + module_name = sys.argv[2] + sys.argv = [module_name, *sys.argv[3:]] + runpy.run_module(module_name, run_name="__main__", alter_sys=True) + raise SystemExit(0) + + script_path = Path(command) + if script_path.is_file(): + resolved_script = script_path.resolve().as_posix() + sys.argv = [resolved_script, *sys.argv[2:]] + runpy.run_path(resolved_script, run_name="__main__") + raise SystemExit(0) + + +_run_python_compat_mode_if_needed() + +import runtime_bootstrap # noqa: E402 runtime_bootstrap.initialize_runtime_bootstrap() From 86d904c77e8b496d6b721b560e5f55c8e387b026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 00:06:17 +0900 Subject: [PATCH 02/49] refactor: migrate desktop backend to cpython runtime --- .gitignore | 4 + README.md | 1 + astrbot/core/star/star_manager.py | 18 ++ astrbot/core/utils/pip_installer.py | 325 ++-------------------------- astrbot/core/utils/runtime_env.py | 2 +- desktop/README.md | 39 +++- desktop/lib/backend-manager.js | 130 +++++++++-- desktop/scripts/build-backend.mjs | 196 +++++++++++------ 8 files changed, 310 insertions(+), 405 deletions(-) diff --git a/.gitignore b/.gitignore index e060b85a6..b632adaf1 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,10 @@ desktop/dist/ desktop/out/ desktop/resources/backend/astrbot-backend* desktop/resources/backend/*.exe +desktop/resources/backend/app/ +desktop/resources/backend/python/ +desktop/resources/backend/launch_backend.py +desktop/resources/backend/runtime-manifest.json desktop/resources/webui/* desktop/resources/.pyinstaller/ package-lock.json diff --git a/README.md b/README.md index 494a43833..0445c1b48 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ paru -S astrbot-git #### 桌面端 Electron 打包 桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。 +打包前需要准备 CPython 运行时目录,并设置 `ASTRBOT_DESKTOP_CPYTHON_HOME`(详见该文档)。 ## 支持的消息平台 diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 51f50aedf..425989673 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -194,6 +194,11 @@ async def _check_plugin_dept_update( await pip_installer.install(requirements_path=pth) except Exception as e: logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}") + if target_plugin: + raise Exception( + "插件依赖安装失败,请检查插件 requirements.txt " + "中的依赖版本或构建环境。" + ) from e return True async def _import_plugin_with_dependency_recovery( @@ -471,6 +476,19 @@ async def load(self, specified_module_path=None, specified_dir_name=None): except Exception as e: logger.error(traceback.format_exc()) logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}") + fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n" + self.failed_plugin_dict[root_dir_name] = { + "error": str(e), + "traceback": traceback.format_exc(), + } + if not reserved: + logger.warning( + f"插件 {root_dir_name} 导入失败,已自动卸载该插件。" + ) + await self._cleanup_failed_plugin_install( + dir_name=root_dir_name, + plugin_path=plugin_dir_path, + ) continue # 检查 _conf_schema.json diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index fecef2683..eb852e197 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -7,8 +7,6 @@ import logging import os import re -import shutil -import subprocess import sys import threading from collections import deque @@ -18,9 +16,7 @@ logger = logging.getLogger("astrbot") -_DISTLIB_FINDER_PATCH_ATTEMPTED = False _SITE_PACKAGES_IMPORT_LOCK = threading.RLock() -_PIP_EXECUTABLE_PATCH_LOCK = threading.RLock() def _canonicalize_distribution_name(name: str) -> str: @@ -37,7 +33,7 @@ def _get_pip_main(): raise ImportError( "pip module is unavailable " f"(sys.executable={sys.executable}, " - f"frozen={getattr(sys, 'frozen', False)}, " + f"packaged_electron={is_packaged_electron_runtime()}, " f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})" ) from exc @@ -46,189 +42,11 @@ def _get_pip_main(): def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]: stream = io.StringIO() - with ( - _patch_python_executable_for_pip_subprocesses(), - contextlib.redirect_stdout(stream), - contextlib.redirect_stderr(stream), - ): + with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream): result_code = pip_main(args) return result_code, stream.getvalue() -def _normalize_executable_path(candidate: str | None) -> str | None: - if not candidate: - return None - - normalized = candidate.strip().strip('"').strip("'") - if not normalized: - return None - - if os.path.sep not in normalized and ( - os.path.altsep is None or os.path.altsep not in normalized - ): - resolved = shutil.which(normalized) - if not resolved: - return None - normalized = resolved - - normalized = os.path.realpath(os.path.expanduser(normalized)) - if not os.path.exists(normalized): - return None - if not os.access(normalized, os.X_OK): - return None - return normalized - - -def _get_python_version_for_executable(executable: str) -> tuple[int, int] | None: - try: - completed = subprocess.run( - [ - executable, - "-c", - "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}')", - ], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - timeout=3, - check=False, - ) - except Exception: - return None - - if completed.returncode != 0: - return None - - output = completed.stdout.strip() - parts = output.split(".", maxsplit=1) - if len(parts) != 2: - return None - - try: - return int(parts[0]), int(parts[1]) - except ValueError: - return None - - -def _resolve_python_executable_for_pip_subprocesses() -> str | None: - requested_version = (sys.version_info.major, sys.version_info.minor) - - candidate_names = [ - f"python{requested_version[0]}.{requested_version[1]}", - f"python{requested_version[0]}", - "python3", - "python", - ] - candidates = [ - os.environ.get("ASTRBOT_PIP_PYTHON"), - sys.executable, - os.environ.get("PYTHON"), - os.environ.get("PYTHONEXECUTABLE"), - getattr(sys, "_base_executable", None), - *candidate_names, - ] - - same_version_fallback: str | None = None - any_version_fallback: str | None = None - seen: set[str] = set() - - for candidate in candidates: - normalized = _normalize_executable_path(candidate) - if not normalized or normalized in seen: - continue - seen.add(normalized) - - detected_version = _get_python_version_for_executable(normalized) - if detected_version is None: - continue - - if detected_version == requested_version: - if normalized == os.path.realpath(sys.executable): - return normalized - if same_version_fallback is None: - same_version_fallback = normalized - continue - - if any_version_fallback is None: - any_version_fallback = normalized - - return same_version_fallback or any_version_fallback - - -def _is_pip_feature_enabled_in_args(args: list[str], feature: str) -> bool: - for index, token in enumerate(args): - if token == "--use-feature": - if index + 1 < len(args) and args[index + 1] == feature: - return True - continue - - if token.startswith("--use-feature="): - enabled_features = token.split("=", maxsplit=1)[1] - if feature in {item.strip() for item in enabled_features.split(",")}: - return True - - return False - - -def _pip_supports_feature(feature: str) -> bool: - try: - from pip._internal.cli import cmdoptions as pip_cmdoptions - except Exception: - return False - - use_new_feature = getattr(pip_cmdoptions, "use_new_feature", None) - choices = getattr(use_new_feature, "keywords", {}).get("choices", []) - if not isinstance(choices, (list, tuple, set)): - return False - return feature in choices - - -@contextlib.contextmanager -def _patch_python_executable_for_pip_subprocesses(): - if not getattr(sys, "frozen", False): - yield - return - - with _PIP_EXECUTABLE_PATCH_LOCK: - patched_executable = _resolve_python_executable_for_pip_subprocesses() - if not patched_executable: - logger.warning( - "Cannot find a runnable Python interpreter for pip subprocesses in " - "frozen runtime. Source-built dependencies may fail. " - "You can set ASTRBOT_PIP_PYTHON to a Python executable path." - ) - yield - return - - original_executable = sys.executable - original_python = os.environ.get("PYTHON") - original_python_executable = os.environ.get("PYTHONEXECUTABLE") - - sys.executable = patched_executable - os.environ["PYTHON"] = patched_executable - os.environ["PYTHONEXECUTABLE"] = patched_executable - - if os.path.realpath(original_executable) != patched_executable: - logger.info( - "Patched pip subprocess interpreter in frozen runtime: %s -> %s", - original_executable, - patched_executable, - ) - - try: - yield - finally: - sys.executable = original_executable - if original_python is None: - os.environ.pop("PYTHON", None) - else: - os.environ["PYTHON"] = original_python - if original_python_executable is None: - os.environ.pop("PYTHONEXECUTABLE", None) - else: - os.environ["PYTHONEXECUTABLE"] = original_python_executable - - def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None: root_logger = logging.getLogger() original_handler_ids = {id(handler) for handler in original_handlers} @@ -607,110 +425,6 @@ def _ensure_plugin_dependencies_preferred( _ensure_preferred_modules(candidate_modules, target_site_packages) -def _get_loader_for_package(package: object) -> object | None: - loader = getattr(package, "__loader__", None) - if loader is not None: - return loader - - spec = getattr(package, "__spec__", None) - if spec is None: - return None - return getattr(spec, "loader", None) - - -def _try_register_distlib_finder( - distlib_resources: object, - finder_registry: dict[type, object], - register_finder, - resource_finder: object, - loader: object, - package_name: str, -) -> bool: - loader_type = type(loader) - if loader_type in finder_registry: - return False - - try: - register_finder(loader, resource_finder) - except Exception as exc: - logger.warning( - "Failed to patch pip distlib finder for loader %s (%s): %s", - loader_type.__name__, - package_name, - exc, - ) - return False - - updated_registry = getattr(distlib_resources, "_finder_registry", finder_registry) - if isinstance(updated_registry, dict) and loader_type not in updated_registry: - logger.warning( - "Distlib finder patch did not take effect for loader %s (%s).", - loader_type.__name__, - package_name, - ) - return False - - logger.info( - "Patched pip distlib finder for frozen loader: %s (%s)", - loader_type.__name__, - package_name, - ) - return True - - -def _patch_distlib_finder_for_frozen_runtime() -> None: - global _DISTLIB_FINDER_PATCH_ATTEMPTED - - if not getattr(sys, "frozen", False): - return - if _DISTLIB_FINDER_PATCH_ATTEMPTED: - return - - _DISTLIB_FINDER_PATCH_ATTEMPTED = True - - try: - from pip._vendor.distlib import resources as distlib_resources - except Exception: - return - - finder_registry = getattr(distlib_resources, "_finder_registry", None) - register_finder = getattr(distlib_resources, "register_finder", None) - resource_finder = getattr(distlib_resources, "ResourceFinder", None) - - if not isinstance(finder_registry, dict): - logger.warning( - "Skip patching distlib finder because _finder_registry is unavailable." - ) - return - if not callable(register_finder) or resource_finder is None: - logger.warning( - "Skip patching distlib finder because register API is unavailable." - ) - return - - for package_name in ("pip._vendor.distlib", "pip._vendor"): - try: - package = importlib.import_module(package_name) - except Exception: - continue - - loader = _get_loader_for_package(package) - if loader is None: - continue - - if _try_register_distlib_finder( - distlib_resources, - finder_registry, - register_finder, - resource_finder, - loader, - package_name, - ): - finder_registry = getattr( - distlib_resources, "_finder_registry", finder_registry - ) - - class PipInstaller: def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None) -> None: self.pip_install_arg = pip_install_arg @@ -721,6 +435,7 @@ async def install( package_name: str | None = None, requirements_path: str | None = None, mirror: str | None = None, + wheel_only: bool = False, ) -> None: args = ["install"] pip_install_args = self.pip_install_arg.split() if self.pip_install_arg else [] @@ -745,33 +460,26 @@ async def install( args.extend(["--target", target_site_packages]) args.extend(["--upgrade", "--force-reinstall"]) - feature_name = "inprocess-build-deps" - feature_configured = _is_pip_feature_enabled_in_args( - [*args, *pip_install_args], - feature_name, - ) - if feature_configured: - logger.info("Frozen runtime pip feature enabled: %s", feature_name) - elif _pip_supports_feature(feature_name): - args.extend(["--use-feature", feature_name]) - logger.info( - "Enabled pip feature for frozen runtime: %s", - feature_name, - ) - else: - logger.warning( - "Current pip does not support --use-feature=%s. " - "Build dependency installation may require subprocess fallback.", - feature_name, - ) - if pip_install_args: args.extend(pip_install_args) + if wheel_only: + if not any( + token == "--only-binary" or token.startswith("--only-binary=") + for token in args + ): + args.extend(["--only-binary", ":all:"]) + if "--prefer-binary" not in args: + args.append("--prefer-binary") logger.info(f"Pip 包管理器: pip {' '.join(args)}") result_code = await self._run_pip_in_process(args) if result_code != 0: + if wheel_only: + raise Exception( + "安装失败:插件依赖 wheel-only 检测未通过或依赖安装失败," + "请检查是否存在无可用 wheel 的依赖。" + ) raise Exception(f"安装失败,错误码:{result_code}") if target_site_packages: @@ -804,7 +512,6 @@ def prefer_installed_dependencies(self, requirements_path: str) -> None: async def _run_pip_in_process(self, args: list[str]) -> int: pip_main = _get_pip_main() - _patch_distlib_finder_for_frozen_runtime() original_handlers = list(logging.getLogger().handlers) result_code, output = await asyncio.to_thread( diff --git a/astrbot/core/utils/runtime_env.py b/astrbot/core/utils/runtime_env.py index 2eb1bc7e4..fe84b178e 100644 --- a/astrbot/core/utils/runtime_env.py +++ b/astrbot/core/utils/runtime_env.py @@ -7,4 +7,4 @@ def is_frozen_runtime() -> bool: def is_packaged_electron_runtime() -> bool: - return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1" + return os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1" diff --git a/desktop/README.md b/desktop/README.md index 48dcb341a..42f9a3eb7 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -6,11 +6,12 @@ This document describes how to build the Electron desktop app from source. - Electron desktop shell (`desktop/main.js`) - Bundled WebUI static files (`desktop/resources/webui`) +- Bundled backend runtime payload (`desktop/resources/backend`) - App assets (`desktop/assets`) Current behavior: -- Backend executable is bundled in the installer/package. +- Backend CPython runtime and source are bundled in the installer/package. - App startup checks backend availability and auto-starts bundled backend when needed. - Runtime data is stored under `~/.astrbot` by default, not as a full AstrBot source project. @@ -19,6 +20,7 @@ Current behavior: - Python environment ready in repository root (`uv` available) - Node.js available - `pnpm` available +- A CPython runtime directory for packaged backend (contains runnable `python` and `site-packages`) Desktop dependency management uses `pnpm` with a lockfile: @@ -31,12 +33,21 @@ Run commands from repository root: ```bash uv sync +export ASTRBOT_DESKTOP_CPYTHON_HOME=/path/to/cpython-runtime pnpm --dir dashboard install pnpm --dir dashboard build pnpm --dir desktop install --frozen-lockfile pnpm --dir desktop run dist:full ``` +If you are already developing in this repository, you can directly reuse the local virtual environment as runtime: + +```bash +uv sync +export ASTRBOT_DESKTOP_CPYTHON_HOME="$(pwd)/.venv" +pnpm --dir desktop run build:backend +``` + Output files are generated under: - `desktop/dist/` @@ -57,9 +68,25 @@ pnpm --dir desktop run dev ## Notes -- `dist:full` runs WebUI build + backend build + Electron packaging. +- `dist:full` runs WebUI build + backend runtime packaging + Electron packaging. - In packaged app mode, backend data root defaults to `~/.astrbot` (can be overridden by `ASTRBOT_ROOT`). -- Backend build uses `uv run --with pyinstaller ...`, so no manual `PyInstaller` install is required. +- Backend build requires `ASTRBOT_DESKTOP_CPYTHON_HOME` (or `ASTRBOT_DESKTOP_BACKEND_RUNTIME`) to point to a CPython runtime directory. + +## Packaged Backend Layout + +After `pnpm --dir desktop run build:backend`, backend payload is generated in `desktop/resources/backend`: + +```text +desktop/resources/backend/ + app/ # AstrBot backend source snapshot used in packaged mode + python/ # Bundled CPython runtime directory + launch_backend.py # Launcher executed by Electron + runtime-manifest.json # Runtime metadata (python path, entrypoint, app path) +``` + +Electron reads `runtime-manifest.json` and starts backend with: +- `python` from `python/` +- `launch_backend.py` as entrypoint ## Runtime Directory Layout @@ -122,6 +149,12 @@ Backend auto-start: - `ASTRBOT_BACKEND_AUTO_START=0` disables Electron-managed backend startup. - When disabled, backend must already be running at `ASTRBOT_BACKEND_URL` before launching app. +Backend build errors: + +- `Missing CPython runtime source`: set `ASTRBOT_DESKTOP_CPYTHON_HOME` (or `ASTRBOT_DESKTOP_BACKEND_RUNTIME`). +- `Cannot find Python executable in runtime`: runtime directory is invalid or incomplete. +- `Failed to detect purelib from runtime python`: runtime Python cannot run correctly. + If Electron download times out on restricted networks, configure mirrors before install: ```bash diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index eb8958a4c..2164ace7a 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -54,6 +54,7 @@ class BackendManager { this.backendProcess = null; this.backendConfig = null; + this.packagedBackendManifest = null; this.backendLogger = new BufferedRotatingLogger({ logPath: null, maxBytes: this.backendLogMaxBytes, @@ -114,7 +115,7 @@ class BackendManager { if (!this.app.isPackaged) { return path.resolve(this.baseDir, '..'); } - return this.resolveBackendRoot(); + return this.getPackagedBackendAppDir() || this.resolveBackendRoot(); } resolveWebuiDir() { @@ -129,31 +130,118 @@ class BackendManager { return fs.existsSync(indexPath) ? candidate : null; } - getPackagedBackendPath() { + getPackagedBackendDir() { if (!this.app.isPackaged) { return null; } - const filename = - process.platform === 'win32' ? 'astrbot-backend.exe' : 'astrbot-backend'; - const candidate = path.join(process.resourcesPath, 'backend', filename); + return path.join(process.resourcesPath, 'backend'); + } + + getPackagedBackendManifest() { + if (!this.app.isPackaged) { + return null; + } + if (this.packagedBackendManifest) { + return this.packagedBackendManifest; + } + + const backendDir = this.getPackagedBackendDir(); + if (!backendDir) { + return null; + } + const manifestPath = path.join(backendDir, 'runtime-manifest.json'); + if (!fs.existsSync(manifestPath)) { + return null; + } + + try { + const raw = fs.readFileSync(manifestPath, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + this.packagedBackendManifest = parsed; + } + } catch (error) { + this.log( + `Failed to parse packaged backend manifest: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + this.packagedBackendManifest = null; + } + + return this.packagedBackendManifest; + } + + getPackagedBackendAppDir() { + const backendDir = this.getPackagedBackendDir(); + if (!backendDir) { + return null; + } + + const manifest = this.getPackagedBackendManifest(); + const appRelative = + manifest && typeof manifest.app === 'string' && manifest.app + ? manifest.app + : 'app'; + const candidate = path.join(backendDir, appRelative); return fs.existsSync(candidate) ? candidate : null; } + getPackagedBackendLaunchScriptPath() { + const backendDir = this.getPackagedBackendDir(); + if (!backendDir) { + return null; + } + + const manifest = this.getPackagedBackendManifest(); + const entryRelative = + manifest && typeof manifest.entrypoint === 'string' && manifest.entrypoint + ? manifest.entrypoint + : 'launch_backend.py'; + const candidate = path.join(backendDir, entryRelative); + return fs.existsSync(candidate) ? candidate : null; + } + + getPackagedRuntimePythonPath() { + const backendDir = this.getPackagedBackendDir(); + if (!backendDir) { + return null; + } + + const manifest = this.getPackagedBackendManifest(); + const pythonRelative = + manifest && typeof manifest.python === 'string' && manifest.python + ? manifest.python + : process.platform === 'win32' + ? path.join('python', 'Scripts', 'python.exe') + : path.join('python', 'bin', 'python3'); + + const candidate = path.join(backendDir, pythonRelative); + return fs.existsSync(candidate) ? candidate : null; + } + + buildPackagedBackendLaunch(webuiDir) { + const runtimePython = this.getPackagedRuntimePythonPath(); + const launchScript = this.getPackagedBackendLaunchScriptPath(); + if (!runtimePython || !launchScript) { + return null; + } + + const args = [launchScript]; + if (webuiDir) { + args.push('--webui-dir', webuiDir); + } + + return { + cmd: runtimePython, + args, + shell: false, + }; + } + buildDefaultBackendLaunch(webuiDir) { if (this.app.isPackaged) { - const packagedBackend = this.getPackagedBackendPath(); - if (!packagedBackend) { - return null; - } - const args = []; - if (webuiDir) { - args.push('--webui-dir', webuiDir); - } - return { - cmd: packagedBackend, - args, - shell: false, - }; + return this.buildPackagedBackendLaunch(webuiDir); } const args = ['run', 'main.py']; @@ -641,9 +729,9 @@ class BackendManager { `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, ); - const expectedImageName = ( - path.basename(this.getPackagedBackendPath() || '') || 'astrbot-backend.exe' - ).toLowerCase(); + const expectedImageName = path + .basename(this.getBackendConfig().cmd || 'python.exe') + .toLowerCase(); for (const pid of pids) { const processInfo = this.getWindowsProcessInfo(pid); diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index 921cf19cb..2e00dbc9f 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -1,86 +1,140 @@ -import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..', '..'); const outputDir = path.join(rootDir, 'desktop', 'resources', 'backend'); -const workDir = path.join(rootDir, 'desktop', 'resources', '.pyinstaller'); -const dataSeparator = process.platform === 'win32' ? ';' : ':'; -const kbStopwordsSrc = path.join( - rootDir, - 'astrbot', - 'core', - 'knowledge_base', - 'retrieval', - 'hit_stopwords.txt', -); -const kbStopwordsDest = 'astrbot/core/knowledge_base/retrieval'; -const builtinStarsSrc = path.join(rootDir, 'astrbot', 'builtin_stars'); -const builtinStarsDest = 'astrbot/builtin_stars'; - -const args = [ - 'run', - '--with', - 'pyinstaller', - 'python', - '-m', - 'PyInstaller', - '--noconfirm', - '--clean', - '--onefile', - '--name', - 'astrbot-backend', - '--collect-all', - 'aiosqlite', - '--collect-all', - 'pip', - '--collect-all', - 'bs4', - '--collect-all', - 'readability', - '--collect-all', - 'lxml', - '--collect-all', - 'lxml_html_clean', - '--collect-all', - 'rfc3987_syntax', - '--collect-submodules', - 'astrbot.api', - '--collect-submodules', - 'astrbot.builtin_stars', - '--collect-data', - 'certifi', - '--add-data', - `${builtinStarsSrc}${dataSeparator}${builtinStarsDest}`, - '--add-data', - `${kbStopwordsSrc}${dataSeparator}${kbStopwordsDest}`, - '--distpath', - outputDir, - '--workpath', - workDir, - '--specpath', - workDir, - path.join(rootDir, 'main.py'), +const appDir = path.join(outputDir, 'app'); +const runtimeDir = path.join(outputDir, 'python'); +const manifestPath = path.join(outputDir, 'runtime-manifest.json'); +const launcherPath = path.join(outputDir, 'launch_backend.py'); + +const runtimeSource = + process.env.ASTRBOT_DESKTOP_CPYTHON_HOME || + process.env.ASTRBOT_DESKTOP_BACKEND_RUNTIME; + +if (!runtimeSource) { + console.error( + 'Missing CPython runtime source. Set ASTRBOT_DESKTOP_CPYTHON_HOME ' + + '(recommended) or ASTRBOT_DESKTOP_BACKEND_RUNTIME.', + ); + process.exit(1); +} + +const runtimeSourceReal = path.resolve(rootDir, runtimeSource); +if (!fs.existsSync(runtimeSourceReal)) { + console.error(`CPython runtime source does not exist: ${runtimeSourceReal}`); + process.exit(1); +} + +const sourceEntries = [ + ['astrbot', 'astrbot'], + ['main.py', 'main.py'], + ['runtime_bootstrap.py', 'runtime_bootstrap.py'], + ['requirements.txt', 'requirements.txt'], ]; -const result = spawnSync('uv', args, { - cwd: rootDir, - stdio: 'inherit', - shell: process.platform === 'win32', -}); +const shouldCopy = (srcPath) => { + const base = path.basename(srcPath); + if (base === '__pycache__' || base === '.pytest_cache' || base === '.ruff_cache') { + return false; + } + if (base === '.git' || base === '.mypy_cache' || base === '.DS_Store') { + return false; + } + if (base.endsWith('.pyc') || base.endsWith('.pyo')) { + return false; + } + return true; +}; + +const copyTree = (fromPath, toPath, { dereference = false } = {}) => { + fs.cpSync(fromPath, toPath, { + recursive: true, + force: true, + filter: shouldCopy, + dereference, + }); +}; + +const resolveRuntimePython = (runtimeRoot) => { + const candidates = + process.platform === 'win32' + ? ['python.exe', path.join('Scripts', 'python.exe')] + : [path.join('bin', 'python3'), path.join('bin', 'python')]; -if (result.error) { - console.error(`Failed to run 'uv': ${result.error.message}`); - process.exit(typeof result.status === 'number' ? result.status : 1); + for (const relativeCandidate of candidates) { + const candidate = path.join(runtimeRoot, relativeCandidate); + if (fs.existsSync(candidate)) { + return { + absolute: candidate, + relative: path.relative(outputDir, candidate), + }; + } + } + + return null; +}; + +const writeLauncherScript = () => { + const content = `from __future__ import annotations + +import runpy +import sys +from pathlib import Path + +BACKEND_DIR = Path(__file__).resolve().parent +APP_DIR = BACKEND_DIR / "app" + +sys.path.insert(0, str(APP_DIR)) + +main_file = APP_DIR / "main.py" +if not main_file.is_file(): + raise FileNotFoundError(f"Backend entrypoint not found: {main_file}") + +sys.argv[0] = str(main_file) +runpy.run_path(str(main_file), run_name="__main__") +`; + fs.writeFileSync(launcherPath, content, 'utf8'); +}; + +fs.rmSync(outputDir, { recursive: true, force: true }); +fs.mkdirSync(outputDir, { recursive: true }); +fs.mkdirSync(appDir, { recursive: true }); + +for (const [srcRelative, destRelative] of sourceEntries) { + const sourcePath = path.join(rootDir, srcRelative); + const targetPath = path.join(appDir, destRelative); + if (!fs.existsSync(sourcePath)) { + console.error(`Backend source path does not exist: ${sourcePath}`); + process.exit(1); + } + copyTree(sourcePath, targetPath); } -if (result.status !== 0) { +copyTree(runtimeSourceReal, runtimeDir, { dereference: true }); + +const runtimePython = resolveRuntimePython(runtimeDir); +if (!runtimePython) { console.error( - `'uv' exited with status ${result.status} while running PyInstaller. ` + - 'Verify that uv and pyinstaller are installed and that arguments are valid.', + `Cannot find Python executable in runtime: ${runtimeDir}. ` + + 'Expected python under bin/ or Scripts/.', ); - process.exit(result.status ?? 1); + process.exit(1); } -process.exit(0); +writeLauncherScript(); + +const manifest = { + mode: 'cpython-runtime', + python: runtimePython.relative, + entrypoint: path.basename(launcherPath), + app: path.relative(outputDir, appDir), +}; +fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); + +console.log(`Prepared CPython backend runtime in ${outputDir}`); +console.log(`Runtime source: ${runtimeSourceReal}`); +console.log(`Python executable: ${runtimePython.relative}`); From 42a151fd00a7b78d17ba76d8736ad98c6b3ea4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 09:10:35 +0900 Subject: [PATCH 03/49] fix(desktop): stabilize CI runtime and windows backend cleanup --- .github/workflows/release.yml | 1 + desktop/lib/backend-manager.js | 90 ++++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59c229b04..a1ea7d4bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -135,6 +135,7 @@ jobs: arch: arm64 env: CSC_IDENTITY_AUTO_DISCOVERY: "false" + ASTRBOT_DESKTOP_CPYTHON_HOME: ".venv" steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 2164ace7a..0ec35c887 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -710,6 +710,48 @@ class BackendManager { return { imageName, pid: parsedPid }; } + normalizeWindowsPathForMatch(value) { + return String(value || '') + .replace(/\//g, '\\') + .toLowerCase(); + } + + isGenericWindowsPythonImage(imageName) { + const normalized = String(imageName || '').toLowerCase(); + return ( + normalized === 'python.exe' || + normalized === 'pythonw.exe' || + normalized === 'py.exe' + ); + } + + getWindowsProcessCommandLine(pid) { + const numericPid = Number.parseInt(`${pid}`, 10); + if (!Number.isInteger(numericPid)) { + return null; + } + + const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${numericPid}"; if ($null -ne $p) { $p.CommandLine }`; + const result = spawnSync( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', query], + { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + windowsHide: true, + }, + ); + if (result.status !== 0 || !result.stdout) { + return null; + } + + const line = result.stdout + .split(/\r?\n/) + .map((item) => item.trim()) + .find((item) => item.length > 0); + return line || null; + } + async stopUnmanagedBackendByPort() { if (!this.app.isPackaged || process.platform !== 'win32') { return false; @@ -729,9 +771,25 @@ class BackendManager { `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, ); - const expectedImageName = path - .basename(this.getBackendConfig().cmd || 'python.exe') - .toLowerCase(); + const backendConfig = this.getBackendConfig(); + const expectedImageName = path.basename(backendConfig.cmd || 'python.exe').toLowerCase(); + const requireStrictCommandLineCheck = + this.isGenericWindowsPythonImage(expectedImageName); + const expectedCommandLineMarkers = []; + if (Array.isArray(backendConfig.args) && backendConfig.args.length > 0) { + const primaryArg = backendConfig.args[0]; + if (typeof primaryArg === 'string' && primaryArg) { + const resolvedPrimaryArg = path.isAbsolute(primaryArg) + ? primaryArg + : path.resolve(backendConfig.cwd || process.cwd(), primaryArg); + expectedCommandLineMarkers.push( + this.normalizeWindowsPathForMatch(resolvedPrimaryArg), + ); + expectedCommandLineMarkers.push( + this.normalizeWindowsPathForMatch(path.basename(primaryArg)), + ); + } + } for (const pid of pids) { const processInfo = this.getWindowsProcessInfo(pid); @@ -748,6 +806,32 @@ class BackendManager { continue; } + if (requireStrictCommandLineCheck) { + if (!expectedCommandLineMarkers.length) { + this.log( + `Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`, + ); + continue; + } + const commandLine = this.getWindowsProcessCommandLine(pid); + if (!commandLine) { + this.log( + `Skip unmanaged cleanup for pid=${pid}: unable to resolve process command line.`, + ); + continue; + } + const normalizedCommandLine = this.normalizeWindowsPathForMatch(commandLine); + const markerMatched = expectedCommandLineMarkers.some( + (marker) => marker && normalizedCommandLine.includes(marker), + ); + if (!markerMatched) { + this.log( + `Skip unmanaged cleanup for pid=${pid}: command line does not match AstrBot backend launch marker.`, + ); + continue; + } + } + try { // Synchronous taskkill is acceptable here because unmanaged cleanup // is performed only during shutdown/restart control flows. From cb152551426ae21da06d1b2626b80f7e7f7dee7f Mon Sep 17 00:00:00 2001 From: zouyonghe <1259085392z@gmail.com> Date: Wed, 18 Feb 2026 09:28:38 +0900 Subject: [PATCH 04/49] fix: write knowledge base under astrbot data dir --- astrbot/core/knowledge_base/kb_db_sqlite.py | 7 +++++-- astrbot/core/knowledge_base/kb_mgr.py | 5 +++-- .../provider/sources/sensevoice_selfhosted_source.py | 6 +++++- astrbot/dashboard/utils.py | 9 ++++----- main.py | 2 ++ 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/astrbot/core/knowledge_base/kb_db_sqlite.py b/astrbot/core/knowledge_base/kb_db_sqlite.py index ba25ed7e5..39fc72ac8 100644 --- a/astrbot/core/knowledge_base/kb_db_sqlite.py +++ b/astrbot/core/knowledge_base/kb_db_sqlite.py @@ -13,16 +13,19 @@ KBMedia, KnowledgeBase, ) +from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path class KBSQLiteDatabase: - def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None: + def __init__(self, db_path: str | None = None) -> None: """初始化知识库数据库 Args: - db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db + db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 knowledge_base/kb.db """ + if db_path is None: + db_path = str(Path(get_astrbot_knowledge_base_path()) / "kb.db") self.db_path = db_path self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}" self.inited = False diff --git a/astrbot/core/knowledge_base/kb_mgr.py b/astrbot/core/knowledge_base/kb_mgr.py index ae5a1b9e7..f26409e56 100644 --- a/astrbot/core/knowledge_base/kb_mgr.py +++ b/astrbot/core/knowledge_base/kb_mgr.py @@ -3,6 +3,7 @@ from astrbot.core import logger from astrbot.core.provider.manager import ProviderManager +from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path # from .chunking.fixed_size import FixedSizeChunker from .chunking.recursive import RecursiveCharacterChunker @@ -13,7 +14,7 @@ from .retrieval.rank_fusion import RankFusion from .retrieval.sparse_retriever import SparseRetriever -FILES_PATH = "data/knowledge_base" +FILES_PATH = get_astrbot_knowledge_base_path() DB_PATH = Path(FILES_PATH) / "kb.db" """Knowledge Base storage root directory""" CHUNKER = RecursiveCharacterChunker() @@ -27,7 +28,7 @@ def __init__( self, provider_manager: ProviderManager, ) -> None: - Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + DB_PATH.parent.mkdir(parents=True, exist_ok=True) self.provider_manager = provider_manager self._session_deleted_callback_registered = False diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py index 965b83a5a..af6c0f631 100644 --- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py +++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py @@ -7,12 +7,14 @@ import os import re from datetime import datetime +from pathlib import Path from typing import cast from funasr_onnx import SenseVoiceSmall from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess from astrbot.core import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav @@ -50,7 +52,9 @@ async def initialize(self) -> None: async def get_timestamped_path(self) -> str: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - return os.path.join("data", "temp", f"{timestamp}") + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + return str(temp_dir / timestamp) async def _is_silk_file(self, file_path) -> bool: silk_header = b"SILK" diff --git a/astrbot/dashboard/utils.py b/astrbot/dashboard/utils.py index b81faad06..3a0ee5bdc 100644 --- a/astrbot/dashboard/utils.py +++ b/astrbot/dashboard/utils.py @@ -1,5 +1,4 @@ import base64 -import os import traceback from io import BytesIO @@ -51,14 +50,14 @@ async def generate_tsne_visualization( return None kb = kb_helper.kb - index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss" + index_path = kb_helper.kb_dir / "index.faiss" # 读取 FAISS 索引 - if not os.path.exists(index_path): - logger.warning(f"FAISS 索引不存在: {index_path}") + if not index_path.exists(): + logger.warning(f"FAISS 索引不存在: {index_path!s}") return None - index = faiss.read_index(index_path) + index = faiss.read_index(str(index_path)) if index.ntotal == 0: logger.warning("索引为空") diff --git a/main.py b/main.py index 055c759d1..81abdf295 100644 --- a/main.py +++ b/main.py @@ -53,6 +53,7 @@ def _run_python_compat_mode_if_needed() -> None: from astrbot.core.utils.astrbot_path import ( # noqa: E402 get_astrbot_config_path, get_astrbot_data_path, + get_astrbot_knowledge_base_path, get_astrbot_plugin_path, get_astrbot_root, get_astrbot_site_packages_path, @@ -93,6 +94,7 @@ def check_env() -> None: os.makedirs(get_astrbot_config_path(), exist_ok=True) os.makedirs(get_astrbot_plugin_path(), exist_ok=True) os.makedirs(get_astrbot_temp_path(), exist_ok=True) + os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True) os.makedirs(site_packages_path, exist_ok=True) # 针对问题 #181 的临时解决方案 From 6f6d45b7ba879dcfc30877a18dd26ebb244d00f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 09:33:14 +0900 Subject: [PATCH 05/49] fix(ci): support branch-based manual release runs --- .github/workflows/release.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1ea7d4bd..a74289429 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,11 +7,11 @@ on: workflow_dispatch: inputs: ref: - description: "Git ref to build (branch/tag/SHA)" + description: "Git ref to release from (branch/tag/SHA); leave empty to use selected branch" required: false - default: "master" + default: "" tag: - description: "Release tag to publish assets to (for example: v4.14.6)" + description: "Release tag to publish assets to (required for workflow_dispatch, for example: v4.14.6)" required: false permissions: @@ -30,7 +30,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ inputs.ref || github.ref }} + ref: ${{ inputs.ref != '' && inputs.ref || github.ref_name }} - name: Resolve tag id: tag @@ -41,7 +41,8 @@ jobs: elif [ -n "${{ inputs.tag }}" ]; then tag="${{ inputs.tag }}" else - tag="$(git describe --tags --abbrev=0)" + echo "workflow_dispatch requires 'tag' input (for example: v4.17.5)." >&2 + exit 1 fi if [ -z "$tag" ]; then echo "Failed to resolve tag." >&2 @@ -141,7 +142,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ inputs.ref || github.ref }} + ref: ${{ inputs.ref != '' && inputs.ref || github.ref_name }} - name: Resolve tag id: tag @@ -152,7 +153,8 @@ jobs: elif [ -n "${{ inputs.tag }}" ]; then tag="${{ inputs.tag }}" else - tag="$(git describe --tags --abbrev=0)" + echo "workflow_dispatch requires 'tag' input (for example: v4.17.5)." >&2 + exit 1 fi if [ -z "$tag" ]; then echo "Failed to resolve tag." >&2 @@ -272,7 +274,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ inputs.ref || github.ref }} + ref: ${{ inputs.ref != '' && inputs.ref || github.ref_name }} - name: Resolve tag id: tag @@ -283,7 +285,8 @@ jobs: elif [ -n "${{ inputs.tag }}" ]; then tag="${{ inputs.tag }}" else - tag="$(git describe --tags --abbrev=0)" + echo "workflow_dispatch requires 'tag' input (for example: v4.17.5)." >&2 + exit 1 fi if [ -z "$tag" ]; then echo "Failed to resolve tag." >&2 @@ -356,7 +359,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ inputs.ref || github.ref }} + ref: ${{ inputs.ref != '' && inputs.ref || github.ref_name }} - name: Set up Python uses: actions/setup-python@v6 From 3c7fd49683f64a28aed5bf55f4d65b0ec20cb3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 09:48:42 +0900 Subject: [PATCH 06/49] fix(desktop): harden runtime packaging and windows cleanup --- astrbot/core/utils/pip_installer.py | 5 +- desktop/README.md | 2 +- desktop/lib/backend-manager.js | 312 +++++++++++++++++----------- desktop/scripts/build-backend.mjs | 24 ++- 4 files changed, 219 insertions(+), 124 deletions(-) diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index eb852e197..184f48aef 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -7,6 +7,7 @@ import logging import os import re +import shlex import sys import threading from collections import deque @@ -438,7 +439,9 @@ async def install( wheel_only: bool = False, ) -> None: args = ["install"] - pip_install_args = self.pip_install_arg.split() if self.pip_install_arg else [] + pip_install_args = ( + shlex.split(self.pip_install_arg) if self.pip_install_arg else [] + ) requested_requirements: set[str] = set() if package_name: args.append(package_name) diff --git a/desktop/README.md b/desktop/README.md index 42f9a3eb7..b7b65e0e8 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -40,7 +40,7 @@ pnpm --dir desktop install --frozen-lockfile pnpm --dir desktop run dist:full ``` -If you are already developing in this repository, you can directly reuse the local virtual environment as runtime: +If you are already developing in this repository, you can directly reuse the local virtual environment as the runtime: ```bash uv sync diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 0ec35c887..90351317a 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -19,6 +19,7 @@ const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000; const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000; const BACKEND_LOG_FLUSH_INTERVAL_MS = 120; const BACKEND_LOG_MAX_BUFFER_BYTES = 128 * 1024; +const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 2000; function parseBackendTimeoutMs(app) { const defaultTimeoutMs = app.isPackaged ? 0 : 20000; @@ -54,7 +55,7 @@ class BackendManager { this.backendProcess = null; this.backendConfig = null; - this.packagedBackendManifest = null; + this.packagedBackendConfig = null; this.backendLogger = new BufferedRotatingLogger({ logPath: null, maxBytes: this.backendLogMaxBytes, @@ -131,34 +132,20 @@ class BackendManager { } getPackagedBackendDir() { - if (!this.app.isPackaged) { - return null; - } - return path.join(process.resourcesPath, 'backend'); + const packagedBackendConfig = this.loadPackagedBackendConfig(); + return packagedBackendConfig ? packagedBackendConfig.backendDir : null; } - getPackagedBackendManifest() { - if (!this.app.isPackaged) { - return null; - } - if (this.packagedBackendManifest) { - return this.packagedBackendManifest; - } - - const backendDir = this.getPackagedBackendDir(); - if (!backendDir) { - return null; - } + parsePackagedBackendManifest(backendDir) { const manifestPath = path.join(backendDir, 'runtime-manifest.json'); if (!fs.existsSync(manifestPath)) { return null; } - try { const raw = fs.readFileSync(manifestPath, 'utf8'); const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { - this.packagedBackendManifest = parsed; + return parsed; } } catch (error) { this.log( @@ -166,63 +153,88 @@ class BackendManager { error instanceof Error ? error.message : String(error) }`, ); - this.packagedBackendManifest = null; } + return null; + } - return this.packagedBackendManifest; + resolveManifestPath(backendDir, manifest, key, defaultRelative) { + const relativePath = + manifest && typeof manifest[key] === 'string' && manifest[key] + ? manifest[key] + : defaultRelative; + const candidate = path.join(backendDir, relativePath); + return fs.existsSync(candidate) ? candidate : null; } - getPackagedBackendAppDir() { - const backendDir = this.getPackagedBackendDir(); - if (!backendDir) { + loadPackagedBackendConfig() { + if (!this.app.isPackaged) { return null; } + if (this.packagedBackendConfig) { + return this.packagedBackendConfig; + } - const manifest = this.getPackagedBackendManifest(); - const appRelative = - manifest && typeof manifest.app === 'string' && manifest.app - ? manifest.app - : 'app'; - const candidate = path.join(backendDir, appRelative); - return fs.existsSync(candidate) ? candidate : null; - } - - getPackagedBackendLaunchScriptPath() { - const backendDir = this.getPackagedBackendDir(); - if (!backendDir) { + const backendDir = path.join(process.resourcesPath, 'backend'); + if (!fs.existsSync(backendDir)) { return null; } - const manifest = this.getPackagedBackendManifest(); - const entryRelative = - manifest && typeof manifest.entrypoint === 'string' && manifest.entrypoint - ? manifest.entrypoint - : 'launch_backend.py'; - const candidate = path.join(backendDir, entryRelative); - return fs.existsSync(candidate) ? candidate : null; + const manifest = this.parsePackagedBackendManifest(backendDir); + const manifestForPathResolve = manifest || {}; + const defaultPythonRelative = + process.platform === 'win32' + ? path.join('python', 'Scripts', 'python.exe') + : path.join('python', 'bin', 'python3'); + + this.packagedBackendConfig = Object.freeze({ + backendDir, + manifest, + appDir: this.resolveManifestPath(backendDir, manifestForPathResolve, 'app', 'app'), + launchScriptPath: this.resolveManifestPath( + backendDir, + manifestForPathResolve, + 'entrypoint', + 'launch_backend.py', + ), + runtimePythonPath: this.resolveManifestPath( + backendDir, + manifestForPathResolve, + 'python', + defaultPythonRelative, + ), + }); + + return this.packagedBackendConfig; } - getPackagedRuntimePythonPath() { - const backendDir = this.getPackagedBackendDir(); - if (!backendDir) { - return null; - } + getPackagedBackendManifest() { + const packagedBackendConfig = this.loadPackagedBackendConfig(); + return packagedBackendConfig ? packagedBackendConfig.manifest : null; + } - const manifest = this.getPackagedBackendManifest(); - const pythonRelative = - manifest && typeof manifest.python === 'string' && manifest.python - ? manifest.python - : process.platform === 'win32' - ? path.join('python', 'Scripts', 'python.exe') - : path.join('python', 'bin', 'python3'); + getPackagedBackendAppDir() { + const packagedBackendConfig = this.loadPackagedBackendConfig(); + return packagedBackendConfig ? packagedBackendConfig.appDir : null; + } - const candidate = path.join(backendDir, pythonRelative); - return fs.existsSync(candidate) ? candidate : null; + getPackagedBackendLaunchScriptPath() { + const packagedBackendConfig = this.loadPackagedBackendConfig(); + return packagedBackendConfig ? packagedBackendConfig.launchScriptPath : null; + } + + getPackagedRuntimePythonPath() { + const packagedBackendConfig = this.loadPackagedBackendConfig(); + return packagedBackendConfig ? packagedBackendConfig.runtimePythonPath : null; } buildPackagedBackendLaunch(webuiDir) { - const runtimePython = this.getPackagedRuntimePythonPath(); - const launchScript = this.getPackagedBackendLaunchScriptPath(); + const packagedBackendConfig = this.loadPackagedBackendConfig(); + if (!packagedBackendConfig) { + return null; + } + + const runtimePython = packagedBackendConfig.runtimePythonPath; + const launchScript = packagedBackendConfig.launchScriptPath; if (!runtimePython || !launchScript) { return null; } @@ -725,53 +737,73 @@ class BackendManager { ); } - getWindowsProcessCommandLine(pid) { - const numericPid = Number.parseInt(`${pid}`, 10); - if (!Number.isInteger(numericPid)) { - return null; - } - - const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${numericPid}"; if ($null -ne $p) { $p.CommandLine }`; - const result = spawnSync( - 'powershell', + queryWindowsProcessCommandLine(pid, shellName) { + const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -ne $p) { $p.CommandLine }`; + return spawnSync( + shellName, ['-NoProfile', '-NonInteractive', '-Command', query], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', windowsHide: true, + timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, }, ); - if (result.status !== 0 || !result.stdout) { + } + + parseWindowsProcessCommandLine(result) { + if (!result || !result.stdout) { return null; } - - const line = result.stdout - .split(/\r?\n/) - .map((item) => item.trim()) - .find((item) => item.length > 0); - return line || null; + return ( + result.stdout + .split(/\r?\n/) + .map((item) => item.trim()) + .find((item) => item.length > 0) || null + ); } - async stopUnmanagedBackendByPort() { - if (!this.app.isPackaged || process.platform !== 'win32') { - return false; + getWindowsProcessCommandLine(pid) { + const numericPid = Number.parseInt(`${pid}`, 10); + if (!Number.isInteger(numericPid)) { + return null; } - const port = this.getBackendPort(); - if (!port) { - return false; - } + for (const shellName of ['powershell', 'pwsh']) { + let result = null; + try { + result = this.queryWindowsProcessCommandLine(numericPid, shellName); + } catch (error) { + if (error instanceof Error && error.message) { + this.log( + `Failed to query process command line by ${shellName} for pid=${numericPid}: ${error.message}`, + ); + } + continue; + } - const pids = this.findListeningPidsOnWindows(port); - if (!pids.length) { - return false; + if (result.error && result.error.code === 'ENOENT') { + continue; + } + if (result.error && result.error.code === 'ETIMEDOUT') { + this.log( + `Timed out (${WINDOWS_PROCESS_QUERY_TIMEOUT_MS}ms) querying process command line by ${shellName} for pid=${numericPid}.`, + ); + continue; + } + + if (result.status === 0) { + const commandLine = this.parseWindowsProcessCommandLine(result); + if (commandLine) { + return commandLine; + } + } } - this.log( - `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, - ); + return null; + } - const backendConfig = this.getBackendConfig(); + buildWindowsUnmanagedBackendMatcher(backendConfig) { const expectedImageName = path.basename(backendConfig.cmd || 'python.exe').toLowerCase(); const requireStrictCommandLineCheck = this.isGenericWindowsPythonImage(expectedImageName); @@ -791,47 +823,85 @@ class BackendManager { } } + return { + expectedImageName, + requireStrictCommandLineCheck, + expectedCommandLineMarkers, + }; + } + + shouldKillUnmanagedBackendProcess(pid, processInfo, processMatcher) { + const actualImageName = processInfo.imageName.toLowerCase(); + if (actualImageName !== processMatcher.expectedImageName) { + this.log( + `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, + ); + return false; + } + + if (!processMatcher.requireStrictCommandLineCheck) { + return true; + } + if (!processMatcher.expectedCommandLineMarkers.length) { + this.log( + `Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`, + ); + return false; + } + + const commandLine = this.getWindowsProcessCommandLine(pid); + if (!commandLine) { + this.log( + `Skip unmanaged cleanup for pid=${pid}: unable to resolve process command line.`, + ); + return false; + } + + const normalizedCommandLine = this.normalizeWindowsPathForMatch(commandLine); + const markerMatched = processMatcher.expectedCommandLineMarkers.some( + (marker) => marker && normalizedCommandLine.includes(marker), + ); + if (!markerMatched) { + this.log( + `Skip unmanaged cleanup for pid=${pid}: command line does not match AstrBot backend launch marker.`, + ); + return false; + } + return true; + } + + async stopUnmanagedBackendByPort() { + if (!this.app.isPackaged || process.platform !== 'win32') { + return false; + } + + const port = this.getBackendPort(); + if (!port) { + return false; + } + + const pids = this.findListeningPidsOnWindows(port); + if (!pids.length) { + return false; + } + + this.log( + `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, + ); + + const backendConfig = this.getBackendConfig(); + const processMatcher = this.buildWindowsUnmanagedBackendMatcher(backendConfig); + for (const pid of pids) { const processInfo = this.getWindowsProcessInfo(pid); if (!processInfo) { this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`); continue; } - - const actualImageName = processInfo.imageName.toLowerCase(); - if (actualImageName !== expectedImageName) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, - ); + if (!this.shouldKillUnmanagedBackendProcess(pid, processInfo, processMatcher)) { continue; } - if (requireStrictCommandLineCheck) { - if (!expectedCommandLineMarkers.length) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`, - ); - continue; - } - const commandLine = this.getWindowsProcessCommandLine(pid); - if (!commandLine) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: unable to resolve process command line.`, - ); - continue; - } - const normalizedCommandLine = this.normalizeWindowsPathForMatch(commandLine); - const markerMatched = expectedCommandLineMarkers.some( - (marker) => marker && normalizedCommandLine.includes(marker), - ); - if (!markerMatched) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: command line does not match AstrBot backend launch marker.`, - ); - continue; - } - } - try { // Synchronous taskkill is acceptable here because unmanaged cleanup // is performed only during shutdown/restart control flows. diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index 2e00dbc9f..170f044ab 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -1,6 +1,5 @@ import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -29,6 +28,29 @@ if (!fs.existsSync(runtimeSourceReal)) { process.exit(1); } +const normalizePathForCompare = (targetPath) => { + const resolved = path.resolve(targetPath).replace(/[\\/]+$/, ''); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +}; + +const isSameOrSubPath = (targetPath, parentPath) => { + const target = normalizePathForCompare(targetPath); + const parent = normalizePathForCompare(parentPath); + return target === parent || target.startsWith(`${parent}${path.sep}`); +}; + +if ( + isSameOrSubPath(runtimeSourceReal, outputDir) || + isSameOrSubPath(outputDir, runtimeSourceReal) +) { + console.error( + `CPython runtime source overlaps with backend output directory. ` + + `runtime=${runtimeSourceReal}, output=${outputDir}. ` + + 'Please set ASTRBOT_DESKTOP_CPYTHON_HOME to a separate runtime directory.', + ); + process.exit(1); +} + const sourceEntries = [ ['astrbot', 'astrbot'], ['main.py', 'main.py'], From 07bb9d2d5d733d082127bce124ab48924a8dd099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 10:04:26 +0900 Subject: [PATCH 07/49] fix(desktop): validate runtime python and harden windows pid checks --- desktop/README.md | 2 +- desktop/lib/backend-manager.js | 73 +++++++++++++++--- desktop/scripts/build-backend.mjs | 119 ++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 10 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index b7b65e0e8..61cd51190 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -153,7 +153,7 @@ Backend build errors: - `Missing CPython runtime source`: set `ASTRBOT_DESKTOP_CPYTHON_HOME` (or `ASTRBOT_DESKTOP_BACKEND_RUNTIME`). - `Cannot find Python executable in runtime`: runtime directory is invalid or incomplete. -- `Failed to detect purelib from runtime python`: runtime Python cannot run correctly. +- `Failed to detect purelib from runtime Python`: runtime Python cannot run correctly. If Electron download times out on restricted networks, configure mirrors before install: diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 90351317a..917a41a80 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -20,6 +20,7 @@ const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000; const BACKEND_LOG_FLUSH_INTERVAL_MS = 120; const BACKEND_LOG_MAX_BUFFER_BYTES = 128 * 1024; const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 2000; +const WINDOWS_PROCESS_COMMAND_LINE_CACHE_TTL_MS = 5000; function parseBackendTimeoutMs(app) { const defaultTimeoutMs = app.isPackaged ? 0 : 20000; @@ -56,6 +57,7 @@ class BackendManager { this.backendProcess = null; this.backendConfig = null; this.packagedBackendConfig = null; + this.windowsProcessCommandLineCache = new Map(); this.backendLogger = new BufferedRotatingLogger({ logPath: null, maxBytes: this.backendLogMaxBytes, @@ -737,10 +739,24 @@ class BackendManager { ); } - queryWindowsProcessCommandLine(pid, shellName) { + queryWindowsProcessCommandLineByPowerShell(pid) { const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -ne $p) { $p.CommandLine }`; return spawnSync( - shellName, + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', query], + { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + windowsHide: true, + timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, + }, + ); + } + + queryWindowsProcessCommandLineByPwsh(pid) { + const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -ne $p) { $p.CommandLine }`; + return spawnSync( + 'pwsh', ['-NoProfile', '-NonInteractive', '-Command', query], { stdio: ['ignore', 'pipe', 'ignore'], @@ -763,20 +779,52 @@ class BackendManager { ); } + pruneWindowsProcessCommandLineCache() { + const now = Date.now(); + for (const [pid, cached] of this.windowsProcessCommandLineCache.entries()) { + if ( + !cached || + now - cached.timestampMs > WINDOWS_PROCESS_COMMAND_LINE_CACHE_TTL_MS + ) { + this.windowsProcessCommandLineCache.delete(pid); + } + } + } + getWindowsProcessCommandLine(pid) { const numericPid = Number.parseInt(`${pid}`, 10); if (!Number.isInteger(numericPid)) { return null; } - for (const shellName of ['powershell', 'pwsh']) { + const cached = this.windowsProcessCommandLineCache.get(numericPid); + if ( + cached && + Date.now() - cached.timestampMs <= WINDOWS_PROCESS_COMMAND_LINE_CACHE_TTL_MS + ) { + return cached.commandLine; + } + this.windowsProcessCommandLineCache.delete(numericPid); + + const queryAttempts = [ + { + shellName: 'powershell', + run: () => this.queryWindowsProcessCommandLineByPowerShell(numericPid), + }, + { + shellName: 'pwsh', + run: () => this.queryWindowsProcessCommandLineByPwsh(numericPid), + }, + ]; + + for (const queryAttempt of queryAttempts) { let result = null; try { - result = this.queryWindowsProcessCommandLine(numericPid, shellName); + result = queryAttempt.run(); } catch (error) { if (error instanceof Error && error.message) { this.log( - `Failed to query process command line by ${shellName} for pid=${numericPid}: ${error.message}`, + `Failed to query process command line by ${queryAttempt.shellName} for pid=${numericPid}: ${error.message}`, ); } continue; @@ -787,19 +835,25 @@ class BackendManager { } if (result.error && result.error.code === 'ETIMEDOUT') { this.log( - `Timed out (${WINDOWS_PROCESS_QUERY_TIMEOUT_MS}ms) querying process command line by ${shellName} for pid=${numericPid}.`, + `Timed out (${WINDOWS_PROCESS_QUERY_TIMEOUT_MS}ms) querying process command line by ${queryAttempt.shellName} for pid=${numericPid}.`, ); continue; } if (result.status === 0) { const commandLine = this.parseWindowsProcessCommandLine(result); - if (commandLine) { - return commandLine; - } + this.windowsProcessCommandLineCache.set(numericPid, { + commandLine, + timestampMs: Date.now(), + }); + return commandLine; } } + this.windowsProcessCommandLineCache.set(numericPid, { + commandLine: null, + timestampMs: Date.now(), + }); return null; } @@ -874,6 +928,7 @@ class BackendManager { if (!this.app.isPackaged || process.platform !== 'win32') { return false; } + this.pruneWindowsProcessCommandLineCache(); const port = this.getBackendPort(); if (!port) { diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index 170f044ab..6cc0471e3 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -51,6 +52,60 @@ if ( process.exit(1); } +const parseExpectedRuntimeVersion = (rawVersion, sourceName) => { + const match = /^(\d+)\.(\d+)$/.exec(String(rawVersion).trim()); + if (!match) { + console.error( + `Invalid expected Python version from ${sourceName}: ${rawVersion}. ` + + 'Expected format ..', + ); + process.exit(1); + } + return { + major: Number.parseInt(match[1], 10), + minor: Number.parseInt(match[2], 10), + }; +}; + +const readProjectRequiresPythonLowerBound = () => { + const pyprojectPath = path.join(rootDir, 'pyproject.toml'); + if (!fs.existsSync(pyprojectPath)) { + return null; + } + const content = fs.readFileSync(pyprojectPath, 'utf8'); + const requiresPythonMatch = /^\s*requires-python\s*=\s*"([^"]+)"/m.exec(content); + if (!requiresPythonMatch) { + return null; + } + const lowerBoundMatch = />=\s*(\d+)\.(\d+)/.exec(requiresPythonMatch[1]); + if (!lowerBoundMatch) { + return null; + } + return `${lowerBoundMatch[1]}.${lowerBoundMatch[2]}`; +}; + +const resolveExpectedRuntimeVersion = () => { + if (process.env.ASTRBOT_DESKTOP_EXPECTED_PYTHON) { + return parseExpectedRuntimeVersion( + process.env.ASTRBOT_DESKTOP_EXPECTED_PYTHON, + 'ASTRBOT_DESKTOP_EXPECTED_PYTHON', + ); + } + + const projectLowerBound = readProjectRequiresPythonLowerBound(); + if (projectLowerBound) { + return parseExpectedRuntimeVersion(projectLowerBound, 'pyproject.toml requires-python'); + } + + console.error( + 'Unable to determine expected runtime Python version. ' + + 'Set ASTRBOT_DESKTOP_EXPECTED_PYTHON or declare project.requires-python in pyproject.toml.', + ); + process.exit(1); +}; + +const expectedRuntimeVersion = resolveExpectedRuntimeVersion(); + const sourceEntries = [ ['astrbot', 'astrbot'], ['main.py', 'main.py'], @@ -100,6 +155,60 @@ const resolveRuntimePython = (runtimeRoot) => { return null; }; +const validateRuntimePython = (pythonExecutable) => { + const probe = spawnSync( + pythonExecutable, + ['-c', 'import sys, pip; print(f"{sys.version_info.major}.{sys.version_info.minor}")'], + { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + windowsHide: true, + timeout: 5000, + }, + ); + + if (probe.error) { + const reason = + probe.error.code === 'ETIMEDOUT' + ? 'runtime Python probe timed out' + : probe.error.message || String(probe.error); + console.error(`Runtime Python probe failed: ${reason}`); + process.exit(1); + } + + if (probe.status !== 0) { + const stderrText = (probe.stderr || '').trim(); + console.error( + `Runtime Python probe failed with exit code ${probe.status}. ` + + (stderrText ? `stderr: ${stderrText}` : ''), + ); + process.exit(1); + } + + const versionMatch = /(\d+)\.(\d+)/.exec((probe.stdout || '').trim()); + if (!versionMatch) { + console.error( + `Runtime Python probe did not report a valid version. Output: ${(probe.stdout || '').trim()}`, + ); + process.exit(1); + } + + const actualVersion = { + major: Number.parseInt(versionMatch[1], 10), + minor: Number.parseInt(versionMatch[2], 10), + }; + if ( + actualVersion.major !== expectedRuntimeVersion.major || + actualVersion.minor !== expectedRuntimeVersion.minor + ) { + console.error( + `Runtime Python version mismatch: expected ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + + `got ${actualVersion.major}.${actualVersion.minor}.`, + ); + process.exit(1); + } +}; + const writeLauncherScript = () => { const content = `from __future__ import annotations @@ -122,6 +231,16 @@ runpy.run_path(str(main_file), run_name="__main__") fs.writeFileSync(launcherPath, content, 'utf8'); }; +const sourceRuntimePython = resolveRuntimePython(runtimeSourceReal); +if (!sourceRuntimePython) { + console.error( + `Cannot find Python executable in runtime source: ${runtimeSourceReal}. ` + + 'Expected python under bin/ or Scripts/.', + ); + process.exit(1); +} +validateRuntimePython(sourceRuntimePython.absolute); + fs.rmSync(outputDir, { recursive: true, force: true }); fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(appDir, { recursive: true }); From a858939cd2d414d17189226ea1bd34d548f6ef07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 10:26:38 +0900 Subject: [PATCH 08/49] fix(desktop): harden cleanup fallback and runtime version detection --- astrbot/core/utils/pip_installer.py | 14 +- desktop/lib/backend-manager.js | 45 ++++- desktop/scripts/build-backend.mjs | 298 +++++++++++++++++++--------- 3 files changed, 259 insertions(+), 98 deletions(-) diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 184f48aef..e79059227 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -439,9 +439,17 @@ async def install( wheel_only: bool = False, ) -> None: args = ["install"] - pip_install_args = ( - shlex.split(self.pip_install_arg) if self.pip_install_arg else [] - ) + pip_install_args: list[str] = [] + if self.pip_install_arg: + try: + pip_install_args = shlex.split(self.pip_install_arg) + except ValueError as exc: + logger.warning( + "Failed to parse pip_install_arg with shlex (%s). " + "Falling back to legacy whitespace split.", + exc, + ) + pip_install_args = self.pip_install_arg.split() requested_requirements: set[str] = set() if package_name: args.append(package_name) diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 917a41a80..e64c9a37d 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -858,16 +858,20 @@ class BackendManager { } buildWindowsUnmanagedBackendMatcher(backendConfig) { - const expectedImageName = path.basename(backendConfig.cmd || 'python.exe').toLowerCase(); + const safeBackendConfig = + backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; + const expectedImageName = path + .basename(safeBackendConfig.cmd || 'python.exe') + .toLowerCase(); const requireStrictCommandLineCheck = this.isGenericWindowsPythonImage(expectedImageName); const expectedCommandLineMarkers = []; - if (Array.isArray(backendConfig.args) && backendConfig.args.length > 0) { - const primaryArg = backendConfig.args[0]; + if (Array.isArray(safeBackendConfig.args) && safeBackendConfig.args.length > 0) { + const primaryArg = safeBackendConfig.args[0]; if (typeof primaryArg === 'string' && primaryArg) { const resolvedPrimaryArg = path.isAbsolute(primaryArg) ? primaryArg - : path.resolve(backendConfig.cwd || process.cwd(), primaryArg); + : path.resolve(safeBackendConfig.cwd || process.cwd(), primaryArg); expectedCommandLineMarkers.push( this.normalizeWindowsPathForMatch(resolvedPrimaryArg), ); @@ -884,6 +888,18 @@ class BackendManager { }; } + buildFallbackWindowsUnmanagedBackendMatcher() { + const fallbackCmdRaw = process.env.ASTRBOT_BACKEND_CMD || 'python.exe'; + const fallbackCmd = String(fallbackCmdRaw).trim().split(/\s+/, 1)[0] || 'python.exe'; + return { + expectedImageName: path.basename(fallbackCmd).toLowerCase(), + // Fallback mode only checks image name to avoid false negatives + // when backend config is unavailable in current session. + requireStrictCommandLineCheck: false, + expectedCommandLineMarkers: [], + }; + } + shouldKillUnmanagedBackendProcess(pid, processInfo, processMatcher) { const actualImageName = processInfo.imageName.toLowerCase(); if (actualImageName !== processMatcher.expectedImageName) { @@ -944,8 +960,25 @@ class BackendManager { `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, ); - const backendConfig = this.getBackendConfig(); - const processMatcher = this.buildWindowsUnmanagedBackendMatcher(backendConfig); + let backendConfig = null; + try { + backendConfig = this.getBackendConfig(); + } catch (error) { + this.log( + `Failed to resolve backend config during unmanaged cleanup: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + const hasBackendConfig = backendConfig && typeof backendConfig === 'object'; + const processMatcher = hasBackendConfig + ? this.buildWindowsUnmanagedBackendMatcher(backendConfig) + : this.buildFallbackWindowsUnmanagedBackendMatcher(); + if (!hasBackendConfig) { + this.log( + 'Backend config is unavailable during unmanaged cleanup; falling back to image-name-only matching.', + ); + } for (const pid of pids) { const processInfo = this.getWindowsProcessInfo(pid); diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index 6cc0471e3..78159bbd0 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -15,19 +15,9 @@ const runtimeSource = process.env.ASTRBOT_DESKTOP_CPYTHON_HOME || process.env.ASTRBOT_DESKTOP_BACKEND_RUNTIME; -if (!runtimeSource) { - console.error( - 'Missing CPython runtime source. Set ASTRBOT_DESKTOP_CPYTHON_HOME ' + - '(recommended) or ASTRBOT_DESKTOP_BACKEND_RUNTIME.', - ); - process.exit(1); -} - -const runtimeSourceReal = path.resolve(rootDir, runtimeSource); -if (!fs.existsSync(runtimeSourceReal)) { - console.error(`CPython runtime source does not exist: ${runtimeSourceReal}`); - process.exit(1); -} +const fail = (message) => { + return new Error(message); +}; const normalizePathForCompare = (targetPath) => { const resolved = path.resolve(targetPath).replace(/[\\/]+$/, ''); @@ -40,26 +30,40 @@ const isSameOrSubPath = (targetPath, parentPath) => { return target === parent || target.startsWith(`${parent}${path.sep}`); }; -if ( - isSameOrSubPath(runtimeSourceReal, outputDir) || - isSameOrSubPath(outputDir, runtimeSourceReal) -) { - console.error( - `CPython runtime source overlaps with backend output directory. ` + - `runtime=${runtimeSourceReal}, output=${outputDir}. ` + - 'Please set ASTRBOT_DESKTOP_CPYTHON_HOME to a separate runtime directory.', - ); - process.exit(1); -} +const resolveAndValidateRuntimeSource = () => { + if (!runtimeSource) { + throw fail( + 'Missing CPython runtime source. Set ASTRBOT_DESKTOP_CPYTHON_HOME ' + + '(recommended) or ASTRBOT_DESKTOP_BACKEND_RUNTIME.', + ); + } + + const runtimeSourceReal = path.resolve(rootDir, runtimeSource); + if (!fs.existsSync(runtimeSourceReal)) { + throw fail(`CPython runtime source does not exist: ${runtimeSourceReal}`); + } + + if ( + isSameOrSubPath(runtimeSourceReal, outputDir) || + isSameOrSubPath(outputDir, runtimeSourceReal) + ) { + throw fail( + `CPython runtime source overlaps with backend output directory. ` + + `runtime=${runtimeSourceReal}, output=${outputDir}. ` + + 'Please set ASTRBOT_DESKTOP_CPYTHON_HOME to a separate runtime directory.', + ); + } + + return runtimeSourceReal; +}; const parseExpectedRuntimeVersion = (rawVersion, sourceName) => { const match = /^(\d+)\.(\d+)$/.exec(String(rawVersion).trim()); if (!match) { - console.error( + throw fail( `Invalid expected Python version from ${sourceName}: ${rawVersion}. ` + 'Expected format ..', ); - process.exit(1); } return { major: Number.parseInt(match[1], 10), @@ -67,21 +71,135 @@ const parseExpectedRuntimeVersion = (rawVersion, sourceName) => { }; }; -const readProjectRequiresPythonLowerBound = () => { - const pyprojectPath = path.join(rootDir, 'pyproject.toml'); - if (!fs.existsSync(pyprojectPath)) { +const extractLowerBoundFromPythonSpecifier = (rawSpecifier) => { + if (typeof rawSpecifier !== 'string' || !rawSpecifier.trim()) { return null; } - const content = fs.readFileSync(pyprojectPath, 'utf8'); - const requiresPythonMatch = /^\s*requires-python\s*=\s*"([^"]+)"/m.exec(content); - if (!requiresPythonMatch) { + + const clauses = rawSpecifier.replace(/\s+/g, '').split(',').filter(Boolean); + let bestLowerBound = null; + + const updateLowerBound = (major, minor) => { + if ( + !bestLowerBound || + major > bestLowerBound.major || + (major === bestLowerBound.major && minor > bestLowerBound.minor) + ) { + bestLowerBound = { major, minor }; + } + }; + + for (const clause of clauses) { + const match = /^(>=|>|==|~=)(\d+)(?:\.(\d+))?/.exec(clause); + if (!match) { + continue; + } + + const operator = match[1]; + let major = Number.parseInt(match[2], 10); + let minor = Number.parseInt(match[3] || '0', 10); + if (operator === '>') { + if (match[3]) { + minor += 1; + } else { + major += 1; + minor = 0; + } + } + updateLowerBound(major, minor); + } + + if (!bestLowerBound) { return null; } - const lowerBoundMatch = />=\s*(\d+)\.(\d+)/.exec(requiresPythonMatch[1]); - if (!lowerBoundMatch) { + return `${bestLowerBound.major}.${bestLowerBound.minor}`; +}; + +const parsePyprojectProbeOutput = (stdoutText) => { + const lines = String(stdoutText || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + for (let i = lines.length - 1; i >= 0; i -= 1) { + try { + const parsed = JSON.parse(lines[i]); + if (parsed && typeof parsed === 'object') { + return parsed; + } + } catch {} + } + return null; +}; + +const readProjectRequiresPythonLowerBound = () => { + const pyprojectPath = path.join(rootDir, 'pyproject.toml'); + if (!fs.existsSync(pyprojectPath)) { return null; } - return `${lowerBoundMatch[1]}.${lowerBoundMatch[2]}`; + + const pyprojectProbeScript = `import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +try: + import tomllib +except Exception: + try: + import tomli as tomllib + except Exception: + print(json.dumps({"requires_python": None})) + raise SystemExit(0) + +try: + data = tomllib.loads(path.read_text(encoding="utf-8")) +except Exception: + print(json.dumps({"requires_python": None})) + raise SystemExit(0) + +project = data.get("project") if isinstance(data, dict) else None +requires_python = project.get("requires-python") if isinstance(project, dict) else None +print(json.dumps({"requires_python": requires_python})) +`; + + const probeCommands = + process.platform === 'win32' + ? [ + { cmd: 'python', prefixArgs: [] }, + { cmd: 'py', prefixArgs: ['-3'] }, + ] + : [ + { cmd: 'python3', prefixArgs: [] }, + { cmd: 'python', prefixArgs: [] }, + ]; + + for (const probeCommand of probeCommands) { + const probe = spawnSync( + probeCommand.cmd, + [...probeCommand.prefixArgs, '-c', pyprojectProbeScript, pyprojectPath], + { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + windowsHide: true, + timeout: 5000, + }, + ); + if (probe.error && probe.error.code === 'ENOENT') { + continue; + } + if (probe.error || probe.status !== 0) { + continue; + } + + const parsedOutput = parsePyprojectProbeOutput(probe.stdout); + const requiresPythonSpecifier = parsedOutput?.requires_python; + const lowerBound = extractLowerBoundFromPythonSpecifier(requiresPythonSpecifier); + if (lowerBound) { + return lowerBound; + } + } + + return null; }; const resolveExpectedRuntimeVersion = () => { @@ -97,15 +215,12 @@ const resolveExpectedRuntimeVersion = () => { return parseExpectedRuntimeVersion(projectLowerBound, 'pyproject.toml requires-python'); } - console.error( + throw fail( 'Unable to determine expected runtime Python version. ' + 'Set ASTRBOT_DESKTOP_EXPECTED_PYTHON or declare project.requires-python in pyproject.toml.', ); - process.exit(1); }; -const expectedRuntimeVersion = resolveExpectedRuntimeVersion(); - const sourceEntries = [ ['astrbot', 'astrbot'], ['main.py', 'main.py'], @@ -155,7 +270,7 @@ const resolveRuntimePython = (runtimeRoot) => { return null; }; -const validateRuntimePython = (pythonExecutable) => { +const validateRuntimePython = (pythonExecutable, expectedRuntimeVersion) => { const probe = spawnSync( pythonExecutable, ['-c', 'import sys, pip; print(f"{sys.version_info.major}.{sys.version_info.minor}")'], @@ -172,25 +287,22 @@ const validateRuntimePython = (pythonExecutable) => { probe.error.code === 'ETIMEDOUT' ? 'runtime Python probe timed out' : probe.error.message || String(probe.error); - console.error(`Runtime Python probe failed: ${reason}`); - process.exit(1); + throw fail(`Runtime Python probe failed: ${reason}`); } if (probe.status !== 0) { const stderrText = (probe.stderr || '').trim(); - console.error( + throw fail( `Runtime Python probe failed with exit code ${probe.status}. ` + (stderrText ? `stderr: ${stderrText}` : ''), ); - process.exit(1); } const versionMatch = /(\d+)\.(\d+)/.exec((probe.stdout || '').trim()); if (!versionMatch) { - console.error( + throw fail( `Runtime Python probe did not report a valid version. Output: ${(probe.stdout || '').trim()}`, ); - process.exit(1); } const actualVersion = { @@ -201,11 +313,10 @@ const validateRuntimePython = (pythonExecutable) => { actualVersion.major !== expectedRuntimeVersion.major || actualVersion.minor !== expectedRuntimeVersion.minor ) { - console.error( + throw fail( `Runtime Python version mismatch: expected ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + `got ${actualVersion.major}.${actualVersion.minor}.`, ); - process.exit(1); } }; @@ -231,51 +342,60 @@ runpy.run_path(str(main_file), run_name="__main__") fs.writeFileSync(launcherPath, content, 'utf8'); }; -const sourceRuntimePython = resolveRuntimePython(runtimeSourceReal); -if (!sourceRuntimePython) { - console.error( - `Cannot find Python executable in runtime source: ${runtimeSourceReal}. ` + - 'Expected python under bin/ or Scripts/.', - ); - process.exit(1); -} -validateRuntimePython(sourceRuntimePython.absolute); - -fs.rmSync(outputDir, { recursive: true, force: true }); -fs.mkdirSync(outputDir, { recursive: true }); -fs.mkdirSync(appDir, { recursive: true }); - -for (const [srcRelative, destRelative] of sourceEntries) { - const sourcePath = path.join(rootDir, srcRelative); - const targetPath = path.join(appDir, destRelative); - if (!fs.existsSync(sourcePath)) { - console.error(`Backend source path does not exist: ${sourcePath}`); - process.exit(1); +const main = () => { + const runtimeSourceReal = resolveAndValidateRuntimeSource(); + const expectedRuntimeVersion = resolveExpectedRuntimeVersion(); + + const sourceRuntimePython = resolveRuntimePython(runtimeSourceReal); + if (!sourceRuntimePython) { + throw fail( + `Cannot find Python executable in runtime source: ${runtimeSourceReal}. ` + + 'Expected python under bin/ or Scripts/.', + ); } - copyTree(sourcePath, targetPath); -} + validateRuntimePython(sourceRuntimePython.absolute, expectedRuntimeVersion); -copyTree(runtimeSourceReal, runtimeDir, { dereference: true }); + fs.rmSync(outputDir, { recursive: true, force: true }); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(appDir, { recursive: true }); -const runtimePython = resolveRuntimePython(runtimeDir); -if (!runtimePython) { - console.error( - `Cannot find Python executable in runtime: ${runtimeDir}. ` + - 'Expected python under bin/ or Scripts/.', - ); - process.exit(1); -} + for (const [srcRelative, destRelative] of sourceEntries) { + const sourcePath = path.join(rootDir, srcRelative); + const targetPath = path.join(appDir, destRelative); + if (!fs.existsSync(sourcePath)) { + throw fail(`Backend source path does not exist: ${sourcePath}`); + } + copyTree(sourcePath, targetPath); + } + + copyTree(runtimeSourceReal, runtimeDir, { dereference: true }); + + const runtimePython = resolveRuntimePython(runtimeDir); + if (!runtimePython) { + throw fail( + `Cannot find Python executable in runtime: ${runtimeDir}. ` + + 'Expected python under bin/ or Scripts/.', + ); + } -writeLauncherScript(); + writeLauncherScript(); -const manifest = { - mode: 'cpython-runtime', - python: runtimePython.relative, - entrypoint: path.basename(launcherPath), - app: path.relative(outputDir, appDir), + const manifest = { + mode: 'cpython-runtime', + python: runtimePython.relative, + entrypoint: path.basename(launcherPath), + app: path.relative(outputDir, appDir), + }; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); + + console.log(`Prepared CPython backend runtime in ${outputDir}`); + console.log(`Runtime source: ${runtimeSourceReal}`); + console.log(`Python executable: ${runtimePython.relative}`); }; -fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); -console.log(`Prepared CPython backend runtime in ${outputDir}`); -console.log(`Runtime source: ${runtimeSourceReal}`); -console.log(`Python executable: ${runtimePython.relative}`); +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} From fe94a0ab330a75872b04376c5fe170b118d9c9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 10:46:25 +0900 Subject: [PATCH 09/49] fix(desktop): improve runtime validation and backend cleanup --- astrbot/core/utils/pip_installer.py | 14 ----- desktop/lib/backend-manager.js | 76 ++++++++++++-------------- desktop/scripts/build-backend.mjs | 85 +++++++++++++++++++++++------ 3 files changed, 102 insertions(+), 73 deletions(-) diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index e79059227..fc1819f40 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -436,7 +436,6 @@ async def install( package_name: str | None = None, requirements_path: str | None = None, mirror: str | None = None, - wheel_only: bool = False, ) -> None: args = ["install"] pip_install_args: list[str] = [] @@ -473,24 +472,11 @@ async def install( if pip_install_args: args.extend(pip_install_args) - if wheel_only: - if not any( - token == "--only-binary" or token.startswith("--only-binary=") - for token in args - ): - args.extend(["--only-binary", ":all:"]) - if "--prefer-binary" not in args: - args.append("--prefer-binary") logger.info(f"Pip 包管理器: pip {' '.join(args)}") result_code = await self._run_pip_in_process(args) if result_code != 0: - if wheel_only: - raise Exception( - "安装失败:插件依赖 wheel-only 检测未通过或依赖安装失败," - "请检查是否存在无可用 wheel 的依赖。" - ) raise Exception(f"安装失败,错误码:{result_code}") if target_site_packages: diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index e64c9a37d..c7f936ffc 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -857,14 +857,35 @@ class BackendManager { return null; } - buildWindowsUnmanagedBackendMatcher(backendConfig) { + getFallbackWindowsBackendImageName() { + const fallbackCmdRaw = process.env.ASTRBOT_BACKEND_CMD || 'python.exe'; + const fallbackCmd = String(fallbackCmdRaw).trim().split(/\s+/, 1)[0] || 'python.exe'; + return path.basename(fallbackCmd).toLowerCase(); + } + + shouldKillUnmanagedBackendProcess( + pid, + processInfo, + backendConfig, + allowImageOnlyMatch = false, + ) { const safeBackendConfig = backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; const expectedImageName = path - .basename(safeBackendConfig.cmd || 'python.exe') + .basename(safeBackendConfig.cmd || this.getFallbackWindowsBackendImageName()) .toLowerCase(); - const requireStrictCommandLineCheck = - this.isGenericWindowsPythonImage(expectedImageName); + const actualImageName = processInfo.imageName.toLowerCase(); + if (actualImageName !== expectedImageName) { + this.log( + `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, + ); + return false; + } + + if (allowImageOnlyMatch || !this.isGenericWindowsPythonImage(expectedImageName)) { + return true; + } + const expectedCommandLineMarkers = []; if (Array.isArray(safeBackendConfig.args) && safeBackendConfig.args.length > 0) { const primaryArg = safeBackendConfig.args[0]; @@ -881,38 +902,7 @@ class BackendManager { } } - return { - expectedImageName, - requireStrictCommandLineCheck, - expectedCommandLineMarkers, - }; - } - - buildFallbackWindowsUnmanagedBackendMatcher() { - const fallbackCmdRaw = process.env.ASTRBOT_BACKEND_CMD || 'python.exe'; - const fallbackCmd = String(fallbackCmdRaw).trim().split(/\s+/, 1)[0] || 'python.exe'; - return { - expectedImageName: path.basename(fallbackCmd).toLowerCase(), - // Fallback mode only checks image name to avoid false negatives - // when backend config is unavailable in current session. - requireStrictCommandLineCheck: false, - expectedCommandLineMarkers: [], - }; - } - - shouldKillUnmanagedBackendProcess(pid, processInfo, processMatcher) { - const actualImageName = processInfo.imageName.toLowerCase(); - if (actualImageName !== processMatcher.expectedImageName) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, - ); - return false; - } - - if (!processMatcher.requireStrictCommandLineCheck) { - return true; - } - if (!processMatcher.expectedCommandLineMarkers.length) { + if (!expectedCommandLineMarkers.length) { this.log( `Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`, ); @@ -928,7 +918,7 @@ class BackendManager { } const normalizedCommandLine = this.normalizeWindowsPathForMatch(commandLine); - const markerMatched = processMatcher.expectedCommandLineMarkers.some( + const markerMatched = expectedCommandLineMarkers.some( (marker) => marker && normalizedCommandLine.includes(marker), ); if (!markerMatched) { @@ -971,9 +961,6 @@ class BackendManager { ); } const hasBackendConfig = backendConfig && typeof backendConfig === 'object'; - const processMatcher = hasBackendConfig - ? this.buildWindowsUnmanagedBackendMatcher(backendConfig) - : this.buildFallbackWindowsUnmanagedBackendMatcher(); if (!hasBackendConfig) { this.log( 'Backend config is unavailable during unmanaged cleanup; falling back to image-name-only matching.', @@ -986,7 +973,14 @@ class BackendManager { this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`); continue; } - if (!this.shouldKillUnmanagedBackendProcess(pid, processInfo, processMatcher)) { + if ( + !this.shouldKillUnmanagedBackendProcess( + pid, + processInfo, + backendConfig, + !hasBackendConfig, + ) + ) { continue; } diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index 78159bbd0..d391e90ff 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -14,6 +14,7 @@ const launcherPath = path.join(outputDir, 'launch_backend.py'); const runtimeSource = process.env.ASTRBOT_DESKTOP_CPYTHON_HOME || process.env.ASTRBOT_DESKTOP_BACKEND_RUNTIME; +const requirePipProbe = process.env.ASTRBOT_DESKTOP_REQUIRE_PIP === '1'; const fail = (message) => { return new Error(message); @@ -204,15 +205,26 @@ print(json.dumps({"requires_python": requires_python})) const resolveExpectedRuntimeVersion = () => { if (process.env.ASTRBOT_DESKTOP_EXPECTED_PYTHON) { - return parseExpectedRuntimeVersion( - process.env.ASTRBOT_DESKTOP_EXPECTED_PYTHON, - 'ASTRBOT_DESKTOP_EXPECTED_PYTHON', - ); + return { + expectedRuntimeVersion: parseExpectedRuntimeVersion( + process.env.ASTRBOT_DESKTOP_EXPECTED_PYTHON, + 'ASTRBOT_DESKTOP_EXPECTED_PYTHON', + ), + isLowerBoundRuntimeVersion: false, + source: 'ASTRBOT_DESKTOP_EXPECTED_PYTHON', + }; } const projectLowerBound = readProjectRequiresPythonLowerBound(); if (projectLowerBound) { - return parseExpectedRuntimeVersion(projectLowerBound, 'pyproject.toml requires-python'); + return { + expectedRuntimeVersion: parseExpectedRuntimeVersion( + projectLowerBound, + 'pyproject.toml requires-python', + ), + isLowerBoundRuntimeVersion: true, + source: 'pyproject.toml requires-python', + }; } throw fail( @@ -221,6 +233,22 @@ const resolveExpectedRuntimeVersion = () => { ); }; +const compareMajorMinor = (left, right) => { + if (left.major < right.major) { + return -1; + } + if (left.major > right.major) { + return 1; + } + if (left.minor < right.minor) { + return -1; + } + if (left.minor > right.minor) { + return 1; + } + return 0; +}; + const sourceEntries = [ ['astrbot', 'astrbot'], ['main.py', 'main.py'], @@ -270,10 +298,13 @@ const resolveRuntimePython = (runtimeRoot) => { return null; }; -const validateRuntimePython = (pythonExecutable, expectedRuntimeVersion) => { +const validateRuntimePython = (pythonExecutable, expectedRuntimeConstraint) => { + const probeScript = requirePipProbe + ? 'import sys, pip; print(sys.version_info[0], sys.version_info[1])' + : 'import sys; print(sys.version_info[0], sys.version_info[1])'; const probe = spawnSync( pythonExecutable, - ['-c', 'import sys, pip; print(f"{sys.version_info.major}.{sys.version_info.minor}")'], + ['-c', probeScript], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', @@ -292,29 +323,47 @@ const validateRuntimePython = (pythonExecutable, expectedRuntimeVersion) => { if (probe.status !== 0) { const stderrText = (probe.stderr || '').trim(); + if (requirePipProbe) { + throw fail( + `Runtime Python probe failed with exit code ${probe.status}. ` + + `pip import check is enabled by ASTRBOT_DESKTOP_REQUIRE_PIP=1. ` + + (stderrText ? `stderr: ${stderrText}` : ''), + ); + } throw fail( `Runtime Python probe failed with exit code ${probe.status}. ` + (stderrText ? `stderr: ${stderrText}` : ''), ); } - const versionMatch = /(\d+)\.(\d+)/.exec((probe.stdout || '').trim()); - if (!versionMatch) { + const parts = (probe.stdout || '').trim().split(/\s+/); + if (parts.length < 2) { throw fail( `Runtime Python probe did not report a valid version. Output: ${(probe.stdout || '').trim()}`, ); } const actualVersion = { - major: Number.parseInt(versionMatch[1], 10), - minor: Number.parseInt(versionMatch[2], 10), + major: Number.parseInt(parts[0], 10), + minor: Number.parseInt(parts[1], 10), }; - if ( - actualVersion.major !== expectedRuntimeVersion.major || - actualVersion.minor !== expectedRuntimeVersion.minor - ) { + const expectedRuntimeVersion = expectedRuntimeConstraint.expectedRuntimeVersion; + const compareResult = compareMajorMinor(actualVersion, expectedRuntimeVersion); + if (expectedRuntimeConstraint.isLowerBoundRuntimeVersion) { + if (compareResult < 0) { + throw fail( + `Runtime Python version is too low for ${expectedRuntimeConstraint.source}: ` + + `expected >= ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + + `got ${actualVersion.major}.${actualVersion.minor}.`, + ); + } + return; + } + + if (compareResult !== 0) { throw fail( - `Runtime Python version mismatch: expected ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + + `Runtime Python version mismatch for ${expectedRuntimeConstraint.source}: ` + + `expected ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + `got ${actualVersion.major}.${actualVersion.minor}.`, ); } @@ -344,7 +393,7 @@ runpy.run_path(str(main_file), run_name="__main__") const main = () => { const runtimeSourceReal = resolveAndValidateRuntimeSource(); - const expectedRuntimeVersion = resolveExpectedRuntimeVersion(); + const expectedRuntimeConstraint = resolveExpectedRuntimeVersion(); const sourceRuntimePython = resolveRuntimePython(runtimeSourceReal); if (!sourceRuntimePython) { @@ -353,7 +402,7 @@ const main = () => { 'Expected python under bin/ or Scripts/.', ); } - validateRuntimePython(sourceRuntimePython.absolute, expectedRuntimeVersion); + validateRuntimePython(sourceRuntimePython.absolute, expectedRuntimeConstraint); fs.rmSync(outputDir, { recursive: true, force: true }); fs.mkdirSync(outputDir, { recursive: true }); From c6c238ecd98b1ca771e38c902f826db2a7f58cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 11:16:12 +0900 Subject: [PATCH 10/49] fix(desktop): refactor packaged backend runtime resolution --- desktop/README.md | 2 +- desktop/lib/backend-manager.js | 283 ++++++++++++++---------------- desktop/scripts/build-backend.mjs | 71 ++++---- 3 files changed, 166 insertions(+), 190 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 61cd51190..490b7e555 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -70,7 +70,7 @@ pnpm --dir desktop run dev - `dist:full` runs WebUI build + backend runtime packaging + Electron packaging. - In packaged app mode, backend data root defaults to `~/.astrbot` (can be overridden by `ASTRBOT_ROOT`). -- Backend build requires `ASTRBOT_DESKTOP_CPYTHON_HOME` (or `ASTRBOT_DESKTOP_BACKEND_RUNTIME`) to point to a CPython runtime directory. +- Backend build requires a CPython runtime directory via `ASTRBOT_DESKTOP_BACKEND_RUNTIME` or `ASTRBOT_DESKTOP_CPYTHON_HOME`; if both are set, `ASTRBOT_DESKTOP_BACKEND_RUNTIME` takes precedence. ## Packaged Backend Layout diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index c7f936ffc..ea2f207b7 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -118,7 +118,8 @@ class BackendManager { if (!this.app.isPackaged) { return path.resolve(this.baseDir, '..'); } - return this.getPackagedBackendAppDir() || this.resolveBackendRoot(); + const packagedBackendConfig = this.getPackagedBackendConfig(); + return (packagedBackendConfig && packagedBackendConfig.appDir) || this.resolveBackendRoot(); } resolveWebuiDir() { @@ -133,41 +134,6 @@ class BackendManager { return fs.existsSync(indexPath) ? candidate : null; } - getPackagedBackendDir() { - const packagedBackendConfig = this.loadPackagedBackendConfig(); - return packagedBackendConfig ? packagedBackendConfig.backendDir : null; - } - - parsePackagedBackendManifest(backendDir) { - const manifestPath = path.join(backendDir, 'runtime-manifest.json'); - if (!fs.existsSync(manifestPath)) { - return null; - } - try { - const raw = fs.readFileSync(manifestPath, 'utf8'); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - return parsed; - } - } catch (error) { - this.log( - `Failed to parse packaged backend manifest: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - return null; - } - - resolveManifestPath(backendDir, manifest, key, defaultRelative) { - const relativePath = - manifest && typeof manifest[key] === 'string' && manifest[key] - ? manifest[key] - : defaultRelative; - const candidate = path.join(backendDir, relativePath); - return fs.existsSync(candidate) ? candidate : null; - } - loadPackagedBackendConfig() { if (!this.app.isPackaged) { return null; @@ -181,7 +147,37 @@ class BackendManager { return null; } - const manifest = this.parsePackagedBackendManifest(backendDir); + const parseManifest = () => { + const manifestPath = path.join(backendDir, 'runtime-manifest.json'); + if (!fs.existsSync(manifestPath)) { + return null; + } + try { + const raw = fs.readFileSync(manifestPath, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return parsed; + } + } catch (error) { + this.log( + `Failed to parse packaged backend manifest: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return null; + }; + + const resolveManifestPath = (manifest, key, defaultRelative) => { + const relativePath = + manifest && typeof manifest[key] === 'string' && manifest[key] + ? manifest[key] + : defaultRelative; + const candidate = path.join(backendDir, relativePath); + return fs.existsSync(candidate) ? candidate : null; + }; + + const manifest = parseManifest(); const manifestForPathResolve = manifest || {}; const defaultPythonRelative = process.platform === 'win32' @@ -191,15 +187,13 @@ class BackendManager { this.packagedBackendConfig = Object.freeze({ backendDir, manifest, - appDir: this.resolveManifestPath(backendDir, manifestForPathResolve, 'app', 'app'), - launchScriptPath: this.resolveManifestPath( - backendDir, + appDir: resolveManifestPath(manifestForPathResolve, 'app', 'app'), + launchScriptPath: resolveManifestPath( manifestForPathResolve, 'entrypoint', 'launch_backend.py', ), - runtimePythonPath: this.resolveManifestPath( - backendDir, + runtimePythonPath: resolveManifestPath( manifestForPathResolve, 'python', defaultPythonRelative, @@ -209,28 +203,12 @@ class BackendManager { return this.packagedBackendConfig; } - getPackagedBackendManifest() { - const packagedBackendConfig = this.loadPackagedBackendConfig(); - return packagedBackendConfig ? packagedBackendConfig.manifest : null; - } - - getPackagedBackendAppDir() { - const packagedBackendConfig = this.loadPackagedBackendConfig(); - return packagedBackendConfig ? packagedBackendConfig.appDir : null; - } - - getPackagedBackendLaunchScriptPath() { - const packagedBackendConfig = this.loadPackagedBackendConfig(); - return packagedBackendConfig ? packagedBackendConfig.launchScriptPath : null; - } - - getPackagedRuntimePythonPath() { - const packagedBackendConfig = this.loadPackagedBackendConfig(); - return packagedBackendConfig ? packagedBackendConfig.runtimePythonPath : null; + getPackagedBackendConfig() { + return this.loadPackagedBackendConfig(); } buildPackagedBackendLaunch(webuiDir) { - const packagedBackendConfig = this.loadPackagedBackendConfig(); + const packagedBackendConfig = this.getPackagedBackendConfig(); if (!packagedBackendConfig) { return null; } @@ -739,32 +717,22 @@ class BackendManager { ); } - queryWindowsProcessCommandLineByPowerShell(pid) { + queryWindowsProcessCommandLine(shellName, pid) { const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -ne $p) { $p.CommandLine }`; - return spawnSync( - 'powershell', - ['-NoProfile', '-NonInteractive', '-Command', query], - { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - windowsHide: true, - timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, - }, - ); - } - - queryWindowsProcessCommandLineByPwsh(pid) { - const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -ne $p) { $p.CommandLine }`; - return spawnSync( - 'pwsh', - ['-NoProfile', '-NonInteractive', '-Command', query], - { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - windowsHide: true, - timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, - }, - ); + const args = ['-NoProfile', '-NonInteractive', '-Command', query]; + const options = { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + windowsHide: true, + timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, + }; + if (shellName === 'powershell') { + return spawnSync('powershell', args, options); + } + if (shellName === 'pwsh') { + return spawnSync('pwsh', args, options); + } + throw new Error(`Unsupported shell for process command line query: ${shellName}`); } parseWindowsProcessCommandLine(result) { @@ -791,40 +759,46 @@ class BackendManager { } } + getCachedWindowsCommandLine(numericPid) { + const cached = this.windowsProcessCommandLineCache.get(numericPid); + if ( + cached && + Date.now() - cached.timestampMs <= WINDOWS_PROCESS_COMMAND_LINE_CACHE_TTL_MS + ) { + return { hit: true, commandLine: cached.commandLine }; + } + this.windowsProcessCommandLineCache.delete(numericPid); + return { hit: false, commandLine: null }; + } + + setCachedWindowsCommandLine(numericPid, commandLine) { + this.windowsProcessCommandLineCache.set(numericPid, { + commandLine, + timestampMs: Date.now(), + }); + } + getWindowsProcessCommandLine(pid) { const numericPid = Number.parseInt(`${pid}`, 10); if (!Number.isInteger(numericPid)) { return null; } - const cached = this.windowsProcessCommandLineCache.get(numericPid); - if ( - cached && - Date.now() - cached.timestampMs <= WINDOWS_PROCESS_COMMAND_LINE_CACHE_TTL_MS - ) { + const cached = this.getCachedWindowsCommandLine(numericPid); + if (cached.hit) { return cached.commandLine; } - this.windowsProcessCommandLineCache.delete(numericPid); - const queryAttempts = [ - { - shellName: 'powershell', - run: () => this.queryWindowsProcessCommandLineByPowerShell(numericPid), - }, - { - shellName: 'pwsh', - run: () => this.queryWindowsProcessCommandLineByPwsh(numericPid), - }, - ]; + const queryAttempts = ['powershell', 'pwsh']; - for (const queryAttempt of queryAttempts) { + for (const shellName of queryAttempts) { let result = null; try { - result = queryAttempt.run(); + result = this.queryWindowsProcessCommandLine(shellName, numericPid); } catch (error) { if (error instanceof Error && error.message) { this.log( - `Failed to query process command line by ${queryAttempt.shellName} for pid=${numericPid}: ${error.message}`, + `Failed to query process command line by ${shellName} for pid=${numericPid}: ${error.message}`, ); } continue; @@ -835,25 +809,19 @@ class BackendManager { } if (result.error && result.error.code === 'ETIMEDOUT') { this.log( - `Timed out (${WINDOWS_PROCESS_QUERY_TIMEOUT_MS}ms) querying process command line by ${queryAttempt.shellName} for pid=${numericPid}.`, + `Timed out (${WINDOWS_PROCESS_QUERY_TIMEOUT_MS}ms) querying process command line by ${shellName} for pid=${numericPid}.`, ); continue; } if (result.status === 0) { const commandLine = this.parseWindowsProcessCommandLine(result); - this.windowsProcessCommandLineCache.set(numericPid, { - commandLine, - timestampMs: Date.now(), - }); + this.setCachedWindowsCommandLine(numericPid, commandLine); return commandLine; } } - this.windowsProcessCommandLineCache.set(numericPid, { - commandLine: null, - timestampMs: Date.now(), - }); + this.setCachedWindowsCommandLine(numericPid, null); return null; } @@ -863,49 +831,38 @@ class BackendManager { return path.basename(fallbackCmd).toLowerCase(); } - shouldKillUnmanagedBackendProcess( - pid, - processInfo, - backendConfig, - allowImageOnlyMatch = false, - ) { + getExpectedWindowsBackendImageName(backendConfig) { const safeBackendConfig = backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; - const expectedImageName = path + return path .basename(safeBackendConfig.cmd || this.getFallbackWindowsBackendImageName()) .toLowerCase(); - const actualImageName = processInfo.imageName.toLowerCase(); - if (actualImageName !== expectedImageName) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, - ); - return false; - } + } - if (allowImageOnlyMatch || !this.isGenericWindowsPythonImage(expectedImageName)) { - return true; + buildBackendCommandLineMarkers(backendConfig) { + const safeBackendConfig = + backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; + const markers = []; + if (!Array.isArray(safeBackendConfig.args) || safeBackendConfig.args.length === 0) { + return markers; } - const expectedCommandLineMarkers = []; - if (Array.isArray(safeBackendConfig.args) && safeBackendConfig.args.length > 0) { - const primaryArg = safeBackendConfig.args[0]; - if (typeof primaryArg === 'string' && primaryArg) { - const resolvedPrimaryArg = path.isAbsolute(primaryArg) - ? primaryArg - : path.resolve(safeBackendConfig.cwd || process.cwd(), primaryArg); - expectedCommandLineMarkers.push( - this.normalizeWindowsPathForMatch(resolvedPrimaryArg), - ); - expectedCommandLineMarkers.push( - this.normalizeWindowsPathForMatch(path.basename(primaryArg)), - ); - } + const primaryArg = safeBackendConfig.args[0]; + if (typeof primaryArg !== 'string' || !primaryArg) { + return markers; } - if (!expectedCommandLineMarkers.length) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`, - ); + const resolvedPrimaryArg = path.isAbsolute(primaryArg) + ? primaryArg + : path.resolve(safeBackendConfig.cwd || process.cwd(), primaryArg); + markers.push(this.normalizeWindowsPathForMatch(resolvedPrimaryArg)); + markers.push(this.normalizeWindowsPathForMatch(path.basename(primaryArg))); + return markers; + } + + matchesBackendCommandLine(pid, markers) { + if (!markers.length) { + this.log(`Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`); return false; } @@ -918,16 +875,36 @@ class BackendManager { } const normalizedCommandLine = this.normalizeWindowsPathForMatch(commandLine); - const markerMatched = expectedCommandLineMarkers.some( - (marker) => marker && normalizedCommandLine.includes(marker), - ); - if (!markerMatched) { + const matched = markers.some((marker) => marker && normalizedCommandLine.includes(marker)); + if (!matched) { this.log( `Skip unmanaged cleanup for pid=${pid}: command line does not match AstrBot backend launch marker.`, ); + } + return matched; + } + + shouldKillUnmanagedBackendProcess( + pid, + processInfo, + backendConfig, + allowImageOnlyMatch = false, + ) { + const expectedImageName = this.getExpectedWindowsBackendImageName(backendConfig); + const actualImageName = processInfo.imageName.toLowerCase(); + if (actualImageName !== expectedImageName) { + this.log( + `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, + ); return false; } - return true; + + if (allowImageOnlyMatch || !this.isGenericWindowsPythonImage(expectedImageName)) { + return true; + } + + const markers = this.buildBackendCommandLineMarkers(backendConfig); + return this.matchesBackendCommandLine(pid, markers); } async stopUnmanagedBackendByPort() { diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index d391e90ff..ea9a9972f 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -12,28 +12,13 @@ const manifestPath = path.join(outputDir, 'runtime-manifest.json'); const launcherPath = path.join(outputDir, 'launch_backend.py'); const runtimeSource = - process.env.ASTRBOT_DESKTOP_CPYTHON_HOME || - process.env.ASTRBOT_DESKTOP_BACKEND_RUNTIME; + process.env.ASTRBOT_DESKTOP_BACKEND_RUNTIME || + process.env.ASTRBOT_DESKTOP_CPYTHON_HOME; const requirePipProbe = process.env.ASTRBOT_DESKTOP_REQUIRE_PIP === '1'; -const fail = (message) => { - return new Error(message); -}; - -const normalizePathForCompare = (targetPath) => { - const resolved = path.resolve(targetPath).replace(/[\\/]+$/, ''); - return process.platform === 'win32' ? resolved.toLowerCase() : resolved; -}; - -const isSameOrSubPath = (targetPath, parentPath) => { - const target = normalizePathForCompare(targetPath); - const parent = normalizePathForCompare(parentPath); - return target === parent || target.startsWith(`${parent}${path.sep}`); -}; - const resolveAndValidateRuntimeSource = () => { if (!runtimeSource) { - throw fail( + throw new Error( 'Missing CPython runtime source. Set ASTRBOT_DESKTOP_CPYTHON_HOME ' + '(recommended) or ASTRBOT_DESKTOP_BACKEND_RUNTIME.', ); @@ -41,14 +26,23 @@ const resolveAndValidateRuntimeSource = () => { const runtimeSourceReal = path.resolve(rootDir, runtimeSource); if (!fs.existsSync(runtimeSourceReal)) { - throw fail(`CPython runtime source does not exist: ${runtimeSourceReal}`); + throw new Error(`CPython runtime source does not exist: ${runtimeSourceReal}`); } - if ( - isSameOrSubPath(runtimeSourceReal, outputDir) || - isSameOrSubPath(outputDir, runtimeSourceReal) - ) { - throw fail( + const normalizeForCompare = (targetPath) => { + const resolved = path.resolve(targetPath).replace(/[\\/]+$/, ''); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + }; + + const runtimeNorm = normalizeForCompare(runtimeSourceReal); + const outputNorm = normalizeForCompare(outputDir); + const runtimeIsOutputOrSub = + runtimeNorm === outputNorm || runtimeNorm.startsWith(`${outputNorm}${path.sep}`); + const outputIsRuntimeOrSub = + outputNorm === runtimeNorm || outputNorm.startsWith(`${runtimeNorm}${path.sep}`); + + if (runtimeIsOutputOrSub || outputIsRuntimeOrSub) { + throw new Error( `CPython runtime source overlaps with backend output directory. ` + `runtime=${runtimeSourceReal}, output=${outputDir}. ` + 'Please set ASTRBOT_DESKTOP_CPYTHON_HOME to a separate runtime directory.', @@ -61,7 +55,7 @@ const resolveAndValidateRuntimeSource = () => { const parseExpectedRuntimeVersion = (rawVersion, sourceName) => { const match = /^(\d+)\.(\d+)$/.exec(String(rawVersion).trim()); if (!match) { - throw fail( + throw new Error( `Invalid expected Python version from ${sourceName}: ${rawVersion}. ` + 'Expected format ..', ); @@ -78,6 +72,11 @@ const extractLowerBoundFromPythonSpecifier = (rawSpecifier) => { } const clauses = rawSpecifier.replace(/\s+/g, '').split(',').filter(Boolean); + for (const clause of clauses) { + if (clause.includes('<') || clause.includes('!=')) { + return null; + } + } let bestLowerBound = null; const updateLowerBound = (major, minor) => { @@ -91,7 +90,7 @@ const extractLowerBoundFromPythonSpecifier = (rawSpecifier) => { }; for (const clause of clauses) { - const match = /^(>=|>|==|~=)(\d+)(?:\.(\d+))?/.exec(clause); + const match = /^(>=|>|==|~=)(\d+)(?:\.(\d+))?$/.exec(clause); if (!match) { continue; } @@ -227,7 +226,7 @@ const resolveExpectedRuntimeVersion = () => { }; } - throw fail( + throw new Error( 'Unable to determine expected runtime Python version. ' + 'Set ASTRBOT_DESKTOP_EXPECTED_PYTHON or declare project.requires-python in pyproject.toml.', ); @@ -318,19 +317,19 @@ const validateRuntimePython = (pythonExecutable, expectedRuntimeConstraint) => { probe.error.code === 'ETIMEDOUT' ? 'runtime Python probe timed out' : probe.error.message || String(probe.error); - throw fail(`Runtime Python probe failed: ${reason}`); + throw new Error(`Runtime Python probe failed: ${reason}`); } if (probe.status !== 0) { const stderrText = (probe.stderr || '').trim(); if (requirePipProbe) { - throw fail( + throw new Error( `Runtime Python probe failed with exit code ${probe.status}. ` + `pip import check is enabled by ASTRBOT_DESKTOP_REQUIRE_PIP=1. ` + (stderrText ? `stderr: ${stderrText}` : ''), ); } - throw fail( + throw new Error( `Runtime Python probe failed with exit code ${probe.status}. ` + (stderrText ? `stderr: ${stderrText}` : ''), ); @@ -338,7 +337,7 @@ const validateRuntimePython = (pythonExecutable, expectedRuntimeConstraint) => { const parts = (probe.stdout || '').trim().split(/\s+/); if (parts.length < 2) { - throw fail( + throw new Error( `Runtime Python probe did not report a valid version. Output: ${(probe.stdout || '').trim()}`, ); } @@ -351,7 +350,7 @@ const validateRuntimePython = (pythonExecutable, expectedRuntimeConstraint) => { const compareResult = compareMajorMinor(actualVersion, expectedRuntimeVersion); if (expectedRuntimeConstraint.isLowerBoundRuntimeVersion) { if (compareResult < 0) { - throw fail( + throw new Error( `Runtime Python version is too low for ${expectedRuntimeConstraint.source}: ` + `expected >= ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + `got ${actualVersion.major}.${actualVersion.minor}.`, @@ -361,7 +360,7 @@ const validateRuntimePython = (pythonExecutable, expectedRuntimeConstraint) => { } if (compareResult !== 0) { - throw fail( + throw new Error( `Runtime Python version mismatch for ${expectedRuntimeConstraint.source}: ` + `expected ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + `got ${actualVersion.major}.${actualVersion.minor}.`, @@ -397,7 +396,7 @@ const main = () => { const sourceRuntimePython = resolveRuntimePython(runtimeSourceReal); if (!sourceRuntimePython) { - throw fail( + throw new Error( `Cannot find Python executable in runtime source: ${runtimeSourceReal}. ` + 'Expected python under bin/ or Scripts/.', ); @@ -412,7 +411,7 @@ const main = () => { const sourcePath = path.join(rootDir, srcRelative); const targetPath = path.join(appDir, destRelative); if (!fs.existsSync(sourcePath)) { - throw fail(`Backend source path does not exist: ${sourcePath}`); + throw new Error(`Backend source path does not exist: ${sourcePath}`); } copyTree(sourcePath, targetPath); } @@ -421,7 +420,7 @@ const main = () => { const runtimePython = resolveRuntimePython(runtimeDir); if (!runtimePython) { - throw fail( + throw new Error( `Cannot find Python executable in runtime: ${runtimeDir}. ` + 'Expected python under bin/ or Scripts/.', ); From f63f960b4b305e89494921337323c52679aad987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 11:37:36 +0900 Subject: [PATCH 11/49] fix(desktop): harden packaged launch fallback and cleanup matching --- desktop/lib/backend-manager.js | 226 ++++++++++++++++-------------- desktop/scripts/build-backend.mjs | 5 - 2 files changed, 120 insertions(+), 111 deletions(-) diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index ea2f207b7..8f4b8d273 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -20,7 +20,6 @@ const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000; const BACKEND_LOG_FLUSH_INTERVAL_MS = 120; const BACKEND_LOG_MAX_BUFFER_BYTES = 128 * 1024; const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 2000; -const WINDOWS_PROCESS_COMMAND_LINE_CACHE_TTL_MS = 5000; function parseBackendTimeoutMs(app) { const defaultTimeoutMs = app.isPackaged ? 0 : 20000; @@ -34,6 +33,54 @@ function parseBackendTimeoutMs(app) { return defaultTimeoutMs; } +function resolvePackagedBackendConfig(backendDir, logFn) { + if (!fs.existsSync(backendDir)) { + return null; + } + + const manifestPath = path.join(backendDir, 'runtime-manifest.json'); + let manifest = null; + if (fs.existsSync(manifestPath)) { + try { + const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (parsed && typeof parsed === 'object') { + manifest = parsed; + } + } catch (error) { + if (typeof logFn === 'function') { + logFn( + `Failed to parse packaged backend manifest: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } + + const configFromManifest = manifest && typeof manifest === 'object' ? manifest : {}; + const resolvePath = (key, defaultRelative) => { + const relativePath = + typeof configFromManifest[key] === 'string' && configFromManifest[key] + ? configFromManifest[key] + : defaultRelative; + const candidate = path.join(backendDir, relativePath); + return fs.existsSync(candidate) ? candidate : null; + }; + + const defaultPythonRelative = + process.platform === 'win32' + ? path.join('python', 'Scripts', 'python.exe') + : path.join('python', 'bin', 'python3'); + + return { + backendDir, + manifest, + appDir: resolvePath('app', 'app'), + launchScriptPath: resolvePath('entrypoint', 'launch_backend.py'), + runtimePythonPath: resolvePath('python', defaultPythonRelative), + }; +} + class BackendManager { constructor({ app, baseDir, log, shouldSkipStart }) { this.app = app; @@ -57,7 +104,7 @@ class BackendManager { this.backendProcess = null; this.backendConfig = null; this.packagedBackendConfig = null; - this.windowsProcessCommandLineCache = new Map(); + this.backendConfigResolutionError = null; this.backendLogger = new BufferedRotatingLogger({ logPath: null, maxBytes: this.backendLogMaxBytes, @@ -143,63 +190,9 @@ class BackendManager { } const backendDir = path.join(process.resourcesPath, 'backend'); - if (!fs.existsSync(backendDir)) { - return null; - } - - const parseManifest = () => { - const manifestPath = path.join(backendDir, 'runtime-manifest.json'); - if (!fs.existsSync(manifestPath)) { - return null; - } - try { - const raw = fs.readFileSync(manifestPath, 'utf8'); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - return parsed; - } - } catch (error) { - this.log( - `Failed to parse packaged backend manifest: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - return null; - }; - - const resolveManifestPath = (manifest, key, defaultRelative) => { - const relativePath = - manifest && typeof manifest[key] === 'string' && manifest[key] - ? manifest[key] - : defaultRelative; - const candidate = path.join(backendDir, relativePath); - return fs.existsSync(candidate) ? candidate : null; - }; - - const manifest = parseManifest(); - const manifestForPathResolve = manifest || {}; - const defaultPythonRelative = - process.platform === 'win32' - ? path.join('python', 'Scripts', 'python.exe') - : path.join('python', 'bin', 'python3'); - - this.packagedBackendConfig = Object.freeze({ - backendDir, - manifest, - appDir: resolveManifestPath(manifestForPathResolve, 'app', 'app'), - launchScriptPath: resolveManifestPath( - manifestForPathResolve, - 'entrypoint', - 'launch_backend.py', - ), - runtimePythonPath: resolveManifestPath( - manifestForPathResolve, - 'python', - defaultPythonRelative, - ), - }); - + this.packagedBackendConfig = resolvePackagedBackendConfig(backendDir, (message) => + this.log(message), + ); return this.packagedBackendConfig; } @@ -207,6 +200,37 @@ class BackendManager { return this.loadPackagedBackendConfig(); } + getPackagedBackendLaunchFailureReason() { + if (!this.app.isPackaged) { + return null; + } + const backendDir = path.join(process.resourcesPath, 'backend'); + const packagedBackendConfig = this.getPackagedBackendConfig(); + if (!packagedBackendConfig) { + return ( + `Packaged backend directory is missing: ${backendDir}. ` + + 'Please rebuild desktop backend runtime.' + ); + } + + const missingParts = []; + if (!packagedBackendConfig.runtimePythonPath) { + missingParts.push('runtime python executable'); + } + if (!packagedBackendConfig.launchScriptPath) { + missingParts.push('launch backend script'); + } + if (!missingParts.length) { + return null; + } + + return ( + `Packaged backend runtime is incomplete (${missingParts.join(', ')}). ` + + `backendDir=${packagedBackendConfig.backendDir}. ` + + 'Please run `pnpm --dir desktop run build:backend` before packaging.' + ); + } + buildPackagedBackendLaunch(webuiDir) { const packagedBackendConfig = this.getPackagedBackendConfig(); if (!packagedBackendConfig) { @@ -250,6 +274,8 @@ class BackendManager { resolveBackendConfig() { const webuiDir = this.resolveWebuiDir(); const customCmd = process.env.ASTRBOT_BACKEND_CMD; + this.backendConfigResolutionError = null; + const launch = customCmd ? { cmd: customCmd, @@ -257,6 +283,13 @@ class BackendManager { shell: true, } : this.buildDefaultBackendLaunch(webuiDir); + if (!customCmd && this.app.isPackaged && !launch) { + const failureReason = + this.getPackagedBackendLaunchFailureReason() || 'Backend command is not configured.'; + this.backendConfigResolutionError = failureReason; + this.log(failureReason); + } + const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot(); ensureDir(cwd); @@ -298,6 +331,10 @@ class BackendManager { return Boolean(this.getBackendConfig().cmd); } + getBackendCommandUnavailableReason() { + return this.backendConfigResolutionError || 'Backend command is not configured.'; + } + async flushLogs() { await this.backendLogger.flush(); } @@ -551,7 +588,7 @@ class BackendManager { if (!this.canManageBackend()) { return { ok: false, - reason: 'Backend command is not configured.', + reason: this.getBackendCommandUnavailableReason(), }; } this.backendSpawning = true; @@ -747,46 +784,14 @@ class BackendManager { ); } - pruneWindowsProcessCommandLineCache() { - const now = Date.now(); - for (const [pid, cached] of this.windowsProcessCommandLineCache.entries()) { - if ( - !cached || - now - cached.timestampMs > WINDOWS_PROCESS_COMMAND_LINE_CACHE_TTL_MS - ) { - this.windowsProcessCommandLineCache.delete(pid); - } - } - } - - getCachedWindowsCommandLine(numericPid) { - const cached = this.windowsProcessCommandLineCache.get(numericPid); - if ( - cached && - Date.now() - cached.timestampMs <= WINDOWS_PROCESS_COMMAND_LINE_CACHE_TTL_MS - ) { - return { hit: true, commandLine: cached.commandLine }; - } - this.windowsProcessCommandLineCache.delete(numericPid); - return { hit: false, commandLine: null }; - } - - setCachedWindowsCommandLine(numericPid, commandLine) { - this.windowsProcessCommandLineCache.set(numericPid, { - commandLine, - timestampMs: Date.now(), - }); - } - - getWindowsProcessCommandLine(pid) { + getWindowsProcessCommandLine(pid, commandLineCache = null) { const numericPid = Number.parseInt(`${pid}`, 10); if (!Number.isInteger(numericPid)) { return null; } - const cached = this.getCachedWindowsCommandLine(numericPid); - if (cached.hit) { - return cached.commandLine; + if (commandLineCache && commandLineCache.has(numericPid)) { + return commandLineCache.get(numericPid); } const queryAttempts = ['powershell', 'pwsh']; @@ -816,12 +821,16 @@ class BackendManager { if (result.status === 0) { const commandLine = this.parseWindowsProcessCommandLine(result); - this.setCachedWindowsCommandLine(numericPid, commandLine); + if (commandLineCache) { + commandLineCache.set(numericPid, commandLine); + } return commandLine; } } - this.setCachedWindowsCommandLine(numericPid, null); + if (commandLineCache) { + commandLineCache.set(numericPid, null); + } return null; } @@ -860,13 +869,13 @@ class BackendManager { return markers; } - matchesBackendCommandLine(pid, markers) { + matchesBackendCommandLine(pid, markers, commandLineCache = null) { if (!markers.length) { this.log(`Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`); return false; } - const commandLine = this.getWindowsProcessCommandLine(pid); + const commandLine = this.getWindowsProcessCommandLine(pid, commandLineCache); if (!commandLine) { this.log( `Skip unmanaged cleanup for pid=${pid}: unable to resolve process command line.`, @@ -889,6 +898,7 @@ class BackendManager { processInfo, backendConfig, allowImageOnlyMatch = false, + commandLineCache = null, ) { const expectedImageName = this.getExpectedWindowsBackendImageName(backendConfig); const actualImageName = processInfo.imageName.toLowerCase(); @@ -904,14 +914,13 @@ class BackendManager { } const markers = this.buildBackendCommandLineMarkers(backendConfig); - return this.matchesBackendCommandLine(pid, markers); + return this.matchesBackendCommandLine(pid, markers, commandLineCache); } async stopUnmanagedBackendByPort() { if (!this.app.isPackaged || process.platform !== 'win32') { return false; } - this.pruneWindowsProcessCommandLineCache(); const port = this.getBackendPort(); if (!port) { @@ -943,6 +952,7 @@ class BackendManager { 'Backend config is unavailable during unmanaged cleanup; falling back to image-name-only matching.', ); } + const commandLineCache = new Map(); for (const pid of pids) { const processInfo = this.getWindowsProcessInfo(pid); @@ -956,6 +966,7 @@ class BackendManager { processInfo, backendConfig, !hasBackendConfig, + commandLineCache, ) ) { continue; @@ -1007,9 +1018,12 @@ class BackendManager { if (running) { return true; } - if (!this.backendAutoStart || !this.canManageBackend()) { - this.backendStartupFailureReason = - 'Backend auto-start is disabled or backend command is not configured.'; + if (!this.backendAutoStart) { + this.backendStartupFailureReason = 'Backend auto-start is disabled.'; + return false; + } + if (!this.canManageBackend()) { + this.backendStartupFailureReason = this.getBackendCommandUnavailableReason(); return false; } const waitResult = await this.startBackendAndWait(this.backendTimeoutMs); @@ -1033,7 +1047,7 @@ class BackendManager { if (!this.canManageBackend()) { return { ok: false, - reason: 'Backend command is not configured.', + reason: this.getBackendCommandUnavailableReason(), }; } if (this.backendSpawning || this.backendRestarting) { @@ -1096,7 +1110,7 @@ class BackendManager { if (!this.canManageBackend()) { return { ok: false, - reason: 'Backend command is not configured.', + reason: this.getBackendCommandUnavailableReason(), }; } if (this.backendSpawning || this.backendRestarting) { diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index ea9a9972f..46fd8fcd8 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -72,11 +72,6 @@ const extractLowerBoundFromPythonSpecifier = (rawSpecifier) => { } const clauses = rawSpecifier.replace(/\s+/g, '').split(',').filter(Boolean); - for (const clause of clauses) { - if (clause.includes('<') || clause.includes('!=')) { - return null; - } - } let bestLowerBound = null; const updateLowerBound = (major, minor) => { From d70a75f2580fdc37123371c61c30616efad89e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 12:12:17 +0900 Subject: [PATCH 12/49] refactor(desktop): split backend runtime helpers and cleanup logic --- astrbot/core/star/star_manager.py | 15 +- desktop/README.md | 2 +- desktop/lib/backend-manager.js | 375 +++------------------- desktop/lib/packaged-backend-config.js | 83 +++++ desktop/lib/windows-backend-cleanup.js | 191 +++++++++++ desktop/scripts/build-backend.mjs | 375 ++-------------------- desktop/scripts/read-requires-python.py | 24 ++ desktop/scripts/runtime-layout-utils.mjs | 80 +++++ desktop/scripts/runtime-version-utils.mjs | 247 ++++++++++++++ 9 files changed, 718 insertions(+), 674 deletions(-) create mode 100644 desktop/lib/packaged-backend-config.js create mode 100644 desktop/lib/windows-backend-cleanup.js create mode 100644 desktop/scripts/read-requires-python.py create mode 100644 desktop/scripts/runtime-layout-utils.mjs create mode 100644 desktop/scripts/runtime-version-utils.mjs diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 425989673..7810130cb 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -40,6 +40,13 @@ logger.warning("未安装 watchfiles,无法实现插件的热重载。") +class PluginDependencyInstallError(Exception): + def __init__(self, plugin_name: str, requirements_path: str, message: str) -> None: + super().__init__(message) + self.plugin_name = plugin_name + self.requirements_path = requirements_path + + class PluginManager: def __init__(self, context: Context, config: AstrBotConfig) -> None: self.updator = PluginUpdator() @@ -195,9 +202,11 @@ async def _check_plugin_dept_update( except Exception as e: logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}") if target_plugin: - raise Exception( - "插件依赖安装失败,请检查插件 requirements.txt " - "中的依赖版本或构建环境。" + raise PluginDependencyInstallError( + plugin_name=p, + requirements_path=pth, + message="插件依赖安装失败,请检查插件 requirements.txt " + "中的依赖版本或构建环境。", ) from e return True diff --git a/desktop/README.md b/desktop/README.md index 490b7e555..d784d1ab3 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -81,7 +81,7 @@ desktop/resources/backend/ app/ # AstrBot backend source snapshot used in packaged mode python/ # Bundled CPython runtime directory launch_backend.py # Launcher executed by Electron - runtime-manifest.json # Runtime metadata (python path, entrypoint, app path) + runtime-manifest.json # Runtime metadata (Python path, entrypoint, app path) ``` Electron reads `runtime-manifest.json` and starts backend with: diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 8f4b8d273..b08943063 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -5,6 +5,8 @@ const os = require('os'); const path = require('path'); const { spawn, spawnSync } = require('child_process'); const { BufferedRotatingLogger } = require('./buffered-rotating-logger'); +const { resolvePackagedBackendState } = require('./packaged-backend-config'); +const { shouldKillUnmanagedBackendProcess } = require('./windows-backend-cleanup'); const { delay, ensureDir, @@ -19,7 +21,6 @@ const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000; const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000; const BACKEND_LOG_FLUSH_INTERVAL_MS = 120; const BACKEND_LOG_MAX_BUFFER_BYTES = 128 * 1024; -const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 2000; function parseBackendTimeoutMs(app) { const defaultTimeoutMs = app.isPackaged ? 0 : 20000; @@ -33,54 +34,6 @@ function parseBackendTimeoutMs(app) { return defaultTimeoutMs; } -function resolvePackagedBackendConfig(backendDir, logFn) { - if (!fs.existsSync(backendDir)) { - return null; - } - - const manifestPath = path.join(backendDir, 'runtime-manifest.json'); - let manifest = null; - if (fs.existsSync(manifestPath)) { - try { - const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - if (parsed && typeof parsed === 'object') { - manifest = parsed; - } - } catch (error) { - if (typeof logFn === 'function') { - logFn( - `Failed to parse packaged backend manifest: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - } - - const configFromManifest = manifest && typeof manifest === 'object' ? manifest : {}; - const resolvePath = (key, defaultRelative) => { - const relativePath = - typeof configFromManifest[key] === 'string' && configFromManifest[key] - ? configFromManifest[key] - : defaultRelative; - const candidate = path.join(backendDir, relativePath); - return fs.existsSync(candidate) ? candidate : null; - }; - - const defaultPythonRelative = - process.platform === 'win32' - ? path.join('python', 'Scripts', 'python.exe') - : path.join('python', 'bin', 'python3'); - - return { - backendDir, - manifest, - appDir: resolvePath('app', 'app'), - launchScriptPath: resolvePath('entrypoint', 'launch_backend.py'), - runtimePythonPath: resolvePath('python', defaultPythonRelative), - }; -} - class BackendManager { constructor({ app, baseDir, log, shouldSkipStart }) { this.app = app; @@ -103,8 +56,7 @@ class BackendManager { this.backendProcess = null; this.backendConfig = null; - this.packagedBackendConfig = null; - this.backendConfigResolutionError = null; + this.packagedBackendState = null; this.backendLogger = new BufferedRotatingLogger({ logPath: null, maxBytes: this.backendLogMaxBytes, @@ -165,8 +117,8 @@ class BackendManager { if (!this.app.isPackaged) { return path.resolve(this.baseDir, '..'); } - const packagedBackendConfig = this.getPackagedBackendConfig(); - return (packagedBackendConfig && packagedBackendConfig.appDir) || this.resolveBackendRoot(); + const packagedBackendState = this.getPackagedBackendState(); + return (packagedBackendState?.config?.appDir || null) || this.resolveBackendRoot(); } resolveWebuiDir() { @@ -181,85 +133,25 @@ class BackendManager { return fs.existsSync(indexPath) ? candidate : null; } - loadPackagedBackendConfig() { + loadPackagedBackendState() { if (!this.app.isPackaged) { return null; } - if (this.packagedBackendConfig) { - return this.packagedBackendConfig; + if (this.packagedBackendState) { + return this.packagedBackendState; } - - const backendDir = path.join(process.resourcesPath, 'backend'); - this.packagedBackendConfig = resolvePackagedBackendConfig(backendDir, (message) => - this.log(message), + this.packagedBackendState = resolvePackagedBackendState( + process.resourcesPath, + (message) => this.log(message), ); - return this.packagedBackendConfig; + return this.packagedBackendState; } - getPackagedBackendConfig() { - return this.loadPackagedBackendConfig(); - } - - getPackagedBackendLaunchFailureReason() { - if (!this.app.isPackaged) { - return null; - } - const backendDir = path.join(process.resourcesPath, 'backend'); - const packagedBackendConfig = this.getPackagedBackendConfig(); - if (!packagedBackendConfig) { - return ( - `Packaged backend directory is missing: ${backendDir}. ` + - 'Please rebuild desktop backend runtime.' - ); - } - - const missingParts = []; - if (!packagedBackendConfig.runtimePythonPath) { - missingParts.push('runtime python executable'); - } - if (!packagedBackendConfig.launchScriptPath) { - missingParts.push('launch backend script'); - } - if (!missingParts.length) { - return null; - } - - return ( - `Packaged backend runtime is incomplete (${missingParts.join(', ')}). ` + - `backendDir=${packagedBackendConfig.backendDir}. ` + - 'Please run `pnpm --dir desktop run build:backend` before packaging.' - ); - } - - buildPackagedBackendLaunch(webuiDir) { - const packagedBackendConfig = this.getPackagedBackendConfig(); - if (!packagedBackendConfig) { - return null; - } - - const runtimePython = packagedBackendConfig.runtimePythonPath; - const launchScript = packagedBackendConfig.launchScriptPath; - if (!runtimePython || !launchScript) { - return null; - } - - const args = [launchScript]; - if (webuiDir) { - args.push('--webui-dir', webuiDir); - } - - return { - cmd: runtimePython, - args, - shell: false, - }; + getPackagedBackendState() { + return this.loadPackagedBackendState(); } buildDefaultBackendLaunch(webuiDir) { - if (this.app.isPackaged) { - return this.buildPackagedBackendLaunch(webuiDir); - } - const args = ['run', 'main.py']; if (webuiDir) { args.push('--webui-dir', webuiDir); @@ -274,20 +166,30 @@ class BackendManager { resolveBackendConfig() { const webuiDir = this.resolveWebuiDir(); const customCmd = process.env.ASTRBOT_BACKEND_CMD; - this.backendConfigResolutionError = null; - - const launch = customCmd - ? { - cmd: customCmd, - args: [], - shell: true, - } - : this.buildDefaultBackendLaunch(webuiDir); - if (!customCmd && this.app.isPackaged && !launch) { - const failureReason = - this.getPackagedBackendLaunchFailureReason() || 'Backend command is not configured.'; - this.backendConfigResolutionError = failureReason; - this.log(failureReason); + let launch = null; + let failureReason = null; + + if (customCmd) { + launch = { + cmd: customCmd, + args: [], + shell: true, + }; + } else if (this.app.isPackaged) { + const packagedBackendState = this.getPackagedBackendState(); + if (packagedBackendState?.ok && packagedBackendState.config) { + launch = { + cmd: packagedBackendState.config.runtimePythonPath, + args: [packagedBackendState.config.launchScriptPath, ...(webuiDir ? ['--webui-dir', webuiDir] : [])], + shell: false, + }; + } else { + failureReason = + packagedBackendState?.failureReason || 'Backend command is not configured.'; + this.log(failureReason); + } + } else { + launch = this.buildDefaultBackendLaunch(webuiDir); } const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); @@ -303,6 +205,7 @@ class BackendManager { cwd, webuiDir, rootDir, + failureReason, }; return this.backendConfig; } @@ -332,7 +235,7 @@ class BackendManager { } getBackendCommandUnavailableReason() { - return this.backendConfigResolutionError || 'Backend command is not configured.'; + return this.getBackendConfig().failureReason || 'Backend command is not configured.'; } async flushLogs() { @@ -739,184 +642,6 @@ class BackendManager { return { imageName, pid: parsedPid }; } - normalizeWindowsPathForMatch(value) { - return String(value || '') - .replace(/\//g, '\\') - .toLowerCase(); - } - - isGenericWindowsPythonImage(imageName) { - const normalized = String(imageName || '').toLowerCase(); - return ( - normalized === 'python.exe' || - normalized === 'pythonw.exe' || - normalized === 'py.exe' - ); - } - - queryWindowsProcessCommandLine(shellName, pid) { - const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -ne $p) { $p.CommandLine }`; - const args = ['-NoProfile', '-NonInteractive', '-Command', query]; - const options = { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - windowsHide: true, - timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, - }; - if (shellName === 'powershell') { - return spawnSync('powershell', args, options); - } - if (shellName === 'pwsh') { - return spawnSync('pwsh', args, options); - } - throw new Error(`Unsupported shell for process command line query: ${shellName}`); - } - - parseWindowsProcessCommandLine(result) { - if (!result || !result.stdout) { - return null; - } - return ( - result.stdout - .split(/\r?\n/) - .map((item) => item.trim()) - .find((item) => item.length > 0) || null - ); - } - - getWindowsProcessCommandLine(pid, commandLineCache = null) { - const numericPid = Number.parseInt(`${pid}`, 10); - if (!Number.isInteger(numericPid)) { - return null; - } - - if (commandLineCache && commandLineCache.has(numericPid)) { - return commandLineCache.get(numericPid); - } - - const queryAttempts = ['powershell', 'pwsh']; - - for (const shellName of queryAttempts) { - let result = null; - try { - result = this.queryWindowsProcessCommandLine(shellName, numericPid); - } catch (error) { - if (error instanceof Error && error.message) { - this.log( - `Failed to query process command line by ${shellName} for pid=${numericPid}: ${error.message}`, - ); - } - continue; - } - - if (result.error && result.error.code === 'ENOENT') { - continue; - } - if (result.error && result.error.code === 'ETIMEDOUT') { - this.log( - `Timed out (${WINDOWS_PROCESS_QUERY_TIMEOUT_MS}ms) querying process command line by ${shellName} for pid=${numericPid}.`, - ); - continue; - } - - if (result.status === 0) { - const commandLine = this.parseWindowsProcessCommandLine(result); - if (commandLineCache) { - commandLineCache.set(numericPid, commandLine); - } - return commandLine; - } - } - - if (commandLineCache) { - commandLineCache.set(numericPid, null); - } - return null; - } - - getFallbackWindowsBackendImageName() { - const fallbackCmdRaw = process.env.ASTRBOT_BACKEND_CMD || 'python.exe'; - const fallbackCmd = String(fallbackCmdRaw).trim().split(/\s+/, 1)[0] || 'python.exe'; - return path.basename(fallbackCmd).toLowerCase(); - } - - getExpectedWindowsBackendImageName(backendConfig) { - const safeBackendConfig = - backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; - return path - .basename(safeBackendConfig.cmd || this.getFallbackWindowsBackendImageName()) - .toLowerCase(); - } - - buildBackendCommandLineMarkers(backendConfig) { - const safeBackendConfig = - backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; - const markers = []; - if (!Array.isArray(safeBackendConfig.args) || safeBackendConfig.args.length === 0) { - return markers; - } - - const primaryArg = safeBackendConfig.args[0]; - if (typeof primaryArg !== 'string' || !primaryArg) { - return markers; - } - - const resolvedPrimaryArg = path.isAbsolute(primaryArg) - ? primaryArg - : path.resolve(safeBackendConfig.cwd || process.cwd(), primaryArg); - markers.push(this.normalizeWindowsPathForMatch(resolvedPrimaryArg)); - markers.push(this.normalizeWindowsPathForMatch(path.basename(primaryArg))); - return markers; - } - - matchesBackendCommandLine(pid, markers, commandLineCache = null) { - if (!markers.length) { - this.log(`Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`); - return false; - } - - const commandLine = this.getWindowsProcessCommandLine(pid, commandLineCache); - if (!commandLine) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: unable to resolve process command line.`, - ); - return false; - } - - const normalizedCommandLine = this.normalizeWindowsPathForMatch(commandLine); - const matched = markers.some((marker) => marker && normalizedCommandLine.includes(marker)); - if (!matched) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: command line does not match AstrBot backend launch marker.`, - ); - } - return matched; - } - - shouldKillUnmanagedBackendProcess( - pid, - processInfo, - backendConfig, - allowImageOnlyMatch = false, - commandLineCache = null, - ) { - const expectedImageName = this.getExpectedWindowsBackendImageName(backendConfig); - const actualImageName = processInfo.imageName.toLowerCase(); - if (actualImageName !== expectedImageName) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, - ); - return false; - } - - if (allowImageOnlyMatch || !this.isGenericWindowsPythonImage(expectedImageName)) { - return true; - } - - const markers = this.buildBackendCommandLineMarkers(backendConfig); - return this.matchesBackendCommandLine(pid, markers, commandLineCache); - } - async stopUnmanagedBackendByPort() { if (!this.app.isPackaged || process.platform !== 'win32') { return false; @@ -960,15 +685,17 @@ class BackendManager { this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`); continue; } - if ( - !this.shouldKillUnmanagedBackendProcess( - pid, - processInfo, - backendConfig, - !hasBackendConfig, - commandLineCache, - ) - ) { + const shouldKill = shouldKillUnmanagedBackendProcess({ + pid, + processInfo, + backendConfig, + allowImageOnlyMatch: !hasBackendConfig, + commandLineCache, + spawnSync, + log: (message) => this.log(message), + fallbackCmdRaw: process.env.ASTRBOT_BACKEND_CMD || 'python.exe', + }); + if (!shouldKill) { continue; } diff --git a/desktop/lib/packaged-backend-config.js b/desktop/lib/packaged-backend-config.js new file mode 100644 index 000000000..4c2bc920f --- /dev/null +++ b/desktop/lib/packaged-backend-config.js @@ -0,0 +1,83 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function resolvePackagedBackendState(resourcesPath, logFn) { + const backendDir = path.join(resourcesPath, 'backend'); + if (!fs.existsSync(backendDir)) { + return { + ok: false, + config: null, + failureReason: + `Packaged backend directory is missing: ${backendDir}. ` + + 'Please rebuild desktop backend runtime.', + }; + } + + const manifestPath = path.join(backendDir, 'runtime-manifest.json'); + let manifest = null; + if (fs.existsSync(manifestPath)) { + try { + const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (parsed && typeof parsed === 'object') { + manifest = parsed; + } + } catch (error) { + if (typeof logFn === 'function') { + logFn( + `Failed to parse packaged backend manifest: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } + + const configFromManifest = manifest && typeof manifest === 'object' ? manifest : {}; + const resolvePath = (key, defaultRelative) => { + const relativePath = + typeof configFromManifest[key] === 'string' && configFromManifest[key] + ? configFromManifest[key] + : defaultRelative; + const candidate = path.join(backendDir, relativePath); + return fs.existsSync(candidate) ? candidate : null; + }; + + const defaultPythonRelative = + process.platform === 'win32' + ? path.join('python', 'Scripts', 'python.exe') + : path.join('python', 'bin', 'python3'); + + const config = { + backendDir, + manifest, + appDir: resolvePath('app', 'app'), + launchScriptPath: resolvePath('entrypoint', 'launch_backend.py'), + runtimePythonPath: resolvePath('python', defaultPythonRelative), + }; + + const missingParts = []; + if (!config.runtimePythonPath) { + missingParts.push('runtime python executable'); + } + if (!config.launchScriptPath) { + missingParts.push('launch backend script'); + } + if (missingParts.length > 0) { + return { + ok: false, + config, + failureReason: + `Packaged backend runtime is incomplete (${missingParts.join(', ')}). ` + + `backendDir=${config.backendDir}. ` + + 'Please run `pnpm --dir desktop run build:backend` before packaging.', + }; + } + + return { ok: true, config, failureReason: null }; +} + +module.exports = { + resolvePackagedBackendState, +}; diff --git a/desktop/lib/windows-backend-cleanup.js b/desktop/lib/windows-backend-cleanup.js new file mode 100644 index 000000000..99d28ecb9 --- /dev/null +++ b/desktop/lib/windows-backend-cleanup.js @@ -0,0 +1,191 @@ +'use strict'; + +const path = require('path'); + +const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 2000; + +function normalizeWindowsPathForMatch(value) { + return String(value || '') + .replace(/\//g, '\\') + .toLowerCase(); +} + +function isGenericWindowsPythonImage(imageName) { + const normalized = String(imageName || '').toLowerCase(); + return normalized === 'python.exe' || normalized === 'pythonw.exe' || normalized === 'py.exe'; +} + +function queryWindowsProcessCommandLine({ pid, shellName, spawnSync, timeoutMs }) { + const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -ne $p) { $p.CommandLine }`; + const args = ['-NoProfile', '-NonInteractive', '-Command', query]; + const options = { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + windowsHide: true, + timeout: timeoutMs, + }; + if (shellName === 'powershell') { + return spawnSync('powershell', args, options); + } + if (shellName === 'pwsh') { + return spawnSync('pwsh', args, options); + } + throw new Error(`Unsupported shell for process command line query: ${shellName}`); +} + +function parseWindowsProcessCommandLine(result) { + if (!result || !result.stdout) { + return null; + } + return ( + result.stdout + .split(/\r?\n/) + .map((item) => item.trim()) + .find((item) => item.length > 0) || null + ); +} + +function getWindowsProcessCommandLine({ pid, commandLineCache, spawnSync, log, timeoutMs }) { + const numericPid = Number.parseInt(`${pid}`, 10); + if (!Number.isInteger(numericPid)) { + return null; + } + + if (commandLineCache && commandLineCache.has(numericPid)) { + return commandLineCache.get(numericPid); + } + + const queryAttempts = ['powershell', 'pwsh']; + for (const shellName of queryAttempts) { + let result = null; + try { + result = queryWindowsProcessCommandLine({ + pid: numericPid, + shellName, + spawnSync, + timeoutMs, + }); + } catch (error) { + if (error instanceof Error && error.message) { + log( + `Failed to query process command line by ${shellName} for pid=${numericPid}: ${error.message}`, + ); + } + continue; + } + + if (result.error && result.error.code === 'ENOENT') { + continue; + } + if (result.error && result.error.code === 'ETIMEDOUT') { + log( + `Timed out (${timeoutMs}ms) querying process command line by ${shellName} for pid=${numericPid}.`, + ); + continue; + } + + if (result.status === 0) { + const commandLine = parseWindowsProcessCommandLine(result); + if (commandLineCache) { + commandLineCache.set(numericPid, commandLine); + } + return commandLine; + } + } + + if (commandLineCache) { + commandLineCache.set(numericPid, null); + } + return null; +} + +function getFallbackWindowsBackendImageName(fallbackCmdRaw) { + const fallbackCmd = String(fallbackCmdRaw || 'python.exe') + .trim() + .split(/\s+/, 1)[0]; + return path.basename(fallbackCmd || 'python.exe').toLowerCase(); +} + +function getExpectedWindowsBackendImageName(backendConfig, fallbackCmdRaw) { + const safeBackendConfig = + backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; + return path + .basename(safeBackendConfig.cmd || getFallbackWindowsBackendImageName(fallbackCmdRaw)) + .toLowerCase(); +} + +function buildBackendCommandLineMarkers(backendConfig) { + const safeBackendConfig = + backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; + if (!Array.isArray(safeBackendConfig.args) || safeBackendConfig.args.length === 0) { + return []; + } + + const primaryArg = safeBackendConfig.args[0]; + if (typeof primaryArg !== 'string' || !primaryArg) { + return []; + } + + const resolvedPrimaryArg = path.isAbsolute(primaryArg) + ? primaryArg + : path.resolve(safeBackendConfig.cwd || process.cwd(), primaryArg); + return [ + normalizeWindowsPathForMatch(resolvedPrimaryArg), + normalizeWindowsPathForMatch(path.basename(primaryArg)), + ]; +} + +function shouldKillUnmanagedBackendProcess({ + pid, + processInfo, + backendConfig, + allowImageOnlyMatch, + commandLineCache, + spawnSync, + log, + fallbackCmdRaw, +}) { + const expectedImageName = getExpectedWindowsBackendImageName(backendConfig, fallbackCmdRaw); + const actualImageName = processInfo.imageName.toLowerCase(); + if (actualImageName !== expectedImageName) { + log( + `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, + ); + return false; + } + + if (allowImageOnlyMatch || !isGenericWindowsPythonImage(expectedImageName)) { + return true; + } + + const markers = buildBackendCommandLineMarkers(backendConfig); + if (!markers.length) { + log(`Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`); + return false; + } + + const commandLine = getWindowsProcessCommandLine({ + pid, + commandLineCache, + spawnSync, + log, + timeoutMs: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, + }); + if (!commandLine) { + log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process command line.`); + return false; + } + + const normalizedCommandLine = normalizeWindowsPathForMatch(commandLine); + const matched = markers.some((marker) => marker && normalizedCommandLine.includes(marker)); + if (!matched) { + log( + `Skip unmanaged cleanup for pid=${pid}: command line does not match AstrBot backend launch marker.`, + ); + } + return matched; +} + +module.exports = { + shouldKillUnmanagedBackendProcess, +}; diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index 46fd8fcd8..65bf0139f 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -1,8 +1,17 @@ import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { + copyTree, + resolveAndValidateRuntimeSource, + resolveRuntimePython, +} from './runtime-layout-utils.mjs'; +import { + resolveExpectedRuntimeVersion, + validateRuntimePython, +} from './runtime-version-utils.mjs'; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..', '..'); const outputDir = path.join(rootDir, 'desktop', 'resources', 'backend'); @@ -16,233 +25,6 @@ const runtimeSource = process.env.ASTRBOT_DESKTOP_CPYTHON_HOME; const requirePipProbe = process.env.ASTRBOT_DESKTOP_REQUIRE_PIP === '1'; -const resolveAndValidateRuntimeSource = () => { - if (!runtimeSource) { - throw new Error( - 'Missing CPython runtime source. Set ASTRBOT_DESKTOP_CPYTHON_HOME ' + - '(recommended) or ASTRBOT_DESKTOP_BACKEND_RUNTIME.', - ); - } - - const runtimeSourceReal = path.resolve(rootDir, runtimeSource); - if (!fs.existsSync(runtimeSourceReal)) { - throw new Error(`CPython runtime source does not exist: ${runtimeSourceReal}`); - } - - const normalizeForCompare = (targetPath) => { - const resolved = path.resolve(targetPath).replace(/[\\/]+$/, ''); - return process.platform === 'win32' ? resolved.toLowerCase() : resolved; - }; - - const runtimeNorm = normalizeForCompare(runtimeSourceReal); - const outputNorm = normalizeForCompare(outputDir); - const runtimeIsOutputOrSub = - runtimeNorm === outputNorm || runtimeNorm.startsWith(`${outputNorm}${path.sep}`); - const outputIsRuntimeOrSub = - outputNorm === runtimeNorm || outputNorm.startsWith(`${runtimeNorm}${path.sep}`); - - if (runtimeIsOutputOrSub || outputIsRuntimeOrSub) { - throw new Error( - `CPython runtime source overlaps with backend output directory. ` + - `runtime=${runtimeSourceReal}, output=${outputDir}. ` + - 'Please set ASTRBOT_DESKTOP_CPYTHON_HOME to a separate runtime directory.', - ); - } - - return runtimeSourceReal; -}; - -const parseExpectedRuntimeVersion = (rawVersion, sourceName) => { - const match = /^(\d+)\.(\d+)$/.exec(String(rawVersion).trim()); - if (!match) { - throw new Error( - `Invalid expected Python version from ${sourceName}: ${rawVersion}. ` + - 'Expected format ..', - ); - } - return { - major: Number.parseInt(match[1], 10), - minor: Number.parseInt(match[2], 10), - }; -}; - -const extractLowerBoundFromPythonSpecifier = (rawSpecifier) => { - if (typeof rawSpecifier !== 'string' || !rawSpecifier.trim()) { - return null; - } - - const clauses = rawSpecifier.replace(/\s+/g, '').split(',').filter(Boolean); - let bestLowerBound = null; - - const updateLowerBound = (major, minor) => { - if ( - !bestLowerBound || - major > bestLowerBound.major || - (major === bestLowerBound.major && minor > bestLowerBound.minor) - ) { - bestLowerBound = { major, minor }; - } - }; - - for (const clause of clauses) { - const match = /^(>=|>|==|~=)(\d+)(?:\.(\d+))?$/.exec(clause); - if (!match) { - continue; - } - - const operator = match[1]; - let major = Number.parseInt(match[2], 10); - let minor = Number.parseInt(match[3] || '0', 10); - if (operator === '>') { - if (match[3]) { - minor += 1; - } else { - major += 1; - minor = 0; - } - } - updateLowerBound(major, minor); - } - - if (!bestLowerBound) { - return null; - } - return `${bestLowerBound.major}.${bestLowerBound.minor}`; -}; - -const parsePyprojectProbeOutput = (stdoutText) => { - const lines = String(stdoutText || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - for (let i = lines.length - 1; i >= 0; i -= 1) { - try { - const parsed = JSON.parse(lines[i]); - if (parsed && typeof parsed === 'object') { - return parsed; - } - } catch {} - } - return null; -}; - -const readProjectRequiresPythonLowerBound = () => { - const pyprojectPath = path.join(rootDir, 'pyproject.toml'); - if (!fs.existsSync(pyprojectPath)) { - return null; - } - - const pyprojectProbeScript = `import json -import pathlib -import sys - -path = pathlib.Path(sys.argv[1]) -try: - import tomllib -except Exception: - try: - import tomli as tomllib - except Exception: - print(json.dumps({"requires_python": None})) - raise SystemExit(0) - -try: - data = tomllib.loads(path.read_text(encoding="utf-8")) -except Exception: - print(json.dumps({"requires_python": None})) - raise SystemExit(0) - -project = data.get("project") if isinstance(data, dict) else None -requires_python = project.get("requires-python") if isinstance(project, dict) else None -print(json.dumps({"requires_python": requires_python})) -`; - - const probeCommands = - process.platform === 'win32' - ? [ - { cmd: 'python', prefixArgs: [] }, - { cmd: 'py', prefixArgs: ['-3'] }, - ] - : [ - { cmd: 'python3', prefixArgs: [] }, - { cmd: 'python', prefixArgs: [] }, - ]; - - for (const probeCommand of probeCommands) { - const probe = spawnSync( - probeCommand.cmd, - [...probeCommand.prefixArgs, '-c', pyprojectProbeScript, pyprojectPath], - { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - windowsHide: true, - timeout: 5000, - }, - ); - if (probe.error && probe.error.code === 'ENOENT') { - continue; - } - if (probe.error || probe.status !== 0) { - continue; - } - - const parsedOutput = parsePyprojectProbeOutput(probe.stdout); - const requiresPythonSpecifier = parsedOutput?.requires_python; - const lowerBound = extractLowerBoundFromPythonSpecifier(requiresPythonSpecifier); - if (lowerBound) { - return lowerBound; - } - } - - return null; -}; - -const resolveExpectedRuntimeVersion = () => { - if (process.env.ASTRBOT_DESKTOP_EXPECTED_PYTHON) { - return { - expectedRuntimeVersion: parseExpectedRuntimeVersion( - process.env.ASTRBOT_DESKTOP_EXPECTED_PYTHON, - 'ASTRBOT_DESKTOP_EXPECTED_PYTHON', - ), - isLowerBoundRuntimeVersion: false, - source: 'ASTRBOT_DESKTOP_EXPECTED_PYTHON', - }; - } - - const projectLowerBound = readProjectRequiresPythonLowerBound(); - if (projectLowerBound) { - return { - expectedRuntimeVersion: parseExpectedRuntimeVersion( - projectLowerBound, - 'pyproject.toml requires-python', - ), - isLowerBoundRuntimeVersion: true, - source: 'pyproject.toml requires-python', - }; - } - - throw new Error( - 'Unable to determine expected runtime Python version. ' + - 'Set ASTRBOT_DESKTOP_EXPECTED_PYTHON or declare project.requires-python in pyproject.toml.', - ); -}; - -const compareMajorMinor = (left, right) => { - if (left.major < right.major) { - return -1; - } - if (left.major > right.major) { - return 1; - } - if (left.minor < right.minor) { - return -1; - } - if (left.minor > right.minor) { - return 1; - } - return 0; -}; - const sourceEntries = [ ['astrbot', 'astrbot'], ['main.py', 'main.py'], @@ -250,119 +32,6 @@ const sourceEntries = [ ['requirements.txt', 'requirements.txt'], ]; -const shouldCopy = (srcPath) => { - const base = path.basename(srcPath); - if (base === '__pycache__' || base === '.pytest_cache' || base === '.ruff_cache') { - return false; - } - if (base === '.git' || base === '.mypy_cache' || base === '.DS_Store') { - return false; - } - if (base.endsWith('.pyc') || base.endsWith('.pyo')) { - return false; - } - return true; -}; - -const copyTree = (fromPath, toPath, { dereference = false } = {}) => { - fs.cpSync(fromPath, toPath, { - recursive: true, - force: true, - filter: shouldCopy, - dereference, - }); -}; - -const resolveRuntimePython = (runtimeRoot) => { - const candidates = - process.platform === 'win32' - ? ['python.exe', path.join('Scripts', 'python.exe')] - : [path.join('bin', 'python3'), path.join('bin', 'python')]; - - for (const relativeCandidate of candidates) { - const candidate = path.join(runtimeRoot, relativeCandidate); - if (fs.existsSync(candidate)) { - return { - absolute: candidate, - relative: path.relative(outputDir, candidate), - }; - } - } - - return null; -}; - -const validateRuntimePython = (pythonExecutable, expectedRuntimeConstraint) => { - const probeScript = requirePipProbe - ? 'import sys, pip; print(sys.version_info[0], sys.version_info[1])' - : 'import sys; print(sys.version_info[0], sys.version_info[1])'; - const probe = spawnSync( - pythonExecutable, - ['-c', probeScript], - { - stdio: ['ignore', 'pipe', 'pipe'], - encoding: 'utf8', - windowsHide: true, - timeout: 5000, - }, - ); - - if (probe.error) { - const reason = - probe.error.code === 'ETIMEDOUT' - ? 'runtime Python probe timed out' - : probe.error.message || String(probe.error); - throw new Error(`Runtime Python probe failed: ${reason}`); - } - - if (probe.status !== 0) { - const stderrText = (probe.stderr || '').trim(); - if (requirePipProbe) { - throw new Error( - `Runtime Python probe failed with exit code ${probe.status}. ` + - `pip import check is enabled by ASTRBOT_DESKTOP_REQUIRE_PIP=1. ` + - (stderrText ? `stderr: ${stderrText}` : ''), - ); - } - throw new Error( - `Runtime Python probe failed with exit code ${probe.status}. ` + - (stderrText ? `stderr: ${stderrText}` : ''), - ); - } - - const parts = (probe.stdout || '').trim().split(/\s+/); - if (parts.length < 2) { - throw new Error( - `Runtime Python probe did not report a valid version. Output: ${(probe.stdout || '').trim()}`, - ); - } - - const actualVersion = { - major: Number.parseInt(parts[0], 10), - minor: Number.parseInt(parts[1], 10), - }; - const expectedRuntimeVersion = expectedRuntimeConstraint.expectedRuntimeVersion; - const compareResult = compareMajorMinor(actualVersion, expectedRuntimeVersion); - if (expectedRuntimeConstraint.isLowerBoundRuntimeVersion) { - if (compareResult < 0) { - throw new Error( - `Runtime Python version is too low for ${expectedRuntimeConstraint.source}: ` + - `expected >= ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + - `got ${actualVersion.major}.${actualVersion.minor}.`, - ); - } - return; - } - - if (compareResult !== 0) { - throw new Error( - `Runtime Python version mismatch for ${expectedRuntimeConstraint.source}: ` + - `expected ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + - `got ${actualVersion.major}.${actualVersion.minor}.`, - ); - } -}; - const writeLauncherScript = () => { const content = `from __future__ import annotations @@ -386,17 +55,28 @@ runpy.run_path(str(main_file), run_name="__main__") }; const main = () => { - const runtimeSourceReal = resolveAndValidateRuntimeSource(); - const expectedRuntimeConstraint = resolveExpectedRuntimeVersion(); + const runtimeSourceReal = resolveAndValidateRuntimeSource({ + rootDir, + outputDir, + runtimeSource, + }); + const expectedRuntimeConstraint = resolveExpectedRuntimeVersion({ rootDir }); - const sourceRuntimePython = resolveRuntimePython(runtimeSourceReal); + const sourceRuntimePython = resolveRuntimePython({ + runtimeRoot: runtimeSourceReal, + outputDir, + }); if (!sourceRuntimePython) { throw new Error( `Cannot find Python executable in runtime source: ${runtimeSourceReal}. ` + 'Expected python under bin/ or Scripts/.', ); } - validateRuntimePython(sourceRuntimePython.absolute, expectedRuntimeConstraint); + validateRuntimePython({ + pythonExecutable: sourceRuntimePython.absolute, + expectedRuntimeConstraint, + requirePipProbe, + }); fs.rmSync(outputDir, { recursive: true, force: true }); fs.mkdirSync(outputDir, { recursive: true }); @@ -413,7 +93,10 @@ const main = () => { copyTree(runtimeSourceReal, runtimeDir, { dereference: true }); - const runtimePython = resolveRuntimePython(runtimeDir); + const runtimePython = resolveRuntimePython({ + runtimeRoot: runtimeDir, + outputDir, + }); if (!runtimePython) { throw new Error( `Cannot find Python executable in runtime: ${runtimeDir}. ` + diff --git a/desktop/scripts/read-requires-python.py b/desktop/scripts/read-requires-python.py new file mode 100644 index 000000000..96450cf54 --- /dev/null +++ b/desktop/scripts/read-requires-python.py @@ -0,0 +1,24 @@ +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) + +try: + import tomllib +except Exception: + try: + import tomli as tomllib + except Exception: + print(json.dumps({"requires_python": None})) + raise SystemExit(0) + +try: + data = tomllib.loads(path.read_text(encoding="utf-8")) +except Exception: + print(json.dumps({"requires_python": None})) + raise SystemExit(0) + +project = data.get("project") if isinstance(data, dict) else None +requires_python = project.get("requires-python") if isinstance(project, dict) else None +print(json.dumps({"requires_python": requires_python})) diff --git a/desktop/scripts/runtime-layout-utils.mjs b/desktop/scripts/runtime-layout-utils.mjs new file mode 100644 index 000000000..fa6d86444 --- /dev/null +++ b/desktop/scripts/runtime-layout-utils.mjs @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const shouldCopy = (sourcePath) => { + const base = path.basename(sourcePath); + if (base === '__pycache__' || base === '.pytest_cache' || base === '.ruff_cache') { + return false; + } + if (base === '.git' || base === '.mypy_cache' || base === '.DS_Store') { + return false; + } + if (base.endsWith('.pyc') || base.endsWith('.pyo')) { + return false; + } + return true; +}; + +export const copyTree = (fromPath, toPath, { dereference = false } = {}) => { + fs.cpSync(fromPath, toPath, { + recursive: true, + force: true, + filter: shouldCopy, + dereference, + }); +}; + +export const resolveAndValidateRuntimeSource = ({ rootDir, outputDir, runtimeSource }) => { + if (!runtimeSource) { + throw new Error( + 'Missing CPython runtime source. Set ASTRBOT_DESKTOP_CPYTHON_HOME ' + + '(recommended) or ASTRBOT_DESKTOP_BACKEND_RUNTIME.', + ); + } + + const runtimeSourceReal = path.resolve(rootDir, runtimeSource); + if (!fs.existsSync(runtimeSourceReal)) { + throw new Error(`CPython runtime source does not exist: ${runtimeSourceReal}`); + } + + const normalizeForCompare = (targetPath) => { + const resolved = path.resolve(targetPath).replace(/[\\/]+$/, ''); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + }; + + const runtimeNorm = normalizeForCompare(runtimeSourceReal); + const outputNorm = normalizeForCompare(outputDir); + const runtimeIsOutputOrSub = + runtimeNorm === outputNorm || runtimeNorm.startsWith(`${outputNorm}${path.sep}`); + const outputIsRuntimeOrSub = + outputNorm === runtimeNorm || outputNorm.startsWith(`${runtimeNorm}${path.sep}`); + + if (runtimeIsOutputOrSub || outputIsRuntimeOrSub) { + throw new Error( + `CPython runtime source overlaps with backend output directory. ` + + `runtime=${runtimeSourceReal}, output=${outputDir}. ` + + 'Please set ASTRBOT_DESKTOP_CPYTHON_HOME to a separate runtime directory.', + ); + } + + return runtimeSourceReal; +}; + +export const resolveRuntimePython = ({ runtimeRoot, outputDir }) => { + const candidates = + process.platform === 'win32' + ? ['python.exe', path.join('Scripts', 'python.exe')] + : [path.join('bin', 'python3'), path.join('bin', 'python')]; + + for (const relativeCandidate of candidates) { + const candidate = path.join(runtimeRoot, relativeCandidate); + if (fs.existsSync(candidate)) { + return { + absolute: candidate, + relative: path.relative(outputDir, candidate), + }; + } + } + + return null; +}; diff --git a/desktop/scripts/runtime-version-utils.mjs b/desktop/scripts/runtime-version-utils.mjs new file mode 100644 index 000000000..fadd44ef4 --- /dev/null +++ b/desktop/scripts/runtime-version-utils.mjs @@ -0,0 +1,247 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const readRequiresPythonScriptPath = path.join(__dirname, 'read-requires-python.py'); + +const parseExpectedRuntimeVersion = (rawVersion, sourceName) => { + const match = /^(\d+)\.(\d+)$/.exec(String(rawVersion).trim()); + if (!match) { + throw new Error( + `Invalid expected Python version from ${sourceName}: ${rawVersion}. ` + + 'Expected format ..', + ); + } + return { + major: Number.parseInt(match[1], 10), + minor: Number.parseInt(match[2], 10), + }; +}; + +const extractLowerBoundFromPythonSpecifier = (rawSpecifier) => { + if (typeof rawSpecifier !== 'string' || !rawSpecifier.trim()) { + return null; + } + + const clauses = rawSpecifier.replace(/\s+/g, '').split(',').filter(Boolean); + let bestLowerBound = null; + + const updateLowerBound = (major, minor) => { + if ( + !bestLowerBound || + major > bestLowerBound.major || + (major === bestLowerBound.major && minor > bestLowerBound.minor) + ) { + bestLowerBound = { major, minor }; + } + }; + + for (const clause of clauses) { + const match = /^(>=|>|==|~=)(\d+)(?:\.(\d+))?$/.exec(clause); + if (!match) { + continue; + } + + const operator = match[1]; + let major = Number.parseInt(match[2], 10); + let minor = Number.parseInt(match[3] || '0', 10); + if (operator === '>') { + if (match[3]) { + minor += 1; + } else { + major += 1; + minor = 0; + } + } + updateLowerBound(major, minor); + } + + if (!bestLowerBound) { + return null; + } + return `${bestLowerBound.major}.${bestLowerBound.minor}`; +}; + +const parsePyprojectProbeOutput = (stdoutText) => { + const lines = String(stdoutText || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + for (let i = lines.length - 1; i >= 0; i -= 1) { + try { + const parsed = JSON.parse(lines[i]); + if (parsed && typeof parsed === 'object') { + return parsed; + } + } catch {} + } + return null; +}; + +const readProjectRequiresPythonLowerBound = (rootDir) => { + const pyprojectPath = path.join(rootDir, 'pyproject.toml'); + if (!fs.existsSync(pyprojectPath)) { + return null; + } + if (!fs.existsSync(readRequiresPythonScriptPath)) { + return null; + } + + const probeCommands = + process.platform === 'win32' + ? [ + { cmd: 'python', prefixArgs: [] }, + { cmd: 'py', prefixArgs: ['-3'] }, + ] + : [ + { cmd: 'python3', prefixArgs: [] }, + { cmd: 'python', prefixArgs: [] }, + ]; + + for (const probeCommand of probeCommands) { + const probe = spawnSync( + probeCommand.cmd, + [...probeCommand.prefixArgs, readRequiresPythonScriptPath, pyprojectPath], + { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + windowsHide: true, + timeout: 5000, + }, + ); + if (probe.error && probe.error.code === 'ENOENT') { + continue; + } + if (probe.error || probe.status !== 0) { + continue; + } + + const parsedOutput = parsePyprojectProbeOutput(probe.stdout); + const requiresPythonSpecifier = parsedOutput?.requires_python; + const lowerBound = extractLowerBoundFromPythonSpecifier(requiresPythonSpecifier); + if (lowerBound) { + return lowerBound; + } + } + + return null; +}; + +const compareMajorMinor = (left, right) => { + if (left.major < right.major) { + return -1; + } + if (left.major > right.major) { + return 1; + } + if (left.minor < right.minor) { + return -1; + } + if (left.minor > right.minor) { + return 1; + } + return 0; +}; + +export const resolveExpectedRuntimeVersion = ({ rootDir }) => { + if (process.env.ASTRBOT_DESKTOP_EXPECTED_PYTHON) { + return { + expectedRuntimeVersion: parseExpectedRuntimeVersion( + process.env.ASTRBOT_DESKTOP_EXPECTED_PYTHON, + 'ASTRBOT_DESKTOP_EXPECTED_PYTHON', + ), + isLowerBoundRuntimeVersion: false, + source: 'ASTRBOT_DESKTOP_EXPECTED_PYTHON', + }; + } + + const projectLowerBound = readProjectRequiresPythonLowerBound(rootDir); + if (projectLowerBound) { + return { + expectedRuntimeVersion: parseExpectedRuntimeVersion( + projectLowerBound, + 'pyproject.toml requires-python', + ), + isLowerBoundRuntimeVersion: true, + source: 'pyproject.toml requires-python', + }; + } + + throw new Error( + 'Unable to determine expected runtime Python version. ' + + 'Set ASTRBOT_DESKTOP_EXPECTED_PYTHON or declare project.requires-python in pyproject.toml.', + ); +}; + +export const validateRuntimePython = ({ + pythonExecutable, + expectedRuntimeConstraint, + requirePipProbe, +}) => { + const probeScript = requirePipProbe + ? 'import sys, pip; print(sys.version_info[0], sys.version_info[1])' + : 'import sys; print(sys.version_info[0], sys.version_info[1])'; + const probe = spawnSync(pythonExecutable, ['-c', probeScript], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + windowsHide: true, + timeout: 5000, + }); + + if (probe.error) { + const reason = + probe.error.code === 'ETIMEDOUT' + ? 'runtime Python probe timed out' + : probe.error.message || String(probe.error); + throw new Error(`Runtime Python probe failed: ${reason}`); + } + + if (probe.status !== 0) { + const stderrText = (probe.stderr || '').trim(); + if (requirePipProbe) { + throw new Error( + `Runtime Python probe failed with exit code ${probe.status}. ` + + `pip import check is enabled by ASTRBOT_DESKTOP_REQUIRE_PIP=1. ` + + (stderrText ? `stderr: ${stderrText}` : ''), + ); + } + throw new Error( + `Runtime Python probe failed with exit code ${probe.status}. ` + + (stderrText ? `stderr: ${stderrText}` : ''), + ); + } + + const parts = (probe.stdout || '').trim().split(/\s+/); + if (parts.length < 2) { + throw new Error( + `Runtime Python probe did not report a valid version. Output: ${(probe.stdout || '').trim()}`, + ); + } + + const actualVersion = { + major: Number.parseInt(parts[0], 10), + minor: Number.parseInt(parts[1], 10), + }; + const expectedRuntimeVersion = expectedRuntimeConstraint.expectedRuntimeVersion; + const compareResult = compareMajorMinor(actualVersion, expectedRuntimeVersion); + if (expectedRuntimeConstraint.isLowerBoundRuntimeVersion) { + if (compareResult < 0) { + throw new Error( + `Runtime Python version is too low for ${expectedRuntimeConstraint.source}: ` + + `expected >= ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + + `got ${actualVersion.major}.${actualVersion.minor}.`, + ); + } + return; + } + + if (compareResult !== 0) { + throw new Error( + `Runtime Python version mismatch for ${expectedRuntimeConstraint.source}: ` + + `expected ${expectedRuntimeVersion.major}.${expectedRuntimeVersion.minor}, ` + + `got ${actualVersion.major}.${actualVersion.minor}.`, + ); + } +}; From fb2a41b272ac7ccebaab276c6fbcc110aa5fce03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 13:27:52 +0900 Subject: [PATCH 13/49] refactor(core): remove pyinstaller-specific reboot reset --- astrbot/core/updator.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index 049a19789..a5a6d93d5 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -85,10 +85,9 @@ def _build_frozen_reboot_args(self) -> list[str]: return [] @staticmethod - def _reset_pyinstaller_environment() -> None: + def _reset_frozen_bootloader_environment() -> None: if not getattr(sys, "frozen", False): return - os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = "1" for key in list(os.environ.keys()): if key.startswith("_PYI_"): os.environ.pop(key, None) @@ -121,7 +120,7 @@ def _reboot(self, delay: int = 3) -> None: executable = sys.executable try: - self._reset_pyinstaller_environment() + self._reset_frozen_bootloader_environment() reboot_argv = self._build_reboot_argv(executable) self._exec_reboot(executable, reboot_argv) except Exception as e: From 4260a95bbeefc01f1dcd589fb6f927100e599b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 13:31:01 +0900 Subject: [PATCH 14/49] refactor(core): remove frozen-runtime legacy branches --- astrbot/core/updator.py | 57 ------------------------------- astrbot/core/utils/runtime_env.py | 5 --- 2 files changed, 62 deletions(-) diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index a5a6d93d5..165cfb95c 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -44,70 +44,14 @@ def terminate_child_processes(self) -> None: except psutil.NoSuchProcess: pass - @staticmethod - def _is_option_arg(arg: str) -> bool: - return arg.startswith("-") - - @classmethod - def _collect_flag_values(cls, argv: list[str], flag: str) -> str | None: - try: - idx = argv.index(flag) - except ValueError: - return None - - if idx + 1 >= len(argv): - return None - - value_parts: list[str] = [] - for arg in argv[idx + 1 :]: - if cls._is_option_arg(arg): - break - if arg: - value_parts.append(arg) - - if not value_parts: - return None - - return " ".join(value_parts).strip() or None - - @classmethod - def _resolve_webui_dir_arg(cls, argv: list[str]) -> str | None: - return cls._collect_flag_values(argv, "--webui-dir") - - def _build_frozen_reboot_args(self) -> list[str]: - argv = list(sys.argv[1:]) - webui_dir = self._resolve_webui_dir_arg(argv) - if not webui_dir: - webui_dir = os.environ.get("ASTRBOT_WEBUI_DIR") - - if webui_dir: - return ["--webui-dir", webui_dir] - return [] - - @staticmethod - def _reset_frozen_bootloader_environment() -> None: - if not getattr(sys, "frozen", False): - return - for key in list(os.environ.keys()): - if key.startswith("_PYI_"): - os.environ.pop(key, None) - def _build_reboot_argv(self, executable: str) -> list[str]: if os.environ.get("ASTRBOT_CLI") == "1": args = sys.argv[1:] return [executable, "-m", "astrbot.cli.__main__", *args] - if getattr(sys, "frozen", False): - args = self._build_frozen_reboot_args() - return [executable, *args] return [executable, *sys.argv] @staticmethod def _exec_reboot(executable: str, argv: list[str]) -> None: - if os.name == "nt" and getattr(sys, "frozen", False): - quoted_executable = f'"{executable}"' if " " in executable else executable - quoted_args = [f'"{arg}"' if " " in arg else arg for arg in argv[1:]] - os.execl(executable, quoted_executable, *quoted_args) - return os.execv(executable, argv) def _reboot(self, delay: int = 3) -> None: @@ -120,7 +64,6 @@ def _reboot(self, delay: int = 3) -> None: executable = sys.executable try: - self._reset_frozen_bootloader_environment() reboot_argv = self._build_reboot_argv(executable) self._exec_reboot(executable, reboot_argv) except Exception as e: diff --git a/astrbot/core/utils/runtime_env.py b/astrbot/core/utils/runtime_env.py index fe84b178e..260309a4e 100644 --- a/astrbot/core/utils/runtime_env.py +++ b/astrbot/core/utils/runtime_env.py @@ -1,9 +1,4 @@ import os -import sys - - -def is_frozen_runtime() -> bool: - return bool(getattr(sys, "frozen", False)) def is_packaged_electron_runtime() -> bool: From 9f99b95849363394249b088ed9eb81f4ebbc91b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 13:44:21 +0900 Subject: [PATCH 15/49] docs: use venv runtime env var in desktop readme --- desktop/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/README.md b/desktop/README.md index d784d1ab3..9c519c626 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -33,7 +33,8 @@ Run commands from repository root: ```bash uv sync -export ASTRBOT_DESKTOP_CPYTHON_HOME=/path/to/cpython-runtime +export ASTRBOT_DESKTOP_CPYTHON_HOME="$(pwd)/.venv" +# export ASTRBOT_DESKTOP_CPYTHON_HOME=/path/to/cpython-runtime pnpm --dir dashboard install pnpm --dir dashboard build pnpm --dir desktop install --frozen-lockfile From 5512afbec5b95dd2d0c24c5eaf6fdb18e9f08807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 14:02:01 +0900 Subject: [PATCH 16/49] fix: block desktop packaged online update paths --- astrbot/core/updator.py | 11 ++++++++--- astrbot/dashboard/routes/plugin.py | 7 ++++--- astrbot/dashboard/routes/update.py | 10 ++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index 165cfb95c..61bf8aed6 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -8,6 +8,7 @@ from astrbot.core.config.default import VERSION from astrbot.core.utils.astrbot_path import get_astrbot_path from astrbot.core.utils.io import download_file +from astrbot.core.utils.runtime_env import is_packaged_electron_runtime from .zip_updator import ReleaseInfo, RepoZipUpdator @@ -87,11 +88,15 @@ async def get_releases(self) -> list: return await self.fetch_release_info(self.ASTRBOT_RELEASE_API) async def update(self, reboot=False, latest=True, version=None, proxy="") -> None: - update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest) - file_url = None - if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"): raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱 + if is_packaged_electron_runtime(): + raise Exception( + "桌面打包版不支持在线更新,请下载最新安装包并替换当前应用。" + ) + + update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest) + file_url = None if latest: latest_version = update_data[0]["tag_name"] diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index ca271cdf6..58f06de10 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -20,7 +20,7 @@ from astrbot.core.star.filter.regex import RegexFilter from astrbot.core.star.star_handler import EventType, star_handlers_registry from astrbot.core.star.star_manager import PluginManager -from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path from .route import Response, Route, RouteContext @@ -195,10 +195,11 @@ async def get_online_plugins(self): def _build_registry_source(self, custom_url: str | None) -> RegistrySource: """构建注册表源信息""" + data_dir = get_astrbot_data_path() if custom_url: # 对自定义URL生成一个安全的文件名 url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8] - cache_file = f"data/plugins_custom_{url_hash}.json" + cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json") # 更安全的后缀处理方式 if custom_url.endswith(".json"): @@ -208,7 +209,7 @@ def _build_registry_source(self, custom_url: str | None) -> RegistrySource: urls = [custom_url] else: - cache_file = "data/plugins.json" + cache_file = os.path.join(data_dir, "plugins.json") md5_url = "https://api.soulter.top/astrbot/plugins-md5" urls = [ "https://api.soulter.top/astrbot/plugins", diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index b0520c315..8e64a312c 100644 --- a/astrbot/dashboard/routes/update.py +++ b/astrbot/dashboard/routes/update.py @@ -8,10 +8,14 @@ from astrbot.core.db.migration.helper import check_migration_needed_v4, do_migration_v4 from astrbot.core.updator import AstrBotUpdator from astrbot.core.utils.io import download_dashboard, get_dashboard_version +from astrbot.core.utils.runtime_env import is_packaged_electron_runtime from .route import Response, Route, RouteContext CLEAR_SITE_DATA_HEADERS = {"Clear-Site-Data": '"cache"'} +DESKTOP_PACKAGED_UPDATE_BLOCK_MESSAGE = ( + "桌面打包版不支持在线更新。请下载最新安装包并替换当前应用。" +) class UpdateRoute(Route): @@ -86,6 +90,9 @@ async def get_releases(self): return Response().error(e.__str__()).__dict__ async def update_project(self): + if is_packaged_electron_runtime(): + return Response().error(DESKTOP_PACKAGED_UPDATE_BLOCK_MESSAGE).__dict__ + data = await request.json version = data.get("version", "") reboot = data.get("reboot", True) @@ -137,6 +144,9 @@ async def update_project(self): return Response().error(e.__str__()).__dict__ async def update_dashboard(self): + if is_packaged_electron_runtime(): + return Response().error(DESKTOP_PACKAGED_UPDATE_BLOCK_MESSAGE).__dict__ + try: try: await download_dashboard(version=f"v{VERSION}", latest=False) From 8a1f58ace8a96e8e02859a1d59ce51408d888b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 14:21:07 +0900 Subject: [PATCH 17/49] fix: use writable cwd for packaged backend runtime --- desktop/lib/backend-manager.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index b08943063..617f7204b 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -117,8 +117,7 @@ class BackendManager { if (!this.app.isPackaged) { return path.resolve(this.baseDir, '..'); } - const packagedBackendState = this.getPackagedBackendState(); - return (packagedBackendState?.config?.appDir || null) || this.resolveBackendRoot(); + return this.resolveBackendRoot(); } resolveWebuiDir() { From 0300d5624531fe1277f4a9e82e0a4b13f13b7730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 14:42:34 +0900 Subject: [PATCH 18/49] refactor: simplify desktop runtime build and cleanup flow --- astrbot/core/updator.py | 7 +- astrbot/core/utils/update_guard.py | 18 +++ astrbot/dashboard/routes/update.py | 16 +-- desktop/lib/backend-manager.js | 80 +++++++++---- desktop/lib/windows-backend-cleanup.js | 124 +++++++++++--------- desktop/scripts/build-backend.mjs | 104 ++++++++-------- desktop/scripts/runtime-version-utils.mjs | 17 +-- desktop/scripts/templates/launch_backend.py | 17 +++ 8 files changed, 226 insertions(+), 157 deletions(-) create mode 100644 astrbot/core/utils/update_guard.py create mode 100644 desktop/scripts/templates/launch_backend.py diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index 61bf8aed6..a6cdafda3 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -8,7 +8,7 @@ from astrbot.core.config.default import VERSION from astrbot.core.utils.astrbot_path import get_astrbot_path from astrbot.core.utils.io import download_file -from astrbot.core.utils.runtime_env import is_packaged_electron_runtime +from astrbot.core.utils.update_guard import ensure_packaged_update_allowed from .zip_updator import ReleaseInfo, RepoZipUpdator @@ -90,10 +90,7 @@ async def get_releases(self) -> list: async def update(self, reboot=False, latest=True, version=None, proxy="") -> None: if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"): raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱 - if is_packaged_electron_runtime(): - raise Exception( - "桌面打包版不支持在线更新,请下载最新安装包并替换当前应用。" - ) + ensure_packaged_update_allowed() update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest) file_url = None diff --git a/astrbot/core/utils/update_guard.py b/astrbot/core/utils/update_guard.py new file mode 100644 index 000000000..0ce55a12d --- /dev/null +++ b/astrbot/core/utils/update_guard.py @@ -0,0 +1,18 @@ +from astrbot.core.utils.runtime_env import is_packaged_electron_runtime + +DESKTOP_PACKAGED_UPDATE_BLOCK_MESSAGE = ( + "桌面打包版不支持在线更新。请下载最新安装包并替换当前应用。" +) + + +def should_block_packaged_update() -> bool: + return is_packaged_electron_runtime() + + +def get_packaged_update_block_message() -> str: + return DESKTOP_PACKAGED_UPDATE_BLOCK_MESSAGE + + +def ensure_packaged_update_allowed() -> None: + if should_block_packaged_update(): + raise RuntimeError(get_packaged_update_block_message()) diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index 8e64a312c..4bde7583a 100644 --- a/astrbot/dashboard/routes/update.py +++ b/astrbot/dashboard/routes/update.py @@ -8,14 +8,14 @@ from astrbot.core.db.migration.helper import check_migration_needed_v4, do_migration_v4 from astrbot.core.updator import AstrBotUpdator from astrbot.core.utils.io import download_dashboard, get_dashboard_version -from astrbot.core.utils.runtime_env import is_packaged_electron_runtime +from astrbot.core.utils.update_guard import ( + get_packaged_update_block_message, + should_block_packaged_update, +) from .route import Response, Route, RouteContext CLEAR_SITE_DATA_HEADERS = {"Clear-Site-Data": '"cache"'} -DESKTOP_PACKAGED_UPDATE_BLOCK_MESSAGE = ( - "桌面打包版不支持在线更新。请下载最新安装包并替换当前应用。" -) class UpdateRoute(Route): @@ -90,8 +90,8 @@ async def get_releases(self): return Response().error(e.__str__()).__dict__ async def update_project(self): - if is_packaged_electron_runtime(): - return Response().error(DESKTOP_PACKAGED_UPDATE_BLOCK_MESSAGE).__dict__ + if should_block_packaged_update(): + return Response().error(get_packaged_update_block_message()).__dict__ data = await request.json version = data.get("version", "") @@ -144,8 +144,8 @@ async def update_project(self): return Response().error(e.__str__()).__dict__ async def update_dashboard(self): - if is_packaged_electron_runtime(): - return Response().error(DESKTOP_PACKAGED_UPDATE_BLOCK_MESSAGE).__dict__ + if should_block_packaged_update(): + return Response().error(get_packaged_update_block_message()).__dict__ try: try: diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 617f7204b..f1db159e4 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -162,41 +162,56 @@ class BackendManager { }; } - resolveBackendConfig() { - const webuiDir = this.resolveWebuiDir(); + resolveLaunchCommand(webuiDir) { const customCmd = process.env.ASTRBOT_BACKEND_CMD; - let launch = null; - let failureReason = null; - if (customCmd) { - launch = { - cmd: customCmd, - args: [], - shell: true, + return { + launch: { + cmd: customCmd, + args: [], + shell: true, + }, + failureReason: null, }; - } else if (this.app.isPackaged) { + } + + if (this.app.isPackaged) { const packagedBackendState = this.getPackagedBackendState(); if (packagedBackendState?.ok && packagedBackendState.config) { - launch = { - cmd: packagedBackendState.config.runtimePythonPath, - args: [packagedBackendState.config.launchScriptPath, ...(webuiDir ? ['--webui-dir', webuiDir] : [])], - shell: false, + return { + launch: { + cmd: packagedBackendState.config.runtimePythonPath, + args: [packagedBackendState.config.launchScriptPath, ...(webuiDir ? ['--webui-dir', webuiDir] : [])], + shell: false, + }, + failureReason: null, }; - } else { - failureReason = - packagedBackendState?.failureReason || 'Backend command is not configured.'; - this.log(failureReason); } - } else { - launch = this.buildDefaultBackendLaunch(webuiDir); + return { + launch: null, + failureReason: packagedBackendState?.failureReason || 'Backend command is not configured.', + }; } + return { + launch: this.buildDefaultBackendLaunch(webuiDir), + failureReason: null, + }; + } + + resolveBackendConfig() { + const webuiDir = this.resolveWebuiDir(); + const { launch, failureReason } = this.resolveLaunchCommand(webuiDir); + const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot(); ensureDir(cwd); if (rootDir) { ensureDir(rootDir); } + if (failureReason) { + this.log(failureReason); + } this.backendConfig = { cmd: launch ? launch.cmd : null, args: launch ? launch.args : [], @@ -641,6 +656,25 @@ class BackendManager { return { imageName, pid: parsedPid }; } + shouldKillUnmanagedProcess({ + pid, + processInfo, + backendConfig, + commandLineCache, + }) { + const hasBackendConfig = backendConfig && typeof backendConfig === 'object'; + return shouldKillUnmanagedBackendProcess({ + pid, + processInfo, + backendConfig, + allowImageOnlyMatch: !hasBackendConfig, + commandLineCache, + spawnSync, + log: (message) => this.log(message), + fallbackCmdRaw: process.env.ASTRBOT_BACKEND_CMD || 'python.exe', + }); + } + async stopUnmanagedBackendByPort() { if (!this.app.isPackaged || process.platform !== 'win32') { return false; @@ -684,15 +718,11 @@ class BackendManager { this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`); continue; } - const shouldKill = shouldKillUnmanagedBackendProcess({ + const shouldKill = this.shouldKillUnmanagedProcess({ pid, processInfo, backendConfig, - allowImageOnlyMatch: !hasBackendConfig, commandLineCache, - spawnSync, - log: (message) => this.log(message), - fallbackCmdRaw: process.env.ASTRBOT_BACKEND_CMD || 'python.exe', }); if (!shouldKill) { continue; diff --git a/desktop/lib/windows-backend-cleanup.js b/desktop/lib/windows-backend-cleanup.js index 99d28ecb9..25a705de6 100644 --- a/desktop/lib/windows-backend-cleanup.js +++ b/desktop/lib/windows-backend-cleanup.js @@ -3,6 +3,8 @@ const path = require('path'); const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 2000; +const COMMAND_LINE_QUERY_UNAVAILABLE_KEY = '__command_line_query_unavailable__'; +const COMMAND_LINE_FALLBACK_LOGGED_KEY = '__command_line_fallback_logged__'; function normalizeWindowsPathForMatch(value) { return String(value || '') @@ -15,56 +17,38 @@ function isGenericWindowsPythonImage(imageName) { return normalized === 'python.exe' || normalized === 'pythonw.exe' || normalized === 'py.exe'; } -function queryWindowsProcessCommandLine({ pid, shellName, spawnSync, timeoutMs }) { - const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -ne $p) { $p.CommandLine }`; - const args = ['-NoProfile', '-NonInteractive', '-Command', query]; - const options = { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - windowsHide: true, - timeout: timeoutMs, - }; - if (shellName === 'powershell') { - return spawnSync('powershell', args, options); - } - if (shellName === 'pwsh') { - return spawnSync('pwsh', args, options); - } - throw new Error(`Unsupported shell for process command line query: ${shellName}`); -} - -function parseWindowsProcessCommandLine(result) { - if (!result || !result.stdout) { - return null; - } - return ( - result.stdout - .split(/\r?\n/) - .map((item) => item.trim()) - .find((item) => item.length > 0) || null - ); -} - function getWindowsProcessCommandLine({ pid, commandLineCache, spawnSync, log, timeoutMs }) { const numericPid = Number.parseInt(`${pid}`, 10); if (!Number.isInteger(numericPid)) { - return null; + return { commandLine: null, commandLineQueryUnavailable: false }; + } + + if (commandLineCache && commandLineCache.get(COMMAND_LINE_QUERY_UNAVAILABLE_KEY) === true) { + return { commandLine: null, commandLineQueryUnavailable: true }; } if (commandLineCache && commandLineCache.has(numericPid)) { - return commandLineCache.get(numericPid); + return { + commandLine: commandLineCache.get(numericPid), + commandLineQueryUnavailable: false, + }; } + const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${numericPid}"; if ($null -ne $p) { $p.CommandLine }`; + const args = ['-NoProfile', '-NonInteractive', '-Command', query]; + const options = { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + windowsHide: true, + timeout: timeoutMs, + }; + const queryAttempts = ['powershell', 'pwsh']; + let hasAvailableShell = false; for (const shellName of queryAttempts) { let result = null; try { - result = queryWindowsProcessCommandLine({ - pid: numericPid, - shellName, - spawnSync, - timeoutMs, - }); + result = spawnSync(shellName, args, options); } catch (error) { if (error instanceof Error && error.message) { log( @@ -77,41 +61,48 @@ function getWindowsProcessCommandLine({ pid, commandLineCache, spawnSync, log, t if (result.error && result.error.code === 'ENOENT') { continue; } + hasAvailableShell = true; if (result.error && result.error.code === 'ETIMEDOUT') { log( `Timed out (${timeoutMs}ms) querying process command line by ${shellName} for pid=${numericPid}.`, ); continue; } + if (result.error) { + if (result.error.message) { + log( + `Failed to query process command line by ${shellName} for pid=${numericPid}: ${result.error.message}`, + ); + } + continue; + } if (result.status === 0) { - const commandLine = parseWindowsProcessCommandLine(result); + const commandLine = + result.stdout + .split(/\r?\n/) + .map((item) => item.trim()) + .find((item) => item.length > 0) || null; if (commandLineCache) { commandLineCache.set(numericPid, commandLine); + commandLineCache.set(COMMAND_LINE_QUERY_UNAVAILABLE_KEY, false); } - return commandLine; + return { commandLine, commandLineQueryUnavailable: false }; } } + if (!hasAvailableShell) { + if (commandLineCache) { + commandLineCache.set(COMMAND_LINE_QUERY_UNAVAILABLE_KEY, true); + } + return { commandLine: null, commandLineQueryUnavailable: true }; + } + if (commandLineCache) { commandLineCache.set(numericPid, null); + commandLineCache.set(COMMAND_LINE_QUERY_UNAVAILABLE_KEY, false); } - return null; -} - -function getFallbackWindowsBackendImageName(fallbackCmdRaw) { - const fallbackCmd = String(fallbackCmdRaw || 'python.exe') - .trim() - .split(/\s+/, 1)[0]; - return path.basename(fallbackCmd || 'python.exe').toLowerCase(); -} - -function getExpectedWindowsBackendImageName(backendConfig, fallbackCmdRaw) { - const safeBackendConfig = - backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; - return path - .basename(safeBackendConfig.cmd || getFallbackWindowsBackendImageName(fallbackCmdRaw)) - .toLowerCase(); + return { commandLine: null, commandLineQueryUnavailable: false }; } function buildBackendCommandLineMarkers(backendConfig) { @@ -145,7 +136,14 @@ function shouldKillUnmanagedBackendProcess({ log, fallbackCmdRaw, }) { - const expectedImageName = getExpectedWindowsBackendImageName(backendConfig, fallbackCmdRaw); + const safeBackendConfig = + backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; + const fallbackCmd = String(fallbackCmdRaw || 'python.exe') + .trim() + .split(/\s+/, 1)[0]; + const expectedImageName = path + .basename(safeBackendConfig.cmd || fallbackCmd || 'python.exe') + .toLowerCase(); const actualImageName = processInfo.imageName.toLowerCase(); if (actualImageName !== expectedImageName) { log( @@ -164,7 +162,7 @@ function shouldKillUnmanagedBackendProcess({ return false; } - const commandLine = getWindowsProcessCommandLine({ + const { commandLine, commandLineQueryUnavailable } = getWindowsProcessCommandLine({ pid, commandLineCache, spawnSync, @@ -172,6 +170,16 @@ function shouldKillUnmanagedBackendProcess({ timeoutMs: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, }); if (!commandLine) { + if (commandLineQueryUnavailable) { + if (commandLineCache && !commandLineCache.get(COMMAND_LINE_FALLBACK_LOGGED_KEY)) { + commandLineCache.set(COMMAND_LINE_FALLBACK_LOGGED_KEY, true); + log( + 'Neither powershell nor pwsh is available. ' + + 'Falling back to image-name-only matching for generic Python backend cleanup.', + ); + } + return true; + } log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process command line.`); return false; } diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index 65bf0139f..98189ef93 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -19,6 +19,13 @@ const appDir = path.join(outputDir, 'app'); const runtimeDir = path.join(outputDir, 'python'); const manifestPath = path.join(outputDir, 'runtime-manifest.json'); const launcherPath = path.join(outputDir, 'launch_backend.py'); +const launcherTemplatePath = path.join( + rootDir, + 'desktop', + 'scripts', + 'templates', + 'launch_backend.py', +); const runtimeSource = process.env.ASTRBOT_DESKTOP_BACKEND_RUNTIME || @@ -32,56 +39,27 @@ const sourceEntries = [ ['requirements.txt', 'requirements.txt'], ]; -const writeLauncherScript = () => { - const content = `from __future__ import annotations - -import runpy -import sys -from pathlib import Path - -BACKEND_DIR = Path(__file__).resolve().parent -APP_DIR = BACKEND_DIR / "app" - -sys.path.insert(0, str(APP_DIR)) - -main_file = APP_DIR / "main.py" -if not main_file.is_file(): - raise FileNotFoundError(f"Backend entrypoint not found: {main_file}") - -sys.argv[0] = str(main_file) -runpy.run_path(str(main_file), run_name="__main__") -`; - fs.writeFileSync(launcherPath, content, 'utf8'); -}; - -const main = () => { - const runtimeSourceReal = resolveAndValidateRuntimeSource({ - rootDir, - outputDir, - runtimeSource, - }); - const expectedRuntimeConstraint = resolveExpectedRuntimeVersion({ rootDir }); - - const sourceRuntimePython = resolveRuntimePython({ - runtimeRoot: runtimeSourceReal, +const resolveRuntimePythonOrThrow = ({ runtimeRoot, errorSubject }) => { + const runtimePython = resolveRuntimePython({ + runtimeRoot, outputDir, }); - if (!sourceRuntimePython) { + if (!runtimePython) { throw new Error( - `Cannot find Python executable in runtime source: ${runtimeSourceReal}. ` + + `Cannot find Python executable in ${errorSubject}: ${runtimeRoot}. ` + 'Expected python under bin/ or Scripts/.', ); } - validateRuntimePython({ - pythonExecutable: sourceRuntimePython.absolute, - expectedRuntimeConstraint, - requirePipProbe, - }); + return runtimePython; +}; +const prepareOutputDirs = () => { fs.rmSync(outputDir, { recursive: true, force: true }); fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(appDir, { recursive: true }); +}; +const copyAppSources = () => { for (const [srcRelative, destRelative] of sourceEntries) { const sourcePath = path.join(rootDir, srcRelative); const targetPath = path.join(appDir, destRelative); @@ -90,22 +68,25 @@ const main = () => { } copyTree(sourcePath, targetPath); } +}; +const prepareRuntimeExecutable = (runtimeSourceReal) => { copyTree(runtimeSourceReal, runtimeDir, { dereference: true }); - - const runtimePython = resolveRuntimePython({ + return resolveRuntimePythonOrThrow({ runtimeRoot: runtimeDir, - outputDir, + errorSubject: 'runtime', }); - if (!runtimePython) { - throw new Error( - `Cannot find Python executable in runtime: ${runtimeDir}. ` + - 'Expected python under bin/ or Scripts/.', - ); - } +}; - writeLauncherScript(); +const writeLauncherScript = () => { + if (!fs.existsSync(launcherTemplatePath)) { + throw new Error(`Launcher template does not exist: ${launcherTemplatePath}`); + } + const content = fs.readFileSync(launcherTemplatePath, 'utf8'); + fs.writeFileSync(launcherPath, content, 'utf8'); +}; +const writeRuntimeManifest = (runtimePython) => { const manifest = { mode: 'cpython-runtime', python: runtimePython.relative, @@ -113,6 +94,31 @@ const main = () => { app: path.relative(outputDir, appDir), }; fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); +}; + +const main = () => { + const runtimeSourceReal = resolveAndValidateRuntimeSource({ + rootDir, + outputDir, + runtimeSource, + }); + const expectedRuntimeConstraint = resolveExpectedRuntimeVersion({ rootDir }); + + const sourceRuntimePython = resolveRuntimePythonOrThrow({ + runtimeRoot: runtimeSourceReal, + errorSubject: 'runtime source', + }); + validateRuntimePython({ + pythonExecutable: sourceRuntimePython.absolute, + expectedRuntimeConstraint, + requirePipProbe, + }); + + prepareOutputDirs(); + copyAppSources(); + const runtimePython = prepareRuntimeExecutable(runtimeSourceReal); + writeLauncherScript(); + writeRuntimeManifest(runtimePython); console.log(`Prepared CPython backend runtime in ${outputDir}`); console.log(`Runtime source: ${runtimeSourceReal}`); diff --git a/desktop/scripts/runtime-version-utils.mjs b/desktop/scripts/runtime-version-utils.mjs index fadd44ef4..f7cd370f8 100644 --- a/desktop/scripts/runtime-version-utils.mjs +++ b/desktop/scripts/runtime-version-utils.mjs @@ -65,19 +65,12 @@ const extractLowerBoundFromPythonSpecifier = (rawSpecifier) => { }; const parsePyprojectProbeOutput = (stdoutText) => { - const lines = String(stdoutText || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - for (let i = lines.length - 1; i >= 0; i -= 1) { - try { - const parsed = JSON.parse(lines[i]); - if (parsed && typeof parsed === 'object') { - return parsed; - } - } catch {} + try { + const parsed = JSON.parse(String(stdoutText || '').trim()); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; } - return null; }; const readProjectRequiresPythonLowerBound = (rootDir) => { diff --git a/desktop/scripts/templates/launch_backend.py b/desktop/scripts/templates/launch_backend.py new file mode 100644 index 000000000..ac33fb563 --- /dev/null +++ b/desktop/scripts/templates/launch_backend.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import runpy +import sys +from pathlib import Path + +BACKEND_DIR = Path(__file__).resolve().parent +APP_DIR = BACKEND_DIR / "app" + +sys.path.insert(0, str(APP_DIR)) + +main_file = APP_DIR / "main.py" +if not main_file.is_file(): + raise FileNotFoundError(f"Backend entrypoint not found: {main_file}") + +sys.argv[0] = str(main_file) +runpy.run_path(str(main_file), run_name="__main__") From d5ab04b8f1b4f0ae5b290461305f482a63c8b827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 14:57:40 +0900 Subject: [PATCH 19/49] fix: normalize nested css selectors and simplify runtime probe parsing --- dashboard/vite.config.ts | 27 +++++++++++++ desktop/scripts/runtime-version-utils.mjs | 48 ++++++++++++++--------- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index d84889405..bb0d0e72f 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -3,6 +3,30 @@ import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import vuetify from 'vite-plugin-vuetify'; +const normalizeNestedTypeSelectorPlugin = { + postcssPlugin: 'normalize-nested-type-selector', + Rule(rule: { parent?: { type?: string }; selector?: string }) { + if (rule.parent?.type !== 'rule' || typeof rule.selector !== 'string') { + return; + } + + const segments = rule.selector + .split(',') + .map((segment) => segment.trim()) + .filter(Boolean); + if (!segments.length) { + return; + } + + const typeOnlyPattern = /^[a-zA-Z][\w-]*$/; + if (!segments.every((segment) => typeOnlyPattern.test(segment))) { + return; + } + + rule.selector = segments.map((segment) => `:is(${segment})`).join(', '); + } +}; + // https://vitejs.dev/config/ export default defineConfig({ plugins: [ @@ -24,6 +48,9 @@ export default defineConfig({ } }, css: { + postcss: { + plugins: [normalizeNestedTypeSelectorPlugin] + }, preprocessorOptions: { scss: {} } diff --git a/desktop/scripts/runtime-version-utils.mjs b/desktop/scripts/runtime-version-utils.mjs index f7cd370f8..d1aa00df7 100644 --- a/desktop/scripts/runtime-version-utils.mjs +++ b/desktop/scripts/runtime-version-utils.mjs @@ -168,11 +168,7 @@ export const resolveExpectedRuntimeVersion = ({ rootDir }) => { ); }; -export const validateRuntimePython = ({ - pythonExecutable, - expectedRuntimeConstraint, - requirePipProbe, -}) => { +const runPythonProbe = ({ pythonExecutable, requirePipProbe }) => { const probeScript = requirePipProbe ? 'import sys, pip; print(sys.version_info[0], sys.version_info[1])' : 'import sys; print(sys.version_info[0], sys.version_info[1])'; @@ -193,30 +189,46 @@ export const validateRuntimePython = ({ if (probe.status !== 0) { const stderrText = (probe.stderr || '').trim(); - if (requirePipProbe) { - throw new Error( - `Runtime Python probe failed with exit code ${probe.status}. ` + - `pip import check is enabled by ASTRBOT_DESKTOP_REQUIRE_PIP=1. ` + - (stderrText ? `stderr: ${stderrText}` : ''), - ); - } throw new Error( `Runtime Python probe failed with exit code ${probe.status}. ` + + (requirePipProbe ? 'pip import check is enabled by ASTRBOT_DESKTOP_REQUIRE_PIP=1. ' : '') + (stderrText ? `stderr: ${stderrText}` : ''), ); } - const parts = (probe.stdout || '').trim().split(/\s+/); + return probe.stdout || ''; +}; + +const parseProbeVersion = (stdoutText) => { + const trimmedOutput = String(stdoutText || '').trim(); + const parts = trimmedOutput.split(/\s+/); if (parts.length < 2) { throw new Error( - `Runtime Python probe did not report a valid version. Output: ${(probe.stdout || '').trim()}`, + `Runtime Python probe did not report a valid version. Output: ${trimmedOutput}`, ); } - const actualVersion = { - major: Number.parseInt(parts[0], 10), - minor: Number.parseInt(parts[1], 10), - }; + const major = Number.parseInt(parts[0], 10); + const minor = Number.parseInt(parts[1], 10); + if (!Number.isInteger(major) || !Number.isInteger(minor)) { + throw new Error( + `Runtime Python probe did not report a valid version. Output: ${trimmedOutput}`, + ); + } + + return { major, minor }; +}; + +export const validateRuntimePython = ({ + pythonExecutable, + expectedRuntimeConstraint, + requirePipProbe, +}) => { + const probeOutput = runPythonProbe({ + pythonExecutable, + requirePipProbe, + }); + const actualVersion = parseProbeVersion(probeOutput); const expectedRuntimeVersion = expectedRuntimeConstraint.expectedRuntimeVersion; const compareResult = compareMajorMinor(actualVersion, expectedRuntimeVersion); if (expectedRuntimeConstraint.isLowerBoundRuntimeVersion) { From 8761e110426288d14c427cd520776ad4b1cc37a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 15:08:29 +0900 Subject: [PATCH 20/49] fix: scope css selector normalization and simplify backend launch flow --- dashboard/vite.config.ts | 15 +++- desktop/lib/backend-manager.js | 94 +++++++++--------------- desktop/scripts/runtime-layout-utils.mjs | 11 +++ 3 files changed, 60 insertions(+), 60 deletions(-) diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index bb0d0e72f..f44f733ec 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -5,11 +5,24 @@ import vuetify from 'vite-plugin-vuetify'; const normalizeNestedTypeSelectorPlugin = { postcssPlugin: 'normalize-nested-type-selector', - Rule(rule: { parent?: { type?: string }; selector?: string }) { + Rule(rule: { + parent?: { type?: string; parent?: unknown; selector?: string }; + selector?: string; + source?: { input?: { file?: string; from?: string } }; + }) { if (rule.parent?.type !== 'rule' || typeof rule.selector !== 'string') { return; } + const sourceFile = String(rule.source?.input?.file || rule.source?.input?.from || '') + .replace(/\\/g, '/') + .toLowerCase(); + const isProjectSource = sourceFile.includes('/dashboard/src/'); + const isMonacoVendor = sourceFile.includes('/node_modules/monaco-editor/'); + if (!isProjectSource && !isMonacoVendor) { + return; + } + const segments = rule.selector .split(',') .map((segment) => segment.trim()) diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index f1db159e4..a6761d4ac 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -132,24 +132,19 @@ class BackendManager { return fs.existsSync(indexPath) ? candidate : null; } - loadPackagedBackendState() { + getPackagedBackendState() { if (!this.app.isPackaged) { return null; } - if (this.packagedBackendState) { - return this.packagedBackendState; + if (!this.packagedBackendState) { + this.packagedBackendState = resolvePackagedBackendState( + process.resourcesPath, + (message) => this.log(message), + ); } - this.packagedBackendState = resolvePackagedBackendState( - process.resourcesPath, - (message) => this.log(message), - ); return this.packagedBackendState; } - getPackagedBackendState() { - return this.loadPackagedBackendState(); - } - buildDefaultBackendLaunch(webuiDir) { const args = ['run', 'main.py']; if (webuiDir) { @@ -162,46 +157,42 @@ class BackendManager { }; } - resolveLaunchCommand(webuiDir) { - const customCmd = process.env.ASTRBOT_BACKEND_CMD; - if (customCmd) { - return { - launch: { - cmd: customCmd, - args: [], - shell: true, - }, - failureReason: null, - }; - } - - if (this.app.isPackaged) { - const packagedBackendState = this.getPackagedBackendState(); - if (packagedBackendState?.ok && packagedBackendState.config) { - return { - launch: { - cmd: packagedBackendState.config.runtimePythonPath, - args: [packagedBackendState.config.launchScriptPath, ...(webuiDir ? ['--webui-dir', webuiDir] : [])], - shell: false, - }, - failureReason: null, - }; - } + buildLaunchForPackagedBackend(packagedBackendState, webuiDir) { + if (!packagedBackendState?.ok || !packagedBackendState.config) { return { launch: null, - failureReason: packagedBackendState?.failureReason || 'Backend command is not configured.', + failureReason: + packagedBackendState?.failureReason || 'Backend command is not configured.', }; } + const { runtimePythonPath, launchScriptPath } = packagedBackendState.config; + const args = [launchScriptPath]; + if (webuiDir) { + args.push('--webui-dir', webuiDir); + } return { - launch: this.buildDefaultBackendLaunch(webuiDir), + launch: { cmd: runtimePythonPath, args, shell: false }, failureReason: null, }; } resolveBackendConfig() { const webuiDir = this.resolveWebuiDir(); - const { launch, failureReason } = this.resolveLaunchCommand(webuiDir); + let launch = null; + let failureReason = null; + + const customCmd = process.env.ASTRBOT_BACKEND_CMD; + if (customCmd) { + launch = { cmd: customCmd, args: [], shell: true }; + } else if (this.app.isPackaged) { + ({ launch, failureReason } = this.buildLaunchForPackagedBackend( + this.getPackagedBackendState(), + webuiDir, + )); + } else { + launch = this.buildDefaultBackendLaunch(webuiDir); + } const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot(); @@ -656,25 +647,6 @@ class BackendManager { return { imageName, pid: parsedPid }; } - shouldKillUnmanagedProcess({ - pid, - processInfo, - backendConfig, - commandLineCache, - }) { - const hasBackendConfig = backendConfig && typeof backendConfig === 'object'; - return shouldKillUnmanagedBackendProcess({ - pid, - processInfo, - backendConfig, - allowImageOnlyMatch: !hasBackendConfig, - commandLineCache, - spawnSync, - log: (message) => this.log(message), - fallbackCmdRaw: process.env.ASTRBOT_BACKEND_CMD || 'python.exe', - }); - } - async stopUnmanagedBackendByPort() { if (!this.app.isPackaged || process.platform !== 'win32') { return false; @@ -718,11 +690,15 @@ class BackendManager { this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`); continue; } - const shouldKill = this.shouldKillUnmanagedProcess({ + const shouldKill = shouldKillUnmanagedBackendProcess({ pid, processInfo, backendConfig, + allowImageOnlyMatch: !hasBackendConfig, commandLineCache, + spawnSync, + log: (message) => this.log(message), + fallbackCmdRaw: process.env.ASTRBOT_BACKEND_CMD || 'python.exe', }); if (!shouldKill) { continue; diff --git a/desktop/scripts/runtime-layout-utils.mjs b/desktop/scripts/runtime-layout-utils.mjs index fa6d86444..35a43e43e 100644 --- a/desktop/scripts/runtime-layout-utils.mjs +++ b/desktop/scripts/runtime-layout-utils.mjs @@ -44,10 +44,13 @@ export const resolveAndValidateRuntimeSource = ({ rootDir, outputDir, runtimeSou const runtimeNorm = normalizeForCompare(runtimeSourceReal); const outputNorm = normalizeForCompare(outputDir); + const projectRootNorm = normalizeForCompare(rootDir); const runtimeIsOutputOrSub = runtimeNorm === outputNorm || runtimeNorm.startsWith(`${outputNorm}${path.sep}`); const outputIsRuntimeOrSub = outputNorm === runtimeNorm || outputNorm.startsWith(`${runtimeNorm}${path.sep}`); + const runtimeContainsProjectRoot = + runtimeNorm === projectRootNorm || projectRootNorm.startsWith(`${runtimeNorm}${path.sep}`); if (runtimeIsOutputOrSub || outputIsRuntimeOrSub) { throw new Error( @@ -56,6 +59,14 @@ export const resolveAndValidateRuntimeSource = ({ rootDir, outputDir, runtimeSou 'Please set ASTRBOT_DESKTOP_CPYTHON_HOME to a separate runtime directory.', ); } + if (runtimeContainsProjectRoot) { + throw new Error( + `CPython runtime source is too broad and contains the project root. ` + + `runtime=${runtimeSourceReal}, projectRoot=${path.resolve(rootDir)}. ` + + 'Please point ASTRBOT_DESKTOP_CPYTHON_HOME (or ASTRBOT_DESKTOP_BACKEND_RUNTIME) ' + + 'to a dedicated CPython runtime directory instead of the repository root or its parent.', + ); + } return runtimeSourceReal; }; From 8834425d170e77b2288e59fd734af1cbfdfae1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 15:20:13 +0900 Subject: [PATCH 21/49] refactor: remove legacy frozen runtime compatibility path --- main.py | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/main.py b/main.py index 81abdf295..10809e7eb 100644 --- a/main.py +++ b/main.py @@ -2,47 +2,9 @@ import asyncio import mimetypes import os -import runpy import sys from pathlib import Path - -def _run_python_compat_mode_if_needed() -> None: - """Support subprocess calls that expect a Python interpreter in frozen mode.""" - if not getattr(sys, "frozen", False): - return - - if len(sys.argv) < 2: - return - - command = sys.argv[1] - - if command == "-c": - if len(sys.argv) < 3: - raise SystemExit("astrbot-backend: argument expected for -c") - code = sys.argv[2] - sys.argv = ["-c", *sys.argv[3:]] - exec(code, {"__name__": "__main__", "__package__": None, "__spec__": None}) # noqa: S102 - raise SystemExit(0) - - if command == "-m": - if len(sys.argv) < 3: - raise SystemExit("astrbot-backend: argument expected for -m") - module_name = sys.argv[2] - sys.argv = [module_name, *sys.argv[3:]] - runpy.run_module(module_name, run_name="__main__", alter_sys=True) - raise SystemExit(0) - - script_path = Path(command) - if script_path.is_file(): - resolved_script = script_path.resolve().as_posix() - sys.argv = [resolved_script, *sys.argv[2:]] - runpy.run_path(resolved_script, run_name="__main__") - raise SystemExit(0) - - -_run_python_compat_mode_if_needed() - import runtime_bootstrap # noqa: E402 runtime_bootstrap.initialize_runtime_bootstrap() From 72791a46644c318362a4d68ab0b6e94f10264529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 15:27:36 +0900 Subject: [PATCH 22/49] refactor: inline python runtime probe parsing flow --- desktop/scripts/runtime-version-utils.mjs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/desktop/scripts/runtime-version-utils.mjs b/desktop/scripts/runtime-version-utils.mjs index d1aa00df7..fb9a7f685 100644 --- a/desktop/scripts/runtime-version-utils.mjs +++ b/desktop/scripts/runtime-version-utils.mjs @@ -168,7 +168,7 @@ export const resolveExpectedRuntimeVersion = ({ rootDir }) => { ); }; -const runPythonProbe = ({ pythonExecutable, requirePipProbe }) => { +const probePythonVersion = ({ pythonExecutable, requirePipProbe }) => { const probeScript = requirePipProbe ? 'import sys, pip; print(sys.version_info[0], sys.version_info[1])' : 'import sys; print(sys.version_info[0], sys.version_info[1])'; @@ -196,11 +196,7 @@ const runPythonProbe = ({ pythonExecutable, requirePipProbe }) => { ); } - return probe.stdout || ''; -}; - -const parseProbeVersion = (stdoutText) => { - const trimmedOutput = String(stdoutText || '').trim(); + const trimmedOutput = String(probe.stdout || '').trim(); const parts = trimmedOutput.split(/\s+/); if (parts.length < 2) { throw new Error( @@ -224,11 +220,10 @@ export const validateRuntimePython = ({ expectedRuntimeConstraint, requirePipProbe, }) => { - const probeOutput = runPythonProbe({ + const actualVersion = probePythonVersion({ pythonExecutable, requirePipProbe, }); - const actualVersion = parseProbeVersion(probeOutput); const expectedRuntimeVersion = expectedRuntimeConstraint.expectedRuntimeVersion; const compareResult = compareMajorMinor(actualVersion, expectedRuntimeVersion); if (expectedRuntimeConstraint.isLowerBoundRuntimeVersion) { From cdb8b26c299fd055368544edca3f8e1d8ccc6ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 15:36:15 +0900 Subject: [PATCH 23/49] refactor: simplify desktop backend build and launch strategy flow --- desktop/lib/backend-manager.js | 31 +++++--- desktop/lib/windows-backend-cleanup.js | 99 ++++++++++++++++++-------- desktop/scripts/build-backend.mjs | 49 +++++++------ 3 files changed, 114 insertions(+), 65 deletions(-) diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index a6761d4ac..a1a490a0b 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -177,23 +177,32 @@ class BackendManager { }; } - resolveBackendConfig() { - const webuiDir = this.resolveWebuiDir(); - let launch = null; - let failureReason = null; - + resolveLaunchStrategy(webuiDir) { const customCmd = process.env.ASTRBOT_BACKEND_CMD; if (customCmd) { - launch = { cmd: customCmd, args: [], shell: true }; - } else if (this.app.isPackaged) { - ({ launch, failureReason } = this.buildLaunchForPackagedBackend( + return { + launch: { cmd: customCmd, args: [], shell: true }, + failureReason: null, + }; + } + + if (this.app.isPackaged) { + return this.buildLaunchForPackagedBackend( this.getPackagedBackendState(), webuiDir, - )); - } else { - launch = this.buildDefaultBackendLaunch(webuiDir); + ); } + return { + launch: this.buildDefaultBackendLaunch(webuiDir), + failureReason: null, + }; + } + + resolveBackendConfig() { + const webuiDir = this.resolveWebuiDir(); + const { launch, failureReason } = this.resolveLaunchStrategy(webuiDir); + const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot(); ensureDir(cwd); diff --git a/desktop/lib/windows-backend-cleanup.js b/desktop/lib/windows-backend-cleanup.js index 25a705de6..8ff6724f6 100644 --- a/desktop/lib/windows-backend-cleanup.js +++ b/desktop/lib/windows-backend-cleanup.js @@ -6,6 +6,26 @@ const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 2000; const COMMAND_LINE_QUERY_UNAVAILABLE_KEY = '__command_line_query_unavailable__'; const COMMAND_LINE_FALLBACK_LOGGED_KEY = '__command_line_fallback_logged__'; +function isQueryUnavailable(cache) { + return !!(cache && cache.get(COMMAND_LINE_QUERY_UNAVAILABLE_KEY)); +} + +function setQueryUnavailable(cache, value) { + if (cache) { + cache.set(COMMAND_LINE_QUERY_UNAVAILABLE_KEY, !!value); + } +} + +function wasFallbackLogged(cache) { + return !!(cache && cache.get(COMMAND_LINE_FALLBACK_LOGGED_KEY)); +} + +function markFallbackLogged(cache) { + if (cache) { + cache.set(COMMAND_LINE_FALLBACK_LOGGED_KEY, true); + } +} + function normalizeWindowsPathForMatch(value) { return String(value || '') .replace(/\//g, '\\') @@ -23,7 +43,7 @@ function getWindowsProcessCommandLine({ pid, commandLineCache, spawnSync, log, t return { commandLine: null, commandLineQueryUnavailable: false }; } - if (commandLineCache && commandLineCache.get(COMMAND_LINE_QUERY_UNAVAILABLE_KEY) === true) { + if (isQueryUnavailable(commandLineCache)) { return { commandLine: null, commandLineQueryUnavailable: true }; } @@ -85,22 +105,20 @@ function getWindowsProcessCommandLine({ pid, commandLineCache, spawnSync, log, t .find((item) => item.length > 0) || null; if (commandLineCache) { commandLineCache.set(numericPid, commandLine); - commandLineCache.set(COMMAND_LINE_QUERY_UNAVAILABLE_KEY, false); + setQueryUnavailable(commandLineCache, false); } return { commandLine, commandLineQueryUnavailable: false }; } } if (!hasAvailableShell) { - if (commandLineCache) { - commandLineCache.set(COMMAND_LINE_QUERY_UNAVAILABLE_KEY, true); - } + setQueryUnavailable(commandLineCache, true); return { commandLine: null, commandLineQueryUnavailable: true }; } if (commandLineCache) { commandLineCache.set(numericPid, null); - commandLineCache.set(COMMAND_LINE_QUERY_UNAVAILABLE_KEY, false); + setQueryUnavailable(commandLineCache, false); } return { commandLine: null, commandLineQueryUnavailable: false }; } @@ -126,36 +144,28 @@ function buildBackendCommandLineMarkers(backendConfig) { ]; } -function shouldKillUnmanagedBackendProcess({ - pid, - processInfo, - backendConfig, - allowImageOnlyMatch, - commandLineCache, - spawnSync, - log, - fallbackCmdRaw, -}) { +function getExpectedImageName(backendConfig, fallbackCmdRaw) { const safeBackendConfig = backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; const fallbackCmd = String(fallbackCmdRaw || 'python.exe') .trim() .split(/\s+/, 1)[0]; - const expectedImageName = path + return path .basename(safeBackendConfig.cmd || fallbackCmd || 'python.exe') .toLowerCase(); - const actualImageName = processInfo.imageName.toLowerCase(); - if (actualImageName !== expectedImageName) { - log( - `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, - ); - return false; - } +} - if (allowImageOnlyMatch || !isGenericWindowsPythonImage(expectedImageName)) { - return true; - } +function matchesExpectedImage(processInfo, expectedImageName) { + return processInfo.imageName.toLowerCase() === expectedImageName; +} +function matchesBackendMarkers({ + pid, + backendConfig, + commandLineCache, + spawnSync, + log, +}) { const markers = buildBackendCommandLineMarkers(backendConfig); if (!markers.length) { log(`Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`); @@ -171,8 +181,8 @@ function shouldKillUnmanagedBackendProcess({ }); if (!commandLine) { if (commandLineQueryUnavailable) { - if (commandLineCache && !commandLineCache.get(COMMAND_LINE_FALLBACK_LOGGED_KEY)) { - commandLineCache.set(COMMAND_LINE_FALLBACK_LOGGED_KEY, true); + if (!wasFallbackLogged(commandLineCache)) { + markFallbackLogged(commandLineCache); log( 'Neither powershell nor pwsh is available. ' + 'Falling back to image-name-only matching for generic Python backend cleanup.', @@ -194,6 +204,37 @@ function shouldKillUnmanagedBackendProcess({ return matched; } +function shouldKillUnmanagedBackendProcess({ + pid, + processInfo, + backendConfig, + allowImageOnlyMatch, + commandLineCache, + spawnSync, + log, + fallbackCmdRaw, +}) { + const expectedImageName = getExpectedImageName(backendConfig, fallbackCmdRaw); + if (!matchesExpectedImage(processInfo, expectedImageName)) { + log( + `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, + ); + return false; + } + + if (allowImageOnlyMatch || !isGenericWindowsPythonImage(expectedImageName)) { + return true; + } + + return matchesBackendMarkers({ + pid, + backendConfig, + commandLineCache, + spawnSync, + log, + }); +} + module.exports = { shouldKillUnmanagedBackendProcess, }; diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index 98189ef93..3278c42e1 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -33,26 +33,12 @@ const runtimeSource = const requirePipProbe = process.env.ASTRBOT_DESKTOP_REQUIRE_PIP === '1'; const sourceEntries = [ - ['astrbot', 'astrbot'], - ['main.py', 'main.py'], - ['runtime_bootstrap.py', 'runtime_bootstrap.py'], - ['requirements.txt', 'requirements.txt'], + 'astrbot', + 'main.py', + 'runtime_bootstrap.py', + 'requirements.txt', ]; -const resolveRuntimePythonOrThrow = ({ runtimeRoot, errorSubject }) => { - const runtimePython = resolveRuntimePython({ - runtimeRoot, - outputDir, - }); - if (!runtimePython) { - throw new Error( - `Cannot find Python executable in ${errorSubject}: ${runtimeRoot}. ` + - 'Expected python under bin/ or Scripts/.', - ); - } - return runtimePython; -}; - const prepareOutputDirs = () => { fs.rmSync(outputDir, { recursive: true, force: true }); fs.mkdirSync(outputDir, { recursive: true }); @@ -60,9 +46,9 @@ const prepareOutputDirs = () => { }; const copyAppSources = () => { - for (const [srcRelative, destRelative] of sourceEntries) { - const sourcePath = path.join(rootDir, srcRelative); - const targetPath = path.join(appDir, destRelative); + for (const relativePath of sourceEntries) { + const sourcePath = path.join(rootDir, relativePath); + const targetPath = path.join(appDir, relativePath); if (!fs.existsSync(sourcePath)) { throw new Error(`Backend source path does not exist: ${sourcePath}`); } @@ -72,10 +58,17 @@ const copyAppSources = () => { const prepareRuntimeExecutable = (runtimeSourceReal) => { copyTree(runtimeSourceReal, runtimeDir, { dereference: true }); - return resolveRuntimePythonOrThrow({ + const runtimePython = resolveRuntimePython({ runtimeRoot: runtimeDir, - errorSubject: 'runtime', + outputDir, }); + if (!runtimePython) { + throw new Error( + `Cannot find Python executable in runtime: ${runtimeDir}. ` + + 'Expected python under bin/ or Scripts/.', + ); + } + return runtimePython; }; const writeLauncherScript = () => { @@ -104,10 +97,16 @@ const main = () => { }); const expectedRuntimeConstraint = resolveExpectedRuntimeVersion({ rootDir }); - const sourceRuntimePython = resolveRuntimePythonOrThrow({ + const sourceRuntimePython = resolveRuntimePython({ runtimeRoot: runtimeSourceReal, - errorSubject: 'runtime source', + outputDir, }); + if (!sourceRuntimePython) { + throw new Error( + `Cannot find Python executable in runtime source: ${runtimeSourceReal}. ` + + 'Expected python under bin/ or Scripts/.', + ); + } validateRuntimePython({ pythonExecutable: sourceRuntimePython.absolute, expectedRuntimeConstraint, From 4ca96189dc3f92b410e28061dc887927b96c9840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 15:45:43 +0900 Subject: [PATCH 24/49] fix: avoid auto-cleanup on plugin load failure and improve reload checks --- astrbot/core/star/star_manager.py | 57 ++++++++++++++++++------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 7810130cb..1a31ca15e 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -357,13 +357,21 @@ async def reload_failed_plugin(self, dir_name): async with self._pm_lock: if dir_name in self.failed_plugin_dict: success, error = await self.load(specified_dir_name=dir_name) - if success: - self.failed_plugin_dict.pop(dir_name, None) - if not self.failed_plugin_dict: - self.failed_plugin_info = "" - return success, None - else: + if not success: return False, error + plugin_reloaded = any( + star.root_dir_name == dir_name + for star in self.context.get_all_stars() + ) + if not plugin_reloaded: + return ( + False, + f"插件 {dir_name} 重载失败:未在插件目录中找到可加载插件。", + ) + self.failed_plugin_dict.pop(dir_name, None) + if not self.failed_plugin_dict: + self.failed_plugin_info = "" + return True, None return False, "插件不存在于失败列表中" async def reload(self, specified_plugin_name=None): @@ -445,6 +453,8 @@ async def load(self, specified_module_path=None, specified_dir_name=None): return False, "未找到任何插件模块" fail_rec = "" + has_target_filter = bool(specified_module_path or specified_dir_name) + matched_target = False # 导入插件模块,并尝试实例化插件类 for plugin_module in plugin_modules: @@ -471,6 +481,7 @@ async def load(self, specified_module_path=None, specified_dir_name=None): continue if specified_dir_name and root_dir_name != specified_dir_name: continue + matched_target = True logger.info(f"正在载入插件 {root_dir_name} ...") @@ -492,11 +503,7 @@ async def load(self, specified_module_path=None, specified_dir_name=None): } if not reserved: logger.warning( - f"插件 {root_dir_name} 导入失败,已自动卸载该插件。" - ) - await self._cleanup_failed_plugin_install( - dir_name=root_dir_name, - plugin_path=plugin_dir_path, + f"{root_dir_name}插件安装失败,插件目录:{plugin_dir_path}" ) continue @@ -733,6 +740,10 @@ async def load(self, specified_module_path=None, specified_dir_name=None): logger.error(f"同步指令配置失败: {e!s}") logger.error(traceback.format_exc()) + if has_target_filter and not matched_target: + target_label = specified_dir_name or specified_module_path + return False, f"未找到指定插件:{target_label}" + if not fail_rec: return True, None self.failed_plugin_info = fail_rec @@ -808,10 +819,8 @@ async def install_plugin(self, repo_url: str, proxy=""): async with self._pm_lock: plugin_path = "" dir_name = "" - cleanup_required = False try: plugin_path = await self.updator.install(repo_url, proxy) - cleanup_required = True # reload the plugin dir_name = os.path.basename(plugin_path) @@ -856,10 +865,12 @@ async def install_plugin(self, repo_url: str, proxy=""): return plugin_info except Exception: - if cleanup_required and dir_name and plugin_path: - await self._cleanup_failed_plugin_install( - dir_name=dir_name, - plugin_path=plugin_path, + if plugin_path: + plugin_dir_name = dir_name or os.path.basename(plugin_path) + logger.warning( + "%s插件安装失败,插件目录:%s", + plugin_dir_name, + plugin_path, ) raise @@ -1123,7 +1134,6 @@ async def install_plugin_from_file(self, zip_file_path: str): dir_name = os.path.basename(zip_file_path).replace(".zip", "") dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower() desti_dir = os.path.join(self.plugin_store_path, dir_name) - cleanup_required = False # 第一步:检查是否已安装同目录名的插件,先终止旧插件 existing_plugin = None @@ -1145,7 +1155,6 @@ async def install_plugin_from_file(self, zip_file_path: str): try: self.updator.unzip_file(zip_file_path, desti_dir) - cleanup_required = True # 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件 try: @@ -1222,9 +1231,9 @@ async def install_plugin_from_file(self, zip_file_path: str): return plugin_info except Exception: - if cleanup_required: - await self._cleanup_failed_plugin_install( - dir_name=dir_name, - plugin_path=desti_dir, - ) + logger.warning( + "%s插件安装失败,插件目录:%s", + dir_name, + desti_dir, + ) raise From b9c0945946f16b46b8654e9c552182f6a16d2404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 15:52:05 +0900 Subject: [PATCH 25/49] fix: avoid packaging virtualenv as desktop runtime --- .github/workflows/release.yml | 20 +++++++++++++++++++- desktop/README.md | 9 ++++----- desktop/scripts/runtime-layout-utils.mjs | 7 +++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a74289429..cd810b880 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -136,7 +136,6 @@ jobs: arch: arm64 env: CSC_IDENTITY_AUTO_DISCOVERY: "false" - ASTRBOT_DESKTOP_CPYTHON_HOME: ".venv" steps: - name: Checkout repository uses: actions/checkout@v6 @@ -166,10 +165,29 @@ jobs: uses: astral-sh/setup-uv@v7 - name: Setup Python + id: setup-python uses: actions/setup-python@v6 with: python-version: "3.12" + - name: Resolve packaged CPython runtime source + shell: bash + env: + SETUP_PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} + run: | + if [ -z "$SETUP_PYTHON_PATH" ]; then + echo "actions/setup-python did not return python-path output." >&2 + exit 1 + fi + "$SETUP_PYTHON_PATH" - <<'PY' >> "$GITHUB_ENV" + import pathlib + import sys + + executable = pathlib.Path(sys.executable).resolve() + runtime_root = executable.parent if sys.platform == "win32" else executable.parent.parent + print(f"ASTRBOT_DESKTOP_CPYTHON_HOME={runtime_root}") + PY + - name: Setup pnpm uses: pnpm/action-setup@v4 with: diff --git a/desktop/README.md b/desktop/README.md index 9c519c626..1e6b787fe 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -33,19 +33,18 @@ Run commands from repository root: ```bash uv sync -export ASTRBOT_DESKTOP_CPYTHON_HOME="$(pwd)/.venv" -# export ASTRBOT_DESKTOP_CPYTHON_HOME=/path/to/cpython-runtime +export ASTRBOT_DESKTOP_CPYTHON_HOME=/path/to/cpython-runtime pnpm --dir dashboard install pnpm --dir dashboard build pnpm --dir desktop install --frozen-lockfile pnpm --dir desktop run dist:full ``` -If you are already developing in this repository, you can directly reuse the local virtual environment as the runtime: +`ASTRBOT_DESKTOP_CPYTHON_HOME` must point to a standalone/distributable CPython runtime directory. +Virtual environments (for example `.venv`, detected by `pyvenv.cfg`) are not supported for packaged runtime builds. ```bash -uv sync -export ASTRBOT_DESKTOP_CPYTHON_HOME="$(pwd)/.venv" +export ASTRBOT_DESKTOP_CPYTHON_HOME=/path/to/cpython-runtime pnpm --dir desktop run build:backend ``` diff --git a/desktop/scripts/runtime-layout-utils.mjs b/desktop/scripts/runtime-layout-utils.mjs index 35a43e43e..5682153a3 100644 --- a/desktop/scripts/runtime-layout-utils.mjs +++ b/desktop/scripts/runtime-layout-utils.mjs @@ -67,6 +67,13 @@ export const resolveAndValidateRuntimeSource = ({ rootDir, outputDir, runtimeSou 'to a dedicated CPython runtime directory instead of the repository root or its parent.', ); } + if (fs.existsSync(path.join(runtimeSourceReal, 'pyvenv.cfg'))) { + throw new Error( + `CPython runtime source must be a distributable CPython runtime, not a virtual environment: ${runtimeSourceReal}. ` + + 'Detected pyvenv.cfg. Please set ASTRBOT_DESKTOP_CPYTHON_HOME (or ASTRBOT_DESKTOP_BACKEND_RUNTIME) ' + + 'to a standalone CPython runtime directory.', + ); + } return runtimeSourceReal; }; From 184dd4bfefc5a6ef3245c9d2b1d11c1c9e3086bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 16:01:07 +0900 Subject: [PATCH 26/49] refactor: simplify backend launch flow and runtime probe errors --- desktop/lib/backend-manager.js | 81 +++++++++++------------ desktop/scripts/read-requires-python.py | 19 ++++-- desktop/scripts/runtime-version-utils.mjs | 18 +++++ 3 files changed, 72 insertions(+), 46 deletions(-) diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index a1a490a0b..9c0ad1316 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -177,32 +177,22 @@ class BackendManager { }; } - resolveLaunchStrategy(webuiDir) { + resolveBackendConfig() { + const webuiDir = this.resolveWebuiDir(); + let launch = null; + let failureReason = null; const customCmd = process.env.ASTRBOT_BACKEND_CMD; if (customCmd) { - return { - launch: { cmd: customCmd, args: [], shell: true }, - failureReason: null, - }; - } - - if (this.app.isPackaged) { - return this.buildLaunchForPackagedBackend( + launch = { cmd: customCmd, args: [], shell: true }; + } else if (this.app.isPackaged) { + ({ launch, failureReason } = this.buildLaunchForPackagedBackend( this.getPackagedBackendState(), webuiDir, - ); + )); + } else { + launch = this.buildDefaultBackendLaunch(webuiDir); } - return { - launch: this.buildDefaultBackendLaunch(webuiDir), - failureReason: null, - }; - } - - resolveBackendConfig() { - const webuiDir = this.resolveWebuiDir(); - const { launch, failureReason } = this.resolveLaunchStrategy(webuiDir); - const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot(); ensureDir(cwd); @@ -656,6 +646,33 @@ class BackendManager { return { imageName, pid: parsedPid }; } + buildUnmanagedCleanupContext() { + let backendConfig = null; + try { + backendConfig = this.getBackendConfig(); + } catch (error) { + this.log( + `Failed to resolve backend config during unmanaged cleanup: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const hasBackendConfig = backendConfig && typeof backendConfig === 'object'; + if (!hasBackendConfig) { + this.log( + 'Backend config is unavailable during unmanaged cleanup; falling back to image-name-only matching.', + ); + } + + return { + backendConfig, + allowImageOnlyMatch: !hasBackendConfig, + commandLineCache: new Map(), + fallbackCmdRaw: process.env.ASTRBOT_BACKEND_CMD || 'python.exe', + }; + } + async stopUnmanagedBackendByPort() { if (!this.app.isPackaged || process.platform !== 'win32') { return false; @@ -674,24 +691,7 @@ class BackendManager { this.log( `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, ); - - let backendConfig = null; - try { - backendConfig = this.getBackendConfig(); - } catch (error) { - this.log( - `Failed to resolve backend config during unmanaged cleanup: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - const hasBackendConfig = backendConfig && typeof backendConfig === 'object'; - if (!hasBackendConfig) { - this.log( - 'Backend config is unavailable during unmanaged cleanup; falling back to image-name-only matching.', - ); - } - const commandLineCache = new Map(); + const context = this.buildUnmanagedCleanupContext(); for (const pid of pids) { const processInfo = this.getWindowsProcessInfo(pid); @@ -702,12 +702,9 @@ class BackendManager { const shouldKill = shouldKillUnmanagedBackendProcess({ pid, processInfo, - backendConfig, - allowImageOnlyMatch: !hasBackendConfig, - commandLineCache, + ...context, spawnSync, log: (message) => this.log(message), - fallbackCmdRaw: process.env.ASTRBOT_BACKEND_CMD || 'python.exe', }); if (!shouldKill) { continue; diff --git a/desktop/scripts/read-requires-python.py b/desktop/scripts/read-requires-python.py index 96450cf54..abd45e3e7 100644 --- a/desktop/scripts/read-requires-python.py +++ b/desktop/scripts/read-requires-python.py @@ -4,21 +4,32 @@ path = pathlib.Path(sys.argv[1]) + +def emit_result(requires_python=None, error=None, message=None): + payload = {"requires_python": requires_python, "error": error} + if message: + payload["message"] = message + print(json.dumps(payload)) + + try: import tomllib except Exception: try: import tomli as tomllib except Exception: - print(json.dumps({"requires_python": None})) + emit_result( + error="toml_parser_unavailable", + message="tomllib/tomli is unavailable for parsing pyproject.toml.", + ) raise SystemExit(0) try: data = tomllib.loads(path.read_text(encoding="utf-8")) -except Exception: - print(json.dumps({"requires_python": None})) +except Exception as exc: + emit_result(error="parse_failed", message=f"Failed to parse pyproject.toml: {exc}") raise SystemExit(0) project = data.get("project") if isinstance(data, dict) else None requires_python = project.get("requires-python") if isinstance(project, dict) else None -print(json.dumps({"requires_python": requires_python})) +emit_result(requires_python=requires_python) diff --git a/desktop/scripts/runtime-version-utils.mjs b/desktop/scripts/runtime-version-utils.mjs index fb9a7f685..b646278e3 100644 --- a/desktop/scripts/runtime-version-utils.mjs +++ b/desktop/scripts/runtime-version-utils.mjs @@ -93,6 +93,7 @@ const readProjectRequiresPythonLowerBound = (rootDir) => { { cmd: 'python', prefixArgs: [] }, ]; + let probeErrorMessage = null; for (const probeCommand of probeCommands) { const probe = spawnSync( probeCommand.cmd, @@ -112,6 +113,19 @@ const readProjectRequiresPythonLowerBound = (rootDir) => { } const parsedOutput = parsePyprojectProbeOutput(probe.stdout); + if (!parsedOutput) { + continue; + } + if (parsedOutput.error) { + const details = + typeof parsedOutput.message === 'string' && parsedOutput.message + ? parsedOutput.message + : `Probe reported error: ${parsedOutput.error}`; + probeErrorMessage = + `Failed to read project.requires-python from ${pyprojectPath}. ` + + details; + continue; + } const requiresPythonSpecifier = parsedOutput?.requires_python; const lowerBound = extractLowerBoundFromPythonSpecifier(requiresPythonSpecifier); if (lowerBound) { @@ -119,6 +133,10 @@ const readProjectRequiresPythonLowerBound = (rootDir) => { } } + if (probeErrorMessage) { + throw new Error(probeErrorMessage); + } + return null; }; From e1b0a0fb2ea7b86a95685afd6437f4c76eadebd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 16:03:30 +0900 Subject: [PATCH 27/49] docs: add troubleshooting note for requires-python probe failures --- desktop/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/README.md b/desktop/README.md index 1e6b787fe..4cb8cc3d4 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -154,6 +154,7 @@ Backend build errors: - `Missing CPython runtime source`: set `ASTRBOT_DESKTOP_CPYTHON_HOME` (or `ASTRBOT_DESKTOP_BACKEND_RUNTIME`). - `Cannot find Python executable in runtime`: runtime directory is invalid or incomplete. - `Failed to detect purelib from runtime Python`: runtime Python cannot run correctly. +- `Failed to read project.requires-python`: `pyproject.toml` is invalid or cannot be parsed; fix `pyproject.toml` or set `ASTRBOT_DESKTOP_EXPECTED_PYTHON`. If Electron download times out on restricted networks, configure mirrors before install: From 7365cc2f783136a8dc0636e357f6f0ff2447ebb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 16:08:48 +0900 Subject: [PATCH 28/49] refactor: streamline backend config and unmanaged cleanup flow --- desktop/lib/backend-manager.js | 65 +++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 9c0ad1316..1460eb65f 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -177,7 +177,7 @@ class BackendManager { }; } - resolveBackendConfig() { + buildBackendConfig() { const webuiDir = this.resolveWebuiDir(); let launch = null; let failureReason = null; @@ -195,6 +195,19 @@ class BackendManager { const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot(); + + return { + launch, + cwd, + rootDir, + webuiDir, + failureReason, + }; + } + + resolveBackendConfig() { + const { launch, cwd, rootDir, webuiDir, failureReason } = + this.buildBackendConfig(); ensureDir(cwd); if (rootDir) { ensureDir(rootDir); @@ -646,7 +659,24 @@ class BackendManager { return { imageName, pid: parsedPid }; } - buildUnmanagedCleanupContext() { + async stopUnmanagedBackendByPort() { + if (!this.app.isPackaged || process.platform !== 'win32') { + return false; + } + + const port = this.getBackendPort(); + if (!port) { + return false; + } + + const pids = this.findListeningPidsOnWindows(port); + if (!pids.length) { + return false; + } + + this.log( + `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, + ); let backendConfig = null; try { backendConfig = this.getBackendConfig(); @@ -657,41 +687,20 @@ class BackendManager { }`, ); } - const hasBackendConfig = backendConfig && typeof backendConfig === 'object'; if (!hasBackendConfig) { this.log( 'Backend config is unavailable during unmanaged cleanup; falling back to image-name-only matching.', ); } - - return { + const contextBase = { backendConfig, allowImageOnlyMatch: !hasBackendConfig, commandLineCache: new Map(), fallbackCmdRaw: process.env.ASTRBOT_BACKEND_CMD || 'python.exe', + spawnSync, + log: (message) => this.log(message), }; - } - - async stopUnmanagedBackendByPort() { - if (!this.app.isPackaged || process.platform !== 'win32') { - return false; - } - - const port = this.getBackendPort(); - if (!port) { - return false; - } - - const pids = this.findListeningPidsOnWindows(port); - if (!pids.length) { - return false; - } - - this.log( - `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, - ); - const context = this.buildUnmanagedCleanupContext(); for (const pid of pids) { const processInfo = this.getWindowsProcessInfo(pid); @@ -702,9 +711,7 @@ class BackendManager { const shouldKill = shouldKillUnmanagedBackendProcess({ pid, processInfo, - ...context, - spawnSync, - log: (message) => this.log(message), + ...contextBase, }); if (!shouldKill) { continue; From e9920d1311d357160bc0adb642882ed05e9b8ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 17:29:41 +0900 Subject: [PATCH 29/49] fix(ci): package relocatable cpython runtime for desktop --- .github/workflows/release.yml | 251 +++++++++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd810b880..30bb5640f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,19 +173,185 @@ jobs: - name: Resolve packaged CPython runtime source shell: bash env: - SETUP_PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} + RUNNER_TEMP_DIR: ${{ runner.temp }} + UV_NO_CONFIG: "1" run: | - if [ -z "$SETUP_PYTHON_PATH" ]; then - echo "actions/setup-python did not return python-path output." >&2 + uv python install 3.12 --managed-python + RUNTIME_PY="$(uv python find --managed-python --no-project 3.12)" + if [ -z "$RUNTIME_PY" ]; then + echo "Failed to resolve uv-managed Python runtime for packaging." >&2 exit 1 fi - "$SETUP_PYTHON_PATH" - <<'PY' >> "$GITHUB_ENV" + "$RUNTIME_PY" - <<'PY' >> "$GITHUB_ENV" + import os import pathlib + import shutil + import subprocess import sys executable = pathlib.Path(sys.executable).resolve() - runtime_root = executable.parent if sys.platform == "win32" else executable.parent.parent - print(f"ASTRBOT_DESKTOP_CPYTHON_HOME={runtime_root}") + candidate_roots = [] + if sys.platform == "win32": + candidate_roots.extend([executable.parent, executable.parent.parent]) + else: + candidate_roots.extend([executable.parent.parent, executable.parent]) + + def is_runtime_root(root: pathlib.Path) -> bool: + if sys.platform == "win32": + return (root / "python.exe").is_file() or ( + root / "Scripts" / "python.exe" + ).is_file() + return (root / "bin" / "python3").is_file() or ( + root / "bin" / "python" + ).is_file() + + source_runtime_root = next( + (candidate for candidate in candidate_roots if is_runtime_root(candidate)), + None, + ) + if source_runtime_root is None: + raise RuntimeError( + f"Cannot resolve runtime root from executable: {executable}" + ) + + target_runtime_root = ( + pathlib.Path( + os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"] + ) + / "astrbot-cpython-runtime" + ) + if target_runtime_root.exists(): + shutil.rmtree(target_runtime_root) + shutil.copytree( + source_runtime_root, + target_runtime_root, + symlinks=sys.platform != "win32", + ) + + if sys.platform == "darwin": + probe_binary = target_runtime_root / "bin" / "python3" + if not probe_binary.is_file(): + raise RuntimeError( + f"Cannot find runtime probe binary: {probe_binary}" + ) + + probe = subprocess.run( + ["otool", "-L", str(probe_binary)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if probe.returncode != 0: + raise RuntimeError( + f"Failed to inspect runtime binary by otool: {probe.stderr.strip()}" + ) + + old_dependency = None + for line in probe.stdout.splitlines()[1:]: + candidate = line.strip().split(" (", 1)[0] + if ( + candidate.startswith( + "/Library/Frameworks/Python.framework/Versions/" + ) + and candidate.endswith("/Python") + ): + old_dependency = candidate + break + + if old_dependency: + patched = 0 + for file_path in target_runtime_root.rglob("*"): + if not file_path.is_file(): + continue + file_mode = file_path.stat().st_mode + if ( + file_path.name != "Python" + and file_path.suffix not in {".dylib", ".so"} + and (file_mode & 0o111) == 0 + ): + continue + inspected = subprocess.run( + ["otool", "-L", str(file_path)], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + if ( + inspected.returncode != 0 + or old_dependency not in inspected.stdout + ): + continue + + relative_libpython = pathlib.Path( + os.path.relpath( + target_runtime_root / "Python", file_path.parent + ) + ).as_posix() + new_dependency = f"@loader_path/{relative_libpython}" + + subprocess.run( + [ + "install_name_tool", + "-change", + old_dependency, + new_dependency, + str(file_path), + ], + check=True, + ) + subprocess.run( + ["codesign", "--force", "--sign", "-", str(file_path)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + patched += 1 + + if patched == 0: + raise RuntimeError( + "Detected absolute Python.framework linkage on macOS runtime, " + "but failed to patch any binary." + ) + + if sys.platform == "win32": + verify_candidates = [ + target_runtime_root / "python.exe", + target_runtime_root / "Scripts" / "python.exe", + ] + else: + verify_candidates = [ + target_runtime_root / "bin" / "python3", + target_runtime_root / "bin" / "python", + ] + + verify_binary = next( + (candidate for candidate in verify_candidates if candidate.is_file()), None + ) + if verify_binary is None: + raise RuntimeError( + f"Cannot find verification runtime binary under {target_runtime_root}" + ) + + verify = subprocess.run( + [str(verify_binary), "-V"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if verify.returncode != 0: + raise RuntimeError( + "Relocated runtime probe failed: " + + ( + verify.stderr.strip() + or verify.stdout.strip() + or f"exit={verify.returncode}" + ) + ) + + print(f"ASTRBOT_DESKTOP_CPYTHON_HOME={target_runtime_root}") PY - name: Setup pnpm @@ -226,10 +392,83 @@ jobs: - name: Build desktop package shell: bash + env: + SETUP_PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} run: | pnpm --dir dashboard run build pnpm --dir desktop run build:webui pnpm --dir desktop run build:backend + if [ -z "$SETUP_PYTHON_PATH" ]; then + echo "actions/setup-python did not return python-path output." >&2 + exit 1 + fi + "$SETUP_PYTHON_PATH" - <<'PY' + import pathlib + import subprocess + import sys + + runtime_root = pathlib.Path("desktop/resources/backend/python") + if sys.platform == "win32": + candidates = [ + runtime_root / "python.exe", + runtime_root / "Scripts" / "python.exe", + ] + else: + candidates = [ + runtime_root / "bin" / "python3", + runtime_root / "bin" / "python", + ] + runtime_python = next((candidate for candidate in candidates if candidate.is_file()), None) + if runtime_python is None: + raise RuntimeError(f"Packaged runtime python executable is missing under {runtime_root}") + + version_check = subprocess.run( + [str(runtime_python), "-V"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if version_check.returncode != 0: + raise RuntimeError( + "Packaged runtime python smoke test failed: " + + (version_check.stderr.strip() or version_check.stdout.strip()) + ) + + if sys.platform == "darwin": + deps = subprocess.run( + ["otool", "-L", str(runtime_python)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if deps.returncode != 0: + raise RuntimeError( + f"Failed to inspect macOS runtime by otool: {deps.stderr.strip()}" + ) + if "/Library/Frameworks/Python.framework/" in deps.stdout: + raise RuntimeError( + "Packaged runtime still links to absolute /Library/Frameworks/Python.framework path." + ) + + if sys.platform.startswith("linux"): + deps = subprocess.run( + ["ldd", str(runtime_python)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if deps.returncode != 0: + raise RuntimeError( + f"Failed to inspect Linux runtime by ldd: {deps.stderr.strip()}" + ) + if "not found" in deps.stdout: + raise RuntimeError( + "Packaged runtime has unresolved shared libraries:\\n" + deps.stdout + ) + PY pnpm --dir desktop run sync:version pnpm --dir desktop exec electron-builder --publish never From 3108863b9d0dabbc8d7de0a6acb78e66c7d39c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 17:58:12 +0900 Subject: [PATCH 30/49] fix(desktop): install runtime deps into packaged python --- .github/workflows/release.yml | 13 ++++++++++++ desktop/scripts/build-backend.mjs | 34 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30bb5640f..373f0a704 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -435,6 +435,19 @@ jobs: + (version_check.stderr.strip() or version_check.stdout.strip()) ) + module_check = subprocess.run( + [str(runtime_python), "-c", "import aiohttp"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if module_check.returncode != 0: + raise RuntimeError( + "Packaged runtime dependency smoke test failed (import aiohttp): " + + (module_check.stderr.strip() or module_check.stdout.strip()) + ) + if sys.platform == "darwin": deps = subprocess.run( ["otool", "-L", str(runtime_python)], diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index 3278c42e1..a81937970 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { @@ -89,6 +90,38 @@ const writeRuntimeManifest = (runtimePython) => { fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); }; +const installRuntimeDependencies = (runtimePython) => { + const requirementsPath = path.join(appDir, 'requirements.txt'); + if (!fs.existsSync(requirementsPath)) { + throw new Error(`Backend requirements file does not exist: ${requirementsPath}`); + } + + const installArgs = [ + '-m', + 'pip', + '--disable-pip-version-check', + 'install', + '--no-cache-dir', + '-r', + requirementsPath, + ]; + const installResult = spawnSync(runtimePython.absolute, installArgs, { + cwd: outputDir, + stdio: 'inherit', + windowsHide: true, + }); + if (installResult.error) { + throw new Error( + `Failed to install backend runtime dependencies: ${installResult.error.message}`, + ); + } + if (installResult.status !== 0) { + throw new Error( + `Backend runtime dependency installation failed with exit code ${installResult.status}.`, + ); + } +}; + const main = () => { const runtimeSourceReal = resolveAndValidateRuntimeSource({ rootDir, @@ -116,6 +149,7 @@ const main = () => { prepareOutputDirs(); copyAppSources(); const runtimePython = prepareRuntimeExecutable(runtimeSourceReal); + installRuntimeDependencies(runtimePython); writeLauncherScript(); writeRuntimeManifest(runtimePython); From bdc963c0e58f78557e3c8a95eaf8fbc729303fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 18:24:28 +0900 Subject: [PATCH 31/49] fix(desktop): retry pip install for uv-managed runtime --- desktop/scripts/build-backend.mjs | 69 +++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index a81937970..bd8f7a16b 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -96,30 +96,65 @@ const installRuntimeDependencies = (runtimePython) => { throw new Error(`Backend requirements file does not exist: ${requirementsPath}`); } - const installArgs = [ - '-m', - 'pip', - '--disable-pip-version-check', - 'install', - '--no-cache-dir', - '-r', - requirementsPath, - ]; - const installResult = spawnSync(runtimePython.absolute, installArgs, { - cwd: outputDir, - stdio: 'inherit', - windowsHide: true, - }); + const runPipInstall = (extraArgs = []) => { + const installArgs = [ + '-m', + 'pip', + '--disable-pip-version-check', + 'install', + '--no-cache-dir', + '-r', + requirementsPath, + ...extraArgs, + ]; + const installResult = spawnSync(runtimePython.absolute, installArgs, { + cwd: outputDir, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + windowsHide: true, + }); + if (installResult.stdout) { + process.stdout.write(installResult.stdout); + } + if (installResult.stderr) { + process.stderr.write(installResult.stderr); + } + return installResult; + }; + + let installResult = runPipInstall(); if (installResult.error) { throw new Error( `Failed to install backend runtime dependencies: ${installResult.error.message}`, ); } - if (installResult.status !== 0) { - throw new Error( - `Backend runtime dependency installation failed with exit code ${installResult.status}.`, + if (installResult.status === 0) { + return; + } + + const outputText = `${installResult.stderr || ''}\n${installResult.stdout || ''}`; + const shouldRetryWithBreakSystemPackages = + outputText.includes('externally-managed-environment') || + outputText.includes('This environment is externally managed'); + + if (shouldRetryWithBreakSystemPackages) { + console.warn( + 'Detected externally managed Python runtime; retrying pip install with --break-system-packages.', ); + installResult = runPipInstall(['--break-system-packages']); + if (installResult.error) { + throw new Error( + `Failed to install backend runtime dependencies: ${installResult.error.message}`, + ); + } + if (installResult.status === 0) { + return; + } } + + throw new Error( + `Backend runtime dependency installation failed with exit code ${installResult.status}.`, + ); }; const main = () => { From 525754864e806ed216dbb3673050e70b5c825d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 18:38:22 +0900 Subject: [PATCH 32/49] fix(ci): use setup-python runtime source for desktop packaging --- .github/workflows/release.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 373f0a704..d5269d361 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -174,15 +174,13 @@ jobs: shell: bash env: RUNNER_TEMP_DIR: ${{ runner.temp }} - UV_NO_CONFIG: "1" + SETUP_PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} run: | - uv python install 3.12 --managed-python - RUNTIME_PY="$(uv python find --managed-python --no-project 3.12)" - if [ -z "$RUNTIME_PY" ]; then - echo "Failed to resolve uv-managed Python runtime for packaging." >&2 + if [ -z "$SETUP_PYTHON_PATH" ]; then + echo "actions/setup-python did not return python-path output." >&2 exit 1 fi - "$RUNTIME_PY" - <<'PY' >> "$GITHUB_ENV" + "$SETUP_PYTHON_PATH" - <<'PY' >> "$GITHUB_ENV" import os import pathlib import shutil From 126954fdd21416736e5e70f426faf0f262b5e2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 18:42:50 +0900 Subject: [PATCH 33/49] refactor(ci): remove obsolete uv fallback paths --- .github/workflows/release.yml | 4 -- desktop/scripts/build-backend.mjs | 69 ++++++++----------------------- 2 files changed, 17 insertions(+), 56 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5269d361..f89f228d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,9 +161,6 @@ jobs: fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - - name: Setup uv - uses: astral-sh/setup-uv@v7 - - name: Setup Python id: setup-python uses: actions/setup-python@v6 @@ -384,7 +381,6 @@ jobs: - name: Install dependencies shell: bash run: | - uv sync pnpm --dir dashboard install --frozen-lockfile pnpm --dir desktop install --frozen-lockfile diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index bd8f7a16b..a81937970 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -96,65 +96,30 @@ const installRuntimeDependencies = (runtimePython) => { throw new Error(`Backend requirements file does not exist: ${requirementsPath}`); } - const runPipInstall = (extraArgs = []) => { - const installArgs = [ - '-m', - 'pip', - '--disable-pip-version-check', - 'install', - '--no-cache-dir', - '-r', - requirementsPath, - ...extraArgs, - ]; - const installResult = spawnSync(runtimePython.absolute, installArgs, { - cwd: outputDir, - stdio: ['ignore', 'pipe', 'pipe'], - encoding: 'utf8', - windowsHide: true, - }); - if (installResult.stdout) { - process.stdout.write(installResult.stdout); - } - if (installResult.stderr) { - process.stderr.write(installResult.stderr); - } - return installResult; - }; - - let installResult = runPipInstall(); + const installArgs = [ + '-m', + 'pip', + '--disable-pip-version-check', + 'install', + '--no-cache-dir', + '-r', + requirementsPath, + ]; + const installResult = spawnSync(runtimePython.absolute, installArgs, { + cwd: outputDir, + stdio: 'inherit', + windowsHide: true, + }); if (installResult.error) { throw new Error( `Failed to install backend runtime dependencies: ${installResult.error.message}`, ); } - if (installResult.status === 0) { - return; - } - - const outputText = `${installResult.stderr || ''}\n${installResult.stdout || ''}`; - const shouldRetryWithBreakSystemPackages = - outputText.includes('externally-managed-environment') || - outputText.includes('This environment is externally managed'); - - if (shouldRetryWithBreakSystemPackages) { - console.warn( - 'Detected externally managed Python runtime; retrying pip install with --break-system-packages.', + if (installResult.status !== 0) { + throw new Error( + `Backend runtime dependency installation failed with exit code ${installResult.status}.`, ); - installResult = runPipInstall(['--break-system-packages']); - if (installResult.error) { - throw new Error( - `Failed to install backend runtime dependencies: ${installResult.error.message}`, - ); - } - if (installResult.status === 0) { - return; - } } - - throw new Error( - `Backend runtime dependency installation failed with exit code ${installResult.status}.`, - ); }; const main = () => { From 3a85efb85f50d7faf628eafc034bee2ae2722b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 18:51:27 +0900 Subject: [PATCH 34/49] refactor(desktop): remove unused electron runtime APIs --- desktop/lib/backend-manager.js | 12 ------------ desktop/lib/common.js | 2 -- desktop/lib/electron-logger.js | 1 - desktop/lib/locale-service.js | 1 - 4 files changed, 16 deletions(-) diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 1460eb65f..f59131cbd 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -74,10 +74,6 @@ class BackendManager { return this.backendUrl; } - getBackendTimeoutMs() { - return this.backendTimeoutMs; - } - getRootDir() { return ( process.env.ASTRBOT_ROOT || @@ -98,14 +94,6 @@ class BackendManager { return this.backendStartupFailureReason; } - isSpawning() { - return this.backendSpawning; - } - - isRestarting() { - return this.backendRestarting; - } - resolveBackendRoot() { if (!this.app.isPackaged) { return null; diff --git a/desktop/lib/common.js b/desktop/lib/common.js index 9f39358dc..56334fefe 100644 --- a/desktop/lib/common.js +++ b/desktop/lib/common.js @@ -100,8 +100,6 @@ function formatLogTimestamp(date = new Date()) { } module.exports = { - LOG_ROTATION_DEFAULT_BACKUP_COUNT, - LOG_ROTATION_DEFAULT_MAX_MB, delay, ensureDir, formatLogTimestamp, diff --git a/desktop/lib/electron-logger.js b/desktop/lib/electron-logger.js index 6a52d1c76..a1416cd86 100644 --- a/desktop/lib/electron-logger.js +++ b/desktop/lib/electron-logger.js @@ -42,7 +42,6 @@ function createElectronLogger({ app, getRootDir }) { } return { - getElectronLogPath, logElectron, flushElectron, }; diff --git a/desktop/lib/locale-service.js b/desktop/lib/locale-service.js index d68039e7d..8e3b3ddc4 100644 --- a/desktop/lib/locale-service.js +++ b/desktop/lib/locale-service.js @@ -170,5 +170,4 @@ function createLocaleService({ app, getRootDir }) { module.exports = { createLocaleService, - normalizeLocale, }; From 650039a5f7fd609cd013adf2c0b3fe2eefe7a150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 19:33:11 +0900 Subject: [PATCH 35/49] fix(ci): use python-build-standalone runtime for desktop packaging --- .github/workflows/release.yml | 200 +++++++++++++++------------------- README.md | 1 + desktop/README.md | 13 +++ 3 files changed, 101 insertions(+), 113 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f89f228d3..cccc72f70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,28 +114,36 @@ jobs: runner: ubuntu-24.04 os: linux arch: amd64 + pbs_target: x86_64-unknown-linux-gnu - name: linux-arm64 runner: ubuntu-24.04-arm os: linux arch: arm64 + pbs_target: aarch64-unknown-linux-gnu - name: windows-x64 runner: windows-2022 os: win arch: amd64 + pbs_target: x86_64-pc-windows-msvc - name: windows-arm64 runner: windows-11-arm os: win arch: arm64 + pbs_target: aarch64-pc-windows-msvc - name: macos-x64 runner: macos-15-intel os: mac arch: amd64 + pbs_target: x86_64-apple-darwin - name: macos-arm64 runner: macos-15 os: mac arch: arm64 + pbs_target: aarch64-apple-darwin env: CSC_IDENTITY_AUTO_DISCOVERY: "false" + PYTHON_BUILD_STANDALONE_RELEASE: "20260211" + PYTHON_BUILD_STANDALONE_VERSION: "3.12.12" steps: - name: Checkout repository uses: actions/checkout@v6 @@ -172,6 +180,7 @@ jobs: env: RUNNER_TEMP_DIR: ${{ runner.temp }} SETUP_PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} + PYTHON_BUILD_STANDALONE_TARGET: ${{ matrix.pbs_target }} run: | if [ -z "$SETUP_PYTHON_PATH" ]; then echo "actions/setup-python did not return python-path output." >&2 @@ -183,32 +192,28 @@ jobs: import shutil import subprocess import sys - - executable = pathlib.Path(sys.executable).resolve() - candidate_roots = [] - if sys.platform == "win32": - candidate_roots.extend([executable.parent, executable.parent.parent]) - else: - candidate_roots.extend([executable.parent.parent, executable.parent]) - - def is_runtime_root(root: pathlib.Path) -> bool: - if sys.platform == "win32": - return (root / "python.exe").is_file() or ( - root / "Scripts" / "python.exe" - ).is_file() - return (root / "bin" / "python3").is_file() or ( - root / "bin" / "python" - ).is_file() - - source_runtime_root = next( - (candidate for candidate in candidate_roots if is_runtime_root(candidate)), - None, - ) - if source_runtime_root is None: + import tarfile + import time + import urllib.parse + import urllib.request + + release = (os.environ.get("PYTHON_BUILD_STANDALONE_RELEASE") or "").strip() + version = (os.environ.get("PYTHON_BUILD_STANDALONE_VERSION") or "").strip() + target = (os.environ.get("PYTHON_BUILD_STANDALONE_TARGET") or "").strip() + if not release or not version or not target: raise RuntimeError( - f"Cannot resolve runtime root from executable: {executable}" + "Missing python-build-standalone selection envs: " + "PYTHON_BUILD_STANDALONE_RELEASE / PYTHON_BUILD_STANDALONE_VERSION / PYTHON_BUILD_STANDALONE_TARGET." ) + asset_name = ( + f"cpython-{version}+{release}-{target}-install_only_stripped.tar.gz" + ) + asset_url = ( + "https://github.com/astral-sh/python-build-standalone/releases/download/" + f"{release}/{urllib.parse.quote(asset_name)}" + ) + target_runtime_root = ( pathlib.Path( os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"] @@ -217,99 +222,50 @@ jobs: ) if target_runtime_root.exists(): shutil.rmtree(target_runtime_root) + + download_archive_path = pathlib.Path( + os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"] + ) / asset_name + extract_root = pathlib.Path( + os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"] + ) / "astrbot-cpython-runtime-extract" + if extract_root.exists(): + shutil.rmtree(extract_root) + extract_root.mkdir(parents=True, exist_ok=True) + + last_error = None + for attempt in range(1, 4): + try: + with urllib.request.urlopen(asset_url, timeout=180) as response: + with download_archive_path.open("wb") as output: + shutil.copyfileobj(response, output) + break + except Exception as exc: + last_error = exc + if attempt >= 3: + raise RuntimeError( + f"Failed to download python-build-standalone asset: {asset_url}" + ) from exc + time.sleep(attempt * 2) + if last_error and not download_archive_path.exists(): + raise RuntimeError( + f"Failed to download python-build-standalone asset: {asset_url}" + ) from last_error + + with tarfile.open(download_archive_path, "r:gz") as archive: + archive.extractall(extract_root) + + source_runtime_root = extract_root / "python" + if not source_runtime_root.is_dir(): + raise RuntimeError( + "Invalid python-build-standalone archive layout: missing top-level python/ directory." + ) shutil.copytree( source_runtime_root, target_runtime_root, symlinks=sys.platform != "win32", ) - if sys.platform == "darwin": - probe_binary = target_runtime_root / "bin" / "python3" - if not probe_binary.is_file(): - raise RuntimeError( - f"Cannot find runtime probe binary: {probe_binary}" - ) - - probe = subprocess.run( - ["otool", "-L", str(probe_binary)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if probe.returncode != 0: - raise RuntimeError( - f"Failed to inspect runtime binary by otool: {probe.stderr.strip()}" - ) - - old_dependency = None - for line in probe.stdout.splitlines()[1:]: - candidate = line.strip().split(" (", 1)[0] - if ( - candidate.startswith( - "/Library/Frameworks/Python.framework/Versions/" - ) - and candidate.endswith("/Python") - ): - old_dependency = candidate - break - - if old_dependency: - patched = 0 - for file_path in target_runtime_root.rglob("*"): - if not file_path.is_file(): - continue - file_mode = file_path.stat().st_mode - if ( - file_path.name != "Python" - and file_path.suffix not in {".dylib", ".so"} - and (file_mode & 0o111) == 0 - ): - continue - inspected = subprocess.run( - ["otool", "-L", str(file_path)], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - check=False, - ) - if ( - inspected.returncode != 0 - or old_dependency not in inspected.stdout - ): - continue - - relative_libpython = pathlib.Path( - os.path.relpath( - target_runtime_root / "Python", file_path.parent - ) - ).as_posix() - new_dependency = f"@loader_path/{relative_libpython}" - - subprocess.run( - [ - "install_name_tool", - "-change", - old_dependency, - new_dependency, - str(file_path), - ], - check=True, - ) - subprocess.run( - ["codesign", "--force", "--sign", "-", str(file_path)], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - patched += 1 - - if patched == 0: - raise RuntimeError( - "Detected absolute Python.framework linkage on macOS runtime, " - "but failed to patch any binary." - ) - if sys.platform == "win32": verify_candidates = [ target_runtime_root / "python.exe", @@ -338,7 +294,7 @@ jobs: ) if verify.returncode != 0: raise RuntimeError( - "Relocated runtime probe failed: " + "Packaged runtime probe failed: " + ( verify.stderr.strip() or verify.stdout.strip() @@ -346,7 +302,25 @@ jobs: ) ) + ssl_verify = subprocess.run( + [str(verify_binary), "-c", "import ssl"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if ssl_verify.returncode != 0: + raise RuntimeError( + "Packaged runtime ssl probe failed: " + + ( + ssl_verify.stderr.strip() + or ssl_verify.stdout.strip() + or f"exit={ssl_verify.returncode}" + ) + ) + print(f"ASTRBOT_DESKTOP_CPYTHON_HOME={target_runtime_root}") + print(f"ASTRBOT_DESKTOP_CPYTHON_ASSET={asset_name}") PY - name: Setup pnpm @@ -430,7 +404,7 @@ jobs: ) module_check = subprocess.run( - [str(runtime_python), "-c", "import aiohttp"], + [str(runtime_python), "-c", "import ssl, aiohttp"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -438,7 +412,7 @@ jobs: ) if module_check.returncode != 0: raise RuntimeError( - "Packaged runtime dependency smoke test failed (import aiohttp): " + "Packaged runtime dependency smoke test failed (import ssl, aiohttp): " + (module_check.stderr.strip() or module_check.stdout.strip()) ) diff --git a/README.md b/README.md index 0445c1b48..6d4eb71cb 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ paru -S astrbot-git 桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。 打包前需要准备 CPython 运行时目录,并设置 `ASTRBOT_DESKTOP_CPYTHON_HOME`(详见该文档)。 +建议使用 `python-build-standalone` 的 `install_only` 运行时作为分发基线。 ## 支持的消息平台 diff --git a/desktop/README.md b/desktop/README.md index 4cb8cc3d4..1d510b2d4 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -40,6 +40,19 @@ pnpm --dir desktop install --frozen-lockfile pnpm --dir desktop run dist:full ``` +Recommended runtime source for local packaging is `python-build-standalone` (same family used in CI): + +```bash +PBS_RELEASE=20260211 +PBS_VERSION=3.12.12 +PBS_TARGET=aarch64-apple-darwin # e.g. x86_64-apple-darwin / x86_64-unknown-linux-gnu / x86_64-pc-windows-msvc +RUNTIME_BASE="$HOME/.cache/astrbot-python-runtime/$PBS_TARGET-$PBS_VERSION" +mkdir -p "$RUNTIME_BASE" +curl -L "https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_VERSION}%2B${PBS_RELEASE}-${PBS_TARGET}-install_only_stripped.tar.gz" \ + | tar -xzf - -C "$RUNTIME_BASE" +export ASTRBOT_DESKTOP_CPYTHON_HOME="$RUNTIME_BASE/python" +``` + `ASTRBOT_DESKTOP_CPYTHON_HOME` must point to a standalone/distributable CPython runtime directory. Virtual environments (for example `.venv`, detected by `pyvenv.cfg`) are not supported for packaged runtime builds. From 4987911764ff0bc3e848cc223a2d48c322197214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 19:40:33 +0900 Subject: [PATCH 36/49] chore(ci): remove runtime import smoke check --- .github/workflows/release.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cccc72f70..f3bae6e5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -403,19 +403,6 @@ jobs: + (version_check.stderr.strip() or version_check.stdout.strip()) ) - module_check = subprocess.run( - [str(runtime_python), "-c", "import ssl, aiohttp"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if module_check.returncode != 0: - raise RuntimeError( - "Packaged runtime dependency smoke test failed (import ssl, aiohttp): " - + (module_check.stderr.strip() or module_check.stdout.strip()) - ) - if sys.platform == "darwin": deps = subprocess.run( ["otool", "-L", str(runtime_python)], From 3d863243767b788edee7e2434744b45a28d6fdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 21:20:00 +0900 Subject: [PATCH 37/49] fix(desktop): add windows dll search paths for bundled runtime --- desktop/scripts/templates/launch_backend.py | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/desktop/scripts/templates/launch_backend.py b/desktop/scripts/templates/launch_backend.py index ac33fb563..0bfbdac93 100644 --- a/desktop/scripts/templates/launch_backend.py +++ b/desktop/scripts/templates/launch_backend.py @@ -1,11 +1,55 @@ from __future__ import annotations +import os import runpy import sys from pathlib import Path BACKEND_DIR = Path(__file__).resolve().parent APP_DIR = BACKEND_DIR / "app" +_WINDOWS_DLL_DIRECTORY_HANDLES: list[object] = [] + + +def configure_windows_dll_search_path() -> None: + if sys.platform != "win32" or not hasattr(os, "add_dll_directory"): + return + + runtime_executable_dir = Path(sys.executable).resolve().parent + candidates = [ + runtime_executable_dir, + runtime_executable_dir / "DLLs", + BACKEND_DIR / "python", + BACKEND_DIR / "python" / "DLLs", + ] + + normalized_added: set[str] = set() + path_entries: list[str] = [] + for candidate in candidates: + if not candidate.is_dir(): + continue + candidate_str = str(candidate) + candidate_key = candidate_str.lower() + if candidate_key in normalized_added: + continue + normalized_added.add(candidate_key) + path_entries.append(candidate_str) + try: + _WINDOWS_DLL_DIRECTORY_HANDLES.append( + os.add_dll_directory(candidate_str), + ) + except OSError: + continue + + if path_entries: + existing_path = os.environ.get("PATH", "") + os.environ["PATH"] = ( + ";".join(path_entries + [existing_path]) + if existing_path + else ";".join(path_entries) + ) + + +configure_windows_dll_search_path() sys.path.insert(0, str(APP_DIR)) From 8e15e80797253800aa2287ab8d02a1729bf8b093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 22:27:57 +0900 Subject: [PATCH 38/49] fix(desktop): harden windows dll resolution in launcher --- desktop/scripts/templates/launch_backend.py | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/desktop/scripts/templates/launch_backend.py b/desktop/scripts/templates/launch_backend.py index 0bfbdac93..ee90ef2ac 100644 --- a/desktop/scripts/templates/launch_backend.py +++ b/desktop/scripts/templates/launch_backend.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ctypes import os import runpy import sys @@ -15,12 +16,23 @@ def configure_windows_dll_search_path() -> None: return runtime_executable_dir = Path(sys.executable).resolve().parent + site_packages_dirs = [ + runtime_executable_dir / "Lib" / "site-packages", + BACKEND_DIR / "python" / "Lib" / "site-packages", + ] candidates = [ runtime_executable_dir, runtime_executable_dir / "DLLs", BACKEND_DIR / "python", BACKEND_DIR / "python" / "DLLs", ] + for site_packages_dir in site_packages_dirs: + candidates.extend( + [ + site_packages_dir / "cryptography.libs", + site_packages_dir / "cryptography" / "hazmat" / "bindings", + ], + ) normalized_added: set[str] = set() path_entries: list[str] = [] @@ -49,7 +61,45 @@ def configure_windows_dll_search_path() -> None: ) +def preload_windows_runtime_dlls() -> None: + if sys.platform != "win32": + return + + runtime_executable_dir = Path(sys.executable).resolve().parent + runtime_dll_dir = runtime_executable_dir / "DLLs" + backend_runtime_dir = BACKEND_DIR / "python" + backend_runtime_dll_dir = backend_runtime_dir / "DLLs" + candidate_dirs = [ + runtime_executable_dir, + runtime_dll_dir, + backend_runtime_dir, + backend_runtime_dll_dir, + ] + patterns = [ + "python3.dll", + "python*.dll", + "vcruntime*.dll", + "libcrypto-*.dll", + "libssl-*.dll", + ] + loaded: set[str] = set() + for candidate_dir in candidate_dirs: + if not candidate_dir.is_dir(): + continue + for pattern in patterns: + for dll_path in candidate_dir.glob(pattern): + normalized_path = str(dll_path.resolve()).lower() + if normalized_path in loaded: + continue + loaded.add(normalized_path) + try: + ctypes.WinDLL(str(dll_path)) + except OSError: + continue + + configure_windows_dll_search_path() +preload_windows_runtime_dlls() sys.path.insert(0, str(APP_DIR)) From 4e301866a3b18f2db86cb88a9e489d2cec05b5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 23:36:07 +0900 Subject: [PATCH 39/49] refactor(ci): rebuild windows desktop release jobs --- .github/workflows/release.yml | 322 +++++++++++++++++++++++++++++++--- 1 file changed, 297 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3bae6e5b..e4975507b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,16 +120,6 @@ jobs: os: linux arch: arm64 pbs_target: aarch64-unknown-linux-gnu - - name: windows-x64 - runner: windows-2022 - os: win - arch: amd64 - pbs_target: x86_64-pc-windows-msvc - - name: windows-arm64 - runner: windows-11-arm - os: win - arch: arm64 - pbs_target: aarch64-pc-windows-msvc - name: macos-x64 runner: macos-15-intel os: mac @@ -337,21 +327,6 @@ jobs: dashboard/pnpm-lock.yaml desktop/pnpm-lock.yaml - - name: Prepare OpenSSL for Windows ARM64 - if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }} - shell: pwsh - run: | - git clone https://github.com/microsoft/vcpkg.git C:\vcpkg - & C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics - & C:\vcpkg\vcpkg.exe install openssl:arm64-windows - - "VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Install dependencies shell: bash run: | @@ -488,12 +463,309 @@ jobs: if-no-files-found: error path: desktop/dist/release/* + build-desktop-windows: + name: Build windows-${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2022 + arch: amd64 + pbs_target: x86_64-pc-windows-msvc + - runner: windows-11-arm + arch: arm64 + pbs_target: aarch64-pc-windows-msvc + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + PYTHON_BUILD_STANDALONE_RELEASE: "20260211" + PYTHON_BUILD_STANDALONE_VERSION: "3.12.12" + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.ref != '' && inputs.ref || github.ref_name }} + + - name: Resolve tag + id: tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + tag="${GITHUB_REF_NAME}" + elif [ -n "${{ inputs.tag }}" ]; then + tag="${{ inputs.tag }}" + else + echo "workflow_dispatch requires 'tag' input (for example: v4.17.5)." >&2 + exit 1 + fi + if [ -z "$tag" ]; then + echo "Failed to resolve tag." >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Setup Python + id: setup-python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Resolve packaged CPython runtime source + shell: bash + env: + RUNNER_TEMP_DIR: ${{ runner.temp }} + SETUP_PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} + PYTHON_BUILD_STANDALONE_TARGET: ${{ matrix.pbs_target }} + run: | + if [ -z "$SETUP_PYTHON_PATH" ]; then + echo "actions/setup-python did not return python-path output." >&2 + exit 1 + fi + "$SETUP_PYTHON_PATH" - <<'PY' >> "$GITHUB_ENV" + import os + import pathlib + import shutil + import subprocess + import tarfile + import time + import urllib.parse + import urllib.request + + release = (os.environ.get("PYTHON_BUILD_STANDALONE_RELEASE") or "").strip() + version = (os.environ.get("PYTHON_BUILD_STANDALONE_VERSION") or "").strip() + target = (os.environ.get("PYTHON_BUILD_STANDALONE_TARGET") or "").strip() + if not release or not version or not target: + raise RuntimeError( + "Missing python-build-standalone selection envs: " + "PYTHON_BUILD_STANDALONE_RELEASE / PYTHON_BUILD_STANDALONE_VERSION / PYTHON_BUILD_STANDALONE_TARGET." + ) + + asset_name = ( + f"cpython-{version}+{release}-{target}-install_only_stripped.tar.gz" + ) + asset_url = ( + "https://github.com/astral-sh/python-build-standalone/releases/download/" + f"{release}/{urllib.parse.quote(asset_name)}" + ) + + runner_temp = pathlib.Path( + os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"] + ) + target_runtime_root = runner_temp / "astrbot-cpython-runtime" + download_archive_path = runner_temp / asset_name + extract_root = runner_temp / "astrbot-cpython-runtime-extract" + if target_runtime_root.exists(): + shutil.rmtree(target_runtime_root) + if extract_root.exists(): + shutil.rmtree(extract_root) + extract_root.mkdir(parents=True, exist_ok=True) + + last_error = None + for attempt in range(1, 4): + try: + with urllib.request.urlopen(asset_url, timeout=180) as response: + with download_archive_path.open("wb") as output: + shutil.copyfileobj(response, output) + break + except Exception as exc: + last_error = exc + if attempt >= 3: + raise RuntimeError( + f"Failed to download python-build-standalone asset: {asset_url}" + ) from exc + time.sleep(attempt * 2) + if last_error and not download_archive_path.exists(): + raise RuntimeError( + f"Failed to download python-build-standalone asset: {asset_url}" + ) from last_error + + with tarfile.open(download_archive_path, "r:gz") as archive: + archive.extractall(extract_root) + + source_runtime_root = extract_root / "python" + if not source_runtime_root.is_dir(): + raise RuntimeError( + "Invalid python-build-standalone archive layout: missing top-level python/ directory." + ) + shutil.copytree(source_runtime_root, target_runtime_root, symlinks=False) + + verify_candidates = [ + target_runtime_root / "python.exe", + target_runtime_root / "Scripts" / "python.exe", + ] + verify_binary = next( + (candidate for candidate in verify_candidates if candidate.is_file()), None + ) + if verify_binary is None: + raise RuntimeError( + f"Cannot find verification runtime binary under {target_runtime_root}" + ) + + version_check = subprocess.run( + [str(verify_binary), "-V"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if version_check.returncode != 0: + raise RuntimeError( + "Packaged runtime probe failed: " + + ( + version_check.stderr.strip() + or version_check.stdout.strip() + or f"exit={version_check.returncode}" + ) + ) + + ssl_check = subprocess.run( + [str(verify_binary), "-c", "import ssl"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if ssl_check.returncode != 0: + raise RuntimeError( + "Packaged runtime ssl probe failed: " + + ( + ssl_check.stderr.strip() + or ssl_check.stdout.strip() + or f"exit={ssl_check.returncode}" + ) + ) + + print(f"ASTRBOT_DESKTOP_CPYTHON_HOME={target_runtime_root}") + print(f"ASTRBOT_DESKTOP_CPYTHON_ASSET={asset_name}") + PY + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.28.2 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.13.0' + cache: "pnpm" + cache-dependency-path: | + dashboard/pnpm-lock.yaml + desktop/pnpm-lock.yaml + + - name: Install dependencies + shell: bash + run: | + pnpm --dir dashboard install --frozen-lockfile + pnpm --dir desktop install --frozen-lockfile + + - name: Build desktop package + shell: bash + env: + SETUP_PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} + run: | + pnpm --dir dashboard run build + pnpm --dir desktop run build:webui + pnpm --dir desktop run build:backend + if [ -z "$SETUP_PYTHON_PATH" ]; then + echo "actions/setup-python did not return python-path output." >&2 + exit 1 + fi + "$SETUP_PYTHON_PATH" - <<'PY' + import pathlib + import subprocess + + runtime_root = pathlib.Path("desktop/resources/backend/python") + candidates = [ + runtime_root / "python.exe", + runtime_root / "Scripts" / "python.exe", + ] + runtime_python = next((candidate for candidate in candidates if candidate.is_file()), None) + if runtime_python is None: + raise RuntimeError(f"Packaged runtime python executable is missing under {runtime_root}") + + version_check = subprocess.run( + [str(runtime_python), "-V"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if version_check.returncode != 0: + raise RuntimeError( + "Packaged runtime python smoke test failed: " + + (version_check.stderr.strip() or version_check.stdout.strip()) + ) + + ssl_check = subprocess.run( + [str(runtime_python), "-c", "import ssl"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if ssl_check.returncode != 0: + raise RuntimeError( + "Packaged runtime ssl smoke test failed: " + + (ssl_check.stderr.strip() or ssl_check.stdout.strip()) + ) + PY + pnpm --dir desktop run sync:version + if [ "${{ matrix.arch }}" = "arm64" ]; then + arch_flag="--arm64" + else + arch_flag="--x64" + fi + pnpm --dir desktop exec electron-builder --publish never --win nsis zip "$arch_flag" + + - name: Normalize artifact names + shell: bash + env: + NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-win + run: | + shopt -s nullglob + out_dir="desktop/dist/release" + mkdir -p "$out_dir" + files=( + desktop/dist/*.zip + desktop/dist/*.exe + ) + if [ ${#files[@]} -eq 0 ]; then + echo "No Windows desktop artifacts found to rename." >&2 + exit 1 + fi + for src in "${files[@]}"; do + file="$(basename "$src")" + case "$file" in + *.exe) + dest="$out_dir/${NAME_PREFIX}.exe" + ;; + *.zip) + dest="$out_dir/${NAME_PREFIX}.zip" + ;; + *) + continue + ;; + esac + cp "$src" "$dest" + done + ls -la "$out_dir" + + - name: Upload desktop artifacts + uses: actions/upload-artifact@v6 + with: + name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-win + if-no-files-found: error + path: desktop/dist/release/* + publish-release: name: Publish GitHub Release runs-on: ubuntu-24.04 needs: - build-dashboard - build-desktop + - build-desktop-windows steps: - name: Checkout repository uses: actions/checkout@v6 From b46bd76f946ac4c476a7f9226a1dbd2a6b79bddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 18 Feb 2026 23:49:34 +0900 Subject: [PATCH 40/49] fix(ci): avoid cryptography source build on windows arm64 --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4975507b..26e28c934 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -664,6 +664,7 @@ jobs: shell: bash env: SETUP_PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} + PIP_ONLY_BINARY: cryptography run: | pnpm --dir dashboard run build pnpm --dir desktop run build:webui From 908c3671326ab349a40152c60d47e77c47316262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 19 Feb 2026 08:19:29 +0900 Subject: [PATCH 41/49] fix(desktop): bundle msvc runtime for windows backend --- desktop/scripts/build-backend.mjs | 46 +++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index a81937970..503801e9e 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -96,20 +96,23 @@ const installRuntimeDependencies = (runtimePython) => { throw new Error(`Backend requirements file does not exist: ${requirementsPath}`); } - const installArgs = [ - '-m', - 'pip', - '--disable-pip-version-check', - 'install', - '--no-cache-dir', - '-r', - requirementsPath, - ]; - const installResult = spawnSync(runtimePython.absolute, installArgs, { - cwd: outputDir, - stdio: 'inherit', - windowsHide: true, - }); + const runPipInstall = (pipArgs) => { + const installArgs = [ + '-m', + 'pip', + '--disable-pip-version-check', + 'install', + '--no-cache-dir', + ...pipArgs, + ]; + return spawnSync(runtimePython.absolute, installArgs, { + cwd: outputDir, + stdio: 'inherit', + windowsHide: true, + }); + }; + + const installResult = runPipInstall(['-r', requirementsPath]); if (installResult.error) { throw new Error( `Failed to install backend runtime dependencies: ${installResult.error.message}`, @@ -120,6 +123,21 @@ const installRuntimeDependencies = (runtimePython) => { `Backend runtime dependency installation failed with exit code ${installResult.status}.`, ); } + + if (process.platform === 'win32') { + // greenlet and some binary wheels require MSVC runtime DLLs on end-user machines. + const msvcRuntimeResult = runPipInstall(['--only-binary=:all:', 'msvc-runtime']); + if (msvcRuntimeResult.error) { + throw new Error( + `Failed to install Windows MSVC runtime package: ${msvcRuntimeResult.error.message}`, + ); + } + if (msvcRuntimeResult.status !== 0) { + throw new Error( + `Windows MSVC runtime installation failed with exit code ${msvcRuntimeResult.status}.`, + ); + } + } }; const main = () => { From ac27872a5533f935d38c48e06a9adb47bc8c31d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 19 Feb 2026 09:38:46 +0900 Subject: [PATCH 42/49] fix(desktop): force utf-8 backend log output on windows --- desktop/lib/backend-manager.js | 2 ++ desktop/scripts/templates/launch_backend.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index f59131cbd..6e2e6aece 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -414,6 +414,8 @@ class BackendManager { const env = { ...process.env, PYTHONUNBUFFERED: '1', + PYTHONUTF8: process.env.PYTHONUTF8 || '1', + PYTHONIOENCODING: process.env.PYTHONIOENCODING || 'utf-8', }; if (this.app.isPackaged) { env.ASTRBOT_ELECTRON_CLIENT = '1'; diff --git a/desktop/scripts/templates/launch_backend.py b/desktop/scripts/templates/launch_backend.py index ee90ef2ac..ce31c4f06 100644 --- a/desktop/scripts/templates/launch_backend.py +++ b/desktop/scripts/templates/launch_backend.py @@ -11,6 +11,21 @@ _WINDOWS_DLL_DIRECTORY_HANDLES: list[object] = [] +def configure_stdio_utf8() -> None: + os.environ.setdefault("PYTHONUTF8", "1") + os.environ.setdefault("PYTHONIOENCODING", "utf-8") + + for stream_name in ("stdout", "stderr"): + stream = getattr(sys, stream_name, None) + reconfigure = getattr(stream, "reconfigure", None) + if not callable(reconfigure): + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except Exception: + continue + + def configure_windows_dll_search_path() -> None: if sys.platform != "win32" or not hasattr(os, "add_dll_directory"): return @@ -98,6 +113,7 @@ def preload_windows_runtime_dlls() -> None: continue +configure_stdio_utf8() configure_windows_dll_search_path() preload_windows_runtime_dlls() From 669d6d8f6d727985d79f5927836d00ae89e8fb22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 19 Feb 2026 09:51:44 +0900 Subject: [PATCH 43/49] fix: make tray backend restart always run in main process --- desktop/main.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/desktop/main.js b/desktop/main.js index 5adff38b3..ac83ce8d1 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -124,11 +124,6 @@ function updateTrayMenu() { } if (mainWindow && !mainWindow.isDestroyed()) { showWindow(); - const currentUrl = mainWindow.webContents.getURL(); - if (currentUrl.startsWith(backendManager.getBackendUrl())) { - mainWindow.webContents.send('astrbot-desktop:tray-restart-backend'); - return; - } } const result = await backendManager.restartBackend(); From 38f8d556248ff9b6577389276eef9c9499825147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 19 Feb 2026 10:13:31 +0900 Subject: [PATCH 44/49] fix: enforce wheel-only plugin dependency installs in packaged runtime --- astrbot/core/utils/pip_installer.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index fc1819f40..39fdd8a88 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -131,6 +131,25 @@ def _extract_requirement_names(requirements_path: str) -> set[str]: return names +def _strip_binary_selector_args(args: list[str]) -> list[str]: + normalized: list[str] = [] + skip_next = False + for arg in args: + if skip_next: + skip_next = False + continue + + if arg in {"--only-binary", "--no-binary"}: + skip_next = True + continue + + if arg.startswith("--only-binary=") or arg.startswith("--no-binary="): + continue + + normalized.append(arg) + return normalized + + def _extract_top_level_modules( distribution: importlib_metadata.Distribution, ) -> set[str]: @@ -469,6 +488,8 @@ async def install( _prepend_sys_path(target_site_packages) args.extend(["--target", target_site_packages]) args.extend(["--upgrade", "--force-reinstall"]) + pip_install_args = _strip_binary_selector_args(pip_install_args) + args.append("--only-binary=:all") if pip_install_args: args.extend(pip_install_args) From ae9b164b26962a5764418d23e9be30eadd1db2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 19 Feb 2026 10:46:39 +0900 Subject: [PATCH 45/49] refactor: simplify backend cleanup flow and extract vite postcss plugin --- .../plugins/normalize-nested-type-selector.ts | 40 +++++++++++ dashboard/vite.config.ts | 38 +---------- desktop/lib/backend-manager.js | 68 +++++++++---------- desktop/lib/windows-backend-cleanup.js | 53 ++++----------- 4 files changed, 85 insertions(+), 114 deletions(-) create mode 100644 dashboard/src/plugins/normalize-nested-type-selector.ts diff --git a/dashboard/src/plugins/normalize-nested-type-selector.ts b/dashboard/src/plugins/normalize-nested-type-selector.ts new file mode 100644 index 000000000..1218b44e6 --- /dev/null +++ b/dashboard/src/plugins/normalize-nested-type-selector.ts @@ -0,0 +1,40 @@ +type RuleNode = { + parent?: { type?: string }; + selector?: string; + source?: { input?: { file?: string; from?: string } }; +}; + +const normalizeNestedTypeSelectorPlugin = { + postcssPlugin: 'normalize-nested-type-selector', + Rule(rule: RuleNode) { + if (rule.parent?.type !== 'rule' || typeof rule.selector !== 'string') { + return; + } + + const sourceFile = String(rule.source?.input?.file || rule.source?.input?.from || '') + .replace(/\\/g, '/') + .toLowerCase(); + const isProjectSource = sourceFile.includes('/dashboard/src/'); + const isMonacoVendor = sourceFile.includes('/node_modules/monaco-editor/'); + if (!isProjectSource && !isMonacoVendor) { + return; + } + + const segments = rule.selector + .split(',') + .map((segment) => segment.trim()) + .filter(Boolean); + if (!segments.length) { + return; + } + + const typeOnlyPattern = /^[a-zA-Z][\w-]*$/; + if (!segments.every((segment) => typeOnlyPattern.test(segment))) { + return; + } + + rule.selector = segments.map((segment) => `:is(${segment})`).join(', '); + }, +}; + +export default normalizeNestedTypeSelectorPlugin; diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index f44f733ec..b9de7157e 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -2,43 +2,7 @@ import { fileURLToPath, URL } from 'url'; import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import vuetify from 'vite-plugin-vuetify'; - -const normalizeNestedTypeSelectorPlugin = { - postcssPlugin: 'normalize-nested-type-selector', - Rule(rule: { - parent?: { type?: string; parent?: unknown; selector?: string }; - selector?: string; - source?: { input?: { file?: string; from?: string } }; - }) { - if (rule.parent?.type !== 'rule' || typeof rule.selector !== 'string') { - return; - } - - const sourceFile = String(rule.source?.input?.file || rule.source?.input?.from || '') - .replace(/\\/g, '/') - .toLowerCase(); - const isProjectSource = sourceFile.includes('/dashboard/src/'); - const isMonacoVendor = sourceFile.includes('/node_modules/monaco-editor/'); - if (!isProjectSource && !isMonacoVendor) { - return; - } - - const segments = rule.selector - .split(',') - .map((segment) => segment.trim()) - .filter(Boolean); - if (!segments.length) { - return; - } - - const typeOnlyPattern = /^[a-zA-Z][\w-]*$/; - if (!segments.every((segment) => typeOnlyPattern.test(segment))) { - return; - } - - rule.selector = segments.map((segment) => `:is(${segment})`).join(', '); - } -}; +import normalizeNestedTypeSelectorPlugin from './src/plugins/normalize-nested-type-selector'; // https://vitejs.dev/config/ export default defineConfig({ diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 6e2e6aece..bcd7a692e 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -68,6 +68,11 @@ class BackendManager { this.backendStartupFailureReason = null; this.backendSpawning = false; this.backendRestarting = false; + this.unmanagedCleanupBaseContext = { + fallbackCmdRaw: process.env.ASTRBOT_BACKEND_CMD || 'python.exe', + spawnSync, + log: (message) => this.log(message), + }; } getBackendUrl() { @@ -165,7 +170,7 @@ class BackendManager { }; } - buildBackendConfig() { + resolveBackendConfig() { const webuiDir = this.resolveWebuiDir(); let launch = null; let failureReason = null; @@ -183,19 +188,6 @@ class BackendManager { const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot(); - - return { - launch, - cwd, - rootDir, - webuiDir, - failureReason, - }; - } - - resolveBackendConfig() { - const { launch, cwd, rootDir, webuiDir, failureReason } = - this.buildBackendConfig(); ensureDir(cwd); if (rootDir) { ensureDir(rootDir); @@ -217,7 +209,24 @@ class BackendManager { getBackendConfig() { if (!this.backendConfig) { - return this.resolveBackendConfig(); + try { + return this.resolveBackendConfig(); + } catch (error) { + this.log( + `Failed to resolve backend config: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + this.backendConfig = { + cmd: null, + args: [], + shell: true, + cwd: process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(), + webuiDir: this.resolveWebuiDir(), + rootDir: process.env.ASTRBOT_ROOT || this.resolveBackendRoot(), + failureReason: 'Backend command is not configured.', + }; + } } return this.backendConfig; } @@ -667,30 +676,13 @@ class BackendManager { this.log( `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, ); - let backendConfig = null; - try { - backendConfig = this.getBackendConfig(); - } catch (error) { - this.log( - `Failed to resolve backend config during unmanaged cleanup: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - const hasBackendConfig = backendConfig && typeof backendConfig === 'object'; - if (!hasBackendConfig) { + const backendConfig = this.getBackendConfig(); + const hasBackendCommand = Boolean(backendConfig.cmd); + if (!hasBackendCommand) { this.log( - 'Backend config is unavailable during unmanaged cleanup; falling back to image-name-only matching.', + 'Backend command is not configured during unmanaged cleanup; falling back to image-name-only matching.', ); } - const contextBase = { - backendConfig, - allowImageOnlyMatch: !hasBackendConfig, - commandLineCache: new Map(), - fallbackCmdRaw: process.env.ASTRBOT_BACKEND_CMD || 'python.exe', - spawnSync, - log: (message) => this.log(message), - }; for (const pid of pids) { const processInfo = this.getWindowsProcessInfo(pid); @@ -701,7 +693,9 @@ class BackendManager { const shouldKill = shouldKillUnmanagedBackendProcess({ pid, processInfo, - ...contextBase, + backendConfig, + allowImageOnlyMatch: !hasBackendCommand, + ...this.unmanagedCleanupBaseContext, }); if (!shouldKill) { continue; diff --git a/desktop/lib/windows-backend-cleanup.js b/desktop/lib/windows-backend-cleanup.js index 8ff6724f6..e20519200 100644 --- a/desktop/lib/windows-backend-cleanup.js +++ b/desktop/lib/windows-backend-cleanup.js @@ -3,28 +3,9 @@ const path = require('path'); const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 2000; -const COMMAND_LINE_QUERY_UNAVAILABLE_KEY = '__command_line_query_unavailable__'; -const COMMAND_LINE_FALLBACK_LOGGED_KEY = '__command_line_fallback_logged__'; - -function isQueryUnavailable(cache) { - return !!(cache && cache.get(COMMAND_LINE_QUERY_UNAVAILABLE_KEY)); -} - -function setQueryUnavailable(cache, value) { - if (cache) { - cache.set(COMMAND_LINE_QUERY_UNAVAILABLE_KEY, !!value); - } -} - -function wasFallbackLogged(cache) { - return !!(cache && cache.get(COMMAND_LINE_FALLBACK_LOGGED_KEY)); -} - -function markFallbackLogged(cache) { - if (cache) { - cache.set(COMMAND_LINE_FALLBACK_LOGGED_KEY, true); - } -} +const commandLineCache = new Map(); +let commandLineQueryUnavailable = false; +let commandLineFallbackLogged = false; function normalizeWindowsPathForMatch(value) { return String(value || '') @@ -37,17 +18,17 @@ function isGenericWindowsPythonImage(imageName) { return normalized === 'python.exe' || normalized === 'pythonw.exe' || normalized === 'py.exe'; } -function getWindowsProcessCommandLine({ pid, commandLineCache, spawnSync, log, timeoutMs }) { +function getWindowsProcessCommandLine({ pid, spawnSync, log, timeoutMs }) { const numericPid = Number.parseInt(`${pid}`, 10); if (!Number.isInteger(numericPid)) { return { commandLine: null, commandLineQueryUnavailable: false }; } - if (isQueryUnavailable(commandLineCache)) { + if (commandLineQueryUnavailable) { return { commandLine: null, commandLineQueryUnavailable: true }; } - if (commandLineCache && commandLineCache.has(numericPid)) { + if (commandLineCache.has(numericPid)) { return { commandLine: commandLineCache.get(numericPid), commandLineQueryUnavailable: false, @@ -103,23 +84,19 @@ function getWindowsProcessCommandLine({ pid, commandLineCache, spawnSync, log, t .split(/\r?\n/) .map((item) => item.trim()) .find((item) => item.length > 0) || null; - if (commandLineCache) { - commandLineCache.set(numericPid, commandLine); - setQueryUnavailable(commandLineCache, false); - } + commandLineCache.set(numericPid, commandLine); + commandLineQueryUnavailable = false; return { commandLine, commandLineQueryUnavailable: false }; } } if (!hasAvailableShell) { - setQueryUnavailable(commandLineCache, true); + commandLineQueryUnavailable = true; return { commandLine: null, commandLineQueryUnavailable: true }; } - if (commandLineCache) { - commandLineCache.set(numericPid, null); - setQueryUnavailable(commandLineCache, false); - } + commandLineCache.set(numericPid, null); + commandLineQueryUnavailable = false; return { commandLine: null, commandLineQueryUnavailable: false }; } @@ -162,7 +139,6 @@ function matchesExpectedImage(processInfo, expectedImageName) { function matchesBackendMarkers({ pid, backendConfig, - commandLineCache, spawnSync, log, }) { @@ -174,15 +150,14 @@ function matchesBackendMarkers({ const { commandLine, commandLineQueryUnavailable } = getWindowsProcessCommandLine({ pid, - commandLineCache, spawnSync, log, timeoutMs: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, }); if (!commandLine) { if (commandLineQueryUnavailable) { - if (!wasFallbackLogged(commandLineCache)) { - markFallbackLogged(commandLineCache); + if (!commandLineFallbackLogged) { + commandLineFallbackLogged = true; log( 'Neither powershell nor pwsh is available. ' + 'Falling back to image-name-only matching for generic Python backend cleanup.', @@ -209,7 +184,6 @@ function shouldKillUnmanagedBackendProcess({ processInfo, backendConfig, allowImageOnlyMatch, - commandLineCache, spawnSync, log, fallbackCmdRaw, @@ -229,7 +203,6 @@ function shouldKillUnmanagedBackendProcess({ return matchesBackendMarkers({ pid, backendConfig, - commandLineCache, spawnSync, log, }); From bca8ab11132464df7a6c0228ffc84f4d64e3ec86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 19 Feb 2026 11:00:05 +0900 Subject: [PATCH 46/49] refactor(ci): deduplicate packaged cpython runtime resolution fix: use --only-binary=:all: and reset windows cleanup probe state --- .../resolve_packaged_cpython_runtime.py | 139 ++++++++++ .github/workflows/release.yml | 255 +----------------- astrbot/core/utils/pip_installer.py | 2 +- desktop/lib/backend-manager.js | 6 +- desktop/lib/windows-backend-cleanup.js | 7 + 5 files changed, 154 insertions(+), 255 deletions(-) create mode 100644 .github/scripts/resolve_packaged_cpython_runtime.py diff --git a/.github/scripts/resolve_packaged_cpython_runtime.py b/.github/scripts/resolve_packaged_cpython_runtime.py new file mode 100644 index 000000000..cacd38ee6 --- /dev/null +++ b/.github/scripts/resolve_packaged_cpython_runtime.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Resolve and verify python-build-standalone runtime for desktop packaging.""" + +from __future__ import annotations + +import os +import pathlib +import shutil +import subprocess +import sys +import tarfile +import time +import urllib.parse +import urllib.request + + +def _require_env(name: str) -> str: + value = (os.environ.get(name) or "").strip() + if not value: + raise RuntimeError(f"Missing required environment variable: {name}") + return value + + +def _download_with_retries( + url: str, output_path: pathlib.Path, retries: int = 3 +) -> None: + last_error: Exception | None = None + for attempt in range(1, retries + 1): + try: + with urllib.request.urlopen(url, timeout=180) as response: + with output_path.open("wb") as output: + shutil.copyfileobj(response, output) + return + except Exception as exc: # pragma: no cover - network-path fallback + last_error = exc + if attempt >= retries: + raise RuntimeError( + f"Failed to download python-build-standalone asset: {url}" + ) from exc + time.sleep(attempt * 2) + + raise RuntimeError( + f"Failed to download python-build-standalone asset: {url}" + ) from last_error + + +def _resolve_runtime_python(runtime_root: pathlib.Path) -> pathlib.Path: + if sys.platform == "win32": + candidates = [ + runtime_root / "python.exe", + runtime_root / "Scripts" / "python.exe", + ] + else: + candidates = [ + runtime_root / "bin" / "python3", + runtime_root / "bin" / "python", + ] + + runtime_python = next( + (candidate for candidate in candidates if candidate.is_file()), None + ) + if runtime_python is None: + raise RuntimeError( + f"Cannot find verification runtime binary under {runtime_root}" + ) + return runtime_python + + +def _run_probe(runtime_python: pathlib.Path, args: list[str], label: str) -> None: + result = subprocess.run( + [str(runtime_python), *args], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"Packaged runtime {label} probe failed: " + + ( + result.stderr.strip() + or result.stdout.strip() + or f"exit={result.returncode}" + ) + ) + + +def main() -> None: + runner_temp_dir = os.environ.get("RUNNER_TEMP_DIR") or os.environ.get("RUNNER_TEMP") + if not runner_temp_dir: + raise RuntimeError("RUNNER_TEMP_DIR or RUNNER_TEMP must be set.") + runner_temp = pathlib.Path(runner_temp_dir) + + release = _require_env("PYTHON_BUILD_STANDALONE_RELEASE") + version = _require_env("PYTHON_BUILD_STANDALONE_VERSION") + target = _require_env("PYTHON_BUILD_STANDALONE_TARGET") + + asset_name = f"cpython-{version}+{release}-{target}-install_only_stripped.tar.gz" + asset_url = ( + "https://github.com/astral-sh/python-build-standalone/releases/download/" + f"{release}/{urllib.parse.quote(asset_name)}" + ) + + target_runtime_root = runner_temp / "astrbot-cpython-runtime" + download_archive_path = runner_temp / asset_name + extract_root = runner_temp / "astrbot-cpython-runtime-extract" + + if target_runtime_root.exists(): + shutil.rmtree(target_runtime_root) + if extract_root.exists(): + shutil.rmtree(extract_root) + extract_root.mkdir(parents=True, exist_ok=True) + + _download_with_retries(asset_url, download_archive_path) + + with tarfile.open(download_archive_path, "r:gz") as archive: + archive.extractall(extract_root) + + source_runtime_root = extract_root / "python" + if not source_runtime_root.is_dir(): + raise RuntimeError( + "Invalid python-build-standalone archive layout: missing top-level python/ directory." + ) + + shutil.copytree( + source_runtime_root, + target_runtime_root, + symlinks=sys.platform != "win32", + ) + + runtime_python = _resolve_runtime_python(target_runtime_root) + _run_probe(runtime_python, ["-V"], "version") + _run_probe(runtime_python, ["-c", "import ssl"], "ssl") + + print(f"ASTRBOT_DESKTOP_CPYTHON_HOME={target_runtime_root}") + print(f"ASTRBOT_DESKTOP_CPYTHON_ASSET={asset_name}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26e28c934..43cf2add9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,142 +176,7 @@ jobs: echo "actions/setup-python did not return python-path output." >&2 exit 1 fi - "$SETUP_PYTHON_PATH" - <<'PY' >> "$GITHUB_ENV" - import os - import pathlib - import shutil - import subprocess - import sys - import tarfile - import time - import urllib.parse - import urllib.request - - release = (os.environ.get("PYTHON_BUILD_STANDALONE_RELEASE") or "").strip() - version = (os.environ.get("PYTHON_BUILD_STANDALONE_VERSION") or "").strip() - target = (os.environ.get("PYTHON_BUILD_STANDALONE_TARGET") or "").strip() - if not release or not version or not target: - raise RuntimeError( - "Missing python-build-standalone selection envs: " - "PYTHON_BUILD_STANDALONE_RELEASE / PYTHON_BUILD_STANDALONE_VERSION / PYTHON_BUILD_STANDALONE_TARGET." - ) - - asset_name = ( - f"cpython-{version}+{release}-{target}-install_only_stripped.tar.gz" - ) - asset_url = ( - "https://github.com/astral-sh/python-build-standalone/releases/download/" - f"{release}/{urllib.parse.quote(asset_name)}" - ) - - target_runtime_root = ( - pathlib.Path( - os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"] - ) - / "astrbot-cpython-runtime" - ) - if target_runtime_root.exists(): - shutil.rmtree(target_runtime_root) - - download_archive_path = pathlib.Path( - os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"] - ) / asset_name - extract_root = pathlib.Path( - os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"] - ) / "astrbot-cpython-runtime-extract" - if extract_root.exists(): - shutil.rmtree(extract_root) - extract_root.mkdir(parents=True, exist_ok=True) - - last_error = None - for attempt in range(1, 4): - try: - with urllib.request.urlopen(asset_url, timeout=180) as response: - with download_archive_path.open("wb") as output: - shutil.copyfileobj(response, output) - break - except Exception as exc: - last_error = exc - if attempt >= 3: - raise RuntimeError( - f"Failed to download python-build-standalone asset: {asset_url}" - ) from exc - time.sleep(attempt * 2) - if last_error and not download_archive_path.exists(): - raise RuntimeError( - f"Failed to download python-build-standalone asset: {asset_url}" - ) from last_error - - with tarfile.open(download_archive_path, "r:gz") as archive: - archive.extractall(extract_root) - - source_runtime_root = extract_root / "python" - if not source_runtime_root.is_dir(): - raise RuntimeError( - "Invalid python-build-standalone archive layout: missing top-level python/ directory." - ) - shutil.copytree( - source_runtime_root, - target_runtime_root, - symlinks=sys.platform != "win32", - ) - - if sys.platform == "win32": - verify_candidates = [ - target_runtime_root / "python.exe", - target_runtime_root / "Scripts" / "python.exe", - ] - else: - verify_candidates = [ - target_runtime_root / "bin" / "python3", - target_runtime_root / "bin" / "python", - ] - - verify_binary = next( - (candidate for candidate in verify_candidates if candidate.is_file()), None - ) - if verify_binary is None: - raise RuntimeError( - f"Cannot find verification runtime binary under {target_runtime_root}" - ) - - verify = subprocess.run( - [str(verify_binary), "-V"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if verify.returncode != 0: - raise RuntimeError( - "Packaged runtime probe failed: " - + ( - verify.stderr.strip() - or verify.stdout.strip() - or f"exit={verify.returncode}" - ) - ) - - ssl_verify = subprocess.run( - [str(verify_binary), "-c", "import ssl"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if ssl_verify.returncode != 0: - raise RuntimeError( - "Packaged runtime ssl probe failed: " - + ( - ssl_verify.stderr.strip() - or ssl_verify.stdout.strip() - or f"exit={ssl_verify.returncode}" - ) - ) - - print(f"ASTRBOT_DESKTOP_CPYTHON_HOME={target_runtime_root}") - print(f"ASTRBOT_DESKTOP_CPYTHON_ASSET={asset_name}") - PY + "$SETUP_PYTHON_PATH" .github/scripts/resolve_packaged_cpython_runtime.py >> "$GITHUB_ENV" - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -522,123 +387,7 @@ jobs: echo "actions/setup-python did not return python-path output." >&2 exit 1 fi - "$SETUP_PYTHON_PATH" - <<'PY' >> "$GITHUB_ENV" - import os - import pathlib - import shutil - import subprocess - import tarfile - import time - import urllib.parse - import urllib.request - - release = (os.environ.get("PYTHON_BUILD_STANDALONE_RELEASE") or "").strip() - version = (os.environ.get("PYTHON_BUILD_STANDALONE_VERSION") or "").strip() - target = (os.environ.get("PYTHON_BUILD_STANDALONE_TARGET") or "").strip() - if not release or not version or not target: - raise RuntimeError( - "Missing python-build-standalone selection envs: " - "PYTHON_BUILD_STANDALONE_RELEASE / PYTHON_BUILD_STANDALONE_VERSION / PYTHON_BUILD_STANDALONE_TARGET." - ) - - asset_name = ( - f"cpython-{version}+{release}-{target}-install_only_stripped.tar.gz" - ) - asset_url = ( - "https://github.com/astral-sh/python-build-standalone/releases/download/" - f"{release}/{urllib.parse.quote(asset_name)}" - ) - - runner_temp = pathlib.Path( - os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"] - ) - target_runtime_root = runner_temp / "astrbot-cpython-runtime" - download_archive_path = runner_temp / asset_name - extract_root = runner_temp / "astrbot-cpython-runtime-extract" - if target_runtime_root.exists(): - shutil.rmtree(target_runtime_root) - if extract_root.exists(): - shutil.rmtree(extract_root) - extract_root.mkdir(parents=True, exist_ok=True) - - last_error = None - for attempt in range(1, 4): - try: - with urllib.request.urlopen(asset_url, timeout=180) as response: - with download_archive_path.open("wb") as output: - shutil.copyfileobj(response, output) - break - except Exception as exc: - last_error = exc - if attempt >= 3: - raise RuntimeError( - f"Failed to download python-build-standalone asset: {asset_url}" - ) from exc - time.sleep(attempt * 2) - if last_error and not download_archive_path.exists(): - raise RuntimeError( - f"Failed to download python-build-standalone asset: {asset_url}" - ) from last_error - - with tarfile.open(download_archive_path, "r:gz") as archive: - archive.extractall(extract_root) - - source_runtime_root = extract_root / "python" - if not source_runtime_root.is_dir(): - raise RuntimeError( - "Invalid python-build-standalone archive layout: missing top-level python/ directory." - ) - shutil.copytree(source_runtime_root, target_runtime_root, symlinks=False) - - verify_candidates = [ - target_runtime_root / "python.exe", - target_runtime_root / "Scripts" / "python.exe", - ] - verify_binary = next( - (candidate for candidate in verify_candidates if candidate.is_file()), None - ) - if verify_binary is None: - raise RuntimeError( - f"Cannot find verification runtime binary under {target_runtime_root}" - ) - - version_check = subprocess.run( - [str(verify_binary), "-V"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if version_check.returncode != 0: - raise RuntimeError( - "Packaged runtime probe failed: " - + ( - version_check.stderr.strip() - or version_check.stdout.strip() - or f"exit={version_check.returncode}" - ) - ) - - ssl_check = subprocess.run( - [str(verify_binary), "-c", "import ssl"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if ssl_check.returncode != 0: - raise RuntimeError( - "Packaged runtime ssl probe failed: " - + ( - ssl_check.stderr.strip() - or ssl_check.stdout.strip() - or f"exit={ssl_check.returncode}" - ) - ) - - print(f"ASTRBOT_DESKTOP_CPYTHON_HOME={target_runtime_root}") - print(f"ASTRBOT_DESKTOP_CPYTHON_ASSET={asset_name}") - PY + "$SETUP_PYTHON_PATH" .github/scripts/resolve_packaged_cpython_runtime.py >> "$GITHUB_ENV" - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 39fdd8a88..ef28301ef 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -489,7 +489,7 @@ async def install( args.extend(["--target", target_site_packages]) args.extend(["--upgrade", "--force-reinstall"]) pip_install_args = _strip_binary_selector_args(pip_install_args) - args.append("--only-binary=:all") + args.append("--only-binary=:all:") if pip_install_args: args.extend(pip_install_args) diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index bcd7a692e..63e25a8f4 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -6,7 +6,10 @@ const path = require('path'); const { spawn, spawnSync } = require('child_process'); const { BufferedRotatingLogger } = require('./buffered-rotating-logger'); const { resolvePackagedBackendState } = require('./packaged-backend-config'); -const { shouldKillUnmanagedBackendProcess } = require('./windows-backend-cleanup'); +const { + resetWindowsBackendCleanupState, + shouldKillUnmanagedBackendProcess, +} = require('./windows-backend-cleanup'); const { delay, ensureDir, @@ -676,6 +679,7 @@ class BackendManager { this.log( `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, ); + resetWindowsBackendCleanupState(); const backendConfig = this.getBackendConfig(); const hasBackendCommand = Boolean(backendConfig.cmd); if (!hasBackendCommand) { diff --git a/desktop/lib/windows-backend-cleanup.js b/desktop/lib/windows-backend-cleanup.js index e20519200..ed835efe2 100644 --- a/desktop/lib/windows-backend-cleanup.js +++ b/desktop/lib/windows-backend-cleanup.js @@ -7,6 +7,12 @@ const commandLineCache = new Map(); let commandLineQueryUnavailable = false; let commandLineFallbackLogged = false; +function resetWindowsBackendCleanupState() { + commandLineCache.clear(); + commandLineQueryUnavailable = false; + commandLineFallbackLogged = false; +} + function normalizeWindowsPathForMatch(value) { return String(value || '') .replace(/\//g, '\\') @@ -209,5 +215,6 @@ function shouldKillUnmanagedBackendProcess({ } module.exports = { + resetWindowsBackendCleanupState, shouldKillUnmanagedBackendProcess, }; From 6f8417bcc1aff04c6d85e2732307631eafdfd22b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 19 Feb 2026 11:17:52 +0900 Subject: [PATCH 47/49] refactor: simplify windows cleanup state and harden runtime CI checks --- .../resolve_packaged_cpython_runtime.py | 75 +++++ .../scripts/smoke_test_packaged_runtime.py | 102 ++++++ .github/workflows/release.yml | 108 +----- desktop/README.md | 2 + desktop/lib/backend-manager.js | 10 +- desktop/lib/windows-backend-cleanup.js | 310 ++++++++++-------- requirements.txt | 3 + 7 files changed, 355 insertions(+), 255 deletions(-) create mode 100644 .github/scripts/smoke_test_packaged_runtime.py diff --git a/.github/scripts/resolve_packaged_cpython_runtime.py b/.github/scripts/resolve_packaged_cpython_runtime.py index cacd38ee6..96f10959e 100644 --- a/.github/scripts/resolve_packaged_cpython_runtime.py +++ b/.github/scripts/resolve_packaged_cpython_runtime.py @@ -3,6 +3,8 @@ from __future__ import annotations +import hashlib +import json import os import pathlib import shutil @@ -44,6 +46,72 @@ def _download_with_retries( ) from last_error +def _build_request(url: str) -> urllib.request.Request: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "astrbot-release-workflow", + } + github_token = (os.environ.get("GITHUB_TOKEN") or "").strip() + if github_token: + headers["Authorization"] = f"Bearer {github_token}" + return urllib.request.Request(url, headers=headers) + + +def _read_json_with_retries(url: str, retries: int = 3) -> dict: + last_error: Exception | None = None + for attempt in range(1, retries + 1): + try: + request = _build_request(url) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + except Exception as exc: # pragma: no cover - network-path fallback + last_error = exc + if attempt >= retries: + raise RuntimeError(f"Failed to fetch release metadata: {url}") from exc + time.sleep(attempt * 2) + + raise RuntimeError(f"Failed to fetch release metadata: {url}") from last_error + + +def _resolve_expected_sha256(release: str, asset_name: str) -> str: + release_api_url = ( + "https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/" + f"{urllib.parse.quote(release)}" + ) + release_data = _read_json_with_retries(release_api_url) + assets = release_data.get("assets") + if not isinstance(assets, list): + raise RuntimeError("Invalid GitHub release metadata: missing assets list.") + + matched_asset = next( + ( + item + for item in assets + if isinstance(item, dict) and item.get("name") == asset_name + ), + None, + ) + if matched_asset is None: + raise RuntimeError( + f"Cannot find expected python-build-standalone asset in release {release}: {asset_name}" + ) + + digest = matched_asset.get("digest") + if not isinstance(digest, str) or not digest.startswith("sha256:"): + raise RuntimeError( + f"Release metadata does not provide sha256 digest for asset: {asset_name}" + ) + return digest.split(":", 1)[1].lower() + + +def _calculate_sha256(file_path: pathlib.Path) -> str: + digest = hashlib.sha256() + with file_path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def _resolve_runtime_python(runtime_root: pathlib.Path) -> pathlib.Path: if sys.platform == "win32": candidates = [ @@ -99,6 +167,7 @@ def main() -> None: "https://github.com/astral-sh/python-build-standalone/releases/download/" f"{release}/{urllib.parse.quote(asset_name)}" ) + expected_sha256 = _resolve_expected_sha256(release, asset_name) target_runtime_root = runner_temp / "astrbot-cpython-runtime" download_archive_path = runner_temp / asset_name @@ -111,6 +180,12 @@ def main() -> None: extract_root.mkdir(parents=True, exist_ok=True) _download_with_retries(asset_url, download_archive_path) + actual_sha256 = _calculate_sha256(download_archive_path) + if actual_sha256 != expected_sha256: + raise RuntimeError( + "Downloaded runtime archive sha256 mismatch: " + + f"expected={expected_sha256} actual={actual_sha256}" + ) with tarfile.open(download_archive_path, "r:gz") as archive: archive.extractall(extract_root) diff --git a/.github/scripts/smoke_test_packaged_runtime.py b/.github/scripts/smoke_test_packaged_runtime.py new file mode 100644 index 000000000..a67428be4 --- /dev/null +++ b/.github/scripts/smoke_test_packaged_runtime.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Run smoke checks against bundled desktop runtime python.""" + +from __future__ import annotations + +import argparse +import pathlib +import subprocess +import sys + + +def _resolve_runtime_python(runtime_root: pathlib.Path) -> pathlib.Path: + if sys.platform == "win32": + candidates = [ + runtime_root / "python.exe", + runtime_root / "Scripts" / "python.exe", + ] + else: + candidates = [ + runtime_root / "bin" / "python3", + runtime_root / "bin" / "python", + ] + + runtime_python = next( + (candidate for candidate in candidates if candidate.is_file()), None + ) + if runtime_python is None: + raise RuntimeError( + f"Packaged runtime python executable is missing under {runtime_root}" + ) + return runtime_python + + +def _run_command( + command: list[str], failure_message: str +) -> subprocess.CompletedProcess: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + failure_message + + ( + result.stderr.strip() + or result.stdout.strip() + or f"exit={result.returncode}" + ) + ) + return result + + +def _check_runtime_dependencies(runtime_python: pathlib.Path) -> None: + if sys.platform == "darwin": + deps = _run_command( + ["otool", "-L", str(runtime_python)], + "Failed to inspect macOS runtime by otool: ", + ) + if "/Library/Frameworks/Python.framework/" in deps.stdout: + raise RuntimeError( + "Packaged runtime still links to absolute /Library/Frameworks/Python.framework path." + ) + elif sys.platform.startswith("linux"): + deps = _run_command( + ["ldd", str(runtime_python)], + "Failed to inspect Linux runtime by ldd: ", + ) + if "not found" in deps.stdout: + raise RuntimeError( + "Packaged runtime has unresolved shared libraries:\n" + deps.stdout + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Smoke test packaged desktop runtime python." + ) + parser.add_argument( + "runtime_root", + nargs="?", + default="desktop/resources/backend/python", + help="Path to packaged runtime root directory.", + ) + args = parser.parse_args() + + runtime_root = pathlib.Path(args.runtime_root) + runtime_python = _resolve_runtime_python(runtime_root) + + _run_command( + [str(runtime_python), "-V"], "Packaged runtime python smoke test failed: " + ) + _run_command( + [str(runtime_python), "-c", "import ssl"], + "Packaged runtime ssl smoke test failed: ", + ) + _check_runtime_dependencies(runtime_python) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43cf2add9..c44bd1bb8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -210,73 +210,7 @@ jobs: echo "actions/setup-python did not return python-path output." >&2 exit 1 fi - "$SETUP_PYTHON_PATH" - <<'PY' - import pathlib - import subprocess - import sys - - runtime_root = pathlib.Path("desktop/resources/backend/python") - if sys.platform == "win32": - candidates = [ - runtime_root / "python.exe", - runtime_root / "Scripts" / "python.exe", - ] - else: - candidates = [ - runtime_root / "bin" / "python3", - runtime_root / "bin" / "python", - ] - runtime_python = next((candidate for candidate in candidates if candidate.is_file()), None) - if runtime_python is None: - raise RuntimeError(f"Packaged runtime python executable is missing under {runtime_root}") - - version_check = subprocess.run( - [str(runtime_python), "-V"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if version_check.returncode != 0: - raise RuntimeError( - "Packaged runtime python smoke test failed: " - + (version_check.stderr.strip() or version_check.stdout.strip()) - ) - - if sys.platform == "darwin": - deps = subprocess.run( - ["otool", "-L", str(runtime_python)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if deps.returncode != 0: - raise RuntimeError( - f"Failed to inspect macOS runtime by otool: {deps.stderr.strip()}" - ) - if "/Library/Frameworks/Python.framework/" in deps.stdout: - raise RuntimeError( - "Packaged runtime still links to absolute /Library/Frameworks/Python.framework path." - ) - - if sys.platform.startswith("linux"): - deps = subprocess.run( - ["ldd", str(runtime_python)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if deps.returncode != 0: - raise RuntimeError( - f"Failed to inspect Linux runtime by ldd: {deps.stderr.strip()}" - ) - if "not found" in deps.stdout: - raise RuntimeError( - "Packaged runtime has unresolved shared libraries:\\n" + deps.stdout - ) - PY + "$SETUP_PYTHON_PATH" .github/scripts/smoke_test_packaged_runtime.py desktop/resources/backend/python pnpm --dir desktop run sync:version pnpm --dir desktop exec electron-builder --publish never @@ -422,45 +356,7 @@ jobs: echo "actions/setup-python did not return python-path output." >&2 exit 1 fi - "$SETUP_PYTHON_PATH" - <<'PY' - import pathlib - import subprocess - - runtime_root = pathlib.Path("desktop/resources/backend/python") - candidates = [ - runtime_root / "python.exe", - runtime_root / "Scripts" / "python.exe", - ] - runtime_python = next((candidate for candidate in candidates if candidate.is_file()), None) - if runtime_python is None: - raise RuntimeError(f"Packaged runtime python executable is missing under {runtime_root}") - - version_check = subprocess.run( - [str(runtime_python), "-V"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if version_check.returncode != 0: - raise RuntimeError( - "Packaged runtime python smoke test failed: " - + (version_check.stderr.strip() or version_check.stdout.strip()) - ) - - ssl_check = subprocess.run( - [str(runtime_python), "-c", "import ssl"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if ssl_check.returncode != 0: - raise RuntimeError( - "Packaged runtime ssl smoke test failed: " - + (ssl_check.stderr.strip() or ssl_check.stdout.strip()) - ) - PY + "$SETUP_PYTHON_PATH" .github/scripts/smoke_test_packaged_runtime.py desktop/resources/backend/python pnpm --dir desktop run sync:version if [ "${{ matrix.arch }}" = "arm64" ]; then arch_flag="--arm64" diff --git a/desktop/README.md b/desktop/README.md index 1d510b2d4..43a109afc 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -84,6 +84,8 @@ pnpm --dir desktop run dev - `dist:full` runs WebUI build + backend runtime packaging + Electron packaging. - In packaged app mode, backend data root defaults to `~/.astrbot` (can be overridden by `ASTRBOT_ROOT`). - Backend build requires a CPython runtime directory via `ASTRBOT_DESKTOP_BACKEND_RUNTIME` or `ASTRBOT_DESKTOP_CPYTHON_HOME`; if both are set, `ASTRBOT_DESKTOP_BACKEND_RUNTIME` takes precedence. +- Packaged backend dependency installation reads `requirements.txt`; refresh it from `pyproject.toml` dependencies with: + `uv export --format requirements.txt --no-hashes -o requirements.txt`. ## Packaged Backend Layout diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 63e25a8f4..e7b4c0fe1 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -6,10 +6,7 @@ const path = require('path'); const { spawn, spawnSync } = require('child_process'); const { BufferedRotatingLogger } = require('./buffered-rotating-logger'); const { resolvePackagedBackendState } = require('./packaged-backend-config'); -const { - resetWindowsBackendCleanupState, - shouldKillUnmanagedBackendProcess, -} = require('./windows-backend-cleanup'); +const { WindowsBackendCleaner } = require('./windows-backend-cleanup'); const { delay, ensureDir, @@ -76,6 +73,7 @@ class BackendManager { spawnSync, log: (message) => this.log(message), }; + this.windowsBackendCleaner = new WindowsBackendCleaner(); } getBackendUrl() { @@ -679,7 +677,7 @@ class BackendManager { this.log( `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, ); - resetWindowsBackendCleanupState(); + this.windowsBackendCleaner.resetState(); const backendConfig = this.getBackendConfig(); const hasBackendCommand = Boolean(backendConfig.cmd); if (!hasBackendCommand) { @@ -694,7 +692,7 @@ class BackendManager { this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`); continue; } - const shouldKill = shouldKillUnmanagedBackendProcess({ + const shouldKill = this.windowsBackendCleaner.shouldKillUnmanagedBackendProcess({ pid, processInfo, backendConfig, diff --git a/desktop/lib/windows-backend-cleanup.js b/desktop/lib/windows-backend-cleanup.js index ed835efe2..971bd7605 100644 --- a/desktop/lib/windows-backend-cleanup.js +++ b/desktop/lib/windows-backend-cleanup.js @@ -3,15 +3,6 @@ const path = require('path'); const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 2000; -const commandLineCache = new Map(); -let commandLineQueryUnavailable = false; -let commandLineFallbackLogged = false; - -function resetWindowsBackendCleanupState() { - commandLineCache.clear(); - commandLineQueryUnavailable = false; - commandLineFallbackLogged = false; -} function normalizeWindowsPathForMatch(value) { return String(value || '') @@ -24,35 +15,65 @@ function isGenericWindowsPythonImage(imageName) { return normalized === 'python.exe' || normalized === 'pythonw.exe' || normalized === 'py.exe'; } -function getWindowsProcessCommandLine({ pid, spawnSync, log, timeoutMs }) { - const numericPid = Number.parseInt(`${pid}`, 10); - if (!Number.isInteger(numericPid)) { - return { commandLine: null, commandLineQueryUnavailable: false }; +function buildBackendCommandLineMarkers(backendConfig) { + const safeBackendConfig = + backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; + if (!Array.isArray(safeBackendConfig.args) || safeBackendConfig.args.length === 0) { + return []; } - if (commandLineQueryUnavailable) { - return { commandLine: null, commandLineQueryUnavailable: true }; + const primaryArg = safeBackendConfig.args[0]; + if (typeof primaryArg !== 'string' || !primaryArg) { + return []; } - if (commandLineCache.has(numericPid)) { - return { - commandLine: commandLineCache.get(numericPid), - commandLineQueryUnavailable: false, - }; + const resolvedPrimaryArg = path.isAbsolute(primaryArg) + ? primaryArg + : path.resolve(safeBackendConfig.cwd || process.cwd(), primaryArg); + return [ + normalizeWindowsPathForMatch(resolvedPrimaryArg), + normalizeWindowsPathForMatch(path.basename(primaryArg)), + ]; +} + +function getExpectedImageName(backendConfig, fallbackCmdRaw) { + const safeBackendConfig = + backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; + const fallbackCmd = String(fallbackCmdRaw || 'python.exe') + .trim() + .split(/\s+/, 1)[0]; + return path + .basename(safeBackendConfig.cmd || fallbackCmd || 'python.exe') + .toLowerCase(); +} + +function matchesExpectedImage(processInfo, expectedImageName) { + return processInfo.imageName.toLowerCase() === expectedImageName; +} + +class WindowsBackendCleaner { + constructor() { + this.commandLineCache = new Map(); + this.commandLineQueryUnavailable = false; + this.commandLineFallbackLogged = false; + } + + resetState() { + this.commandLineCache.clear(); + this.commandLineQueryUnavailable = false; + this.commandLineFallbackLogged = false; } - const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${numericPid}"; if ($null -ne $p) { $p.CommandLine }`; - const args = ['-NoProfile', '-NonInteractive', '-Command', query]; - const options = { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - windowsHide: true, - timeout: timeoutMs, - }; - - const queryAttempts = ['powershell', 'pwsh']; - let hasAvailableShell = false; - for (const shellName of queryAttempts) { + _tryShellForCommandLine({ shellName, numericPid, spawnSync, log, timeoutMs }) { + const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${numericPid}"; if ($null -ne $p) { $p.CommandLine }`; + const args = ['-NoProfile', '-NonInteractive', '-Command', query]; + const options = { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + windowsHide: true, + timeout: timeoutMs, + }; + let result = null; try { result = spawnSync(shellName, args, options); @@ -62,18 +83,17 @@ function getWindowsProcessCommandLine({ pid, spawnSync, log, timeoutMs }) { `Failed to query process command line by ${shellName} for pid=${numericPid}: ${error.message}`, ); } - continue; + return { hasShell: false, commandLine: null }; } if (result.error && result.error.code === 'ENOENT') { - continue; + return { hasShell: false, commandLine: null }; } - hasAvailableShell = true; if (result.error && result.error.code === 'ETIMEDOUT') { log( `Timed out (${timeoutMs}ms) querying process command line by ${shellName} for pid=${numericPid}.`, ); - continue; + return { hasShell: true, commandLine: null }; } if (result.error) { if (result.error.message) { @@ -81,140 +101,144 @@ function getWindowsProcessCommandLine({ pid, spawnSync, log, timeoutMs }) { `Failed to query process command line by ${shellName} for pid=${numericPid}: ${result.error.message}`, ); } - continue; + return { hasShell: true, commandLine: null }; } - if (result.status === 0) { - const commandLine = - result.stdout - .split(/\r?\n/) - .map((item) => item.trim()) - .find((item) => item.length > 0) || null; - commandLineCache.set(numericPid, commandLine); - commandLineQueryUnavailable = false; - return { commandLine, commandLineQueryUnavailable: false }; + if (result.status !== 0) { + return { hasShell: true, commandLine: null }; } - } - if (!hasAvailableShell) { - commandLineQueryUnavailable = true; - return { commandLine: null, commandLineQueryUnavailable: true }; + const commandLine = + result.stdout + .split(/\r?\n/) + .map((item) => item.trim()) + .find((item) => item.length > 0) || null; + return { hasShell: true, commandLine }; } - commandLineCache.set(numericPid, null); - commandLineQueryUnavailable = false; - return { commandLine: null, commandLineQueryUnavailable: false }; -} - -function buildBackendCommandLineMarkers(backendConfig) { - const safeBackendConfig = - backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; - if (!Array.isArray(safeBackendConfig.args) || safeBackendConfig.args.length === 0) { - return []; - } + getWindowsProcessCommandLine({ pid, spawnSync, log, timeoutMs }) { + const numericPid = Number.parseInt(`${pid}`, 10); + if (!Number.isInteger(numericPid)) { + return { commandLine: null, commandLineQueryUnavailable: false }; + } - const primaryArg = safeBackendConfig.args[0]; - if (typeof primaryArg !== 'string' || !primaryArg) { - return []; - } + if (this.commandLineQueryUnavailable) { + return { commandLine: null, commandLineQueryUnavailable: true }; + } - const resolvedPrimaryArg = path.isAbsolute(primaryArg) - ? primaryArg - : path.resolve(safeBackendConfig.cwd || process.cwd(), primaryArg); - return [ - normalizeWindowsPathForMatch(resolvedPrimaryArg), - normalizeWindowsPathForMatch(path.basename(primaryArg)), - ]; -} + if (this.commandLineCache.has(numericPid)) { + return { + commandLine: this.commandLineCache.get(numericPid), + commandLineQueryUnavailable: false, + }; + } -function getExpectedImageName(backendConfig, fallbackCmdRaw) { - const safeBackendConfig = - backendConfig && typeof backendConfig === 'object' ? backendConfig : {}; - const fallbackCmd = String(fallbackCmdRaw || 'python.exe') - .trim() - .split(/\s+/, 1)[0]; - return path - .basename(safeBackendConfig.cmd || fallbackCmd || 'python.exe') - .toLowerCase(); -} + const queryAttempts = ['powershell', 'pwsh']; + let hasAvailableShell = false; + for (const shellName of queryAttempts) { + const { hasShell, commandLine } = this._tryShellForCommandLine({ + shellName, + numericPid, + spawnSync, + log, + timeoutMs, + }); + if (!hasShell) { + continue; + } + hasAvailableShell = true; + if (commandLine !== null) { + this.commandLineCache.set(numericPid, commandLine); + this.commandLineQueryUnavailable = false; + return { commandLine, commandLineQueryUnavailable: false }; + } + } -function matchesExpectedImage(processInfo, expectedImageName) { - return processInfo.imageName.toLowerCase() === expectedImageName; -} + if (!hasAvailableShell) { + this.commandLineQueryUnavailable = true; + return { commandLine: null, commandLineQueryUnavailable: true }; + } -function matchesBackendMarkers({ - pid, - backendConfig, - spawnSync, - log, -}) { - const markers = buildBackendCommandLineMarkers(backendConfig); - if (!markers.length) { - log(`Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`); - return false; + this.commandLineCache.set(numericPid, null); + this.commandLineQueryUnavailable = false; + return { commandLine: null, commandLineQueryUnavailable: false }; } - const { commandLine, commandLineQueryUnavailable } = getWindowsProcessCommandLine({ + _matchesBackendMarkers({ pid, + backendConfig, spawnSync, log, - timeoutMs: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, - }); - if (!commandLine) { - if (commandLineQueryUnavailable) { - if (!commandLineFallbackLogged) { - commandLineFallbackLogged = true; - log( - 'Neither powershell nor pwsh is available. ' + - 'Falling back to image-name-only matching for generic Python backend cleanup.', - ); - } - return true; + }) { + const markers = buildBackendCommandLineMarkers(backendConfig); + if (!markers.length) { + log(`Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`); + return false; } - log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process command line.`); - return false; - } - const normalizedCommandLine = normalizeWindowsPathForMatch(commandLine); - const matched = markers.some((marker) => marker && normalizedCommandLine.includes(marker)); - if (!matched) { - log( - `Skip unmanaged cleanup for pid=${pid}: command line does not match AstrBot backend launch marker.`, - ); - } - return matched; -} + const { commandLine, commandLineQueryUnavailable } = this.getWindowsProcessCommandLine({ + pid, + spawnSync, + log, + timeoutMs: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, + }); + if (!commandLine) { + if (commandLineQueryUnavailable) { + if (!this.commandLineFallbackLogged) { + this.commandLineFallbackLogged = true; + log( + 'Neither powershell nor pwsh is available. ' + + 'Falling back to image-name-only matching for generic Python backend cleanup.', + ); + } + return true; + } + log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process command line.`); + return false; + } -function shouldKillUnmanagedBackendProcess({ - pid, - processInfo, - backendConfig, - allowImageOnlyMatch, - spawnSync, - log, - fallbackCmdRaw, -}) { - const expectedImageName = getExpectedImageName(backendConfig, fallbackCmdRaw); - if (!matchesExpectedImage(processInfo, expectedImageName)) { - log( - `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, + const normalizedCommandLine = normalizeWindowsPathForMatch(commandLine); + const matched = markers.some( + (marker) => marker && normalizedCommandLine.includes(marker), ); - return false; - } - - if (allowImageOnlyMatch || !isGenericWindowsPythonImage(expectedImageName)) { - return true; + if (!matched) { + log( + `Skip unmanaged cleanup for pid=${pid}: command line does not match AstrBot backend launch marker.`, + ); + } + return matched; } - return matchesBackendMarkers({ + shouldKillUnmanagedBackendProcess({ pid, + processInfo, backendConfig, + allowImageOnlyMatch, spawnSync, log, - }); + fallbackCmdRaw, + }) { + const expectedImageName = getExpectedImageName(backendConfig, fallbackCmdRaw); + if (!matchesExpectedImage(processInfo, expectedImageName)) { + log( + `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, + ); + return false; + } + + if (allowImageOnlyMatch || !isGenericWindowsPythonImage(expectedImageName)) { + return true; + } + + return this._matchesBackendMarkers({ + pid, + backendConfig, + spawnSync, + log, + }); + } } module.exports = { - resetWindowsBackendCleanupState, - shouldKillUnmanagedBackendProcess, + WindowsBackendCleaner, }; diff --git a/requirements.txt b/requirements.txt index 779136e88..b2b1aacf4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ +# This file is used by desktop packaged runtime builds (`desktop/scripts/build-backend.mjs`). +# Keep `pyproject.toml` as the dependency source of truth and refresh this file with: +# `uv export --format requirements.txt --no-hashes -o requirements.txt` aiocqhttp>=1.4.4 aiodocker>=0.24.0 aiohttp>=3.11.18 From cd164513afbe16af56ffe7ad52680254a30d3994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 19 Feb 2026 11:34:13 +0900 Subject: [PATCH 48/49] fix(ci): pass github token to runtime resolver --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c44bd1bb8..a7dcedd99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,6 +170,7 @@ jobs: env: RUNNER_TEMP_DIR: ${{ runner.temp }} SETUP_PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} + GITHUB_TOKEN: ${{ github.token }} PYTHON_BUILD_STANDALONE_TARGET: ${{ matrix.pbs_target }} run: | if [ -z "$SETUP_PYTHON_PATH" ]; then @@ -315,6 +316,7 @@ jobs: env: RUNNER_TEMP_DIR: ${{ runner.temp }} SETUP_PYTHON_PATH: ${{ steps.setup-python.outputs.python-path }} + GITHUB_TOKEN: ${{ github.token }} PYTHON_BUILD_STANDALONE_TARGET: ${{ matrix.pbs_target }} run: | if [ -z "$SETUP_PYTHON_PATH" ]; then From ed53778b8acd3a26315afc7a074f22651dbc355e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 19 Feb 2026 12:04:52 +0900 Subject: [PATCH 49/49] fix(desktop): disable blockmap outputs and add jsonschema dependency --- desktop/package.json | 9 ++++++--- pyproject.toml | 1 + requirements.txt | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 3e04f21b1..596b43055 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -78,11 +78,13 @@ }, "mac": { "target": [ - "dmg", - "zip" + "dmg" ], "category": "public.app-category.productivity" }, + "dmg": { + "writeUpdateInfo": false + }, "win": { "target": [ "nsis", @@ -91,7 +93,8 @@ }, "nsis": { "oneClick": false, - "allowToChangeInstallationDirectory": true + "allowToChangeInstallationDirectory": true, + "differentialPackage": false } } } diff --git a/pyproject.toml b/pyproject.toml index d8d751b8e..566b44e5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "pydantic>=2.12.5", "pydub>=0.25.1", "pyjwt>=2.10.1", + "jsonschema>=4.25.1", "python-telegram-bot>=22.0", "qq-botpy>=1.2.1", "quart>=0.20.0", diff --git a/requirements.txt b/requirements.txt index b2b1aacf4..f23deeb00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,6 +35,7 @@ py-cord>=2.6.1 pydantic>=2.12.5 pydub>=0.25.1 pyjwt>=2.10.1 +jsonschema>=4.25.1 python-telegram-bot>=22.0 qq-botpy>=1.2.1 quart>=0.20.0