feat: add gio instances and settings shell
This commit is contained in:
+30
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 | 默认只监听本机。 |
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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 构建验证。
|
||||
|
||||
@@ -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 接入。
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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`)
|
||||
}
|
||||
Reference in New Issue
Block a user