chore: remove Electron desktop pipeline and switch to tauri repo (#5226)

* ci: remove Electron desktop build from release pipeline

* chore: remove electron desktop and switch to tauri release trigger

* ci: remove desktop workflow dispatch trigger

* refactor: migrate data paths to astrbot_path helpers

* fix: point desktop update prompt to AstrBot-desktop releases
This commit is contained in:
エイカク
2026-02-19 23:04:18 +09:00
committed by GitHub
parent 3597726aad
commit 9c691b2266
33 changed files with 45 additions and 4969 deletions
-165
View File
@@ -102,170 +102,11 @@ jobs:
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
build-desktop:
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
runner: ubuntu-24.04
os: linux
arch: amd64
- name: linux-arm64
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
arch: amd64
- name: macos-arm64
runner: macos-15
os: mac
arch: arm64
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
- 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
tag="$(git describe --tags --abbrev=0)"
fi
if [ -z "$tag" ]; then
echo "Failed to resolve tag." >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup uv
uses: astral-sh/setup-uv@v7
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- 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: 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
run: |
pnpm --dir dashboard run build
pnpm --dir desktop run build:webui
pnpm --dir desktop run build:backend
pnpm --dir desktop run sync:version
pnpm --dir desktop exec electron-builder --publish never
- name: Normalize artifact names
shell: bash
env:
NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
run: |
shopt -s nullglob
out_dir="desktop/dist/release"
mkdir -p "$out_dir"
files=(
desktop/dist/*.AppImage
desktop/dist/*.dmg
desktop/dist/*.zip
desktop/dist/*.exe
)
if [ ${#files[@]} -eq 0 ]; then
echo "No desktop artifacts found to rename." >&2
exit 1
fi
for src in "${files[@]}"; do
file="$(basename "$src")"
case "$file" in
*.AppImage)
dest="$out_dir/${NAME_PREFIX}.AppImage"
;;
*.dmg)
dest="$out_dir/${NAME_PREFIX}.dmg"
;;
*.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 }}-${{ matrix.os }}
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
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -296,12 +137,6 @@ jobs:
name: Dashboard-${{ steps.tag.outputs.tag }}
path: release-assets
- name: Download desktop artifacts
uses: actions/download-artifact@v7
with:
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
path: release-assets
merge-multiple: true
- name: Resolve release notes
id: notes
-7
View File
@@ -33,13 +33,6 @@ tests/astrbot_plugin_openai
dashboard/node_modules/
dashboard/dist/
.pnpm-store/
desktop/node_modules/
desktop/dist/
desktop/out/
desktop/resources/backend/astrbot-backend*
desktop/resources/backend/*.exe
desktop/resources/webui/*
desktop/resources/.pyinstaller/
package-lock.json
yarn.lock
+2 -2
View File
@@ -146,9 +146,9 @@ yay -S astrbot-git
paru -S astrbot-git
```
#### 桌面端 Electron 打包
#### 桌面端Tauri
桌面端Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
桌面端已迁移为独立仓库(Tauri):[https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
## 支持的消息平台
+2 -2
View File
@@ -154,9 +154,9 @@ yay -S astrbot-git
paru -S astrbot-git
```
#### Desktop Electron Build
#### Desktop (Tauri)
For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md).
Desktop packaging has moved to a standalone Tauri repository: [https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
## Supported Messaging Platforms
+5 -2
View File
@@ -13,16 +13,19 @@ from astrbot.core.knowledge_base.models import (
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
+3 -2
View File
@@ -3,6 +3,7 @@ from pathlib import Path
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.manager import RetrievalManager, RetrievalResult
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 @@ class KnowledgeBaseManager:
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
@@ -7,12 +7,14 @@ import asyncio
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 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
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"
+2 -2
View File
@@ -15,7 +15,7 @@ Skills 目录路径:固定为数据目录下的 skills 目录
import os
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
def get_astrbot_path() -> str:
@@ -29,7 +29,7 @@ def get_astrbot_root() -> str:
"""获取Astrbot根目录路径"""
if path := os.environ.get("ASTRBOT_ROOT"):
return os.path.realpath(path)
if is_packaged_electron_runtime():
if is_packaged_desktop_runtime():
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
return os.path.realpath(os.getcwd())
+4 -4
View File
@@ -12,7 +12,7 @@ import threading
from collections import deque
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
logger = logging.getLogger("astrbot")
@@ -35,7 +35,7 @@ def _get_pip_main():
"pip module is unavailable "
f"(sys.executable={sys.executable}, "
f"frozen={getattr(sys, 'frozen', False)}, "
f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})"
f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})"
) from exc
return pip_main
@@ -556,7 +556,7 @@ class PipInstaller:
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
target_site_packages = None
if is_packaged_electron_runtime():
if is_packaged_desktop_runtime():
target_site_packages = get_astrbot_site_packages_path()
os.makedirs(target_site_packages, exist_ok=True)
_prepend_sys_path(target_site_packages)
@@ -582,7 +582,7 @@ class PipInstaller:
def prefer_installed_dependencies(self, requirements_path: str) -> None:
"""优先使用已安装在插件 site-packages 中的依赖,不执行安装。"""
if not is_packaged_electron_runtime():
if not is_packaged_desktop_runtime():
return
target_site_packages = get_astrbot_site_packages_path()
+2 -2
View File
@@ -6,5 +6,5 @@ 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"
def is_packaged_desktop_runtime() -> bool:
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
+7 -3
View File
@@ -20,7 +20,10 @@ from astrbot.core.star.filter.permission import PermissionTypeFilter
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
@@ -196,10 +199,11 @@ class PluginRoute(Route):
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"):
@@ -209,7 +213,7 @@ class PluginRoute(Route):
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",
+4 -5
View File
@@ -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("索引为空")
@@ -51,7 +51,8 @@ const isElectronApp = ref(
const redirectConfirmDialog = ref(false);
const pendingRedirectUrl = ref('');
const resolvingReleaseTarget = ref(false);
const fallbackReleaseUrl = 'https://github.com/AstrBotDevs/AstrBot/releases/latest';
const desktopReleaseBaseUrl = 'https://github.com/AstrBotDevs/AstrBot-desktop/releases';
const fallbackReleaseUrl = desktopReleaseBaseUrl;
const getSelectedGitHubProxy = () => {
if (typeof window === "undefined" || !window.localStorage) return "";
@@ -128,12 +129,15 @@ function confirmExternalRedirect() {
const getReleaseUrlForElectron = () => {
const firstRelease = (releases.value as any[])?.[0];
if (firstRelease?.html_url) return firstRelease.html_url as string;
if (firstRelease?.tag_name) {
const tag = firstRelease.tag_name as string;
return `${desktopReleaseBaseUrl}/tag/${tag}`;
}
if (hasNewVersion.value) return fallbackReleaseUrl;
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
return tag === 'latest'
? fallbackReleaseUrl
: `https://github.com/AstrBotDevs/AstrBot/releases/tag/${tag}`;
: `${desktopReleaseBaseUrl}/tag/${tag}`;
};
function handleUpdateClick() {
-131
View File
@@ -1,131 +0,0 @@
# AstrBot Desktop (Electron)
This document describes how to build the Electron desktop app from source.
## What This Package Contains
- Electron desktop shell (`desktop/main.js`)
- Bundled WebUI static files (`desktop/resources/webui`)
- App assets (`desktop/assets`)
Current behavior:
- Backend executable is 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.
## Prerequisites
- Python environment ready in repository root (`uv` available)
- Node.js available
- `pnpm` available
Desktop dependency management uses `pnpm` with a lockfile:
- `desktop/pnpm-lock.yaml`
- `pnpm --dir desktop install --frozen-lockfile`
## Build From Scratch
Run commands from repository root:
```bash
uv sync
pnpm --dir dashboard install
pnpm --dir dashboard build
pnpm --dir desktop install --frozen-lockfile
pnpm --dir desktop run dist:full
```
Output files are generated under:
- `desktop/dist/`
## Local Run (Development)
Start backend first:
```bash
uv run main.py
```
Start Electron shell:
```bash
pnpm --dir desktop run dev
```
## Notes
- `dist:full` runs WebUI build + backend build + 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.
## Runtime Directory Layout
By default (`ASTRBOT_ROOT` not set), packaged desktop app uses this layout:
```text
~/.astrbot/
data/
config/ # Main configuration
plugins/ # Installed plugins
plugin_data/ # Plugin persistent data
site-packages/ # Plugin dependency installation target in packaged mode
temp/ # Runtime temp files
skills/ # Skill-related runtime data
knowledge_base/ # Knowledge base files
backups/ # Backup data
```
The app does not store a full AstrBot source tree in home directory.
## Troubleshooting
Startup behavior:
- Packaged app shows a local startup page first, then switches to dashboard after backend is reachable.
- If startup page never switches, check logs and timeout settings below.
Runtime logs:
- Electron shell log: `~/.astrbot/logs/electron.log`
- Backend stdout/stderr log: `~/.astrbot/logs/backend.log`
- Both files rotate by size by default: `20MB` per file, keep `3` backups.
- Electron log rotation envs:
- `ASTRBOT_ELECTRON_LOG_MAX_MB`
- `ASTRBOT_ELECTRON_LOG_BACKUP_COUNT`
- Backend log rotation envs:
- `ASTRBOT_BACKEND_LOG_MAX_MB`
- `ASTRBOT_BACKEND_LOG_BACKUP_COUNT`
- Rotation debug logging:
- `ASTRBOT_LOG_ROTATION_DEBUG=1` (or `NODE_ENV=development`) to print filesystem errors from rotation operations.
- On backend startup failure, the app dialog also shows the backend reason and backend log path.
Timeout and loading controls:
- `ASTRBOT_BACKEND_TIMEOUT_MS` controls how long Electron waits for backend reachability.
- In packaged mode, default is `0` (auto mode with a 5-minute safety cap).
- In development mode, default is `20000`.
- If backend startup times out, app shows startup failure dialog and exits.
- `ASTRBOT_DASHBOARD_TIMEOUT_MS` controls dashboard page load wait time after backend is ready (default `20000`).
- If you see `Unable to load the AstrBot dashboard.`, increase `ASTRBOT_DASHBOARD_TIMEOUT_MS`.
Startup page locale:
- Startup page language follows cached dashboard locale in `~/.astrbot/data/desktop_state.json`.
- Supported startup locales are `zh-CN` and `en-US`.
- Remove that file to reset locale fallback behavior.
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.
If Electron download times out on restricted networks, configure mirrors before install:
```bash
export ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
export ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/"
pnpm --dir desktop install --frozen-lockfile
```
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

-821
View File
@@ -1,821 +0,0 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { BufferedRotatingLogger } = require('./buffered-rotating-logger');
const {
delay,
ensureDir,
formatLogTimestamp,
normalizeUrl,
parseLogBackupCount,
parseLogMaxBytes,
waitForProcessExit,
} = require('./common');
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;
function parseBackendTimeoutMs(app) {
const defaultTimeoutMs = app.isPackaged ? 0 : 20000;
const parsed = Number.parseInt(
process.env.ASTRBOT_BACKEND_TIMEOUT_MS || `${defaultTimeoutMs}`,
10,
);
if (Number.isFinite(parsed) && parsed >= 0) {
return parsed;
}
return defaultTimeoutMs;
}
class BackendManager {
constructor({ app, baseDir, log, shouldSkipStart }) {
this.app = app;
this.baseDir = baseDir;
this.log = typeof log === 'function' ? log : () => {};
this.shouldSkipStart =
typeof shouldSkipStart === 'function' ? shouldSkipStart : () => false;
this.backendUrl = normalizeUrl(
process.env.ASTRBOT_BACKEND_URL || 'http://127.0.0.1:6185/',
);
this.backendAutoStart = process.env.ASTRBOT_BACKEND_AUTO_START !== '0';
this.backendTimeoutMs = parseBackendTimeoutMs(app);
this.backendLogMaxBytes = parseLogMaxBytes(
process.env.ASTRBOT_BACKEND_LOG_MAX_MB,
);
this.backendLogBackupCount = parseLogBackupCount(
process.env.ASTRBOT_BACKEND_LOG_BACKUP_COUNT,
);
this.backendProcess = null;
this.backendConfig = null;
this.backendLogger = new BufferedRotatingLogger({
logPath: null,
maxBytes: this.backendLogMaxBytes,
backupCount: this.backendLogBackupCount,
flushIntervalMs: BACKEND_LOG_FLUSH_INTERVAL_MS,
maxBufferBytes: BACKEND_LOG_MAX_BUFFER_BYTES,
});
this.backendLastExitReason = null;
this.backendStartupFailureReason = null;
this.backendSpawning = false;
this.backendRestarting = false;
}
getBackendUrl() {
return this.backendUrl;
}
getBackendTimeoutMs() {
return this.backendTimeoutMs;
}
getRootDir() {
return (
process.env.ASTRBOT_ROOT ||
this.backendConfig?.rootDir ||
this.resolveBackendRoot()
);
}
getBackendLogPath() {
const rootDir = this.getRootDir();
if (!rootDir) {
return null;
}
return path.join(rootDir, 'logs', 'backend.log');
}
getStartupFailureReason() {
return this.backendStartupFailureReason;
}
isSpawning() {
return this.backendSpawning;
}
isRestarting() {
return this.backendRestarting;
}
resolveBackendRoot() {
if (!this.app.isPackaged) {
return null;
}
return path.join(os.homedir(), '.astrbot');
}
resolveBackendCwd() {
if (!this.app.isPackaged) {
return path.resolve(this.baseDir, '..');
}
return this.resolveBackendRoot();
}
resolveWebuiDir() {
if (process.env.ASTRBOT_WEBUI_DIR) {
return process.env.ASTRBOT_WEBUI_DIR;
}
if (!this.app.isPackaged) {
return null;
}
const candidate = path.join(process.resourcesPath, 'webui');
const indexPath = path.join(candidate, 'index.html');
return fs.existsSync(indexPath) ? candidate : null;
}
getPackagedBackendPath() {
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;
}
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);
}
return {
cmd: 'uv',
args,
shell: process.platform === 'win32',
};
}
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,
};
return this.backendConfig;
}
getBackendConfig() {
if (!this.backendConfig) {
return this.resolveBackendConfig();
}
return this.backendConfig;
}
getBackendPort() {
try {
const parsed = new URL(this.backendUrl);
if (parsed.port) {
const port = Number.parseInt(parsed.port, 10);
return Number.isFinite(port) ? port : null;
}
return parsed.protocol === 'https:' ? 443 : 80;
} catch {
return null;
}
}
canManageBackend() {
return Boolean(this.getBackendConfig().cmd);
}
async flushLogs() {
await this.backendLogger.flush();
}
async pingBackend(timeoutMs = 800) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
await fetch(this.backendUrl, {
signal: controller.signal,
redirect: 'manual',
});
return true;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
getEffectiveWaitMs(maxWaitMs = 0) {
if (maxWaitMs > 0) {
return maxWaitMs;
}
if (this.app.isPackaged) {
return PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS;
}
return 0;
}
async requestBackendJson(pathname, options = {}) {
const timeoutMs = options.timeoutMs || 2000;
const method = options.method || 'GET';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const requestUrl = new URL(pathname, this.backendUrl);
requestUrl.searchParams.set('_ts', `${Date.now()}`);
const authToken =
typeof options.authToken === 'string' && options.authToken
? options.authToken
: null;
try {
const response = await fetch(requestUrl.toString(), {
method,
signal: controller.signal,
redirect: 'manual',
headers: {
Accept: 'application/json',
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...(options.headers || {}),
},
});
if (!response.ok) {
return { ok: false, data: null };
}
const data = await response.json();
return { ok: true, data };
} catch {
return { ok: false, data: null };
} finally {
clearTimeout(timeout);
}
}
async getBackendStartTime() {
const result = await this.requestBackendJson('/api/stat/start-time', {
timeoutMs: 1800,
method: 'GET',
});
if (!result.ok || !result.data) {
return null;
}
const rawStartTime = result.data?.data?.start_time;
const numericStartTime = Number(rawStartTime);
return Number.isFinite(numericStartTime) ? numericStartTime : null;
}
async requestGracefulRestart(authToken = null) {
const result = await this.requestBackendJson('/api/stat/restart-core', {
timeoutMs: 2500,
method: 'POST',
authToken,
headers: {
'Content-Type': 'application/json',
},
});
return result.ok;
}
async waitForGracefulRestart(previousStartTime, maxWaitMs = 0) {
const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs);
const gracefulWaitMs =
effectiveMaxWaitMs > 0
? effectiveMaxWaitMs
: GRACEFUL_RESTART_WAIT_FALLBACK_MS;
const start = Date.now();
let sawBackendDown = false;
while (true) {
const reachable = await this.pingBackend(700);
if (!reachable) {
sawBackendDown = true;
} else {
const currentStartTime = await this.getBackendStartTime();
if (
previousStartTime !== null &&
currentStartTime !== null &&
currentStartTime !== previousStartTime
) {
return { ok: true, reason: null };
}
if (sawBackendDown && previousStartTime === null) {
return { ok: true, reason: null };
}
}
if (Date.now() - start >= gracefulWaitMs) {
return {
ok: false,
reason: `Timed out after ${gracefulWaitMs}ms waiting for graceful restart.`,
};
}
await delay(350);
}
}
async waitForBackend(maxWaitMs = 0, failOnProcessExit = false) {
const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs);
const start = Date.now();
while (true) {
if (await this.pingBackend()) {
return { ok: true, reason: null };
}
if (failOnProcessExit && !this.backendProcess) {
return {
ok: false,
reason:
this.backendLastExitReason ||
'Backend process exited before becoming reachable.',
};
}
if (effectiveMaxWaitMs > 0 && Date.now() - start >= effectiveMaxWaitMs) {
return {
ok: false,
reason: `Timed out after ${effectiveMaxWaitMs}ms waiting for backend startup.`,
};
}
await delay(600);
}
}
async startBackend() {
if (this.shouldSkipStart()) {
this.log('Skip backend start because app is quitting.');
return;
}
if (this.backendProcess) {
return;
}
const backendConfig = this.getBackendConfig();
if (!backendConfig.cmd) {
return;
}
this.backendLastExitReason = null;
const env = {
...process.env,
PYTHONUNBUFFERED: '1',
};
if (this.app.isPackaged) {
env.ASTRBOT_ELECTRON_CLIENT = '1';
const hasExplicitDashboardHost = Boolean(
process.env.DASHBOARD_HOST || process.env.ASTRBOT_DASHBOARD_HOST,
);
const hasExplicitDashboardPort = Boolean(
process.env.DASHBOARD_PORT || process.env.ASTRBOT_DASHBOARD_PORT,
);
if (!hasExplicitDashboardHost) {
env.DASHBOARD_HOST = '127.0.0.1';
}
if (!hasExplicitDashboardPort) {
env.DASHBOARD_PORT = '6185';
}
}
if (backendConfig.webuiDir) {
env.ASTRBOT_WEBUI_DIR = backendConfig.webuiDir;
}
let backendLogPath = null;
if (backendConfig.rootDir) {
env.ASTRBOT_ROOT = backendConfig.rootDir;
const logsDir = path.join(backendConfig.rootDir, 'logs');
ensureDir(logsDir);
backendLogPath = path.join(logsDir, 'backend.log');
}
await this.backendLogger.setLogPath(backendLogPath);
const usePipedLogging = Boolean(backendLogPath);
this.backendProcess = spawn(backendConfig.cmd, backendConfig.args || [], {
cwd: backendConfig.cwd,
env,
shell: backendConfig.shell,
stdio: usePipedLogging ? ['ignore', 'pipe', 'pipe'] : 'ignore',
windowsHide: true,
});
if (usePipedLogging) {
if (this.backendProcess.stdout) {
this.backendProcess.stdout.on('data', (chunk) => {
this.backendLogger.log(chunk);
});
}
if (this.backendProcess.stderr) {
this.backendProcess.stderr.on('data', (chunk) => {
this.backendLogger.log(chunk);
});
}
}
if (usePipedLogging) {
const launchLine = [backendConfig.cmd, ...(backendConfig.args || [])]
.map((item) => JSON.stringify(item))
.join(' ');
this.backendLogger.log(
`[${formatLogTimestamp()}] [Electron] Start backend ${launchLine}\n`,
);
}
this.backendProcess.on('error', (error) => {
this.backendLastExitReason =
error instanceof Error ? error.message : String(error);
this.backendLogger.log(
`[${formatLogTimestamp()}] [Electron] Backend spawn error: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
void this.backendLogger.flush();
this.backendProcess = null;
});
this.backendProcess.on('exit', (code, signal) => {
this.backendLastExitReason = `Backend process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`;
void this.backendLogger.flush();
this.backendProcess = null;
});
}
async startBackendAndWait(maxWaitMs = this.backendTimeoutMs) {
if (!this.canManageBackend()) {
return {
ok: false,
reason: 'Backend command is not configured.',
};
}
this.backendSpawning = true;
try {
await this.startBackend();
return await this.waitForBackend(maxWaitMs, true);
} finally {
this.backendSpawning = false;
}
}
async stopManagedBackend() {
if (!this.backendProcess) {
return;
}
const processToStop = this.backendProcess;
const pid = processToStop.pid;
this.backendProcess = null;
this.log(`Stop backend requested pid=${pid ?? 'unknown'}`);
if (process.platform === 'win32' && pid) {
try {
// Synchronous taskkill is acceptable here because stop/restart is
// already a control-path operation and not latency-sensitive.
const result = spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
if (result.status !== 0) {
this.log(
`taskkill failed pid=${pid} status=${result.status} signal=${result.signal ?? 'null'}`,
);
} else {
this.log(`taskkill completed pid=${pid}`);
}
} catch (error) {
this.log(
`taskkill threw for pid=${pid}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
await waitForProcessExit(processToStop, 5000);
} else {
if (!processToStop.killed) {
try {
processToStop.kill('SIGTERM');
} catch (error) {
this.log(
`SIGTERM failed for pid=${pid ?? 'unknown'}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
const exitResult = await waitForProcessExit(processToStop, 5000);
if (exitResult === 'timeout' && !processToStop.killed) {
try {
processToStop.kill('SIGKILL');
} catch {}
await waitForProcessExit(processToStop, 1500);
}
}
await this.backendLogger.flush();
}
findListeningPidsOnWindows(port) {
// Synchronous netstat parsing is acceptable here because this helper is
// used only during shutdown/restart cleanup paths.
const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
windowsHide: true,
});
if (result.status !== 0 || !result.stdout) {
return [];
}
const pids = new Set();
const lines = result.stdout.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.toUpperCase().startsWith('TCP')) {
continue;
}
const parts = trimmed.split(/\s+/);
if (parts.length < 5) {
continue;
}
const localAddress = parts[1] || '';
const state = (parts[3] || '').toUpperCase();
const pid = parts[parts.length - 1];
if (!/^\d+$/.test(pid)) {
continue;
}
if (state !== 'LISTENING') {
continue;
}
const cleanedLocalAddress = localAddress.replace(/\]$/, '');
const segments = cleanedLocalAddress.split(':');
const portStr = segments[segments.length - 1];
const portNum = Number(portStr);
if (Number.isInteger(portNum) && portNum === Number(port)) {
pids.add(pid);
}
}
return Array.from(pids);
}
getWindowsProcessInfo(pid) {
const result = spawnSync(
'tasklist',
['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'],
{
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
windowsHide: true,
},
);
if (result.status !== 0 || !result.stdout) {
return null;
}
const firstLine = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine || firstLine.startsWith('INFO:')) {
return null;
}
const fields = firstLine
.replace(/^"/, '')
.replace(/"$/, '')
.split('","');
const imageName = fields[0] || '';
const parsedPid = Number.parseInt(fields[1] || '', 10);
if (!imageName || !Number.isInteger(parsedPid) || parsedPid !== Number(pid)) {
return null;
}
return { imageName, pid: parsedPid };
}
async stopUnmanagedBackendByPort() {
if (!this.app.isPackaged || process.platform !== 'win32') {
return false;
}
const port = this.getBackendPort();
if (!port) {
return false;
}
const pids = this.findListeningPidsOnWindows(port);
if (!pids.length) {
return false;
}
this.log(
`Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`,
);
const expectedImageName = (
path.basename(this.getPackagedBackendPath() || '') || 'astrbot-backend.exe'
).toLowerCase();
for (const pid of pids) {
const processInfo = this.getWindowsProcessInfo(pid);
if (!processInfo) {
this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`);
continue;
}
const actualImageName = processInfo.imageName.toLowerCase();
if (actualImageName !== expectedImageName) {
this.log(
`Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`,
);
continue;
}
try {
// Synchronous taskkill is acceptable here because unmanaged cleanup
// is performed only during shutdown/restart control flows.
spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
await delay(500);
return !(await this.pingBackend(1200));
}
async stopAnyBackend() {
if (this.backendProcess) {
await this.stopManagedBackend();
const running = await this.pingBackend();
if (!running) {
return { ok: true, reason: null };
}
} else {
const running = await this.pingBackend();
if (!running) {
return { ok: true, reason: null };
}
}
const cleaned = await this.stopUnmanagedBackendByPort();
if (cleaned) {
return { ok: true, reason: null };
}
return {
ok: false,
reason: 'Backend is running but not managed by Electron.',
};
}
async ensureBackend() {
this.backendStartupFailureReason = null;
const running = await this.pingBackend();
if (running) {
return true;
}
if (!this.backendAutoStart || !this.canManageBackend()) {
this.backendStartupFailureReason =
'Backend auto-start is disabled or backend command is not configured.';
return false;
}
const waitResult = await this.startBackendAndWait(this.backendTimeoutMs);
if (!waitResult.ok) {
this.backendStartupFailureReason = waitResult.reason;
return false;
}
return true;
}
async getState() {
return {
running: await this.pingBackend(),
spawning: this.backendSpawning,
restarting: this.backendRestarting,
canManage: this.canManageBackend(),
};
}
async restartBackend(authToken = null) {
if (!this.canManageBackend()) {
return {
ok: false,
reason: 'Backend command is not configured.',
};
}
if (this.backendSpawning || this.backendRestarting) {
return {
ok: false,
reason: 'Backend action already in progress.',
};
}
this.backendRestarting = true;
try {
const backendRunning = await this.pingBackend(900);
if (backendRunning) {
const previousStartTime = await this.getBackendStartTime();
const gracefulRequested = await this.requestGracefulRestart(authToken);
if (gracefulRequested) {
const gracefulResult = await this.waitForGracefulRestart(
previousStartTime,
this.backendTimeoutMs,
);
if (gracefulResult.ok) {
return {
ok: true,
reason: null,
};
}
this.log(
`Graceful restart did not complete: ${gracefulResult.reason || 'unknown reason'}`,
);
} else {
this.log(
'Graceful restart request failed; falling back to managed restart.',
);
}
}
await this.stopManagedBackend();
const startResult = await this.startBackendAndWait(this.backendTimeoutMs);
if (!startResult.ok) {
return {
ok: false,
reason: startResult.reason || 'Failed to restart backend.',
};
}
return {
ok: true,
reason: null,
};
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
} finally {
this.backendRestarting = false;
}
}
async stopBackendForIpc() {
if (!this.canManageBackend()) {
return {
ok: false,
reason: 'Backend command is not configured.',
};
}
if (this.backendSpawning || this.backendRestarting) {
return {
ok: false,
reason: 'Backend action already in progress.',
};
}
try {
return await this.stopAnyBackend();
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
}
}
module.exports = {
BackendManager,
};
-162
View File
@@ -1,162 +0,0 @@
'use strict';
const { RotatingLogWriter } = require('./rotating-log-writer');
const { parseEnvInt } = require('./common');
const DEFAULT_FLUSH_INTERVAL_MS = 120;
const DEFAULT_MAX_BUFFER_BYTES = 128 * 1024;
const MIN_FLUSH_INTERVAL_MS = 10;
const MIN_MAX_BUFFER_BYTES = 4 * 1024;
const MAX_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
function clampIntOption(raw, { defaultValue, min, max }) {
const value = parseEnvInt(raw, defaultValue);
return Math.min(Math.max(value, min), max);
}
class BufferedRotatingLogger {
constructor({
logPath = null,
maxBytes,
backupCount,
flushIntervalMs,
maxBufferBytes,
label = 'buffered-log',
}) {
this.logPath = logPath || null;
this.flushIntervalMs = clampIntOption(flushIntervalMs, {
defaultValue: DEFAULT_FLUSH_INTERVAL_MS,
min: MIN_FLUSH_INTERVAL_MS,
max: 60 * 1000,
});
this.maxBufferBytes = clampIntOption(maxBufferBytes, {
defaultValue: DEFAULT_MAX_BUFFER_BYTES,
min: MIN_MAX_BUFFER_BYTES,
max: MAX_MAX_BUFFER_BYTES,
});
this.buffer = [];
this.bufferBytes = 0;
this.flushTimer = null;
this.pathSwitch = Promise.resolve();
this.writer = new RotatingLogWriter({
logPath: this.logPath,
maxBytes,
backupCount,
label,
});
}
setLogPath(logPath) {
const nextLogPath = logPath || null;
this.pathSwitch = this.pathSwitch.then(async () => {
if (nextLogPath === this.logPath) {
await this.flush();
return;
}
const previousLogPath = this.logPath;
if (previousLogPath) {
await this.flush();
}
this.logPath = null;
await this.writer.setLogPath(nextLogPath);
this.logPath = nextLogPath;
await this.flush();
});
return this.pathSwitch;
}
log(payload) {
if (payload === undefined || payload === null) {
return;
}
const chunk = Buffer.isBuffer(payload)
? payload
: Buffer.from(String(payload), 'utf8');
if (!chunk.length) {
return;
}
if (!this.logPath) {
const boundedChunk = this.clipChunkToBufferLimit(chunk);
this.dropOldestUntilWithinLimit(boundedChunk.length);
this.buffer.push(boundedChunk);
this.bufferBytes += boundedChunk.length;
return;
}
this.buffer.push(chunk);
this.bufferBytes += chunk.length;
if (this.bufferBytes >= this.maxBufferBytes) {
void this.flush();
return;
}
this.scheduleFlush();
}
flush() {
this.clearFlushTimer();
if (!this.buffer.length) {
return this.writer.flush();
}
if (!this.logPath) {
// Path is switching or temporarily unavailable; keep buffered data.
this.dropOldestUntilWithinLimit(0);
return this.writer.flush();
}
const chunks = this.buffer;
this.buffer = [];
this.bufferBytes = 0;
const payload = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
this.writer.append(payload);
return this.writer.flush();
}
dropOldestUntilWithinLimit(incomingBytes = 0) {
while (
this.buffer.length &&
this.bufferBytes + Math.max(0, incomingBytes) > this.maxBufferBytes
) {
const removed = this.buffer.shift();
if (removed) {
this.bufferBytes -= removed.length;
}
}
if (this.bufferBytes < 0) {
this.bufferBytes = 0;
}
}
clipChunkToBufferLimit(chunk) {
if (chunk.length <= this.maxBufferBytes) {
return chunk;
}
return chunk.subarray(chunk.length - this.maxBufferBytes);
}
scheduleFlush() {
if (this.flushTimer !== null) {
return;
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
void this.flush();
}, this.flushIntervalMs);
this.flushTimer.unref?.();
}
clearFlushTimer() {
if (this.flushTimer === null) {
return;
}
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
}
module.exports = {
BufferedRotatingLogger,
};
-115
View File
@@ -1,115 +0,0 @@
'use strict';
const fs = require('fs');
const LOG_ROTATION_DEFAULT_MAX_MB = 20;
const LOG_ROTATION_DEFAULT_BACKUP_COUNT = 3;
function normalizeUrl(value) {
try {
const url = new URL(value);
if (!url.pathname.endsWith('/')) {
url.pathname += '/';
}
return url.toString();
} catch {
return 'http://127.0.0.1:6185/';
}
}
function ensureDir(value) {
if (!value) {
return;
}
if (fs.existsSync(value)) {
return;
}
fs.mkdirSync(value, { recursive: true });
}
function parseEnvInt(raw, defaultValue) {
const parsed = Number.parseInt(`${raw ?? ''}`, 10);
return Number.isFinite(parsed) ? parsed : defaultValue;
}
function isLogRotationDebugEnabled() {
return (
process.env.ASTRBOT_LOG_ROTATION_DEBUG === '1' ||
process.env.NODE_ENV === 'development'
);
}
function parseLogMaxBytes(envValue) {
const mb = parseEnvInt(envValue, LOG_ROTATION_DEFAULT_MAX_MB);
const maxMb = mb > 0 ? mb : LOG_ROTATION_DEFAULT_MAX_MB;
return maxMb * 1024 * 1024;
}
function parseLogBackupCount(envValue) {
const count = parseEnvInt(envValue, LOG_ROTATION_DEFAULT_BACKUP_COUNT);
return count >= 0 ? count : LOG_ROTATION_DEFAULT_BACKUP_COUNT;
}
function isIgnorableFsError(error) {
return Boolean(error && typeof error === 'object' && error.code === 'ENOENT');
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function waitForProcessExit(child, timeoutMs = 5000) {
if (!child) {
return Promise.resolve('missing');
}
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve('exited');
}
return new Promise((resolve) => {
let settled = false;
const finish = (reason) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve(reason);
};
const timeout = setTimeout(() => finish('timeout'), timeoutMs);
child.once('exit', () => finish('exit'));
child.once('error', () => finish('error'));
});
}
function formatLogTimestamp(date = new Date()) {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0');
const minute = `${date.getMinutes()}`.padStart(2, '0');
const second = `${date.getSeconds()}`.padStart(2, '0');
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
const offsetMinutes = -date.getTimezoneOffset();
const offsetSign = offsetMinutes >= 0 ? '+' : '-';
const absOffsetMinutes = Math.abs(offsetMinutes);
const offsetHour = `${Math.floor(absOffsetMinutes / 60)}`.padStart(2, '0');
const offsetMinute = `${absOffsetMinutes % 60}`.padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond} ${offsetSign}${offsetHour}${offsetMinute}`;
}
module.exports = {
LOG_ROTATION_DEFAULT_BACKUP_COUNT,
LOG_ROTATION_DEFAULT_MAX_MB,
delay,
ensureDir,
formatLogTimestamp,
isIgnorableFsError,
isLogRotationDebugEnabled,
normalizeUrl,
parseEnvInt,
parseLogBackupCount,
parseLogMaxBytes,
waitForProcessExit,
};
-30
View File
@@ -1,30 +0,0 @@
'use strict';
const { delay } = require('./common');
async function loadDashboard(mainWindow, backendUrl, maxWaitMs = 20000) {
if (!mainWindow) {
return false;
}
const loadUrl = new URL(backendUrl);
loadUrl.searchParams.set('_electron_ts', `${Date.now()}`);
const start = Date.now();
let lastError = null;
while (maxWaitMs <= 0 || Date.now() - start < maxWaitMs) {
try {
await mainWindow.loadURL(loadUrl.toString());
return true;
} catch (error) {
lastError = error;
await delay(600);
}
}
if (lastError) {
throw lastError;
}
throw new Error(`Timed out loading ${backendUrl}`);
}
module.exports = {
loadDashboard,
};
-53
View File
@@ -1,53 +0,0 @@
'use strict';
const path = require('path');
const { RotatingLogWriter } = require('./rotating-log-writer');
const {
formatLogTimestamp,
parseLogBackupCount,
parseLogMaxBytes,
} = require('./common');
function createElectronLogger({ app, getRootDir }) {
const electronLogMaxBytes = parseLogMaxBytes(
process.env.ASTRBOT_ELECTRON_LOG_MAX_MB,
);
const electronLogBackupCount = parseLogBackupCount(
process.env.ASTRBOT_ELECTRON_LOG_BACKUP_COUNT,
);
const writer = new RotatingLogWriter({
logPath: null,
maxBytes: electronLogMaxBytes,
backupCount: electronLogBackupCount,
label: 'electron-log',
});
function getElectronLogPath() {
const rootDir =
process.env.ASTRBOT_ROOT ||
(typeof getRootDir === 'function' ? getRootDir() : null) ||
app.getPath('userData');
return path.join(rootDir, 'logs', 'electron.log');
}
function logElectron(message) {
const logPath = getElectronLogPath();
const line = `[${formatLogTimestamp()}] ${message}\n`;
void writer.setLogPath(logPath);
void writer.append(line);
}
async function flushElectron() {
await writer.flush();
}
return {
getElectronLogPath,
logElectron,
flushElectron,
};
}
module.exports = {
createElectronLogger,
};
-174
View File
@@ -1,174 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { delay, ensureDir } = require('./common');
const LOCALE_STORAGE_KEY = 'astrbot-locale';
const SUPPORTED_STARTUP_LOCALES = new Set(['zh-CN', 'en-US']);
function normalizeLocale(value) {
if (!value) {
return null;
}
const raw = String(value).trim();
if (!raw) {
return null;
}
if (SUPPORTED_STARTUP_LOCALES.has(raw)) {
return raw;
}
const lower = raw.toLowerCase();
if (lower.startsWith('zh')) {
return 'zh-CN';
}
if (lower.startsWith('en')) {
return 'en-US';
}
return null;
}
function getStartupTexts(locale) {
if (locale === 'zh-CN') {
return {
title: 'AstrBot 正在启动',
message: '界面很快就会加载完成。',
};
}
return {
title: 'AstrBot is starting',
message: 'The dashboard will be ready in a moment.',
};
}
function getShellTexts(locale) {
if (locale === 'zh-CN') {
return {
trayHide: '隐藏 AstrBot',
trayShow: '显示 AstrBot',
trayReload: '重新加载',
trayRestartBackend: '重启后端',
trayQuit: '退出',
startupFailTitle: 'AstrBot 启动失败',
startupFailMessage: 'AstrBot 后端不可达。',
startupFailReasonPrefix: '原因',
startupFailAction:
'请先启动 http://127.0.0.1:6185 的后端服务,然后重新打开 AstrBot。',
startupFailLogPrefix: '后端日志',
dashboardFailTitle: 'AstrBot 加载失败',
dashboardFailMessage: '无法加载 AstrBot 控制台页面。',
};
}
return {
trayHide: 'Hide AstrBot',
trayShow: 'Show AstrBot',
trayReload: 'Reload',
trayRestartBackend: 'Restart Backend',
trayQuit: 'Quit',
startupFailTitle: 'AstrBot startup failed',
startupFailMessage: 'AstrBot backend is not reachable.',
startupFailReasonPrefix: 'Reason',
startupFailAction:
'Please start the backend at http://127.0.0.1:6185 and relaunch AstrBot.',
startupFailLogPrefix: 'Backend log',
dashboardFailTitle: 'Failed to load AstrBot',
dashboardFailMessage: 'Unable to load the AstrBot dashboard.',
};
}
function createLocaleService({ app, getRootDir }) {
function resolveStateRoot() {
const callbackRoot = (() => {
try {
return getRootDir ? getRootDir() : null;
} catch {
return null;
}
})();
return process.env.ASTRBOT_ROOT || callbackRoot || app.getPath('userData');
}
function getDesktopStatePath() {
return path.join(resolveStateRoot(), 'data', 'desktop_state.json');
}
function readCachedLocale() {
const statePath = getDesktopStatePath();
try {
const raw = fs.readFileSync(statePath, 'utf8');
const parsed = JSON.parse(raw);
return normalizeLocale(parsed?.locale);
} catch {
return null;
}
}
function writeCachedLocale(locale) {
const normalized = normalizeLocale(locale);
if (!normalized) {
return;
}
const statePath = getDesktopStatePath();
ensureDir(path.dirname(statePath));
try {
fs.writeFileSync(
statePath,
`${JSON.stringify({ locale: normalized }, null, 2)}\n`,
'utf8',
);
} catch {}
}
function resolveStartupLocale() {
const cached = readCachedLocale();
if (cached) {
return cached;
}
return normalizeLocale(app.getLocale()) || 'zh-CN';
}
async function persistLocaleFromDashboard(
mainWindow,
backendUrl,
timeoutMs = 1200,
) {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
const currentUrl = mainWindow.webContents.getURL();
if (!currentUrl || !currentUrl.startsWith(backendUrl)) {
return;
}
try {
const localeRaw = await Promise.race([
mainWindow.webContents.executeJavaScript(
`(() => {
try {
return window.localStorage.getItem('${LOCALE_STORAGE_KEY}') || '';
} catch {
return '';
}
})();`,
true,
),
delay(timeoutMs).then(() => null),
]);
const locale = normalizeLocale(localeRaw);
if (locale) {
writeCachedLocale(locale);
}
} catch {}
}
return {
getShellTexts,
getStartupTexts,
persistLocaleFromDashboard,
resolveStartupLocale,
};
}
module.exports = {
createLocaleService,
normalizeLocale,
};
-178
View File
@@ -1,178 +0,0 @@
'use strict';
const fs = require('fs/promises');
const path = require('path');
const { isIgnorableFsError, isLogRotationDebugEnabled } = require('./common');
class RotatingLogWriter {
constructor({ logPath = null, maxBytes = 0, backupCount = 0, label = 'log' }) {
this.logPath = logPath || null;
this.maxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 0;
this.backupCount = Number.isFinite(backupCount) && backupCount >= 0 ? backupCount : 0;
this.label = label;
this.cachedSize = null;
this.dirReadyForPath = null;
this.queue = Promise.resolve();
}
setLogPath(logPath) {
const nextPath = logPath || null;
if (nextPath === this.logPath) {
return this.queue;
}
return this.enqueue(async () => {
this.logPath = nextPath;
this.cachedSize = null;
this.dirReadyForPath = null;
});
}
append(payload) {
if (payload === undefined || payload === null) {
return this.queue;
}
const content = Buffer.isBuffer(payload)
? payload
: Buffer.from(String(payload), 'utf8');
if (!content.length) {
return this.queue;
}
return this.enqueue(async () => {
if (!this.logPath) {
return;
}
await this.ensureDirReady();
await this.ensureSizeLoaded();
await this.rotateIfNeeded(content.length);
await fs.appendFile(this.logPath, content);
if (!Number.isFinite(this.cachedSize)) {
this.cachedSize = await this.readSize();
} else {
this.cachedSize += content.length;
}
});
}
flush() {
return this.queue;
}
enqueue(task) {
const run = async () => {
try {
await task();
} catch (error) {
this.reportError('write', this.logPath || 'unknown', error);
}
};
this.queue = this.queue.then(run, run);
return this.queue;
}
async ensureSizeLoaded() {
if (Number.isFinite(this.cachedSize)) {
return;
}
this.cachedSize = await this.readSize();
}
async ensureDirReady() {
if (!this.logPath) {
return;
}
if (this.dirReadyForPath === this.logPath) {
return;
}
const dirPath = path.dirname(this.logPath);
try {
await fs.mkdir(dirPath, { recursive: true });
this.dirReadyForPath = this.logPath;
} catch (error) {
this.reportError('mkdir', dirPath, error);
}
}
async readSize() {
if (!this.logPath) {
return 0;
}
try {
const stat = await fs.stat(this.logPath);
return stat.size;
} catch (error) {
if (isIgnorableFsError(error)) {
return 0;
}
this.reportError('stat', this.logPath, error);
return 0;
}
}
async rotateIfNeeded(incomingBytes) {
if (!this.logPath || this.maxBytes <= 0) {
return;
}
const currentSize = Number.isFinite(this.cachedSize) ? this.cachedSize : 0;
if (currentSize + Math.max(0, incomingBytes) <= this.maxBytes) {
return;
}
if (this.backupCount <= 0) {
try {
await fs.truncate(this.logPath, 0);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('truncate', this.logPath, error);
}
}
this.cachedSize = await this.readSize();
return;
}
const oldestPath = `${this.logPath}.${this.backupCount}`;
try {
await fs.unlink(oldestPath);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('unlink', oldestPath, error);
}
}
for (let index = this.backupCount - 1; index >= 1; index -= 1) {
const sourcePath = `${this.logPath}.${index}`;
const targetPath = `${this.logPath}.${index + 1}`;
try {
await fs.rename(sourcePath, targetPath);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('rename', `${sourcePath} -> ${targetPath}`, error);
}
}
}
try {
await fs.rename(this.logPath, `${this.logPath}.1`);
} catch (error) {
if (!isIgnorableFsError(error)) {
this.reportError('rename', `${this.logPath} -> ${this.logPath}.1`, error);
}
}
this.cachedSize = await this.readSize();
}
reportError(action, targetPath, error) {
if (!isLogRotationDebugEnabled()) {
return;
}
const details = error instanceof Error ? error.message : String(error);
console.error(
`[astrbot][${this.label}] ${action} failed for ${targetPath}: ${details}`,
);
}
}
module.exports = {
RotatingLogWriter,
};
-116
View File
@@ -1,116 +0,0 @@
'use strict';
const fs = require('fs');
async function loadStartupScreen(mainWindow, { getAssetPath, startupTexts }) {
if (!mainWindow) {
return false;
}
let iconUrl = '';
try {
const iconBuffer = fs.readFileSync(getAssetPath('icon-no-shadow.svg'));
iconUrl = `data:image/svg+xml;base64,${iconBuffer.toString('base64')}`;
} catch {}
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AstrBot</title>
<style>
:root {
color-scheme: light dark;
--primary: #3c96ca;
--bg: #f9fafc;
--surface: #ffffff;
--text: #1b1c1d;
--muted: #556170;
--border: #eeeeee;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Poppins", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
display: grid;
place-items: center;
background: var(--bg);
color: var(--text);
transition: background-color 0.2s ease, color 0.2s ease;
}
.card {
text-align: center;
padding: 28px 30px 24px;
border-radius: 14px;
background: var(--surface);
border: 1px solid var(--border);
width: min(360px, calc(100vw - 48px));
}
.logo {
width: 64px;
height: 64px;
display: block;
margin: 0 auto 12px;
}
.spinner {
width: 32px;
height: 32px;
margin: 0 auto 14px;
border: 3px solid rgba(60, 150, 202, 0.22);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
h1 {
margin: 0 0 10px;
font-size: 19px;
font-weight: 700;
letter-spacing: 0.01em;
}
p {
margin: 0;
line-height: 1.55;
color: var(--muted);
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
:root {
--primary: #1677ff;
--bg: #1d1d1d;
--surface: #1f1f1f;
--text: #ffffff;
--muted: #c8c8cc;
--border: #333333;
}
.card {
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.45);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="card">
${
iconUrl
? `<img class="logo" src="${iconUrl}" alt="AstrBot logo" />`
: '<div class="logo" aria-hidden="true"></div>'
}
<div class="spinner" aria-hidden="true"></div>
<h1>${startupTexts.title}</h1>
<p>${startupTexts.message}</p>
</div>
</body>
</html>`;
const startupUrl = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
await mainWindow.loadURL(startupUrl);
return true;
}
module.exports = {
loadStartupScreen,
};
-420
View File
@@ -1,420 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const {
app,
BrowserWindow,
Menu,
Tray,
nativeImage,
shell,
dialog,
ipcMain,
} = require('electron');
const { BackendManager } = require('./lib/backend-manager');
const { loadDashboard } = require('./lib/dashboard-loader');
const { createElectronLogger } = require('./lib/electron-logger');
const { createLocaleService } = require('./lib/locale-service');
const { loadStartupScreen } = require('./lib/startup-screen');
const isMac = process.platform === 'darwin';
const dashboardTimeoutMsParsed = Number.parseInt(
process.env.ASTRBOT_DASHBOARD_TIMEOUT_MS || '20000',
10,
);
const dashboardTimeoutMs = Number.isFinite(dashboardTimeoutMsParsed)
? dashboardTimeoutMsParsed
: 20000;
let mainWindow = null;
let tray = null;
let isQuitting = false;
let quitInProgress = false;
let backendManager = null;
app.commandLine.appendSwitch('disable-http-cache');
const { logElectron, flushElectron } = createElectronLogger({
app,
getRootDir: () => (backendManager ? backendManager.getRootDir() : null),
});
backendManager = new BackendManager({
app,
baseDir: __dirname,
log: logElectron,
shouldSkipStart: () => isQuitting || quitInProgress,
});
const localeService = createLocaleService({
app,
getRootDir: () => backendManager.getRootDir(),
});
function getAssetPath(filename) {
if (app.isPackaged) {
const packaged = path.join(process.resourcesPath, 'assets', filename);
if (fs.existsSync(packaged)) {
return packaged;
}
}
return path.join(__dirname, 'assets', filename);
}
function loadImageSafe(imagePath) {
try {
const image = nativeImage.createFromPath(imagePath);
if (!image.isEmpty()) {
return image;
}
} catch {}
return nativeImage.createEmpty();
}
function showWindow() {
if (!mainWindow) {
return;
}
mainWindow.show();
mainWindow.focus();
updateTrayMenu();
}
function toggleWindow() {
if (!mainWindow) {
return;
}
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
}
updateTrayMenu();
}
function updateTrayMenu() {
if (!tray || !mainWindow) {
return;
}
const shellTexts = localeService.getShellTexts(
localeService.resolveStartupLocale(),
);
const isVisible = mainWindow.isVisible();
const contextMenu = Menu.buildFromTemplate([
{
label: isVisible ? shellTexts.trayHide : shellTexts.trayShow,
click: () => toggleWindow(),
},
{
label: shellTexts.trayReload,
click: () => {
if (mainWindow) {
mainWindow.reload();
}
},
},
{
label: shellTexts.trayRestartBackend,
click: async () => {
if (!backendManager) {
return;
}
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();
if (!result.ok) {
logElectron(
`Tray restart backend fallback failed: ${result.reason || 'unknown reason'}`,
);
}
},
},
{ type: 'separator' },
{
label: shellTexts.trayQuit,
click: () => app.quit(),
},
]);
tray.setContextMenu(contextMenu);
}
function createTray() {
const traySize = isMac ? 18 : 16;
const trayPath = getAssetPath('tray.png');
let trayImage = loadImageSafe(trayPath);
if (trayImage.isEmpty()) {
trayImage = loadImageSafe(getAssetPath('icon.png'));
}
if (!trayImage.isEmpty()) {
trayImage = trayImage.resize({ width: traySize, height: traySize });
if (isMac) {
trayImage.setTemplateImage(true);
}
tray = new Tray(trayImage);
} else {
tray = new Tray(nativeImage.createEmpty());
}
tray.setToolTip('AstrBot');
tray.on('click', () => toggleWindow());
updateTrayMenu();
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 980,
minHeight: 680,
show: false,
backgroundColor: '#f9fafc',
autoHideMenuBar: !isMac,
icon: getAssetPath('icon.png'),
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: path.join(__dirname, 'preload.js'),
...(isMac
? {
defaultFontFamily: {
standard: 'PingFang SC',
sansSerif: 'PingFang SC',
serif: 'Songti SC',
monospace: 'SF Mono',
},
}
: {}),
},
});
mainWindow.on('close', (event) => {
if (isQuitting) {
return;
}
event.preventDefault();
mainWindow.hide();
});
mainWindow.on('minimize', (event) => {
event.preventDefault();
mainWindow.hide();
});
mainWindow.on('show', () => updateTrayMenu());
mainWindow.on('hide', () => updateTrayMenu());
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on(
'did-fail-load',
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (!isMainFrame) {
return;
}
logElectron(
`did-fail-load main-frame code=${errorCode} desc=${errorDescription} url=${validatedURL}`,
);
},
);
mainWindow.webContents.on('did-finish-load', () => {
const currentUrl = mainWindow.webContents.getURL();
logElectron(`did-finish-load url=${currentUrl}`);
if (currentUrl.startsWith(backendManager.getBackendUrl())) {
void localeService.persistLocaleFromDashboard(
mainWindow,
backendManager.getBackendUrl(),
);
}
});
mainWindow.webContents.on('render-process-gone', (_event, details) => {
logElectron(
`render-process-gone reason=${details.reason} exitCode=${details.exitCode}`,
);
});
mainWindow.webContents.on(
'console-message',
(_event, level, message, line, sourceId) => {
if (level >= 2) {
logElectron(
`renderer-console level=${level} source=${sourceId}:${line} message=${message}`,
);
}
},
);
return mainWindow;
}
function registerIpcHandlers() {
ipcMain.handle('astrbot-desktop:is-electron-runtime', async () => true);
ipcMain.handle('astrbot-desktop:get-backend-state', async () => {
return backendManager.getState();
});
ipcMain.handle('astrbot-desktop:restart-backend', async (_event, authToken) => {
return backendManager.restartBackend(authToken);
});
ipcMain.handle('astrbot-desktop:stop-backend', async () => {
return backendManager.stopBackendForIpc();
});
}
async function startDesktopFlow() {
createWindow();
createTray();
try {
const startupTexts = localeService.getStartupTexts(
localeService.resolveStartupLocale(),
);
await loadStartupScreen(mainWindow, {
getAssetPath,
startupTexts,
});
} catch (error) {
logElectron(
`failed to load startup screen: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
showWindow();
const ready = await backendManager.ensureBackend();
if (isQuitting) {
return;
}
if (!ready) {
const shellTexts = localeService.getShellTexts(
localeService.resolveStartupLocale(),
);
const backendLogPath = backendManager.getBackendLogPath();
const detailLines = [];
const startupFailureReason = backendManager.getStartupFailureReason();
if (startupFailureReason) {
detailLines.push(
`${shellTexts.startupFailReasonPrefix}: ${startupFailureReason}`,
);
}
detailLines.push(shellTexts.startupFailAction);
if (backendLogPath) {
detailLines.push(`${shellTexts.startupFailLogPrefix}: ${backendLogPath}`);
}
await dialog.showMessageBox({
type: 'error',
title: shellTexts.startupFailTitle,
message: shellTexts.startupFailMessage,
detail: detailLines.join('\n'),
});
isQuitting = true;
app.quit();
return;
}
try {
await loadDashboard(
mainWindow,
backendManager.getBackendUrl(),
dashboardTimeoutMs,
);
showWindow();
} catch (error) {
const shellTexts = localeService.getShellTexts(
localeService.resolveStartupLocale(),
);
await dialog.showMessageBox({
type: 'error',
title: shellTexts.dashboardFailTitle,
message: shellTexts.dashboardFailMessage,
detail: error instanceof Error ? error.message : String(error),
});
isQuitting = true;
app.quit();
}
}
registerIpcHandlers();
app.setAppUserModelId('com.astrbot.desktop');
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
showWindow();
});
}
app.on('before-quit', (event) => {
if (quitInProgress) {
event.preventDefault();
return;
}
event.preventDefault();
quitInProgress = true;
isQuitting = true;
logElectron('before-quit received, stopping backend.');
localeService
.persistLocaleFromDashboard(mainWindow, backendManager.getBackendUrl())
.catch(() => {})
.then(() =>
backendManager.stopAnyBackend().then((result) => {
if (!result.ok) {
logElectron(`stopBackend failed: ${result.reason || 'unknown reason'}`);
}
}),
)
.finally(async () => {
logElectron('Backend stop finished, exiting app.');
await Promise.allSettled([
flushElectron(),
backendManager ? backendManager.flushLogs() : Promise.resolve(),
]);
app.exit(0);
});
});
app.whenReady().then(async () => {
if (isMac && app.dock) {
const dockIcon = getAssetPath('icon.png');
if (fs.existsSync(dockIcon)) {
app.dock.setIcon(dockIcon);
}
}
await startDesktopFlow();
});
app.on('activate', () => {
if (mainWindow) {
showWindow();
}
});
app.on('window-all-closed', () => {
if (!isMac) {
app.quit();
}
});
-97
View File
@@ -1,97 +0,0 @@
{
"name": "astrbot-desktop",
"version": "4.17.5",
"description": "AstrBot desktop wrapper",
"private": true,
"main": "main.js",
"author": "AstrBot",
"packageManager": "pnpm@10.28.2",
"pnpm": {
"onlyBuiltDependencies": [
"electron"
]
},
"scripts": {
"dev": "electron .",
"start": "electron .",
"sync:version": "node scripts/sync-version.mjs",
"build:webui": "node scripts/prepare-webui.mjs",
"build:backend": "node scripts/build-backend.mjs",
"dist:full": "pnpm run build:webui && pnpm run build:backend && pnpm run dist",
"pack": "pnpm run sync:version && electron-builder --dir",
"dist": "pnpm run sync:version && electron-builder"
},
"devDependencies": {
"electron": "^40.3.0",
"electron-builder": "^24.13.0"
},
"build": {
"appId": "com.astrbot.desktop",
"productName": "AstrBot",
"icon": "assets/icon.png",
"extraResources": [
{
"from": "resources/backend",
"to": "backend",
"filter": [
"**/*",
"!**/*.map"
]
},
{
"from": "resources/webui",
"to": "webui",
"filter": [
"**/*",
"!**/*.map"
]
},
{
"from": "assets",
"to": "assets",
"filter": [
"**/*",
"!**/*.map"
]
}
],
"files": [
"**/*",
"!**/*.map",
"!**/*.d.ts",
"!**/{test,__tests__,tests,powered-test,example,examples}/**"
],
"compression": "maximum",
"electronLanguages": [
"en-US",
"zh-CN"
],
"asar": true,
"directories": {
"buildResources": "assets"
},
"linux": {
"target": [
"AppImage"
],
"category": "Utility"
},
"mac": {
"target": [
"dmg",
"zip"
],
"category": "public.app-category.productivity"
},
"win": {
"target": [
"nsis",
"zip"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
}
-2277
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('astrbotDesktop', {
isElectron: true,
isElectronRuntime: () => ipcRenderer.invoke('astrbot-desktop:is-electron-runtime'),
getBackendState: () => ipcRenderer.invoke('astrbot-desktop:get-backend-state'),
restartBackend: (authToken) =>
ipcRenderer.invoke('astrbot-desktop:restart-backend', authToken),
stopBackend: () => ipcRenderer.invoke('astrbot-desktop:stop-backend'),
onTrayRestartBackend: (callback) => {
const listener = () => {
if (typeof callback === 'function') {
callback();
}
};
ipcRenderer.on('astrbot-desktop:tray-restart-backend', listener);
return () =>
ipcRenderer.removeListener('astrbot-desktop:tray-restart-backend', listener);
},
});
-86
View File
@@ -1,86 +0,0 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const outputDir = path.join(rootDir, 'desktop', 'resources', 'backend');
const workDir = path.join(rootDir, 'desktop', 'resources', '.pyinstaller');
const dataSeparator = process.platform === 'win32' ? ';' : ':';
const kbStopwordsSrc = path.join(
rootDir,
'astrbot',
'core',
'knowledge_base',
'retrieval',
'hit_stopwords.txt',
);
const kbStopwordsDest = 'astrbot/core/knowledge_base/retrieval';
const builtinStarsSrc = path.join(rootDir, 'astrbot', 'builtin_stars');
const builtinStarsDest = 'astrbot/builtin_stars';
const args = [
'run',
'--with',
'pyinstaller',
'python',
'-m',
'PyInstaller',
'--noconfirm',
'--clean',
'--onefile',
'--name',
'astrbot-backend',
'--collect-all',
'aiosqlite',
'--collect-all',
'pip',
'--collect-all',
'bs4',
'--collect-all',
'readability',
'--collect-all',
'lxml',
'--collect-all',
'lxml_html_clean',
'--collect-all',
'rfc3987_syntax',
'--collect-submodules',
'astrbot.api',
'--collect-submodules',
'astrbot.builtin_stars',
'--collect-data',
'certifi',
'--add-data',
`${builtinStarsSrc}${dataSeparator}${builtinStarsDest}`,
'--add-data',
`${kbStopwordsSrc}${dataSeparator}${kbStopwordsDest}`,
'--distpath',
outputDir,
'--workpath',
workDir,
'--specpath',
workDir,
path.join(rootDir, 'main.py'),
];
const result = spawnSync('uv', args, {
cwd: rootDir,
stdio: 'inherit',
shell: process.platform === 'win32',
});
if (result.error) {
console.error(`Failed to run 'uv': ${result.error.message}`);
process.exit(typeof result.status === 'number' ? result.status : 1);
}
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);
}
process.exit(0);
-20
View File
@@ -1,20 +0,0 @@
import { cp, mkdir, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const distDir = path.join(rootDir, 'dashboard', 'dist');
const targetDir = path.join(rootDir, 'desktop', 'resources', 'webui');
if (!existsSync(distDir)) {
console.error('dashboard/dist is missing. Run `pnpm --dir dashboard build` first.');
process.exit(1);
}
await rm(targetDir, { recursive: true, force: true });
await mkdir(targetDir, { recursive: true });
await cp(distDir, targetDir, { recursive: true });
console.log(`Copied WebUI to ${targetDir}`);
-66
View File
@@ -1,66 +0,0 @@
import { readFile, writeFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const desktopPackagePath = path.join(rootDir, 'desktop', 'package.json');
const pyprojectPath = path.join(rootDir, 'pyproject.toml');
function getGitTag() {
const result = spawnSync('git', ['describe', '--tags', '--abbrev=0'], {
cwd: rootDir,
encoding: 'utf8',
});
if (result.status === 0) {
const tag = result.stdout.trim();
return tag.length ? tag : null;
}
return null;
}
function normalizeTag(tag) {
return tag.replace(/^v/i, '');
}
async function getPyprojectVersion() {
try {
const data = await readFile(pyprojectPath, 'utf8');
const match = data.match(/^\s*version\s*=\s*"([^"]+)"/m);
return match ? match[1] : null;
} catch {
return null;
}
}
const pkgRaw = await readFile(desktopPackagePath, 'utf8');
const pkg = JSON.parse(pkgRaw);
const tag = getGitTag();
const versionFromTag = tag ? normalizeTag(tag) : null;
const versionFromPyproject = await getPyprojectVersion();
const version = versionFromPyproject || versionFromTag || pkg.version;
if (
versionFromPyproject &&
versionFromTag &&
versionFromPyproject !== versionFromTag
) {
console.log(
`Using pyproject version ${versionFromPyproject} (ignoring git tag ${versionFromTag}).`,
);
}
if (!version) {
console.warn('No version found to sync.');
process.exit(0);
}
if (pkg.version === version) {
console.log(`Desktop version already ${version}`);
process.exit(0);
}
pkg.version = version;
await writeFile(desktopPackagePath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
console.log(`Updated desktop version to ${version}`);
+2
View File
@@ -15,6 +15,7 @@ from astrbot.core.initial_loader import InitialLoader # noqa: E402
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 的临时解决方案