2026-07-22 14:51:11 +08:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-07-22 15:19:11 +08:00
|
|
|
"encoding/json"
|
2026-07-22 15:57:02 +08:00
|
|
|
"errors"
|
2026-07-22 15:19:11 +08:00
|
|
|
"fmt"
|
|
|
|
|
"io"
|
2026-07-22 15:13:55 +08:00
|
|
|
"log/slog"
|
2026-07-22 14:51:11 +08:00
|
|
|
"os"
|
2026-07-22 15:57:02 +08:00
|
|
|
"path/filepath"
|
2026-07-22 15:19:11 +08:00
|
|
|
"strings"
|
2026-07-25 17:04:26 +08:00
|
|
|
"sync"
|
2026-07-22 17:02:59 +08:00
|
|
|
"time"
|
2026-07-22 14:51:11 +08:00
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
"chub/internal/domain"
|
|
|
|
|
"chub/internal/platform/browser"
|
2026-07-22 15:17:08 +08:00
|
|
|
"chub/internal/platform/config"
|
2026-07-22 16:06:39 +08:00
|
|
|
"chub/internal/platform/files"
|
2026-07-22 14:51:11 +08:00
|
|
|
"chub/internal/platform/logging"
|
2026-07-22 15:13:55 +08:00
|
|
|
"chub/internal/ui"
|
|
|
|
|
"gioui.org/app"
|
|
|
|
|
"gioui.org/layout"
|
|
|
|
|
"gioui.org/op"
|
|
|
|
|
"gioui.org/unit"
|
|
|
|
|
"gioui.org/widget/material"
|
2026-07-22 14:51:11 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const version = "0.1.0-dev"
|
|
|
|
|
|
2026-07-27 12:09:54 +08:00
|
|
|
var (
|
2026-07-28 00:12:29 +08:00
|
|
|
openDefaultConfig = config.OpenDefault
|
|
|
|
|
defaultPortableDirectories = config.DefaultPortableDirectories
|
2026-07-27 12:09:54 +08:00
|
|
|
)
|
2026-07-27 11:33:22 +08:00
|
|
|
|
2026-07-22 14:51:11 +08:00
|
|
|
func main() {
|
2026-07-22 15:19:11 +08:00
|
|
|
if len(os.Args) > 1 {
|
|
|
|
|
os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr))
|
|
|
|
|
}
|
2026-07-22 14:51:11 +08:00
|
|
|
ctx := context.Background()
|
|
|
|
|
logger := logging.New(os.Stderr)
|
|
|
|
|
logger.InfoContext(ctx, "chub starting", "version", version)
|
2026-07-22 15:13:55 +08:00
|
|
|
go runWindow(logger)
|
|
|
|
|
app.Main()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 15:19:11 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-27 11:33:22 +08:00
|
|
|
store, err := openDefaultConfig()
|
2026-07-22 15:19:11 +08:00
|
|
|
if err != nil {
|
2026-07-27 11:33:22 +08:00
|
|
|
writeCLIError(errout, "system_error", "default configuration is unavailable")
|
2026-07-22 15:19:11 +08:00
|
|
|
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})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 15:13:55 +08:00
|
|
|
func runWindow(logger *slog.Logger) {
|
|
|
|
|
window := new(app.Window)
|
2026-07-22 15:57:02 +08:00
|
|
|
window.Option(app.Title("Chub"), app.Size(unit.Dp(1100), unit.Dp(720)))
|
2026-07-22 15:13:55 +08:00
|
|
|
theme := material.NewTheme()
|
|
|
|
|
shell := ui.NewShell(theme)
|
2026-07-28 00:12:29 +08:00
|
|
|
if directories, err := defaultPortableDirectories(); err != nil {
|
|
|
|
|
logger.Error("resolve portable directories", "error", err)
|
|
|
|
|
shell.ReportStartupIssue("无法确定程序目录中的默认 User Data Dir 和日志目录;请检查程序位置后重试。")
|
2026-07-27 12:09:54 +08:00
|
|
|
} else {
|
2026-07-28 00:12:29 +08:00
|
|
|
shell.SetPortableDirectoryDefaults(directories.DefaultDir, directories.LogDir)
|
2026-07-27 12:09:54 +08:00
|
|
|
}
|
2026-07-22 16:06:39 +08:00
|
|
|
directoryPicker := files.NewDirectoryPicker()
|
|
|
|
|
shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate)
|
2026-07-27 22:46:31 +08:00
|
|
|
executablePicker := files.NewExecutablePicker()
|
|
|
|
|
shell.OnChooseExecutable(executablePicker.ChooseExecutable, window.Invalidate)
|
2026-07-22 15:57:02 +08:00
|
|
|
discoverer := browser.NewDiscoverer()
|
2026-07-25 15:53:31 +08:00
|
|
|
launcher := browser.NewOSLauncher()
|
|
|
|
|
cdp := browser.NewCDPInspector()
|
2026-07-25 16:36:26 +08:00
|
|
|
externalProfiles := browser.NewWindowsProfileInspector()
|
2026-07-25 17:04:26 +08:00
|
|
|
proxies := newProxyDirectory()
|
2026-07-25 18:23:33 +08:00
|
|
|
exitMonitor := newManagedExitMonitor(shell.ReportManagedExit, window.Invalidate)
|
|
|
|
|
defer exitMonitor.Close()
|
2026-07-25 15:53:31 +08:00
|
|
|
shell.OnStartInstance(instanceStarter{
|
|
|
|
|
launcher: launcher,
|
|
|
|
|
resolver: discoverer,
|
|
|
|
|
managedProfiles: launcher,
|
2026-07-25 16:36:26 +08:00
|
|
|
externalProfiles: externalProfiles,
|
2026-07-25 15:53:31 +08:00
|
|
|
remoteDebug: cdp,
|
|
|
|
|
portAllocator: cdp,
|
2026-07-25 17:04:26 +08:00
|
|
|
proxyResolver: proxies,
|
2026-07-25 18:23:33 +08:00
|
|
|
monitor: exitMonitor,
|
2026-07-28 00:12:29 +08:00
|
|
|
dataPreparer: files.NewInstanceDataPreparer(),
|
2026-07-25 15:53:31 +08:00
|
|
|
}.Start, window.Invalidate)
|
2026-07-25 18:23:33 +08:00
|
|
|
shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher, monitor: exitMonitor}.Stop, window.Invalidate)
|
2026-07-27 23:16:11 +08:00
|
|
|
dataRemover := files.NewInstanceDataRemover()
|
|
|
|
|
shell.OnDeleteInstanceData(instanceDataDeleter{
|
|
|
|
|
managedProfiles: launcher,
|
|
|
|
|
externalProfiles: externalProfiles,
|
|
|
|
|
dataRemover: dataRemover,
|
|
|
|
|
}.Delete, window.Invalidate)
|
2026-07-25 16:36:26 +08:00
|
|
|
shell.OnRefreshInstances(instanceStatusRefresher{
|
|
|
|
|
managedProfiles: launcher,
|
|
|
|
|
externalProfiles: externalProfiles,
|
|
|
|
|
remoteDebug: cdp,
|
|
|
|
|
}.Refresh, window.Invalidate)
|
2026-07-27 10:01:07 +08:00
|
|
|
shell.OnInspectTabs(instanceTabsInspector{remoteDebug: cdp}.Inspect, window.Invalidate)
|
2026-07-22 15:57:02 +08:00
|
|
|
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)
|
2026-07-27 11:33:22 +08:00
|
|
|
store, err := openDefaultConfig()
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Error("open default configuration", "error", err)
|
|
|
|
|
shell.ReportStartupIssue("程序目录中的 config.json 无法使用;请检查目录权限或恢复旧配置。")
|
|
|
|
|
} else if saved, err := store.Load(); err != nil {
|
|
|
|
|
logger.Error("load default configuration", "error", err)
|
|
|
|
|
shell.ReportStartupIssue("程序目录中的 config.json 无法读取;请恢复有效配置后重试。")
|
|
|
|
|
} else {
|
2026-07-28 00:12:29 +08:00
|
|
|
logPreparer := files.NewDirectoryPreparer()
|
|
|
|
|
if err := logPreparer.PrepareDirectory(context.Background(), saved.Settings.LogDir); err != nil {
|
|
|
|
|
logger.Error("prepare log directory", "error", err)
|
|
|
|
|
shell.ReportStartupIssue("日志目录不可写;请在设置中选择可写目录后重试。")
|
|
|
|
|
} else if fileLogger, closer, logErr := logging.OpenFile(saved.Settings.LogDir); logErr != nil {
|
|
|
|
|
logger.Error("open diagnostic log", "error", logErr)
|
|
|
|
|
shell.ReportStartupIssue("日志目录不可写;请在设置中选择可写目录后重试。")
|
|
|
|
|
} else {
|
|
|
|
|
logger = fileLogger
|
|
|
|
|
defer closer.Close()
|
|
|
|
|
logger.Info("diagnostic log opened", "version", version)
|
|
|
|
|
}
|
|
|
|
|
if directories, directoriesErr := store.PortableDirectories(); directoriesErr != nil {
|
|
|
|
|
logger.Error("resolve portable settings directories", "error", directoriesErr)
|
|
|
|
|
} else {
|
|
|
|
|
shell.SetPortableDirectoryDefaults(directories.DefaultDir, directories.LogDir)
|
|
|
|
|
}
|
|
|
|
|
shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, DefaultDirMode: string(saved.Settings.DefaultDirSource), LogDir: saved.Settings.LogDir, LogDirMode: string(saved.Settings.LogDirSource), RemoteDebugStartPort: saved.Settings.RemoteDebugStartPort, CloseOnExit: saved.Settings.CloseOnExit})
|
2026-07-27 11:33:22 +08:00
|
|
|
proxies.Set(saved.Proxies)
|
|
|
|
|
shell.SetProxies(proxyOptions(saved.Proxies))
|
|
|
|
|
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, ProxyID: item.ProxyID, PreferredRemoteDebugPort: item.PreferredRemoteDebugPort, Status: "已退出"})
|
2026-07-22 15:17:08 +08:00
|
|
|
}
|
2026-07-27 11:33:22 +08:00
|
|
|
shell.SetInstances(rows)
|
|
|
|
|
shell.OnSave(func(value ui.SettingsState) {
|
2026-07-28 00:12:29 +08:00
|
|
|
saved.Settings = config.Settings{ChromePath: value.ChromePath, EdgePath: value.EdgePath, DefaultDir: value.DefaultDir, DefaultDirSource: config.DirectorySource(value.DefaultDirMode), LogDir: value.LogDir, LogDirSource: config.DirectorySource(value.LogDirMode), RemoteDebugStartPort: value.RemoteDebugStartPort, CloseOnExit: value.CloseOnExit}
|
2026-07-27 11:33:22 +08:00
|
|
|
go saveSettings(logger, store, configSnapshot(saved))
|
|
|
|
|
})
|
|
|
|
|
shell.OnInstancesChanged(func(rows []ui.InstanceRow) {
|
|
|
|
|
saved.Instances = mergeInstanceConfig(saved.Instances, rows)
|
|
|
|
|
go saveSettings(logger, store, configSnapshot(saved))
|
|
|
|
|
})
|
|
|
|
|
shell.OnProxiesChanged(func(options []ui.ProxyOption) {
|
|
|
|
|
saved.Proxies = mergeProxyConfig(saved.Proxies, options)
|
|
|
|
|
proxies.Set(saved.Proxies)
|
|
|
|
|
go saveSettings(logger, store, configSnapshot(saved))
|
|
|
|
|
})
|
2026-07-22 15:17:08 +08:00
|
|
|
}
|
2026-07-22 15:13:55 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-22 14:51:11 +08:00
|
|
|
}
|
2026-07-22 15:17:08 +08:00
|
|
|
|
|
|
|
|
func saveSettings(logger *slog.Logger, store *config.Store, value config.File) {
|
|
|
|
|
err := store.Save(value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Error("save settings", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
type proxyServerResolver interface {
|
|
|
|
|
Resolve(string) (string, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// proxyDirectory is the single in-memory authority used by UI launch requests.
|
|
|
|
|
// It stores only endpoints already validated by config and never exposes secrets.
|
|
|
|
|
type proxyDirectory struct {
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
servers map[string]string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newProxyDirectory() *proxyDirectory {
|
|
|
|
|
return &proxyDirectory{servers: make(map[string]string)}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *proxyDirectory) Set(profiles []config.ProxyProfile) {
|
|
|
|
|
if d == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
servers := make(map[string]string, len(profiles))
|
|
|
|
|
for _, profile := range profiles {
|
|
|
|
|
servers[profile.ID] = profile.Server
|
|
|
|
|
}
|
|
|
|
|
d.mu.Lock()
|
|
|
|
|
d.servers = servers
|
|
|
|
|
d.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *proxyDirectory) Resolve(id string) (string, error) {
|
|
|
|
|
if d == nil || strings.TrimSpace(id) == "" {
|
|
|
|
|
return "", errors.New("所选代理不可用")
|
|
|
|
|
}
|
|
|
|
|
d.mu.RLock()
|
|
|
|
|
server, exists := d.servers[id]
|
|
|
|
|
d.mu.RUnlock()
|
|
|
|
|
if !exists {
|
|
|
|
|
return "", errors.New("所选代理不可用")
|
|
|
|
|
}
|
|
|
|
|
return server, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func proxyOptions(profiles []config.ProxyProfile) []ui.ProxyOption {
|
|
|
|
|
options := make([]ui.ProxyOption, 0, len(profiles))
|
|
|
|
|
for _, profile := range profiles {
|
|
|
|
|
options = append(options, ui.ProxyOption{ID: profile.ID, Name: profile.Name, Server: profile.Server})
|
|
|
|
|
}
|
|
|
|
|
return options
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
type executableResolver interface {
|
|
|
|
|
Resolve(context.Context, domain.BrowserKind, string) (string, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type processLauncher interface {
|
|
|
|
|
Start(context.Context, domain.LaunchSpec) (browser.ProcessHandle, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
type managedProfileInspector interface {
|
|
|
|
|
InspectProfile(context.Context, string) (browser.ProfileUse, error)
|
2026-07-22 17:02:59 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 17:10:20 +08:00
|
|
|
type profileStopper interface {
|
|
|
|
|
StopProfile(context.Context, string, bool) error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// instanceStopper is deliberately scoped to Chub's in-memory registry. It
|
|
|
|
|
// requests graceful exit only, then waits for that registry entry to disappear;
|
|
|
|
|
// it never enumerates or terminates browsers by process name.
|
|
|
|
|
type instanceStopper struct {
|
|
|
|
|
launcher profileStopper
|
|
|
|
|
managedProfiles managedProfileInspector
|
2026-07-25 18:23:33 +08:00
|
|
|
monitor managedExitObserver
|
2026-07-25 17:10:20 +08:00
|
|
|
waitTimeout time.Duration
|
|
|
|
|
waitInterval time.Duration
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow, launchGeneration uint64) error {
|
2026-07-25 17:10:20 +08:00
|
|
|
if row.Status != "运行中" && row.Status != "运行中(调试不可用)" {
|
|
|
|
|
return errors.New("实例当前不是可停止的 Chub 托管状态")
|
|
|
|
|
}
|
|
|
|
|
if row.OccupancySource != "" && row.OccupancySource != "chub_registry" {
|
|
|
|
|
return domain.ErrIdentityMismatch
|
|
|
|
|
}
|
|
|
|
|
if s.launcher == nil || s.managedProfiles == nil {
|
|
|
|
|
return errors.New("浏览器停止服务尚未准备好")
|
|
|
|
|
}
|
|
|
|
|
use, err := s.managedProfiles.InspectProfile(ctx, row.UserDataDir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("检查 Chub 实例身份失败:%w", err)
|
|
|
|
|
}
|
|
|
|
|
if !use.Occupied {
|
|
|
|
|
return domain.ErrInstanceNotFound
|
|
|
|
|
}
|
|
|
|
|
if use.Source != "chub_registry" || (row.PID > 0 && use.PID > 0 && row.PID != use.PID) {
|
|
|
|
|
return domain.ErrIdentityMismatch
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
expected := false
|
|
|
|
|
if s.monitor != nil {
|
|
|
|
|
expected = s.monitor.ExpectStop(row.ID, launchGeneration, row.PID)
|
|
|
|
|
}
|
2026-07-25 17:10:20 +08:00
|
|
|
if err := s.launcher.StopProfile(ctx, row.UserDataDir, false); err != nil {
|
2026-07-25 18:23:33 +08:00
|
|
|
if expected {
|
|
|
|
|
s.monitor.ClearExpectedStop(row.ID, launchGeneration, row.PID)
|
|
|
|
|
}
|
2026-07-25 17:10:20 +08:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
timeout := s.waitTimeout
|
|
|
|
|
if timeout <= 0 {
|
|
|
|
|
timeout = 8 * time.Second
|
|
|
|
|
}
|
|
|
|
|
interval := s.waitInterval
|
|
|
|
|
if interval <= 0 {
|
|
|
|
|
interval = 100 * time.Millisecond
|
|
|
|
|
}
|
|
|
|
|
waitCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
for {
|
|
|
|
|
use, err = s.managedProfiles.InspectProfile(waitCtx, row.UserDataDir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
|
|
|
return errors.New("浏览器未在等待时间内退出;可稍后刷新状态")
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("检查浏览器退出状态失败:%w", err)
|
|
|
|
|
}
|
|
|
|
|
if !use.Occupied {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
timer := time.NewTimer(interval)
|
|
|
|
|
select {
|
|
|
|
|
case <-waitCtx.Done():
|
|
|
|
|
if !timer.Stop() {
|
|
|
|
|
<-timer.C
|
|
|
|
|
}
|
|
|
|
|
return errors.New("浏览器未在等待时间内退出;可稍后刷新状态")
|
|
|
|
|
case <-timer.C:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
type instanceStarter struct {
|
|
|
|
|
launcher processLauncher
|
|
|
|
|
resolver executableResolver
|
|
|
|
|
managedProfiles managedProfileInspector
|
|
|
|
|
externalProfiles browser.ExternalProfileInspector
|
|
|
|
|
remoteDebug browser.RemoteDebugEndpointInspector
|
|
|
|
|
portAllocator browser.RemoteDebugPortAllocator
|
2026-07-25 17:04:26 +08:00
|
|
|
proxyResolver proxyServerResolver
|
2026-07-25 18:23:33 +08:00
|
|
|
monitor managedExitObserver
|
2026-07-28 00:12:29 +08:00
|
|
|
dataPreparer files.InstanceDataPreparer
|
2026-07-25 15:53:31 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
type instanceStatusRefresher struct {
|
|
|
|
|
managedProfiles managedProfileInspector
|
|
|
|
|
externalProfiles browser.ExternalProfileInspector
|
|
|
|
|
remoteDebug browser.RemoteDebugEndpointInspector
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 10:01:07 +08:00
|
|
|
// instanceTabsInspector is a read-only adapter. It accepts only a UI row that
|
|
|
|
|
// already has a verified runtime port and maps platform targets into UI DTOs.
|
|
|
|
|
type instanceTabsInspector struct {
|
|
|
|
|
remoteDebug browser.RemoteDebugTargetInspector
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (i instanceTabsInspector) Inspect(ctx context.Context, row ui.InstanceRow) ([]ui.CDPTarget, error) {
|
|
|
|
|
if i.remoteDebug == nil {
|
|
|
|
|
return nil, errors.New("CDP 页面目标服务尚未准备好")
|
|
|
|
|
}
|
|
|
|
|
if row.Status != "运行中" && row.Status != "外部已关联" {
|
|
|
|
|
return nil, errors.New("实例需要处于已验证的运行或外部关联状态")
|
|
|
|
|
}
|
|
|
|
|
if !domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
|
|
|
|
|
return nil, errors.New("实例没有可验证的本地调试端口")
|
|
|
|
|
}
|
|
|
|
|
kind, err := browserKind(row.Browser)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
targets, err := i.remoteDebug.ListRemoteDebugTargets(ctx, kind, row.RemoteDebugPort)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("读取 CDP 页面目标失败:%w", err)
|
|
|
|
|
}
|
|
|
|
|
result := make([]ui.CDPTarget, 0, len(targets))
|
|
|
|
|
for _, target := range targets {
|
|
|
|
|
result = append(result, ui.CDPTarget{ID: target.ID, Type: target.Type, Title: target.Title, URL: target.URL})
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
func (s instanceStatusRefresher) Refresh(ctx context.Context, rows []ui.InstanceRow) ([]ui.InstanceRefreshResult, error) {
|
|
|
|
|
results := make([]ui.InstanceRefreshResult, 0, len(rows))
|
|
|
|
|
for _, row := range rows {
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
|
return results, err
|
|
|
|
|
}
|
|
|
|
|
results = append(results, s.refreshOne(ctx, row))
|
|
|
|
|
}
|
|
|
|
|
return results, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s instanceStatusRefresher) refreshOne(ctx context.Context, row ui.InstanceRow) ui.InstanceRefreshResult {
|
|
|
|
|
result := ui.InstanceRefreshResult{ID: row.ID, Status: "已退出"}
|
|
|
|
|
kind, err := browserKind(row.Browser)
|
|
|
|
|
if err != nil {
|
|
|
|
|
result.Status = "未知占用"
|
|
|
|
|
result.Message = "浏览器类型无效,无法刷新实例状态。"
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
if s.managedProfiles != nil {
|
|
|
|
|
use, inspectErr := s.managedProfiles.InspectProfile(ctx, row.UserDataDir)
|
|
|
|
|
if inspectErr != nil {
|
|
|
|
|
result.Status = "未知占用"
|
|
|
|
|
result.Message = "无法检查 Chub 托管实例状态。"
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
if use.Occupied {
|
|
|
|
|
result.Status = "运行中"
|
|
|
|
|
result.PID = use.PID
|
|
|
|
|
result.OccupancySource = use.Source
|
|
|
|
|
if s.remoteDebug == nil || row.RemoteDebugPort <= 0 {
|
|
|
|
|
result.Status = "运行中(调试不可用)"
|
|
|
|
|
result.Message = "未记录可验证的本地调试端口。"
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
endpoint, endpointErr := s.remoteDebug.InspectRemoteDebugPort(ctx, kind, row.RemoteDebugPort)
|
|
|
|
|
if endpointErr != nil {
|
|
|
|
|
result.Status = "运行中(调试不可用)"
|
|
|
|
|
result.Message = "本地调试端点当前不可用。"
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
result.RemoteDebugPort = endpoint.Port
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var externalUse browser.ProfileUse
|
|
|
|
|
if s.externalProfiles != nil {
|
|
|
|
|
use, inspectErr := s.externalProfiles.InspectProfile(ctx, kind, row.UserDataDir, row.PID)
|
|
|
|
|
if inspectErr != nil {
|
|
|
|
|
result.Status = "未知占用"
|
|
|
|
|
result.Message = "无法检查外部浏览器占用状态。"
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
externalUse = use
|
|
|
|
|
}
|
|
|
|
|
if s.remoteDebug != nil {
|
|
|
|
|
endpoint, endpointErr := s.remoteDebug.InspectRemoteDebugEndpoint(ctx, kind, row.UserDataDir)
|
|
|
|
|
if endpointErr == nil {
|
|
|
|
|
result.Status = "外部已关联"
|
|
|
|
|
result.PID = externalUse.PID
|
|
|
|
|
result.RemoteDebugPort = endpoint.Port
|
|
|
|
|
result.OccupancySource = externalUse.Source
|
|
|
|
|
if result.OccupancySource == "" {
|
|
|
|
|
result.OccupancySource = "devtools_active_port"
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if externalUse.Occupied {
|
|
|
|
|
result.Status = "外部占用"
|
|
|
|
|
result.PID = externalUse.PID
|
|
|
|
|
result.OccupancySource = externalUse.Source
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 23:16:11 +08:00
|
|
|
type instanceDataDeleter struct {
|
|
|
|
|
managedProfiles managedProfileInspector
|
|
|
|
|
externalProfiles browser.ExternalProfileInspector
|
|
|
|
|
dataRemover files.InstanceDataRemover
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d instanceDataDeleter) Delete(ctx context.Context, row ui.InstanceRow) error {
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if d.managedProfiles == nil || d.externalProfiles == nil || d.dataRemover == nil {
|
|
|
|
|
return errors.New("实例数据删除服务尚未准备好")
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(row.UserDataDir) == "" || !filepath.IsAbs(row.UserDataDir) {
|
|
|
|
|
return files.ErrUnsafeInstanceDataDirectory
|
|
|
|
|
}
|
|
|
|
|
kind, err := browserKind(row.Browser)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
managed, err := d.managedProfiles.InspectProfile(ctx, row.UserDataDir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("检查 Chub 实例占用失败:%w", err)
|
|
|
|
|
}
|
|
|
|
|
if managed.Occupied {
|
|
|
|
|
return domain.ErrProfileOccupied
|
|
|
|
|
}
|
|
|
|
|
external, err := d.externalProfiles.InspectProfile(ctx, kind, row.UserDataDir, row.PID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("检查外部浏览器占用失败:%w", err)
|
|
|
|
|
}
|
|
|
|
|
if external.Occupied {
|
|
|
|
|
return domain.ErrProfileOccupied
|
|
|
|
|
}
|
|
|
|
|
return d.dataRemover.RemoveInstanceData(ctx, row.UserDataDir)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState, launchGeneration uint64) (ui.InstanceStartOutcome, error) {
|
2026-07-22 17:02:59 +08:00
|
|
|
kind, err := browserKind(row.Browser)
|
|
|
|
|
if err != nil {
|
2026-07-25 15:53:31 +08:00
|
|
|
return ui.InstanceStartOutcome{}, err
|
2026-07-22 17:02:59 +08:00
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
if s.launcher == nil || s.resolver == nil || s.remoteDebug == nil || s.portAllocator == nil {
|
|
|
|
|
return ui.InstanceStartOutcome{}, errors.New("浏览器启动服务尚未准备好")
|
|
|
|
|
}
|
|
|
|
|
if s.managedProfiles != nil {
|
|
|
|
|
use, inspectErr := s.managedProfiles.InspectProfile(ctx, row.UserDataDir)
|
|
|
|
|
if inspectErr != nil {
|
|
|
|
|
return ui.InstanceStartOutcome{}, fmt.Errorf("检查 Chub 实例状态失败:%w", inspectErr)
|
|
|
|
|
}
|
|
|
|
|
if use.Occupied {
|
|
|
|
|
endpoint, _ := s.remoteDebug.InspectRemoteDebugEndpoint(ctx, kind, row.UserDataDir)
|
|
|
|
|
return ui.InstanceStartOutcome{PID: use.PID, RemoteDebugPort: endpoint.Port, Source: use.Source}, &browser.ProfileOccupiedError{UserDataDir: row.UserDataDir, PID: use.PID}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if s.externalProfiles != nil {
|
|
|
|
|
use, inspectErr := s.externalProfiles.InspectProfile(ctx, kind, row.UserDataDir, 0)
|
|
|
|
|
if inspectErr != nil {
|
|
|
|
|
return ui.InstanceStartOutcome{Source: "unknown"}, fmt.Errorf("检查外部浏览器占用失败:%w", inspectErr)
|
|
|
|
|
}
|
|
|
|
|
endpoint, endpointErr := s.remoteDebug.InspectRemoteDebugEndpoint(ctx, kind, row.UserDataDir)
|
|
|
|
|
if endpointErr == nil {
|
|
|
|
|
source := use.Source
|
|
|
|
|
if source == "" {
|
|
|
|
|
source = "devtools_active_port"
|
|
|
|
|
}
|
|
|
|
|
return ui.InstanceStartOutcome{PID: use.PID, RemoteDebugPort: endpoint.Port, External: true, Source: source}, nil
|
|
|
|
|
}
|
|
|
|
|
if use.Occupied {
|
|
|
|
|
return ui.InstanceStartOutcome{PID: use.PID, Source: use.Source}, &browser.ProfileOccupiedError{UserDataDir: row.UserDataDir, PID: use.PID}
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
}
|
2026-07-28 00:12:29 +08:00
|
|
|
if s.dataPreparer == nil {
|
|
|
|
|
return ui.InstanceStartOutcome{}, errors.New("User Data Dir 预检服务尚未准备好")
|
|
|
|
|
}
|
|
|
|
|
if err := s.dataPreparer.PrepareInstanceDataDir(ctx, row.UserDataDir); err != nil {
|
|
|
|
|
return ui.InstanceStartOutcome{}, fmt.Errorf("User Data Dir 不可写,请选择其他目录:%w", err)
|
|
|
|
|
}
|
2026-07-25 17:04:26 +08:00
|
|
|
proxyServer := ""
|
|
|
|
|
if row.ProxyID != "" {
|
|
|
|
|
if s.proxyResolver == nil {
|
|
|
|
|
return ui.InstanceStartOutcome{}, errors.New("所选代理不可用")
|
|
|
|
|
}
|
|
|
|
|
proxyServer, err = s.proxyResolver.Resolve(row.ProxyID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ui.InstanceStartOutcome{}, err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
configuredExecutable := settings.ChromePath
|
|
|
|
|
if kind == domain.BrowserEdge {
|
|
|
|
|
configuredExecutable = settings.EdgePath
|
|
|
|
|
}
|
|
|
|
|
executable, err := s.resolver.Resolve(ctx, kind, configuredExecutable)
|
|
|
|
|
if err != nil {
|
2026-07-25 15:53:31 +08:00
|
|
|
return ui.InstanceStartOutcome{}, fmt.Errorf("无法找到%s可执行文件:%w", browserLabel(kind), err)
|
|
|
|
|
}
|
2026-07-27 09:29:10 +08:00
|
|
|
startPort := row.PreferredRemoteDebugPort
|
|
|
|
|
if !domain.ValidRemoteDebugPort(startPort) {
|
|
|
|
|
startPort = settings.RemoteDebugStartPort
|
|
|
|
|
}
|
|
|
|
|
port, err := findAvailableUnreservedRemoteDebugPort(ctx, s.portAllocator, startPort, settings.ReservedRemoteDebugPorts)
|
2026-07-25 15:53:31 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return ui.InstanceStartOutcome{}, fmt.Errorf("无法分配本地调试端口:%w", err)
|
2026-07-22 17:02:59 +08:00
|
|
|
}
|
|
|
|
|
handle, err := s.launcher.Start(ctx, domain.LaunchSpec{
|
2026-07-25 15:53:31 +08:00
|
|
|
Kind: kind,
|
|
|
|
|
Executable: executable,
|
|
|
|
|
UserDataDir: row.UserDataDir,
|
|
|
|
|
RemoteDebugPort: port,
|
|
|
|
|
TargetURL: row.TargetURL,
|
2026-07-25 17:04:26 +08:00
|
|
|
ProxyServer: proxyServer,
|
2026-07-22 17:02:59 +08:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
2026-07-25 15:53:31 +08:00
|
|
|
return ui.InstanceStartOutcome{}, err
|
2026-07-22 17:02:59 +08:00
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
if s.monitor != nil {
|
|
|
|
|
s.monitor.Watch(row.ID, launchGeneration, handle)
|
|
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
endpointCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
endpoint, endpointErr := s.remoteDebug.WaitForRemoteDebugPort(endpointCtx, kind, port)
|
|
|
|
|
if endpointErr != nil {
|
|
|
|
|
return ui.InstanceStartOutcome{PID: handle.PID(), Warning: "本地调试端点不可用"}, nil
|
|
|
|
|
}
|
|
|
|
|
return ui.InstanceStartOutcome{PID: handle.PID(), RemoteDebugPort: endpoint.Port, Source: "chub_registry"}, nil
|
2026-07-22 17:02:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-25 17:04:26 +08:00
|
|
|
item.ProxyID = row.ProxyID
|
2026-07-27 09:29:10 +08:00
|
|
|
item.PreferredRemoteDebugPort = row.PreferredRemoteDebugPort
|
2026-07-25 17:04:26 +08:00
|
|
|
if item.ProxyID != "" {
|
|
|
|
|
item.Launch.ProxyServer = ""
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
item.UpdatedAt = time.Now()
|
|
|
|
|
updated = append(updated, item)
|
|
|
|
|
}
|
|
|
|
|
return updated
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 09:29:10 +08:00
|
|
|
func findAvailableUnreservedRemoteDebugPort(ctx context.Context, allocator browser.RemoteDebugPortAllocator, start int, reserved []int) (int, error) {
|
|
|
|
|
if allocator == nil {
|
|
|
|
|
return 0, errors.New("remote debug port allocator is required")
|
|
|
|
|
}
|
|
|
|
|
if !domain.ValidRemoteDebugPort(start) {
|
|
|
|
|
return 0, fmt.Errorf("invalid remote debug start port %d", start)
|
|
|
|
|
}
|
|
|
|
|
reservedSet := make(map[int]struct{}, len(reserved))
|
|
|
|
|
for _, port := range reserved {
|
|
|
|
|
if domain.ValidRemoteDebugPort(port) {
|
|
|
|
|
reservedSet[port] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for candidate := start; candidate <= domain.MaxRemoteDebugPort; {
|
|
|
|
|
port, err := allocator.FindAvailableRemoteDebugPort(ctx, candidate)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
if !domain.ValidRemoteDebugPort(port) || port < candidate {
|
|
|
|
|
return 0, fmt.Errorf("port allocator returned invalid port %d", port)
|
|
|
|
|
}
|
|
|
|
|
if _, reservedByOtherInstance := reservedSet[port]; !reservedByOtherInstance {
|
|
|
|
|
return port, nil
|
|
|
|
|
}
|
|
|
|
|
if port == domain.MaxRemoteDebugPort {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
candidate = port + 1
|
|
|
|
|
}
|
|
|
|
|
return 0, errors.New("no unreserved local remote debug port is available")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
func mergeProxyConfig(existing []config.ProxyProfile, options []ui.ProxyOption) []config.ProxyProfile {
|
|
|
|
|
byID := make(map[string]config.ProxyProfile, len(existing))
|
|
|
|
|
for _, profile := range existing {
|
|
|
|
|
byID[profile.ID] = profile
|
|
|
|
|
}
|
|
|
|
|
updated := make([]config.ProxyProfile, 0, len(options))
|
|
|
|
|
for _, option := range options {
|
|
|
|
|
profile := byID[option.ID]
|
|
|
|
|
profile.ID = option.ID
|
|
|
|
|
profile.Name = option.Name
|
|
|
|
|
profile.Server = option.Server
|
|
|
|
|
profile.UpdatedAt = time.Now()
|
|
|
|
|
updated = append(updated, profile)
|
|
|
|
|
}
|
|
|
|
|
return updated
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
func configSnapshot(value config.File) config.File {
|
|
|
|
|
clone := value
|
|
|
|
|
clone.Instances = append([]config.Instance(nil), value.Instances...)
|
2026-07-25 17:04:26 +08:00
|
|
|
clone.Proxies = append([]config.ProxyProfile(nil), value.Proxies...)
|
2026-07-22 17:02:59 +08:00
|
|
|
for i := range clone.Instances {
|
|
|
|
|
clone.Instances[i].Launch.ExtraArgs = append([]string(nil), clone.Instances[i].Launch.ExtraArgs...)
|
|
|
|
|
}
|
|
|
|
|
return clone
|
|
|
|
|
}
|