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:
@@ -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
@@ -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/
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+5491
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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2282
File diff suppressed because it is too large
Load Diff
@@ -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'),
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user