Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,41 +130,51 @@ backend 子系统对上提供统一的“后端可否管理、如何启动、何
### 3.3 资源与根目录解析

- `runtime_paths.rs` 负责 packaged root、workspace root 和资源路径探测。
- Tauri 资源路径支持直接资源路径和 `_up_/resources` 回退路径。
- `launch_plan.rs` 根据当前模式决定 backend cwd、root_dir 和 webui_dir。
- Tauri 资源路径支持直接资源路径和 `_up_/resources` 候选;`launch_plan.rs` 会把每个候选视为完整的 backend/WebUI 根,不跨根混用资源。
- 正式构建会把最终 `runtime-manifest.json` 的 SHA-256 编入可执行文件;打包态只接受 manifest 摘要与当前可执行文件一致的候选。
- `launch_plan.rs` 校验 Desktop/Core/WebUI 版本、manifest 路径、WebUI marker、index 和入口摘要,再决定 backend cwd、root_dir 和 webui_dir。
- packaged Core 最低要求为 `4.26.0`,因为打包态 readiness 必须通过 `/api/v1/stats/versions` 核对实际运行的 Core/code/WebUI。该限制不应用于 debug/dev 启动计划或显式外部 backend。

## 4. 主要流程

### 4.1 启动流程
### 4.1 打包资源生成与身份绑定

1. `scripts/prepare-resources.mjs all` 从同一个 AstrBot checkout 依次准备 WebUI 和 backend,避免两次任务之间 source ref 漂移。
2. `resource-identity.mjs` 要求 Core `>=4.26.0`,写入 WebUI `assets/version`,并校验 index 及其本地 JavaScript/CSS 入口。
3. `runtime-manifest.mjs` 生成 backend manifest;最终 attestation 加入 Desktop/Core/source 信息以及 WebUI marker/index/入口摘要。
4. `src-tauri/build.rs` 对最终 manifest 原始字节计算 SHA-256 并编入可执行文件;release 构建缺少 manifest 时直接失败。

### 4.2 启动流程

1. `app_runtime.rs` 初始化 Tauri 插件、窗口事件、页面加载事件和托盘。
2. `startup_task.rs` 异步解析启动计划,执行 backend readiness 检查与必要拉起。
3. backend ready 后导航主窗口;失败时进入 startup error 路径。
4. 页面加载过程中按来源策略注入 desktop bridge,并在需要时注入 startup loading mode。
2. `startup_task.rs` 异步解析启动计划;打包态从 direct / `_up_/resources` 中选取与可执行文件绑定的完整资源根,开发态仍使用独立的 dev/custom 计划。
3. backend readiness 在接受已运行或刚拉起的打包 backend 前,校验 `/api/v1/stats/versions`,并核对实际送出的 index 和 manifest 声明的入口摘要。
4. backend ready 后用 manifest 摘要生成的 `astrbot_bundle` 查询参数导航主窗口;失败时进入可见的 startup error 路径。
5. 页面加载过程中按来源策略注入 desktop bridge,并在需要时注入 startup loading mode。

### 4.2 bridge 注入与桌面交互流程
### 4.3 bridge 注入与桌面交互流程

1. `bridge/origin_policy.rs` 判断当前页面是否允许注入 desktop bridge。
2. `bridge/desktop.rs` 把 bootstrap 脚本注入 WebView。
3. WebUI 通过 `bridge/commands.rs` 调用 desktop IPC。
4. tray / window 子系统根据当前 locale 和窗口状态刷新文案与可见性。

### 4.3 更新检查/安装流程
### 4.4 更新检查/安装流程

1. `bridge/commands.rs` 先用 `bridge/updater_mode.rs` 判定当前 updater 模式。
2. `ManualDownload` / `Unsupported` 直接短路,复用 `bridge/updater_messages.rs` 和 `bridge/updater_types.rs` 返回统一结果。
3. `NativeUpdater` 路径下,`update_channel.rs` 先读缓存的 `updateChannel`,未命中时按当前版本推断通道。
4. updater manifest endpoint 优先取 `ASTRBOT_DESKTOP_UPDATER_STABLE_ENDPOINT` / `ASTRBOT_DESKTOP_UPDATER_NIGHTLY_ENDPOINT`,否则回退到 `tauri.conf.json`。
5. 版本比较仍由 `update_channel.rs` 统一控制 stable / nightly 跨通道规则。

### 4.4 重启流程
### 4.5 重启流程

1. 触发源来自 tray 菜单或 bridge IPC。
2. `restart_backend_flow.rs` 统一处理并发门禁。
3. `backend/restart.rs` 和 `backend/restart_strategy.rs` 决定 graceful 或 fallback 路径。
4. 完成后刷新 bridge / tray 侧可观察状态。

### 4.5 退出流程
### 4.6 退出流程

1. `lifecycle/events.rs` 在 `ExitRequested` 阶段先阻止直接退出。
2. `exit_state.rs` 尝试进入清理态。
Expand All @@ -179,11 +189,15 @@ backend 子系统对上提供统一的“后端可否管理、如何启动、何
- 源码仓库 URL/ref、clone/fetch/checkout。
- `scripts/prepare-resources/version-sync.mjs`
- 桌面版本同步。
- `scripts/prepare-resources/resource-identity.mjs`
- packaged Core 最低能力门禁、WebUI marker/index/入口校验,以及最终 Core/WebUI attestation。
- `scripts/prepare-resources/backend-runtime.mjs`
- CPython runtime 准备。
- `scripts/prepare-resources/mode-tasks.mjs`
- WebUI / backend 资源准备任务。
- `scripts/prepare-resources/desktop-bridge-checks.mjs`
- bridge 工件校验。
- `scripts/backend/runtime-manifest.mjs`
- backend runtime manifest 字段、相对路径和 source identity 生成规则。

当前本地和 CI 主要通过 `make lint`、`make test`、`check-rust.yml`、`check-scripts.yml` 维持这些边界。
17 changes: 12 additions & 5 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,13 @@ make prune

```bash
make update
make update ASTRBOT_SOURCE_GIT_REF=v4.17.5
make build ASTRBOT_DESKTOP_VERSION=v4.17.5
make update ASTRBOT_SOURCE_GIT_REF=v4.26.0
make build ASTRBOT_SOURCE_GIT_REF=v4.26.0 ASTRBOT_DESKTOP_VERSION=v4.26.0
make build ASTRBOT_BUILD_SOURCE_DIR=/path/to/AstrBot
```

正式打包要求 AstrBot Core `>=4.26.0`。这是 `/api/v1/stats/versions` 首次可用于 Desktop 启动期 Core/code/WebUI 身份核对的版本;更早的 Core 会在 packaged resource 准备开始时明确失败。`make dev` 的开发启动计划和显式配置的外部 backend 不使用这条 packaged identity 门禁。

如果需要清理构建相关环境变量:

```bash
Expand All @@ -118,9 +120,14 @@ beforeBuildCommand = pnpm run prepare:resources
构建时会自动完成以下步骤:

1. 拉取或更新 AstrBot 源码。
2. 构建并同步 `resources/webui`。
3. 准备 `resources/backend`(包括运行时与启动脚本)。
4. 执行 Tauri 打包。
2. 校验 packaged Core 至少为 `4.26.0`。
3. 从同一个 source checkout 构建并同步 `resources/webui`,写入 `assets/version`,校验 index 与本地 JavaScript/CSS 入口。
4. 准备 `resources/backend`(包括运行时与启动脚本),生成包含 Desktop/Core/source identity 的 `runtime-manifest.json`。
5. 把 WebUI version/index/入口摘要写入最终 manifest,并再次校验整套资源。
6. `src-tauri/build.rs` 把最终 manifest 的 SHA-256 编入可执行文件;release 构建缺少 manifest 时失败。
7. 执行 Tauri 打包。

运行时不会把 direct 与 `_up_/resources` 下的 backend/WebUI 混用。打包启动计划只接受 manifest 摘要与当前可执行文件一致的完整资源根,并在导航前核对运行中 backend 的公开版本、served index 和入口资产。主窗口 URL 使用该 manifest 摘要作为 `astrbot_bundle` 缓存身份。

补充说明:主窗口当前显式设置了 `backgroundThrottling = "disabled"`,用于缓解 macOS 上窗口隐藏或转入后台后 `WKWebView` 被系统节流/挂起导致的前端假死问题。根据当前 Tauri 2 配置能力,该选项在 macOS 14+ 上生效;更早版本的 macOS 会回退到系统默认后台策略。

Expand Down
2 changes: 1 addition & 1 deletion docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
| `ASTRBOT_BRIDGE_BACKEND_PING_TIMEOUT_MS` | 桥接层 ping 超时 | 默认回退到 `ASTRBOT_BACKEND_PING_TIMEOUT_MS` |
| `ASTRBOT_BACKEND_CMD` | 后端启动命令覆盖 | 未设置则按 launch plan 推导 |
| `ASTRBOT_BACKEND_CWD` | 后端工作目录覆盖 | 未设置则按 launch plan 推导 |
| `ASTRBOT_WEBUI_DIR` | WebUI 目录覆盖 | 未设置则按资源目录推导 |
| `ASTRBOT_WEBUI_DIR` | 自定义/开发启动时的 WebUI 目录覆盖 | 打包版忽略该变量,以保证 Core 与 WebUI 来自同一已校验资源包 |
| `ASTRBOT_ROOT` | AstrBot 根目录 | 未设置则按打包/临时目录回退 |
| `ASTRBOT_DASHBOARD_HOST` | 后端读取的 dashboard host 变量 | 若 `DASHBOARD_HOST` 与本变量都未设置,打包态桌面默认写入 `DASHBOARD_HOST=127.0.0.1` |
| `ASTRBOT_DASHBOARD_PORT` | 后端读取的 dashboard port 变量 | 若 `DASHBOARD_PORT` 与本变量都未设置,打包态桌面默认写入 `DASHBOARD_PORT=6185` |
Expand Down
13 changes: 11 additions & 2 deletions docs/repository-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
- `backend/runtime.rs`
- backend 运行时参数(timeout/readiness/ping)解析与缓存。
- `backend/readiness.rs`
- backend 就绪探测、等待轮询与超时日志收敛
- backend 就绪探测、等待轮询、打包态 live Core/WebUI identity 校验与超时日志收敛
- `backend/restart.rs`
- backend restart token 管理、graceful/fallback 策略与 bridge 状态组装。
- `backend/restart_strategy.rs`
Expand Down Expand Up @@ -115,7 +115,7 @@
- `restart_backend_flow.rs`
- backend 重启任务与并发判定流程封装。
- `launch_plan.rs`
- custom/packaged/dev 启动计划构建与路径解析
- custom/packaged/dev 启动计划构建;打包态完整资源候选选择、manifest 绑定和 WebUI 摘要校验
- `startup_task.rs`
- 启动阶段后端就绪等待与主线程导航分发。
- `app_runtime.rs`
Expand Down Expand Up @@ -143,9 +143,18 @@
- `webui/backend/all` 任务实现。
- `desktop-bridge-checks.mjs`
- bridge 相关校验。
- `resource-identity.mjs`
- packaged Core 最低版本门禁、WebUI marker/index/入口校验,以及最终 Core/WebUI bundle attestation。
- `*.test.mjs`
- Node 行为测试。

`scripts/backend/` 中与资源身份直接相关的模块:

- `runtime-manifest.mjs`
- 生成 backend runtime manifest,规范 runtime 相对路径、Desktop/Core 版本和 source ref/commit 字段。

正式 `prepare:resources` 会按 WebUI -> backend -> 最终 attestation 的顺序运行;`src-tauri/build.rs` 再把最终 manifest 摘要编入可执行文件。`version` 单独模式以及 Rust 的 debug/dev/custom external backend 路径不使用 packaged identity 门禁。

## 4. 文档组织(`docs/`)

- `architecture.md`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"test:prepare-resources": "node --test \"scripts/**/*.test.mjs\"",
"prepare:webui": "node scripts/prepare-resources.mjs webui",
"prepare:backend": "node scripts/prepare-resources.mjs backend",
"prepare:resources": "pnpm run prepare:webui && pnpm run prepare:backend",
"prepare:resources": "node scripts/prepare-resources.mjs all",
"dev": "tauri dev",
"build": "tauri build"
},
Expand Down
33 changes: 28 additions & 5 deletions scripts/backend/build-backend.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from './runtime-linux-compat-utils.mjs';
import { isWindowsArm64BundledRuntime } from './runtime-arch-utils.mjs';
import { generateRuntimeCoreLock } from './runtime-core-lock.mjs';
import { createRuntimeManifest } from './runtime-manifest.mjs';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..', '..');
Expand All @@ -40,6 +41,9 @@ const runtimeSource =
process.env.ASTRBOT_DESKTOP_BACKEND_RUNTIME ||
process.env.ASTRBOT_DESKTOP_CPYTHON_HOME;
const requirePipProbe = process.env.ASTRBOT_DESKTOP_REQUIRE_PIP === '1';
const desktopVersionOverride = process.env.ASTRBOT_DESKTOP_VERSION || '';
const sourceRef = process.env.ASTRBOT_SOURCE_GIT_REF || '';
const sourceCommit = process.env.ASTRBOT_SOURCE_GIT_COMMIT || '';

const requiredSourceEntries = ['astrbot', 'main.py', 'requirements.txt'];
const optionalSourceEntries = ['changelogs'];
Expand Down Expand Up @@ -449,13 +453,32 @@ const writeLauncherScript = () => {
fs.writeFileSync(launcherPath, content, 'utf8');
};

const writeRuntimeManifest = (runtimePython) => {
const manifest = {
mode: 'cpython-runtime',
const readCoreVersion = (resolvedSourceDir) => {
const explicitVersion = String(process.env.ASTRBOT_CORE_VERSION || '').trim();
if (explicitVersion) {
return explicitVersion;
}

const pyprojectPath = path.join(resolvedSourceDir, 'pyproject.toml');
const content = fs.readFileSync(pyprojectPath, 'utf8');
const match = /^version\s*=\s*["']([^"']+)["']/m.exec(content);
if (!match) {
throw new Error(`Cannot resolve AstrBot Core version from ${pyprojectPath}.`);
}
return match[1];
};

const writeRuntimeManifest = (runtimePython, resolvedSourceDir) => {
const coreVersion = readCoreVersion(resolvedSourceDir);
const manifest = createRuntimeManifest({
python: runtimePython.relative,
entrypoint: path.basename(launcherPath),
app: path.relative(outputDir, appDir),
};
desktopVersion: desktopVersionOverride || coreVersion,
coreVersion,
sourceRef,
sourceCommit,
});
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
};

Expand Down Expand Up @@ -705,7 +728,7 @@ const main = () => {
pruneLinuxTkinterRuntime(runtimeDir);
patchLinuxRuntimeRpaths(runtimeDir);
writeLauncherScript();
writeRuntimeManifest(runtimePython);
writeRuntimeManifest(runtimePython, resolvedSourceDir);

console.log(`Prepared CPython backend runtime in ${outputDir}`);
console.log(`Runtime source: ${runtimeSourceReal}`);
Expand Down
50 changes: 50 additions & 0 deletions scripts/backend/runtime-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import path from 'node:path';

const requiredString = (value, field) => {
const normalized = typeof value === 'string' ? value.trim() : '';
if (!normalized) {
throw new Error(`Backend runtime manifest field ${field} must not be empty.`);
}
return normalized;
};

export const requiredRuntimeRelativePath = (value, field) => {
const normalized = requiredString(value, field);
const portablePath = normalized.replaceAll('\\', '/');
const segments = portablePath.split('/');
if (
normalized.includes('\0') ||
path.posix.isAbsolute(portablePath) ||
path.win32.parse(normalized).root ||
segments.some((segment) => !segment || segment === '.' || segment === '..')
) {
throw new Error(
`Backend runtime manifest field ${field} must be a canonical relative path inside the backend directory.`,
);
}
return normalized;
};

const optionalString = (value) => {
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || null;
};

export const createRuntimeManifest = ({
python,
entrypoint,
app,
desktopVersion,
coreVersion,
sourceRef,
sourceCommit,
}) => ({
mode: 'cpython-runtime',
python: requiredRuntimeRelativePath(python, 'python'),
entrypoint: requiredRuntimeRelativePath(entrypoint, 'entrypoint'),
app: requiredString(app, 'app'),
desktopVersion: requiredString(desktopVersion, 'desktopVersion').replace(/^v/i, ''),
coreVersion: requiredString(coreVersion, 'coreVersion').replace(/^v/i, ''),
sourceRef: optionalString(sourceRef),
sourceCommit: optionalString(sourceCommit),
});
75 changes: 75 additions & 0 deletions scripts/backend/runtime-manifest.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { createRuntimeManifest } from './runtime-manifest.mjs';

test('createRuntimeManifest records Core source identity', () => {
const manifest = createRuntimeManifest({
python: 'python/bin/python3',
entrypoint: 'launch_backend.py',
app: 'app',
desktopVersion: 'v4.27.4',
coreVersion: 'v4.27.4',
sourceRef: 'v4.27.4',
sourceCommit: 'a'.repeat(40),
});

assert.deepEqual(manifest, {
mode: 'cpython-runtime',
python: 'python/bin/python3',
entrypoint: 'launch_backend.py',
app: 'app',
desktopVersion: '4.27.4',
coreVersion: '4.27.4',
sourceRef: 'v4.27.4',
sourceCommit: 'a'.repeat(40),
});
});

test('createRuntimeManifest keeps optional source identity explicit', () => {
const manifest = createRuntimeManifest({
python: 'python/bin/python3',
entrypoint: 'launch_backend.py',
app: 'app',
desktopVersion: '4.27.4-nightly.20260901.abcdef12',
coreVersion: '4.27.4',
});

assert.equal(manifest.sourceRef, null);
assert.equal(manifest.sourceCommit, null);
});

test('createRuntimeManifest requires a Core version', () => {
assert.throws(
() =>
createRuntimeManifest({
python: 'python/bin/python3',
entrypoint: 'launch_backend.py',
app: 'app',
desktopVersion: '4.27.4',
coreVersion: '',
}),
/coreVersion must not be empty/,
);
});

test('createRuntimeManifest rejects backend paths that escape the bundle', () => {
for (const [field, value] of [
['python', '../python.exe'],
['python', 'C:\\outside\\python.exe'],
['entrypoint', '/tmp/launch_backend.py'],
['entrypoint', 'scripts/../launch_backend.py'],
]) {
assert.throws(
() =>
createRuntimeManifest({
python: field === 'python' ? value : 'python/bin/python3',
entrypoint: field === 'entrypoint' ? value : 'launch_backend.py',
app: 'app',
desktopVersion: '4.27.4',
coreVersion: '4.27.4',
}),
new RegExp(`${field} must be a canonical relative path`),
);
}
});
Loading