Files
AstrBot/desktop/lib/backend-manager.js
T
エイカク a7e580407c 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>
2026-02-08 21:49:54 +08:00

505 lines
13 KiB
JavaScript

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