Files
cdp_hub/cmd/chub/main.go
T

270 lines
7.7 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"chub/internal/domain"
"chub/internal/platform/browser"
"chub/internal/platform/config"
"chub/internal/platform/files"
"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"
func main() {
if len(os.Args) > 1 {
os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr))
}
ctx := context.Background()
logger := logging.New(os.Stderr)
logger.InfoContext(ctx, "chub starting", "version", version)
go runWindow(logger)
app.Main()
}
type cliError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func runCLI(args []string, out, errout io.Writer) int {
if len(args) == 0 {
return 2
}
command := args[0]
if command == "--help" || command == "-h" {
fmt.Fprintln(out, "chub [start|list|stop|restart|events]")
return 0
}
path, err := config.DefaultPath()
if err != nil {
writeCLIError(errout, "system_error", err.Error())
return 6
}
store, err := config.New(path)
if err != nil {
writeCLIError(errout, "system_error", err.Error())
return 6
}
switch command {
case "list":
value, err := store.Load()
if err != nil {
writeCLIError(errout, "system_error", err.Error())
return 6
}
return writeJSON(out, map[string]any{"instances": value.Instances})
case "events":
// T-203 exposes a local JSON-lines snapshot; the live in-process bus is
// consumed by the UI and will be connected to a loopback transport later.
value, err := store.Load()
if err != nil {
writeCLIError(errout, "system_error", err.Error())
return 6
}
return writeJSON(out, map[string]any{"event": "browser.snapshot", "instances": value.Instances})
case "start", "stop", "restart":
writeCLIError(errout, "service_unavailable", strings.TrimSpace(command)+" requires the BrowserManager adapter")
return 6
default:
writeCLIError(errout, "invalid_argument", "unknown command: "+command)
return 2
}
}
func writeJSON(out io.Writer, value any) int {
if err := json.NewEncoder(out).Encode(value); err != nil {
return 6
}
return 0
}
func writeCLIError(out io.Writer, code, message string) {
_ = json.NewEncoder(out).Encode(cliError{Code: code, Message: message})
}
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)
directoryPicker := files.NewDirectoryPicker()
shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate)
discoverer := browser.NewDiscoverer()
shell.OnStartInstance(instanceStarter{launcher: browser.NewOSLauncher(), resolver: discoverer}.Start, window.Invalidate)
shell.OnPathSearch(func(ctx context.Context, field ui.PathField, current string) (string, error) {
switch field {
case ui.PathChromeExecutable:
return discoverer.Resolve(ctx, domain.BrowserChrome, "")
case ui.PathEdgeExecutable:
return discoverer.Resolve(ctx, domain.BrowserEdge, "")
case ui.PathDefaultUserData, ui.PathLogDirectory:
if err := ctx.Err(); err != nil {
return "", err
}
path := strings.TrimSpace(current)
if path == "" {
return "", errors.New("请先输入要验证的目录,或使用选择按钮")
}
info, err := os.Stat(path)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", errors.New("该路径不是目录")
}
return filepath.Clean(path), nil
default:
return "", errors.New("不支持的搜索字段")
}
}, window.Invalidate)
if path, err := config.DefaultPath(); err == nil {
if store, err := config.New(path); err == nil {
if saved, err := store.Load(); err == nil {
shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, LogDir: saved.Settings.LogDir, CloseOnExit: saved.Settings.CloseOnExit})
rows := make([]ui.InstanceRow, 0, len(saved.Instances))
for _, item := range saved.Instances {
rows = append(rows, ui.InstanceRow{ID: item.ID, Name: item.Name, Browser: browserLabel(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, TargetURL: item.Launch.TargetURL, Status: "已退出"})
}
shell.SetInstances(rows)
shell.OnSave(func(value ui.SettingsState) {
saved.Settings = config.Settings{ChromePath: value.ChromePath, EdgePath: value.EdgePath, DefaultDir: value.DefaultDir, LogDir: value.LogDir, CloseOnExit: value.CloseOnExit}
go saveSettings(logger, store, configSnapshot(saved))
})
shell.OnInstancesChanged(func(rows []ui.InstanceRow) {
saved.Instances = mergeInstanceConfig(saved.Instances, rows)
go saveSettings(logger, store, configSnapshot(saved))
})
}
}
}
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)
}
}
}
func saveSettings(logger *slog.Logger, store *config.Store, value config.File) {
err := store.Save(value)
if err != nil {
logger.Error("save settings", "error", err)
}
}
type executableResolver interface {
Resolve(context.Context, domain.BrowserKind, string) (string, error)
}
type processLauncher interface {
Start(context.Context, domain.LaunchSpec) (browser.ProcessHandle, error)
}
type instanceStarter struct {
launcher processLauncher
resolver executableResolver
}
func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState) (int, error) {
kind, err := browserKind(row.Browser)
if err != nil {
return 0, err
}
if s.launcher == nil || s.resolver == nil {
return 0, errors.New("浏览器启动服务尚未准备好")
}
configuredExecutable := settings.ChromePath
if kind == domain.BrowserEdge {
configuredExecutable = settings.EdgePath
}
executable, err := s.resolver.Resolve(ctx, kind, configuredExecutable)
if err != nil {
return 0, fmt.Errorf("无法找到%s可执行文件:%w", browserLabel(kind), err)
}
handle, err := s.launcher.Start(ctx, domain.LaunchSpec{
Kind: kind,
Executable: executable,
UserDataDir: row.UserDataDir,
TargetURL: row.TargetURL,
})
if err != nil {
return 0, err
}
return handle.PID(), nil
}
func browserKind(value string) (domain.BrowserKind, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "chrome":
return domain.BrowserChrome, nil
case "edge":
return domain.BrowserEdge, nil
default:
return "", fmt.Errorf("不支持的浏览器类型:%s", value)
}
}
func browserLabel(kind domain.BrowserKind) string {
if kind == domain.BrowserEdge {
return "Edge"
}
return "Chrome"
}
func mergeInstanceConfig(existing []config.Instance, rows []ui.InstanceRow) []config.Instance {
byID := make(map[string]config.Instance, len(existing))
for _, item := range existing {
byID[item.ID] = item
}
updated := make([]config.Instance, 0, len(rows))
for _, row := range rows {
kind, err := browserKind(row.Browser)
if err != nil {
continue
}
item := byID[row.ID]
item.ID = row.ID
item.Name = row.Name
item.Launch.Kind = kind
item.Launch.UserDataDir = row.UserDataDir
item.Launch.TargetURL = row.TargetURL
item.UpdatedAt = time.Now()
updated = append(updated, item)
}
return updated
}
func configSnapshot(value config.File) config.File {
clone := value
clone.Instances = append([]config.Instance(nil), value.Instances...)
for i := range clone.Instances {
clone.Instances[i].Launch.ExtraArgs = append([]string(nil), clone.Instances[i].Launch.ExtraArgs...)
}
return clone
}