diff --git a/.gitignore b/.gitignore index d806ccae..790eede2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ yarn-error.log* # python cache __pycache__/ *.py[cod] + +# local helper scripts +.astrbot-reset-env.sh diff --git a/Makefile b/Makefile index 384d4f68..46d4d95a 100644 --- a/Makefile +++ b/Makefile @@ -9,14 +9,21 @@ ASTRBOT_LOCAL_DIR ?= $(VENDOR_DIR)/AstrBot-local ASTRBOT_LOCAL_DESKTOP_DIR ?= $(ASTRBOT_LOCAL_DIR)/desktop ASTRBOT_SOURCE_GIT_URL ?= https://github.com/AstrBotDevs/AstrBot.git ASTRBOT_SOURCE_GIT_REF ?= master +ASTRBOT_BUILD_SOURCE_DIR ?= +ASTRBOT_RESET_ENV_SCRIPT ?= .astrbot-reset-env.sh RUST_MANIFEST ?= src-tauri/Cargo.toml NODE_MODULES_DIR ?= node_modules PNPM_STORE_DIR ?= .pnpm-store TAURI_TARGET_DIR ?= src-tauri/target +# Single source of env keys managed by `make clean-env`. +# If build/resource scripts start consuming a new persistent env var, add it here. +ASTRBOT_ENV_KEYS := ASTRBOT_SOURCE_DIR ASTRBOT_SOURCE_GIT_URL ASTRBOT_SOURCE_GIT_REF ASTRBOT_DESKTOP_VERSION ASTRBOT_BUILD_SOURCE_DIR +# Hash of ASTRBOT_ENV_KEYS for stale reset-script detection in `make clean-env`. +ASTRBOT_ENV_KEYS_HASH := $(shell (printf '%s\n' "$(ASTRBOT_ENV_KEYS)" | shasum -a 256 2>/dev/null || printf '%s\n' "$(ASTRBOT_ENV_KEYS)" | sha256sum 2>/dev/null || printf '%s\n' "$(ASTRBOT_ENV_KEYS)" | cksum 2>/dev/null) | awk '{print $$1}' | head -n 1) .PHONY: help deps sync-version update prepare-webui prepare-backend prepare-resources dev build \ prepare rebuild lint test doctor prune size clean clean-rust clean-resources \ - clean-vendor-local clean-vendor clean-node clean-all + clean-vendor-local clean-vendor clean-node clean-env clean-all help: @echo "AstrBot Desktop Make Targets" @@ -30,6 +37,7 @@ help: @echo " make prepare-resources Prepare all resources" @echo " make dev Run Tauri dev" @echo " make build Run Tauri build" + @echo " (set ASTRBOT_SOURCE_DIR=... or ASTRBOT_BUILD_SOURCE_DIR=...)" @echo " make rebuild Clean and build" @echo " make lint Run formatting and clippy checks" @echo " make test Run Rust tests" @@ -42,6 +50,8 @@ help: @echo " make clean-vendor-local Remove vendor/AstrBot-local" @echo " make clean-vendor Remove vendor and runtime" @echo " make clean-node Remove node_modules and pnpm store" + @echo " make clean-env Generate shell script to unset build env vars" + @echo " (then source the script in current shell)" @echo " make clean Clean all build artifacts" @echo " make clean-all Alias of clean" @@ -75,12 +85,23 @@ dev: build: @set -e; \ build_version="$(ASTRBOT_DESKTOP_VERSION)"; \ + build_source_dir="$(ASTRBOT_BUILD_SOURCE_DIR)"; \ + if [ -z "$$build_source_dir" ]; then \ + build_source_dir="$(ASTRBOT_SOURCE_DIR)"; \ + fi; \ if [ -z "$$build_version" ]; then \ build_version="$$(node -e "console.log(require('./package.json').version)")"; \ fi; \ - ASTRBOT_SOURCE_GIT_URL="$(ASTRBOT_SOURCE_GIT_URL)" \ - ASTRBOT_SOURCE_GIT_REF="$(ASTRBOT_SOURCE_GIT_REF)" \ - ASTRBOT_DESKTOP_VERSION="$$build_version" \ + if [ -n "$$build_source_dir" ]; then \ + echo "Using build source dir: $$build_source_dir"; \ + fi; \ + echo "Build resource source dir: $${build_source_dir:-}"; \ + export ASTRBOT_SOURCE_GIT_URL="$(ASTRBOT_SOURCE_GIT_URL)"; \ + export ASTRBOT_SOURCE_GIT_REF="$(ASTRBOT_SOURCE_GIT_REF)"; \ + export ASTRBOT_DESKTOP_VERSION="$$build_version"; \ + if [ -n "$$build_source_dir" ]; then \ + export ASTRBOT_SOURCE_DIR="$$build_source_dir"; \ + fi; \ pnpm run build rebuild: clean build @@ -126,6 +147,31 @@ clean-vendor: clean-node: rm -rf $(NODE_MODULES_DIR) $(PNPM_STORE_DIR) +clean-env: + @set -e; \ + reset_script="$(ASTRBOT_RESET_ENV_SCRIPT)"; \ + current_hash="$(ASTRBOT_ENV_KEYS_HASH)"; \ + existing_hash=""; \ + if [ -f "$$reset_script" ]; then \ + existing_hash="$$(sed -n 's/^# ASTRBOT_ENV_KEYS_HASH=//p' "$$reset_script" | head -n 1)"; \ + fi; \ + if [ "$$existing_hash" != "$$current_hash" ]; then \ + { \ + echo "#!/usr/bin/env sh"; \ + echo "# Generated by make clean-env. Keys come from ASTRBOT_ENV_KEYS in Makefile."; \ + echo "# ASTRBOT_ENV_KEYS_HASH=$$current_hash"; \ + for key in $(ASTRBOT_ENV_KEYS); do \ + printf 'unset %s\n' "$$key"; \ + done; \ + } > "$$reset_script"; \ + chmod +x "$$reset_script"; \ + echo "Generated $$reset_script"; \ + else \ + echo "$$reset_script is up to date"; \ + fi; \ + echo "Run: source $$reset_script"; \ + echo "Note: executing $$reset_script directly runs in a child shell and cannot clear parent-shell env." + clean: clean-rust clean-resources clean-vendor clean-node clean-all: clean diff --git a/README.md b/README.md index c829cf80..c51f37e6 100644 --- a/README.md +++ b/README.md @@ -28,172 +28,76 @@ AstrBot 桌面应用(Tauri)。 ## 手动构建 -适用于需要调试桌面应用、切换上游分支或验证本地改动的场景。 -推荐优先使用 `make` 命令,仓库已封装常用流程。 - -### 1. 查看可用命令(推荐) - -仓库内置了 `Makefile`,可直接查看常用命令: - -```bash -make help -``` - -### 2. 安装依赖 +推荐直接使用 Makefile: ```bash make deps -``` - -也可以使用: - -```bash -pnpm install -``` - -### 3. 准备资源 - -```bash make prepare -``` - -也可以使用: - -```bash -pnpm run prepare:resources -``` - -### 4. 本地开发运行 - -```bash make dev -``` - -也可以使用: - -```bash -pnpm run dev -``` - -### 5. 构建安装包 - -```bash make build ``` -也可以使用: - -```bash -pnpm run build -``` - -等价命令(直接使用 Tauri CLI): +可用命令总览: ```bash -cargo tauri build +make help ``` -构建产物目录: - -- `src-tauri/target/release/bundle/` -- 若使用 `--target` 显式指定目标(例如 CI 的 macOS 构建),产物目录为 `src-tauri/target//release/bundle/` +构建产物默认在 `src-tauri/target/release/bundle/`。 ## 常用维护命令 -代码检查与测试: - ```bash make lint make test -``` - -环境排查: - -```bash make doctor -``` - -清理构建产物: - -```bash make clean -``` - -仅清理占用空间较大的本地缓存: - -```bash make prune ``` ## 版本维护(重要) -桌面端版本会同步到以下三个文件: +- `make update`:从上游同步版本(推荐日常使用)。 +- `make sync-version`:从当前解析到的 AstrBot 源同步版本(会受本地环境变量影响)。 +- `make build`:默认使用当前 `package.json` 的版本,可用 `ASTRBOT_DESKTOP_VERSION=...` 覆盖。 +桌面端版本会同步到: - `package.json` - `src-tauri/Cargo.toml` - `src-tauri/tauri.conf.json` -### `make sync-version` 与 `make update` 的区别 +### 常用环境变量 -- `make sync-version`:从当前解析到的 AstrBot 源同步版本,受本地环境变量影响(例如 `ASTRBOT_SOURCE_DIR`)。 -- `make update`:用于“对齐上游”,会忽略 `ASTRBOT_SOURCE_DIR`,并使用 `ASTRBOT_SOURCE_GIT_URL` + `ASTRBOT_SOURCE_GIT_REF` 同步版本。 - -推荐日常使用 `make update`,避免本地切换分支导致版本漂移。 - -补充:`make build` 会默认使用当前 `package.json` 中的版本作为 `ASTRBOT_DESKTOP_VERSION`,避免构建前资源准备阶段把版本回写到其他值。若需覆盖,可显式传入 `ASTRBOT_DESKTOP_VERSION=...`。 +- `ASTRBOT_SOURCE_GIT_URL` / `ASTRBOT_SOURCE_GIT_REF`:指定上游仓库与分支/标签(默认 `https://github.com/AstrBotDevs/AstrBot.git` + `master`)。 +- `ASTRBOT_SOURCE_DIR`:指定本地 AstrBot 源码目录(用于 `sync-version`/资源准备,`build` 也会读取)。 +- `ASTRBOT_BUILD_SOURCE_DIR`:仅用于本次 `make build` 的源码目录,优先级高于 `ASTRBOT_SOURCE_DIR`。 +- `ASTRBOT_DESKTOP_VERSION`:覆盖写入桌面版本号。 示例: ```bash -# 同步到上游 master make update - -# 同步到指定上游 tag make update ASTRBOT_SOURCE_GIT_REF=v4.17.5 - -# 强制写入指定版本(通常用于 CI) -make update ASTRBOT_DESKTOP_VERSION=4.17.5 -``` - -## 上游仓库策略 - -默认上游仓库: - -- `https://github.com/AstrBotDevs/AstrBot.git` - -如需覆盖默认值: - -```bash -export ASTRBOT_SOURCE_GIT_URL=https://github.com/AstrBotDevs/AstrBot.git -export ASTRBOT_SOURCE_GIT_REF=master +make build ASTRBOT_BUILD_SOURCE_DIR=/path/to/AstrBot ``` -使用本地 AstrBot 源码(优先级最高): +清理构建相关环境变量: ```bash -export ASTRBOT_SOURCE_DIR=/path/to/AstrBot +make clean-env +source .astrbot-reset-env.sh ``` -临时测试仓库示例: - -```bash -export ASTRBOT_SOURCE_GIT_URL=https://github.com/zouyonghe/AstrBot.git -export ASTRBOT_SOURCE_GIT_REF=cpython-runtime-refactor -``` ## CI 版本同步策略 -`build-desktop-tauri` 工作流在定时任务(`schedule`)检测到上游新 tag 且需要构建时,会先自动同步并提交上述三个版本文件,然后继续构建产物。 - -- 定时构建:会自动回写版本到仓库(commit + push)。 -- 手动触发(`workflow_dispatch`):默认只构建,不自动回写版本文件。 +- 定时构建(`schedule`)检测到上游新 tag 时,会先自动同步版本文件并提交,再继续构建。 +- 手动触发(`workflow_dispatch`)默认只构建,不自动回写版本文件。 ## 构建流程说明 -`src-tauri/tauri.conf.json` 已配置 `beforeBuildCommand=pnpm run prepare:resources`,构建时会自动执行以下流程: - -1. 拉取或更新 AstrBot 上游源码 -2. 构建 Dashboard 并同步 `resources/webui` -3. 下载或复用 CPython 运行时(缓存到 `runtime/`) -4. 生成 `resources/backend`(含 Python 运行时、依赖、启动脚本) -5. 调用 `cargo tauri build` 输出安装包 +`src-tauri/tauri.conf.json` 配置了 `beforeBuildCommand=pnpm run prepare:resources`。构建时会自动完成: +1. 拉取/更新 AstrBot 源码 +2. 构建并同步 `resources/webui` +3. 准备 `resources/backend`(含运行时与启动脚本) +4. 执行 Tauri 打包 diff --git a/scripts/prepare-resources.mjs b/scripts/prepare-resources.mjs index 227fcfc2..299197f9 100644 --- a/scripts/prepare-resources.mjs +++ b/scripts/prepare-resources.mjs @@ -209,155 +209,112 @@ const patchMonacoCssNestingWarnings = async (dashboardDir) => { } }; -const LEGACY_DESKTOP_BRIDGE_PATTERNS = { - trayRestartGuard: - /if\s*\(\s*!desktopBridge\?\.isElectron\s*\|\|\s*!desktopBridge\.onTrayRestartBackend\s*\)\s*\{/, - typeIsElectron: /^(\s+)isElectron:\s*boolean;(\r?\n)/m, - typeIsElectronRuntime: /^(\s+)isElectronRuntime:\s*\(\)\s*=>\s*Promise;(\r?\n)/m, - electronAppFlagToken: /\bisElectronApp\b/, - electronAppFlagReplace: /\bisElectronApp\b/g, - desktopReleaseEnvGuard: - /typeof\s+window\s*!==\s*'undefined'\s*&&\s*!!window\.astrbotDesktop\?\.isElectron/, - desktopReleaseRuntimeGuard: - /isDesktopReleaseMode\.value\s*=\s*!!window\.astrbotDesktop\?\.isElectron\s*\|\|\s*\r?\n\s*!!\(\s*await\s+window\.astrbotDesktop\?\.isElectronRuntime\?\.\(\)\s*\)\s*;/, - legacyRuntimeUsage: /window\.astrbotDesktop\?\.isElectronRuntime\?\.\(\)/, - restartGuard: /if\s*\(\s*desktopBridge\?\.isElectron\s*\)\s*\{/, +const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); +const isDesktopBridgeExpectationStrict = TRUTHY_ENV_VALUES.has( + String(process.env.ASTRBOT_DESKTOP_STRICT_BRIDGE_EXPECTATIONS || '') + .trim() + .toLowerCase(), +); + +const DESKTOP_BRIDGE_PATTERNS = { + trayRestartGuard: /if\s*\(\s*!desktopBridge\s*\?\.\s*onTrayRestartBackend\s*\)\s*\{/, + trayRestartPromptInvoke: + /await\s+globalWaitingRef\s*\.\s*value\s*\?\.\s*check\s*\?\.\s*\(\s*[^)]*\s*\)\s*;?/, + desktopRuntimeImport: + /import\s+\{\s*getDesktopRuntimeInfo\s*\}\s+from\s+['"]@\/utils\/desktopRuntime['"]\s*;?/, + desktopRuntimeUsageInRestart: + /hasDesktopRestartCapability[\s\S]*?await\s+getDesktopRuntimeInfo\s*\(\s*\)/, + desktopRuntimeUsageInHeader: + /const\s+runtimeInfo\s*=\s*await\s+getDesktopRuntimeInfo\s*\(\s*\)\s*;?[\s\S]*?isDesktopReleaseMode\.value\s*=\s*runtimeInfo\.isDesktopRuntime/, + desktopReleaseModeFlag: /\bisDesktopReleaseMode\b/, + desktopRuntimeProbeWarn: /console\.warn\([\s\S]*desktop runtime/i, }; -const MODERN_DESKTOP_BRIDGE_PATTERNS = { - trayRestartGuard: /if\s*\(\s*!desktopBridge\?\.onTrayRestartBackend\s*\)\s*\{/, - desktopBridgeTypeIsDesktop: /^\s+isDesktop:\s*boolean;\r?\n/m, - desktopBridgeTypeRuntime: /^\s+isDesktopRuntime:\s*\(\)\s*=>\s*Promise;\r?\n/m, - restartCapabilityGuard: /const hasDesktopRestartCapability\s*=/, -}; - -const patchRequiredLegacyFile = async ({ filePath, transform, patchLabel, isAlreadyModern }) => { - if (!existsSync(filePath)) { - throw new Error( - `[prepare-resources] Missing required file for ${patchLabel}: ${path.relative(projectRoot, filePath)}`, - ); - } - - const source = await readFile(filePath, 'utf8'); - const patched = transform(source); - - // Invariant: transformed content must be modern/compatible. - if (!isAlreadyModern(patched)) { - throw new Error( - `[prepare-resources] ${patchLabel} failed invariant check in ${path.relative(projectRoot, filePath)}`, - ); - } - - if (patched !== source) { - await writeFile(filePath, patched, 'utf8'); - console.log( - `[prepare-resources] Patched ${patchLabel} in ${path.relative(projectRoot, filePath)}`, - ); - return; - } - - if (!isAlreadyModern(source)) { - throw new Error( - `[prepare-resources] ${patchLabel} did not match expected legacy pattern in ${path.relative(projectRoot, filePath)}`, - ); - } - - console.warn( - `[prepare-resources] WARN: No changes applied for ${patchLabel} in ${path.relative(projectRoot, filePath)} (already compatible)`, - ); -}; - -const patchLegacyDesktopBridgeArtifacts = async (dashboardDir) => { - const hasModernTrayRestartGuard = (source) => - MODERN_DESKTOP_BRIDGE_PATTERNS.trayRestartGuard.test(source); - const hasModernDesktopBridgeTypes = (source) => - MODERN_DESKTOP_BRIDGE_PATTERNS.desktopBridgeTypeIsDesktop.test(source) && - MODERN_DESKTOP_BRIDGE_PATTERNS.desktopBridgeTypeRuntime.test(source); - const hasLegacyDesktopReleaseGuards = (source) => - LEGACY_DESKTOP_BRIDGE_PATTERNS.electronAppFlagToken.test(source) || - LEGACY_DESKTOP_BRIDGE_PATTERNS.desktopReleaseEnvGuard.test(source) || - LEGACY_DESKTOP_BRIDGE_PATTERNS.legacyRuntimeUsage.test(source); - const hasModernRestartCapabilityGuard = (source) => - MODERN_DESKTOP_BRIDGE_PATTERNS.restartCapabilityGuard.test(source); - - await patchRequiredLegacyFile({ - filePath: path.join(dashboardDir, 'src', 'App.vue'), - transform: (source) => - source.replace( - LEGACY_DESKTOP_BRIDGE_PATTERNS.trayRestartGuard, - 'if (!desktopBridge?.onTrayRestartBackend) {', - ), - patchLabel: 'tray restart desktop guard', - isAlreadyModern: hasModernTrayRestartGuard, - }); - - await patchRequiredLegacyFile({ - filePath: path.join(dashboardDir, 'src', 'types', 'electron-bridge.d.ts'), - transform: (source) => { - let patched = source; - patched = patched.replace( - LEGACY_DESKTOP_BRIDGE_PATTERNS.typeIsElectron, - '$1isDesktop: boolean;$2', - ); - patched = patched.replace( - LEGACY_DESKTOP_BRIDGE_PATTERNS.typeIsElectronRuntime, - '$1isDesktopRuntime: () => Promise;$2', - ); - return patched; - }, - patchLabel: 'desktop bridge type definitions', - isAlreadyModern: hasModernDesktopBridgeTypes, - }); - - await patchRequiredLegacyFile({ - filePath: path.join(dashboardDir, 'src', 'layouts', 'full', 'vertical-header', 'VerticalHeader.vue'), - transform: (source) => { - let patched = source.replaceAll( - LEGACY_DESKTOP_BRIDGE_PATTERNS.electronAppFlagReplace, - 'isDesktopReleaseMode', - ); - patched = patched.replace( - LEGACY_DESKTOP_BRIDGE_PATTERNS.desktopReleaseEnvGuard, - 'false', - ); - patched = patched.replace( - LEGACY_DESKTOP_BRIDGE_PATTERNS.desktopReleaseRuntimeGuard, - 'isDesktopReleaseMode.value = false;', - ); - return patched; - }, - patchLabel: 'desktop update mode guards', - isAlreadyModern: (source) => !hasLegacyDesktopReleaseGuards(source), - }); +const DESKTOP_BRIDGE_EXPECTATIONS = [ + { + filePath: ['src', 'App.vue'], + pattern: DESKTOP_BRIDGE_PATTERNS.trayRestartGuard, + label: 'tray restart desktop guard', + hint: "Expected `if (!desktopBridge?.onTrayRestartBackend) {` in App.vue.", + required: false, + }, + { + filePath: ['src', 'App.vue'], + pattern: DESKTOP_BRIDGE_PATTERNS.trayRestartPromptInvoke, + label: 'tray restart waiting prompt', + hint: 'Expected tray callback to call `globalWaitingRef.value?.check?.(...)`.', + required: false, + }, + { + filePath: ['src', 'utils', 'restartAstrBot.ts'], + pattern: DESKTOP_BRIDGE_PATTERNS.desktopRuntimeImport, + label: 'desktop runtime helper import', + hint: 'Expected `import { getDesktopRuntimeInfo } from "@/utils/desktopRuntime"`.', + required: true, + }, + { + filePath: ['src', 'utils', 'restartAstrBot.ts'], + pattern: DESKTOP_BRIDGE_PATTERNS.desktopRuntimeUsageInRestart, + label: 'desktop runtime helper usage in restart flow', + hint: 'Expected restart flow to use `hasDesktopRestartCapability` + `await getDesktopRuntimeInfo()`.', + required: true, + }, + { + filePath: ['src', 'layouts', 'full', 'vertical-header', 'VerticalHeader.vue'], + pattern: DESKTOP_BRIDGE_PATTERNS.desktopReleaseModeFlag, + label: 'desktop release mode flag', + hint: 'Expected `isDesktopReleaseMode` flag in header update UI.', + required: false, + }, + { + filePath: ['src', 'layouts', 'full', 'vertical-header', 'VerticalHeader.vue'], + pattern: DESKTOP_BRIDGE_PATTERNS.desktopRuntimeUsageInHeader, + label: 'desktop runtime helper usage in header', + hint: 'Expected header runtime probe: `const runtimeInfo = await getDesktopRuntimeInfo()`.', + required: true, + }, + { + filePath: ['src', 'utils', 'desktopRuntime.ts'], + pattern: DESKTOP_BRIDGE_PATTERNS.desktopRuntimeProbeWarn, + label: 'desktop runtime probe warning', + hint: 'Expected warning log when desktop runtime detection fails.', + required: false, + }, +]; + +const verifyDesktopBridgeArtifacts = async (dashboardDir) => { + const issues = []; + + for (const expectation of DESKTOP_BRIDGE_EXPECTATIONS) { + const mustPass = expectation.required || isDesktopBridgeExpectationStrict; + const file = path.join(dashboardDir, ...expectation.filePath); + if (!existsSync(file)) { + const relativePath = path.relative(projectRoot, file); + const message = mustPass + ? `[prepare-resources] Missing required file for ${expectation.label}: ${relativePath}` + : `[prepare-resources] Missing optional (best-effort) file for ${expectation.label}: ${relativePath}`; + if (mustPass) { + issues.push(message); + } else { + console.warn(`${message} (compatibility check skipped)`); + } + continue; + } - await patchRequiredLegacyFile({ - filePath: path.join(dashboardDir, 'src', 'utils', 'restartAstrBot.ts'), - transform: (source) => { - if (MODERN_DESKTOP_BRIDGE_PATTERNS.restartCapabilityGuard.test(source)) { - return source; + const source = await readFile(file, 'utf8'); + if (!expectation.pattern.test(source)) { + const message = `[prepare-resources] Expected ${expectation.label} in ${path.relative(projectRoot, file)}. ${expectation.hint || ''} Please sync AstrBot dashboard sources.`; + if (mustPass) { + issues.push(message); + } else { + console.warn(`${message} (compatibility check skipped)`); } - return source.replace( - LEGACY_DESKTOP_BRIDGE_PATTERNS.restartGuard, - `const hasDesktopRestartCapability = - !!desktopBridge && - typeof desktopBridge.restartBackend === 'function' && - typeof desktopBridge.isDesktopRuntime === 'function' - - let isDesktopRuntime = false - if (hasDesktopRestartCapability) { - try { - isDesktopRuntime = !!(await desktopBridge.isDesktopRuntime()) - } catch (_error) { - isDesktopRuntime = false } } - if (hasDesktopRestartCapability && isDesktopRuntime) {`, - ); - }, - patchLabel: 'desktop restart capability guard', - isAlreadyModern: hasModernRestartCapabilityGuard, - }); + if (issues.length > 0) { + throw new Error(issues.join('\n')); + } }; const readAstrbotVersionFromPyproject = async (sourceDir) => { @@ -537,7 +494,7 @@ const prepareWebui = async (sourceDir) => { const dashboardDir = path.join(sourceDir, 'dashboard'); ensurePackageInstall(dashboardDir, 'AstrBot dashboard'); await patchMonacoCssNestingWarnings(dashboardDir); - await patchLegacyDesktopBridgeArtifacts(dashboardDir); + await verifyDesktopBridgeArtifacts(dashboardDir); runPnpmChecked(['--dir', dashboardDir, 'build'], sourceDir); const sourceWebuiDir = path.join(sourceDir, 'dashboard', 'dist'); diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 00000000..132039fc --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main-capability", + "description": "Default IPC capability for the main window and loopback dashboard origin.", + "windows": ["main"], + "local": true, + "remote": { + "urls": ["http://127.0.0.1:*", "http://localhost:*"] + }, + "permissions": ["core:default"] +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fadc96bf..9a6f35ce 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -13,7 +13,7 @@ use std::{ path::{Path, PathBuf}, process::{Child, Command, ExitStatus, Stdio}, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, Arc, Mutex, OnceLock, }, thread, @@ -24,7 +24,7 @@ use tauri::{ path::BaseDirectory, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, webview::PageLoadEvent, - AppHandle, Manager, RunEvent, WindowEvent, + AppHandle, Emitter, Manager, RunEvent, WindowEvent, }; use url::Url; @@ -52,6 +52,7 @@ const TRAY_MENU_TOGGLE_WINDOW: &str = "tray_toggle_window"; const TRAY_MENU_RELOAD_WINDOW: &str = "tray_reload_window"; const TRAY_MENU_RESTART_BACKEND: &str = "tray_restart_backend"; const TRAY_MENU_QUIT: &str = "tray_quit"; +const TRAY_RESTART_BACKEND_EVENT: &str = "astrbot://tray-restart-backend"; const DEFAULT_SHELL_LOCALE: &str = "zh-CN"; const STARTUP_MODE_ENV: &str = "ASTRBOT_DESKTOP_STARTUP_MODE"; // Keep in sync with STARTUP_MODES in ui/index.html. @@ -64,6 +65,7 @@ const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; static BACKEND_PING_TIMEOUT_MS: OnceLock = OnceLock::new(); static BRIDGE_BACKEND_PING_TIMEOUT_MS: OnceLock = OnceLock::new(); static DESKTOP_LOG_WRITE_LOCK: OnceLock> = OnceLock::new(); +static TRAY_RESTART_SIGNAL_TOKEN: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone, Copy)] struct ShellTexts { @@ -1393,6 +1395,7 @@ fn handle_tray_menu_event(app_handle: &AppHandle, menu_id: &str) { TRAY_MENU_RESTART_BACKEND => { append_desktop_log("tray requested backend restart"); show_main_window(app_handle); + emit_tray_restart_backend_event(app_handle); let app_handle_cloned = app_handle.clone(); thread::spawn(move || match do_restart_backend(&app_handle_cloned, None) { @@ -1414,6 +1417,20 @@ fn handle_tray_menu_event(app_handle: &AppHandle, menu_id: &str) { } } +fn emit_tray_restart_backend_event(app_handle: &AppHandle) { + let Some(window) = app_handle.get_webview_window("main") else { + append_desktop_log("tray restart event skipped: main window not found"); + return; + }; + let token = TRAY_RESTART_SIGNAL_TOKEN.fetch_add(1, Ordering::Relaxed) + 1; + + if let Err(error) = window.emit(TRAY_RESTART_BACKEND_EVENT, token) { + append_desktop_log(&format!( + "failed to emit tray restart backend event: {error}" + )); + } +} + fn do_restart_backend(app_handle: &AppHandle, auth_token: Option<&str>) -> Result<(), String> { let state = app_handle.state::(); state.restart_backend(app_handle, auth_token) @@ -1609,17 +1626,21 @@ fn update_tray_menu_labels(app_handle: &AppHandle) { set_menu_text_safe(&tray_state.quit_item, shell_texts.tray_quit, TRAY_MENU_QUIT); } -const DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT: &str = r#" +const DESKTOP_BRIDGE_BOOTSTRAP_TEMPLATE: &str = r#" (() => { + const existingTrayRestartState = window.__astrbotDesktopTrayRestartState; if ( window.astrbotDesktop && window.astrbotDesktop.__tauriBridge === true && - typeof window.astrbotDesktop.onTrayRestartBackend === 'function' + typeof window.astrbotDesktop.onTrayRestartBackend === 'function' && + typeof existingTrayRestartState?.unlistenTrayRestartBackendEvent === 'function' ) { return; } const invoke = window.__TAURI_INTERNALS__?.invoke; + const transformCallback = window.__TAURI_INTERNALS__?.transformCallback; + const tauriEvent = window.__TAURI_INTERNALS__?.event ?? window.__TAURI__?.event; if (typeof invoke !== 'function') return; const BRIDGE_COMMANDS = Object.freeze({ @@ -1629,6 +1650,7 @@ const DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT: &str = r#" RESTART_BACKEND: 'desktop_bridge_restart_backend', STOP_BACKEND: 'desktop_bridge_stop_backend', }); + const TRAY_RESTART_BACKEND_EVENT = '{TRAY_RESTART_BACKEND_EVENT}'; const invokeBridge = async (command, payload = {}) => { try { @@ -1638,11 +1660,77 @@ const DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT: &str = r#" } }; + const createLegacyEventListener = async (eventName, handler) => { + if (typeof transformCallback !== 'function') { + throw new Error( + 'No supported Tauri event listener API: expected tauriEvent.listen or __TAURI_INTERNALS__.invoke + transformCallback' + ); + } + + let eventId; + try { + eventId = await invoke('plugin:event|listen', { + event: eventName, + target: { kind: 'Any' }, + handler: transformCallback(handler), + }); + } catch (error) { + throw new Error(`plugin:event|listen failed: ${String(error)}`); + } + + return async () => { + try { + window.__TAURI_EVENT_PLUGIN_INTERNALS__?.unregisterListener?.(eventName, eventId); + } catch {} + try { + await invoke('plugin:event|unlisten', { + event: eventName, + eventId, + }); + } catch {} + }; + }; + + const createEventListener = async (eventName, handler) => { + if (typeof tauriEvent?.listen === 'function') { + return tauriEvent.listen(eventName, handler); + } + return createLegacyEventListener(eventName, handler); + }; + const trayRestartState = window.__astrbotDesktopTrayRestartState || - (window.__astrbotDesktopTrayRestartState = { handlers: new Set(), pending: 0 }); + (window.__astrbotDesktopTrayRestartState = { + handlers: new Set(), + pending: 0, + lastToken: 0, + unlistenTrayRestartBackendEvent: null + }); + if ( + typeof trayRestartState.lastToken !== 'number' || + !Number.isFinite(trayRestartState.lastToken) + ) { + trayRestartState.lastToken = 0; + } + if (typeof trayRestartState.unlistenTrayRestartBackendEvent === 'undefined') { + trayRestartState.unlistenTrayRestartBackendEvent = null; + } + + const shouldEmitForToken = (token) => { + const numericToken = Number(token); + if (Number.isFinite(numericToken) && numericToken > 0) { + if (numericToken <= trayRestartState.lastToken) return false; + trayRestartState.lastToken = numericToken; + return true; + } else { + trayRestartState.lastToken += 1; + return true; + } + }; + + const emitTrayRestart = (token = null) => { + if (!shouldEmitForToken(token)) return; - const emitTrayRestart = () => { if (trayRestartState.handlers.size === 0) { trayRestartState.pending = Number(trayRestartState.pending || 0) + 1; return; @@ -1654,8 +1742,6 @@ const DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT: &str = r#" } }; - window.__astrbotDesktopEmitTrayRestart = emitTrayRestart; - const onTrayRestartBackend = (callback) => { if (typeof callback !== 'function') return () => {}; const handler = () => callback(); @@ -1664,7 +1750,23 @@ const DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT: &str = r#" trayRestartState.pending -= 1; handler(); } - return () => trayRestartState.handlers.delete(handler); + return () => { + trayRestartState.handlers.delete(handler); + }; + }; + + const listenToTrayRestartBackendEvent = async () => { + if (typeof trayRestartState.unlistenTrayRestartBackendEvent === 'function') return; + try { + const unlisten = await createEventListener(TRAY_RESTART_BACKEND_EVENT, (event) => { + emitTrayRestart(event?.payload); + }); + if (typeof unlisten === 'function') { + trayRestartState.unlistenTrayRestartBackendEvent = unlisten; + } + } catch (error) { + console.warn('Failed to listen for tray restart backend event', error); + } }; const getStoredAuthToken = () => { @@ -1934,11 +2036,23 @@ const DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT: &str = r#" onTrayRestartBackend, }; + void listenToTrayRestartBackendEvent(); patchLocalStorageTokenSync(); void syncAuthToken(); })(); "#; +static DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT: OnceLock = OnceLock::new(); + +fn desktop_bridge_bootstrap_script() -> &'static str { + DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT + .get_or_init(|| { + DESKTOP_BRIDGE_BOOTSTRAP_TEMPLATE + .replace("{TRAY_RESTART_BACKEND_EVENT}", TRAY_RESTART_BACKEND_EVENT) + }) + .as_str() +} + fn same_origin(left: &Url, right: &Url) -> bool { left.scheme() == right.scheme() && left.host_str() == right.host_str() @@ -1990,7 +2104,7 @@ fn should_inject_desktop_bridge(app_handle: &AppHandle, page_url: &Url) -> bool } fn inject_desktop_bridge(webview: &tauri::Webview) { - if let Err(error) = webview.eval(DESKTOP_BRIDGE_BOOTSTRAP_SCRIPT) { + if let Err(error) = webview.eval(desktop_bridge_bootstrap_script()) { append_desktop_log(&format!("failed to inject desktop bridge script: {error}")); } }