feat: migrate config beside executable

This commit is contained in:
QiuSW
2026-07-27 11:33:22 +08:00
parent 206794f78c
commit eeb7950806
9 changed files with 307 additions and 42 deletions
+32 -32
View File
@@ -28,6 +28,8 @@ import (
const version = "0.1.0-dev"
var openDefaultConfig = config.OpenDefault
func main() {
if len(os.Args) > 1 {
os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr))
@@ -53,14 +55,9 @@ func runCLI(args []string, out, errout io.Writer) int {
fmt.Fprintln(out, "chub [start|list|stop|restart|events]")
return 0
}
path, err := config.DefaultPath()
store, err := openDefaultConfig()
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())
writeCLIError(errout, "system_error", "default configuration is unavailable")
return 6
}
switch command {
@@ -157,32 +154,35 @@ func runWindow(logger *slog.Logger) {
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, 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, LogDir: value.LogDir, 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))
})
}
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 {
shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, LogDir: saved.Settings.LogDir, 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, LogDir: value.LogDir, 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 {
+28
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
@@ -21,6 +22,33 @@ func TestRunCLIRejectsUnknownCommandWithStableCode(t *testing.T) {
}
}
func TestRunCLIUsesPortableDefaultStore(t *testing.T) {
store, err := config.New(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
if err := store.Save(config.File{Instances: []config.Instance{{ID: "portable", Name: "便携实例", PreferredRemoteDebugPort: 9666}}}); err != nil {
t.Fatal(err)
}
restore := openDefaultConfig
openDefaultConfig = func() (*config.Store, error) { return store, nil }
t.Cleanup(func() { openDefaultConfig = restore })
var out bytes.Buffer
if code := runCLI([]string{"list"}, &out, &out); code != 0 || !strings.Contains(out.String(), `"portable"`) {
t.Fatalf("code=%d output=%s", code, out.String())
}
}
func TestRunCLIReportsUnavailablePortableConfigWithoutDetails(t *testing.T) {
restore := openDefaultConfig
openDefaultConfig = func() (*config.Store, error) { return nil, config.ErrDefaultConfigUnavailable }
t.Cleanup(func() { openDefaultConfig = restore })
var out bytes.Buffer
if code := runCLI([]string{"list"}, &out, &out); code != 6 || !strings.Contains(out.String(), `"default configuration is unavailable"`) {
t.Fatalf("code=%d output=%s", code, out.String())
}
}
func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) {
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
resolver := &fakeExecutableResolver{path: `C:\Browser\msedge.exe`}
+1 -1
View File
@@ -53,7 +53,7 @@
| --- | --- | --- | --- |
| T-401 | 已验证实例的 CDP 页面目标只读查看 | T-302,T-303,T-310 | DONE |
| T-402 | Windows 通知区域入口与显式退出协调 | T-303,T-305,T-307 | TODO |
| T-403 | 便携配置路径与安全 legacy 迁移 | T-202,T-309 | TODO |
| T-403 | 便携配置路径与安全 legacy 迁移 | T-202,T-309 | DONE |
## Backlog
+4 -4
View File
@@ -3,8 +3,8 @@
## 快照
- 日期:2026-07-27
- 阶段:Phase 4 CDP 只读观察与后台入口(T-301 至 T-310、T-201 至 T-208、T-401 已完成;T-402、T-403 已规划)
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、受限 CDP 目标读取、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 至 T-310、T-401 已完成
- 阶段:Phase 4 CDP 只读观察与后台入口(T-301 至 T-310、T-201 至 T-208、T-401、T-403 已完成;T-402 已规划)
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、受限 CDP 目标读取、exe 同级 JSON 配置与 legacy 安全迁移、优雅关闭、Job Object、真实 Chrome/Edge smoke、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 至 T-310、T-401、T-403 已完成
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和带边框的操作列;端口列在未启动/已退出时显示“建议 {端口}”、启动中显示“准备 {端口}”、托管运行显示“实际 {端口}”、外部关联显示“外部 {端口}”,避免将持久首选端口误作监听事实。新建表单从设置的起始端口推荐最低未冲突端口,编辑停止实例可修改首选端口;启动后台优先该端口并避开其他实例的首选/实际端口,成功回退后才保存新的首选值。操作顺序固定为启动/停止(或重新检测)、编辑、删除,删除与相邻操作保持更大间距以降低误点。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、首选端口、完整地址代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页使用完整代理地址选择器、地址输入及保存/删除操作;选择会回填地址,保存按稳定 `proxyId` 新增或更新,删除未引用代理前必须确认,仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
- T-307:本应用会话启动的受管 Chrome/Edge 根进程由单实例后台 `Wait` 监控;意外退出按 ID、启动代次和 PID 验证后立即变为“已退出”,清空运行时 PID/端口,并以可关闭、可合并的提示告知用户。提示关闭后焦点回到启动操作;Chub 请求停止、外部实例、过期事件和应用关闭取消监控均不提示。
- T-308:设置页以可滚动的外层工作区承载浏览器路径、默认目录、运行行为和代理分区;分区、字段、状态提示和保存操作区使用统一的浅色圆角描边。路径字段在紧凑内容区会将输入与“选择/搜索”操作纵向重排,切换页面后滚动、输入与异步搜索状态仍由持久 Gio widget 保留。
@@ -13,8 +13,8 @@
- T-310:实例新建页使用外层工作区、字段组件和操作区三级边框;编辑弹层使用可辨外框,并为字段、运行态信息、反馈和操作区添加一致描边。紧凑窗口下沿用原有路径/操作重排,字段状态和键盘交互未改变。
- T-401:仅在运行中或外部已关联实例已有有效实际端口时,编辑弹层可打开“标签页(CDP 页面目标)”只读模态层。后台先复核 loopback `/json/version` 的浏览器类型,再读取有限的 `/json/list`;只显示 ID、类型、标题和移除 userinfo/query/fragment 的 URL,不暴露 WebSocket 地址、不建立 WebSocket、也不执行浏览器控制。读取、错误、空列表和端口/状态变化都有反馈,Escape/关闭后焦点回到入口。
- T-402:已规划 Windows 通知区域图标、显示主窗口、刷新已保存实例状态和显式退出协调。首版不在托盘菜单中直接操作浏览器,不改变窗口关闭语义,也不实现关闭窗口后隐藏;Windows 原生 Shell adapter、Explorer 重启恢复、图标资源与退出清理将在实施阶段完成。
- T-403:已规划将默认 `config.json` 定位到实际 `chub.exe` 同级目录,并将旧 AppData 文件在目标缺失时无损、原子迁移为新配置。目标配置优先;旧文件保留恢复备份;不自动合并、切回 AppData 或迁移浏览器 User Data Dir。
- blocker:无;当前任务为 T-403。
- T-403:默认配置已改为实际 `chub.exe` 同级 `config.json`,不受当前工作目录影响。目标缺失时会校验并原子复制旧 AppData 配置,旧文件保持恢复副本;目标优先且不会自动合并、切回 AppData 或迁移浏览器 User Data Dir。CLI 与 GUI 共用 `OpenDefault()`;配置不可用时 CLI 返回无敏感细节的稳定错误,GUI 显示恢复提示。
- blocker:无;下一任务为 T-402。
## 当前目录
+4 -4
View File
@@ -3,7 +3,7 @@ id: T-403
title: 便携配置路径与安全 legacy 迁移
phase: 4
deps: [T-202, T-309]
status: TODO
status: DONE
created: 2026-07-27
owner: codex
---
@@ -29,8 +29,8 @@ owner: codex
## 执行记录
- 状态:TODO
- 变更:已完成需求、架构、API、交互和迁移决策记录;待实施。
- 验证:待执行。
- 状态:DONE
- 变更:新增 exe 同级 `DefaultPath()`、legacy `LegacyPath()`、统一 `OpenDefault()` 与严格目标优先的原子迁移。CLI 和 GUI 均改用该入口;GUI 配置不可用时提供不含路径或配置内容的恢复反馈,CLI 返回稳定泛化错误。补充根目录配置和临时文件忽略规则。
- 验证:已执行 `gofmt`、`go test ./...`、`go test -race ./cmd/chub ./internal/platform/config`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub` 和 `powershell -ExecutionPolicy Bypass -File scripts/smoke-windows.ps1`,均通过。Windows smoke 已验证 CLI 从 exe 同级 Store 读取配置。
- 阻塞:无。
- 残余风险:程序安装到不可写目录时 strict portable 策略会拒绝保存;可配置路径/安装版回退留待后续任务。
+94 -1
View File
@@ -15,6 +15,8 @@ import (
const currentVersion = 1
var ErrDefaultConfigUnavailable = errors.New("default config is unavailable")
type Settings struct {
ChromePath string `json:"chromePath"`
EdgePath string `json:"edgePath"`
@@ -59,15 +61,106 @@ func New(path string) (*Store, error) {
}
func DefaultPath() (string, error) {
executable, err := os.Executable()
if err != nil {
return "", fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable)
}
return defaultPathForExecutable(executable)
}
// LegacyPath is the pre-portable configuration location. It is read only when
// the executable-side config does not exist yet.
func LegacyPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve user config dir: %w", err)
return "", fmt.Errorf("%w: resolve legacy config directory", ErrDefaultConfigUnavailable)
}
return filepath.Join(dir, "chub", "config.json"), nil
}
// OpenDefault returns the config store beside the currently executing binary.
// A valid legacy user config is copied there once only when the target does not
// already exist. The legacy file is deliberately retained for recovery.
func OpenDefault() (*Store, error) {
target, err := DefaultPath()
if err != nil {
return nil, err
}
store, targetExists, err := defaultStore(target)
if err != nil || targetExists {
return store, err
}
legacy, err := LegacyPath()
if err != nil {
return nil, err
}
return migrateLegacyConfig(store, legacy)
}
func (s *Store) Path() string { return s.path }
func defaultPathForExecutable(executable string) (string, error) {
path := strings.TrimSpace(executable)
if path == "" || !filepath.IsAbs(path) {
return "", fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable)
}
return filepath.Join(filepath.Dir(filepath.Clean(path)), "config.json"), nil
}
func openDefaultAt(target, legacy string) (*Store, error) {
store, targetExists, err := defaultStore(target)
if err != nil || targetExists {
return store, err
}
return migrateLegacyConfig(store, legacy)
}
func defaultStore(target string) (*Store, bool, error) {
store, err := New(target)
if err != nil {
return nil, false, fmt.Errorf("%w: initialize target store", ErrDefaultConfigUnavailable)
}
info, err := os.Stat(store.Path())
if errors.Is(err, os.ErrNotExist) {
return store, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("%w: inspect target config", ErrDefaultConfigUnavailable)
}
if info.IsDir() {
return nil, false, fmt.Errorf("%w: target config is a directory", ErrDefaultConfigUnavailable)
}
return store, true, nil
}
func migrateLegacyConfig(target *Store, legacyPath string) (*Store, error) {
if target == nil {
return nil, fmt.Errorf("%w: target store is unavailable", ErrDefaultConfigUnavailable)
}
legacy, err := New(legacyPath)
if err != nil {
return nil, fmt.Errorf("%w: initialize legacy store", ErrDefaultConfigUnavailable)
}
if filepath.Clean(target.Path()) == filepath.Clean(legacy.Path()) {
return target, nil
}
info, err := os.Stat(legacy.Path())
if errors.Is(err, os.ErrNotExist) {
return target, nil
}
if err != nil || info.IsDir() {
return nil, fmt.Errorf("%w: inspect legacy config", ErrDefaultConfigUnavailable)
}
value, err := legacy.Load()
if err != nil {
return nil, fmt.Errorf("%w: validate legacy config", ErrDefaultConfigUnavailable)
}
if err := target.Save(value); err != nil {
return nil, fmt.Errorf("%w: migrate legacy config", ErrDefaultConfigUnavailable)
}
return target, nil
}
func (s *Store) Load() (File, error) {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
+130
View File
@@ -1,6 +1,7 @@
package config
import (
"errors"
"os"
"path/filepath"
"testing"
@@ -100,6 +101,135 @@ func TestStoreMissingFileReturnsDefaults(t *testing.T) {
}
}
func TestDefaultPathForExecutableUsesExecutableDirectory(t *testing.T) {
executable := filepath.Join(t.TempDir(), "bin", "chub.exe")
path, err := defaultPathForExecutable(executable)
if err != nil || path != filepath.Join(filepath.Dir(executable), "config.json") {
t.Fatalf("defaultPathForExecutable() = %q, %v", path, err)
}
if _, err := defaultPathForExecutable("chub.exe"); !errors.Is(err, ErrDefaultConfigUnavailable) {
t.Fatalf("relative executable error = %v", err)
}
}
func TestOpenDefaultAtMigratesValidLegacyWithoutRemovingIt(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "portable", "config.json")
legacy := filepath.Join(directory, "legacy", "config.json")
legacyStore, err := New(legacy)
if err != nil {
t.Fatal(err)
}
want := File{
Settings: Settings{RemoteDebugStartPort: 9777, CloseOnExit: true},
Instances: []Instance{{
ID: "migrated",
Name: "迁移实例",
PreferredRemoteDebugPort: 9777,
Launch: domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: `C:\profiles\migrated`},
}},
}
if err := legacyStore.Save(want); err != nil {
t.Fatal(err)
}
store, err := openDefaultAt(target, legacy)
if err != nil || store.Path() != target {
t.Fatalf("openDefaultAt() = %#v, %v", store, err)
}
got, err := store.Load()
if err != nil || len(got.Instances) != 1 || got.Instances[0].ID != "migrated" || got.Instances[0].PreferredRemoteDebugPort != 9777 {
t.Fatalf("migrated config = %#v, error = %v", got, err)
}
if _, err := os.Stat(legacy); err != nil {
t.Fatalf("legacy config was not retained: %v", err)
}
}
func TestOpenDefaultAtPrefersExistingTargetOverLegacy(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "portable", "config.json")
legacy := filepath.Join(directory, "legacy", "config.json")
targetStore, err := New(target)
if err != nil {
t.Fatal(err)
}
if err := targetStore.Save(File{Instances: []Instance{{ID: "target", PreferredRemoteDebugPort: 9666}}}); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacy, []byte(`not-json`), 0o600); err != nil {
t.Fatal(err)
}
store, err := openDefaultAt(target, legacy)
if err != nil {
t.Fatalf("existing target was affected by legacy error: %v", err)
}
got, err := store.Load()
if err != nil || len(got.Instances) != 1 || got.Instances[0].ID != "target" {
t.Fatalf("target config = %#v, error = %v", got, err)
}
}
func TestOpenDefaultAtLeavesMissingTargetAbsentWithoutLegacy(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "portable", "config.json")
store, err := openDefaultAt(target, filepath.Join(directory, "legacy", "config.json"))
if err != nil || store.Path() != target {
t.Fatalf("openDefaultAt() = %#v, %v", store, err)
}
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("target was unexpectedly created: %v", err)
}
}
func TestOpenDefaultAtRejectsInvalidLegacyWithoutCreatingTarget(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "portable", "config.json")
legacy := filepath.Join(directory, "legacy", "config.json")
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacy, []byte(`{`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := openDefaultAt(target, legacy); !errors.Is(err, ErrDefaultConfigUnavailable) {
t.Fatalf("invalid legacy error = %v", err)
}
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("target was created after invalid legacy: %v", err)
}
if _, err := os.Stat(legacy); err != nil {
t.Fatalf("legacy config was not retained: %v", err)
}
}
func TestOpenDefaultAtRejectsUncreatableTargetWithoutTouchingLegacy(t *testing.T) {
directory := t.TempDir()
parentFile := filepath.Join(directory, "not-a-directory")
if err := os.WriteFile(parentFile, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
target := filepath.Join(parentFile, "config.json")
legacy := filepath.Join(directory, "legacy", "config.json")
legacyStore, err := New(legacy)
if err != nil {
t.Fatal(err)
}
if err := legacyStore.Save(File{Instances: []Instance{{ID: "legacy", PreferredRemoteDebugPort: 9666}}}); err != nil {
t.Fatal(err)
}
if _, err := openDefaultAt(target, legacy); !errors.Is(err, ErrDefaultConfigUnavailable) {
t.Fatalf("uncreatable target error = %v", err)
}
if _, err := os.Stat(legacy); err != nil {
t.Fatalf("legacy config was changed: %v", err)
}
}
func TestStoreRoundTripsReferencedProxyWithoutCredentials(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
store, err := New(path)
+6
View File
@@ -476,6 +476,12 @@ func (s *Shell) SetSettings(value SettingsState) {
s.closeOnExit.Value = value.CloseOnExit
}
// ReportStartupIssue records a safe, user-recoverable configuration message.
// The application calls it before the frame loop starts; it performs no I/O.
func (s *Shell) ReportStartupIssue(message string) {
s.instanceFeedback = strings.TrimSpace(message)
}
func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn }
func (s *Shell) OnInstancesChanged(fn func([]InstanceRow)) { s.onInstancesChanged = fn }
+8
View File
@@ -26,6 +26,14 @@ func TestShellStartsWithSemanticInstanceStatuses(t *testing.T) {
}
}
func TestShellReportsSafeStartupIssue(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.ReportStartupIssue(" 程序目录中的 config.json 无法使用;请检查目录权限或恢复旧配置。 ")
if shell.instanceFeedback != "程序目录中的 config.json 无法使用;请检查目录权限或恢复旧配置。" {
t.Fatalf("startup feedback = %q", shell.instanceFeedback)
}
}
func TestShellInstanceListScrollsVertically(t *testing.T) {
shell := NewShell(material.NewTheme())
if shell.list.Axis != layout.Vertical {