feat: supports electron app (#4952)

* feat: add desktop wrapper with frontend-only packaging

* docs: add desktop build docs and track dashboard lockfile

* fix: track desktop lockfile for npm ci

* fix: allow custom install directory for windows installer

* chore: migrate desktop workflow to pnpm

* fix(desktop): build AppImage only on Linux

* fix(desktop): harden packaged startup and backend bundling

* fix(desktop): adapt packaged restart and plugin dependency flow

* fix(desktop): prevent backend respawn race on quit

* fix(desktop): prefer pyproject version for desktop packaging

* fix(desktop): improve startup loading UX and reduce flicker

* ci: add desktop multi-platform release workflow

* ci: fix desktop release build and mac runner labels

* ci: disable electron-builder auto publish in desktop build

* ci: avoid electron-builder publish path in build matrix

* ci: normalize desktop release artifact names

* ci: exclude blockmap files from desktop release assets

* ci: prefix desktop release assets with AstrBot and purge blockmaps

* feat: add electron bridge types and expose backend control methods in preload script

* Update startup screen assets and styles

- Changed the icon from PNG to SVG format for better scalability.
- Updated the border color from #d0d0d0 to #eeeeee for a softer appearance.
- Adjusted the width of the startup screen from 460px to 360px for improved responsiveness.

* Update .gitignore to include package.json

* chore: remove desktop gitkeep ignore exceptions

* docs: update desktop troubleshooting for current runtime behavior

* refactor(desktop): modularize runtime and harden startup flow

---------

Co-authored-by: Soulter <905617992@qq.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
This commit is contained in:
エイカク
2026-02-08 22:49:54 +09:00
committed by GitHub
parent 8bd1565696
commit a7e580407c
29 changed files with 9854 additions and 47 deletions
+227
View File
@@ -0,0 +1,227 @@
name: Desktop Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
ref:
description: "Git ref to build (branch/tag/SHA)"
required: false
default: "master"
tag:
description: "Release tag to upload assets to (for example: v4.14.6)"
required: false
permissions:
contents: write
jobs:
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: Setup uv
uses: astral-sh/setup-uv@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- 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: 20
cache: "pnpm"
cache-dependency-path: |
dashboard/pnpm-lock.yaml
desktop/pnpm-lock.yaml
- name: Install dependencies
run: |
uv sync
pnpm --dir dashboard install --frozen-lockfile
pnpm --dir desktop install --frozen-lockfile
- name: Build desktop package
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: Resolve artifact 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 artifact tag." >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- 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 Release Assets
runs-on: ubuntu-24.04
needs: build-desktop
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
- name: Resolve release 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 release tag." >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Download built artifacts
uses: actions/download-artifact@v6
with:
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
path: release-assets
merge-multiple: true
- name: Ensure release exists
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ steps.tag.outputs.tag }}"
if ! gh release view "$tag" >/dev/null 2>&1; then
gh release create "$tag" --title "$tag" --notes ""
fi
- name: Remove stale desktop assets from release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ steps.tag.outputs.tag }}"
while IFS= read -r asset; do
case "$asset" in
*.AppImage|*.dmg|*.zip|*.exe|*.blockmap)
gh release delete-asset "$tag" "$asset" -y || true
;;
esac
done < <(gh release view "$tag" --json assets --jq '.assets[].name')
- name: Upload assets to release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ steps.tag.outputs.tag }}"
gh release upload "$tag" release-assets/* --clobber
+9 -1
View File
@@ -32,6 +32,14 @@ tests/astrbot_plugin_openai
# Dashboard
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
@@ -52,4 +60,4 @@ IFLOW.md
# genie_tts data
CharacterModels/
GenieData/
GenieData/
+4 -1
View File
@@ -132,6 +132,10 @@ uv run main.py
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
#### 桌面端 Electron 打包
桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
## 支持的消息平台
**官方维护**
@@ -269,4 +273,3 @@ _陪伴与能力从来不应该是对立面。我们希望创造的是一个既
_私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
+4
View File
@@ -117,6 +117,10 @@ uv run main.py
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
#### Desktop Electron Build
For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md).
## Supported Messaging Platforms
**Officially Maintained**
+8 -2
View File
@@ -57,14 +57,20 @@ class AstrBotUpdator(RepoZipUpdator):
py = sys.executable
try:
if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli
# 仅 CLI 模式走 `python -m astrbot.cli.__main__`
# 打包后的后端可执行文件需要直接 exec 自身。
if os.environ.get("ASTRBOT_CLI") == "1":
if os.name == "nt":
args = [f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]]
else:
args = sys.argv[1:]
os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args)
else:
os.execl(sys.executable, py, *sys.argv)
if getattr(sys, "frozen", False):
# Frozen executable should not receive argv[0] as a positional argument.
os.execl(sys.executable, py, *sys.argv[1:])
else:
os.execl(sys.executable, py, *sys.argv)
except Exception as e:
logger.error(f"重启失败({py}, {e}),请尝试手动重启。")
raise e
+6
View File
@@ -10,6 +10,7 @@ T2I 模板目录路径:固定为数据目录下的 t2i_templates 目录
WebChat 数据目录路径:固定为数据目录下的 webchat 目录
临时文件目录路径:固定为数据目录下的 temp 目录
Skills 目录路径:固定为数据目录下的 skills 目录
第三方依赖目录路径:固定为数据目录下的 site-packages 目录
"""
import os
@@ -69,6 +70,11 @@ def get_astrbot_skills_path() -> str:
return os.path.realpath(os.path.join(get_astrbot_data_path(), "skills"))
def get_astrbot_site_packages_path() -> str:
"""获取Astrbot第三方依赖目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "site-packages"))
def get_astrbot_knowledge_base_path() -> str:
"""获取Astrbot知识库根目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "knowledge_base"))
+82 -24
View File
@@ -1,8 +1,14 @@
import asyncio
import contextlib
import importlib
import io
import locale
import logging
import os
import sys
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
logger = logging.getLogger("astrbot")
@@ -24,6 +30,36 @@ def _robust_decode(line: bytes) -> str:
return line.decode("utf-8", errors="replace").strip()
def _is_frozen_runtime() -> bool:
return bool(getattr(sys, "frozen", False))
def _get_pip_main():
try:
from pip._internal.cli.main import main as pip_main
except ImportError:
from pip import main as pip_main
return pip_main
def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]:
stream = io.StringIO()
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
result_code = pip_main(args)
return result_code, stream.getvalue()
def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None:
root_logger = logging.getLogger()
original_handler_ids = {id(handler) for handler in original_handlers}
for handler in list(root_logger.handlers):
if id(handler) not in original_handler_ids:
root_logger.removeHandler(handler)
with contextlib.suppress(Exception):
handler.close()
class PipInstaller:
def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None):
self.pip_install_arg = pip_install_arg
@@ -45,37 +81,59 @@ class PipInstaller:
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
target_site_packages = None
if _is_frozen_runtime():
target_site_packages = get_astrbot_site_packages_path()
os.makedirs(target_site_packages, exist_ok=True)
args.extend(["--target", target_site_packages])
if self.pip_install_arg:
args.extend(self.pip_install_arg.split())
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
try:
process = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"pip",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
result_code = None
if _is_frozen_runtime():
result_code = await self._run_pip_in_process(args)
else:
try:
result_code = await self._run_pip_subprocess(args)
except FileNotFoundError:
result_code = await self._run_pip_in_process(args)
assert process.stdout is not None
async for line in process.stdout:
logger.info(_robust_decode(line))
if result_code != 0:
raise Exception(f"安装失败,错误码:{result_code}")
await process.wait()
if target_site_packages and target_site_packages not in sys.path:
sys.path.insert(0, target_site_packages)
importlib.invalidate_caches()
if process.returncode != 0:
raise Exception(f"安装失败,错误码:{process.returncode}")
except FileNotFoundError:
# 没有 pip
from pip import main as pip_main
async def _run_pip_subprocess(self, args: list[str]) -> int:
process = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"pip",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
result_code = await asyncio.to_thread(pip_main, args)
assert process.stdout is not None
async for line in process.stdout:
logger.info(_robust_decode(line))
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
await process.wait()
return process.returncode
if result_code != 0:
raise Exception(f"安装失败,错误码:{result_code}")
async def _run_pip_in_process(self, args: list[str]) -> int:
pip_main = _get_pip_main()
original_handlers = list(logging.getLogger().handlers)
result_code, output = await asyncio.to_thread(
_run_pip_main_with_output, pip_main, args
)
for line in output.splitlines():
line = line.strip()
if line:
logger.info(line)
_cleanup_added_root_handlers(original_handlers)
return result_code
+5491
View File
File diff suppressed because it is too large Load Diff
@@ -33,9 +33,15 @@ export default {
methods: {
async check() {
this.newStartTime = -1
this.startTime = useCommonStore().getStartTime()
this.cnt = 0
this.visible = true
this.status = ""
const commonStore = useCommonStore()
try {
this.startTime = await commonStore.fetchStartTime()
} catch (_error) {
this.startTime = commonStore.getStartTime()
}
console.log('start wfr')
setTimeout(() => {
this.timeoutInternal()
@@ -50,7 +56,7 @@ export default {
this.timeoutInternal()
}, 1000)
} else {
if (this.cnt == 10) {
if (this.cnt >= 60) {
this.status = this.t('core.common.restart.maxRetriesReached')
}
this.cnt = 0
@@ -60,18 +66,22 @@ export default {
}
},
async checkStartTime() {
let res = await axios.get('/api/stat/start-time', { timeout: 3000 })
let newStartTime = res.data.data.start_time
console.log('wfr: checkStartTime', this.newStartTime, this.startTime)
if (this.newStartTime !== this.startTime) {
this.newStartTime = newStartTime
console.log('wfr: restarted')
this.visible = false
// reload
window.location.reload()
try {
let res = await axios.get('/api/stat/start-time', { timeout: 3000 })
let newStartTime = res.data.data.start_time
console.log('wfr: checkStartTime', newStartTime, this.startTime)
if (this.startTime !== -1 && newStartTime !== this.startTime) {
this.newStartTime = newStartTime
console.log('wfr: restarted')
this.visible = false
// reload
window.location.reload()
}
} catch (_error) {
// backend may be unavailable during restart window
}
return this.newStartTime
}
}
}
</script>
</script>
+7 -3
View File
@@ -132,13 +132,17 @@ export const useCommonStore = defineStore({
getLogCache() {
return this.log_cache
},
async fetchStartTime() {
const res = await axios.get('/api/stat/start-time');
this.startTime = res.data.data.start_time;
return this.startTime;
},
getStartTime() {
if (this.startTime !== -1) {
return this.startTime
}
axios.get('/api/stat/start-time').then((res) => {
this.startTime = res.data.data.start_time
})
this.fetchStartTime().catch(() => {});
return this.startTime
},
async getPluginCollections(force = false, customSource = null) {
// 获取插件市场数据
+24
View File
@@ -0,0 +1,24 @@
export {};
declare global {
interface Window {
astrbotDesktop?: {
isElectron: boolean;
isElectronRuntime: () => Promise<boolean>;
getBackendState: () => Promise<{
running: boolean;
spawning: boolean;
restarting: boolean;
canManage: boolean;
}>;
restartBackend: () => Promise<{
ok: boolean;
reason: string | null;
}>;
stopBackend: () => Promise<{
ok: boolean;
reason: string | null;
}>;
};
}
}
+122
View File
@@ -0,0 +1,122 @@
# 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`
- 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

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+504
View File
@@ -0,0 +1,504 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { delay, ensureDir, normalizeUrl, waitForProcessExit } = require('./common');
const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000;
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.backendProcess = null;
this.backendConfig = null;
this.backendLogFd = null;
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;
}
canManageBackend() {
return Boolean(this.getBackendConfig().cmd);
}
closeBackendLogFd() {
if (this.backendLogFd === null) {
return;
}
try {
fs.closeSync(this.backendLogFd);
} catch {}
this.backendLogFd = null;
}
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);
}
}
async waitForBackend(maxWaitMs = 0, failOnProcessExit = false) {
const effectiveMaxWaitMs =
maxWaitMs > 0
? maxWaitMs
: this.app.isPackaged
? PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS
: 0;
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);
}
}
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 (backendConfig.rootDir) {
env.ASTRBOT_ROOT = backendConfig.rootDir;
const logsDir = path.join(backendConfig.rootDir, 'logs');
ensureDir(logsDir);
const logPath = path.join(logsDir, 'backend.log');
try {
this.backendLogFd = fs.openSync(logPath, 'a');
} catch {
this.backendLogFd = null;
}
}
this.backendProcess = spawn(backendConfig.cmd, backendConfig.args || [], {
cwd: backendConfig.cwd,
env,
shell: backendConfig.shell,
stdio:
this.backendLogFd === null
? 'ignore'
: ['ignore', this.backendLogFd, this.backendLogFd],
windowsHide: true,
});
if (this.backendLogFd !== null) {
const launchLine = [backendConfig.cmd, ...(backendConfig.args || [])]
.map((item) => JSON.stringify(item))
.join(' ');
try {
fs.writeSync(
this.backendLogFd,
`[${new Date().toISOString()}] [Electron] Start backend ${launchLine}\n`,
);
} catch {}
}
this.backendProcess.on('error', (error) => {
this.backendLastExitReason =
error instanceof Error ? error.message : String(error);
if (this.backendLogFd !== null) {
try {
fs.writeSync(
this.backendLogFd,
`[${new Date().toISOString()}] [Electron] Backend spawn error: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
} catch {}
}
this.closeBackendLogFd();
this.backendProcess = null;
});
this.backendProcess.on('exit', (code, signal) => {
this.backendLastExitReason = `Backend process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`;
this.closeBackendLogFd();
this.backendProcess = null;
});
}
async startBackendAndWait(maxWaitMs = this.backendTimeoutMs) {
if (!this.canManageBackend()) {
return {
ok: false,
reason: 'Backend command is not configured.',
};
}
this.backendSpawning = true;
try {
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 {
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);
}
}
this.closeBackendLogFd();
}
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() {
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 {
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 {
if (!this.backendProcess) {
const running = await this.pingBackend();
if (running) {
return {
ok: false,
reason: 'Backend is running but not managed by Electron.',
};
}
return {
ok: true,
reason: null,
};
}
await this.stopManagedBackend();
const running = await this.pingBackend();
if (running) {
return {
ok: false,
reason: 'Backend is still reachable after stop request.',
};
}
return {
ok: true,
reason: null,
};
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
}
}
module.exports = {
BackendManager,
};
+59
View File
@@ -0,0 +1,59 @@
'use strict';
const fs = require('fs');
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 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'));
});
}
module.exports = {
delay,
ensureDir,
normalizeUrl,
waitForProcessExit,
};
+30
View File
@@ -0,0 +1,30 @@
'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,
};
+33
View File
@@ -0,0 +1,33 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { ensureDir } = require('./common');
function createElectronLogger({ app, getRootDir }) {
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();
ensureDir(path.dirname(logPath));
const line = `[${new Date().toISOString()}] ${message}\n`;
try {
fs.appendFileSync(logPath, line, 'utf8');
} catch {}
}
return {
getElectronLogPath,
logElectron,
};
}
module.exports = {
createElectronLogger,
};
+172
View File
@@ -0,0 +1,172 @@
'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: '重新加载',
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',
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,
};
+116
View File
@@ -0,0 +1,116 @@
'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,
};
+385
View File
@@ -0,0 +1,385 @@
'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 } = 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();
}
},
},
{ 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'),
},
});
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 () => {
return backendManager.restartBackend();
});
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.stopManagedBackend().catch((error) => {
logElectron(
`stopBackend failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
}),
)
.finally(() => {
logElectron('Backend stop finished, exiting app.');
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();
}
});
+81
View File
@@ -0,0 +1,81 @@
{
"name": "astrbot-desktop",
"version": "4.14.6",
"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": "^30.0.0",
"electron-builder": "^24.13.0"
},
"build": {
"appId": "com.astrbot.desktop",
"productName": "AstrBot",
"icon": "assets/icon.png",
"extraResources": [
{
"from": "resources/backend",
"to": "backend"
},
{
"from": "resources/webui",
"to": "webui"
},
{
"from": "assets",
"to": "assets"
}
],
"files": [
"**/*",
"!resources/backend{,/**}",
"!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
"!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}",
"!**/node_modules/.bin"
],
"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
}
}
}
+2282
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
'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: () => ipcRenderer.invoke('astrbot-desktop:restart-backend'),
stopBackend: () => ipcRenderer.invoke('astrbot-desktop:stop-backend'),
});
+68
View File
@@ -0,0 +1,68 @@
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 args = [
'run',
'--with',
'pyinstaller',
'python',
'-m',
'PyInstaller',
'--noconfirm',
'--clean',
'--onefile',
'--name',
'astrbot-backend',
'--collect-all',
'aiosqlite',
'--collect-all',
'pip',
'--collect-submodules',
'astrbot.api',
'--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
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,66 @@
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}`);
+20 -4
View File
@@ -8,7 +8,14 @@ from pathlib import Path
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.config.default import VERSION
from astrbot.core.initial_loader import InitialLoader
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_data_path,
get_astrbot_plugin_path,
get_astrbot_root,
get_astrbot_site_packages_path,
get_astrbot_temp_path,
)
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
# 将父目录添加到 sys.path
@@ -30,9 +37,18 @@ def check_env():
logger.error("请使用 Python3.10+ 运行本项目。")
exit()
os.makedirs("data/config", exist_ok=True)
os.makedirs("data/plugins", exist_ok=True)
os.makedirs("data/temp", exist_ok=True)
astrbot_root = get_astrbot_root()
if astrbot_root not in sys.path:
sys.path.insert(0, astrbot_root)
site_packages_path = get_astrbot_site_packages_path()
if site_packages_path not in sys.path:
sys.path.insert(0, site_packages_path)
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(site_packages_path, exist_ok=True)
# 针对问题 #181 的临时解决方案
mimetypes.add_type("text/javascript", ".js")