diff --git a/cmd/chub/main.go b/cmd/chub/main.go index bb92dd7..d2e8324 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -2,10 +2,16 @@ package main import ( "context" - "fmt" + "log/slog" "os" "chub/internal/platform/logging" + "chub/internal/ui" + "gioui.org/app" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget/material" ) const version = "0.1.0-dev" @@ -14,5 +20,27 @@ func main() { ctx := context.Background() logger := logging.New(os.Stderr) logger.InfoContext(ctx, "chub starting", "version", version) - fmt.Printf("Chub %s\n", version) + go runWindow(logger) + app.Main() +} + +func runWindow(logger *slog.Logger) { + window := new(app.Window) + window.Option(app.Title("Chub 浏览器管理"), app.Size(unit.Dp(1100), unit.Dp(720))) + theme := material.NewTheme() + shell := ui.NewShell(theme) + var ops op.Ops + for { + switch event := window.Event().(type) { + case app.DestroyEvent: + if event.Err != nil { + logger.ErrorContext(context.Background(), "chub window closed", "error", event.Err) + } + return + case app.FrameEvent: + gtx := app.NewContext(&ops, event) + shell.Layout(layout.Context(gtx)) + event.Frame(gtx.Ops) + } + } } diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 2a0c998..2750e12 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -2,8 +2,8 @@ | 层 | 选择 | 约束 | | --- | --- | --- | -| 核心 | Go 1.23(当前基线) | 标准库优先,Windows API 通过小型适配层封装;升级到 Go 1.25+ 需重新验证构建。 | -| Windows UI | Go Gio(版本待锁定) | UI 线程不执行进程和文件 I/O;实现前制作 HTML 原型。 | +| 核心 | Go module `go 1.24` | 标准库优先,Windows API 通过小型适配层封装;当前环境使用 Go toolchain 自动切换到 1.25.12。 | +| Windows UI | Gio `v0.10.1` | UI 线程不执行进程和文件 I/O;原型已交接,widget 状态由 Shell 持有。 | | 持久化 | SQLite 或 JSON(MVP 前决策) | 配置不保存密码、Cookie、Token。 | | 进程 | `os/exec` + Windows API | 参数数组启动;Job Object、窗口消息和句柄按职责封装。 | | API | 本地 CLI,后续可选 loopback HTTP/WebSocket | 默认只监听本机。 | diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 49e0902..6f9d8e8 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -23,7 +23,7 @@ | ID | 任务 | 依赖 | 状态 | | --- | --- | --- | --- | -| T-201 | HTML 原型、Gio 外壳和实例列表/表单 | T-002 | TODO | +| T-201 | HTML 原型、Gio 外壳和实例列表/表单 | T-002 | DONE | | T-202 | 本地配置持久化和实例恢复 | T-101 | TODO | | T-203 | CLI 合约和本地事件推送 | T-103 | TODO | | T-204 | UI 全状态验收与 Windows 打包 smoke | T-201,T-202,T-203 | TODO | diff --git a/docs/current-state.md b/docs/current-state.md index 07c2077..5fe217d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -3,11 +3,11 @@ ## 快照 - 日期:2026-07-22 -- 阶段:文档与交互原型准备 -- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object 和真实 Chrome/Edge smoke,T-001 至 T-003、T-101 至 T-104 已完成 -- UI:尚未实现;P0 页面需要先制作 HTML 原型 +- 阶段:Phase 2 产品外壳(T-201 已完成) +- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object 和真实 Chrome/Edge smoke,T-001 至 T-003、T-101 至 T-104、T-201 已完成 +- UI:已接入 Gio v0.10.1 双页桌面壳,包含实例列表、状态展示、启动入口、设置表单;配置保存和真实服务接入由 T-202/T-203 完成 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;Phase 1 核心进程任务已完成,下一个任务为 T-201(Gio 外壳与 UI 原型交接) +- blocker:无;下一个任务为 T-202(本地配置持久化和实例恢复) ## 当前目录 @@ -16,8 +16,9 @@ | `docs/` | 已建立 | Harness Coding 项目文档 | | `docs/ui/` | 已建立 | Chub HTML 交互原型 | | `docs/tasks/` | 已建立 | 一任务一文件目录 | -| `cmd/` | 待建 | Go 命令入口 | -| `cmd/chub/` | 已建立 | Go 命令入口和版本输出 | +| `cmd/` | 已建立 | Go 命令入口 | +| `cmd/chub/` | 已建立 | Gio 窗口入口和版本常量 | +| `internal/ui/` | 已建立 | Gio 实例/设置双页 Shell | | `internal/platform/logging/` | 已建立 | 可注入的结构化 JSON 日志 | | `internal/` | 部分建立 | 后续扩展 application/domain/platform 实现 | @@ -39,4 +40,4 @@ go build -o build/chub.exe ./cmd/chub 本机 Chrome 和 Edge 均已通过 headless 双实例 smoke;headless 不提供可见 message window,因此外部 profile lock/window 证据仍是后续补充验证项。 -`shop_helm` 仅是参考项目,不是本项目代码目录。当前基线已通过 T-001 的测试、vet、构建和启动验证。 +`shop_helm` 仅是参考项目,不是本项目代码目录。当前基线已通过 T-201 的测试、vet 和 Windows 构建验证。 diff --git a/docs/tasks/T-201.md b/docs/tasks/T-201.md new file mode 100644 index 0000000..a8113a8 --- /dev/null +++ b/docs/tasks/T-201.md @@ -0,0 +1,36 @@ +--- +id: T-201 +title: 完成 HTML 原型交接、Gio 外壳和实例列表/表单 +phase: 2 +deps: [T-002] +status: DONE +created: 2026-07-22 +owner: codex +--- + +## 需求与背景 + +把已确认的 `docs/ui/browser-manager.html` 交接为可运行的 Windows 桌面 UI 基础,先建立实例与设置两个稳定入口,再接入真实服务和配置存储。 + +## 方案与边界 + +- 使用 Gio `v0.10.1`,窗口入口位于 `cmd/chub`,页面壳位于 `internal/ui`。 +- 顶部只保留“实例”和“设置”两个导航入口;活动信息后续放入实例详情,不增加第三个模块。 +- 实例列表当前使用明确标注的 mock view model,展示 Chrome/Edge、Profile、运行中/启动中/外部占用/已退出状态。 +- 设置页先完成 Chrome/Edge 路径、默认 User Data Dir、日志目录和退出行为表单;保存接入由 T-202 完成。 +- 所有 Gio editor、clickable、list 和 checkbox 状态均由 `Shell` 持有,避免每帧重建。 + +## 验收要点 + +- `go test ./...`、`go vet ./...` 通过。 +- `go build -o build/chub.exe ./cmd/chub` 通过。 +- Windows 窗口默认显示实例页,能切换设置页;交互控件具备稳定状态。 +- 不在 FrameEvent 中执行进程、文件或网络 I/O。 + +## 执行记录 + +- 状态:DONE +- 变更:锁定 Go module `go 1.24` 与 Gio `v0.10.1`;新增双页 `internal/ui.Shell`,更新 `cmd/chub` 为 Gio 窗口入口,并同步技术栈、当前状态和路线图文档。 +- 验证:`gofmt`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub` 均通过。 +- 阻塞:无。 +- 残余风险:当前实例数据仍为 mock,设置保存尚未写入本地配置;分别由 T-202 和 T-203 接入。 diff --git a/go.mod b/go.mod index a25e9b2..534b16d 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,15 @@ module chub -go 1.23 +go 1.24.0 +require gioui.org v0.10.1 + +require ( + gioui.org/shader v1.0.8 // indirect + github.com/go-text/typesetting v0.3.4 // indirect + golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/image v0.26.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..24b5ba7 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= +gioui.org v0.10.1 h1:Dvp6iDk9RKuZk19jxhOmb4p673CLVvb656LyMxQ+uO0= +gioui.org v0.10.1/go.mod h1:MZJZsdEPkTBzChdqeE8CiiQhreUQBj43qusDxQNDf7k= +gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= +gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= +github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= +golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/internal/ui/shell.go b/internal/ui/shell.go new file mode 100644 index 0000000..a2196b9 --- /dev/null +++ b/internal/ui/shell.go @@ -0,0 +1,173 @@ +package ui + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" +) + +type page uint8 + +const ( + pageInstances page = iota + pageSettings +) + +// InstanceRow is the read-only view model used by the first Gio shell. +// Runtime data will replace these fixtures when the application service is wired. +type InstanceRow struct { + Name string + Browser string + Profile string + Status string +} + +// Shell owns every interactive Gio widget. Keeping this state outside Layout +// prevents focus, editor content, and list scroll position from resetting. +type Shell struct { + theme *material.Theme + page page + + instancesClick widget.Clickable + settingsClick widget.Clickable + newClick widget.Clickable + startClick widget.Clickable + saveClick widget.Clickable + cancelClick widget.Clickable + + chromePath widget.Editor + edgePath widget.Editor + dataDir widget.Editor + logDir widget.Editor + closeOnExit widget.Bool + + list widget.List + rows []InstanceRow +} + +func NewShell(theme *material.Theme) *Shell { + s := &Shell{theme: theme, rows: []InstanceRow{ + {Name: "运营主账号", Browser: "Chrome", Profile: "Default", Status: "运行中"}, + {Name: "广告投放", Browser: "Edge", Profile: "Profile 2", Status: "启动中"}, + {Name: "素材采集", Browser: "Chrome", Profile: "Default", Status: "外部占用"}, + {Name: "备用环境", Browser: "Edge", Profile: "Profile 1", Status: "已退出"}, + }} + s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) + s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) + s.dataDir.SetText(`C:\Users\Public\chub\profiles`) + s.logDir.SetText(`C:\Users\Public\chub\logs`) + s.closeOnExit.Value = true + return s +} + +func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { + for s.instancesClick.Clicked(gtx) { + s.page = pageInstances + } + for s.settingsClick.Clicked(gtx) { + s.page = pageSettings + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(s.header), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: unit.Dp(20), Bottom: unit.Dp(20), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content) + }), + ) +} + +func (s *Shell) header(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: unit.Dp(18), Bottom: unit.Dp(10), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, material.H5(s.theme, "Chub 浏览器管理").Layout), + layout.Rigid(material.Button(s.theme, &s.instancesClick, "实例").Layout), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(material.Button(s.theme, &s.settingsClick, "设置").Layout), + ) + }) +} + +func (s *Shell) content(gtx layout.Context) layout.Dimensions { + if s.page == pageSettings { + return s.settings(gtx) + } + return s.instances(gtx) +} + +func (s *Shell) instances(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.H4(s.theme, "实例").Layout), + layout.Rigid(material.Body1(s.theme, "统一查看、启动和管理 Chrome / Edge 浏览器进程").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), + layout.Rigid(material.Button(s.theme, &s.newClick, "新建实例").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return material.List(s.theme, &s.list).Layout(gtx, len(s.rows), func(gtx layout.Context, i int) layout.Dimensions { + row := s.rows[i] + return layout.Inset{Bottom: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Subtitle1(s.theme, row.Name).Layout), + layout.Rigid(material.Caption(s.theme, row.Browser+" · "+row.Profile).Layout), + ) + }), + layout.Rigid(statusLabel(s.theme, row.Status)), + layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout), + layout.Rigid(material.Button(s.theme, &s.startClick, "启动").Layout), + ) + }) + }) + }), + ) +} + +func statusLabel(theme *material.Theme, status string) layout.Widget { + style := material.Label(theme, unit.Sp(13), status) + style.Color = map[string]color.NRGBA{ + "运行中": {R: 24, G: 125, B: 78, A: 255}, + "启动中": {R: 175, G: 105, B: 0, A: 255}, + "外部占用": {R: 190, G: 75, B: 35, A: 255}, + "已退出": {R: 100, G: 105, B: 115, A: 255}, + }[status] + return style.Layout +} + +func (s *Shell) settings(gtx layout.Context) layout.Dimensions { + for s.saveClick.Clicked(gtx) { /* persistence is T-202; retain edited values for now */ + } + for s.cancelClick.Clicked(gtx) { + s.resetSettings() + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.H4(s.theme, "设置").Layout), + layout.Rigid(material.Body1(s.theme, "配置浏览器可执行文件、默认目录和本地运行行为").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), + layout.Rigid(material.Editor(s.theme, &s.chromePath, "Chrome 可执行文件").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(material.Editor(s.theme, &s.edgePath, "Edge 可执行文件").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(material.Editor(s.theme, &s.dataDir, "默认 User Data Dir").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(material.Editor(s.theme, &s.logDir, "日志目录").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(material.CheckBox(s.theme, &s.closeOnExit, "退出时关闭托管浏览器").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(material.Button(s.theme, &s.saveClick, "保存设置").Layout), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(material.Button(s.theme, &s.cancelClick, "取消").Layout), + ) + }), + ) +} + +func (s *Shell) resetSettings() { + s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) + s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) + s.dataDir.SetText(`C:\Users\Public\chub\profiles`) + s.logDir.SetText(`C:\Users\Public\chub\logs`) +}