490 lines
11 KiB
Markdown
490 lines
11 KiB
Markdown
# 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/<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. 目录规划
|
||
|
||
示例:
|
||
|
||
```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/<id>/1.0.0/
|
||
extensions/<id>/1.1.0/
|
||
```
|
||
|
||
不要让 Browser 运行期间直接修改:
|
||
|
||
```text
|
||
extensions/<id>/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。
|