diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml
index 4c30711..0f66c06 100644
--- a/.github/workflows/installers.yml
+++ b/.github/workflows/installers.yml
@@ -165,8 +165,37 @@ jobs:
compression-level: 0
retention-days: 3
+ linux-tarballs:
+ needs: verify
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: ubuntu-latest
+ arch: x64
+ - runner: ubuntu-24.04-arm
+ arch: arm64
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: npm
+ - run: npm ci
+ - name: Build and verify Linux tar.gz
+ run: npm run desktop:linux:tarball -- --${{ matrix.arch }}
+ - uses: actions/upload-artifact@v7
+ with:
+ name: botcf-agent-linux-${{ matrix.arch }}
+ path: out/installers/botcf-agent-*-linux-${{ matrix.arch }}.tar.gz
+ if-no-files-found: error
+ compression-level: 0
+ retention-days: 3
+
publish:
- needs: [verify, macos-arm64, windows-x64]
+ needs: [verify, macos-arm64, windows-x64, linux-tarballs]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
diff --git a/README.md b/README.md
index 3b46103..429691a 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
@@ -32,7 +32,7 @@ BOTCF的 Agent 主引擎已经替换为 DeepSeek Harness(DSH)。开发服务
## 第一次使用
-1. 从本项目的 Releases 页面下载 macOS Apple 芯片版或 Windows x64 安装包。
+1. 从本项目的 Releases 页面下载 macOS Apple 芯片版、Windows x64 安装包,或与机器架构匹配的 Linux tar.gz。
2. 启动 BOTCF Agent,并在登录页输入 BOTCF 账号。
3. 登录成功后,应用会自动补齐专属兜底 Key,并同步当前 Key 的模型与思考能力;默认使用 `🚀codex-pro`,也可以随时切换到其他 Key,前台模型列表会随之替换。
@@ -95,7 +95,7 @@ DSH 模型选择器采用三级来源解析思考等级:先读取 New API `/v1
## 桌面安装包
-公开发布提供两个原生安装包:macOS Apple 芯片版(DMG)与 Windows x64 版(NSIS EXE)。两者都由 GitHub Actions 在对应系统的原生 Runner 上构建,避免原生模块交叉编译导致不兼容。应用内更新还会使用 macOS ZIP、blockmap 与 `latest*.yml` 元数据。
+公开发布提供 macOS Apple 芯片版(DMG)、Windows x64 版(NSIS EXE)和 Linux x64/arm64 目录压缩包。Linux 版本由 Linux 原生环境生成 `tar.gz`,解压后使用包内 `install.sh` 安装,不依赖 `deb` 或 AppImage;所有平台都应在目标平台构建,避免原生模块交叉编译导致不兼容。
Windows 的 Sharp、Koffi、SWC、ripgrep、Node 内建加载器和 node-pty 都包含平台原生文件,并随包携带经过 SHA-256 固定校验的官方 MinGit,版本中心不依赖系统 `PATH` 或用户另行安装 Git。发布校验不仅检查 npm 依赖清单和 Windows x64 PE 文件,还会通过打包后的 Electron 实际启动 ripgrep,防止 `app.asar` 虚拟路径被误传给子进程;完整操作和故障案例见[桌面打包与应用内更新手册](docs/desktop-release-playbook.md)。
@@ -105,8 +105,26 @@ npm run desktop:installer -- --mac dmg zip --arm64
# Windows x64
npm run desktop:installer -- --win nsis --x64
+
+# Linux x64(解压后执行 install.sh)
+npm run desktop:linux:tarball -- --x64
+
+# Linux arm64
+npm run desktop:linux:tarball -- --arm64
+```
+
+Linux 压缩包默认支持用户级安装:
+
+```bash
+tar -xzf out/installers/botcf-agent-X.Y.Z-linux-x64.tar.gz
+cd botcf-agent-X.Y.Z-linux-x64
+./install.sh
```
+需要安装到 `/opt` 并创建系统级桌面入口时使用 `sudo ./install.sh --system`。Linux 版本仍需要兼容的 glibc、Electron 图形库、Bash 和 Git;应用会在启动前动态选择可用的 Secret Service 或 KWallet 后端。相关服务都不可用时,自动登录和 SSH 凭据只能保持在当前进程中。Linux 当前不启用应用内更新,升级时重新下载并执行安装脚本即可。
+
+卸载用户级安装运行 `./uninstall.sh`;卸载系统级安装运行 `sudo ./uninstall.sh --system`。卸载默认保留登录凭据、用户配置和 DSH 数据。
+
发布流水线优先使用 Apple Developer ID、公证凭据和 Windows Authenticode 证书;仓库未配置商业证书时,macOS 改用固定 requirement 的 ad-hoc 测试签名,Windows 生成未签名测试安装包,并在发布说明中明确风险。发布页同时提供 SHA-256 校验值用于核对文件完整性。签名证书和密码只允许通过 CI Secret 注入,禁止提交到仓库。
### 软件更新
@@ -185,7 +203,7 @@ npm run lint
- 服务容灾:按配置的 BOTCF 地址依次尝试,网络故障时自动切换备用地址,凭据错误不会盲目重试。
- 请求优化:只读取当前选中 Token 的密钥;响应设有超时和体积限制,避免无关请求、慢请求或异常大响应占用进程。
- 并发登录:登录请求按顺序执行并带版本标识,新请求不会复用旧请求的结果。
-- 自动登录:优先用 Electron `safeStorage` 加密密码;系统安全存储不可用时仅在当前进程内保留,不向磁盘写入明文。
+- 自动登录:优先用 Electron `safeStorage` 加密密码;Linux 启动时按桌面环境和 D-Bus 服务动态选择 `gnome-libsecret` 或 `kwallet5/6`,系统安全存储不可用时仅在当前进程内保留,不向磁盘写入明文。
- 会话校验:登录后定期检查账号状态;短时网络波动保留当前会话,连续失败达到阈值后才清理模型路由并返回登录页。
- Token 管理:内置控制台支持创建、编辑、启停、删除和切换当前 Token;前台只展示当前 Token 可用的模型。
- 模型能力:优先使用远端元数据,结合兼容规则和用户手动覆盖生成推理等级;上下文窗口切换后会重新同步现有 BOTCF 路由。
diff --git a/desktop/dsh-runtime.mjs b/desktop/dsh-runtime.mjs
index 7123c8c..b0fe4a6 100644
--- a/desktop/dsh-runtime.mjs
+++ b/desktop/dsh-runtime.mjs
@@ -60,6 +60,7 @@ export function startDshRuntime({
"127.0.0.1",
"--port",
"0",
+ "--no-open",
];
const child = spawn(nodeExecutable, arguments_, {
cwd: workspace,
diff --git a/desktop/linux-password-store.mjs b/desktop/linux-password-store.mjs
new file mode 100644
index 0000000..e6b6b7c
--- /dev/null
+++ b/desktop/linux-password-store.mjs
@@ -0,0 +1,84 @@
+import { execFileSync } from "node:child_process";
+
+const SERVICE_BACKENDS = Object.freeze([
+ Object.freeze({ backend: "kwallet6", service: "org.kde.kwalletd6" }),
+ Object.freeze({ backend: "kwallet5", service: "org.kde.kwalletd5" }),
+ Object.freeze({ backend: "kwallet", service: "org.kde.kwalletd" }),
+ Object.freeze({ backend: "gnome-libsecret", service: "org.freedesktop.secrets" }),
+]);
+
+function desktopValues(env) {
+ return [
+ env.XDG_CURRENT_DESKTOP,
+ env.XDG_SESSION_DESKTOP,
+ env.DESKTOP_SESSION,
+ ]
+ .filter((value) => typeof value === "string")
+ .flatMap((value) => value.split(/[:;,]/u))
+ .map((value) => value.trim().toLowerCase())
+ .filter(Boolean);
+}
+
+function defaultServiceProbe(service, env = process.env) {
+ if (!env.DBUS_SESSION_BUS_ADDRESS) return false;
+ try {
+ execFileSync("busctl", ["--user", "status", service], {
+ stdio: "ignore",
+ timeout: 1_200,
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function kdeBackends(env, values) {
+ const version = String(env.KDE_SESSION_VERSION || "").trim();
+ if (version === "6" || values.some((value) => value === "kde6" || value === "plasma6")) {
+ return ["kwallet6", "kwallet5", "kwallet"];
+ }
+ if (version === "5" || values.some((value) => value === "kde5" || value === "plasma5")) {
+ return ["kwallet5", "kwallet6", "kwallet"];
+ }
+ return ["kwallet6", "kwallet5", "kwallet"];
+}
+
+function serviceForBackend(backend) {
+ return SERVICE_BACKENDS.find((entry) => entry.backend === backend)?.service;
+}
+
+/**
+ * Select a usable Chromium password-store backend before Electron emits ready.
+ * An empty result intentionally leaves Electron's own platform heuristic intact.
+ */
+export function detectLinuxPasswordStore({
+ env = process.env,
+ probeService = defaultServiceProbe,
+} = {}) {
+ const probe = probeService === defaultServiceProbe
+ ? (service) => defaultServiceProbe(service, env)
+ : probeService;
+ const values = desktopValues(env);
+ const isKde = values.some((value) => value === "kde" || value.startsWith("plasma")) ||
+ /^(1|true|yes)$/iu.test(String(env.KDE_FULL_SESSION || "")) ||
+ Boolean(env.KDE_SESSION_VERSION);
+
+ if (isKde) {
+ for (const backend of kdeBackends(env, values)) {
+ const service = serviceForBackend(backend);
+ if (service && probe(service)) return backend;
+ }
+ }
+
+ const secretService = serviceForBackend("gnome-libsecret");
+ if (secretService && probe(secretService)) return "gnome-libsecret";
+
+ if (!isKde) {
+ for (const backend of ["kwallet6", "kwallet5", "kwallet"]) {
+ const service = serviceForBackend(backend);
+ if (service && probe(service)) return backend;
+ }
+ }
+
+ return "";
+}
diff --git a/desktop/main.mjs b/desktop/main.mjs
index 4ad4ad0..9cf302a 100644
--- a/desktop/main.mjs
+++ b/desktop/main.mjs
@@ -15,6 +15,7 @@ import {
} from "electron";
import { startDshRuntime } from "./dsh-runtime.mjs";
import { DEFAULT_NEWAPI_ROUTES } from "./domains/auth.mjs";
+import { detectLinuxPasswordStore } from "./linux-password-store.mjs";
import { bundledPluginMode } from "./local-plugins.mjs";
import {
clearPendingPlugin,
@@ -35,6 +36,13 @@ import {
const productName = app.isPackaged ? "BOTCF" : "BOTCF(开发版)";
+if (process.platform === "linux") {
+ const passwordStore = detectLinuxPasswordStore();
+ if (passwordStore) {
+ app.commandLine.appendSwitch("password-store", passwordStore);
+ }
+}
+
app.setName(productName);
const isolatedUserData = resolveLaobosUserDataPath(
app.getPath("appData"),
diff --git a/docs/desktop-release-playbook.md b/docs/desktop-release-playbook.md
index ebd2a75..8cd39c2 100644
--- a/docs/desktop-release-playbook.md
+++ b/docs/desktop-release-playbook.md
@@ -10,6 +10,7 @@ BOTCF 的源码、安装包与自动更新元数据统一发布在公开仓库 `
| --- | --- | --- | --- |
| macOS ARM64 | DMG | ZIP + blockmap | `latest-mac.yml` |
| Windows x64 | NSIS Setup EXE | EXE + blockmap | `latest.yml` |
+| Linux x64/arm64 | tar.gz + `install.sh` | 重新安装 tar.gz | 无,校验 SHA-256 |
Release 公开前必须保证元数据引用的文件已经完整上传。
@@ -24,6 +25,10 @@ npm run desktop:installer:test-update -- --mac dmg zip --arm64
# Windows x64
npm run desktop:installer -- --win nsis --x64
+
+# Linux,必须分别在目标架构的原生 Linux 环境执行
+npm run desktop:linux:tarball -- --x64
+npm run desktop:linux:tarball -- --arm64
```
正式发布优先使用 GitHub Actions 的 `macos-15` 与 `windows-latest` 原生 Runner。跨平台构建只能用于受控测试,而且必须显式准备目标平台的 optional dependencies。
@@ -41,6 +46,9 @@ botcf-agent-X.Y.Z-windows-x64-setup.exe
botcf-agent-X.Y.Z-windows-x64-setup.exe.blockmap
latest.yml
+botcf-agent-X.Y.Z-linux-x64.tar.gz
+botcf-agent-X.Y.Z-linux-arm64.tar.gz
+
SHA256SUMS
```
@@ -59,7 +67,7 @@ shasum -a 256 out/installers/*
Windows 专项验证当前检查 10 个 PE x64 文件,覆盖内置 MinGit、Koffi、Sharp/libvips、Next SWC、ripgrep、Node 内建加载器,以及 node-pty 的 ConPTY 模块、进程列表模块和兼容 DLL。只统计 npm 包数量不能证明原生运行时完整。
-原生文件“存在”也不等于“可启动”。`verify-packaged-executables.mjs` 使用打包后的 Electron 作为 Node 运行时,从 `app.asar` 加载应用依赖,取得其实际返回的二进制路径并执行 `rg --version`。macOS 与 Windows 的正式工作流都必须运行该校验。
+原生文件“存在”也不等于“可启动”。`verify-packaged-executables.mjs` 使用打包后的 Electron 作为 Node 运行时,从 `app.asar` 加载应用依赖,取得其实际返回的二进制路径并执行 `rg --version`;Linux 还校验 Landlock 启动器的物理路径。macOS、Windows 与 Linux 的正式工作流都必须运行该校验。
## 4. macOS 更新记录
diff --git a/docs/project-architecture.md b/docs/project-architecture.md
index 675406c..a9a1949 100644
--- a/docs/project-architecture.md
+++ b/docs/project-architecture.md
@@ -70,6 +70,12 @@ Windows 生产数据默认位于:
%APPDATA%\BOTCF
```
+Linux 生产数据默认位于:
+
+```text
+~/.config/botcf-dsh-agent
+```
+
DSH Home 位于应用数据目录下的 `dsh`。软件更新缓存由 electron-updater 管理:
```text
@@ -84,9 +90,9 @@ Windows: %LOCALAPPDATA%\laobos-agent-studio-updater\pending
- DSH 只监听本机回环地址和随机端口。
- Electron 默认拒绝网页权限请求。
- 文件接口执行工作区边界、真实路径、符号链接和敏感文件检查。
-- 密码、SSH 私钥和自动登录凭据优先由 `safeStorage` 加密;当系统安全存储暂不可用时,自动登录凭据只保留在当前进程内存中,退出应用即清除,不写入磁盘。
+- 密码、SSH 私钥和自动登录凭据优先由 `safeStorage` 加密;Linux 启动时按桌面环境和 D-Bus 服务动态选择 Secret Service 或 KWallet,系统安全存储暂不可用时,自动登录凭据只保留在当前进程内存中,退出应用即清除,不写入磁盘。
- 更新器使用固定 GitHub Release 仓库,渲染器不能传入任意 URL 或安装路径。
-- 正式 macOS 包需要 Developer ID 与公证;正式 Windows 包需要 Authenticode。
+- 正式 macOS 包需要 Developer ID 与公证;正式 Windows 包需要 Authenticode;Linux tar.gz 通过 SHA-256 校验清单验证完整性。
- ad-hoc/未签名构建只用于受控测试,不代表发布者身份。
## 开发与验证
diff --git a/electron-builder.yml b/electron-builder.yml
index 38e78e6..29cce90 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -3,6 +3,11 @@ productName: BOTCF
asar: true
asarUnpack:
- "packages/**"
+ - "node_modules/@deepseek-ai/node-addon-landlock-run/**"
+ - "node_modules/@img/sharp-linux-*/**"
+ - "node_modules/@img/sharp-libvips-linux-*/**"
+ - "node_modules/@vscode/ripgrep/**"
+ - "node_modules/node-pty/**"
compression: normal
npmRebuild: false
electronUpdaterCompatibility: ">=2.16"
@@ -51,6 +56,17 @@ mac:
- arm64
artifactName: "botcf-agent-${version}-macos-${arch}.${ext}"
+linux:
+ icon: public/botcf-logo.png
+ category: Development
+ executableName: botcf-agent
+ target:
+ - target: dir
+ arch:
+ - x64
+ - arm64
+ artifactName: "botcf-agent-${version}-linux-${arch}.${ext}"
+
win:
icon: build/botcf-icon.ico
executableName: botcf-agent
diff --git a/package.json b/package.json
index f8210c7..0ba8ac5 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"desktop:package": "npm run build:desktop-plugins && node scripts/package-desktop.mjs",
"desktop:make": "npm run build:desktop-plugins && node scripts/make-desktop.mjs",
"desktop:installer": "npm run build:desktop-plugins && electron-builder --publish never",
+ "desktop:linux:tarball": "npm run build:desktop-plugins && node scripts/package-linux-tarball.mjs",
"desktop:installer:test-update": "npm run build:desktop-plugins && electron-builder --config electron-builder.test-update.yml --publish never",
"site:dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
"site:start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
diff --git a/packages/laobos-system-tools/lib/client.js b/packages/laobos-system-tools/lib/client.js
index 99d6733..985bb17 100644
--- a/packages/laobos-system-tools/lib/client.js
+++ b/packages/laobos-system-tools/lib/client.js
@@ -133,6 +133,8 @@ window.__ModuleLoader__.load({
@media(max-width:720px){.lbs-grid,.lbs-split{grid-template-columns:1fr}.lbs-panel{min-height:auto}}
/* ── 设置面板平坦化:去遮罩、去圆角,平坦覆盖中央设置区(左右止于项目栏) ── */
.VOzbGW_overlay{z-index:2147483000;justify-content:flex-start;align-items:stretch;left:var(--lbs-left-column,0px);right:var(--lbs-right-column,0px)}
+ /* Portalled settings menus live under body, so keep them above the flattened panel. */
+ body > [role="menu"]{z-index:2147483600!important}
.VOzbGW_mask{display:none!important}
.VOzbGW_panel{flex:1;width:auto;height:100vh;max-width:none;max-height:none;border-radius:0;box-shadow:none;background:var(--dsw-alias-bg-base)}
.VOzbGW_nav{border-right:1px solid var(--dsw-alias-border-l1);box-sizing:border-box;width:218px;padding:26px 14px 0;gap:22px}
diff --git a/scripts/install-linux-tarball.sh b/scripts/install-linux-tarball.sh
new file mode 100755
index 0000000..9528e00
--- /dev/null
+++ b/scripts/install-linux-tarball.sh
@@ -0,0 +1,141 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+SOURCE_APP=${BOTCF_SOURCE_APP:-"$SCRIPT_DIR/app"}
+MODE=user
+PREFIX=""
+NO_DESKTOP=0
+
+usage() {
+ cat <<'EOF'
+用法:./install.sh [选项]
+
+默认安装到当前用户目录。选项:
+ --user 安装到 ~/.local/share/botcf-agent(默认)
+ --system 安装到 /opt/botcf-agent,需要 root
+ --prefix <目录> 指定安装目录;不会自动切换安装权限
+ --no-desktop 不创建桌面启动项
+ -h, --help 显示帮助
+EOF
+}
+
+while (($# > 0)); do
+ case "$1" in
+ --user)
+ MODE=user
+ shift
+ ;;
+ --system)
+ MODE=system
+ shift
+ ;;
+ --prefix)
+ [[ $# -ge 2 ]] || { echo "--prefix 需要一个目录。" >&2; exit 2; }
+ PREFIX=$2
+ shift 2
+ ;;
+ --no-desktop)
+ NO_DESKTOP=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "未知参数:$1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+[[ -d "$SOURCE_APP" ]] || { echo "找不到打包应用目录:$SOURCE_APP" >&2; exit 1; }
+[[ -x "$SOURCE_APP/botcf-agent" ]] || {
+ echo "打包应用缺少可执行文件:$SOURCE_APP/botcf-agent" >&2
+ exit 1
+}
+
+if [[ -z "$PREFIX" ]]; then
+ if [[ "$MODE" == system ]]; then
+ PREFIX=/opt/botcf-agent
+ else
+ PREFIX=${XDG_DATA_HOME:-"$HOME/.local/share"}/botcf-agent
+ fi
+fi
+
+if [[ "$MODE" == system && ${EUID:-$(id -u)} -ne 0 ]]; then
+ echo "--system 安装需要 root;请使用 sudo ./install.sh --system。" >&2
+ exit 1
+fi
+
+PARENT_DIR=$(dirname -- "$PREFIX")
+install -d -m 755 "$PARENT_DIR"
+PREFIX=$(CDPATH= cd -- "$PARENT_DIR" && pwd)/$(basename -- "$PREFIX")
+[[ "$PREFIX" != "/" ]] || { echo "安装目录不能是根目录。" >&2; exit 2; }
+PARENT_DIR=$(dirname -- "$PREFIX")
+STAGE_DIR="$PARENT_DIR/.botcf-agent-install.$$"
+BACKUP_DIR="${PREFIX}.backup.$(date +%Y%m%d%H%M%S)"
+
+cleanup() {
+ rm -rf -- "$STAGE_DIR"
+}
+trap cleanup EXIT
+
+rm -rf -- "$STAGE_DIR"
+install -d -m 755 "$STAGE_DIR"
+cp -a "$SOURCE_APP/." "$STAGE_DIR/"
+chmod 755 "$STAGE_DIR/botcf-agent"
+
+if [[ "$MODE" == system && -f "$STAGE_DIR/chrome-sandbox" ]]; then
+ chown root:root "$STAGE_DIR/chrome-sandbox"
+ chmod 4755 "$STAGE_DIR/chrome-sandbox" 2>/dev/null || {
+ echo "警告:无法设置 chrome-sandbox 的 setuid 权限,运行时可能受系统沙箱策略影响。" >&2
+ }
+fi
+
+if [[ -e "$PREFIX" || -L "$PREFIX" ]]; then
+ mv -- "$PREFIX" "$BACKUP_DIR"
+ echo "旧版本已保留到:$BACKUP_DIR"
+fi
+mv -- "$STAGE_DIR" "$PREFIX"
+
+if [[ "$MODE" == system ]]; then
+ BIN_DIR=/usr/local/bin
+ DESKTOP_FILE=/usr/share/applications/botcf-agent.desktop
+else
+ BIN_DIR=${XDG_BIN_HOME:-"$HOME/.local/bin"}
+ DESKTOP_FILE=${XDG_DATA_HOME:-"$HOME/.local/share"}/applications/botcf-agent.desktop
+fi
+
+install -d -m 755 "$BIN_DIR"
+ln -sfn "$PREFIX/botcf-agent" "$BIN_DIR/botcf-agent"
+
+if ((NO_DESKTOP == 0)); then
+ install -d -m 755 "$(dirname -- "$DESKTOP_FILE")"
+ ICON_PATH="$PREFIX/resources/app.asar.unpacked/packages/laobos-system-tools/assets/botcf-logo.png"
+ if [[ ! -f "$ICON_PATH" ]]; then
+ ICON_PATH="$PREFIX/public/botcf-logo.png"
+ fi
+ cat > "$DESKTOP_FILE" </dev/null 2>&1; then
+ update-desktop-database "$(dirname -- "$DESKTOP_FILE")" >/dev/null 2>&1 || true
+ fi
+fi
+
+echo "BOTCF Linux 版本安装完成。"
+echo "应用目录:$PREFIX"
+echo "命令:$BIN_DIR/botcf-agent"
diff --git a/scripts/package-linux-tarball.mjs b/scripts/package-linux-tarball.mjs
new file mode 100755
index 0000000..3e4f1d9
--- /dev/null
+++ b/scripts/package-linux-tarball.mjs
@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+
+import { chmod, cp, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const packageJson = JSON.parse(
+ await readFile(path.join(projectRoot, "package.json"), "utf8"),
+);
+
+function parseArgs(arguments_) {
+ let arch = process.arch === "arm64" ? "arm64" : "x64";
+ for (let index = 0; index < arguments_.length; index += 1) {
+ const argument = arguments_[index];
+ if (argument === "--arch") {
+ arch = arguments_[index + 1] || "";
+ index += 1;
+ } else if (argument === "--x64") {
+ arch = "x64";
+ } else if (argument === "--arm64") {
+ arch = "arm64";
+ } else if (argument === "--help" || argument === "-h") {
+ console.log("用法:node scripts/package-linux-tarball.mjs [--x64|--arm64]");
+ process.exit(0);
+ } else {
+ throw new Error(`未知参数:${argument}`);
+ }
+ }
+ if (!["x64", "arm64"].includes(arch)) {
+ throw new Error(`不支持的 Linux 架构:${arch}。当前只提供 x64 和 arm64。`);
+ }
+ return { arch };
+}
+
+function run(command, arguments_, options = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, arguments_, {
+ cwd: projectRoot,
+ stdio: "inherit",
+ ...options,
+ });
+ child.once("error", reject);
+ child.once("exit", (code, signal) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error(
+ `${path.basename(command)} 失败(code=${String(code)}, signal=${String(signal)})`,
+ ));
+ }
+ });
+ });
+}
+
+async function findLinuxUnpacked(outputDirectory, arch) {
+ const preferredNames = [
+ arch === "x64" ? "linux-unpacked" : "linux-arm64-unpacked",
+ `linux-${arch}-unpacked`,
+ ];
+ for (const name of preferredNames) {
+ const candidate = path.join(outputDirectory, name);
+ const info = await stat(candidate).catch(() => undefined);
+ if (info?.isDirectory()) return candidate;
+ }
+
+ const entries = await readdir(outputDirectory, { withFileTypes: true });
+ const candidates = entries
+ .filter((entry) => entry.isDirectory() && /^linux(?:-[a-z0-9]+)?-unpacked$/u.test(entry.name))
+ .map((entry) => path.join(outputDirectory, entry.name));
+ if (candidates.length === 1) return candidates[0];
+ throw new Error(`无法定位 Linux 未压缩应用目录:${outputDirectory}`);
+}
+
+async function main() {
+ if (process.platform !== "linux") {
+ throw new Error("Linux tar.gz 产物必须在 Linux 构建机上生成,以确保原生依赖和 Electron ABI 正确。");
+ }
+
+ const { arch } = parseArgs(process.argv.slice(2));
+ const hostArch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : process.arch;
+ if (arch !== hostArch) {
+ throw new Error(
+ `Linux tar.gz 必须在目标架构原生构建机上生成:请求 ${arch},当前 ${hostArch}。`,
+ );
+ }
+ const electronBuilder = path.join(
+ projectRoot,
+ "node_modules",
+ ".bin",
+ "electron-builder",
+ );
+ const outputDirectory = path.join(projectRoot, "out", "installers");
+ const stagingDirectory = path.join(projectRoot, "out", "linux-tarball");
+ const packageName = `botcf-agent-${packageJson.version}-linux-${arch}`;
+ const packageDirectory = path.join(stagingDirectory, packageName);
+ const appDirectory = path.join(packageDirectory, "app");
+ const archivePath = path.join(outputDirectory, `${packageName}.tar.gz`);
+
+ await run(electronBuilder, ["--linux", "dir", `--${arch}`, "--publish", "never"]);
+
+ const unpackedDirectory = await findLinuxUnpacked(outputDirectory, arch);
+ const appExecutable = path.join(unpackedDirectory, "botcf-agent");
+ const appAsar = path.join(unpackedDirectory, "resources", "app.asar");
+ const resourcesDirectory = path.join(unpackedDirectory, "resources");
+ await run(process.execPath, [
+ path.join(projectRoot, "scripts", "verify-packaged-runtime.mjs"),
+ appAsar,
+ ]);
+ await run(process.execPath, [
+ path.join(projectRoot, "scripts", "verify-desktop-resources.mjs"),
+ resourcesDirectory,
+ ]);
+ await run(process.execPath, [
+ path.join(projectRoot, "scripts", "verify-packaged-executables.mjs"),
+ appExecutable,
+ appAsar,
+ ]);
+
+ await rm(stagingDirectory, { recursive: true, force: true });
+ await mkdir(packageDirectory, { recursive: true });
+ await cp(unpackedDirectory, appDirectory, { recursive: true, force: true });
+ await cp(
+ path.join(projectRoot, "scripts", "install-linux-tarball.sh"),
+ path.join(packageDirectory, "install.sh"),
+ );
+ await chmod(path.join(packageDirectory, "install.sh"), 0o755);
+ await cp(
+ path.join(projectRoot, "scripts", "uninstall-linux-tarball.sh"),
+ path.join(packageDirectory, "uninstall.sh"),
+ );
+ await chmod(path.join(packageDirectory, "uninstall.sh"), 0o755);
+ await rm(archivePath, { force: true });
+ await mkdir(outputDirectory, { recursive: true });
+ await run("tar", [
+ "-czf",
+ archivePath,
+ "-C",
+ stagingDirectory,
+ packageName,
+ ]);
+
+ console.log(`Linux ${arch} tar.gz 已生成:${path.relative(projectRoot, archivePath)}`);
+ console.log(`解压后运行:cd ${packageName} && ./install.sh`);
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/scripts/probe-packaged-executables.cjs b/scripts/probe-packaged-executables.cjs
index 57897bd..46a8948 100644
--- a/scripts/probe-packaged-executables.cjs
+++ b/scripts/probe-packaged-executables.cjs
@@ -11,6 +11,7 @@ assert.equal(path.basename(appAsar), "app.asar", "需要传入打包后的 resou
const requireFromApp = createRequire(path.join(appAsar, "package.json"));
const { rgPath } = requireFromApp("@vscode/ripgrep");
+const sharp = requireFromApp("sharp");
const unpackedBoundary = `.asar.unpacked${path.sep}`;
assert.ok(
@@ -30,6 +31,7 @@ assert.equal(
`ripgrep 启动失败(status=${String(ripgrep.status)}):${ripgrep.stderr}`,
);
assert.match(ripgrep.stdout, /^ripgrep\s/u);
+assert.ok(sharp?.versions?.vips, "Sharp libvips 运行库未能从打包应用加载");
if (process.platform === "win32") {
const ssh2Entry = requireFromApp.resolve("ssh2");
@@ -55,4 +57,6 @@ if (process.platform === "linux") {
assert.ok(fs.existsSync(landlockPath), `Landlock 启动器不存在:${landlockPath}`);
}
-console.log(`打包可执行组件启动校验通过:${ripgrep.stdout.trim().split("\n")[0]}`);
+console.log(
+ `打包可执行组件启动校验通过:${ripgrep.stdout.trim().split("\n")[0]},libvips ${sharp.versions.vips}`,
+);
diff --git a/scripts/start-dsh.mjs b/scripts/start-dsh.mjs
index c0eed6f..365bff2 100644
--- a/scripts/start-dsh.mjs
+++ b/scripts/start-dsh.mjs
@@ -76,11 +76,14 @@ async function main() {
dshHome,
mode: bundledPluginMode(),
});
+ const isConfigDump = forwarded.includes("--dump-config") ||
+ forwarded.includes("--dump-default-config");
const dshArguments = [
"--profile",
"web",
"--patch",
patchFile,
+ ...(isConfigDump ? [] : ["--no-open"]),
...(platformPatchFile ? ["--patch", platformPatchFile] : []),
...forwarded,
];
diff --git a/scripts/uninstall-linux-tarball.sh b/scripts/uninstall-linux-tarball.sh
new file mode 100755
index 0000000..5d2c43e
--- /dev/null
+++ b/scripts/uninstall-linux-tarball.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+MODE=user
+PREFIX=""
+FORCE=0
+
+usage() {
+ cat <<'EOF'
+用法:./uninstall.sh [选项]
+
+默认卸载当前用户安装的 BOTCF,但保留用户配置、登录凭据和 DSH 数据。
+
+选项:
+ --user 卸载用户级安装(默认)
+ --system 卸载 /opt/botcf-agent,需要 root
+ --prefix <目录> 指定安装目录
+ --force 应用仍在运行时继续卸载
+ -h, --help 显示帮助
+EOF
+}
+
+while (($# > 0)); do
+ case "$1" in
+ --user)
+ MODE=user
+ shift
+ ;;
+ --system)
+ MODE=system
+ shift
+ ;;
+ --prefix)
+ [[ $# -ge 2 ]] || { echo "--prefix 需要一个目录。" >&2; exit 2; }
+ PREFIX=$2
+ shift 2
+ ;;
+ --force)
+ FORCE=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "未知参数:$1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ -z "$PREFIX" ]]; then
+ if [[ "$MODE" == system ]]; then
+ PREFIX=/opt/botcf-agent
+ else
+ PREFIX=${XDG_DATA_HOME:-"$HOME/.local/share"}/botcf-agent
+ fi
+fi
+
+if [[ "$MODE" == system && ${EUID:-$(id -u)} -ne 0 ]]; then
+ echo "--system 卸载需要 root;请使用 sudo ./uninstall.sh --system。" >&2
+ exit 1
+fi
+
+PREFIX=$(CDPATH= cd -- "$(dirname -- "$PREFIX")" && pwd)/$(basename -- "$PREFIX")
+[[ "$PREFIX" != "/" && "$PREFIX" != "$HOME" ]] || {
+ echo "拒绝卸载不安全的目录:$PREFIX" >&2
+ exit 2
+}
+
+if ((FORCE == 0)) && command -v pgrep >/dev/null 2>&1; then
+ if pgrep -f -- "${PREFIX}/botcf-agent( |$)" >/dev/null 2>&1; then
+ echo "BOTCF 仍在运行,请先退出应用;如确认要继续,请加 --force。" >&2
+ exit 1
+ fi
+fi
+
+if [[ "$MODE" == system ]]; then
+ BIN_DIR=/usr/local/bin
+ DESKTOP_FILE=/usr/share/applications/botcf-agent.desktop
+else
+ BIN_DIR=${XDG_BIN_HOME:-"$HOME/.local/bin"}
+ DESKTOP_FILE=${XDG_DATA_HOME:-"$HOME/.local/share"}/applications/botcf-agent.desktop
+fi
+
+BIN_LINK="$BIN_DIR/botcf-agent"
+if [[ -L "$BIN_LINK" ]]; then
+ LINK_TARGET=$(readlink -f -- "$BIN_LINK" 2>/dev/null || true)
+ if [[ "$LINK_TARGET" == "$PREFIX/botcf-agent" ]]; then
+ rm -f -- "$BIN_LINK"
+ fi
+fi
+
+if [[ -f "$DESKTOP_FILE" ]] && grep -Fq "Exec=$PREFIX/botcf-agent" "$DESKTOP_FILE"; then
+ rm -f -- "$DESKTOP_FILE"
+ if command -v update-desktop-database >/dev/null 2>&1; then
+ update-desktop-database "$(dirname -- "$DESKTOP_FILE")" >/dev/null 2>&1 || true
+ fi
+fi
+
+if [[ -e "$PREFIX" || -L "$PREFIX" ]]; then
+ rm -rf -- "$PREFIX"
+ echo "已删除应用目录:$PREFIX"
+else
+ echo "应用目录不存在:$PREFIX"
+fi
+
+echo "BOTCF 已卸载。用户配置和 DSH 数据未删除。"
diff --git a/scripts/verify-update-release.mjs b/scripts/verify-update-release.mjs
index 7dff5ff..31011ae 100644
--- a/scripts/verify-update-release.mjs
+++ b/scripts/verify-update-release.mjs
@@ -33,7 +33,11 @@ for (const name of ["latest.yml", "latest-mac.yml"]) {
const prefix = expectedVersion ? `botcf-agent-${expectedVersion}-` : "botcf-agent-";
const hasMacInstaller = names.has(`${prefix}macos-arm64.dmg`);
const hasWindowsInstaller = names.has(`${prefix}windows-x64-setup.exe`);
+const hasLinuxX64Tarball = names.has(`${prefix}linux-x64.tar.gz`);
+const hasLinuxArm64Tarball = names.has(`${prefix}linux-arm64.tar.gz`);
if (!hasMacInstaller) throw new Error("缺少 macOS ARM64 DMG。");
if (!hasWindowsInstaller) throw new Error("缺少 Windows x64 安装器。");
+if (!hasLinuxX64Tarball) throw new Error("缺少 Linux x64 tar.gz。");
+if (!hasLinuxArm64Tarball) throw new Error("缺少 Linux arm64 tar.gz。");
console.log(`更新 Release 校验通过:${names.size} 个文件。`);
diff --git a/tests/desktop-shell.test.mjs b/tests/desktop-shell.test.mjs
index 90eb29a..14abef3 100644
--- a/tests/desktop-shell.test.mjs
+++ b/tests/desktop-shell.test.mjs
@@ -9,6 +9,7 @@ import {
fileImportSpecifier,
summarizeDshFailureOutput,
} from "../desktop/dsh-runtime.mjs";
+import { detectLinuxPasswordStore } from "../desktop/linux-password-store.mjs";
import { verifyPackagedRuntime } from "../scripts/verify-packaged-runtime.mjs";
import { verifyDesktopResources } from "../scripts/verify-desktop-resources.mjs";
import { verifyWindowsPackagedRuntime } from "../scripts/verify-windows-packaged-runtime.mjs";
@@ -81,6 +82,8 @@ test("desktop shell boots DSH directly with a sandboxed renderer", async () => {
assert.match(permissionSource, /clipboard-sanitized-write/);
assert.match(mainSource, /resolveLaobosUserDataPath/);
assert.match(mainSource, /const productName = app\.isPackaged \? "BOTCF" : "BOTCF(开发版)"/);
+ assert.match(mainSource, /detectLinuxPasswordStore/);
+ assert.match(mainSource, /appendSwitch\("password-store", passwordStore\)/);
assert.match(mainSource, /before-quit-for-update/);
assert.match(mainSource, /event\.preventDefault\(\)[\s\S]*prepareToQuit\(\)[\s\S]*app\.quit\(\)/);
assert.doesNotMatch(mainSource, /bridge-process|resolvePiBinary|PI_STUDIO_PI_BIN/);
@@ -93,6 +96,40 @@ test("desktop shell boots DSH directly with a sandboxed renderer", async () => {
assert.match(runtimeSource, /"--import"/);
assert.match(runtimeSource, /fileImportSpecifier\(asarBootstrap\)/);
assert.match(runtimeSource, /laobos\.windows\.cordis\.patch\.yml/);
+ assert.match(runtimeSource, /"--no-open"/);
+});
+
+test("Linux password-store detection follows the available desktop service", () => {
+ const services = new Set();
+ const probeService = (service) => services.has(service);
+
+ services.add("org.kde.kwalletd6");
+ assert.equal(
+ detectLinuxPasswordStore({
+ env: { XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "6" },
+ probeService,
+ }),
+ "kwallet6",
+ );
+
+ services.clear();
+ services.add("org.freedesktop.secrets");
+ assert.equal(
+ detectLinuxPasswordStore({
+ env: { XDG_CURRENT_DESKTOP: "niri" },
+ probeService,
+ }),
+ "gnome-libsecret",
+ );
+
+ services.clear();
+ assert.equal(
+ detectLinuxPasswordStore({
+ env: { XDG_CURRENT_DESKTOP: "niri" },
+ probeService,
+ }),
+ "",
+ );
});
test("desktop mode does not expose the manual Bridge connection dialog", async () => {
@@ -127,18 +164,24 @@ test("desktop development starts Electron without the legacy Pi renderer bridge"
assert.match(packageJson, /"dev": "npm run build:desktop-plugins && node scripts\/start-dsh\.mjs"/);
assert.match(startDshSource, /dsh-asar-bootstrap\.mjs/);
assert.match(startDshSource, /"--import", asarBootstrapUrl/);
+ assert.match(startDshSource, /"--no-open"/);
});
-test("desktop release configuration builds native macOS and Windows installers", async () => {
- const [packageJson, builderConfig, testUpdateConfig, workflow] = await Promise.all([
+test("desktop release configuration builds native macOS, Windows, and Linux packages", async () => {
+ const [packageJson, builderConfig, testUpdateConfig, workflow, linuxPackager, linuxInstaller, linuxUninstaller, systemToolsClient] = await Promise.all([
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../electron-builder.yml", import.meta.url), "utf8"),
readFile(new URL("../electron-builder.test-update.yml", import.meta.url), "utf8"),
readFile(new URL("../.github/workflows/installers.yml", import.meta.url), "utf8"),
+ readFile(new URL("../scripts/package-linux-tarball.mjs", import.meta.url), "utf8"),
+ readFile(new URL("../scripts/install-linux-tarball.sh", import.meta.url), "utf8"),
+ readFile(new URL("../scripts/uninstall-linux-tarball.sh", import.meta.url), "utf8"),
+ readFile(new URL("../packages/laobos-system-tools/lib/client.js", import.meta.url), "utf8"),
]);
assert.match(packageJson, /"desktop:installer": "npm run build:desktop-plugins && electron-builder --publish never"/);
assert.match(packageJson, /"desktop:installer:test-update"/);
+ assert.match(packageJson, /"desktop:linux:tarball"/);
assert.match(testUpdateConfig, /identity: "-"/);
assert.match(testUpdateConfig, /hardenedRuntime: false/);
assert.match(testUpdateConfig, /afterSign: \.\/scripts\/sign-test-update\.mjs/);
@@ -146,6 +189,7 @@ test("desktop release configuration builds native macOS and Windows installers",
assert.match(builderConfig, /artifactName: "botcf-agent-\$\{version\}-macos-\$\{arch\}\.\$\{ext\}"/);
assert.match(builderConfig, /target: nsis[\s\S]*arch:\n\s+- x64/);
assert.match(builderConfig, /artifactName: "botcf-agent-\$\{version\}-windows-\$\{arch\}-setup\.\$\{ext\}"/);
+ assert.match(builderConfig, /linux:[\s\S]*target: dir[\s\S]*- x64[\s\S]*- arm64/);
assert.match(builderConfig, /asar: true/);
assert.match(builderConfig, /asarUnpack:[\s\S]*"packages\/\*\*"/);
assert.match(builderConfig, /compression: normal/);
@@ -159,13 +203,25 @@ test("desktop release configuration builds native macOS and Windows installers",
assert.equal(builderConfig.match(/!\.cache/g)?.length, 2);
assert.match(workflow, /runs-on: macos-15/);
assert.match(workflow, /runs-on: windows-latest/);
+ assert.match(workflow, /ubuntu-24\.04-arm/);
+ assert.match(workflow, /desktop:linux:tarball/);
+ assert.match(linuxPackager, /verify-packaged-executables\.mjs/);
+ assert.match(linuxPackager, /"-czf"/);
+ assert.match(linuxInstaller, /MODE=user/);
+ assert.match(linuxInstaller, /--system/);
+ assert.match(linuxInstaller, /chrome-sandbox/);
+ assert.match(linuxPackager, /uninstall-linux-tarball\.sh/);
+ assert.match(linuxUninstaller, /保留用户配置、登录凭据和 DSH 数据/);
+ assert.match(linuxUninstaller, /--force/);
+ assert.match(linuxUninstaller, /Exec=\$PREFIX\/botcf-agent/);
+ assert.match(systemToolsClient, /body > \[role="menu"\]\{z-index:2147483600!important\}/);
assert.equal(workflow.match(/npm run audit:public/g)?.length, 1);
assert.equal(workflow.match(/verify-packaged-runtime\.mjs/g)?.length, 2);
assert.equal(workflow.match(/verify-desktop-resources\.mjs/g)?.length, 2);
assert.equal(workflow.match(/verify-windows-packaged-runtime\.mjs/g)?.length, 1);
assert.equal(workflow.match(/verify-packaged-executables\.mjs/g)?.length, 2);
assert.equal(workflow.match(/app\.asar/g)?.length, 4);
- assert.equal(workflow.match(/actions\/upload-artifact@v7/g)?.length, 2);
+ assert.equal(workflow.match(/actions\/upload-artifact@v7/g)?.length, 3);
assert.match(builderConfig, /target: zip[\s\S]*arch:\n\s+- arm64/);
assert.match(builderConfig, /repo: botcf-agent/);
assert.match(workflow, /latest-mac\.yml/);
diff --git a/tests/updater.test.mjs b/tests/updater.test.mjs
index 4116995..687dd90 100644
--- a/tests/updater.test.mjs
+++ b/tests/updater.test.mjs
@@ -221,6 +221,8 @@ test("release validator rejects missing files and accepts complete update metada
"botcf-agent-0.3.0-macos-arm64.dmg",
"botcf-agent-0.3.0-macos-arm64.zip",
"botcf-agent-0.3.0-windows-x64-setup.exe",
+ "botcf-agent-0.3.0-linux-x64.tar.gz",
+ "botcf-agent-0.3.0-linux-arm64.tar.gz",
];
for (const artifact of artifacts) {
await writeFile(path.join(temporary, artifact), "fixture");