Files
SerpMax/docs/extension-bind-mount-minimal.md
T

11 KiB
Raw Blame History

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 代码。

目标结构:

共享 Extension
/opt/serpmax/extensions/<extension-id>/<version>/
        |
        +---- bind mount ---- Profile 001/UserData/Default/Extensions/<id>/<version>
        +---- bind mount ---- Profile 002/UserData/Default/Extensions/<id>/<version>
        +---- bind mount ---- Profile 003/UserData/Default/Extensions/<id>/<version>

每个 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. 目录规划

示例:

/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 文件:

SHARED="/opt/serpmax/extensions/abcdefghijklmnopabcdefghijklmnop/1.0.0"

mkdir -p "$SHARED"
# 将已经确认可用的 Extension 文件放入 $SHARED
test -f "$SHARED/manifest.json"

检查版本:

cat "$SHARED/manifest.json" | grep '"version"'

Extension ID、目录名和版本目录必须与 Chromium 实际使用的 Extension 路径一致。

5. 为单个 Profile 创建 bind mount

假设:

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"

创建目标父目录:

mkdir -p "$(dirname "$DST")"

目标目录第一次不能已经是普通文件目录。先确认:

ls -ld "$DST" 2>/dev/null || true

如果是 Chromium 已经复制出来的普通目录,需要先停止 Browser,再删除该目录:

rm -rf "$DST"

创建空目标目录:

mkdir -p "$DST"

执行 bind mount:

mount --bind "$SRC" "$DST"

建议再设置只读:

mount -o remount,bind,ro "$DST"

检查:

mountpoint "$DST"
findmnt "$DST"

6. 多 Profile

每个 Profile 都执行一次:

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 进程。

正确流程:

创建 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 前至少检查:

test -f "$SRC/manifest.json" || exit 1
mountpoint -q "$DST" || exit 1
test -f "$DST/manifest.json" || exit 1

确认实际文件来自共享目录:

readlink -f "$DST/manifest.json"

bind mount 下这里看到的是目标路径,但文件内容应与:

"$SRC/manifest.json"

一致。

9. 删除 Profile 时卸载

Browser 退出后不卸载 bind mount。

只要 Profile / USER_DATA 仍然存在,就保持 bind mount,这样下次启动 Browser 可以直接复用。

删除 Profile 时,必须先确认 Browser 已经完全退出,再执行卸载:

if mountpoint -q "$DST"; then
    umount "$DST"
fi

rm -rf "$PROFILE"

如果存在多个 Extension,需要逐个卸载后再删除 USER_DATA:

删除 Profile
    |
    +-- Browser 是否已退出?
    |
    +-- umount 所有 Extension bind mount
    |
    +-- 删除 USER_DATA

不要在 Browser 运行期间删除 USER_DATA,也不要直接 rm -rf 包含活动 mount point 的 USER_DATA。

10. 关键实现:Profile Manager

SerpMax 后续可以只实现三个函数:

mountExtension(profileId, extensionId, version)
unmountExtension(profileId, extensionId, version)
isExtensionMounted(profileId, extensionId, version)

伪代码:

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 实现

可以先使用下面脚本验证整个方案:

#!/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 是否成功

findmnt "$DST"

应该能看到 bind mount。

12.2 文件是否一致

sha256sum "$SRC/manifest.json"
sha256sum "$DST/manifest.json"

两个 Hash 应一致。

12.3 多 Profile 是否共用文件

分别执行:

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,不使用:

--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 代码时,共享目录必须视为只读资源。

推荐:

Extension Code       共享、只读
Extension State      每 Profile 独立
Cookies              每 Profile 独立
Preferences          每 Profile 独立
Secure Preferences   每 Profile 独立
Local Storage        每 Profile 独立
Service Worker State 每 Profile 独立

13.2 Extension 更新

不要直接覆盖正在使用的版本目录。

推荐:

1. 新版本写入新的共享目录
2. 停止相关 Browser
3. 更新 Profile 的 bind mount
4. 创建新版本 bind mount
5. 启动 Browser

如果不需要立即切换版本,也可以等 Profile 下次启动前更新 mount。

例如:

extensions/<id>/1.0.0/
extensions/<id>/1.1.0/

不要让 Browser 运行期间直接修改:

extensions/<id>/1.0.0/

13.3 权限

执行 mount --bind 通常需要 root 或 CAP_SYS_ADMIN。

SerpMax 如果本身不是 root 运行,建议单独提供一个最小权限的 mount helper,而不是让整个应用拥有完整 root 权限。

14. 最小生产结构

第一阶段不需要复杂的 Mount Manager:

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 验证:

Shared Extension
       |
       +-- Profile 001
       |
       +-- Profile 002

确认 Extension 正常运行、两个 Profile 状态独立、USER_DATA 不再保存重复 Extension 文件后,再扩展到多 Extension / 多 Profile。