feat(browser): 浏览器配置与探测库(internal/capability/browsercfg,纯库零接线)
按 design/28 §2/§3 的「配置不进 .env」口径:读 $DATA_DIR/config/plugins/browser.json (0600 + temp+rename 范式,**不读 env、不加新键**);有头/无头、CDP 端点、profile 目录、 视口/超时/并发(1)/截图上限全部类型化 + 常量默认值;**探测优先级**(本机 Chrome/Chromium 可执行文件 → 已在跑的容器)并返回可分类的「为什么不可用」(供上层如实降级,不假装可用); profile 目录含凭据 ⇒ 0600 + 不进 git/不进上下文的注释与校验。 本片零既有文件改动(不注册工具、不碰 cmd/),接线留 B2。
This commit is contained in:
1 parent
b7fb4dd1c7
commit
29181245ce
9 files changed
+1398
No files matched your search
@@ -0,0 +1,142 @@
|
||||
package browsercfg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Load 从 path 读 browser 插件配置,返回**归一化且校验过**的配置。
|
||||
//
|
||||
// 返回 `(nil, nil)` 表示**文件不存在** —— 即「未配置」,交给探测自动判定
|
||||
// (design/28 §8 B1:「无配置文件时按序探测本机 Chrome/Chromium → 已在跑的容器」)。
|
||||
// 文件存在但读不动 / 是空文件 / JSON 非法 / 字段越界 ⇒ 返回非 nil 错误,调用方应如实
|
||||
// 报错并**不注册工具**,绝不静默拿默认值顶替(design/28 §6 坑 5)。
|
||||
//
|
||||
// dataDir 只用于把空的 ProfileDir 落成默认位置(`$DATA_DIR/browser/profile`)。
|
||||
func Load(path, dataDir string) (*Config, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("browsercfg: 读 %s: %w", path, err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||
return nil, fmt.Errorf("%w: 空文件 %s", ErrInvalidConfig, path)
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("%w: 解码 %s: %w", ErrInvalidConfig, path, err)
|
||||
}
|
||||
cfg = cfg.Normalize(dataDir)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Save 把 cfg 归一化后以 **0600** 写入 path:先写 `<path>.tmp` 再 rename(照
|
||||
// runtimeconfig.SavePlugins 的范式),保证并发读者不会看到半个文件。写之前先 Validate:
|
||||
// 非法配置**不落盘**(宁可没有配置文件,也不留一个半对的)。
|
||||
//
|
||||
// 0600 的依据(design/28 §2/§4):browser.json 可能带上显式 CDP 端点与凭据化 profile 的
|
||||
// 位置,属于本地运行期配置,不进 git、不进上下文。
|
||||
func Save(path, dataDir string, cfg Config) error {
|
||||
cfg = cfg.Normalize(dataDir)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("browsercfg: 编码配置: %w", err)
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("browsercfg: 建配置目录 %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return fmt.Errorf("browsercfg: 写临时文件 %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("browsercfg: 重命名 %s -> %s: %w", tmp, path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureProfileDir 以 **0700** 建好(或确认已存在)profile 目录,并在建之前核查它
|
||||
// **不会进 git**(CheckProfileLocation)。0700 而非 0755:目录里的 cookie / 登录态是凭据
|
||||
// (design/28 §2 三件套之 2、§4)。
|
||||
func EnsureProfileDir(dir, dataDir string) error {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return fmt.Errorf("%w: profile 目录为空", ErrInvalidConfig)
|
||||
}
|
||||
if err := CheckProfileLocation(dir, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("browsercfg: 建 profile 目录 %s: %w", dir, err)
|
||||
}
|
||||
// 已存在的目录 MkdirAll 不会改权限,这里补一次 Chmod,保证「长期保留的凭据目录」
|
||||
// 不被更宽松的旧权限一直带着(Windows 上 Chmod 只影响只读位,属尽力而为)。
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("browsercfg: 收敛 profile 目录权限 %s: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckProfileLocation 核查 dir **不会进 git**。判定口径(务实版,见 design/13 §2.1):
|
||||
// profile 目录要么落在**数据目录**内(`$DATA_DIR` 按项目约定是 gitignored 的运行期区,
|
||||
// 仓库 `.gitignore` 有 `data/`),要么落在**任何 git 工作树之外**;两者都不满足 ⇒
|
||||
// ErrProfileInGitTree。这样默认落点 `./data/browser/profile` 合法,而把它指到 `./configs/`
|
||||
// 或仓库根这种会被 `git add` 收走的位置就会被拦下。
|
||||
func CheckProfileLocation(dir, dataDir string) error {
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("browsercfg: 解析 profile 目录 %s: %w", dir, err)
|
||||
}
|
||||
if strings.TrimSpace(dataDir) != "" {
|
||||
root, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("browsercfg: 解析数据目录 %s: %w", dataDir, err)
|
||||
}
|
||||
if withinDir(abs, root) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if worktree, ok := hasGitAncestor(abs); ok {
|
||||
return fmt.Errorf("%w: %s 位于工作树 %s 内", ErrProfileInGitTree, abs, worktree)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withinDir 报告 child 是否等于 parent 或位于其下(两者都会先转绝对路径再比)。
|
||||
func withinDir(child, parent string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if rel == "." {
|
||||
return true
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// hasGitAncestor 从 dir 起向上逐级找 `.git`(目录,或 worktree 的 `.git` 文件)。
|
||||
// 命中即返回该工作树根;到文件系统根仍未命中返回 false。
|
||||
func hasGitAncestor(dir string) (string, bool) {
|
||||
for cur := dir; ; {
|
||||
if _, err := os.Stat(filepath.Join(cur, ".git")); err == nil {
|
||||
return cur, true
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return "", false
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user