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

490 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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。