# Chromium Extension Bind Mount 最小实现 > **[2026-10-01] 本方案已被「静默安装」Policy 取代,仅作历史设计存档。** > 用户定版:不再坚持插件文件共享。插件改为每 Profile 静默安装 > (External Extensions + CRX),扩展文件自然落在各 Profile 内部, > 且 install 事件每 Profile 只发一次、磁盘与装载语义均优于 bind mount。 > 实测结论与现行实现见 `extension-install-events-findings.md` §7 与 > `node/src/core/browser/extension.silent.ts`。 ## 1. 目标 在 Linux + CloakBrowser/Chromium 环境中,让多个独立 Browser Profile 共用同一份第三方 Extension 文件,减少每个 `USER_DATA` 重复保存 Extension 代码。 目标结构: ```text 共享 Extension /opt/serpmax/extensions/// | +---- bind mount ---- Profile 001/UserData/Default/Extensions// +---- bind mount ---- Profile 002/UserData/Default/Extensions// +---- bind mount ---- Profile 003/UserData/Default/Extensions// ``` 每个 Browser 仍然拥有独立的 `USER_DATA`、Cookie、Preferences、Extension State 等运行状态;只共享 Extension 代码目录。 本方案不修改 Chromium/CloakBrowser 源码。 ## 2. 适用范围 - Linux - Chromium/CloakBrowser - 第三方 Extension,不修改 Extension 源码 - 多 Browser Profile - Extension 代码版本固定 - 允许使用 root 或具备 mount 权限的服务执行 `mount --bind` 不使用: - `--load-extension` - CloakBrowser `extension_paths` - 每个 Profile 复制一份 Extension 代码 ## 3. 目录规划 示例: ```text /opt/serpmax/ ├── extensions/ │ └── abcdefghijklmnopabcdefghijklmnop/ │ └── 1.0.0/ │ ├── manifest.json │ ├── background.js │ ├── content.js │ └── ... └── profiles/ ├── 001/ │ └── UserData/ │ └── Default/ │ └── Extensions/ │ └── abcdefghijklmnopabcdefghijklmnop/ │ └── 1.0.0/ # bind mount └── 002/ └── UserData/ └── Default/ └── Extensions/ └── abcdefghijklmnopabcdefghijklmnop/ └── 1.0.0/ # bind mount ``` 建议共享目录设置为只读,避免某个 Browser 修改共享 Extension 文件影响其它 Browser。 ## 4. 前置条件 先准备一份完整的 Extension 文件: ```bash SHARED="/opt/serpmax/extensions/abcdefghijklmnopabcdefghijklmnop/1.0.0" mkdir -p "$SHARED" # 将已经确认可用的 Extension 文件放入 $SHARED test -f "$SHARED/manifest.json" ``` 检查版本: ```bash cat "$SHARED/manifest.json" | grep '"version"' ``` Extension ID、目录名和版本目录必须与 Chromium 实际使用的 Extension 路径一致。 ## 5. 为单个 Profile 创建 bind mount 假设: ```bash EXT_ID="abcdefghijklmnopabcdefghijklmnop" VERSION="1.0.0" PROFILE="/opt/serpmax/profiles/001/UserData" SRC="/opt/serpmax/extensions/$EXT_ID/$VERSION" DST="$PROFILE/Default/Extensions/$EXT_ID/$VERSION" ``` 创建目标父目录: ```bash mkdir -p "$(dirname "$DST")" ``` 目标目录第一次不能已经是普通文件目录。先确认: ```bash ls -ld "$DST" 2>/dev/null || true ``` 如果是 Chromium 已经复制出来的普通目录,需要先停止 Browser,再删除该目录: ```bash rm -rf "$DST" ``` 创建空目标目录: ```bash mkdir -p "$DST" ``` 执行 bind mount: ```bash mount --bind "$SRC" "$DST" ``` 建议再设置只读: ```bash mount -o remount,bind,ro "$DST" ``` 检查: ```bash mountpoint "$DST" findmnt "$DST" ``` ## 6. 多 Profile 每个 Profile 都执行一次: ```bash for ID in 001 002 003 004; do PROFILE="/opt/serpmax/profiles/$ID/UserData" DST="$PROFILE/Default/Extensions/$EXT_ID/$VERSION" mkdir -p "$(dirname "$DST")" if mountpoint -q "$DST"; then continue fi rm -rf "$DST" mkdir -p "$DST" mount --bind "$SRC" "$DST" mount -o remount,bind,ro "$DST" done ``` ## 7. Profile 生命周期与启动顺序 Bind mount 的生命周期跟随 **Profile / USER_DATA**,不跟随 Browser 进程。 正确流程: ```text 创建 Profile | +-- 创建 USER_DATA | +-- 创建 Extension bind mount | +-- 启动 CloakBrowser | +-- Browser 正常运行 | +-- Browser 退出 | | | +-- 保留 USER_DATA | +-- 保留 bind mount | +-- 下次启动 | | | +-- 直接复用已有 bind mount | +-- 删除 Profile | +-- 确认 Browser 已退出 +-- umount +-- 删除 USER_DATA ``` 核心原则: **先 mount,再启动 Browser;Browser 退出不卸载;只有删除 Profile 时才卸载。** 不要在 Browser 已经启动并加载 Extension 后动态替换目录。 ## 8. 启动前校验 启动 Browser 前至少检查: ```bash test -f "$SRC/manifest.json" || exit 1 mountpoint -q "$DST" || exit 1 test -f "$DST/manifest.json" || exit 1 ``` 确认实际文件来自共享目录: ```bash readlink -f "$DST/manifest.json" ``` bind mount 下这里看到的是目标路径,但文件内容应与: ```bash "$SRC/manifest.json" ``` 一致。 ## 9. 删除 Profile 时卸载 Browser 退出后**不卸载** bind mount。 只要 Profile / USER_DATA 仍然存在,就保持 bind mount,这样下次启动 Browser 可以直接复用。 删除 Profile 时,必须先确认 Browser 已经完全退出,再执行卸载: ```bash if mountpoint -q "$DST"; then umount "$DST" fi rm -rf "$PROFILE" ``` 如果存在多个 Extension,需要逐个卸载后再删除 USER_DATA: ```text 删除 Profile | +-- Browser 是否已退出? | +-- umount 所有 Extension bind mount | +-- 删除 USER_DATA ``` **不要在 Browser 运行期间删除 USER_DATA,也不要直接 rm -rf 包含活动 mount point 的 USER_DATA。** ## 10. 关键实现:Profile Manager SerpMax 后续可以只实现三个函数: ```text mountExtension(profileId, extensionId, version) unmountExtension(profileId, extensionId, version) isExtensionMounted(profileId, extensionId, version) ``` 伪代码: ```text mountExtension(): 1. 定位共享 Extension 2. 检查 manifest.json 3. 定位 Profile Extension 目录 4. 检查 Browser 是否运行 5. 删除原来的版本目录 6. 创建空目录 7. mount --bind 8. remount,bind,ro 9. 校验 mount 10. 返回成功 unmountExtension(): 1. 检查 mountpoint 2. 确认 Browser 已退出 3. umount 4. 删除 Profile 时再删除 USER_DATA 中对应目录 ``` ## 11. 最小 Shell 实现 可以先使用下面脚本验证整个方案: ```bash #!/usr/bin/env bash set -euo pipefail EXT_ID="abcdefghijklmnopabcdefghijklmnop" VERSION="1.0.0" SRC="/opt/serpmax/extensions/$EXT_ID/$VERSION" PROFILE="/opt/serpmax/profiles/001/UserData" DST="$PROFILE/Default/Extensions/$EXT_ID/$VERSION" test -f "$SRC/manifest.json" mkdir -p "$(dirname "$DST")" if mountpoint -q "$DST"; then echo "already mounted: $DST" exit 0 fi if [ -e "$DST" ]; then rm -rf "$DST" fi mkdir -p "$DST" mount --bind "$SRC" "$DST" mount -o remount,bind,ro "$DST" mountpoint "$DST" echo "mounted: $SRC -> $DST" ``` ## 12. 验证测试 ### 12.1 Mount 是否成功 ```bash findmnt "$DST" ``` 应该能看到 bind mount。 ### 12.2 文件是否一致 ```bash sha256sum "$SRC/manifest.json" sha256sum "$DST/manifest.json" ``` 两个 Hash 应一致。 ### 12.3 多 Profile 是否共用文件 分别执行: ```bash stat "$SRC/manifest.json" stat "/opt/serpmax/profiles/001/UserData/Default/Extensions/$EXT_ID/$VERSION/manifest.json" stat "/opt/serpmax/profiles/002/UserData/Default/Extensions/$EXT_ID/$VERSION/manifest.json" ``` 确认所有 Profile 都指向同一个共享源目录。 ### 12.4 Extension 生命周期测试 使用正常安装方式让 Chromium 识别 Extension,不使用: ```text --load-extension extension_paths ``` 然后测试: 1. 第一次启动 Profile。 2. 检查 Extension 是否正常运行。 3. 记录 `chrome.runtime.onInstalled`。 4. 关闭 Browser。 5. 再次启动同一个 Profile。 6. 确认不会因为每次启动都重新作为 command-line unpacked extension 加载而触发 `reason=install`。 注意:bind mount 本身不会改变 Chromium 的 Extension 生命周期。它只解决文件存储层共享问题。要避免 `--load-extension` 每次启动产生的 install 行为,必须使用正常的 Extension 安装/注册方式。 ## 13. 重要限制 ### 13.1 不要多个 Browser 同时写共享目录 多个 Browser 使用同一份 Extension 代码时,共享目录必须视为只读资源。 推荐: ```text Extension Code 共享、只读 Extension State 每 Profile 独立 Cookies 每 Profile 独立 Preferences 每 Profile 独立 Secure Preferences 每 Profile 独立 Local Storage 每 Profile 独立 Service Worker State 每 Profile 独立 ``` ### 13.2 Extension 更新 不要直接覆盖正在使用的版本目录。 推荐: ```text 1. 新版本写入新的共享目录 2. 停止相关 Browser 3. 更新 Profile 的 bind mount 4. 创建新版本 bind mount 5. 启动 Browser 如果不需要立即切换版本,也可以等 Profile 下次启动前更新 mount。 ``` 例如: ```text extensions//1.0.0/ extensions//1.1.0/ ``` 不要让 Browser 运行期间直接修改: ```text extensions//1.0.0/ ``` ### 13.3 权限 执行 `mount --bind` 通常需要 root 或 CAP_SYS_ADMIN。 SerpMax 如果本身不是 root 运行,建议单独提供一个最小权限的 mount helper,而不是让整个应用拥有完整 root 权限。 ## 14. 最小生产结构 第一阶段不需要复杂的 Mount Manager: ```text SerpMax | +-- Extension Registry | | | +-- extension-id | +-- version | +-- shared-path | +-- Profile Manager | | | +-- profile-id | +-- user-data-path | +-- Mount Manager | +-- mount +-- unmount +-- status ``` Mount Manager 只负责文件系统 Mount,不负责 Extension 生命周期、Cookie 或 Browser 状态。 ## 15. 实施结论 本阶段只实现: - 一个共享 Extension 目录 - 一个 Profile 一个 bind mount - Profile 创建/初始化时完成 mount - Browser 退出后保持 mount - 删除 Profile 时才 unmount - 共享 Extension 目录只读 - Profile 状态保持独立 - 不修改 Chromium/CloakBrowser 源码 先用单 Extension + 2 个 Profile 验证: ```text Shared Extension | +-- Profile 001 | +-- Profile 002 ``` 确认 Extension 正常运行、两个 Profile 状态独立、`USER_DATA` 不再保存重复 Extension 文件后,再扩展到多 Extension / 多 Profile。