diff --git a/.github/scripts/resolve_packaged_cpython_runtime.py b/.github/scripts/resolve_packaged_cpython_runtime.py new file mode 100644 index 000000000..96f10959e --- /dev/null +++ b/.github/scripts/resolve_packaged_cpython_runtime.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Resolve and verify python-build-standalone runtime for desktop packaging.""" + +from __future__ import annotations + +import hashlib +import json +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 _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 = [ + 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)}" + ) + expected_sha256 = _resolve_expected_sha256(release, 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) + 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) + + 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/scripts/resolve_pbs_target.py b/.github/scripts/resolve_pbs_target.py new file mode 100644 index 000000000..cd07e29f8 --- /dev/null +++ b/.github/scripts/resolve_pbs_target.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Resolve python-build-standalone target by platform and architecture.""" + +from __future__ import annotations + +import argparse + +TARGETS = { + ("linux", "amd64"): "x86_64-unknown-linux-gnu", + ("linux", "arm64"): "aarch64-unknown-linux-gnu", + ("mac", "amd64"): "x86_64-apple-darwin", + ("mac", "arm64"): "aarch64-apple-darwin", + ("windows", "amd64"): "x86_64-pc-windows-msvc", + ("windows", "arm64"): "aarch64-pc-windows-msvc", +} + + +def resolve_target(platform: str, arch: str) -> str: + key = (platform.strip().lower(), arch.strip().lower()) + target = TARGETS.get(key) + if not target: + supported = ", ".join( + f"{item_platform}/{item_arch}" for item_platform, item_arch in TARGETS + ) + raise RuntimeError( + f"Unsupported python-build-standalone mapping for {platform}/{arch}. " + f"Supported: {supported}" + ) + return target + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", required=True, help="linux/mac/windows") + parser.add_argument("--arch", required=True, help="amd64/arm64") + args = parser.parse_args() + + print(resolve_target(args.platform, args.arch)) + + +if __name__ == "__main__": + main() 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 59c229b04..8b0bb3e9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,16 +7,20 @@ 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: contents: write +env: + PYTHON_BUILD_STANDALONE_RELEASE: "20260211" + PYTHON_BUILD_STANDALONE_VERSION: "3.12.12" + jobs: build-dashboard: name: Build Dashboard @@ -30,7 +34,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 +45,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 @@ -117,14 +122,6 @@ jobs: runner: ubuntu-24.04-arm os: linux arch: arm64 - - name: windows-x64 - runner: windows-2022 - os: win - arch: amd64 - - name: windows-arm64 - runner: windows-11-arm - os: win - arch: arm64 - name: macos-x64 runner: macos-15-intel os: mac @@ -140,7 +137,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 @@ -151,7 +148,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 @@ -159,14 +157,29 @@ 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 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 }} + GITHUB_TOKEN: ${{ github.token }} + PYTHON_BUILD_STANDALONE_PLATFORM: ${{ matrix.os }} + PYTHON_BUILD_STANDALONE_ARCH: ${{ matrix.arch }} + run: | + if [ -z "$SETUP_PYTHON_PATH" ]; then + echo "actions/setup-python did not return python-path output." >&2 + exit 1 + fi + PYTHON_BUILD_STANDALONE_TARGET="$("$SETUP_PYTHON_PATH" .github/scripts/resolve_pbs_target.py --platform "$PYTHON_BUILD_STANDALONE_PLATFORM" --arch "$PYTHON_BUILD_STANDALONE_ARCH")" + echo "PYTHON_BUILD_STANDALONE_TARGET=$PYTHON_BUILD_STANDALONE_TARGET" >> "$GITHUB_ENV" + "$SETUP_PYTHON_PATH" .github/scripts/resolve_packaged_cpython_runtime.py >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v4 with: @@ -181,34 +194,25 @@ 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: | - uv sync 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" .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 @@ -260,18 +264,164 @@ 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 + os: windows + arch: amd64 + - runner: windows-11-arm + os: windows + arch: arm64 + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + 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 }} + GITHUB_TOKEN: ${{ github.token }} + PYTHON_BUILD_STANDALONE_PLATFORM: ${{ matrix.os }} + PYTHON_BUILD_STANDALONE_ARCH: ${{ matrix.arch }} + run: | + if [ -z "$SETUP_PYTHON_PATH" ]; then + echo "actions/setup-python did not return python-path output." >&2 + exit 1 + fi + PYTHON_BUILD_STANDALONE_TARGET="$("$SETUP_PYTHON_PATH" .github/scripts/resolve_pbs_target.py --platform "$PYTHON_BUILD_STANDALONE_PLATFORM" --arch "$PYTHON_BUILD_STANDALONE_ARCH")" + echo "PYTHON_BUILD_STANDALONE_TARGET=$PYTHON_BUILD_STANDALONE_TARGET" >> "$GITHUB_ENV" + "$SETUP_PYTHON_PATH" .github/scripts/resolve_packaged_cpython_runtime.py >> "$GITHUB_ENV" + + - 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 }} + PIP_ONLY_BINARY: cryptography + 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" .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" + 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 with: fetch-depth: 0 - ref: ${{ inputs.ref || github.ref }} + ref: ${{ inputs.ref != '' && inputs.ref || github.ref_name }} - name: Resolve tag id: tag @@ -282,7 +432,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 @@ -355,7 +506,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 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..6d4eb71cb 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ paru -S astrbot-git #### 桌面端 Electron 打包 桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。 +打包前需要准备 CPython 运行时目录,并设置 `ASTRBOT_DESKTOP_CPYTHON_HOME`(详见该文档)。 +建议使用 `python-build-standalone` 的 `install_only` 运行时作为分发基线。 ## 支持的消息平台 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/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 51f50aedf..1a31ca15e 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() @@ -194,6 +201,13 @@ 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 PluginDependencyInstallError( + plugin_name=p, + requirements_path=pth, + message="插件依赖安装失败,请检查插件 requirements.txt " + "中的依赖版本或构建环境。", + ) from e return True async def _import_plugin_with_dependency_recovery( @@ -343,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): @@ -431,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: @@ -457,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} ...") @@ -471,6 +496,15 @@ 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}插件安装失败,插件目录:{plugin_dir_path}" + ) continue # 检查 _conf_schema.json @@ -706,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 @@ -781,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) @@ -829,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 @@ -1096,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 @@ -1118,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: @@ -1195,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 diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index 049a19789..a6cdafda3 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.update_guard import ensure_packaged_update_allowed from .zip_updator import ReleaseInfo, RepoZipUpdator @@ -44,71 +45,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_pyinstaller_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) - 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: @@ -121,7 +65,6 @@ def _reboot(self, delay: int = 3) -> None: executable = sys.executable try: - self._reset_pyinstaller_environment() reboot_argv = self._build_reboot_argv(executable) self._exec_reboot(executable, reboot_argv) except Exception as e: @@ -145,11 +88,12 @@ 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") # 避免版本管理混乱 + ensure_packaged_update_allowed() + + 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/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 1c8da23c1..ef28301ef 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 @@ -16,7 +17,6 @@ logger = logging.getLogger("astrbot") -_DISTLIB_FINDER_PATCH_ATTEMPTED = False _SITE_PACKAGES_IMPORT_LOCK = threading.RLock() @@ -34,7 +34,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 @@ -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]: @@ -426,110 +445,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 @@ -542,6 +457,17 @@ async def install( mirror: str | None = None, ) -> None: args = ["install"] + 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) @@ -562,9 +488,11 @@ 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 self.pip_install_arg: - args.extend(self.pip_install_arg.split()) + 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) @@ -602,7 +530,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..260309a4e 100644 --- a/astrbot/core/utils/runtime_env.py +++ b/astrbot/core/utils/runtime_env.py @@ -1,10 +1,5 @@ import os -import sys - - -def is_frozen_runtime() -> bool: - return bool(getattr(sys, "frozen", False)) 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/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/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..4bde7583a 100644 --- a/astrbot/dashboard/routes/update.py +++ b/astrbot/dashboard/routes/update.py @@ -8,6 +8,10 @@ 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.update_guard import ( + get_packaged_update_block_message, + should_block_packaged_update, +) from .route import Response, Route, RouteContext @@ -86,6 +90,9 @@ async def get_releases(self): return Response().error(e.__str__()).__dict__ async def update_project(self): + if should_block_packaged_update(): + return Response().error(get_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 should_block_packaged_update(): + return Response().error(get_packaged_update_block_message()).__dict__ + try: try: await download_dashboard(version=f"v{VERSION}", latest=False) 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/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 d84889405..b9de7157e 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -2,6 +2,7 @@ import { fileURLToPath, URL } from 'url'; import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import vuetify from 'vite-plugin-vuetify'; +import normalizeNestedTypeSelectorPlugin from './src/plugins/normalize-nested-type-selector'; // https://vitejs.dev/config/ export default defineConfig({ @@ -24,6 +25,9 @@ export default defineConfig({ } }, css: { + postcss: { + plugins: [normalizeNestedTypeSelectorPlugin] + }, preprocessorOptions: { scss: {} } diff --git a/desktop/README.md b/desktop/README.md index 48dcb341a..caf8d08ab 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,36 @@ 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 ``` +Recommended runtime source for local packaging is `python-build-standalone` (same family used in CI): + +```bash +PBS_RELEASE="$(awk -F'"' '/PYTHON_BUILD_STANDALONE_RELEASE:/ { print $2; exit }' .github/workflows/release.yml)" +PBS_VERSION="$(awk -F'"' '/PYTHON_BUILD_STANDALONE_VERSION:/ { print $2; exit }' .github/workflows/release.yml)" +PBS_PLATFORM=mac # linux / mac / windows +PBS_ARCH=arm64 # amd64 / arm64 +PBS_TARGET="$(python3 .github/scripts/resolve_pbs_target.py --platform "$PBS_PLATFORM" --arch "$PBS_ARCH")" +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. + +```bash +export ASTRBOT_DESKTOP_CPYTHON_HOME=/path/to/cpython-runtime +pnpm --dir desktop run build:backend +``` + Output files are generated under: - `desktop/dist/` @@ -57,9 +83,27 @@ 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 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 + +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 +166,13 @@ 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. +- `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: ```bash diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index eb8958a4c..b067fa1d8 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -5,6 +5,12 @@ 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 { + createWindowsBackendCleanupState, + prefetchWindowsProcessCommandLines, + shouldKillUnmanagedBackendProcess, +} = require('./windows-backend-cleanup'); const { delay, ensureDir, @@ -19,6 +25,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 DEFAULT_BACKEND_COMMAND_UNAVAILABLE_REASON = 'Backend command is not configured.'; function parseBackendTimeoutMs(app) { const defaultTimeoutMs = app.isPackaged ? 0 : 20000; @@ -32,6 +39,93 @@ function parseBackendTimeoutMs(app) { return defaultTimeoutMs; } +function buildBackendConfig({ + cmd, + args, + shell, + cwd, + webuiDir, + rootDir, + failureReason, +}) { + return { + cmd: cmd || null, + args: Array.isArray(args) ? args : [], + shell: typeof shell === 'boolean' ? shell : true, + cwd, + webuiDir, + rootDir, + failureReason: failureReason || null, + }; +} + +function resolveBackendConfigResult({ + app, + env, + log, + resolveWebuiDir, + resolveBackendCwd, + resolveBackendRoot, + getPackagedBackendState, + buildDefaultBackendLaunch, + buildLaunchForPackagedBackend, +}) { + const webuiDir = resolveWebuiDir(); + let launch = null; + let failureReason = null; + + const customCmd = env.ASTRBOT_BACKEND_CMD; + if (customCmd) { + launch = { cmd: customCmd, args: [], shell: true }; + } else if (app.isPackaged) { + ({ launch, failureReason } = buildLaunchForPackagedBackend( + getPackagedBackendState(), + webuiDir, + )); + } else { + launch = buildDefaultBackendLaunch(webuiDir); + } + + const cwd = env.ASTRBOT_BACKEND_CWD || resolveBackendCwd(); + const rootDir = env.ASTRBOT_ROOT || resolveBackendRoot(); + ensureDir(cwd); + if (rootDir) { + ensureDir(rootDir); + } + + if (!launch || !launch.cmd) { + const reason = failureReason || DEFAULT_BACKEND_COMMAND_UNAVAILABLE_REASON; + log(reason); + return { + ok: false, + reason, + config: buildBackendConfig({ + cmd: null, + args: [], + shell: true, + cwd, + webuiDir, + rootDir, + failureReason: reason, + }), + }; + } + + return { + ok: true, + reason: null, + config: buildBackendConfig({ + cmd: launch.cmd, + args: launch.args, + shell: launch.shell, + cwd, + webuiDir, + rootDir, + failureReason: null, + }), + }; +} + class BackendManager { constructor({ app, baseDir, log, shouldSkipStart }) { this.app = app; @@ -54,6 +148,7 @@ class BackendManager { this.backendProcess = null; this.backendConfig = null; + this.packagedBackendState = null; this.backendLogger = new BufferedRotatingLogger({ logPath: null, maxBytes: this.backendLogMaxBytes, @@ -71,10 +166,6 @@ class BackendManager { return this.backendUrl; } - getBackendTimeoutMs() { - return this.backendTimeoutMs; - } - getRootDir() { return ( process.env.ASTRBOT_ROOT || @@ -95,14 +186,6 @@ class BackendManager { return this.backendStartupFailureReason; } - isSpawning() { - return this.backendSpawning; - } - - isRestarting() { - return this.backendRestarting; - } - resolveBackendRoot() { if (!this.app.isPackaged) { return null; @@ -129,33 +212,20 @@ class BackendManager { return fs.existsSync(indexPath) ? candidate : null; } - getPackagedBackendPath() { + getPackagedBackendState() { 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 fs.existsSync(candidate) ? candidate : null; + if (!this.packagedBackendState) { + this.packagedBackendState = resolvePackagedBackendState( + process.resourcesPath, + (message) => this.log(message), + ); + } + return this.packagedBackendState; } 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, - }; - } - const args = ['run', 'main.py']; if (webuiDir) { args.push('--webui-dir', webuiDir); @@ -167,36 +237,60 @@ class BackendManager { }; } - resolveBackendConfig() { - const webuiDir = this.resolveWebuiDir(); - const customCmd = process.env.ASTRBOT_BACKEND_CMD; - const launch = customCmd - ? { - cmd: customCmd, - args: [], - shell: true, - } - : this.buildDefaultBackendLaunch(webuiDir); - const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); - const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot(); - ensureDir(cwd); - if (rootDir) { - ensureDir(rootDir); - } - this.backendConfig = { - cmd: launch ? launch.cmd : null, - args: launch ? launch.args : [], - shell: launch ? launch.shell : true, - cwd, - webuiDir, - rootDir, + buildLaunchForPackagedBackend(packagedBackendState, webuiDir) { + if (!packagedBackendState?.ok || !packagedBackendState.config) { + return { + launch: null, + failureReason: + packagedBackendState?.failureReason || + DEFAULT_BACKEND_COMMAND_UNAVAILABLE_REASON, + }; + } + + const { runtimePythonPath, launchScriptPath } = packagedBackendState.config; + const args = [launchScriptPath]; + if (webuiDir) { + args.push('--webui-dir', webuiDir); + } + return { + launch: { cmd: runtimePythonPath, args, shell: false }, + failureReason: null, }; - return this.backendConfig; } getBackendConfig() { if (!this.backendConfig) { - return this.resolveBackendConfig(); + try { + const result = resolveBackendConfigResult({ + app: this.app, + env: process.env, + log: (message) => this.log(message), + resolveWebuiDir: () => this.resolveWebuiDir(), + resolveBackendCwd: () => this.resolveBackendCwd(), + resolveBackendRoot: () => this.resolveBackendRoot(), + getPackagedBackendState: () => this.getPackagedBackendState(), + buildDefaultBackendLaunch: (webuiDir) => + this.buildDefaultBackendLaunch(webuiDir), + buildLaunchForPackagedBackend: (packagedBackendState, webuiDir) => + this.buildLaunchForPackagedBackend(packagedBackendState, webuiDir), + }); + this.backendConfig = result.config; + } catch (error) { + this.log( + `Failed to resolve backend config: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + this.backendConfig = buildBackendConfig({ + cmd: null, + args: [], + shell: true, + cwd: process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(), + webuiDir: this.resolveWebuiDir(), + rootDir: process.env.ASTRBOT_ROOT || this.resolveBackendRoot(), + failureReason: DEFAULT_BACKEND_COMMAND_UNAVAILABLE_REASON, + }); + } } return this.backendConfig; } @@ -218,6 +312,13 @@ class BackendManager { return Boolean(this.getBackendConfig().cmd); } + getBackendCommandUnavailableReason() { + return ( + this.getBackendConfig().failureReason || + DEFAULT_BACKEND_COMMAND_UNAVAILABLE_REASON + ); + } + async flushLogs() { await this.backendLogger.flush(); } @@ -389,6 +490,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'; @@ -471,7 +574,7 @@ class BackendManager { if (!this.canManageBackend()) { return { ok: false, - reason: 'Backend command is not configured.', + reason: this.getBackendCommandUnavailableReason(), }; } this.backendSpawning = true; @@ -640,30 +743,54 @@ class BackendManager { this.log( `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, ); + const backendConfig = this.getBackendConfig(); + const hasBackendCommand = Boolean(backendConfig.cmd); + const fallbackCmdRaw = process.env.ASTRBOT_BACKEND_CMD || 'python.exe'; + const cleanupState = createWindowsBackendCleanupState(); + if (!hasBackendCommand) { + this.log( + 'Backend command is not configured during unmanaged cleanup; falling back to image-name-only matching.', + ); + } - const expectedImageName = ( - path.basename(this.getPackagedBackendPath() || '') || 'astrbot-backend.exe' - ).toLowerCase(); - + const processEntries = []; 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; } + processEntries.push({ pid, processInfo }); + } - const actualImageName = processInfo.imageName.toLowerCase(); - if (actualImageName !== expectedImageName) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, - ); + if (hasBackendCommand && processEntries.length > 0) { + prefetchWindowsProcessCommandLines({ + pids: processEntries.map((entry) => entry.pid), + spawnSync, + log: (message) => this.log(message), + cleanupState, + }); + } + + for (const entry of processEntries) { + const shouldKill = shouldKillUnmanagedBackendProcess({ + pid: entry.pid, + processInfo: entry.processInfo, + backendConfig, + allowImageOnlyMatch: !hasBackendCommand, + fallbackCmdRaw, + spawnSync, + log: (message) => this.log(message), + cleanupState, + }); + if (!shouldKill) { continue; } try { // Synchronous taskkill is acceptable here because unmanaged cleanup // is performed only during shutdown/restart control flows. - spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], { + spawnSync('taskkill', ['/pid', `${entry.pid}`, '/t', '/f'], { stdio: 'ignore', windowsHide: true, }); @@ -706,9 +833,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); @@ -732,7 +862,7 @@ class BackendManager { if (!this.canManageBackend()) { return { ok: false, - reason: 'Backend command is not configured.', + reason: this.getBackendCommandUnavailableReason(), }; } if (this.backendSpawning || this.backendRestarting) { @@ -795,7 +925,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/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, }; 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..459760a87 --- /dev/null +++ b/desktop/lib/windows-backend-cleanup.js @@ -0,0 +1,352 @@ +'use strict'; + +const path = require('path'); + +const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 2000; + +function createWindowsBackendCleanupState() { + return { + commandLineCache: new Map(), + commandLineQueryUnavailable: false, + commandLineFallbackLogged: false, + }; +} + +function normalizeWindowsPathForMatch(value) { + return String(value || '') + .replace(/\//g, '\\') + .toLowerCase(); +} + +function normalizeNumericPid(pid) { + const numericPid = Number.parseInt(`${pid}`, 10); + return Number.isInteger(numericPid) ? numericPid : null; +} + +function normalizePidList(pids) { + const numericPids = []; + const seen = new Set(); + for (const pid of Array.isArray(pids) ? pids : []) { + const numericPid = normalizeNumericPid(pid); + if (numericPid === null || seen.has(numericPid)) { + continue; + } + seen.add(numericPid); + numericPids.push(numericPid); + } + return numericPids; +} + +function isGenericWindowsPythonImage(imageName) { + const normalized = String(imageName || '').toLowerCase(); + return normalized === 'python.exe' || normalized === 'pythonw.exe' || normalized === 'py.exe'; +} + +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 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; +} + +function parseWindowsCommandLineQueryOutput({ stdout, shellName, log }) { + const trimmed = String(stdout || '').trim(); + if (!trimmed) { + return new Map(); + } + + let parsed = null; + try { + parsed = JSON.parse(trimmed); + } catch (error) { + log( + `Failed to parse process command line query output from ${shellName}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return null; + } + + const items = Array.isArray(parsed) ? parsed : [parsed]; + const commandLineMap = new Map(); + for (const item of items) { + if (!item || typeof item !== 'object') { + continue; + } + const numericPid = normalizeNumericPid(item.ProcessId); + if (numericPid === null) { + continue; + } + const rawCommandLine = + typeof item.CommandLine === 'string' ? item.CommandLine.trim() : ''; + commandLineMap.set(numericPid, rawCommandLine || null); + } + return commandLineMap; +} + +function queryWindowsProcessCommandLinesByShell({ + shellName, + numericPids, + spawnSync, + log, + timeoutMs, +}) { + const filter = numericPids.map((numericPid) => `ProcessId = ${numericPid}`).join(' OR '); + const query = + `$p = Get-CimInstance Win32_Process -Filter "${filter}"; ` + + 'if ($null -ne $p) { $p | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress }'; + 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); + } catch (error) { + if (error instanceof Error && error.message) { + log( + `Failed to query process command line by ${shellName}: ${error.message}`, + ); + } + return { hasShell: false, commandLineMap: null }; + } + + if (result.error && result.error.code === 'ENOENT') { + return { hasShell: false, commandLineMap: null }; + } + if (result.error && result.error.code === 'ETIMEDOUT') { + log(`Timed out (${timeoutMs}ms) querying process command lines by ${shellName}.`); + return { hasShell: true, commandLineMap: null }; + } + if (result.error) { + if (result.error.message) { + log( + `Failed to query process command line by ${shellName}: ${result.error.message}`, + ); + } + return { hasShell: true, commandLineMap: null }; + } + if (result.status !== 0) { + return { hasShell: true, commandLineMap: null }; + } + + const commandLineMap = parseWindowsCommandLineQueryOutput({ + stdout: result.stdout, + shellName, + log, + }); + return { hasShell: true, commandLineMap }; +} + +function prefetchWindowsProcessCommandLines({ + pids, + spawnSync, + log, + timeoutMs = WINDOWS_PROCESS_QUERY_TIMEOUT_MS, + cleanupState, +}) { + const state = cleanupState || createWindowsBackendCleanupState(); + const numericPids = normalizePidList(pids).filter( + (numericPid) => !state.commandLineCache.has(numericPid), + ); + + if (numericPids.length === 0 || state.commandLineQueryUnavailable) { + return state; + } + + const queryAttempts = ['powershell', 'pwsh']; + let hasAvailableShell = false; + for (const shellName of queryAttempts) { + const { hasShell, commandLineMap } = queryWindowsProcessCommandLinesByShell({ + shellName, + numericPids, + spawnSync, + log, + timeoutMs, + }); + if (!hasShell) { + continue; + } + hasAvailableShell = true; + if (commandLineMap === null) { + continue; + } + + for (const numericPid of numericPids) { + state.commandLineCache.set( + numericPid, + commandLineMap.has(numericPid) ? commandLineMap.get(numericPid) : null, + ); + } + state.commandLineQueryUnavailable = false; + return state; + } + + if (!hasAvailableShell) { + state.commandLineQueryUnavailable = true; + return state; + } + + for (const numericPid of numericPids) { + state.commandLineCache.set(numericPid, null); + } + state.commandLineQueryUnavailable = false; + return state; +} + +function getWindowsProcessCommandLine({ + pid, + spawnSync, + log, + timeoutMs = WINDOWS_PROCESS_QUERY_TIMEOUT_MS, + cleanupState, +}) { + const state = cleanupState || createWindowsBackendCleanupState(); + const numericPid = normalizeNumericPid(pid); + if (numericPid === null) { + return { commandLine: null, commandLineQueryUnavailable: false, cleanupState: state }; + } + + if (state.commandLineQueryUnavailable) { + return { commandLine: null, commandLineQueryUnavailable: true, cleanupState: state }; + } + + if (!state.commandLineCache.has(numericPid)) { + prefetchWindowsProcessCommandLines({ + pids: [numericPid], + spawnSync, + log, + timeoutMs, + cleanupState: state, + }); + } + + if (state.commandLineQueryUnavailable) { + return { commandLine: null, commandLineQueryUnavailable: true, cleanupState: state }; + } + + return { + commandLine: state.commandLineCache.has(numericPid) + ? state.commandLineCache.get(numericPid) + : null, + commandLineQueryUnavailable: false, + cleanupState: state, + }; +} + +function matchesBackendMarkers({ + pid, + backendConfig, + spawnSync, + log, + cleanupState, +}) { + const state = cleanupState || createWindowsBackendCleanupState(); + const markers = buildBackendCommandLineMarkers(backendConfig); + if (!markers.length) { + log(`Skip unmanaged cleanup for pid=${pid}: backend launch marker is unavailable.`); + return false; + } + + const { commandLine, commandLineQueryUnavailable } = getWindowsProcessCommandLine({ + pid, + spawnSync, + log, + timeoutMs: WINDOWS_PROCESS_QUERY_TIMEOUT_MS, + cleanupState: state, + }); + if (!commandLine) { + if (commandLineQueryUnavailable) { + if (!state.commandLineFallbackLogged) { + state.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; + } + + 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; +} + +function shouldKillUnmanagedBackendProcess({ + pid, + processInfo, + backendConfig, + allowImageOnlyMatch, + fallbackCmdRaw, + spawnSync, + log, + cleanupState, +}) { + const state = cleanupState || createWindowsBackendCleanupState(); + 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, + spawnSync, + log, + cleanupState: state, + }); +} + +module.exports = { + createWindowsBackendCleanupState, + prefetchWindowsProcessCommandLines, + shouldKillUnmanagedBackendProcess, +}; 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(); 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/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs index 921cf19cb..503801e9e 100644 --- a/desktop/scripts/build-backend.mjs +++ b/desktop/scripts/build-backend.mjs @@ -1,86 +1,184 @@ -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'; +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'); -const workDir = path.join(rootDir, 'desktop', 'resources', '.pyinstaller'); -const dataSeparator = process.platform === 'win32' ? ';' : ':'; -const kbStopwordsSrc = path.join( +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, - 'astrbot', - 'core', - 'knowledge_base', - 'retrieval', - 'hit_stopwords.txt', + 'desktop', + 'scripts', + 'templates', + 'launch_backend.py', ); -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 runtimeSource = + process.env.ASTRBOT_DESKTOP_BACKEND_RUNTIME || + process.env.ASTRBOT_DESKTOP_CPYTHON_HOME; +const requirePipProbe = process.env.ASTRBOT_DESKTOP_REQUIRE_PIP === '1'; + +const sourceEntries = [ + 'astrbot', + 'main.py', + 'runtime_bootstrap.py', + 'requirements.txt', ]; -const result = spawnSync('uv', args, { - cwd: rootDir, - stdio: 'inherit', - shell: process.platform === 'win32', -}); +const prepareOutputDirs = () => { + fs.rmSync(outputDir, { recursive: true, force: true }); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(appDir, { recursive: true }); +}; -if (result.error) { - console.error(`Failed to run 'uv': ${result.error.message}`); - process.exit(typeof result.status === 'number' ? result.status : 1); -} +const copyAppSources = () => { + 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}`); + } + copyTree(sourcePath, targetPath); + } +}; -if (result.status !== 0) { - console.error( - `'uv' exited with status ${result.status} while running PyInstaller. ` + - 'Verify that uv and pyinstaller are installed and that arguments are valid.', - ); - process.exit(result.status ?? 1); -} +const prepareRuntimeExecutable = (runtimeSourceReal) => { + copyTree(runtimeSourceReal, runtimeDir, { dereference: true }); + const runtimePython = resolveRuntimePython({ + runtimeRoot: runtimeDir, + outputDir, + }); + if (!runtimePython) { + throw new Error( + `Cannot find Python executable in runtime: ${runtimeDir}. ` + + 'Expected python under bin/ or Scripts/.', + ); + } + return runtimePython; +}; + +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'); +}; -process.exit(0); +const writeRuntimeManifest = (runtimePython) => { + 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'); +}; + +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 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}`, + ); + } + if (installResult.status !== 0) { + throw new Error( + `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 = () => { + const runtimeSourceReal = resolveAndValidateRuntimeSource({ + rootDir, + outputDir, + runtimeSource, + }); + const expectedRuntimeConstraint = resolveExpectedRuntimeVersion({ rootDir }); + + 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({ + pythonExecutable: sourceRuntimePython.absolute, + expectedRuntimeConstraint, + requirePipProbe, + }); + + prepareOutputDirs(); + copyAppSources(); + const runtimePython = prepareRuntimeExecutable(runtimeSourceReal); + installRuntimeDependencies(runtimePython); + writeLauncherScript(); + writeRuntimeManifest(runtimePython); + + 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); +} diff --git a/desktop/scripts/read-requires-python.py b/desktop/scripts/read-requires-python.py new file mode 100644 index 000000000..abd45e3e7 --- /dev/null +++ b/desktop/scripts/read-requires-python.py @@ -0,0 +1,35 @@ +import json +import pathlib +import sys + +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: + 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 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 +emit_result(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..5682153a3 --- /dev/null +++ b/desktop/scripts/runtime-layout-utils.mjs @@ -0,0 +1,98 @@ +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 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( + `CPython runtime source overlaps with backend output directory. ` + + `runtime=${runtimeSourceReal}, output=${outputDir}. ` + + '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.', + ); + } + 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; +}; + +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..b646278e3 --- /dev/null +++ b/desktop/scripts/runtime-version-utils.mjs @@ -0,0 +1,265 @@ +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) => { + try { + const parsed = JSON.parse(String(stdoutText || '').trim()); + return parsed && typeof parsed === 'object' ? parsed : null; + } 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: [] }, + ]; + + let probeErrorMessage = null; + 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); + 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) { + return lowerBound; + } + } + + if (probeErrorMessage) { + throw new Error(probeErrorMessage); + } + + 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.', + ); +}; + +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])'; + 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(); + 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 trimmedOutput = String(probe.stdout || '').trim(); + const parts = trimmedOutput.split(/\s+/); + if (parts.length < 2) { + throw new Error( + `Runtime Python probe did not report a valid version. Output: ${trimmedOutput}`, + ); + } + + 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 actualVersion = probePythonVersion({ + pythonExecutable, + requirePipProbe, + }); + 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}.`, + ); + } +}; diff --git a/desktop/scripts/templates/launch_backend.py b/desktop/scripts/templates/launch_backend.py new file mode 100644 index 000000000..ce31c4f06 --- /dev/null +++ b/desktop/scripts/templates/launch_backend.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import ctypes +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_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 + + 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] = [] + 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) + ) + + +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_stdio_utf8() +configure_windows_dll_search_path() +preload_windows_runtime_dlls() + +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__") diff --git a/main.py b/main.py index be188140c..10809e7eb 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -import runtime_bootstrap +import runtime_bootstrap # noqa: E402 runtime_bootstrap.initialize_runtime_bootstrap() @@ -15,6 +15,7 @@ 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, @@ -55,6 +56,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 的临时解决方案 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 779136e88..f23deeb00 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 @@ -32,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