package browser // 本文件是 `browser` 插件向**面板**声明的管理页面(design/09 §5.2 的 `adminPanels`, // design/28 §8 B4 的落点):形状即 `pkg/plugins/sdk` 的 `Panel`。 // // 声明只说三件事:**要什么字段、怎么渲染、动作叫什么**。它 // - 不注入任何 JS/CSS/HTML、 // - 不给 URL(只有动作名,走宿主的通用动作端点分发)、 // - 不开任何路由。 // 这三条是 design/09 §5.2.1 的红线:声明而非编码、声明里塞不进请求面(防 SSRF)、 // 面板数据一律经插件管理器的端点。渲染与流程由宿主负责,本文件只负责「说得清楚」。 // // 页面构成(一处读全): // // ① form 浏览器配置 —— 有头/无头、视口、profile 目录、并发上限…(保存即热重载) // ② table 运行状态 —— 只读事实面 + 行内动作「拍一张(当前页面)」 // ③ table 指纹自检 —— 只读事实面 + 行内动作「重新自检」(唯一会起浏览器的读面动作) // ④ table 最近截图 —— 只统计(字节/尺寸/格式),不含路径与内容 // // 为什么状态/自检/截图都做成 **table** 而不是 `status`:`status` 区块按 §5.2.1 的接入点表 // **不消费 `source`**(`paneldecl.shapeOf` 明写 notAllowed),没有来源就没有活数据 —— 硬要 // 用 status 只能给一份声明期的静态文本,那才是真正的「看起来有、其实没有」。表格是 // 声明面里唯一既有来源又能带按钮的形状,因此三块活数据都用它(同为一处口径)。 import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // PanelPluginID 是本插件在面板体系里的 id。它与可变源的文件名**同源**: // `$DATA_DIR/config/plugins/browser.json`(design/13 §2.1:文件名 = 插件 id), // 两边由 panel_test.go 钉住。 const PanelPluginID = "browser" // PanelIDSettings 是「浏览器与截图」页的面板 id。 // 前端路由为 `/admin/plugin/browser/settings`(design/09 §5.7 C)。 const PanelIDSettings = "settings" // 本插件声明的**全部**动作名(design/09 §5.2.1 的动作面)。 // // 表里的动作名一律取自这里,通用动作端点也只接受这几个名字(未声明 ⇒ 404/403)。 // 把「声明」与「动作名」放同一个文件,是为了让「表里写了一个没实现的动作」这种半渲染 // 在 review 时一眼可见 —— 它们就挨着。 const ( // ActionState 是运行状态只读来源(表格数据源)。**不启动浏览器**。 ActionState = "plugin.getBrowserState" // ActionShots 是最近截图只读来源(表格数据源)。**不读文件、不含内容**。 ActionShots = "plugin.listShots" // ActionFingerprint 是最后一次指纹自检的只读来源(表格数据源),未自检过时如实说「未自检」。 ActionFingerprint = "plugin.getFingerprint" // ActionSaveConfig 保存浏览器配置(表单提交)。落 `$DATA_DIR/config/plugins/browser.json` // 并**热重载**工具(design/09 §5.5 的口径:面板的意义就是不改 `.env`、不重启)。 ActionSaveConfig = "plugin.saveBrowserConfig" // ActionShoot 拍一张(行内动作)。owner-only + 落审计(design/28 §1 M3)。 ActionShoot = "plugin.shootPage" // ActionCheck 重新做一次指纹自检(行内动作)。它会**启动浏览器** —— 这是本页唯一有 // 进程外副作用的动作,因此是显式按钮而绝非「打开页面就顺手跑」。 ActionCheck = "plugin.checkFingerprint" ) // ActionNames 返回本插件声明的动作名(顺序与 AdminPanels 里出现的顺序一致:写表单 → // 三个读来源 → 两个行内动作)。调用方(插件管理器的动作端点)据此建白名单: // 白名单外的动作名一律拒,绝不「先执行再看结果」。 func ActionNames() []string { return []string{ ActionSaveConfig, ActionState, ActionFingerprint, ActionShots, ActionShoot, ActionCheck, } } // 表里三块只读数据的列 key(与 paneldata.go 的行字段同源:声明一个 key、行里写另一个, // 表现就是「表头在、数据空」—— 所以两边只允许引用常量)。 const ( // 运行状态列 colSource = "source" colAvailable = "available" colHeadless = "headless" colViewport = "viewport" colProfileDir = "profileDir" colConcurrent = "concurrency" colIdleReco = "idleTimeoutSeconds" colShotCap = "shotMaxBytes" // 指纹自检列 colWebdriver = "webdriver" colScreen = "screen" colTimezone = "timezone" colLanguage = "language" colUserAgent = "userAgent" colCheckedAt = "checkedAt" // 最近截图列 colShotAt = "at" colShotBytes = "bytes" colShotSize = "size" colShotFormat = "format" colShotDegraded = "degraded" ) // 表单字段 key(与 `browsercfg.Config` 的 JSON tag 逐字一致 —— 字段名与文件字段名对上, // 面板写的键就是运行期读的键)。 const ( FieldHeadless = "headless" FieldExecutable = "executable" FieldCDPEndpoint = "cdpEndpoint" FieldProfileDir = "profileDir" FieldViewportWidth = "viewportWidth" FieldViewportHeight = "viewportHeight" FieldIdleTimeout = "idleTimeoutSeconds" FieldConcurrency = "concurrency" ) // PanelInput 是声明生成需要的**当前事实**:配置(给表单默认值)与可用性(决定描述文案 // 是否如实说明「这台机器现在没有浏览器」)。 // // 为什么默认值要由调用方传入而不是读文件:声明是**装配期**生成的(组合根一处), // 读文件是它唯一的事实来源;把「读哪份配置」的判断塞进声明函数会让「面板显示的」与 // 「工具真的用的」两处各自算一次。 type PanelInput struct { // Config 是**当前生效**的配置(已 Normalize)。 Config browsercfg.Config // Available 报告当前有没有可用来源。 Available bool // Source 是来源描述(本机 / 容器端点),只进描述文案。 Source string } // AdminPanels 返回本插件声明的管理页面。 func AdminPanels(in PanelInput) []sdk.Panel { return []sdk.Panel{{ ID: PanelIDSettings, Title: "浏览器与截图", Description: panelDescription(in), Group: sdk.GroupPlugins, Order: 30, Icon: "browser", // 未启用的插件 ⇒ 入口不出现(design/09 §5.2.1 nav.item)。browser 是编进二进制的 // 内置能力,没有卸载路径 ⇒ 恒为启用;写条件是为了让「启用事实」由插件管理器给出。 When: &sdk.When{Enabled: boolFlag(true)}, Sections: []sdk.Section{ configSection(in.Config), stateSection(), fingerprintSection(), shotsSection(), }, }} } // panelDescription 拼页面描述。不可用时**如实说明**(照 web-search `off` 的诚实口径: // 面板可以配,配好保存后热重载生效;没有来源时工具不会注册)。 func panelDescription(in PanelInput) string { base := "配置这个浏览器(有头/无头、视口、profile 目录、并发上限),查看运行状态与指纹自检," + "并拍一张当前页面。改动落 $DATA_DIR/config/plugins/browser.json,**保存后立即生效**" + "(工具会按新配置重建,无需重启)。" if in.Available { return base + "当前来源:" + in.Source + "。" } return base + "⚠️ 当前**没有**可用来源(未探测到本机 Chrome/Chromium,也没有在跑的 CDP 端点):" + "`browser` 工具此刻**不在**模型的能力面里;在这里配好路径/端点并保存,重建后即生效。" } // configSection 是配置表单:字段与 `browsercfg.Config` 同源,默认值取当前生效值 // (面板因此「打开即显示现状」)。 func configSection(cfg browsercfg.Config) sdk.Section { one := 1.0 return sdk.Section{ Type: sdk.SectionForm, ID: "config", Title: "浏览器配置", Fields: []sdk.Field{ {Key: FieldHeadless, Label: "无头模式", Type: sdk.FieldSwitch, Default: cfg.Headless, Placeholder: "关 = 有头(默认;过风控的主路径)"}, {Key: FieldViewportWidth, Label: "视口宽", Type: sdk.FieldNumber, Default: cfg.ViewportWidth, Min: floatPtr(320), Max: floatPtr(float64(browsercfg.MaxViewportWidth))}, {Key: FieldViewportHeight, Label: "视口高", Type: sdk.FieldNumber, Default: cfg.ViewportHeight, Min: floatPtr(240), Max: floatPtr(float64(browsercfg.MaxViewportHeight))}, {Key: FieldProfileDir, Label: "profile 目录", Type: sdk.FieldText, Default: cfg.ProfileDir, Placeholder: "含 cookie/登录态;默认 $DATA_DIR/browser/profile"}, {Key: FieldConcurrency, Label: "并发上限", Type: sdk.FieldNumber, Default: cfg.Concurrency, Min: &one, Max: &one, Placeholder: "固定 1:同一 profile 起两个实例会互锁"}, {Key: FieldIdleTimeout, Label: "空闲回收(秒)", Type: sdk.FieldNumber, Default: cfg.IdleTimeoutSeconds, Min: floatPtr(1)}, {Key: FieldExecutable, Label: "浏览器可执行文件", Type: sdk.FieldText, Default: cfg.Executable, Placeholder: "留空 = 自动探测本机 Chrome/Chromium"}, {Key: FieldCDPEndpoint, Label: "CDP 端点", Type: sdk.FieldText, Default: cfg.CDPEndpoint, Placeholder: "如 http://127.0.0.1:9222(显式给出时取代自动探测)"}, }, Submit: &sdk.Submit{Action: ActionSaveConfig}, } } // stateSection 是「运行状态」:只读事实面 + 行内动作「拍一张」。 func stateSection() sdk.Section { return sdk.Section{ Type: sdk.SectionTable, ID: "state", Title: "运行状态", Source: &sdk.Source{Action: ActionState}, Columns: []sdk.Column{ {Key: colSource, Label: "来源"}, {Key: colAvailable, Label: "可用"}, {Key: colHeadless, Label: "无头"}, {Key: colViewport, Label: "视口"}, {Key: colProfileDir, Label: "profile"}, {Key: colConcurrent, Label: "并发"}, {Key: colIdleReco, Label: "空闲回收(秒)"}, {Key: colShotCap, Label: "截图上限(字节)"}, }, RowActions: []sdk.RowAction{ {ID: "shoot", Label: "拍一张(当前页面)", Action: ActionShoot}, }, } } // fingerprintSection 是「指纹自检」:默认只显示**上一次**读数(不启动浏览器), // 要刷新得点行内动作(那是显式的、有进程外副作用的动作)。 func fingerprintSection() sdk.Section { return sdk.Section{ Type: sdk.SectionTable, ID: "fingerprint", Title: "指纹自检", Source: &sdk.Source{Action: ActionFingerprint}, Columns: []sdk.Column{ {Key: colWebdriver, Label: "webdriver"}, {Key: colScreen, Label: "屏幕"}, {Key: colTimezone, Label: "时区"}, {Key: colLanguage, Label: "语言"}, {Key: colUserAgent, Label: "User-Agent"}, {Key: colCheckedAt, Label: "自检时间"}, }, RowActions: []sdk.RowAction{ {ID: "check", Label: "重新自检(会启动浏览器)", Action: ActionCheck}, }, } } // shotsSection 是「最近截图」:只有统计(时间/字节/尺寸/格式),**不含路径与内容**。 func shotsSection() sdk.Section { return sdk.Section{ Type: sdk.SectionTable, ID: "shots", Title: "最近截图", Source: &sdk.Source{Action: ActionShots}, Columns: []sdk.Column{ {Key: colShotAt, Label: "时间"}, {Key: colShotBytes, Label: "字节"}, {Key: colShotSize, Label: "尺寸"}, {Key: colShotFormat, Label: "格式"}, {Key: colShotDegraded, Label: "降级"}, }, } } // boolFlag 是 `&true` / `&false` 的简写(sdk.When.Enabled 是三态指针)。 func boolFlag(v bool) *bool { return &v } // floatPtr 是 `&1.0` 的简写(sdk.Field.Min/Max 是指针,缺省与「显式 0」不同义)。 func floatPtr(v float64) *float64 { return &v }