package main // 本文件是 browser 面板的**数据面**(切片 Y4 / B4):把动作载荷翻成配置补丁、把配置 // 写盘/回滚,以及保存之后的**热重载**(重建工具 + 换注册表条目)。 // // 拆出来的理由同 web-search 那一组:`wire_browser_panel.go` 只留「名字 → 处理器」的接线, // 载荷解析与文件事务各自成文件,函数才守得住 ≤80 行。 // // 两条口径: // // 1. **补丁是补丁**:前端提交的是 `{values:{…}}`,而 `planSubmit` 只发**改动过的**字段 // (见 `frontend/web/src/admin/schema/values.ts`)⇒ 没出现的键一律**保持原值**, // 绝不把「没提到」当成「清空」(那是静默清空的经典事故,design/09 §5.4.2); // 2. **回滚是凭据**:保存前先把盘上那一份读进内存,热重载失败就写回去 —— 绝不把后端 // 留在「盘上这份配置让它下次起不来」的状态。 import ( "fmt" "log/slog" "net/http" "os" "strings" browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" ) // browserConfigRollbackMode 是回滚写回时的文件权限:与 `browsercfg.Save` 的 0600 同口径 // (那份文件带显式 CDP 端点与 profile 位置)。硬编码 0600 而不是引用对方的常量,是因为 // 变化方向是「收紧」,不存在比 0600 更松的风险。 const browserConfigRollbackMode os.FileMode = 0o600 // browserConfigPatch 解析表单提交的载荷(`{values:{…}}`)并做**形状**校验: // 只接受本面板声明过的键,未知键一律拒(**绝不静默忽略** —— 静默忽略会让「我改了但它没生效」 // 变成查不出的谜,design/09 §5.2.1 的同一条口径)。 func browserConfigPatch(payload any) (map[string]any, error) { values, err := decodeUserValues(payload) if err != nil { return nil, err } known := map[string]bool{ browsertools.FieldHeadless: true, browsertools.FieldExecutable: true, browsertools.FieldCDPEndpoint: true, browsertools.FieldProfileDir: true, browsertools.FieldViewportWidth: true, browsertools.FieldViewportHeight: true, browsertools.FieldIdleTimeout: true, browsertools.FieldConcurrency: true, } out := make(map[string]any, len(values)) for key, raw := range values { if !known[key] { return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, fmt.Sprintf("未知字段 %q:本面板只接受 %d 个已声明字段", key, len(known)), http.StatusBadRequest, nil) } out[key] = raw } return out, nil } // applyBrowserConfigPatch 把补丁合进当前配置。未出现的键保持原值(见文件头口径 1)。 // // 类型不匹配一律报 400(`values` 已由前端按声明类型还原:number → 数字、switch → 布尔); // 越界值交给 `browsercfg.Validate` 判(它只判**原始值**,不 Normalize —— 悄悄改小会让 // 面板显示的与生效的不是一回事)。 func applyBrowserConfigPatch(cur browsercfg.Config, patch map[string]any) (browsercfg.Config, error) { out := cur for key, raw := range patch { var err error switch key { case browsertools.FieldHeadless: out.Headless, err = patchBool(key, raw) case browsertools.FieldExecutable: out.Executable, err = requireString(key, raw) case browsertools.FieldCDPEndpoint: out.CDPEndpoint, err = requireString(key, raw) case browsertools.FieldProfileDir: out.ProfileDir, err = requireString(key, raw) case browsertools.FieldViewportWidth: out.ViewportWidth, err = patchInt(key, raw) case browsertools.FieldViewportHeight: out.ViewportHeight, err = patchInt(key, raw) case browsertools.FieldIdleTimeout: out.IdleTimeoutSeconds, err = patchInt(key, raw) case browsertools.FieldConcurrency: out.Concurrency, err = patchInt(key, raw) default: err = fmt.Errorf("未知字段 %q", key) } if err != nil { return browsercfg.Config{}, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, fmt.Sprintf("字段 %s 的取值不合法:%v", key, err), http.StatusBadRequest, err) } } if verr := out.Validate(); verr != nil { return browsercfg.Config{}, pluginmgr.NewActionError(browserCodeConfigRejected, "这份配置超出硬上限,**没有**落盘:"+verr.Error(), http.StatusConflict, verr) } return out, nil } // patchBool 取一个布尔补丁值。 func patchBool(key string, raw any) (bool, error) { v, ok := raw.(bool) if !ok { return false, fmt.Errorf("需要布尔值") } return v, nil } // patchInt 取一个数值补丁值(JSON 数字解出来是 float64;整数字符串也认,因为 // 「留空/清空」在某些控件上会以空串抵达)。 func patchInt(key string, raw any) (int, error) { switch v := raw.(type) { case float64: if v != float64(int(v)) { return 0, fmt.Errorf("需要整数") } return int(v), nil case int: return v, nil case string: trimmed := strings.TrimSpace(v) if trimmed == "" { // 清空 = 回落默认(Normalize 的口径:≤0 ⇒ 用默认值)。 return 0, nil } var n int if _, err := fmt.Sscanf(trimmed, "%d", &n); err != nil { return 0, fmt.Errorf("需要整数") } return n, nil default: return 0, fmt.Errorf("需要整数") } } // readConfigSnapshot 读一份文件快照(回滚凭据)。文件不存在时返回 (nil, false, nil) —— // 那是「保存前没有配置文件」这种合法状态,回滚时要把它恢复成「没有文件」。 func readConfigSnapshot(path string) ([]byte, bool, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, false, nil } return nil, false, fmt.Errorf("读配置快照 %s: %w", path, err) } return raw, true, nil } // restoreConfigSnapshot 把文件恢复到快照那一份;快照本来不存在 ⇒ 删掉它。 func restoreConfigSnapshot(path string, raw []byte, existed bool) error { if !existed { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("删除配置 %s: %w", path, err) } return nil } if err := os.WriteFile(path, raw, browserConfigRollbackMode); err != nil { return fmt.Errorf("回写配置 %s: %w", path, err) } return nil } // replaceBrowserTool 按**落盘后的配置**重建 browser 工具并替换能力面里的条目 // (design/09 §5.5 的口径:面板保存后下一轮即用新接线,不必重启)。 // // 返回 (applied, registered, reason): // - applied=true 报告工具真的被换掉/加上(reason 为空); // - applied=false 时 reason 是分类原因码(`browsercfg.Reason*` 或本文件的 // "replace-failed"/"register-failed"/"no-registry"),调用方据此决定回滚与文案。 // // 两种「不生效」都**不改动既有的工具**:没有可用来源时工具不该存在(判据 1 的诚实口径), // 但也没理由因为一次保存把已经在跑的那一把弄没(那是「保存一次就少一个能力」的事故形状)。 func (a *app) replaceBrowserTool(log *slog.Logger) (bool, bool, string) { if a == nil || a.caps == nil { return false, false, "no-registry" } existing, gerr := a.caps.Get(browsertools.ToolName) haveExisting := gerr == nil carry := shotOutboxOf(existing.Value) build := a.buildBrowserTool(carry) if !build.available { // 新配置下没有可用来源:工具**不注册**(design/28 §7 判据 1 的诚实口径)。 log.Warn("browser tool not rebuilt after config save", "reason", build.reason, "detail", build.detail, "configPath", build.configPath, "effect", browserNotRegisteredEffect) return false, haveExisting, build.reason } if haveExisting { if err := a.caps.ReplaceTool(build.tool); err != nil { closeBrowserManager(log, build.mgr) log.Warn("browser tool replace failed", "error", err) return false, true, "replace-failed" } closeOldBrowserTool(log, existing.Value) return true, true, "" } if err := a.caps.RegisterTool(build.tool); err != nil { closeBrowserManager(log, build.mgr) log.Warn("browser tool register failed after config save", "error", err) return false, false, "register-failed" } log.Info("browser tool registered after config save", "source", build.source, "headless", build.headless, "configSource", build.configSource) return true, true, "" } // shotOutboxOf 取旧条目上的附件箱(重建时沿用:模型刚拍完还没送出去的那一张不能丢)。 // 用结构断言而不是具体类型:换工具实现只要还暴露 ShotOutbox 就接得上。 func shotOutboxOf(value any) *browsertools.ShotOutbox { holder, ok := value.(interface { ShotOutbox() *browsertools.ShotOutbox }) if !ok { return nil } return holder.ShotOutbox() } // closeOldBrowserTool 关掉被换下来的工具(它持有进程外资源:我们起的真 Chromium、CDP 连接、 // profile 锁)。不关就是每次保存泄漏一个浏览器进程。 func closeOldBrowserTool(log *slog.Logger, value any) { closer, ok := value.(interface{ Close() error }) if !ok { return } if err := closer.Close(); err != nil { log.Warn("old browser tool close failed", "error", err) } } // closeBrowserManager 关掉刚建好但没能接上的管理器(注册被拒 / 替换失败时的收尾)。 func closeBrowserManager(log *slog.Logger, mgr *browsercap.Manager) { if mgr == nil { return } if err := mgr.Close(); err != nil { log.Warn("browser manager close failed", "error", err) } }