refactor(config): move webui save to usecase + port/gateway; add shared domain validation
- commit
- 7831cd55e962954bb3bea2da8b069f375afb7f2a
- author
- brice · · 2025-12-20 18:47 UTC
- parent
- c7dc5f2
- signature
- unsigned
10 files · +413 -6
A internal/application/port/webui_config.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // WebUIConfig represents the subset of configuration editable in dumb://config. | |
| 6 | + | type WebUIConfig struct { | |
| 7 | + | Appearance WebUIAppearanceConfig `json:"appearance"` | |
| 8 | + | DefaultUIScale float64 `json:"default_ui_scale"` | |
| 9 | + | DefaultSearchEngine string `json:"default_search_engine"` | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | type WebUIAppearanceConfig struct { | |
| 13 | + | SansFont string `json:"sans_font"` | |
| 14 | + | SerifFont string `json:"serif_font"` | |
| 15 | + | MonospaceFont string `json:"monospace_font"` | |
| 16 | + | DefaultFontSize int `json:"default_font_size"` | |
| 17 | + | ColorScheme string `json:"color_scheme"` | |
| 18 | + | LightPalette ColorPalette `json:"light_palette"` | |
| 19 | + | DarkPalette ColorPalette `json:"dark_palette"` | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type ColorPalette struct { | |
| 23 | + | Background string `json:"background"` | |
| 24 | + | Surface string `json:"surface"` | |
| 25 | + | SurfaceVariant string `json:"surface_variant"` | |
| 26 | + | Text string `json:"text"` | |
| 27 | + | Muted string `json:"muted"` | |
| 28 | + | Accent string `json:"accent"` | |
| 29 | + | Border string `json:"border"` | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // WebUIConfigSaver persists WebUI configuration changes. | |
| 33 | + | type WebUIConfigSaver interface { | |
| 34 | + | SaveWebUIConfig(ctx context.Context, cfg WebUIConfig) error | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // WebUIConfig represents the subset of configuration editable in dumb://config. | |||
| 6 | + | type WebUIConfig struct { | |||
| 7 | + | Appearance WebUIAppearanceConfig `json:"appearance"` | |||
| 8 | + | DefaultUIScale float64 `json:"default_ui_scale"` | |||
| 9 | + | DefaultSearchEngine string `json:"default_search_engine"` | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | type WebUIAppearanceConfig struct { | |||
| 13 | + | SansFont string `json:"sans_font"` | |||
| 14 | + | SerifFont string `json:"serif_font"` | |||
| 15 | + | MonospaceFont string `json:"monospace_font"` | |||
| 16 | + | DefaultFontSize int `json:"default_font_size"` | |||
| 17 | + | ColorScheme string `json:"color_scheme"` | |||
| 18 | + | LightPalette ColorPalette `json:"light_palette"` | |||
| 19 | + | DarkPalette ColorPalette `json:"dark_palette"` | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type ColorPalette struct { | |||
| 23 | + | Background string `json:"background"` | |||
| 24 | + | Surface string `json:"surface"` | |||
| 25 | + | SurfaceVariant string `json:"surface_variant"` | |||
| 26 | + | Text string `json:"text"` | |||
| 27 | + | Muted string `json:"muted"` | |||
| 28 | + | Accent string `json:"accent"` | |||
| 29 | + | Border string `json:"border"` | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // WebUIConfigSaver persists WebUI configuration changes. | |||
| 33 | + | type WebUIConfigSaver interface { | |||
| 34 | + | SaveWebUIConfig(ctx context.Context, cfg WebUIConfig) error | |||
| 35 | + | } | |||
A internal/application/usecase/save_webui_config.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/validation" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type SaveWebUIConfigUseCase struct { | |
| 13 | + | saver port.WebUIConfigSaver | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseCase { | |
| 17 | + | return &SaveWebUIConfigUseCase{saver: saver} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUIConfig) error { | |
| 21 | + | if uc == nil || uc.saver == nil { | |
| 22 | + | return fmt.Errorf("config saver is nil") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | normalized := normalizeWebUIConfig(cfg) | |
| 26 | + | if err := validateWebUIConfig(normalized); err != nil { | |
| 27 | + | return err | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | return uc.saver.SaveWebUIConfig(ctx, normalized) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig { | |
| 34 | + | cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont) | |
| 35 | + | cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont) | |
| 36 | + | cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont) | |
| 37 | + | cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme) | |
| 38 | + | cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine) | |
| 39 | + | return cfg | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func validateWebUIConfig(cfg port.WebUIConfig) error { | |
| 43 | + | var errs []string | |
| 44 | + | ||
| 45 | + | if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 { | |
| 46 | + | errs = append(errs, "appearance.default_font_size must be between 1 and 72") | |
| 47 | + | } | |
| 48 | + | if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 { | |
| 49 | + | errs = append(errs, "default_ui_scale must be between 0.5 and 3.0") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...) | |
| 53 | + | errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...) | |
| 54 | + | errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...) | |
| 55 | + | ||
| 56 | + | if cfg.DefaultSearchEngine == "" { | |
| 57 | + | errs = append(errs, "default_search_engine cannot be empty") | |
| 58 | + | } else if !strings.Contains(cfg.DefaultSearchEngine, "%s") { | |
| 59 | + | errs = append(errs, "default_search_engine must contain %s placeholder for the search query") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | switch cfg.Appearance.ColorScheme { | |
| 63 | + | case "default", "prefer-dark", "prefer-light": | |
| 64 | + | // ok | |
| 65 | + | default: | |
| 66 | + | errs = append(errs, fmt.Sprintf("appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", cfg.Appearance.ColorScheme)) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | errs = append(errs, validation.ValidatePaletteHex( | |
| 70 | + | "appearance.light_palette", | |
| 71 | + | cfg.Appearance.LightPalette.Background, | |
| 72 | + | cfg.Appearance.LightPalette.Surface, | |
| 73 | + | cfg.Appearance.LightPalette.SurfaceVariant, | |
| 74 | + | cfg.Appearance.LightPalette.Text, | |
| 75 | + | cfg.Appearance.LightPalette.Muted, | |
| 76 | + | cfg.Appearance.LightPalette.Accent, | |
| 77 | + | cfg.Appearance.LightPalette.Border, | |
| 78 | + | )...) | |
| 79 | + | errs = append(errs, validation.ValidatePaletteHex( | |
| 80 | + | "appearance.dark_palette", | |
| 81 | + | cfg.Appearance.DarkPalette.Background, | |
| 82 | + | cfg.Appearance.DarkPalette.Surface, | |
| 83 | + | cfg.Appearance.DarkPalette.SurfaceVariant, | |
| 84 | + | cfg.Appearance.DarkPalette.Text, | |
| 85 | + | cfg.Appearance.DarkPalette.Muted, | |
| 86 | + | cfg.Appearance.DarkPalette.Accent, | |
| 87 | + | cfg.Appearance.DarkPalette.Border, | |
| 88 | + | )...) | |
| 89 | + | ||
| 90 | + | if len(errs) > 0 { | |
| 91 | + | return fmt.Errorf("config validation failed:\n - %s", strings.Join(errs, "\n - ")) | |
| 92 | + | } | |
| 93 | + | return nil | |
| 94 | + | } | |
| @@ -0,0 +1,94 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/validation" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type SaveWebUIConfigUseCase struct { | |||
| 13 | + | saver port.WebUIConfigSaver | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseCase { | |||
| 17 | + | return &SaveWebUIConfigUseCase{saver: saver} | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUIConfig) error { | |||
| 21 | + | if uc == nil || uc.saver == nil { | |||
| 22 | + | return fmt.Errorf("config saver is nil") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | normalized := normalizeWebUIConfig(cfg) | |||
| 26 | + | if err := validateWebUIConfig(normalized); err != nil { | |||
| 27 | + | return err | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | return uc.saver.SaveWebUIConfig(ctx, normalized) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig { | |||
| 34 | + | cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont) | |||
| 35 | + | cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont) | |||
| 36 | + | cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont) | |||
| 37 | + | cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme) | |||
| 38 | + | cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine) | |||
| 39 | + | return cfg | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func validateWebUIConfig(cfg port.WebUIConfig) error { | |||
| 43 | + | var errs []string | |||
| 44 | + | ||||
| 45 | + | if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 { | |||
| 46 | + | errs = append(errs, "appearance.default_font_size must be between 1 and 72") | |||
| 47 | + | } | |||
| 48 | + | if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 { | |||
| 49 | + | errs = append(errs, "default_ui_scale must be between 0.5 and 3.0") | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...) | |||
| 53 | + | errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...) | |||
| 54 | + | errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...) | |||
| 55 | + | ||||
| 56 | + | if cfg.DefaultSearchEngine == "" { | |||
| 57 | + | errs = append(errs, "default_search_engine cannot be empty") | |||
| 58 | + | } else if !strings.Contains(cfg.DefaultSearchEngine, "%s") { | |||
| 59 | + | errs = append(errs, "default_search_engine must contain %s placeholder for the search query") | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | switch cfg.Appearance.ColorScheme { | |||
| 63 | + | case "default", "prefer-dark", "prefer-light": | |||
| 64 | + | // ok | |||
| 65 | + | default: | |||
| 66 | + | errs = append(errs, fmt.Sprintf("appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", cfg.Appearance.ColorScheme)) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | errs = append(errs, validation.ValidatePaletteHex( | |||
| 70 | + | "appearance.light_palette", | |||
| 71 | + | cfg.Appearance.LightPalette.Background, | |||
| 72 | + | cfg.Appearance.LightPalette.Surface, | |||
| 73 | + | cfg.Appearance.LightPalette.SurfaceVariant, | |||
| 74 | + | cfg.Appearance.LightPalette.Text, | |||
| 75 | + | cfg.Appearance.LightPalette.Muted, | |||
| 76 | + | cfg.Appearance.LightPalette.Accent, | |||
| 77 | + | cfg.Appearance.LightPalette.Border, | |||
| 78 | + | )...) | |||
| 79 | + | errs = append(errs, validation.ValidatePaletteHex( | |||
| 80 | + | "appearance.dark_palette", | |||
| 81 | + | cfg.Appearance.DarkPalette.Background, | |||
| 82 | + | cfg.Appearance.DarkPalette.Surface, | |||
| 83 | + | cfg.Appearance.DarkPalette.SurfaceVariant, | |||
| 84 | + | cfg.Appearance.DarkPalette.Text, | |||
| 85 | + | cfg.Appearance.DarkPalette.Muted, | |||
| 86 | + | cfg.Appearance.DarkPalette.Accent, | |||
| 87 | + | cfg.Appearance.DarkPalette.Border, | |||
| 88 | + | )...) | |||
| 89 | + | ||||
| 90 | + | if len(errs) > 0 { | |||
| 91 | + | return fmt.Errorf("config validation failed:\n - %s", strings.Join(errs, "\n - ")) | |||
| 92 | + | } | |||
| 93 | + | return nil | |||
| 94 | + | } | |||
A internal/domain/validation/colors.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package validation | |
| 2 | + | ||
| 3 | + | import "regexp" | |
| 4 | + | ||
| 5 | + | var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 6 | + | ||
| 7 | + | func IsHexColor(value string) bool { | |
| 8 | + | return hexColorRE.MatchString(value) | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | func ValidatePaletteHex( | |
| 12 | + | prefix string, | |
| 13 | + | background string, | |
| 14 | + | surface string, | |
| 15 | + | surfaceVariant string, | |
| 16 | + | text string, | |
| 17 | + | muted string, | |
| 18 | + | accent string, | |
| 19 | + | border string, | |
| 20 | + | ) []string { | |
| 21 | + | var errs []string | |
| 22 | + | ||
| 23 | + | if !IsHexColor(background) { | |
| 24 | + | errs = append(errs, prefix+".background must be a hex color like #RRGGBB") | |
| 25 | + | } | |
| 26 | + | if !IsHexColor(surface) { | |
| 27 | + | errs = append(errs, prefix+".surface must be a hex color like #RRGGBB") | |
| 28 | + | } | |
| 29 | + | if !IsHexColor(surfaceVariant) { | |
| 30 | + | errs = append(errs, prefix+".surface_variant must be a hex color like #RRGGBB") | |
| 31 | + | } | |
| 32 | + | if !IsHexColor(text) { | |
| 33 | + | errs = append(errs, prefix+".text must be a hex color like #RRGGBB") | |
| 34 | + | } | |
| 35 | + | if !IsHexColor(muted) { | |
| 36 | + | errs = append(errs, prefix+".muted must be a hex color like #RRGGBB") | |
| 37 | + | } | |
| 38 | + | if !IsHexColor(accent) { | |
| 39 | + | errs = append(errs, prefix+".accent must be a hex color like #RRGGBB") | |
| 40 | + | } | |
| 41 | + | if !IsHexColor(border) { | |
| 42 | + | errs = append(errs, prefix+".border must be a hex color like #RRGGBB") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | return errs | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package validation | |||
| 2 | + | ||||
| 3 | + | import "regexp" | |||
| 4 | + | ||||
| 5 | + | var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |||
| 6 | + | ||||
| 7 | + | func IsHexColor(value string) bool { | |||
| 8 | + | return hexColorRE.MatchString(value) | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | func ValidatePaletteHex( | |||
| 12 | + | prefix string, | |||
| 13 | + | background string, | |||
| 14 | + | surface string, | |||
| 15 | + | surfaceVariant string, | |||
| 16 | + | text string, | |||
| 17 | + | muted string, | |||
| 18 | + | accent string, | |||
| 19 | + | border string, | |||
| 20 | + | ) []string { | |||
| 21 | + | var errs []string | |||
| 22 | + | ||||
| 23 | + | if !IsHexColor(background) { | |||
| 24 | + | errs = append(errs, prefix+".background must be a hex color like #RRGGBB") | |||
| 25 | + | } | |||
| 26 | + | if !IsHexColor(surface) { | |||
| 27 | + | errs = append(errs, prefix+".surface must be a hex color like #RRGGBB") | |||
| 28 | + | } | |||
| 29 | + | if !IsHexColor(surfaceVariant) { | |||
| 30 | + | errs = append(errs, prefix+".surface_variant must be a hex color like #RRGGBB") | |||
| 31 | + | } | |||
| 32 | + | if !IsHexColor(text) { | |||
| 33 | + | errs = append(errs, prefix+".text must be a hex color like #RRGGBB") | |||
| 34 | + | } | |||
| 35 | + | if !IsHexColor(muted) { | |||
| 36 | + | errs = append(errs, prefix+".muted must be a hex color like #RRGGBB") | |||
| 37 | + | } | |||
| 38 | + | if !IsHexColor(accent) { | |||
| 39 | + | errs = append(errs, prefix+".accent must be a hex color like #RRGGBB") | |||
| 40 | + | } | |||
| 41 | + | if !IsHexColor(border) { | |||
| 42 | + | errs = append(errs, prefix+".border must be a hex color like #RRGGBB") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | return errs | |||
| 46 | + | } | |||
A internal/domain/validation/fonts.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package validation | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | func ValidateFontFamily(field string, value string) []string { | |
| 6 | + | value = strings.TrimSpace(value) | |
| 7 | + | var errs []string | |
| 8 | + | ||
| 9 | + | if value == "" { | |
| 10 | + | errs = append(errs, field+" cannot be empty") | |
| 11 | + | return errs | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | if strings.ContainsAny(value, "\r\n") { | |
| 15 | + | errs = append(errs, field+" must not contain newlines") | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | if len(value) > 200 { | |
| 19 | + | errs = append(errs, field+" is too long") | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | return errs | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package validation | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | func ValidateFontFamily(field string, value string) []string { | |||
| 6 | + | value = strings.TrimSpace(value) | |||
| 7 | + | var errs []string | |||
| 8 | + | ||||
| 9 | + | if value == "" { | |||
| 10 | + | errs = append(errs, field+" cannot be empty") | |||
| 11 | + | return errs | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | if strings.ContainsAny(value, "\r\n") { | |||
| 15 | + | errs = append(errs, field+" must not contain newlines") | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | if len(value) > 200 { | |||
| 19 | + | errs = append(errs, field+" is too long") | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | return errs | |||
| 23 | + | } | |||
M internal/infrastructure/config/loader.go +44 -0
| @@ -180,6 +180,50 @@ func (m *Manager) Get() *Config { | |||
| 180 | 180 | return &configCopy | |
| 181 | 181 | } | |
| 182 | 182 | ||
| 183 | + | // Save saves the provided configuration to disk and updates Viper. | |
| 184 | + | func (m *Manager) Save(cfg *Config) error { | |
| 185 | + | m.mu.Lock() | |
| 186 | + | defer m.mu.Unlock() | |
| 187 | + | ||
| 188 | + | if cfg == nil { | |
| 189 | + | return fmt.Errorf("config is nil") | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // Validate before writing so UI gets immediate errors. | |
| 193 | + | if err := validateConfig(cfg); err != nil { | |
| 194 | + | return fmt.Errorf("configuration validation failed: %w", err) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // Update Viper with the new values. | |
| 198 | + | // Since we can't easily update Viper from a struct automatically while preserving | |
| 199 | + | // all settings, we update the main keys we care about. | |
| 200 | + | m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont) | |
| 201 | + | m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont) | |
| 202 | + | m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont) | |
| 203 | + | m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize) | |
| 204 | + | m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme) | |
| 205 | + | m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette) | |
| 206 | + | m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette) | |
| 207 | + | m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom) | |
| 208 | + | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | |
| 209 | + | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | |
| 210 | + | ||
| 211 | + | if err := m.viper.WriteConfig(); err != nil { | |
| 212 | + | return fmt.Errorf("failed to write config: %w", err) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // We don't update m.config here manually because viper.OnConfigChange | |
| 216 | + | // will trigger reload() if Watch() is active. | |
| 217 | + | // If Watch() is not active, we should call reload() manually. | |
| 218 | + | if !m.watching { | |
| 219 | + | if err := m.reload(); err != nil { | |
| 220 | + | return err | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | return nil | |
| 225 | + | } | |
| 226 | + | ||
| 183 | 227 | // GetConfigFile returns the path to the configuration file being used. | |
| 184 | 228 | func (m *Manager) GetConfigFile() string { | |
| 185 | 229 | return m.viper.ConfigFileUsed() | |
| @@ -180,6 +180,50 @@ func (m *Manager) Get() *Config { | |||||
| 180 | return &configCopy | 180 | return &configCopy | ||
| 181 | } | 181 | } | ||
| 182 | 182 | ||||
| 183 | + | // Save saves the provided configuration to disk and updates Viper. | |||
| 184 | + | func (m *Manager) Save(cfg *Config) error { | |||
| 185 | + | m.mu.Lock() | |||
| 186 | + | defer m.mu.Unlock() | |||
| 187 | + | ||||
| 188 | + | if cfg == nil { | |||
| 189 | + | return fmt.Errorf("config is nil") | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // Validate before writing so UI gets immediate errors. | |||
| 193 | + | if err := validateConfig(cfg); err != nil { | |||
| 194 | + | return fmt.Errorf("configuration validation failed: %w", err) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // Update Viper with the new values. | |||
| 198 | + | // Since we can't easily update Viper from a struct automatically while preserving | |||
| 199 | + | // all settings, we update the main keys we care about. | |||
| 200 | + | m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont) | |||
| 201 | + | m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont) | |||
| 202 | + | m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont) | |||
| 203 | + | m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize) | |||
| 204 | + | m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme) | |||
| 205 | + | m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette) | |||
| 206 | + | m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette) | |||
| 207 | + | m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom) | |||
| 208 | + | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | |||
| 209 | + | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | |||
| 210 | + | ||||
| 211 | + | if err := m.viper.WriteConfig(); err != nil { | |||
| 212 | + | return fmt.Errorf("failed to write config: %w", err) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // We don't update m.config here manually because viper.OnConfigChange | |||
| 216 | + | // will trigger reload() if Watch() is active. | |||
| 217 | + | // If Watch() is not active, we should call reload() manually. | |||
| 218 | + | if !m.watching { | |||
| 219 | + | if err := m.reload(); err != nil { | |||
| 220 | + | return err | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | return nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 183 | // GetConfigFile returns the path to the configuration file being used. | 227 | // GetConfigFile returns the path to the configuration file being used. | ||
| 184 | func (m *Manager) GetConfigFile() string { | 228 | func (m *Manager) GetConfigFile() string { | ||
| 185 | return m.viper.ConfigFileUsed() | 229 | return m.viper.ConfigFileUsed() | ||
M internal/infrastructure/config/validation.go +27 -0
| @@ -3,6 +3,8 @@ package config | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "strings" | |
| 6 | + | ||
| 7 | + | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | // validateConfig performs comprehensive validation of configuration values | |
| @@ -28,6 +30,31 @@ func validateConfig(config *Config) error { | |||
| 28 | 30 | validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72") | |
| 29 | 31 | } | |
| 30 | 32 | ||
| 33 | + | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...) | |
| 34 | + | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...) | |
| 35 | + | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...) | |
| 36 | + | ||
| 37 | + | validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex( | |
| 38 | + | "appearance.light_palette", | |
| 39 | + | config.Appearance.LightPalette.Background, | |
| 40 | + | config.Appearance.LightPalette.Surface, | |
| 41 | + | config.Appearance.LightPalette.SurfaceVariant, | |
| 42 | + | config.Appearance.LightPalette.Text, | |
| 43 | + | config.Appearance.LightPalette.Muted, | |
| 44 | + | config.Appearance.LightPalette.Accent, | |
| 45 | + | config.Appearance.LightPalette.Border, | |
| 46 | + | )...) | |
| 47 | + | validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex( | |
| 48 | + | "appearance.dark_palette", | |
| 49 | + | config.Appearance.DarkPalette.Background, | |
| 50 | + | config.Appearance.DarkPalette.Surface, | |
| 51 | + | config.Appearance.DarkPalette.SurfaceVariant, | |
| 52 | + | config.Appearance.DarkPalette.Text, | |
| 53 | + | config.Appearance.DarkPalette.Muted, | |
| 54 | + | config.Appearance.DarkPalette.Accent, | |
| 55 | + | config.Appearance.DarkPalette.Border, | |
| 56 | + | )...) | |
| 57 | + | ||
| 31 | 58 | if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 { | |
| 32 | 59 | validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0") | |
| 33 | 60 | } | |
| @@ -3,6 +3,8 @@ package config | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | + | ||||
| 7 | + | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | |||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | // validateConfig performs comprehensive validation of configuration values | 10 | // validateConfig performs comprehensive validation of configuration values | ||
| @@ -28,6 +30,31 @@ func validateConfig(config *Config) error { | |||||
| 28 | validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72") | 30 | validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72") | ||
| 29 | } | 31 | } | ||
| 30 | 32 | ||||
| 33 | + | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...) | |||
| 34 | + | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...) | |||
| 35 | + | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...) | |||
| 36 | + | ||||
| 37 | + | validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex( | |||
| 38 | + | "appearance.light_palette", | |||
| 39 | + | config.Appearance.LightPalette.Background, | |||
| 40 | + | config.Appearance.LightPalette.Surface, | |||
| 41 | + | config.Appearance.LightPalette.SurfaceVariant, | |||
| 42 | + | config.Appearance.LightPalette.Text, | |||
| 43 | + | config.Appearance.LightPalette.Muted, | |||
| 44 | + | config.Appearance.LightPalette.Accent, | |||
| 45 | + | config.Appearance.LightPalette.Border, | |||
| 46 | + | )...) | |||
| 47 | + | validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex( | |||
| 48 | + | "appearance.dark_palette", | |||
| 49 | + | config.Appearance.DarkPalette.Background, | |||
| 50 | + | config.Appearance.DarkPalette.Surface, | |||
| 51 | + | config.Appearance.DarkPalette.SurfaceVariant, | |||
| 52 | + | config.Appearance.DarkPalette.Text, | |||
| 53 | + | config.Appearance.DarkPalette.Muted, | |||
| 54 | + | config.Appearance.DarkPalette.Accent, | |||
| 55 | + | config.Appearance.DarkPalette.Border, | |||
| 56 | + | )...) | |||
| 57 | + | ||||
| 31 | if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 { | 58 | if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 { | ||
| 32 | validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0") | 59 | validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0") | ||
| 33 | } | 60 | } | ||
A internal/infrastructure/config/webui_gateway.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type WebUIConfigGateway struct { | |
| 11 | + | mgr *Manager | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func NewWebUIConfigGateway(mgr *Manager) *WebUIConfigGateway { | |
| 15 | + | return &WebUIConfigGateway{mgr: mgr} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error { | |
| 19 | + | _ = ctx | |
| 20 | + | if g == nil || g.mgr == nil { | |
| 21 | + | return fmt.Errorf("config manager not initialized") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | current := Get() | |
| 25 | + | current.Appearance.SansFont = cfg.Appearance.SansFont | |
| 26 | + | current.Appearance.SerifFont = cfg.Appearance.SerifFont | |
| 27 | + | current.Appearance.MonospaceFont = cfg.Appearance.MonospaceFont | |
| 28 | + | current.Appearance.DefaultFontSize = cfg.Appearance.DefaultFontSize | |
| 29 | + | current.Appearance.ColorScheme = cfg.Appearance.ColorScheme | |
| 30 | + | current.Appearance.LightPalette = ColorPalette{ | |
| 31 | + | Background: cfg.Appearance.LightPalette.Background, | |
| 32 | + | Surface: cfg.Appearance.LightPalette.Surface, | |
| 33 | + | SurfaceVariant: cfg.Appearance.LightPalette.SurfaceVariant, | |
| 34 | + | Text: cfg.Appearance.LightPalette.Text, | |
| 35 | + | Muted: cfg.Appearance.LightPalette.Muted, | |
| 36 | + | Accent: cfg.Appearance.LightPalette.Accent, | |
| 37 | + | Border: cfg.Appearance.LightPalette.Border, | |
| 38 | + | } | |
| 39 | + | current.Appearance.DarkPalette = ColorPalette{ | |
| 40 | + | Background: cfg.Appearance.DarkPalette.Background, | |
| 41 | + | Surface: cfg.Appearance.DarkPalette.Surface, | |
| 42 | + | SurfaceVariant: cfg.Appearance.DarkPalette.SurfaceVariant, | |
| 43 | + | Text: cfg.Appearance.DarkPalette.Text, | |
| 44 | + | Muted: cfg.Appearance.DarkPalette.Muted, | |
| 45 | + | Accent: cfg.Appearance.DarkPalette.Accent, | |
| 46 | + | Border: cfg.Appearance.DarkPalette.Border, | |
| 47 | + | } | |
| 48 | + | current.DefaultUIScale = cfg.DefaultUIScale | |
| 49 | + | current.DefaultSearchEngine = cfg.DefaultSearchEngine | |
| 50 | + | ||
| 51 | + | return g.mgr.Save(current) | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type WebUIConfigGateway struct { | |||
| 11 | + | mgr *Manager | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func NewWebUIConfigGateway(mgr *Manager) *WebUIConfigGateway { | |||
| 15 | + | return &WebUIConfigGateway{mgr: mgr} | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error { | |||
| 19 | + | _ = ctx | |||
| 20 | + | if g == nil || g.mgr == nil { | |||
| 21 | + | return fmt.Errorf("config manager not initialized") | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | current := Get() | |||
| 25 | + | current.Appearance.SansFont = cfg.Appearance.SansFont | |||
| 26 | + | current.Appearance.SerifFont = cfg.Appearance.SerifFont | |||
| 27 | + | current.Appearance.MonospaceFont = cfg.Appearance.MonospaceFont | |||
| 28 | + | current.Appearance.DefaultFontSize = cfg.Appearance.DefaultFontSize | |||
| 29 | + | current.Appearance.ColorScheme = cfg.Appearance.ColorScheme | |||
| 30 | + | current.Appearance.LightPalette = ColorPalette{ | |||
| 31 | + | Background: cfg.Appearance.LightPalette.Background, | |||
| 32 | + | Surface: cfg.Appearance.LightPalette.Surface, | |||
| 33 | + | SurfaceVariant: cfg.Appearance.LightPalette.SurfaceVariant, | |||
| 34 | + | Text: cfg.Appearance.LightPalette.Text, | |||
| 35 | + | Muted: cfg.Appearance.LightPalette.Muted, | |||
| 36 | + | Accent: cfg.Appearance.LightPalette.Accent, | |||
| 37 | + | Border: cfg.Appearance.LightPalette.Border, | |||
| 38 | + | } | |||
| 39 | + | current.Appearance.DarkPalette = ColorPalette{ | |||
| 40 | + | Background: cfg.Appearance.DarkPalette.Background, | |||
| 41 | + | Surface: cfg.Appearance.DarkPalette.Surface, | |||
| 42 | + | SurfaceVariant: cfg.Appearance.DarkPalette.SurfaceVariant, | |||
| 43 | + | Text: cfg.Appearance.DarkPalette.Text, | |||
| 44 | + | Muted: cfg.Appearance.DarkPalette.Muted, | |||
| 45 | + | Accent: cfg.Appearance.DarkPalette.Accent, | |||
| 46 | + | Border: cfg.Appearance.DarkPalette.Border, | |||
| 47 | + | } | |||
| 48 | + | current.DefaultUIScale = cfg.DefaultUIScale | |||
| 49 | + | current.DefaultSearchEngine = cfg.DefaultSearchEngine | |||
| 50 | + | ||||
| 51 | + | return g.mgr.Save(current) | |||
| 52 | + | } | |||
A internal/infrastructure/webkit/handlers/config.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package handlers | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // ConfigHandler handles configuration-related messages. | |
| 16 | + | type ConfigHandler struct{} | |
| 17 | + | ||
| 18 | + | // NewConfigHandler creates a new ConfigHandler. | |
| 19 | + | func NewConfigHandler() *ConfigHandler { | |
| 20 | + | return &ConfigHandler{} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Handle processes the save_config message. | |
| 24 | + | func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 25 | + | log := logging.FromContext(ctx).With().Str("handler", "config").Logger() | |
| 26 | + | ||
| 27 | + | var payloadCfg port.WebUIConfig | |
| 28 | + | if err := json.Unmarshal(payload, &payloadCfg); err != nil { | |
| 29 | + | log.Error().Err(err).Msg("failed to unmarshal config payload") | |
| 30 | + | return nil, fmt.Errorf("invalid config format: %w", err) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | log.Info().Msg("saving appearance configuration from webui") | |
| 34 | + | mgr := config.GetManager() | |
| 35 | + | if mgr == nil { | |
| 36 | + | return nil, fmt.Errorf("config manager not initialized") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | uc := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr)) | |
| 40 | + | if err := uc.Execute(ctx, payloadCfg); err != nil { | |
| 41 | + | log.Error().Err(err).Msg("failed to save config") | |
| 42 | + | return nil, fmt.Errorf("failed to save config: %w", err) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | return map[string]any{"status": "success"}, nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // RegisterConfigHandlers registers configuration handlers with the router. | |
| 49 | + | func RegisterConfigHandlers(ctx context.Context, router *webkit.MessageRouter) error { | |
| 50 | + | handler := NewConfigHandler() | |
| 51 | + | ||
| 52 | + | // worldName empty means main world (since dumb:// pages run in main world) | |
| 53 | + | // callback/errorCallback can be used if we want to notify JS of success/fail | |
| 54 | + | // In Svelte we use fetch() for GET, but for POST we use message bridge | |
| 55 | + | // Actually, if we use message bridge, we might want response callbacks. | |
| 56 | + | ||
| 57 | + | log := logging.FromContext(ctx).With().Str("component", "handlers").Logger() | |
| 58 | + | ||
| 59 | + | // Message type: save_config | |
| 60 | + | if err := router.RegisterHandlerWithCallbacks( | |
| 61 | + | "save_config", | |
| 62 | + | "__dumber_config_saved", | |
| 63 | + | "__dumber_config_error", | |
| 64 | + | "", | |
| 65 | + | handler, | |
| 66 | + | ); err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | log.Info().Msg("registered save_config handler") | |
| 71 | + | ||
| 72 | + | return nil | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // ConfigHandler handles configuration-related messages. | |||
| 16 | + | type ConfigHandler struct{} | |||
| 17 | + | ||||
| 18 | + | // NewConfigHandler creates a new ConfigHandler. | |||
| 19 | + | func NewConfigHandler() *ConfigHandler { | |||
| 20 | + | return &ConfigHandler{} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Handle processes the save_config message. | |||
| 24 | + | func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |||
| 25 | + | log := logging.FromContext(ctx).With().Str("handler", "config").Logger() | |||
| 26 | + | ||||
| 27 | + | var payloadCfg port.WebUIConfig | |||
| 28 | + | if err := json.Unmarshal(payload, &payloadCfg); err != nil { | |||
| 29 | + | log.Error().Err(err).Msg("failed to unmarshal config payload") | |||
| 30 | + | return nil, fmt.Errorf("invalid config format: %w", err) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | log.Info().Msg("saving appearance configuration from webui") | |||
| 34 | + | mgr := config.GetManager() | |||
| 35 | + | if mgr == nil { | |||
| 36 | + | return nil, fmt.Errorf("config manager not initialized") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | uc := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr)) | |||
| 40 | + | if err := uc.Execute(ctx, payloadCfg); err != nil { | |||
| 41 | + | log.Error().Err(err).Msg("failed to save config") | |||
| 42 | + | return nil, fmt.Errorf("failed to save config: %w", err) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | return map[string]any{"status": "success"}, nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // RegisterConfigHandlers registers configuration handlers with the router. | |||
| 49 | + | func RegisterConfigHandlers(ctx context.Context, router *webkit.MessageRouter) error { | |||
| 50 | + | handler := NewConfigHandler() | |||
| 51 | + | ||||
| 52 | + | // worldName empty means main world (since dumb:// pages run in main world) | |||
| 53 | + | // callback/errorCallback can be used if we want to notify JS of success/fail | |||
| 54 | + | // In Svelte we use fetch() for GET, but for POST we use message bridge | |||
| 55 | + | // Actually, if we use message bridge, we might want response callbacks. | |||
| 56 | + | ||||
| 57 | + | log := logging.FromContext(ctx).With().Str("component", "handlers").Logger() | |||
| 58 | + | ||||
| 59 | + | // Message type: save_config | |||
| 60 | + | if err := router.RegisterHandlerWithCallbacks( | |||
| 61 | + | "save_config", | |||
| 62 | + | "__dumber_config_saved", | |||
| 63 | + | "__dumber_config_error", | |||
| 64 | + | "", | |||
| 65 | + | handler, | |||
| 66 | + | ); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | log.Info().Msg("registered save_config handler") | |||
| 71 | + | ||||
| 72 | + | return nil | |||
| 73 | + | } | |||
M internal/infrastructure/webkit/handlers/register.go +11 -4
| @@ -17,10 +17,17 @@ type Config struct { | |||
| 17 | 17 | // RegisterAll registers all message handlers with the router. | |
| 18 | 18 | func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) error { | |
| 19 | 19 | // Homepage handlers (history, favorites, folders, tags) | |
| 20 | - | if err := homepage.RegisterHandlers(ctx, router, homepage.Config{ | |
| 21 | - | HistoryUC: cfg.HistoryUC, | |
| 22 | - | FavoritesUC: cfg.FavoritesUC, | |
| 23 | - | }); err != nil { | |
| 20 | + | if cfg.HistoryUC != nil && cfg.FavoritesUC != nil { | |
| 21 | + | if err := homepage.RegisterHandlers(ctx, router, homepage.Config{ | |
| 22 | + | HistoryUC: cfg.HistoryUC, | |
| 23 | + | FavoritesUC: cfg.FavoritesUC, | |
| 24 | + | }); err != nil { | |
| 25 | + | return err | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Configuration handlers (always available) | |
| 30 | + | if err := RegisterConfigHandlers(ctx, router); err != nil { | |
| 24 | 31 | return err | |
| 25 | 32 | } | |
| 26 | 33 | ||
| @@ -17,10 +17,17 @@ type Config struct { | |||||
| 17 | // RegisterAll registers all message handlers with the router. | 17 | // RegisterAll registers all message handlers with the router. | ||
| 18 | func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) error { | 18 | func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) error { | ||
| 19 | // Homepage handlers (history, favorites, folders, tags) | 19 | // Homepage handlers (history, favorites, folders, tags) | ||
| 20 | - | if err := homepage.RegisterHandlers(ctx, router, homepage.Config{ | 20 | + | if cfg.HistoryUC != nil && cfg.FavoritesUC != nil { |
| 21 | - | HistoryUC: cfg.HistoryUC, | 21 | + | if err := homepage.RegisterHandlers(ctx, router, homepage.Config{ |
| 22 | - | FavoritesUC: cfg.FavoritesUC, | 22 | + | HistoryUC: cfg.HistoryUC, |
| 23 | - | }); err != nil { | 23 | + | FavoritesUC: cfg.FavoritesUC, |
| 24 | + | }); err != nil { | |||
| 25 | + | return err | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Configuration handlers (always available) | |||
| 30 | + | if err := RegisterConfigHandlers(ctx, router); err != nil { | |||
| 24 | return err | 31 | return err | ||
| 25 | } | 32 | } | ||
| 26 | 33 | ||||
M internal/infrastructure/webkit/messaging.go +8 -2
| @@ -146,11 +146,11 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, worl | |||
| 146 | 146 | return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName) | |
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | - | log.Debug(). | |
| 149 | + | log.Info(). | |
| 150 | 150 | Str("handler", MessageHandlerName). | |
| 151 | 151 | Str("world", "main"). | |
| 152 | 152 | Uint32("signal_id", signalID). | |
| 153 | - | Msg("script message handler connected successfully") | |
| 153 | + | Msg("script message handler connected") | |
| 154 | 154 | ||
| 155 | 155 | return signalID, nil | |
| 156 | 156 | } | |
| @@ -196,6 +196,12 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) { | |||
| 196 | 196 | return | |
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | + | log.Info(). | |
| 200 | + | Str("type", msg.Type). | |
| 201 | + | Uint64("webview_id", msg.WebViewID). | |
| 202 | + | Int("payload_len", len(msg.Payload)). | |
| 203 | + | Msg("received script message") | |
| 204 | + | ||
| 199 | 205 | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | |
| 200 | 206 | if err != nil { | |
| 201 | 207 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | |
| @@ -146,11 +146,11 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, worl | |||||
| 146 | return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName) | 146 | return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName) | ||
| 147 | } | 147 | } | ||
| 148 | 148 | ||||
| 149 | - | log.Debug(). | 149 | + | log.Info(). |
| 150 | Str("handler", MessageHandlerName). | 150 | Str("handler", MessageHandlerName). | ||
| 151 | Str("world", "main"). | 151 | Str("world", "main"). | ||
| 152 | Uint32("signal_id", signalID). | 152 | Uint32("signal_id", signalID). | ||
| 153 | - | Msg("script message handler connected successfully") | 153 | + | Msg("script message handler connected") |
| 154 | 154 | ||||
| 155 | return signalID, nil | 155 | return signalID, nil | ||
| 156 | } | 156 | } | ||
| @@ -196,6 +196,12 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) { | |||||
| 196 | return | 196 | return | ||
| 197 | } | 197 | } | ||
| 198 | 198 | ||||
| 199 | + | log.Info(). | |||
| 200 | + | Str("type", msg.Type). | |||
| 201 | + | Uint64("webview_id", msg.WebViewID). | |||
| 202 | + | Int("payload_len", len(msg.Payload)). | |||
| 203 | + | Msg("received script message") | |||
| 204 | + | ||||
| 199 | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | 205 | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | ||
| 200 | if err != nil { | 206 | if err != nil { | ||
| 201 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | 207 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | ||