From f6009420ba3f663434bdc12c406f1a2439d76ae9 Mon Sep 17 00:00:00 2001 From: hopelessFantast Date: Fri, 21 Aug 2026 03:26:19 +0800 Subject: [PATCH] feat: add Linux tarball desktop distribution --- .github/workflows/installers.yml | 31 ++++- README.md | 26 +++- desktop/dsh-runtime.mjs | 1 + desktop/linux-password-store.mjs | 84 ++++++++++++ desktop/main.mjs | 8 ++ docs/desktop-release-playbook.md | 10 +- docs/project-architecture.md | 10 +- electron-builder.yml | 16 +++ package.json | 1 + packages/laobos-system-tools/lib/client.js | 2 + scripts/install-linux-tarball.sh | 141 +++++++++++++++++++ scripts/package-linux-tarball.mjs | 151 +++++++++++++++++++++ scripts/probe-packaged-executables.cjs | 6 +- scripts/start-dsh.mjs | 3 + scripts/uninstall-linux-tarball.sh | 111 +++++++++++++++ scripts/verify-update-release.mjs | 4 + tests/desktop-shell.test.mjs | 62 ++++++++- tests/updater.test.mjs | 2 + 18 files changed, 657 insertions(+), 12 deletions(-) create mode 100644 desktop/linux-password-store.mjs create mode 100755 scripts/install-linux-tarball.sh create mode 100755 scripts/package-linux-tarball.mjs create mode 100755 scripts/uninstall-linux-tarball.sh 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 @@

License: PolyForm Noncommercial 1.0.0 - Platforms: macOS and Windows + Platforms: macOS, Windows, and Linux Powered by DeepSeek Harness

@@ -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");