package main import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "strings" "sync" "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" var ( openDefaultConfig = config.OpenDefault defaultPortableDirectories = config.DefaultPortableDirectories ) 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 } store, err := openDefaultConfig() if err != nil { writeCLIError(errout, "system_error", "default configuration is unavailable") 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) if directories, err := defaultPortableDirectories(); err != nil { logger.Error("resolve portable directories", "error", err) shell.ReportStartupIssue("无法确定程序目录中的默认 User Data Dir 和日志目录;请检查程序位置后重试。") } else { shell.SetPortableDirectoryDefaults(directories.DefaultDir, directories.LogDir) } directoryPicker := files.NewDirectoryPicker() shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate) executablePicker := files.NewExecutablePicker() shell.OnChooseExecutable(executablePicker.ChooseExecutable, window.Invalidate) discoverer := browser.NewDiscoverer() launcher := browser.NewOSLauncher() cdp := browser.NewCDPInspector() externalProfiles := browser.NewWindowsProfileInspector() proxies := newProxyDirectory() exitMonitor := newManagedExitMonitor(shell.ReportManagedExit, window.Invalidate) defer exitMonitor.Close() shell.OnStartInstance(instanceStarter{ launcher: launcher, resolver: discoverer, managedProfiles: launcher, externalProfiles: externalProfiles, remoteDebug: cdp, portAllocator: cdp, proxyResolver: proxies, monitor: exitMonitor, dataPreparer: files.NewInstanceDataPreparer(), }.Start, window.Invalidate) shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher, monitor: exitMonitor}.Stop, window.Invalidate) dataRemover := files.NewInstanceDataRemover() shell.OnDeleteInstanceData(instanceDataDeleter{ managedProfiles: launcher, externalProfiles: externalProfiles, dataRemover: dataRemover, }.Delete, window.Invalidate) shell.OnRefreshInstances(instanceStatusRefresher{ managedProfiles: launcher, externalProfiles: externalProfiles, remoteDebug: cdp, }.Refresh, window.Invalidate) shell.OnInspectTabs(instanceTabsInspector{remoteDebug: cdp}.Inspect, 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) 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 { 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}) 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: "已退出"}) } shell.SetInstances(rows) shell.OnSave(func(value ui.SettingsState) { 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} 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)) }) } 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 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 } type executableResolver interface { Resolve(context.Context, domain.BrowserKind, string) (string, error) } type processLauncher interface { Start(context.Context, domain.LaunchSpec) (browser.ProcessHandle, error) } type managedProfileInspector interface { InspectProfile(context.Context, string) (browser.ProfileUse, error) } 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 monitor managedExitObserver waitTimeout time.Duration waitInterval time.Duration } func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow, launchGeneration uint64) error { 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 } expected := false if s.monitor != nil { expected = s.monitor.ExpectStop(row.ID, launchGeneration, row.PID) } if err := s.launcher.StopProfile(ctx, row.UserDataDir, false); err != nil { if expected { s.monitor.ClearExpectedStop(row.ID, launchGeneration, row.PID) } 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: } } } type instanceStarter struct { launcher processLauncher resolver executableResolver managedProfiles managedProfileInspector externalProfiles browser.ExternalProfileInspector remoteDebug browser.RemoteDebugEndpointInspector portAllocator browser.RemoteDebugPortAllocator proxyResolver proxyServerResolver monitor managedExitObserver dataPreparer files.InstanceDataPreparer } type instanceStatusRefresher struct { managedProfiles managedProfileInspector externalProfiles browser.ExternalProfileInspector remoteDebug browser.RemoteDebugEndpointInspector } // 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 } 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 } 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) } func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState, launchGeneration uint64) (ui.InstanceStartOutcome, error) { kind, err := browserKind(row.Browser) if err != nil { return ui.InstanceStartOutcome{}, err } 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} } } 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) } 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 } } configuredExecutable := settings.ChromePath if kind == domain.BrowserEdge { configuredExecutable = settings.EdgePath } executable, err := s.resolver.Resolve(ctx, kind, configuredExecutable) if err != nil { return ui.InstanceStartOutcome{}, fmt.Errorf("无法找到%s可执行文件:%w", browserLabel(kind), err) } startPort := row.PreferredRemoteDebugPort if !domain.ValidRemoteDebugPort(startPort) { startPort = settings.RemoteDebugStartPort } port, err := findAvailableUnreservedRemoteDebugPort(ctx, s.portAllocator, startPort, settings.ReservedRemoteDebugPorts) if err != nil { return ui.InstanceStartOutcome{}, fmt.Errorf("无法分配本地调试端口:%w", err) } handle, err := s.launcher.Start(ctx, domain.LaunchSpec{ Kind: kind, Executable: executable, UserDataDir: row.UserDataDir, RemoteDebugPort: port, TargetURL: row.TargetURL, ProxyServer: proxyServer, }) if err != nil { return ui.InstanceStartOutcome{}, err } if s.monitor != nil { s.monitor.Watch(row.ID, launchGeneration, handle) } 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 } 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.ProxyID = row.ProxyID item.PreferredRemoteDebugPort = row.PreferredRemoteDebugPort if item.ProxyID != "" { item.Launch.ProxyServer = "" } item.UpdatedAt = time.Now() updated = append(updated, item) } return updated } 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") } 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 } func configSnapshot(value config.File) config.File { clone := value clone.Instances = append([]config.Instance(nil), value.Instances...) clone.Proxies = append([]config.ProxyProfile(nil), value.Proxies...) for i := range clone.Instances { clone.Instances[i].Launch.ExtraArgs = append([]string(nil), clone.Instances[i].Launch.ExtraArgs...) } return clone }