package main import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "strings" "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.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: string(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, 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, 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) } }