From 8b3d0bab36f3a470d88bc8845cf9cd5e540a52f8 Mon Sep 17 00:00:00 2001 From: nasakura <92672842+yingzeliangzi@users.noreply.github.com> Date: Thu, 15 Jan 2026 05:31:10 +0000 Subject: [PATCH] feat: add listen address configuration option --- cmd/desktop/app.go | 3 + cmd/desktop/frontend/src/i18n/en.js | 12 +- cmd/desktop/frontend/src/i18n/zh-CN.js | 12 +- cmd/desktop/frontend/src/main.js | 2 + cmd/desktop/frontend/src/modules/config.js | 7 +- cmd/desktop/frontend/src/modules/modal.js | 23 +- cmd/desktop/frontend/src/modules/ui.js | 9 + cmd/desktop/frontend/src/style.css | 25 + cmd/desktop/frontend/wailsjs/go/main/App.d.ts | 2 + cmd/desktop/frontend/wailsjs/go/main/App.js | 4 + cmd/server/main.go | 275 ++++----- cmd/server/webui/api/config.go | 32 +- cmd/server/webui/ui/js/api.js | 8 +- internal/config/config.go | 66 ++- internal/proxy/proxy.go | 29 +- internal/service/settings.go | 553 +++++++++--------- 16 files changed, 618 insertions(+), 444 deletions(-) diff --git a/cmd/desktop/app.go b/cmd/desktop/app.go index c2bf7a75..fb907f54 100644 --- a/cmd/desktop/app.go +++ b/cmd/desktop/app.go @@ -397,6 +397,9 @@ func (a *App) UpdateConfig(configJSON string) error { return a.settings.UpdateConfig(configJSON, a.proxy) } func (a *App) UpdatePort(port int) error { return a.settings.UpdatePort(port) } +func (a *App) UpdateNetwork(port int, listenAddr string) error { + return a.settings.UpdateNetwork(port, listenAddr) +} func (a *App) GetSystemLanguage() string { return a.settings.GetSystemLanguage() } func (a *App) GetLanguage() string { return a.settings.GetLanguage() } func (a *App) SetLanguage(language string) error { return a.settings.SetLanguage(language) } diff --git a/cmd/desktop/frontend/src/i18n/en.js b/cmd/desktop/frontend/src/i18n/en.js index db2c9bdc..e4f04018 100644 --- a/cmd/desktop/frontend/src/i18n/en.js +++ b/cmd/desktop/frontend/src/i18n/en.js @@ -71,10 +71,16 @@ export default { changePort: 'Change Port', port: 'Port', portLabel: 'Port (1-65535):', - portNote: 'Note: Changing port requires application restart', + listenAddrLabel: 'Listen address:', + listenAddrPlaceholder: 'e.g., 127.0.0.1', + listenAddrPresetPublic: 'Public 0.0.0.0', + listenAddrPresetLAN: 'LAN 192.168.0.0', + listenAddrPresetLocal: 'Local 127.0.0.1', + listenAddrInvalid: 'Please enter a valid listen address', + portNote: 'Note: Changing port or listen address requires application restart', portInvalid: 'Please enter a valid port number (1-65535)', - portUpdateSuccess: 'Port updated successfully! Please restart the application for changes to take effect.', - portUpdateFailed: 'Failed to update port: {error}', + portUpdateSuccess: 'Port and listen address updated successfully! Please restart the application for changes to take effect.', + portUpdateFailed: 'Failed to update port or listen address: {error}', requiredFields: 'Please fill in all required fields', modelRequired: 'Model field is required for {transformer} transformer', saveFailed: 'Failed to save: {error}', diff --git a/cmd/desktop/frontend/src/i18n/zh-CN.js b/cmd/desktop/frontend/src/i18n/zh-CN.js index f3fbca0b..39a4c55c 100644 --- a/cmd/desktop/frontend/src/i18n/zh-CN.js +++ b/cmd/desktop/frontend/src/i18n/zh-CN.js @@ -71,10 +71,16 @@ export default { changePort: '修改端口', port: '端口', portLabel: '端口号 (1-65535):', - portNote: '注意:修改端口号需要重启应用', + listenAddrLabel: '监听地址:', + listenAddrPlaceholder: '例如:127.0.0.1', + listenAddrPresetPublic: '公网 0.0.0.0', + listenAddrPresetLAN: '局域网 192.168.0.0', + listenAddrPresetLocal: '本地 127.0.0.1', + listenAddrInvalid: '请输入有效的监听地址', + portNote: '注意:修改端口号或监听地址需要重启应用', portInvalid: '请输入有效的端口号(1-65535)', - portUpdateSuccess: '端口修改成功!请重启应用以使更改生效。', - portUpdateFailed: '端口修改失败:{error}', + portUpdateSuccess: '端口和监听地址修改成功!请重启应用以使更改生效。', + portUpdateFailed: '端口或监听地址修改失败:{error}', requiredFields: '请填写所有必填项', modelRequired: '使用 {transformer} 转换器时,模型字段为必填项', saveFailed: '保存失败:{error}', diff --git a/cmd/desktop/frontend/src/main.js b/cmd/desktop/frontend/src/main.js index 88f83f15..5c0d9e02 100644 --- a/cmd/desktop/frontend/src/main.js +++ b/cmd/desktop/frontend/src/main.js @@ -28,6 +28,7 @@ import { showEditPortModal, savePort, closePortModal, + setListenAddrPreset, showWelcomeModal, closeWelcomeModal, showWelcomeModalIfFirstTime, @@ -177,6 +178,7 @@ window.toggleModelDropdown = toggleModelDropdown; window.showEditPortModal = showEditPortModal; window.savePort = savePort; window.closePortModal = closePortModal; +window.setListenAddrPreset = setListenAddrPreset; window.showWelcomeModal = showWelcomeModal; window.closeWelcomeModal = closeWelcomeModal; window.showChangelogModal = showChangelogModal; diff --git a/cmd/desktop/frontend/src/modules/config.js b/cmd/desktop/frontend/src/modules/config.js index 6b57aab4..9ded95e8 100644 --- a/cmd/desktop/frontend/src/modules/config.js +++ b/cmd/desktop/frontend/src/modules/config.js @@ -15,7 +15,8 @@ export async function loadConfig() { const configStr = await window.go.main.App.GetConfig(); const config = JSON.parse(configStr); - document.getElementById('proxyPort').textContent = config.port; + const listenAddr = config.listenAddr || '127.0.0.1'; + document.getElementById('proxyPort').textContent = `${listenAddr}:${config.port}`; document.getElementById('totalEndpoints').textContent = config.endpoints.length; const activeCount = config.endpoints.filter(ep => ep.enabled !== false).length; @@ -32,6 +33,10 @@ export async function updatePort(port) { await window.go.main.App.UpdatePort(port); } +export async function updateNetwork(port, listenAddr) { + await window.go.main.App.UpdateNetwork(port, listenAddr); +} + export async function addEndpoint(name, url, key, transformer, model, remark) { await window.go.main.App.AddEndpoint(name, url, key, transformer, model, remark || ''); } diff --git a/cmd/desktop/frontend/src/modules/modal.js b/cmd/desktop/frontend/src/modules/modal.js index 53d7aa59..d3a584df 100644 --- a/cmd/desktop/frontend/src/modules/modal.js +++ b/cmd/desktop/frontend/src/modules/modal.js @@ -1,6 +1,6 @@ import { t } from '../i18n/index.js'; import { escapeHtml } from '../utils/format.js'; -import { addEndpoint, updateEndpoint, removeEndpoint, testEndpoint, testEndpointLight, updatePort } from './config.js'; +import { addEndpoint, updateEndpoint, removeEndpoint, testEndpoint, testEndpointLight, updateNetwork } from './config.js'; import { setTestState, clearTestState, saveEndpointTestStatus } from './endpoints.js'; let currentEditIndex = -1; @@ -345,19 +345,30 @@ export async function showEditPortModal() { const config = JSON.parse(configStr); document.getElementById('portInput').value = config.port; + const listenAddrInput = document.getElementById('listenAddrInput'); + if (listenAddrInput) { + listenAddrInput.value = config.listenAddr || '127.0.0.1'; + } document.getElementById('portModal').classList.add('active'); } export async function savePort() { const port = parseInt(document.getElementById('portInput').value); + const listenAddrInput = document.getElementById('listenAddrInput'); + const listenAddr = (listenAddrInput?.value || '').trim() || '127.0.0.1'; if (!port || port < 1 || port > 65535) { showNotification(t('modal.portInvalid'), 'error'); return; } + if (!listenAddr || /\s/.test(listenAddr)) { + showNotification(t('modal.listenAddrInvalid'), 'error'); + return; + } + try { - await updatePort(port); + await updateNetwork(port, listenAddr); closePortModal(); window.loadConfig(); showNotification(t('modal.portUpdateSuccess'), 'success'); @@ -370,6 +381,14 @@ export function closePortModal() { document.getElementById('portModal').classList.remove('active'); } +export function setListenAddrPreset(addr) { + const input = document.getElementById('listenAddrInput'); + if (input) { + input.value = addr; + input.focus(); + } +} + // ========== 加群二维码URL配置 ========== // 上传到图床后填写URL,过期时直接替换图床文件即可自动更新 diff --git a/cmd/desktop/frontend/src/modules/ui.js b/cmd/desktop/frontend/src/modules/ui.js index 6ae70f9f..995eaff5 100644 --- a/cmd/desktop/frontend/src/modules/ui.js +++ b/cmd/desktop/frontend/src/modules/ui.js @@ -442,6 +442,15 @@ export function initUI() { +
+ + +
+ + + +
+

⚠️ ${t('modal.portNote')}

diff --git a/cmd/desktop/frontend/src/style.css b/cmd/desktop/frontend/src/style.css index 6f35ff4d..ecb0a956 100644 --- a/cmd/desktop/frontend/src/style.css +++ b/cmd/desktop/frontend/src/style.css @@ -1399,6 +1399,31 @@ body { font-family: 'Monaco', 'Menlo', 'Courier New', monospace; } +/* Listen address presets */ +.listen-addr-presets { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.preset-chip { + border: 1px solid rgba(102, 126, 234, 0.35); + background: rgba(102, 126, 234, 0.08); + color: #4c51bf; + padding: 6px 10px; + border-radius: 8px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; +} + +.preset-chip:hover { + background: rgba(102, 126, 234, 0.15); + border-color: rgba(102, 126, 234, 0.6); + transform: translateY(-1px); +} + /* Endpoint Toggle Button */ .endpoint-toggle-btn { padding: 0 10px; diff --git a/cmd/desktop/frontend/wailsjs/go/main/App.d.ts b/cmd/desktop/frontend/wailsjs/go/main/App.d.ts index db0bd7ef..b14aa290 100755 --- a/cmd/desktop/frontend/wailsjs/go/main/App.d.ts +++ b/cmd/desktop/frontend/wailsjs/go/main/App.d.ts @@ -191,6 +191,8 @@ export function UpdateEndpoint(arg1:number,arg2:string,arg3:string,arg4:string,a export function UpdateLocalBackupDir(arg1:string):Promise; +export function UpdateNetwork(arg1:number,arg2:string):Promise; + export function UpdatePort(arg1:number):Promise; export function UpdateS3BackupConfig(arg1:string,arg2:string,arg3:string,arg4:string,arg5:string,arg6:string,arg7:string,arg8:boolean,arg9:boolean):Promise; diff --git a/cmd/desktop/frontend/wailsjs/go/main/App.js b/cmd/desktop/frontend/wailsjs/go/main/App.js index f546a9fc..d509a724 100755 --- a/cmd/desktop/frontend/wailsjs/go/main/App.js +++ b/cmd/desktop/frontend/wailsjs/go/main/App.js @@ -382,6 +382,10 @@ export function UpdateLocalBackupDir(arg1) { return window['go']['main']['App']['UpdateLocalBackupDir'](arg1); } +export function UpdateNetwork(arg1, arg2) { + return window['go']['main']['App']['UpdateNetwork'](arg1, arg2); +} + export function UpdatePort(arg1) { return window['go']['main']['App']['UpdatePort'](arg1); } diff --git a/cmd/server/main.go b/cmd/server/main.go index 514f8f28..61d88e9f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,153 +1,160 @@ package main import ( - "errors" - "net/http" - "os" - "os/signal" - "path/filepath" - "strconv" - "syscall" - - "github.com/lich0821/ccNexus/internal/config" - "github.com/lich0821/ccNexus/internal/logger" - "github.com/lich0821/ccNexus/internal/proxy" - "github.com/lich0821/ccNexus/internal/storage" + "errors" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "syscall" + + "github.com/lich0821/ccNexus/internal/config" + "github.com/lich0821/ccNexus/internal/logger" + "github.com/lich0821/ccNexus/internal/proxy" + "github.com/lich0821/ccNexus/internal/storage" ) func main() { - dataDir := resolveDataDir() - if err := os.MkdirAll(dataDir, 0755); err != nil { - logger.Error("Failed to create data dir %s: %v", dataDir, err) - os.Exit(1) - } - - dbPath := os.Getenv("CCNEXUS_DB_PATH") - if dbPath == "" { - dbPath = filepath.Join(dataDir, "ccnexus.db") - } - - sqliteStorage, err := storage.NewSQLiteStorage(dbPath) - if err != nil { - logger.Error("Failed to open SQLite storage: %v", err) - os.Exit(1) - } - defer sqliteStorage.Close() - - cfg, err := loadConfig(sqliteStorage) - if err != nil { - logger.Error("Unable to load configuration: %v", err) - os.Exit(1) - } - - applyEnvOverrides(cfg) - setLogLevels(cfg.GetLogLevel()) - - if err := cfg.Validate(); err != nil { - logger.Error("Invalid configuration: %v", err) - os.Exit(1) - } - - deviceID, err := sqliteStorage.GetOrCreateDeviceID() - if err != nil { - logger.Warn("Failed to get device ID: %v, using default", err) - deviceID = "default" - } - - statsAdapter := storage.NewStatsStorageAdapter(sqliteStorage) - p := proxy.New(cfg, statsAdapter, deviceID) - - // Create HTTP mux - mux := http.NewServeMux() - - // Initialize and register Web UI (optional plugin) - // If webui package is not available, this will be skipped at compile time - if err := registerWebUI(mux, cfg, p, sqliteStorage); err != nil { - logger.Warn("Web UI not available: %v", err) - } else { - logger.Info("Web UI available at /ui/") - } - - errCh := make(chan error, 1) - go func() { - errCh <- p.StartWithMux(mux) - }() - - logger.Info("ccNexus headless API listening on :%d (data dir: %s, db: %s)", cfg.GetPort(), dataDir, dbPath) - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - select { - case sig := <-sigCh: - logger.Info("Received signal %s, shutting down", sig.String()) - if err := p.Stop(); err != nil { - logger.Warn("Graceful shutdown failed: %v", err) - } - case err := <-errCh: - if err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.Error("Proxy server stopped with error: %v", err) - os.Exit(1) - } - } - - logger.Info("ccNexus stopped") + dataDir := resolveDataDir() + if err := os.MkdirAll(dataDir, 0755); err != nil { + logger.Error("Failed to create data dir %s: %v", dataDir, err) + os.Exit(1) + } + + dbPath := os.Getenv("CCNEXUS_DB_PATH") + if dbPath == "" { + dbPath = filepath.Join(dataDir, "ccnexus.db") + } + + sqliteStorage, err := storage.NewSQLiteStorage(dbPath) + if err != nil { + logger.Error("Failed to open SQLite storage: %v", err) + os.Exit(1) + } + defer sqliteStorage.Close() + + cfg, err := loadConfig(sqliteStorage) + if err != nil { + logger.Error("Unable to load configuration: %v", err) + os.Exit(1) + } + + applyEnvOverrides(cfg) + setLogLevels(cfg.GetLogLevel()) + + if err := cfg.Validate(); err != nil { + logger.Error("Invalid configuration: %v", err) + os.Exit(1) + } + + deviceID, err := sqliteStorage.GetOrCreateDeviceID() + if err != nil { + logger.Warn("Failed to get device ID: %v, using default", err) + deviceID = "default" + } + + statsAdapter := storage.NewStatsStorageAdapter(sqliteStorage) + p := proxy.New(cfg, statsAdapter, deviceID) + + // Create HTTP mux + mux := http.NewServeMux() + + // Initialize and register Web UI (optional plugin) + // If webui package is not available, this will be skipped at compile time + if err := registerWebUI(mux, cfg, p, sqliteStorage); err != nil { + logger.Warn("Web UI not available: %v", err) + } else { + logger.Info("Web UI available at /ui/") + } + + errCh := make(chan error, 1) + go func() { + errCh <- p.StartWithMux(mux) + }() + + listenAddr := cfg.GetListenAddr() + addr := net.JoinHostPort(listenAddr, strconv.Itoa(cfg.GetPort())) + logger.Info("ccNexus headless API listening on %s (data dir: %s, db: %s)", addr, dataDir, dbPath) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + select { + case sig := <-sigCh: + logger.Info("Received signal %s, shutting down", sig.String()) + if err := p.Stop(); err != nil { + logger.Warn("Graceful shutdown failed: %v", err) + } + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("Proxy server stopped with error: %v", err) + os.Exit(1) + } + } + + logger.Info("ccNexus stopped") } func resolveDataDir() string { - if dir := os.Getenv("CCNEXUS_DATA_DIR"); dir != "" { - return dir - } - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, ".ccNexus") - } - return "/data" + if dir := os.Getenv("CCNEXUS_DATA_DIR"); dir != "" { + return dir + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".ccNexus") + } + return "/data" } func loadConfig(sqliteStorage *storage.SQLiteStorage) (*config.Config, error) { - adapter := storage.NewConfigStorageAdapter(sqliteStorage) - cfg, err := config.LoadFromStorage(adapter) - if err != nil { - logger.Warn("Failed to load config from storage, using default: %v", err) - cfg = config.DefaultConfig() - if saveErr := cfg.SaveToStorage(adapter); saveErr != nil { - logger.Warn("Failed to persist default config: %v", saveErr) - } - } - - // Seed a default endpoint when none are configured to avoid boot failure - if len(cfg.Endpoints) == 0 { - logger.Warn("No endpoints found; seeding a default endpoint") - cfg.Endpoints = config.DefaultConfig().Endpoints - if saveErr := cfg.SaveToStorage(adapter); saveErr != nil { - logger.Warn("Failed to persist seeded endpoint: %v", saveErr) - } - } - return cfg, nil + adapter := storage.NewConfigStorageAdapter(sqliteStorage) + cfg, err := config.LoadFromStorage(adapter) + if err != nil { + logger.Warn("Failed to load config from storage, using default: %v", err) + cfg = config.DefaultConfig() + if saveErr := cfg.SaveToStorage(adapter); saveErr != nil { + logger.Warn("Failed to persist default config: %v", saveErr) + } + } + + // Seed a default endpoint when none are configured to avoid boot failure + if len(cfg.Endpoints) == 0 { + logger.Warn("No endpoints found; seeding a default endpoint") + cfg.Endpoints = config.DefaultConfig().Endpoints + if saveErr := cfg.SaveToStorage(adapter); saveErr != nil { + logger.Warn("Failed to persist seeded endpoint: %v", saveErr) + } + } + return cfg, nil } func applyEnvOverrides(cfg *config.Config) { - if portStr := os.Getenv("CCNEXUS_PORT"); portStr != "" { - if port, err := strconv.Atoi(portStr); err == nil { - cfg.UpdatePort(port) - } else { - logger.Warn("Invalid CCNEXUS_PORT value %q: %v", portStr, err) - } - } - - if levelStr := os.Getenv("CCNEXUS_LOG_LEVEL"); levelStr != "" { - if level, err := strconv.Atoi(levelStr); err == nil { - cfg.UpdateLogLevel(level) - } else { - logger.Warn("Invalid CCNEXUS_LOG_LEVEL value %q: %v", levelStr, err) - } - } + if portStr := os.Getenv("CCNEXUS_PORT"); portStr != "" { + if port, err := strconv.Atoi(portStr); err == nil { + cfg.UpdatePort(port) + } else { + logger.Warn("Invalid CCNEXUS_PORT value %q: %v", portStr, err) + } + } + + if addr := os.Getenv("CCNEXUS_LISTEN_ADDR"); addr != "" { + cfg.UpdateListenAddr(addr) + } + + if levelStr := os.Getenv("CCNEXUS_LOG_LEVEL"); levelStr != "" { + if level, err := strconv.Atoi(levelStr); err == nil { + cfg.UpdateLogLevel(level) + } else { + logger.Warn("Invalid CCNEXUS_LOG_LEVEL value %q: %v", levelStr, err) + } + } } func setLogLevels(level int) { - if level < 0 { - return - } - logger.GetLogger().SetMinLevel(logger.LogLevel(level)) - logger.GetLogger().SetConsoleLevel(logger.LogLevel(level)) + if level < 0 { + return + } + logger.GetLogger().SetMinLevel(logger.LogLevel(level)) + logger.GetLogger().SetConsoleLevel(logger.LogLevel(level)) } diff --git a/cmd/server/webui/api/config.go b/cmd/server/webui/api/config.go index d96c83fc..6952bc48 100644 --- a/cmd/server/webui/api/config.go +++ b/cmd/server/webui/api/config.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "net/http" + "strings" "github.com/lich0821/ccNexus/internal/logger" "github.com/lich0821/ccNexus/internal/storage" @@ -23,16 +24,18 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { // getConfig returns the full configuration func (h *Handler) getConfig(w http.ResponseWriter, r *http.Request) { WriteSuccess(w, map[string]interface{}{ - "port": h.config.GetPort(), - "logLevel": h.config.GetLogLevel(), + "port": h.config.GetPort(), + "listenAddr": h.config.GetListenAddr(), + "logLevel": h.config.GetLogLevel(), }) } // updateConfig updates the full configuration func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { var req struct { - Port int `json:"port"` - LogLevel int `json:"logLevel"` + Port int `json:"port"` + ListenAddr string `json:"listenAddr"` + LogLevel int `json:"logLevel"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -45,6 +48,10 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { h.config.UpdatePort(req.Port) } + if strings.TrimSpace(req.ListenAddr) != "" { + h.config.UpdateListenAddr(req.ListenAddr) + } + // Update log level if provided if req.LogLevel >= 0 { h.config.UpdateLogLevel(req.LogLevel) @@ -68,11 +75,13 @@ func (h *Handler) handleConfigPort(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: WriteSuccess(w, map[string]interface{}{ - "port": h.config.GetPort(), + "port": h.config.GetPort(), + "listenAddr": h.config.GetListenAddr(), }) case http.MethodPut: var req struct { - Port int `json:"port"` + Port int `json:"port"` + ListenAddr string `json:"listenAddr"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -85,7 +94,13 @@ func (h *Handler) handleConfigPort(w http.ResponseWriter, r *http.Request) { return } + if strings.TrimSpace(req.ListenAddr) == "" { + WriteError(w, http.StatusBadRequest, "Invalid listen address") + return + } + h.config.UpdatePort(req.Port) + h.config.UpdateListenAddr(req.ListenAddr) // Save to storage adapter := storage.NewConfigStorageAdapter(h.storage) @@ -96,8 +111,9 @@ func (h *Handler) handleConfigPort(w http.ResponseWriter, r *http.Request) { } WriteSuccess(w, map[string]interface{}{ - "port": req.Port, - "message": "Port updated successfully (restart required)", + "port": req.Port, + "listenAddr": req.ListenAddr, + "message": "Port and listen address updated successfully (restart required)", }) default: WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") diff --git a/cmd/server/webui/ui/js/api.js b/cmd/server/webui/ui/js/api.js index 73488763..ed9e3b8d 100644 --- a/cmd/server/webui/ui/js/api.js +++ b/cmd/server/webui/ui/js/api.js @@ -106,8 +106,12 @@ class APIClient { return this.request('GET', '/config/port'); } - async updatePort(port) { - return this.request('PUT', '/config/port', { port }); + async updatePort(port, listenAddr) { + const payload = { port }; + if (listenAddr) { + payload.listenAddr = listenAddr; + } + return this.request('PUT', '/config/port', payload); } async getLogLevel() { diff --git a/internal/config/config.go b/internal/config/config.go index 29c601f1..8e03aa40 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" "sync" ) @@ -73,31 +74,33 @@ type ProxyConfig struct { // Config represents the application configuration type Config struct { - Port int `json:"port"` - Endpoints []Endpoint `json:"endpoints"` - LogLevel int `json:"logLevel"` // 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR - Language string `json:"language"` // UI language: en, zh-CN - Theme string `json:"theme"` // UI theme: light, dark - ThemeAuto bool `json:"themeAuto"` // Auto switch theme based on time - AutoLightTheme string `json:"autoLightTheme,omitempty"` // Theme to use in daytime when auto mode is on - AutoDarkTheme string `json:"autoDarkTheme,omitempty"` // Theme to use in nighttime when auto mode is on - WindowWidth int `json:"windowWidth"` // Window width in pixels - WindowHeight int `json:"windowHeight"` // Window height in pixels + Port int `json:"port"` + ListenAddr string `json:"listenAddr"` + Endpoints []Endpoint `json:"endpoints"` + LogLevel int `json:"logLevel"` // 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR + Language string `json:"language"` // UI language: en, zh-CN + Theme string `json:"theme"` // UI theme: light, dark + ThemeAuto bool `json:"themeAuto"` // Auto switch theme based on time + AutoLightTheme string `json:"autoLightTheme,omitempty"` // Theme to use in daytime when auto mode is on + AutoDarkTheme string `json:"autoDarkTheme,omitempty"` // Theme to use in nighttime when auto mode is on + WindowWidth int `json:"windowWidth"` // Window width in pixels + WindowHeight int `json:"windowHeight"` // Window height in pixels CloseWindowBehavior string `json:"closeWindowBehavior,omitempty"` // "quit", "minimize", "ask" ClaudeNotificationEnabled bool `json:"claudeNotificationEnabled"` // Enable Claude Code task completion notification ClaudeNotificationType string `json:"claudeNotificationType"` // Notification type: toast, dialog, disabled WebDAV *WebDAVConfig `json:"webdav,omitempty"` // WebDAV synchronization config - Backup *BackupConfig `json:"backup,omitempty"` // Backup/sync configuration - Update *UpdateConfig `json:"update,omitempty"` // Update configuration - Terminal *TerminalConfig `json:"terminal,omitempty"` // Terminal launcher config - Proxy *ProxyConfig `json:"proxy,omitempty"` // HTTP proxy config - mu sync.RWMutex + Backup *BackupConfig `json:"backup,omitempty"` // Backup/sync configuration + Update *UpdateConfig `json:"update,omitempty"` // Update configuration + Terminal *TerminalConfig `json:"terminal,omitempty"` // Terminal launcher config + Proxy *ProxyConfig `json:"proxy,omitempty"` // HTTP proxy config + mu sync.RWMutex } // DefaultConfig returns a default configuration func DefaultConfig() *Config { return &Config{ Port: 3000, + ListenAddr: "127.0.0.1", LogLevel: 1, // Default to INFO level Language: "zh-CN", // Default to Chinese WindowWidth: 1024, // Default window width @@ -123,6 +126,10 @@ func (c *Config) Validate() error { c.mu.RLock() defer c.mu.RUnlock() + if strings.TrimSpace(c.ListenAddr) == "" { + return fmt.Errorf("invalid listen address") + } + if c.Port < 1 || c.Port > 65535 { return fmt.Errorf("invalid port: %d", c.Port) } @@ -170,6 +177,16 @@ func (c *Config) GetPort() int { return c.Port } +// GetListenAddr returns the configured listen address (thread-safe) +func (c *Config) GetListenAddr() string { + c.mu.RLock() + defer c.mu.RUnlock() + if strings.TrimSpace(c.ListenAddr) == "" { + return "127.0.0.1" + } + return c.ListenAddr +} + // GetLogLevel returns the configured log level (thread-safe) func (c *Config) GetLogLevel() int { c.mu.RLock() @@ -191,6 +208,17 @@ func (c *Config) UpdatePort(port int) { c.Port = port } +// UpdateListenAddr updates the listen address (thread-safe) +func (c *Config) UpdateListenAddr(addr string) { + c.mu.Lock() + defer c.mu.Unlock() + if strings.TrimSpace(addr) == "" { + c.ListenAddr = "127.0.0.1" + return + } + c.ListenAddr = addr +} + // UpdateLogLevel updates the log level (thread-safe) func (c *Config) UpdateLogLevel(level int) { c.mu.Lock() @@ -458,6 +486,13 @@ func LoadFromStorage(storage StorageAdapter) (*Config, error) { config.Port = 3000 } + if listenAddr, err := storage.GetConfig("listenAddr"); err == nil { + config.ListenAddr = listenAddr + } + if strings.TrimSpace(config.ListenAddr) == "" { + config.ListenAddr = "127.0.0.1" + } + if logLevelStr, err := storage.GetConfig("logLevel"); err == nil && logLevelStr != "" { if logLevel, err := strconv.Atoi(logLevelStr); err == nil { config.LogLevel = logLevel @@ -681,6 +716,7 @@ func (c *Config) SaveToStorage(storage StorageAdapter) error { // Save app config storage.SetConfig("port", strconv.Itoa(c.Port)) + storage.SetConfig("listenAddr", c.ListenAddr) storage.SetConfig("logLevel", strconv.Itoa(c.LogLevel)) storage.SetConfig("language", c.Language) storage.SetConfig("theme", c.Theme) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index c5f17f1c..77c0584c 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "strings" "sync" @@ -33,17 +34,17 @@ type APIResponse struct { // Proxy represents the proxy server type Proxy struct { - config *config.Config - stats *Stats - currentIndex int - mu sync.RWMutex - server *http.Server - activeRequests map[string]bool // tracks active requests by endpoint name - activeRequestsMu sync.RWMutex // protects activeRequests map - endpointCtx map[string]context.Context // context per endpoint for cancellation - endpointCancel map[string]context.CancelFunc // cancel functions per endpoint - ctxMu sync.RWMutex // protects context maps - onEndpointSuccess func(endpointName string) // callback when endpoint request succeeds + config *config.Config + stats *Stats + currentIndex int + mu sync.RWMutex + server *http.Server + activeRequests map[string]bool // tracks active requests by endpoint name + activeRequestsMu sync.RWMutex // protects activeRequests map + endpointCtx map[string]context.Context // context per endpoint for cancellation + endpointCancel map[string]context.CancelFunc // cancel functions per endpoint + ctxMu sync.RWMutex // protects context maps + onEndpointSuccess func(endpointName string) // callback when endpoint request succeeds } // New creates a new Proxy instance @@ -73,6 +74,8 @@ func (p *Proxy) Start() error { // StartWithMux starts the proxy server with an optional custom mux func (p *Proxy) StartWithMux(customMux *http.ServeMux) error { port := p.config.GetPort() + listenAddr := p.config.GetListenAddr() + addr := net.JoinHostPort(listenAddr, fmt.Sprintf("%d", port)) var mux *http.ServeMux if customMux != nil { @@ -88,11 +91,11 @@ func (p *Proxy) StartWithMux(customMux *http.ServeMux) error { mux.HandleFunc("/stats", p.handleStats) p.server = &http.Server{ - Addr: fmt.Sprintf(":%d", port), + Addr: addr, Handler: mux, } - logger.Info("ccNexus starting on port %d", port) + logger.Info("ccNexus starting on %s", addr) logger.Info("Configured %d endpoints", len(p.config.GetEndpoints())) return p.server.ListenAndServe() diff --git a/internal/service/settings.go b/internal/service/settings.go index 8ac9b272..67c3d1d2 100644 --- a/internal/service/settings.go +++ b/internal/service/settings.go @@ -1,313 +1,340 @@ package service import ( - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/lich0821/ccNexus/internal/config" - "github.com/lich0821/ccNexus/internal/logger" - "github.com/lich0821/ccNexus/internal/storage" - "github.com/lich0821/ccNexus/internal/tray" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/lich0821/ccNexus/internal/config" + "github.com/lich0821/ccNexus/internal/logger" + "github.com/lich0821/ccNexus/internal/storage" + "github.com/lich0821/ccNexus/internal/tray" ) // SettingsService handles settings operations type SettingsService struct { - config *config.Config - storage *storage.SQLiteStorage + config *config.Config + storage *storage.SQLiteStorage } // NewSettingsService creates a new SettingsService func NewSettingsService(cfg *config.Config, s *storage.SQLiteStorage) *SettingsService { - return &SettingsService{config: cfg, storage: s} + return &SettingsService{config: cfg, storage: s} } // GetConfig returns the current configuration as JSON func (s *SettingsService) GetConfig() string { - data, _ := json.Marshal(s.config) - return string(data) + data, _ := json.Marshal(s.config) + return string(data) } // UpdateConfig updates the configuration func (s *SettingsService) UpdateConfig(configJSON string, proxy interface{ UpdateConfig(*config.Config) error }) error { - var newConfig config.Config - if err := json.Unmarshal([]byte(configJSON), &newConfig); err != nil { - return fmt.Errorf("invalid config format: %w", err) - } - - if err := newConfig.Validate(); err != nil { - return fmt.Errorf("invalid config: %w", err) - } - - if err := proxy.UpdateConfig(&newConfig); err != nil { - return err - } - - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := newConfig.SaveToStorage(configAdapter); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - } - - *s.config = newConfig - return nil + var newConfig config.Config + if err := json.Unmarshal([]byte(configJSON), &newConfig); err != nil { + return fmt.Errorf("invalid config format: %w", err) + } + + if strings.TrimSpace(newConfig.ListenAddr) == "" { + newConfig.ListenAddr = "127.0.0.1" + } + + if err := newConfig.Validate(); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + if err := proxy.UpdateConfig(&newConfig); err != nil { + return err + } + + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := newConfig.SaveToStorage(configAdapter); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + } + + *s.config = newConfig + return nil } // UpdatePort updates the proxy port func (s *SettingsService) UpdatePort(port int) error { - if port < 1 || port > 65535 { - return fmt.Errorf("invalid port: %d", port) - } + if port < 1 || port > 65535 { + return fmt.Errorf("invalid port: %d", port) + } - s.config.UpdatePort(port) + s.config.UpdatePort(port) - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - } + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + } - return nil + return nil +} + +// UpdateNetwork updates port and listen address together +func (s *SettingsService) UpdateNetwork(port int, listenAddr string) error { + if port < 1 || port > 65535 { + return fmt.Errorf("invalid port: %d", port) + } + + if strings.TrimSpace(listenAddr) == "" { + return fmt.Errorf("invalid listen address") + } + + s.config.UpdatePort(port) + s.config.UpdateListenAddr(listenAddr) + + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + } + + return nil } // GetSystemLanguage detects the system language func (s *SettingsService) GetSystemLanguage() string { - locale := os.Getenv("LANG") - if locale == "" { - locale = os.Getenv("LC_ALL") - } - if locale == "" { - locale = os.Getenv("LANGUAGE") - } - if locale == "" { - return "en" - } - - if strings.Contains(strings.ToLower(locale), "zh") { - return "zh-CN" - } - return "en" + locale := os.Getenv("LANG") + if locale == "" { + locale = os.Getenv("LC_ALL") + } + if locale == "" { + locale = os.Getenv("LANGUAGE") + } + if locale == "" { + return "en" + } + + if strings.Contains(strings.ToLower(locale), "zh") { + return "zh-CN" + } + return "en" } // GetLanguage returns the current language setting func (s *SettingsService) GetLanguage() string { - lang := s.config.GetLanguage() - if lang == "" { - return s.GetSystemLanguage() - } - return lang + lang := s.config.GetLanguage() + if lang == "" { + return s.GetSystemLanguage() + } + return lang } // SetLanguage sets the UI language func (s *SettingsService) SetLanguage(language string) error { - s.config.UpdateLanguage(language) - - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - return fmt.Errorf("failed to save language: %w", err) - } - } - - tray.UpdateLanguage(language) - logger.Info("Language changed to: %s", language) - return nil + s.config.UpdateLanguage(language) + + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + return fmt.Errorf("failed to save language: %w", err) + } + } + + tray.UpdateLanguage(language) + logger.Info("Language changed to: %s", language) + return nil } // GetTheme returns the current theme setting func (s *SettingsService) GetTheme() string { - theme := s.config.GetTheme() - if theme == "" { - return "light" - } - return theme + theme := s.config.GetTheme() + if theme == "" { + return "light" + } + return theme } // SetTheme sets the UI theme func (s *SettingsService) SetTheme(theme string) error { - s.config.UpdateTheme(theme) + s.config.UpdateTheme(theme) - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - return fmt.Errorf("failed to save theme: %w", err) - } - } + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + return fmt.Errorf("failed to save theme: %w", err) + } + } - logger.Info("Theme changed to: %s", theme) - return nil + logger.Info("Theme changed to: %s", theme) + return nil } // GetThemeAuto returns whether auto theme switching is enabled func (s *SettingsService) GetThemeAuto() bool { - return s.config.GetThemeAuto() + return s.config.GetThemeAuto() } // SetThemeAuto enables or disables auto theme switching func (s *SettingsService) SetThemeAuto(auto bool) error { - s.config.UpdateThemeAuto(auto) + s.config.UpdateThemeAuto(auto) - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - return fmt.Errorf("failed to save theme auto setting: %w", err) - } - } + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + return fmt.Errorf("failed to save theme auto setting: %w", err) + } + } - logger.Info("Theme auto mode changed to: %v", auto) - return nil + logger.Info("Theme auto mode changed to: %v", auto) + return nil } // GetAutoLightTheme returns the theme to use in daytime when auto mode is on func (s *SettingsService) GetAutoLightTheme() string { - theme := s.config.GetAutoLightTheme() - if theme == "" { - return "light" - } - return theme + theme := s.config.GetAutoLightTheme() + if theme == "" { + return "light" + } + return theme } // SetAutoLightTheme sets the theme to use in daytime when auto mode is on func (s *SettingsService) SetAutoLightTheme(theme string) error { - s.config.UpdateAutoLightTheme(theme) + s.config.UpdateAutoLightTheme(theme) - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - return fmt.Errorf("failed to save auto light theme: %w", err) - } - } + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + return fmt.Errorf("failed to save auto light theme: %w", err) + } + } - logger.Info("Auto light theme changed to: %s", theme) - return nil + logger.Info("Auto light theme changed to: %s", theme) + return nil } // GetAutoDarkTheme returns the theme to use in nighttime when auto mode is on func (s *SettingsService) GetAutoDarkTheme() string { - theme := s.config.GetAutoDarkTheme() - if theme == "" { - return "dark" - } - return theme + theme := s.config.GetAutoDarkTheme() + if theme == "" { + return "dark" + } + return theme } // SetAutoDarkTheme sets the theme to use in nighttime when auto mode is on func (s *SettingsService) SetAutoDarkTheme(theme string) error { - s.config.UpdateAutoDarkTheme(theme) + s.config.UpdateAutoDarkTheme(theme) - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - return fmt.Errorf("failed to save auto dark theme: %w", err) - } - } + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + return fmt.Errorf("failed to save auto dark theme: %w", err) + } + } - logger.Info("Auto dark theme changed to: %s", theme) - return nil + logger.Info("Auto dark theme changed to: %s", theme) + return nil } // GetLogs returns all log entries func (s *SettingsService) GetLogs() string { - logs := logger.GetLogger().GetLogs() - data, _ := json.Marshal(logs) - return string(data) + logs := logger.GetLogger().GetLogs() + data, _ := json.Marshal(logs) + return string(data) } // GetLogsByLevel returns logs filtered by level func (s *SettingsService) GetLogsByLevel(level int) string { - logs := logger.GetLogger().GetLogsByLevel(logger.LogLevel(level)) - data, _ := json.Marshal(logs) - return string(data) + logs := logger.GetLogger().GetLogsByLevel(logger.LogLevel(level)) + data, _ := json.Marshal(logs) + return string(data) } // ClearLogs clears all log entries func (s *SettingsService) ClearLogs() { - logger.GetLogger().Clear() + logger.GetLogger().Clear() } // SetLogLevel sets the minimum log level to record func (s *SettingsService) SetLogLevel(level int) { - logger.GetLogger().SetMinLevel(logger.LogLevel(level)) - s.config.UpdateLogLevel(level) - - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - logger.Warn("Failed to save log level: %v", err) - } else { - logger.Debug("Log level saved: %d", level) - } - } + logger.GetLogger().SetMinLevel(logger.LogLevel(level)) + s.config.UpdateLogLevel(level) + + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + logger.Warn("Failed to save log level: %v", err) + } else { + logger.Debug("Log level saved: %d", level) + } + } } // GetLogLevel returns the current minimum log level func (s *SettingsService) GetLogLevel() int { - return s.config.GetLogLevel() + return s.config.GetLogLevel() } // SetCloseWindowBehavior sets the user's preference for close window behavior func (s *SettingsService) SetCloseWindowBehavior(behavior string) error { - if behavior != "quit" && behavior != "minimize" && behavior != "ask" { - return fmt.Errorf("invalid behavior: %s (must be 'quit', 'minimize', or 'ask')", behavior) - } - - s.config.UpdateCloseWindowBehavior(behavior) - - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - logger.Warn("Failed to save close window behavior: %v", err) - return err - } - } - - logger.Info("Close window behavior set to: %s", behavior) - return nil + if behavior != "quit" && behavior != "minimize" && behavior != "ask" { + return fmt.Errorf("invalid behavior: %s (must be 'quit', 'minimize', or 'ask')", behavior) + } + + s.config.UpdateCloseWindowBehavior(behavior) + + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + logger.Warn("Failed to save close window behavior: %v", err) + return err + } + } + + logger.Info("Close window behavior set to: %s", behavior) + return nil } // SaveWindowSize saves the window size to config func (s *SettingsService) SaveWindowSize(width, height int) { - if width > 0 && height > 0 { - s.config.UpdateWindowSize(width, height) - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - logger.Warn("Failed to save window size: %v", err) - } else { - logger.Debug("Window size saved: %dx%d", width, height) - } - } - } + if width > 0 && height > 0 { + s.config.UpdateWindowSize(width, height) + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + logger.Warn("Failed to save window size: %v", err) + } else { + logger.Debug("Window size saved: %dx%d", width, height) + } + } + } } // GetProxyURL returns the current proxy URL func (s *SettingsService) GetProxyURL() string { - if proxy := s.config.GetProxy(); proxy != nil { - return proxy.URL - } - return "" + if proxy := s.config.GetProxy(); proxy != nil { + return proxy.URL + } + return "" } // SetProxyURL sets the proxy URL func (s *SettingsService) SetProxyURL(proxyURL string) error { - var proxyCfg *config.ProxyConfig - if proxyURL != "" { - proxyCfg = &config.ProxyConfig{URL: proxyURL} - } - s.config.UpdateProxy(proxyCfg) - - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - return fmt.Errorf("failed to save proxy config: %w", err) - } - } - - logger.Info("Proxy URL changed to: %s", proxyURL) - return nil + var proxyCfg *config.ProxyConfig + if proxyURL != "" { + proxyCfg = &config.ProxyConfig{URL: proxyURL} + } + s.config.UpdateProxy(proxyCfg) + + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + return fmt.Errorf("failed to save proxy config: %w", err) + } + } + + logger.Info("Proxy URL changed to: %s", proxyURL) + return nil } // SettingsData represents the settings data for batch save @@ -324,71 +351,71 @@ type SettingsData struct { // SaveSettings saves all settings in a single operation to avoid database lock issues func (s *SettingsService) SaveSettings(settingsJSON string) error { - var settings SettingsData - if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil { - return fmt.Errorf("invalid settings format: %w", err) - } - - // Validate close window behavior - if settings.CloseWindowBehavior != "" && - settings.CloseWindowBehavior != "quit" && - settings.CloseWindowBehavior != "minimize" && - settings.CloseWindowBehavior != "ask" { - return fmt.Errorf("invalid close window behavior: %s", settings.CloseWindowBehavior) - } - - // Update all settings in memory - if settings.CloseWindowBehavior != "" { - s.config.UpdateCloseWindowBehavior(settings.CloseWindowBehavior) - } - - if settings.Theme != "" { - s.config.UpdateTheme(settings.Theme) - } - - s.config.UpdateThemeAuto(settings.ThemeAuto) - - // Update auto theme settings - if settings.AutoLightTheme != "" { - s.config.UpdateAutoLightTheme(settings.AutoLightTheme) - } - if settings.AutoDarkTheme != "" { - s.config.UpdateAutoDarkTheme(settings.AutoDarkTheme) - } - - // Update proxy config - var proxyCfg *config.ProxyConfig - if settings.ProxyURL != "" { - proxyCfg = &config.ProxyConfig{URL: settings.ProxyURL} - } - s.config.UpdateProxy(proxyCfg) - - // Update Claude notification config - // Validate notification type - if settings.ClaudeNotificationType != "" && - settings.ClaudeNotificationType != "toast" && - settings.ClaudeNotificationType != "dialog" && - settings.ClaudeNotificationType != "disabled" { - return fmt.Errorf("invalid notification type: %s", settings.ClaudeNotificationType) - } - s.config.UpdateClaudeNotification(settings.ClaudeNotificationEnabled, settings.ClaudeNotificationType) - - // Save to storage only once - if s.storage != nil { - configAdapter := storage.NewConfigStorageAdapter(s.storage) - if err := s.config.SaveToStorage(configAdapter); err != nil { - return fmt.Errorf("failed to save settings: %w", err) - } - } - - // Apply Claude notification hook to ~/.claude/settings.json - claudeService := NewClaudeConfigService(s.config) - if err := claudeService.UpdateNotificationHook(); err != nil { - logger.Warn("Failed to update Claude notification hook: %v", err) - // Don't fail the whole save operation, just log the warning - } - - logger.Info("Settings saved: closeWindowBehavior=%s, theme=%s, themeAuto=%v, proxyUrl=%s, claudeNotification=%v", - settings.CloseWindowBehavior, settings.Theme, settings.ThemeAuto, settings.ProxyURL, settings.ClaudeNotificationEnabled) - return nil + var settings SettingsData + if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil { + return fmt.Errorf("invalid settings format: %w", err) + } + + // Validate close window behavior + if settings.CloseWindowBehavior != "" && + settings.CloseWindowBehavior != "quit" && + settings.CloseWindowBehavior != "minimize" && + settings.CloseWindowBehavior != "ask" { + return fmt.Errorf("invalid close window behavior: %s", settings.CloseWindowBehavior) + } + + // Update all settings in memory + if settings.CloseWindowBehavior != "" { + s.config.UpdateCloseWindowBehavior(settings.CloseWindowBehavior) + } + + if settings.Theme != "" { + s.config.UpdateTheme(settings.Theme) + } + + s.config.UpdateThemeAuto(settings.ThemeAuto) + + // Update auto theme settings + if settings.AutoLightTheme != "" { + s.config.UpdateAutoLightTheme(settings.AutoLightTheme) + } + if settings.AutoDarkTheme != "" { + s.config.UpdateAutoDarkTheme(settings.AutoDarkTheme) + } + + // Update proxy config + var proxyCfg *config.ProxyConfig + if settings.ProxyURL != "" { + proxyCfg = &config.ProxyConfig{URL: settings.ProxyURL} + } + s.config.UpdateProxy(proxyCfg) + + // Update Claude notification config + // Validate notification type + if settings.ClaudeNotificationType != "" && + settings.ClaudeNotificationType != "toast" && + settings.ClaudeNotificationType != "dialog" && + settings.ClaudeNotificationType != "disabled" { + return fmt.Errorf("invalid notification type: %s", settings.ClaudeNotificationType) + } + s.config.UpdateClaudeNotification(settings.ClaudeNotificationEnabled, settings.ClaudeNotificationType) + + // Save to storage only once + if s.storage != nil { + configAdapter := storage.NewConfigStorageAdapter(s.storage) + if err := s.config.SaveToStorage(configAdapter); err != nil { + return fmt.Errorf("failed to save settings: %w", err) + } + } + + // Apply Claude notification hook to ~/.claude/settings.json + claudeService := NewClaudeConfigService(s.config) + if err := claudeService.UpdateNotificationHook(); err != nil { + logger.Warn("Failed to update Claude notification hook: %v", err) + // Don't fail the whole save operation, just log the warning + } + + logger.Info("Settings saved: closeWindowBehavior=%s, theme=%s, themeAuto=%v, proxyUrl=%s, claudeNotification=%v", + settings.CloseWindowBehavior, settings.Theme, settings.ThemeAuto, settings.ProxyURL, settings.ClaudeNotificationEnabled) + return nil }