fix(desktop): smooth settings and config flows

This commit is contained in:
apale7 2026-04-19 19:03:44 +08:00
parent 0b3098ccf6
commit bbe21e14df
18 changed files with 2121 additions and 570 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,9 @@
import type {
AliasTargetInput,
AliasUpsertInput,
ConfigExportView,
ConfigImportInput,
ConfigImportResult,
DesktopPrefsSaveResult,
AliasView,
DesktopPrefsView,
@ -60,6 +63,16 @@ export function getOverview(): Promise<Overview> {
return isWails() ? bridge().Overview() : http<Overview>('/api/overview')
}
export function exportConfig(): Promise<ConfigExportView> {
return isWails() ? bridge().ExportConfig() : http<ConfigExportView>('/api/config/export')
}
export function importConfig(input: ConfigImportInput): Promise<ConfigImportResult> {
return isWails()
? bridge().ImportConfig(input)
: http<ConfigImportResult>('/api/config/import', { method: 'POST', body: JSON.stringify(input) })
}
export function listProviders(): Promise<ProviderView[]> {
return isWails() ? bridge().Providers() : http<ProviderView[]>('/api/providers')
}

View File

@ -7,6 +7,8 @@ declare global {
App?: {
Meta: () => Promise<Record<string, string>>
Overview: () => Promise<import('./types').Overview>
ExportConfig: () => Promise<import('./types').ConfigExportView>
ImportConfig: (input: import('./types').ConfigImportInput) => Promise<import('./types').ConfigImportResult>
Providers: () => Promise<import('./types').ProviderView[]>
Aliases: () => Promise<import('./types').AliasView[]>
SaveProvider: (input: import('./types').ProviderUpsertInput) => Promise<import('./types').ProviderSaveResult>

View File

@ -1,6 +1,7 @@
{
"app": {
"eyebrow": "ocswitch desktop",
"brand": "ocswitch desktop",
"title": "Native control panel",
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
"loadingConfig": "loading config",
@ -31,7 +32,17 @@
"unbind": "Unbind",
"newProvider": "New provider",
"newAlias": "New alias",
"useInBindForm": "Use in bind form"
"useInBindForm": "Use in bind form",
"close": "Close",
"cancel": "Cancel",
"confirm": "Confirm",
"exportConfig": "Export config",
"importConfig": "Import config"
},
"confirm": {
"deleteProviderTitle": "Delete provider",
"deleteAliasTitle": "Delete alias",
"unbindTargetTitle": "Unbind target"
},
"status": {
"proxyRunning": "Proxy running",
@ -59,7 +70,10 @@
"previewChanges": "Preview shows changes",
"previewNoChanges": "Preview shows no changes",
"syncApplied": "Sync applied",
"syncUpToDate": "Already up to date"
"syncUpToDate": "Already up to date",
"confirmDeleteProvider": "Delete provider {{id}}? This cannot be undone.",
"confirmDeleteAlias": "Delete alias {{alias}}? This cannot be undone.",
"confirmUnbindTarget": "Unbind {{provider}}/{{model}} from {{alias}}?"
},
"overview": {
"title": "Overview",
@ -67,6 +81,7 @@
"actionsTitle": "Actions",
"statsTitle": "System snapshot",
"environmentTitle": "Environment",
"environmentSubtitle": "Only runtime and desktop app details stay here.",
"doctorTitle": "Doctor summary",
"debugTitle": "Debug details",
"debugSummary": "Show raw overview JSON",
@ -98,7 +113,9 @@
"filterDisabled": "Disabled",
"listCount": "{{shown}} shown / {{total}} total",
"empty": "No providers configured yet.",
"emptyHint": "Create one manually or import providers from your OpenCode config.",
"noMatches": "No providers match the current filters.",
"noMatchesHint": "Try a broader search term or switch the status filter.",
"id": "Provider id",
"name": "Display name",
"baseUrl": "Base URL",
@ -114,6 +131,7 @@
"apiKeyNotSet": "not set",
"headersNone": "none",
"modelsNone": "none",
"modelsCount": "{{count}} models",
"sourceLabel": "Source",
"placeholderId": "su8",
"placeholderName": "SU8",
@ -142,7 +160,9 @@
"searchPlaceholder": "Filter by alias, display name, provider, or model",
"listCount": "{{shown}} shown / {{total}} total",
"empty": "No aliases configured yet.",
"emptyHint": "Create an alias first, then bind provider/model targets to it.",
"noMatches": "No aliases match the current filters.",
"noMatchesHint": "Try a different search term or create a new alias route.",
"alias": "Alias",
"displayName": "Display name",
"aliasForBinding": "Alias for binding",
@ -156,6 +176,7 @@
"placeholderProviderId": "su8",
"placeholderModel": "gpt-5.4",
"targets": "Targets",
"targetsCount": "{{count}} targets",
"routable": "{{available}}/{{total}} routable",
"statusSaved": "Saved alias {{alias}}",
"statusEditing": "Editing alias {{alias}}",
@ -194,9 +215,23 @@
"appearanceTitle": "Appearance",
"languageTitle": "Language",
"behaviorTitle": "Desktop behavior",
"configTitle": "Config file",
"importTitle": "Import mode",
"importModeText": "Import text",
"importModeFile": "Import file",
"importText": "Config text",
"importFile": "Config file",
"importPlaceholder": "Paste full ocswitch config JSON",
"fileEmpty": "No file selected yet.",
"exportDone": "Exported config {{path}}",
"importDone": "Imported config {{path}}",
"fileLoaded": "Loaded file {{name}}",
"importEmpty": "Provide config content first.",
"aboutTitle": "About",
"aboutSubtitle": "Read-only environment details for the current desktop shell.",
"theme": "Theme",
"language": "Language",
"languageLabel": "Preferred language",
"themeSystem": "System",
"themeLight": "Light",
"themeDark": "Dark",

View File

@ -1,6 +1,7 @@
{
"app": {
"eyebrow": "ocswitch desktop",
"brand": "ocswitch desktop",
"title": "桌面控制台",
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
"loadingConfig": "正在读取配置",
@ -31,7 +32,17 @@
"unbind": "解绑",
"newProvider": "新建提供商",
"newAlias": "新建别名",
"useInBindForm": "带入绑定表单"
"useInBindForm": "带入绑定表单",
"close": "关闭",
"cancel": "取消",
"confirm": "确认",
"exportConfig": "导出配置",
"importConfig": "导入配置"
},
"confirm": {
"deleteProviderTitle": "确认删除提供商",
"deleteAliasTitle": "确认删除别名",
"unbindTargetTitle": "确认解绑目标"
},
"status": {
"proxyRunning": "代理运行中",
@ -59,7 +70,10 @@
"previewChanges": "预览显示有变更",
"previewNoChanges": "预览显示无变更",
"syncApplied": "同步已应用",
"syncUpToDate": "已经是最新状态"
"syncUpToDate": "已经是最新状态",
"confirmDeleteProvider": "确认删除提供商 {{id}}?此操作不可撤销。",
"confirmDeleteAlias": "确认删除别名 {{alias}}?此操作不可撤销。",
"confirmUnbindTarget": "确认从 {{alias}} 解绑 {{provider}}/{{model}}"
},
"overview": {
"title": "概览",
@ -67,6 +81,7 @@
"actionsTitle": "快捷操作",
"statsTitle": "系统快照",
"environmentTitle": "环境信息",
"environmentSubtitle": "仅保留当前运行态和桌面应用相关信息。",
"doctorTitle": "Doctor 摘要",
"debugTitle": "调试详情",
"debugSummary": "查看原始 overview JSON",
@ -98,7 +113,9 @@
"filterDisabled": "已禁用",
"listCount": "显示 {{shown}} / 共 {{total}}",
"empty": "还没有配置任何提供商。",
"emptyHint": "你可以手动新建提供商,或从 OpenCode 配置中批量导入。",
"noMatches": "当前筛选条件下没有匹配的提供商。",
"noMatchesHint": "尝试放宽搜索词或切换状态筛选。",
"id": "提供商 id",
"name": "显示名称",
"baseUrl": "Base URL",
@ -114,6 +131,7 @@
"apiKeyNotSet": "未设置",
"headersNone": "无",
"modelsNone": "无",
"modelsCount": "{{count}} 个模型",
"sourceLabel": "来源",
"placeholderId": "su8",
"placeholderName": "SU8",
@ -142,7 +160,9 @@
"searchPlaceholder": "按别名、显示名、provider 或 model 过滤",
"listCount": "显示 {{shown}} / 共 {{total}}",
"empty": "还没有配置任何别名。",
"emptyHint": "先新建别名,再把上游 provider/model 绑定到它。",
"noMatches": "当前筛选条件下没有匹配的别名。",
"noMatchesHint": "尝试修改搜索词,或直接新建一个新的别名路由。",
"alias": "别名",
"displayName": "显示名称",
"aliasForBinding": "用于绑定的别名",
@ -156,6 +176,7 @@
"placeholderProviderId": "su8",
"placeholderModel": "gpt-5.4",
"targets": "目标",
"targetsCount": "{{count}} 个目标",
"routable": "可路由 {{available}}/{{total}}",
"statusSaved": "已保存别名 {{alias}}",
"statusEditing": "正在编辑别名 {{alias}}",
@ -194,9 +215,23 @@
"appearanceTitle": "外观",
"languageTitle": "语言",
"behaviorTitle": "桌面行为",
"configTitle": "配置文件",
"importTitle": "导入方式",
"importModeText": "文本导入",
"importModeFile": "文件导入",
"importText": "配置文本",
"importFile": "配置文件",
"importPlaceholder": "粘贴完整 ocswitch 配置 JSON",
"fileEmpty": "尚未选择文件。",
"exportDone": "已导出配置 {{path}}",
"importDone": "已导入配置 {{path}}",
"fileLoaded": "已读取文件 {{name}}",
"importEmpty": "请先提供配置内容。",
"aboutTitle": "关于",
"aboutSubtitle": "显示当前桌面壳环境信息,仅用于查看。",
"theme": "主题",
"language": "语言",
"languageLabel": "首选语言",
"themeSystem": "跟随系统",
"themeLight": "浅色",
"themeDark": "深色",

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,20 @@ export type DesktopPrefsSaveResult = {
warnings?: string[]
}
export type ConfigExportView = {
configPath: string
content: string
}
export type ConfigImportInput = {
content: string
}
export type ConfigImportResult = {
configPath: string
warnings?: string[]
}
export type ProxyStatusView = {
running: boolean
bindAddress: string

View File

@ -22,6 +22,10 @@ export function DesktopPrefs():Promise<app.DesktopPrefsView>;
export function DoctorRun():Promise<app.DoctorRunResult>;
export function ExportConfig():Promise<app.ConfigExportView>;
export function ImportConfig(arg1:app.ConfigImportInput):Promise<app.ConfigImportResult>;
export function ImportProviders(arg1:app.ProviderImportInput):Promise<app.ProviderImportResult>;
export function Meta():Promise<Record<string, string>>;

View File

@ -38,6 +38,14 @@ export function DoctorRun() {
return window['go']['desktop']['App']['DoctorRun']();
}
export function ExportConfig() {
return window['go']['desktop']['App']['ExportConfig']();
}
export function ImportConfig(arg1) {
return window['go']['desktop']['App']['ImportConfig'](arg1);
}
export function ImportProviders(arg1) {
return window['go']['desktop']['App']['ImportProviders'](arg1);
}

View File

@ -90,6 +90,46 @@ export namespace app {
return a;
}
}
export class ConfigExportView {
configPath: string;
content: string;
static createFrom(source: any = {}) {
return new ConfigExportView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.configPath = source["configPath"];
this.content = source["content"];
}
}
export class ConfigImportInput {
content: string;
static createFrom(source: any = {}) {
return new ConfigImportInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.content = source["content"];
}
}
export class ConfigImportResult {
configPath: string;
warnings?: string[];
static createFrom(source: any = {}) {
return new ConfigImportResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.configPath = source["configPath"];
this.warnings = source["warnings"];
}
}
export class DesktopPrefsInput {
launchAtLogin: boolean;
minimizeToTray: boolean;

View File

@ -0,0 +1,90 @@
package app
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/Apale7/opencode-provider-switch/internal/config"
)
func (s *Service) ExportConfig(ctx context.Context) (ConfigExportView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ConfigExportView{}, err
}
content, err := marshalConfigContent(cfg)
if err != nil {
return ConfigExportView{}, err
}
return ConfigExportView{ConfigPath: cfg.Path(), Content: content}, nil
}
func (s *Service) ImportConfig(ctx context.Context, in ConfigImportInput) (ConfigImportResult, error) {
_ = ctx
content := strings.TrimSpace(in.Content)
if content == "" {
return ConfigImportResult{}, fmt.Errorf("config content is required")
}
imported := config.Default()
if err := json.Unmarshal([]byte(content), imported); err != nil {
return ConfigImportResult{}, fmt.Errorf("parse config: %w", err)
}
if imported.Server.Host == "" {
imported.Server.Host = "127.0.0.1"
}
if imported.Server.Port == 0 {
imported.Server.Port = 9982
}
if imported.Server.APIKey == "" {
imported.Server.APIKey = config.DefaultLocalAPIKey
}
if errs := imported.Validate(); len(errs) > 0 {
return ConfigImportResult{}, errs[0]
}
cfg, err := s.loadConfig()
if err != nil {
return ConfigImportResult{}, err
}
cfg.Server = imported.Server
cfg.Desktop = imported.Desktop
cfg.Providers = append([]config.Provider(nil), imported.Providers...)
cfg.Aliases = append([]config.Alias(nil), imported.Aliases...)
if err := cfg.Save(); err != nil {
return ConfigImportResult{}, err
}
result := ConfigImportResult{ConfigPath: cfg.Path()}
if s.currentProxyStatus(proxyBindAddress(cfg)).Running {
result.Warnings = append(result.Warnings, "proxy is still running with the previous in-memory config; restart it to apply imported settings")
}
return result, nil
}
func marshalConfigContent(cfg *config.Config) (string, error) {
providers := append([]config.Provider(nil), cfg.Providers...)
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
aliases := append([]config.Alias(nil), cfg.Aliases...)
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
snapshot := struct {
Server config.Server `json:"server"`
Desktop config.Desktop `json:"desktop,omitempty"`
Providers []config.Provider `json:"providers"`
Aliases []config.Alias `json:"aliases"`
}{
Server: cfg.Server,
Desktop: cfg.Desktop,
Providers: providers,
Aliases: aliases,
}
data, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
return "", fmt.Errorf("marshal config: %w", err)
}
return string(append(data, '\n')), nil
}

View File

@ -316,7 +316,7 @@ func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView {
s.mu.Lock()
defer s.mu.Unlock()
status := s.proxyStatus
if status.BindAddress == "" {
if !status.Running || status.BindAddress == "" {
status.BindAddress = bindAddress
}
return status

View File

@ -116,6 +116,52 @@ func TestStartStopProxyUpdatesStatus(t *testing.T) {
}
}
func TestGetProxyStatusUsesCurrentConfigAddressWhenStopped(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
firstPort := freePort(t)
secondPort := freePort(t)
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = firstPort
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
svc := NewService(path)
if err := svc.StartProxy(context.Background()); err != nil {
t.Fatalf("StartProxy() error = %v", err)
}
if err := svc.StopProxy(context.Background()); err != nil {
t.Fatalf("StopProxy() error = %v", err)
}
cfg, err = config.Load(path)
if err != nil {
t.Fatalf("config.Load() reload error = %v", err)
}
cfg.Server.Port = secondPort
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() update error = %v", err)
}
status, err := svc.GetProxyStatus(context.Background())
if err != nil {
t.Fatalf("GetProxyStatus() error = %v", err)
}
if status.Running {
t.Fatalf("status.Running = true, want false")
}
want := "127.0.0.1:" + itoa(secondPort)
if status.BindAddress != want {
t.Fatalf("status.BindAddress = %q, want %q", status.BindAddress, want)
}
}
func TestUpsertProviderReturnsWarningsAndKeepsCatalog(t *testing.T) {
t.Parallel()

View File

@ -115,6 +115,20 @@ type DesktopPrefsInput struct {
Language string `json:"language"`
}
type ConfigExportView struct {
ConfigPath string `json:"configPath"`
Content string `json:"content"`
}
type ConfigImportInput struct {
Content string `json:"content"`
}
type ConfigImportResult struct {
ConfigPath string `json:"configPath"`
Warnings []string `json:"warnings,omitempty"`
}
type ProviderUpsertInput struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`

View File

@ -86,12 +86,10 @@ func (a *App) SyncDesktopPreferences(ctx context.Context) error {
if err != nil {
return err
}
if err := a.auto.Sync(ctx, prefs); err != nil {
return err
}
autoErr := a.auto.Sync(ctx, prefs)
a.tray.Sync(ctx, prefs)
a.notify.Sync(ctx, prefs)
return nil
return autoErr
}
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsSaveResult, error) {
@ -127,6 +125,22 @@ func (a *App) Overview() (app.Overview, error) {
return a.bindings.GetOverview(a.callContext())
}
func (a *App) ExportConfig() (app.ConfigExportView, error) {
return a.bindings.ExportConfig(a.callContext())
}
func (a *App) ImportConfig(in app.ConfigImportInput) (app.ConfigImportResult, error) {
result, err := a.bindings.ImportConfig(a.callContext(), in)
if err != nil {
return app.ConfigImportResult{}, err
}
if syncErr := a.SyncDesktopPreferences(a.callContext()); syncErr != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("imported config but could not sync desktop integrations: %v", syncErr))
}
a.tray.RefreshProxyStatus(a.callContext())
return result, nil
}
func (a *App) Providers() ([]app.ProviderView, error) {
return a.bindings.ListProviders(a.callContext())
}

View File

@ -21,6 +21,14 @@ func (b *Bindings) GetOverview(ctx context.Context) (app.Overview, error) {
return b.service.GetOverview(ctx)
}
func (b *Bindings) ExportConfig(ctx context.Context) (app.ConfigExportView, error) {
return b.service.ExportConfig(ctx)
}
func (b *Bindings) ImportConfig(ctx context.Context, in app.ConfigImportInput) (app.ConfigImportResult, error) {
return b.service.ImportConfig(ctx, in)
}
func (b *Bindings) ListProviders(ctx context.Context) ([]app.ProviderView, error) {
return b.service.ListProviders(ctx)
}
@ -99,6 +107,14 @@ func (b *Bindings) Overview() (app.Overview, error) {
return b.GetOverview(context.Background())
}
func (b *Bindings) ExportConfigNow() (app.ConfigExportView, error) {
return b.ExportConfig(context.Background())
}
func (b *Bindings) ImportConfigNow(in app.ConfigImportInput) (app.ConfigImportResult, error) {
return b.ImportConfig(context.Background(), in)
}
func (b *Bindings) Providers() ([]app.ProviderView, error) {
return b.ListProviders(context.Background())
}

View File

@ -123,6 +123,28 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
writeResult(w, data, err)
})
api.HandleFunc("/api/config/export", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
data, err := b.ExportConfig(r.Context())
writeResult(w, data, err)
})
api.HandleFunc("/api/config/import", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var in appcore.ConfigImportInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := instance.ImportConfig(in)
writeResult(w, data, err)
})
api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:

View File

@ -136,6 +136,91 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
}
})
t.Run("import config syncs desktop integrations and reports warning", func(t *testing.T) {
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.UpsertProvider(config.Provider{
ID: "demo",
Name: "Demo",
BaseURL: "https://example.com/v1",
APIKey: "sk-demo-12345678",
Models: []string{"gpt-4.1-mini"},
})
cfg.UpsertAlias(config.Alias{
Alias: "chat",
DisplayName: "Chat",
Enabled: true,
Targets: []config.Target{{
Provider: "demo",
Model: "gpt-4.1-mini",
Enabled: true,
}},
})
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
originalAuto := instance.auto
originalTray := instance.tray
originalNotify := instance.notify
auto := failingAutoStart{message: "startup folder unavailable"}
tray := &spyTray{}
notify := &spyNotifier{}
instance.auto = auto
instance.tray = tray
instance.notify = notify
defer func() {
instance.auto = originalAuto
instance.tray = originalTray
instance.notify = originalNotify
}()
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{
"content":"{\"server\":{\"host\":\"127.0.0.1\",\"port\":9982,\"api_key\":\"ocswitch-local\"},\"desktop\":{\"launch_at_login\":true,\"minimize_to_tray\":true,\"notifications\":true,\"theme\":\"dark\",\"language\":\"zh-CN\"},\"providers\":[{\"id\":\"demo\",\"name\":\"Demo\",\"base_url\":\"https://example.com/v1\",\"api_key\":\"sk-demo-12345678\",\"models\":[\"gpt-4.1-mini\"]}],\"aliases\":[{\"alias\":\"chat\",\"display_name\":\"Chat\",\"enabled\":true,\"targets\":[{\"provider\":\"demo\",\"model\":\"gpt-4.1-mini\",\"enabled\":true}]}]}"
}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
var payload struct {
Data struct {
ConfigPath string `json:"configPath"`
Warnings []string `json:"warnings"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.ConfigPath == "" {
t.Fatalf("configPath is empty: %#v", payload.Data)
}
if len(payload.Data.Warnings) != 1 || !strings.Contains(payload.Data.Warnings[0], "sync desktop integrations") {
t.Fatalf("warnings = %#v, want desktop sync warning", payload.Data.Warnings)
}
if tray.syncCalls != 1 {
t.Fatalf("tray syncCalls = %d, want 1", tray.syncCalls)
}
if tray.refreshCalls != 1 {
t.Fatalf("tray refreshCalls = %d, want 1", tray.refreshCalls)
}
if !notify.lastPrefs.Notifications {
t.Fatalf("notify prefs = %#v, want notifications enabled", notify.lastPrefs)
}
loaded, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications {
t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop)
}
})
t.Run("provider save exposes warnings", func(t *testing.T) {
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
@ -295,3 +380,44 @@ func (f failingAutoStart) Detach() {}
func (f failingAutoStart) Sync(_ context.Context, _ app.DesktopPrefsView) error {
return fmt.Errorf(f.message)
}
type spyTray struct {
syncCalls int
refreshCalls int
lastPrefs app.DesktopPrefsView
}
func (s *spyTray) Attach(_ context.Context) {}
func (s *spyTray) Detach() {}
func (s *spyTray) Sync(_ context.Context, prefs app.DesktopPrefsView) {
s.syncCalls++
s.lastPrefs = prefs
}
func (s *spyTray) RefreshProxyStatus(_ context.Context) {
s.refreshCalls++
}
func (s *spyTray) BeforeClose(_ context.Context) (bool, error) {
return false, nil
}
type spyNotifier struct {
syncCalls int
lastPrefs app.DesktopPrefsView
}
func (s *spyNotifier) Attach(_ context.Context) {}
func (s *spyNotifier) Detach() {}
func (s *spyNotifier) Sync(_ context.Context, prefs app.DesktopPrefsView) {
s.syncCalls++
s.lastPrefs = prefs
}
func (s *spyNotifier) Send(_ context.Context, _ string, _ string) error {
return nil
}