feat: supports electron app (#4952)

* feat: add desktop wrapper with frontend-only packaging

* docs: add desktop build docs and track dashboard lockfile

* fix: track desktop lockfile for npm ci

* fix: allow custom install directory for windows installer

* chore: migrate desktop workflow to pnpm

* fix(desktop): build AppImage only on Linux

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

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

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

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

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

* ci: add desktop multi-platform release workflow

* ci: fix desktop release build and mac runner labels

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

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

* ci: normalize desktop release artifact names

* ci: exclude blockmap files from desktop release assets

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

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

* Update startup screen assets and styles

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

* Update .gitignore to include package.json

* chore: remove desktop gitkeep ignore exceptions

* docs: update desktop troubleshooting for current runtime behavior

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

---------

Co-authored-by: Soulter <905617992@qq.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
This commit is contained in:
エイカク
2026-02-08 22:49:54 +09:00
committed by GitHub
parent 8bd1565696
commit a7e580407c
29 changed files with 9854 additions and 47 deletions
+68
View File
@@ -0,0 +1,68 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const outputDir = path.join(rootDir, 'desktop', 'resources', 'backend');
const workDir = path.join(rootDir, 'desktop', 'resources', '.pyinstaller');
const dataSeparator = process.platform === 'win32' ? ';' : ':';
const kbStopwordsSrc = path.join(
rootDir,
'astrbot',
'core',
'knowledge_base',
'retrieval',
'hit_stopwords.txt',
);
const kbStopwordsDest = 'astrbot/core/knowledge_base/retrieval';
const args = [
'run',
'--with',
'pyinstaller',
'python',
'-m',
'PyInstaller',
'--noconfirm',
'--clean',
'--onefile',
'--name',
'astrbot-backend',
'--collect-all',
'aiosqlite',
'--collect-all',
'pip',
'--collect-submodules',
'astrbot.api',
'--add-data',
`${kbStopwordsSrc}${dataSeparator}${kbStopwordsDest}`,
'--distpath',
outputDir,
'--workpath',
workDir,
'--specpath',
workDir,
path.join(rootDir, 'main.py'),
];
const result = spawnSync('uv', args, {
cwd: rootDir,
stdio: 'inherit',
shell: process.platform === 'win32',
});
if (result.error) {
console.error(`Failed to run 'uv': ${result.error.message}`);
process.exit(typeof result.status === 'number' ? result.status : 1);
}
if (result.status !== 0) {
console.error(
`'uv' exited with status ${result.status} while running PyInstaller. ` +
'Verify that uv and pyinstaller are installed and that arguments are valid.',
);
process.exit(result.status ?? 1);
}
process.exit(0);
+20
View File
@@ -0,0 +1,20 @@
import { cp, mkdir, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const distDir = path.join(rootDir, 'dashboard', 'dist');
const targetDir = path.join(rootDir, 'desktop', 'resources', 'webui');
if (!existsSync(distDir)) {
console.error('dashboard/dist is missing. Run `pnpm --dir dashboard build` first.');
process.exit(1);
}
await rm(targetDir, { recursive: true, force: true });
await mkdir(targetDir, { recursive: true });
await cp(distDir, targetDir, { recursive: true });
console.log(`Copied WebUI to ${targetDir}`);
+66
View File
@@ -0,0 +1,66 @@
import { readFile, writeFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const desktopPackagePath = path.join(rootDir, 'desktop', 'package.json');
const pyprojectPath = path.join(rootDir, 'pyproject.toml');
function getGitTag() {
const result = spawnSync('git', ['describe', '--tags', '--abbrev=0'], {
cwd: rootDir,
encoding: 'utf8',
});
if (result.status === 0) {
const tag = result.stdout.trim();
return tag.length ? tag : null;
}
return null;
}
function normalizeTag(tag) {
return tag.replace(/^v/i, '');
}
async function getPyprojectVersion() {
try {
const data = await readFile(pyprojectPath, 'utf8');
const match = data.match(/^\s*version\s*=\s*"([^"]+)"/m);
return match ? match[1] : null;
} catch {
return null;
}
}
const pkgRaw = await readFile(desktopPackagePath, 'utf8');
const pkg = JSON.parse(pkgRaw);
const tag = getGitTag();
const versionFromTag = tag ? normalizeTag(tag) : null;
const versionFromPyproject = await getPyprojectVersion();
const version = versionFromPyproject || versionFromTag || pkg.version;
if (
versionFromPyproject &&
versionFromTag &&
versionFromPyproject !== versionFromTag
) {
console.log(
`Using pyproject version ${versionFromPyproject} (ignoring git tag ${versionFromTag}).`,
);
}
if (!version) {
console.warn('No version found to sync.');
process.exit(0);
}
if (pkg.version === version) {
console.log(`Desktop version already ${version}`);
process.exit(0);
}
pkg.version = version;
await writeFile(desktopPackagePath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
console.log(`Updated desktop version to ${version}`);