Skip to content

feat: supports electron app on desktop#4952

Merged
Soulter merged 24 commits intoAstrBotDevs:masterfrom
zouyonghe:master
Feb 8, 2026
Merged

feat: supports electron app on desktop#4952
Soulter merged 24 commits intoAstrBotDevs:masterfrom
zouyonghe:master

Conversation

@zouyonghe
Copy link
Collaborator

@zouyonghe zouyonghe commented Feb 7, 2026

Modifications / 改动点

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果


Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

Summary by Sourcery

为 AstrBot 新增一个基于 Electron 的桌面外壳应用,将 WebUI 和可选的打包后后端一并集成,并整合所需的运行时路径、安装器和工作流,以支持在打包和开发环境下的构建、运行和重启。

新功能:

  • 引入一个 Electron 桌面封装程序,用于加载 AstrBot WebUI,管理带托盘集成的单主窗口,并通过预加载桥接与仪表盘进行通信。
  • 新增一个可选的、由 Electron 管理的后端进程,支持健康检查、重启/停止控制,并可使用打包后的后端可执行文件或外部后端 URL。

增强项:

  • 改进插件依赖安装逻辑,以支持冻结/打包运行时:将安装目标指向专用的 AstrBot site-packages 目录,并清理日志处理器。
  • 调整重启和环境初始化逻辑,以更好地支持 CLI 和冻结后的后端可执行文件,包括基于环境的 CLI 检测以及更安全的 argv 处理。
  • 使仪表盘的重启等待对话框在后端重启场景下更加健壮:通过显式获取启动时间、延长重试上限,并容忍后端的短暂不可用。

构建:

  • 创建一个独立的 Electron 项目,包含打包配置、用于打包 WebUI 和构建基于 PyInstaller 的后端二进制的脚本,以及用于可重现安装的 pnpm lockfiles。
  • 新增一个 GitHub Actions 工作流,用于构建并发布跨平台的 Electron 桌面构建产物,并将其附加到打好标签的发布版本中。

CI:

  • 新增一个桌面发布用的 GitHub Actions 工作流,在多种架构上构建 Linux、macOS 和 Windows 的 Electron 安装程序,并将其上传到 GitHub Releases。

部署:

  • 配置 Electron Builder,在 Linux、macOS 和 Windows 目标平台上进行打包,包括对后端二进制文件、WebUI 资源和应用图标的资源打包。

文档:

  • 在新的桌面版 README 中记录 Electron 桌面构建和运行时行为,并从主英文和中文 README 中添加链接指向该文档。
Original summary in English

Summary by Sourcery

Add an Electron-based desktop shell for AstrBot that bundles the WebUI and an optional packaged backend, and integrate the necessary runtime paths, installers, and workflows to support building, running, and restarting in both packaged and development environments.

New Features:

  • Introduce an Electron desktop wrapper that loads the AstrBot WebUI, manages a single main window with tray integration, and communicates with the dashboard via a preload bridge.
  • Add an optional Electron-managed backend process with health checks, restart/stop controls, and support for a packaged backend executable or external backend URL.

Enhancements:

  • Improve plugin dependency installation to support frozen/packaged runtimes by targeting a dedicated AstrBot site-packages directory and cleaning up logging handlers.
  • Adjust restart and environment initialization logic to better support both CLI and frozen backend executables, including environment-based CLI detection and safer argv handling.
  • Make the dashboard restart waiting dialog more robust against backend restarts by fetching start time explicitly, extending retry limits, and tolerating transient backend unavailability.

Build:

  • Create a standalone Electron project with packaging configuration, scripts to bundle the WebUI and build a PyInstaller-based backend binary, and pnpm lockfiles for reproducible installs.
  • Add a GitHub Actions workflow to build and publish cross-platform Electron desktop artifacts and attach them to tagged releases.

CI:

  • Introduce a desktop release GitHub Actions workflow that builds Electron installers for Linux, macOS, and Windows on multiple architectures and uploads them to GitHub releases.

Deployment:

  • Configure Electron Builder packaging for Linux, macOS, and Windows targets, including resource bundling for the backend binary, WebUI assets, and app icons.

Documentation:

  • Document the Electron desktop build and runtime behavior in a new desktop README and link it from the main English and Chinese READMEs.
Original summary in English

Summary by Sourcery

为 AstrBot 新增一个基于 Electron 的桌面外壳应用,将 WebUI 和可选的打包后后端一并集成,并整合所需的运行时路径、安装器和工作流,以支持在打包和开发环境下的构建、运行和重启。

新功能:

  • 引入一个 Electron 桌面封装程序,用于加载 AstrBot WebUI,管理带托盘集成的单主窗口,并通过预加载桥接与仪表盘进行通信。
  • 新增一个可选的、由 Electron 管理的后端进程,支持健康检查、重启/停止控制,并可使用打包后的后端可执行文件或外部后端 URL。

增强项:

  • 改进插件依赖安装逻辑,以支持冻结/打包运行时:将安装目标指向专用的 AstrBot site-packages 目录,并清理日志处理器。
  • 调整重启和环境初始化逻辑,以更好地支持 CLI 和冻结后的后端可执行文件,包括基于环境的 CLI 检测以及更安全的 argv 处理。
  • 使仪表盘的重启等待对话框在后端重启场景下更加健壮:通过显式获取启动时间、延长重试上限,并容忍后端的短暂不可用。

构建:

  • 创建一个独立的 Electron 项目,包含打包配置、用于打包 WebUI 和构建基于 PyInstaller 的后端二进制的脚本,以及用于可重现安装的 pnpm lockfiles。
  • 新增一个 GitHub Actions 工作流,用于构建并发布跨平台的 Electron 桌面构建产物,并将其附加到打好标签的发布版本中。

CI:

  • 新增一个桌面发布用的 GitHub Actions 工作流,在多种架构上构建 Linux、macOS 和 Windows 的 Electron 安装程序,并将其上传到 GitHub Releases。

部署:

  • 配置 Electron Builder,在 Linux、macOS 和 Windows 目标平台上进行打包,包括对后端二进制文件、WebUI 资源和应用图标的资源打包。

文档:

  • 在新的桌面版 README 中记录 Electron 桌面构建和运行时行为,并从主英文和中文 README 中添加链接指向该文档。
Original summary in English

Summary by Sourcery

Add an Electron-based desktop shell for AstrBot that bundles the WebUI and an optional packaged backend, and integrate the necessary runtime paths, installers, and workflows to support building, running, and restarting in both packaged and development environments.

New Features:

  • Introduce an Electron desktop wrapper that loads the AstrBot WebUI, manages a single main window with tray integration, and communicates with the dashboard via a preload bridge.
  • Add an optional Electron-managed backend process with health checks, restart/stop controls, and support for a packaged backend executable or external backend URL.

Enhancements:

  • Improve plugin dependency installation to support frozen/packaged runtimes by targeting a dedicated AstrBot site-packages directory and cleaning up logging handlers.
  • Adjust restart and environment initialization logic to better support both CLI and frozen backend executables, including environment-based CLI detection and safer argv handling.
  • Make the dashboard restart waiting dialog more robust against backend restarts by fetching start time explicitly, extending retry limits, and tolerating transient backend unavailability.

Build:

  • Create a standalone Electron project with packaging configuration, scripts to bundle the WebUI and build a PyInstaller-based backend binary, and pnpm lockfiles for reproducible installs.
  • Add a GitHub Actions workflow to build and publish cross-platform Electron desktop artifacts and attach them to tagged releases.

CI:

  • Introduce a desktop release GitHub Actions workflow that builds Electron installers for Linux, macOS, and Windows on multiple architectures and uploads them to GitHub releases.

Deployment:

  • Configure Electron Builder packaging for Linux, macOS, and Windows targets, including resource bundling for the backend binary, WebUI assets, and app icons.

Documentation:

  • Document the Electron desktop build and runtime behavior in a new desktop README and link it from the main English and Chinese READMEs.

新功能:

  • 引入一个 Electron 桌面外壳,用于加载 AstrBot WebUI,并管理带有托盘集成的单主窗口。
  • 支持从桌面应用可选地自动启动 AstrBot 后端,并包含健康检查与优雅关闭处理。

增强:

  • 编写并完善 Electron 桌面构建与开发工作流的文档,并从中文和英文的 README 中进行链接。

构建:

  • 新增独立的 Electron 项目,包含打包配置和脚本,用于打包 WebUI,并(可选)构建后端二进制用于发布分发。
Original summary in English

Summary by Sourcery

为 AstrBot 新增一个基于 Electron 的桌面外壳应用,将 WebUI 和可选的打包后后端一并集成,并整合所需的运行时路径、安装器和工作流,以支持在打包和开发环境下的构建、运行和重启。

新功能:

  • 引入一个 Electron 桌面封装程序,用于加载 AstrBot WebUI,管理带托盘集成的单主窗口,并通过预加载桥接与仪表盘进行通信。
  • 新增一个可选的、由 Electron 管理的后端进程,支持健康检查、重启/停止控制,并可使用打包后的后端可执行文件或外部后端 URL。

增强项:

  • 改进插件依赖安装逻辑,以支持冻结/打包运行时:将安装目标指向专用的 AstrBot site-packages 目录,并清理日志处理器。
  • 调整重启和环境初始化逻辑,以更好地支持 CLI 和冻结后的后端可执行文件,包括基于环境的 CLI 检测以及更安全的 argv 处理。
  • 使仪表盘的重启等待对话框在后端重启场景下更加健壮:通过显式获取启动时间、延长重试上限,并容忍后端的短暂不可用。

构建:

  • 创建一个独立的 Electron 项目,包含打包配置、用于打包 WebUI 和构建基于 PyInstaller 的后端二进制的脚本,以及用于可重现安装的 pnpm lockfiles。
  • 新增一个 GitHub Actions 工作流,用于构建并发布跨平台的 Electron 桌面构建产物,并将其附加到打好标签的发布版本中。

CI:

  • 新增一个桌面发布用的 GitHub Actions 工作流,在多种架构上构建 Linux、macOS 和 Windows 的 Electron 安装程序,并将其上传到 GitHub Releases。

部署:

  • 配置 Electron Builder,在 Linux、macOS 和 Windows 目标平台上进行打包,包括对后端二进制文件、WebUI 资源和应用图标的资源打包。

文档:

  • 在新的桌面版 README 中记录 Electron 桌面构建和运行时行为,并从主英文和中文 README 中添加链接指向该文档。
Original summary in English

Summary by Sourcery

Add an Electron-based desktop shell for AstrBot that bundles the WebUI and an optional packaged backend, and integrate the necessary runtime paths, installers, and workflows to support building, running, and restarting in both packaged and development environments.

New Features:

  • Introduce an Electron desktop wrapper that loads the AstrBot WebUI, manages a single main window with tray integration, and communicates with the dashboard via a preload bridge.
  • Add an optional Electron-managed backend process with health checks, restart/stop controls, and support for a packaged backend executable or external backend URL.

Enhancements:

  • Improve plugin dependency installation to support frozen/packaged runtimes by targeting a dedicated AstrBot site-packages directory and cleaning up logging handlers.
  • Adjust restart and environment initialization logic to better support both CLI and frozen backend executables, including environment-based CLI detection and safer argv handling.
  • Make the dashboard restart waiting dialog more robust against backend restarts by fetching start time explicitly, extending retry limits, and tolerating transient backend unavailability.

Build:

  • Create a standalone Electron project with packaging configuration, scripts to bundle the WebUI and build a PyInstaller-based backend binary, and pnpm lockfiles for reproducible installs.
  • Add a GitHub Actions workflow to build and publish cross-platform Electron desktop artifacts and attach them to tagged releases.

CI:

  • Introduce a desktop release GitHub Actions workflow that builds Electron installers for Linux, macOS, and Windows on multiple architectures and uploads them to GitHub releases.

Deployment:

  • Configure Electron Builder packaging for Linux, macOS, and Windows targets, including resource bundling for the backend binary, WebUI assets, and app icons.

Documentation:

  • Document the Electron desktop build and runtime behavior in a new desktop README and link it from the main English and Chinese READMEs.
Original summary in English

Summary by Sourcery

为 AstrBot 新增一个基于 Electron 的桌面外壳应用,将 WebUI 和可选的打包后后端一并集成,并整合所需的运行时路径、安装器和工作流,以支持在打包和开发环境下的构建、运行和重启。

新功能:

  • 引入一个 Electron 桌面封装程序,用于加载 AstrBot WebUI,管理带托盘集成的单主窗口,并通过预加载桥接与仪表盘进行通信。
  • 新增一个可选的、由 Electron 管理的后端进程,支持健康检查、重启/停止控制,并可使用打包后的后端可执行文件或外部后端 URL。

增强项:

  • 改进插件依赖安装逻辑,以支持冻结/打包运行时:将安装目标指向专用的 AstrBot site-packages 目录,并清理日志处理器。
  • 调整重启和环境初始化逻辑,以更好地支持 CLI 和冻结后的后端可执行文件,包括基于环境的 CLI 检测以及更安全的 argv 处理。
  • 使仪表盘的重启等待对话框在后端重启场景下更加健壮:通过显式获取启动时间、延长重试上限,并容忍后端的短暂不可用。

构建:

  • 创建一个独立的 Electron 项目,包含打包配置、用于打包 WebUI 和构建基于 PyInstaller 的后端二进制的脚本,以及用于可重现安装的 pnpm lockfiles。
  • 新增一个 GitHub Actions 工作流,用于构建并发布跨平台的 Electron 桌面构建产物,并将其附加到打好标签的发布版本中。

CI:

  • 新增一个桌面发布用的 GitHub Actions 工作流,在多种架构上构建 Linux、macOS 和 Windows 的 Electron 安装程序,并将其上传到 GitHub Releases。

部署:

  • 配置 Electron Builder,在 Linux、macOS 和 Windows 目标平台上进行打包,包括对后端二进制文件、WebUI 资源和应用图标的资源打包。

文档:

  • 在新的桌面版 README 中记录 Electron 桌面构建和运行时行为,并从主英文和中文 README 中添加链接指向该文档。
Original summary in English

Summary by Sourcery

Add an Electron-based desktop shell for AstrBot that bundles the WebUI and an optional packaged backend, and integrate the necessary runtime paths, installers, and workflows to support building, running, and restarting in both packaged and development environments.

New Features:

  • Introduce an Electron desktop wrapper that loads the AstrBot WebUI, manages a single main window with tray integration, and communicates with the dashboard via a preload bridge.
  • Add an optional Electron-managed backend process with health checks, restart/stop controls, and support for a packaged backend executable or external backend URL.

Enhancements:

  • Improve plugin dependency installation to support frozen/packaged runtimes by targeting a dedicated AstrBot site-packages directory and cleaning up logging handlers.
  • Adjust restart and environment initialization logic to better support both CLI and frozen backend executables, including environment-based CLI detection and safer argv handling.
  • Make the dashboard restart waiting dialog more robust against backend restarts by fetching start time explicitly, extending retry limits, and tolerating transient backend unavailability.

Build:

  • Create a standalone Electron project with packaging configuration, scripts to bundle the WebUI and build a PyInstaller-based backend binary, and pnpm lockfiles for reproducible installs.
  • Add a GitHub Actions workflow to build and publish cross-platform Electron desktop artifacts and attach them to tagged releases.

CI:

  • Introduce a desktop release GitHub Actions workflow that builds Electron installers for Linux, macOS, and Windows on multiple architectures and uploads them to GitHub releases.

Deployment:

  • Configure Electron Builder packaging for Linux, macOS, and Windows targets, including resource bundling for the backend binary, WebUI assets, and app icons.

Documentation:

  • Document the Electron desktop build and runtime behavior in a new desktop README and link it from the main English and Chinese READMEs.

@dosubot dosubot bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Feb 7, 2026
@dosubot
Copy link

dosubot bot commented Feb 7, 2026

Related Documentation

Checked 1 published document(s) in 1 knowledge base(s). No updates required.

How did I do? Any feedback?  Join Discord

@dosubot dosubot bot added the area:webui The bug / feature is about webui(dashboard) of astrbot. label Feb 7, 2026
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 3 个问题

给 AI Agents 的提示
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `desktop/scripts/build-backend.mjs:31-35` </location>
<code_context>
+  path.join(rootDir, 'main.py'),
+];
+
+const result = spawnSync('uv', args, {
+  cwd: rootDir,
+  stdio: 'inherit',
+  shell: process.platform === 'win32',
+});
+
</code_context>

<issue_to_address>
**suggestion:** `uv` 的调用可以更清晰地报告失败原因,并在非 Windows 平台上更好地处理可执行文件缺失的情况。

目前的错误处理只覆盖到进程启动失败。在非 Windows 平台上,如果 `PATH` 中缺少 `uv`,会以通用的 `ENOENT` 错误表现出来,而不会说明是哪个命令失败。可以考虑记录 `result.error?.message`,让错误信息更清晰,同时检查 `result.status !== 0`,这样在 `uv` / PyInstaller 调用失败时(例如缺少二进制、参数不正确),就可以输出更有信息量的提示,而不是仅仅用状态码退出。

```suggestion
const result = spawnSync('uv', args, {
  cwd: rootDir,
  stdio: 'inherit',
  shell: process.platform === 'win32',
});

if (result.error) {
  // Covers failures to spawn the process (e.g. missing executable on non-Windows)
  console.error(`Failed to run 'uv': ${result.error.message}`);
  process.exitCode = typeof result.status === 'number' ? result.status : 1;
} else if (result.status !== 0) {
  // Covers non-zero exit statuses from uv/PyInstaller
  console.error(
    `'uv' exited with status ${result.status} while running PyInstaller. ` +
    'Verify that uv and PyInstaller are installed and that the arguments are valid.'
  );
  process.exitCode = result.status ?? 1;
}
```
</issue_to_address>

### Comment 2
<location> `desktop/README.md:15` </location>
<code_context>
+
+- Packaging is frontend-only.
+- Backend is not bundled in the installer/package.
+- If backend is unavailable on app startup, the app will show a startup failure dialog and exit.
+
+## Prerequisites
</code_context>

<issue_to_address>
**suggestion (typo):** 为了语句更通顺,你可能想在 "If backend is unavailable" 中加上冠词。

建议表述:"If the backend is unavailable on app startup, the app will show a startup failure dialog and exit."

```suggestion
- If the backend is unavailable on app startup, the app will show a startup failure dialog and exit.
```
</issue_to_address>

### Comment 3
<location> `desktop/README.md:56` </location>
<code_context>
+## Notes
+
+- `dist:full` currently runs WebUI build preparation + Electron packaging.
+- If you need backend built-in packaging again, you need to restore backend resource build and packaging config.
</code_context>

<issue_to_address>
**suggestion (typo):** "backend built-in packaging" 这个短语有点别扭,建议为了清晰起见进行改写。

例如:"If you need the backend packaged again, restore the backend resource build and packaging configuration."
</issue_to_address>

Sourcery 对开源项目免费 —— 如果你觉得我们的 Review 有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续的 Review。
Original comment in English

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `desktop/scripts/build-backend.mjs:31-35` </location>
<code_context>
+  path.join(rootDir, 'main.py'),
+];
+
+const result = spawnSync('uv', args, {
+  cwd: rootDir,
+  stdio: 'inherit',
+  shell: process.platform === 'win32',
+});
+
</code_context>

<issue_to_address>
**suggestion:** The `uv` invocation could report failures more clearly and handle missing executables on non-Windows platforms.

Currently the error handling only covers spawn failures, and on non‑Windows platforms a missing `uv` in `PATH` will surface as a generic `ENOENT` without indicating which command failed. Consider logging `result.error?.message` to make this clearer, and also checking `result.status !== 0` so you can emit a more informative message when the `uv`/PyInstaller invocation fails (e.g. missing binary, bad flags) instead of just exiting with the status code.

```suggestion
const result = spawnSync('uv', args, {
  cwd: rootDir,
  stdio: 'inherit',
  shell: process.platform === 'win32',
});

if (result.error) {
  // Covers failures to spawn the process (e.g. missing executable on non-Windows)
  console.error(`Failed to run 'uv': ${result.error.message}`);
  process.exitCode = typeof result.status === 'number' ? result.status : 1;
} else if (result.status !== 0) {
  // Covers non-zero exit statuses from uv/PyInstaller
  console.error(
    `'uv' exited with status ${result.status} while running PyInstaller. ` +
    'Verify that uv and PyInstaller are installed and that the arguments are valid.'
  );
  process.exitCode = result.status ?? 1;
}
```
</issue_to_address>

### Comment 2
<location> `desktop/README.md:15` </location>
<code_context>
+
+- Packaging is frontend-only.
+- Backend is not bundled in the installer/package.
+- If backend is unavailable on app startup, the app will show a startup failure dialog and exit.
+
+## Prerequisites
</code_context>

<issue_to_address>
**suggestion (typo):** You may want to add an article in "If backend is unavailable" for smoother grammar.

Suggested wording: "If the backend is unavailable on app startup, the app will show a startup failure dialog and exit."

```suggestion
- If the backend is unavailable on app startup, the app will show a startup failure dialog and exit.
```
</issue_to_address>

### Comment 3
<location> `desktop/README.md:56` </location>
<code_context>
+## Notes
+
+- `dist:full` currently runs WebUI build preparation + Electron packaging.
+- If you need backend built-in packaging again, you need to restore backend resource build and packaging config.
</code_context>

<issue_to_address>
**suggestion (typo):** The phrase "backend built-in packaging" is a bit awkward; consider rephrasing for clarity.

For example: “If you need the backend packaged again, restore the backend resource build and packaging configuration.”
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@dosubot dosubot bot added size:XXL This PR changes 1000+ lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels Feb 8, 2026
@zouyonghe
Copy link
Collaborator Author

构建方式

  • 现在桌面端统一用 pnpm(desktop/package.json:8),完整打包命令是 pnpm --dir desktop run
    dist:full(desktop/package.json:20)。
  • dist:full 流程:先拷贝 WebUI,再打包后端,再 electron-builder(desktop/package.json:18-23)。
  • 后端由 PyInstaller --onefile 生成 astrbot-backend,并额外收集:
    • pip 模块(用于运行时安装插件依赖)
    • astrbot.api 子模块(避免插件 API 导入缺失)
    • 知识库 hit_stopwords.txt
      (见 desktop/scripts/build-backend.mjs:32-39)。
  • Electron 打包时把 resources/backend、resources/webui、assets 放进安装包(desktop/package.json:32-
    45)。

运行工作流程

  • App 启动后,Electron 先检查后端可达性,不可达则自动拉起内置后端,再加载 Dashboard(desktop/
    main.js:21-33, desktop/main.js:223, desktop/main.js:247)。
  • 打包模式默认数据根目录是 ~/.astrbot,后端日志写入 ~/.astrbot/logs/backend.log(desktop/main.js:136-
    141, desktop/main.js:262-267)。
  • 后端启动时会创建 data/config、data/plugins、data/temp、data/site-packages,并把根目录与 site-
    packages 加到 sys.path(main.py:40-51)。
  • 插件安装依赖时:
    • 源码模式:sys.executable -m pip ...
    • 打包模式:改为进程内调用 pip,并安装到 ~/.astrbot/data/site-packages
      (astrbot/core/utils/pip_installer.py:73-97, astrbot/core/utils/pip_installer.py:116-128)。
  • Dashboard 点击“重启”调用 /api/stat/restart-core,重启后通过 start_time 变化检测并刷新页面
    (dashboard/src/components/shared/WaitingForRestart.vue:34-79, dashboard/src/stores/common.js:135-
    146)。

修复的问题

  • 修复打包后插件依赖安装报 unrecognized arguments: -m pip ...(根因是把 sys.executable 当 Python 解释
    器用)。
  • 修复打包后插件 ImportError: cannot import name 'filter' from astrbot.api.event / No module named
    astrbot.api.star(通过打包收集 astrbot.api)。
  • 修复知识库初始化缺少 hit_stopwords.txt。
  • 修复 Dashboard 重启等待逻辑误判(旧逻辑变量比较错误 + 基线 start_time 获取不稳定)。
  • 修复重启时报 unrecognized arguments: /.../astrbot-backend(打包可执行重启时不再重复传 argv[0],
    astrbot/core/updator.py:69-73)。
  • 提升了 pip 失败日志可见性(进程内 pip 输出会写入后端日志)。

@zouyonghe zouyonghe requested review from Dt8333 and RC-CHN February 8, 2026 09:27
@zouyonghe
Copy link
Collaborator Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 4 个问题,并给出了一些整体性的反馈:

  • PipInstaller._run_pip_in_process 中,从 logging.root.handlers 中移除所有 handler 的做法非常侵入;建议在修改前先记录快照并在结束后恢复原有 handlers(或者只移除 pip 自己添加的 handler),以避免对全局日志造成意外副作用。
  • 新的 desktop/main.js 文件体积较大,并且在同一个文件里混合了后端生命周期管理、窗口/托盘管理、IPC 绑定以及启动 UI;建议将其拆分为更聚焦的模块(例如后端管理器、UI/窗口管理器、IPC handler),以便后续改动更易理解和维护。
  • Electron shell 中托盘菜单标签、对话框信息以及启动失败信息目前是用英文/中文逻辑硬编码的,而不是通过现有的 i18n 系统;将这些文案集中管理或与 dashboard 的本地化系统集成,会提升一致性并便于未来增加其他语言。
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `PipInstaller._run_pip_in_process`, removing all handlers from `logging.root.handlers` is very invasive; consider snapshotting and restoring the original handlers (or only removing the handler(s) added by pip) to avoid surprising global logging side effects.
- The new `desktop/main.js` is quite large and mixes backend lifecycle, window/tray management, IPC wiring, and startup UI in a single file; consider splitting this into focused modules (e.g. backend manager, UI/window manager, IPC handlers) to make future changes easier to reason about.
- Tray menu labels, dialog messages, and startup failure texts in the Electron shell are hard-coded in English/Chinese logic instead of going through the existing i18n system; centralizing these strings or integrating with the dashboard’s localization would improve consistency and make future translations easier.

## Individual Comments

### Comment 1
<location> `astrbot/core/utils/pip_installer.py:116-77` </location>
<code_context>
+        await process.wait()
+        return process.returncode
+
+    async def _run_pip_in_process(self, args: list[str]) -> int:
+        pip_main = _get_pip_main()
+        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)
+
+        for handler in logging.root.handlers[:]:
+            logging.root.removeHandler(handler)
+        return result_code
</code_context>

<issue_to_address>
**issue (bug_risk):** Avoid clearing all root logging handlers in `_run_pip_in_process`.

This loop removes all handlers from the root logger every time pip runs in-process. Previously this only happened on a rare fallback path; now `_run_pip_in_process` is used broadly (including frozen runtime), so it will wipe the application’s logging configuration. Instead, only remove handlers that were added for this pip invocation (e.g., track them explicitly), or avoid modifying global logging at all and rely on the captured stdout/stderr for pip output.
</issue_to_address>

### Comment 2
<location> `desktop/main.js:23-32` </location>
<code_context>
+const defaultBackendTimeoutMs = app.isPackaged ? 0 : 20000;
</code_context>

<issue_to_address>
**issue (bug_risk):** Packaged builds can wait indefinitely for backend startup when the process is alive but never becomes reachable.

In packaged mode `defaultBackendTimeoutMs` is `0`, so `startBackendAndWait()` passes `maxWaitMs = 0` into `waitForBackend`. Because the loop only stops when `maxWaitMs > 0 && elapsed >= maxWaitMs`, a zero value means no timeout: it exits only if `pingBackend()` eventually succeeds, or if the process exits and `failOnProcessExit` is `true`.

If the backend process starts but never becomes reachable (e.g., port conflict or hung init), the process stays truthy and the loop never terminates in packaged builds, effectively hanging startup.

Recommend enforcing a finite upper bound even when `backendTimeoutMs` is `0` (e.g., substitute a default) or altering the `maxWaitMs <= 0` behavior to still impose a maximum wait.
</issue_to_address>

### Comment 3
<location> `desktop/README.md:23` </location>
<code_context>
+- Node.js available
+- `pnpm` available
+
+Desktop dependency management uses `pnpm` with lockfile:
+
+- `desktop/pnpm-lock.yaml`
</code_context>

<issue_to_address>
**nitpick (typo):** Consider adding an article before "lockfile" for more natural grammar.

For example: "Desktop dependency management uses `pnpm` with a lockfile:" or "...uses `pnpm` with a pnpm lockfile:"

```suggestion
Desktop dependency management uses `pnpm` with a lockfile:
```
</issue_to_address>

### Comment 4
<location> `desktop/main.js:43` </location>
<code_context>
+let tray = null;
+let isQuitting = false;
+let quitInProgress = false;
+let backendProcess = null;
+let backendConfig = null;
+let backendLogFd = null;
</code_context>

<issue_to_address>
**issue (complexity):** Consider splitting backend lifecycle, logging, locale handling, and startup HTML into focused modules (e.g., a BackendManager and helpers) so main.js becomes mostly simple wiring instead of a large, stateful god file.

You can reduce the mental load without changing behavior by extracting a few focused modules and encapsulating the shared state instead of globals. Here are concrete, incremental refactors that preserve functionality.

---

### 1. Encapsulate backend lifecycle into a `backendManager`

Right now `backendProcess`, `backendConfig`, `backendLogFd`, `backendLastExitReason`, `backendStartupFailureReason`, `backendSpawning`, `backendRestarting` and several helpers are global and touched from many places (including IPC). You can keep all logic but centralize it into a small module, and expose a clean API the IPC handlers and `whenReady` can call.

**Example module: `backendManager.js`**

```js
// backendManager.js
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { app } = require('electron');
const { delay, ensureDir, pingBackend, waitForProcessExit, normalizeUrl } = require('./backendUtils');
const { logElectron } = require('./electronLogger');

const backendUrl = normalizeUrl(
  process.env.ASTRBOT_BACKEND_URL || 'http://127.0.0.1:6185/',
);

class BackendManager {
  constructor({ backendTimeoutMs }) {
    this.backendTimeoutMs = backendTimeoutMs;
    this.backendProcess = null;
    this.backendConfig = null;
    this.backendLogFd = null;
    this.lastExitReason = null;
    this.startupFailureReason = null;
    this.spawning = false;
    this.restarting = false;
    this.autoStart = process.env.ASTRBOT_BACKEND_AUTO_START !== '0';
  }

  getUrl() {
    return backendUrl;
  }

  canManage() {
    if (!this.backendConfig) {
      this.backendConfig = resolveBackendConfig(); // existing logic moved here
    }
    return Boolean(this.backendConfig?.cmd);
  }

  async getState() {
    const running = await pingBackend(backendUrl);
    return {
      running,
      spawning: this.spawning,
      restarting: this.restarting,
      canManage: this.canManage(),
    };
  }

  async ensureBackend() {
    this.startupFailureReason = null;
    if (!this.backendConfig) {
      this.backendConfig = resolveBackendConfig();
    }

    const running = await pingBackend(backendUrl);
    if (running) return true;

    if (!this.autoStart || !this.backendConfig.cmd) {
      this.startupFailureReason =
        'Backend auto-start is disabled or backend command is not configured.';
      return false;
    }

    const waitResult = await this.startAndWait(this.backendTimeoutMs);
    if (!waitResult.ok) {
      this.startupFailureReason = waitResult.reason;
      return false;
    }
    return true;
  }

  async startAndWait(maxWaitMs) {
    if (!this.canManage()) {
      return { ok: false, reason: 'Backend command is not configured.' };
    }
    this.spawning = true;
    try {
      this.start();
      return await waitForBackend(backendUrl, maxWaitMs, () => !this.backendProcess, () => this.lastExitReason); // reuse existing logic
    } finally {
      this.spawning = false;
    }
  }

  start() {
    // move existing startBackend logic here, but use this.* instead of globals
  }

  async stop() {
    // move existing stopBackend logic here, but use this.* instead of globals
  }

  getStartupFailureReason() {
    return this.startupFailureReason;
  }

  getLastExitReason() {
    return this.lastExitReason;
  }
}

module.exports = { BackendManager, backendUrl };
```

**Usage in `main`**

```js
// main.js
const { BackendManager, backendUrl } = require('./backendManager');

const backendManager = new BackendManager({ backendTimeoutMs });

// IPC handlers become simple:

ipcMain.handle('astrbot-desktop:get-backend-state', async () => {
  return backendManager.getState();
});

ipcMain.handle('astrbot-desktop:restart-backend', async () => {
  if (!backendManager.canManage()) {
    return { ok: false, reason: 'Backend command is not configured.' };
  }
  if (backendManager.spawning || backendManager.restarting) {
    return { ok: false, reason: 'Backend action already in progress.' };
  }
  backendManager.restarting = true;
  try {
    await backendManager.stop();
    return await backendManager.startAndWait(backendTimeoutMs);
  } finally {
    backendManager.restarting = false;
  }
});

ipcMain.handle('astrbot-desktop:stop-backend', async () => {
  if (!backendManager.canManage()) {
    return { ok: false, reason: 'Backend command is not configured.' };
  }
  // reuse same manager methods instead of duplicating checks
  // ...
});
```

This removes duplicated state checks (`backendSpawning`/`backendRestarting`, `backendConfig` resolution) and consolidates lifecycle-oriented behavior into one place without altering semantics.

---

### 2. Move logging helpers to a dedicated module

`logElectron`, `getElectronLogPath`, and `closeBackendLogFd` logic is sprinkled around. Even a tiny `electronLogger.js` improves readability and lets backend and window logic focus on their own concerns.

**`electronLogger.js`**

```js
'use strict';
const fs = require('fs');
const path = require('path');
const { app } = require('electron');

function ensureDir(dir) {
  if (!dir) return;
  if (fs.existsSync(dir)) return;
  fs.mkdirSync(dir, { recursive: true });
}

function getElectronLogPath(rootDirOrConfig) {
  const rootDir =
    process.env.ASTRBOT_ROOT ||
    rootDirOrConfig?.rootDir ||
    app.getPath('userData');
  return path.join(rootDir, 'logs', 'electron.log');
}

function logElectron(message, { backendConfig } = {}) {
  const logPath = getElectronLogPath(backendConfig);
  ensureDir(path.dirname(logPath));
  const line = `[${new Date().toISOString()}] ${message}\n`;
  try {
    fs.appendFileSync(logPath, line, 'utf8');
  } catch {}
}

module.exports = { logElectron, getElectronLogPath, ensureDir };
```

**Usage in `main.js` / `backendManager.js`**

```js
const { logElectron, ensureDir } = require('./electronLogger');

// replace inline logElectron / ensureDir implementations
```

You can keep backend log FD management in `backendManager`, but this module keeps the electron log plumbing out of the main file.

---

### 3. Extract locale persistence into a `localeStore`

`normalizeLocale`, JSON state file reading/writing, and `persistLocaleFromDashboard` are all coherent but unrelated to tray/window/backend. Moving them out shrinks the main file and makes behavior easier to reason about.

**`localeStore.js`**

```js
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { app } = require('electron');
const { delay } = require('./backendUtils');

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 getDesktopStatePath({ backendConfig }) {
  const rootDir =
    process.env.ASTRBOT_ROOT ||
    backendConfig?.rootDir ||
    path.join(os.homedir(), '.astrbot') ||
    app.getPath('userData');
  return path.join(rootDir, 'data', 'desktop_state.json');
}

function readCachedLocale(opts) {
  const statePath = getDesktopStatePath(opts);
  try {
    const raw = fs.readFileSync(statePath, 'utf8');
    const parsed = JSON.parse(raw);
    return normalizeLocale(parsed?.locale);
  } catch {
    return null;
  }
}

function writeCachedLocale(locale, opts) {
  const normalized = normalizeLocale(locale);
  if (!normalized) return;
  const statePath = getDesktopStatePath(opts);
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
  try {
    fs.writeFileSync(
      statePath,
      `${JSON.stringify({ locale: normalized }, null, 2)}\n`,
      'utf8',
    );
  } catch {}
}

function resolveStartupLocale(opts) {
  const cached = readCachedLocale(opts);
  if (cached) return cached;
  return normalizeLocale(app.getLocale()) || 'zh-CN';
}

async function persistLocaleFromDashboard(mainWindow, backendUrl, opts, 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, opts);
    }
  } catch {}
}

module.exports = {
  normalizeLocale,
  resolveStartupLocale,
  persistLocaleFromDashboard,
};
```

**Usage in `main.js`**

```js
const { resolveStartupLocale, persistLocaleFromDashboard } = require('./localeStore');
const { backendConfig } = backendManager; // or pass backendManager directly

mainWindow.webContents.on('did-finish-load', () => {
  const currentUrl = mainWindow.webContents.getURL();
  logElectron(`did-finish-load url=${currentUrl}`);
  if (currentUrl.startsWith(backendManager.getUrl())) {
    void persistLocaleFromDashboard(mainWindow, backendManager.getUrl(), { backendConfig });
  }
});

// in loadStartupScreen
const startupLocale = resolveStartupLocale({ backendConfig });
```

This keeps all locale logic self-contained and lets you change localization behavior without touching the main process boilerplate.

---

### 4. Move the inline startup HTML into a separate helper/module

The inline template string inside `loadStartupScreen` is a major contributor to perceived size. Even if you keep the HTML as a string, moving it out will make the entry file easier to scan.

**`startupScreen.js`**

```js
'use strict';
const fs = require('fs');
const path = require('path');
const { getAssetPath } = require('./assets'); // small helper for assets
const { resolveStartupLocale } = require('./localeStore');

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.',
  };
}

async function loadStartupScreen(mainWindow, opts) {
  if (!mainWindow) return false;
  const startupLocale = resolveStartupLocale(opts);
  const startupTexts = getStartupTexts(startupLocale);

  let iconUrl = '';
  try {
    const iconBuffer = fs.readFileSync(getAssetPath('icon-no-shadow.svg'));
    iconUrl = `data:image/svg+xml;base64,${iconBuffer.toString('base64')}`;
  } catch {}

  const html = buildStartupHtml(startupTexts, iconUrl);
  const startupUrl = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
  await mainWindow.loadURL(startupUrl);
  return true;
}

function buildStartupHtml(startupTexts, iconUrl) {
  return `<!doctype html>
<html lang="en">
<head>
  <!-- existing CSS/HTML exactly as before -->
</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>`;
}

module.exports = { loadStartupScreen };
```

**Usage in `main.js`**

```js
const { loadStartupScreen } = require('./startupScreen');

app.whenReady().then(async () => {
  // ...
  await loadStartupScreen(mainWindow, { backendConfig });
  // ...
});
```

---

These steps let you:

- Keep all features and behavior.
- Remove globals from the main file by encapsulating them in `BackendManager`.
- Shrink `main.js` to mostly “wire-up” logic (Electron lifecycle + IPC + delegations).
- Localize backend lifecycle, logging, startup HTML, and locale handling to cohesive modules, directly addressing the “too many responsibilities in one file” and “global state soup” concerns without a large rewrite.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
请帮我变得更有用!请在每条评论上点击 👍 或 👎,我会根据反馈改进后续的代码审查。
Original comment in English

Hey - I've found 4 issues, and left some high level feedback:

  • In PipInstaller._run_pip_in_process, removing all handlers from logging.root.handlers is very invasive; consider snapshotting and restoring the original handlers (or only removing the handler(s) added by pip) to avoid surprising global logging side effects.
  • The new desktop/main.js is quite large and mixes backend lifecycle, window/tray management, IPC wiring, and startup UI in a single file; consider splitting this into focused modules (e.g. backend manager, UI/window manager, IPC handlers) to make future changes easier to reason about.
  • Tray menu labels, dialog messages, and startup failure texts in the Electron shell are hard-coded in English/Chinese logic instead of going through the existing i18n system; centralizing these strings or integrating with the dashboard’s localization would improve consistency and make future translations easier.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `PipInstaller._run_pip_in_process`, removing all handlers from `logging.root.handlers` is very invasive; consider snapshotting and restoring the original handlers (or only removing the handler(s) added by pip) to avoid surprising global logging side effects.
- The new `desktop/main.js` is quite large and mixes backend lifecycle, window/tray management, IPC wiring, and startup UI in a single file; consider splitting this into focused modules (e.g. backend manager, UI/window manager, IPC handlers) to make future changes easier to reason about.
- Tray menu labels, dialog messages, and startup failure texts in the Electron shell are hard-coded in English/Chinese logic instead of going through the existing i18n system; centralizing these strings or integrating with the dashboard’s localization would improve consistency and make future translations easier.

## Individual Comments

### Comment 1
<location> `astrbot/core/utils/pip_installer.py:116-77` </location>
<code_context>
+        await process.wait()
+        return process.returncode
+
+    async def _run_pip_in_process(self, args: list[str]) -> int:
+        pip_main = _get_pip_main()
+        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)
+
+        for handler in logging.root.handlers[:]:
+            logging.root.removeHandler(handler)
+        return result_code
</code_context>

<issue_to_address>
**issue (bug_risk):** Avoid clearing all root logging handlers in `_run_pip_in_process`.

This loop removes all handlers from the root logger every time pip runs in-process. Previously this only happened on a rare fallback path; now `_run_pip_in_process` is used broadly (including frozen runtime), so it will wipe the application’s logging configuration. Instead, only remove handlers that were added for this pip invocation (e.g., track them explicitly), or avoid modifying global logging at all and rely on the captured stdout/stderr for pip output.
</issue_to_address>

### Comment 2
<location> `desktop/main.js:23-32` </location>
<code_context>
+const defaultBackendTimeoutMs = app.isPackaged ? 0 : 20000;
</code_context>

<issue_to_address>
**issue (bug_risk):** Packaged builds can wait indefinitely for backend startup when the process is alive but never becomes reachable.

In packaged mode `defaultBackendTimeoutMs` is `0`, so `startBackendAndWait()` passes `maxWaitMs = 0` into `waitForBackend`. Because the loop only stops when `maxWaitMs > 0 && elapsed >= maxWaitMs`, a zero value means no timeout: it exits only if `pingBackend()` eventually succeeds, or if the process exits and `failOnProcessExit` is `true`.

If the backend process starts but never becomes reachable (e.g., port conflict or hung init), the process stays truthy and the loop never terminates in packaged builds, effectively hanging startup.

Recommend enforcing a finite upper bound even when `backendTimeoutMs` is `0` (e.g., substitute a default) or altering the `maxWaitMs <= 0` behavior to still impose a maximum wait.
</issue_to_address>

### Comment 3
<location> `desktop/README.md:23` </location>
<code_context>
+- Node.js available
+- `pnpm` available
+
+Desktop dependency management uses `pnpm` with lockfile:
+
+- `desktop/pnpm-lock.yaml`
</code_context>

<issue_to_address>
**nitpick (typo):** Consider adding an article before "lockfile" for more natural grammar.

For example: "Desktop dependency management uses `pnpm` with a lockfile:" or "...uses `pnpm` with a pnpm lockfile:"

```suggestion
Desktop dependency management uses `pnpm` with a lockfile:
```
</issue_to_address>

### Comment 4
<location> `desktop/main.js:43` </location>
<code_context>
+let tray = null;
+let isQuitting = false;
+let quitInProgress = false;
+let backendProcess = null;
+let backendConfig = null;
+let backendLogFd = null;
</code_context>

<issue_to_address>
**issue (complexity):** Consider splitting backend lifecycle, logging, locale handling, and startup HTML into focused modules (e.g., a BackendManager and helpers) so main.js becomes mostly simple wiring instead of a large, stateful god file.

You can reduce the mental load without changing behavior by extracting a few focused modules and encapsulating the shared state instead of globals. Here are concrete, incremental refactors that preserve functionality.

---

### 1. Encapsulate backend lifecycle into a `backendManager`

Right now `backendProcess`, `backendConfig`, `backendLogFd`, `backendLastExitReason`, `backendStartupFailureReason`, `backendSpawning`, `backendRestarting` and several helpers are global and touched from many places (including IPC). You can keep all logic but centralize it into a small module, and expose a clean API the IPC handlers and `whenReady` can call.

**Example module: `backendManager.js`**

```js
// backendManager.js
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { app } = require('electron');
const { delay, ensureDir, pingBackend, waitForProcessExit, normalizeUrl } = require('./backendUtils');
const { logElectron } = require('./electronLogger');

const backendUrl = normalizeUrl(
  process.env.ASTRBOT_BACKEND_URL || 'http://127.0.0.1:6185/',
);

class BackendManager {
  constructor({ backendTimeoutMs }) {
    this.backendTimeoutMs = backendTimeoutMs;
    this.backendProcess = null;
    this.backendConfig = null;
    this.backendLogFd = null;
    this.lastExitReason = null;
    this.startupFailureReason = null;
    this.spawning = false;
    this.restarting = false;
    this.autoStart = process.env.ASTRBOT_BACKEND_AUTO_START !== '0';
  }

  getUrl() {
    return backendUrl;
  }

  canManage() {
    if (!this.backendConfig) {
      this.backendConfig = resolveBackendConfig(); // existing logic moved here
    }
    return Boolean(this.backendConfig?.cmd);
  }

  async getState() {
    const running = await pingBackend(backendUrl);
    return {
      running,
      spawning: this.spawning,
      restarting: this.restarting,
      canManage: this.canManage(),
    };
  }

  async ensureBackend() {
    this.startupFailureReason = null;
    if (!this.backendConfig) {
      this.backendConfig = resolveBackendConfig();
    }

    const running = await pingBackend(backendUrl);
    if (running) return true;

    if (!this.autoStart || !this.backendConfig.cmd) {
      this.startupFailureReason =
        'Backend auto-start is disabled or backend command is not configured.';
      return false;
    }

    const waitResult = await this.startAndWait(this.backendTimeoutMs);
    if (!waitResult.ok) {
      this.startupFailureReason = waitResult.reason;
      return false;
    }
    return true;
  }

  async startAndWait(maxWaitMs) {
    if (!this.canManage()) {
      return { ok: false, reason: 'Backend command is not configured.' };
    }
    this.spawning = true;
    try {
      this.start();
      return await waitForBackend(backendUrl, maxWaitMs, () => !this.backendProcess, () => this.lastExitReason); // reuse existing logic
    } finally {
      this.spawning = false;
    }
  }

  start() {
    // move existing startBackend logic here, but use this.* instead of globals
  }

  async stop() {
    // move existing stopBackend logic here, but use this.* instead of globals
  }

  getStartupFailureReason() {
    return this.startupFailureReason;
  }

  getLastExitReason() {
    return this.lastExitReason;
  }
}

module.exports = { BackendManager, backendUrl };
```

**Usage in `main`**

```js
// main.js
const { BackendManager, backendUrl } = require('./backendManager');

const backendManager = new BackendManager({ backendTimeoutMs });

// IPC handlers become simple:

ipcMain.handle('astrbot-desktop:get-backend-state', async () => {
  return backendManager.getState();
});

ipcMain.handle('astrbot-desktop:restart-backend', async () => {
  if (!backendManager.canManage()) {
    return { ok: false, reason: 'Backend command is not configured.' };
  }
  if (backendManager.spawning || backendManager.restarting) {
    return { ok: false, reason: 'Backend action already in progress.' };
  }
  backendManager.restarting = true;
  try {
    await backendManager.stop();
    return await backendManager.startAndWait(backendTimeoutMs);
  } finally {
    backendManager.restarting = false;
  }
});

ipcMain.handle('astrbot-desktop:stop-backend', async () => {
  if (!backendManager.canManage()) {
    return { ok: false, reason: 'Backend command is not configured.' };
  }
  // reuse same manager methods instead of duplicating checks
  // ...
});
```

This removes duplicated state checks (`backendSpawning`/`backendRestarting`, `backendConfig` resolution) and consolidates lifecycle-oriented behavior into one place without altering semantics.

---

### 2. Move logging helpers to a dedicated module

`logElectron`, `getElectronLogPath`, and `closeBackendLogFd` logic is sprinkled around. Even a tiny `electronLogger.js` improves readability and lets backend and window logic focus on their own concerns.

**`electronLogger.js`**

```js
'use strict';
const fs = require('fs');
const path = require('path');
const { app } = require('electron');

function ensureDir(dir) {
  if (!dir) return;
  if (fs.existsSync(dir)) return;
  fs.mkdirSync(dir, { recursive: true });
}

function getElectronLogPath(rootDirOrConfig) {
  const rootDir =
    process.env.ASTRBOT_ROOT ||
    rootDirOrConfig?.rootDir ||
    app.getPath('userData');
  return path.join(rootDir, 'logs', 'electron.log');
}

function logElectron(message, { backendConfig } = {}) {
  const logPath = getElectronLogPath(backendConfig);
  ensureDir(path.dirname(logPath));
  const line = `[${new Date().toISOString()}] ${message}\n`;
  try {
    fs.appendFileSync(logPath, line, 'utf8');
  } catch {}
}

module.exports = { logElectron, getElectronLogPath, ensureDir };
```

**Usage in `main.js` / `backendManager.js`**

```js
const { logElectron, ensureDir } = require('./electronLogger');

// replace inline logElectron / ensureDir implementations
```

You can keep backend log FD management in `backendManager`, but this module keeps the electron log plumbing out of the main file.

---

### 3. Extract locale persistence into a `localeStore`

`normalizeLocale`, JSON state file reading/writing, and `persistLocaleFromDashboard` are all coherent but unrelated to tray/window/backend. Moving them out shrinks the main file and makes behavior easier to reason about.

**`localeStore.js`**

```js
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { app } = require('electron');
const { delay } = require('./backendUtils');

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 getDesktopStatePath({ backendConfig }) {
  const rootDir =
    process.env.ASTRBOT_ROOT ||
    backendConfig?.rootDir ||
    path.join(os.homedir(), '.astrbot') ||
    app.getPath('userData');
  return path.join(rootDir, 'data', 'desktop_state.json');
}

function readCachedLocale(opts) {
  const statePath = getDesktopStatePath(opts);
  try {
    const raw = fs.readFileSync(statePath, 'utf8');
    const parsed = JSON.parse(raw);
    return normalizeLocale(parsed?.locale);
  } catch {
    return null;
  }
}

function writeCachedLocale(locale, opts) {
  const normalized = normalizeLocale(locale);
  if (!normalized) return;
  const statePath = getDesktopStatePath(opts);
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
  try {
    fs.writeFileSync(
      statePath,
      `${JSON.stringify({ locale: normalized }, null, 2)}\n`,
      'utf8',
    );
  } catch {}
}

function resolveStartupLocale(opts) {
  const cached = readCachedLocale(opts);
  if (cached) return cached;
  return normalizeLocale(app.getLocale()) || 'zh-CN';
}

async function persistLocaleFromDashboard(mainWindow, backendUrl, opts, 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, opts);
    }
  } catch {}
}

module.exports = {
  normalizeLocale,
  resolveStartupLocale,
  persistLocaleFromDashboard,
};
```

**Usage in `main.js`**

```js
const { resolveStartupLocale, persistLocaleFromDashboard } = require('./localeStore');
const { backendConfig } = backendManager; // or pass backendManager directly

mainWindow.webContents.on('did-finish-load', () => {
  const currentUrl = mainWindow.webContents.getURL();
  logElectron(`did-finish-load url=${currentUrl}`);
  if (currentUrl.startsWith(backendManager.getUrl())) {
    void persistLocaleFromDashboard(mainWindow, backendManager.getUrl(), { backendConfig });
  }
});

// in loadStartupScreen
const startupLocale = resolveStartupLocale({ backendConfig });
```

This keeps all locale logic self-contained and lets you change localization behavior without touching the main process boilerplate.

---

### 4. Move the inline startup HTML into a separate helper/module

The inline template string inside `loadStartupScreen` is a major contributor to perceived size. Even if you keep the HTML as a string, moving it out will make the entry file easier to scan.

**`startupScreen.js`**

```js
'use strict';
const fs = require('fs');
const path = require('path');
const { getAssetPath } = require('./assets'); // small helper for assets
const { resolveStartupLocale } = require('./localeStore');

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.',
  };
}

async function loadStartupScreen(mainWindow, opts) {
  if (!mainWindow) return false;
  const startupLocale = resolveStartupLocale(opts);
  const startupTexts = getStartupTexts(startupLocale);

  let iconUrl = '';
  try {
    const iconBuffer = fs.readFileSync(getAssetPath('icon-no-shadow.svg'));
    iconUrl = `data:image/svg+xml;base64,${iconBuffer.toString('base64')}`;
  } catch {}

  const html = buildStartupHtml(startupTexts, iconUrl);
  const startupUrl = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
  await mainWindow.loadURL(startupUrl);
  return true;
}

function buildStartupHtml(startupTexts, iconUrl) {
  return `<!doctype html>
<html lang="en">
<head>
  <!-- existing CSS/HTML exactly as before -->
</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>`;
}

module.exports = { loadStartupScreen };
```

**Usage in `main.js`**

```js
const { loadStartupScreen } = require('./startupScreen');

app.whenReady().then(async () => {
  // ...
  await loadStartupScreen(mainWindow, { backendConfig });
  // ...
});
```

---

These steps let you:

- Keep all features and behavior.
- Remove globals from the main file by encapsulating them in `BackendManager`.
- Shrink `main.js` to mostly “wire-up” logic (Electron lifecycle + IPC + delegations).
- Localize backend lifecycle, logging, startup HTML, and locale handling to cohesive modules, directly addressing the “too many responsibilities in one file” and “global state soup” concerns without a large rewrite.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@zouyonghe
Copy link
Collaborator Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

嗨——我已经审查了你的改动,一切看起来都很棒!


Sourcery 对开源项目是免费的——如果你觉得我们的代码审查有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进之后的代码审查。
Original comment in English

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@Soulter Soulter changed the title 增加electron打包webui feat: supports electron app Feb 8, 2026
@dosubot dosubot bot added the lgtm This PR has been approved by a maintainer label Feb 8, 2026
@Soulter Soulter merged commit a7e5804 into AstrBotDevs:master Feb 8, 2026
10 checks passed
@Soulter Soulter changed the title feat: supports electron app feat: supports electron app on desktop Feb 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. lgtm This PR has been approved by a maintainer size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants