Merge feature: 005-webkit-memory-optimization (opt-in memory controls, CGO hardening, performance-first defaults)
55 files · +4221 -1885
M internal/cli/browse.go +22 -22
| @@ -17,11 +17,11 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // NewBrowseCmd creates the browse command | |
| 19 | 19 | func NewBrowseCmd() *cobra.Command { | |
| 20 | - | var renderingMode string | |
| 20 | + | var renderingMode string | |
| 21 | 21 | ||
| 22 | - | cmd := &cobra.Command{ | |
| 23 | - | Use: "browse <url|query>", | |
| 24 | - | Short: "Browse a URL or search query", | |
| 22 | + | cmd := &cobra.Command{ | |
| 23 | + | Use: "browse <url|query>", | |
| 24 | + | Short: "Browse a URL or search query", | |
| 25 | 25 | Long: `Browse a URL directly or use search shortcuts like: | |
| 26 | 26 | g:golang -> Google search for "golang" | |
| 27 | 27 | gh:cobra -> GitHub search for "cobra" | |
| @@ -32,21 +32,21 @@ Direct URLs are also supported: | |||
| 32 | 32 | https://example.com | |
| 33 | 33 | example.com (automatically adds https://)`, | |
| 34 | 34 | Args: cobra.ExactArgs(1), | |
| 35 | - | RunE: func(_ *cobra.Command, args []string) error { | |
| 36 | - | // Propagate rendering mode to GUI process via env var | |
| 37 | - | if renderingMode != "" { | |
| 38 | - | rm := strings.ToLower(renderingMode) | |
| 39 | - | switch rm { | |
| 40 | - | case "auto", "gpu", "cpu": | |
| 41 | - | _ = os.Setenv("DUMBER_RENDERING_MODE", rm) | |
| 42 | - | default: | |
| 43 | - | return fmt.Errorf("invalid --rendering-mode: %s (expected auto|gpu|cpu)", renderingMode) | |
| 44 | - | } | |
| 45 | - | } | |
| 46 | - | cli, err := NewCLI() | |
| 47 | - | if err != nil { | |
| 48 | - | return fmt.Errorf("failed to initialize CLI: %w", err) | |
| 49 | - | } | |
| 35 | + | RunE: func(_ *cobra.Command, args []string) error { | |
| 36 | + | // Propagate rendering mode to GUI process via env var | |
| 37 | + | if renderingMode != "" { | |
| 38 | + | rm := strings.ToLower(renderingMode) | |
| 39 | + | switch rm { | |
| 40 | + | case "auto", "gpu", "cpu": | |
| 41 | + | _ = os.Setenv("DUMBER_RENDERING_MODE", rm) | |
| 42 | + | default: | |
| 43 | + | return fmt.Errorf("invalid --rendering-mode: %s (expected auto|gpu|cpu)", renderingMode) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | cli, err := NewCLI() | |
| 47 | + | if err != nil { | |
| 48 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |
| 49 | + | } | |
| 50 | 50 | defer func() { | |
| 51 | 51 | if closeErr := cli.Close(); closeErr != nil { | |
| 52 | 52 | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |
| @@ -57,10 +57,10 @@ Direct URLs are also supported: | |||
| 57 | 57 | }, | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | // Flags | |
| 61 | - | cmd.Flags().StringVar(&renderingMode, "rendering-mode", "", "Rendering mode: auto|gpu|cpu") | |
| 60 | + | // Flags | |
| 61 | + | cmd.Flags().StringVar(&renderingMode, "rendering-mode", "", "Rendering mode: auto|gpu|cpu") | |
| 62 | 62 | ||
| 63 | - | return cmd | |
| 63 | + | return cmd | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | // browse handles the core browsing logic | |
M internal/cli/history.go +10 -10
| @@ -102,7 +102,7 @@ func listHistory(cmd *cobra.Command, _ []string) error { | |||
| 102 | 102 | }() | |
| 103 | 103 | ||
| 104 | 104 | ctx := context.Background() | |
| 105 | - | ||
| 105 | + | ||
| 106 | 106 | limit, _ := cmd.Flags().GetInt("limit") | |
| 107 | 107 | verbose, _ := cmd.Flags().GetBool("verbose") | |
| 108 | 108 | ||
| @@ -123,7 +123,7 @@ func listHistory(cmd *cobra.Command, _ []string) error { | |||
| 123 | 123 | if verbose { | |
| 124 | 124 | fmt.Fprintln(w, "ID\tVISITS\tLAST VISITED\tURL\tTITLE") | |
| 125 | 125 | fmt.Fprintln(w, "--\t------\t------------\t---\t-----") | |
| 126 | - | ||
| 126 | + | ||
| 127 | 127 | for _, entry := range history { | |
| 128 | 128 | visits := "1" | |
| 129 | 129 | if entry.VisitCount.Valid { | |
| @@ -143,7 +143,7 @@ func listHistory(cmd *cobra.Command, _ []string) error { | |||
| 143 | 143 | url := truncateString(entry.Url, 50) | |
| 144 | 144 | title = truncateString(title, 40) | |
| 145 | 145 | ||
| 146 | - | fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", | |
| 146 | + | fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", | |
| 147 | 147 | entry.ID, visits, lastVisited, url, title) | |
| 148 | 148 | } | |
| 149 | 149 | } else { | |
| @@ -250,7 +250,7 @@ func clearHistory(cmd *cobra.Command, _ []string) error { | |||
| 250 | 250 | var response string | |
| 251 | 251 | _, _ = fmt.Scanln(&response) | |
| 252 | 252 | response = strings.ToLower(strings.TrimSpace(response)) | |
| 253 | - | ||
| 253 | + | ||
| 254 | 254 | if response != "y" && response != "yes" { | |
| 255 | 255 | fmt.Println("Operation cancelled.") | |
| 256 | 256 | return nil | |
| @@ -340,20 +340,20 @@ func showStats(_ *cobra.Command, _ []string) error { | |||
| 340 | 340 | fmt.Println("Browsing History Statistics") | |
| 341 | 341 | fmt.Println("==========================") | |
| 342 | 342 | fmt.Printf("Total unique URLs: %d\n", totalEntries) | |
| 343 | - | ||
| 343 | + | ||
| 344 | 344 | if totalVisits.Valid { | |
| 345 | 345 | fmt.Printf("Total visits: %d\n", totalVisits.Int64) | |
| 346 | 346 | } | |
| 347 | 347 | ||
| 348 | 348 | if oldestDate.Valid && newestDate.Valid { | |
| 349 | - | fmt.Printf("Date range: %s to %s\n", | |
| 350 | - | oldestDate.Time.Format("2006-01-02"), | |
| 349 | + | fmt.Printf("Date range: %s to %s\n", | |
| 350 | + | oldestDate.Time.Format("2006-01-02"), | |
| 351 | 351 | newestDate.Time.Format("2006-01-02")) | |
| 352 | 352 | } | |
| 353 | 353 | ||
| 354 | 354 | fmt.Println("\nTop 5 domains:") | |
| 355 | 355 | fmt.Println("--------------") | |
| 356 | - | ||
| 356 | + | ||
| 357 | 357 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 358 | 358 | defer w.Flush() | |
| 359 | 359 | ||
| @@ -363,7 +363,7 @@ func showStats(_ *cobra.Command, _ []string) error { | |||
| 363 | 363 | for rows.Next() { | |
| 364 | 364 | var domain string | |
| 365 | 365 | var urlCount, visitCount int64 | |
| 366 | - | ||
| 366 | + | ||
| 367 | 367 | if err := rows.Scan(&domain, &urlCount, &visitCount); err != nil { | |
| 368 | 368 | continue | |
| 369 | 369 | } | |
| @@ -377,4 +377,4 @@ func showStats(_ *cobra.Command, _ []string) error { | |||
| 377 | 377 | } | |
| 378 | 378 | ||
| 379 | 379 | return nil | |
| 380 | - | }⏎̸ | |
| 380 | + | } | |
M internal/config/config.go +84 -84
| @@ -16,23 +16,23 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | // Config represents the complete configuration for dumber. | |
| 18 | 18 | type Config struct { | |
| 19 | - | Database DatabaseConfig `mapstructure:"database" yaml:"database"` | |
| 20 | - | History HistoryConfig `mapstructure:"history" yaml:"history"` | |
| 21 | - | SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"` | |
| 22 | - | Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"` | |
| 23 | - | Logging LoggingConfig `mapstructure:"logging" yaml:"logging"` | |
| 24 | - | Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"` | |
| 25 | - | // RenderingMode controls GPU/CPU rendering selection for WebKit | |
| 26 | - | RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"` | |
| 19 | + | Database DatabaseConfig `mapstructure:"database" yaml:"database"` | |
| 20 | + | History HistoryConfig `mapstructure:"history" yaml:"history"` | |
| 21 | + | SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"` | |
| 22 | + | Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"` | |
| 23 | + | Logging LoggingConfig `mapstructure:"logging" yaml:"logging"` | |
| 24 | + | Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"` | |
| 25 | + | // RenderingMode controls GPU/CPU rendering selection for WebKit | |
| 26 | + | RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"` | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | // RenderingMode selects GPU vs CPU rendering. | |
| 30 | 30 | type RenderingMode string | |
| 31 | 31 | ||
| 32 | 32 | const ( | |
| 33 | - | RenderingModeAuto RenderingMode = "auto" | |
| 34 | - | RenderingModeGPU RenderingMode = "gpu" | |
| 35 | - | RenderingModeCPU RenderingMode = "cpu" | |
| 33 | + | RenderingModeAuto RenderingMode = "auto" | |
| 34 | + | RenderingModeGPU RenderingMode = "gpu" | |
| 35 | + | RenderingModeCPU RenderingMode = "cpu" | |
| 36 | 36 | ) | |
| 37 | 37 | ||
| 38 | 38 | // DatabaseConfig holds database-related configuration. | |
| @@ -52,8 +52,8 @@ type HistoryConfig struct { | |||
| 52 | 52 | ||
| 53 | 53 | // SearchShortcut represents a search shortcut configuration. | |
| 54 | 54 | type SearchShortcut struct { | |
| 55 | - | URL string `mapstructure:"url" yaml:"url" json:"url"` | |
| 56 | - | Description string `mapstructure:"description" yaml:"description" json:"description"` | |
| 55 | + | URL string `mapstructure:"url" yaml:"url" json:"url"` | |
| 56 | + | Description string `mapstructure:"description" yaml:"description" json:"description"` | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | 59 | // DmenuConfig holds dmenu/rofi integration configuration. | |
| @@ -81,12 +81,12 @@ type LoggingConfig struct { | |||
| 81 | 81 | ||
| 82 | 82 | // AppearanceConfig holds UI/rendering preferences. | |
| 83 | 83 | type AppearanceConfig struct { | |
| 84 | - | // Default fonts for pages that do not specify fonts. | |
| 85 | - | SansFont string `mapstructure:"sans_font" yaml:"sans_font"` | |
| 86 | - | SerifFont string `mapstructure:"serif_font" yaml:"serif_font"` | |
| 87 | - | MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"` | |
| 88 | - | // Default font size in CSS pixels (approx). | |
| 89 | - | DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"` | |
| 84 | + | // Default fonts for pages that do not specify fonts. | |
| 85 | + | SansFont string `mapstructure:"sans_font" yaml:"sans_font"` | |
| 86 | + | SerifFont string `mapstructure:"serif_font" yaml:"serif_font"` | |
| 87 | + | MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"` | |
| 88 | + | // Default font size in CSS pixels (approx). | |
| 89 | + | DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"` | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 92 | 92 | // Manager handles configuration loading, watching, and reloading. | |
| @@ -119,7 +119,7 @@ func NewManager() (*Manager, error) { | |||
| 119 | 119 | v.AutomaticEnv() | |
| 120 | 120 | ||
| 121 | 121 | // Bind specific environment variables | |
| 122 | - | bindings := map[string]string{ | |
| 122 | + | bindings := map[string]string{ | |
| 123 | 123 | "database.path": "DATABASE_PATH", | |
| 124 | 124 | "database.max_connections": "DATABASE_MAX_CONNECTIONS", | |
| 125 | 125 | "database.max_idle_time": "DATABASE_MAX_IDLE_TIME", | |
| @@ -147,16 +147,16 @@ func NewManager() (*Manager, error) { | |||
| 147 | 147 | "logging.compress": "LOGGING_COMPRESS", | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | - | for key, env := range bindings { | |
| 151 | - | if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil { | |
| 152 | - | return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err) | |
| 153 | - | } | |
| 154 | - | } | |
| 150 | + | for key, env := range bindings { | |
| 151 | + | if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil { | |
| 152 | + | return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | 155 | ||
| 156 | - | // Explicit binding for rendering mode via dedicated env var | |
| 157 | - | if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil { | |
| 158 | - | return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err) | |
| 159 | - | } | |
| 156 | + | // Explicit binding for rendering mode via dedicated env var | |
| 157 | + | if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil { | |
| 158 | + | return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err) | |
| 159 | + | } | |
| 160 | 160 | ||
| 161 | 161 | return &Manager{ | |
| 162 | 162 | viper: v, | |
| @@ -189,11 +189,11 @@ func (m *Manager) Load() error { | |||
| 189 | 189 | } | |
| 190 | 190 | } | |
| 191 | 191 | ||
| 192 | - | // Unmarshal into config struct | |
| 193 | - | config := &Config{} | |
| 194 | - | if err := m.viper.Unmarshal(config); err != nil { | |
| 195 | - | return fmt.Errorf("failed to unmarshal config: %w", err) | |
| 196 | - | } | |
| 192 | + | // Unmarshal into config struct | |
| 193 | + | config := &Config{} | |
| 194 | + | if err := m.viper.Unmarshal(config); err != nil { | |
| 195 | + | return fmt.Errorf("failed to unmarshal config: %w", err) | |
| 196 | + | } | |
| 197 | 197 | ||
| 198 | 198 | // Set database path if not specified | |
| 199 | 199 | if config.Database.Path == "" { | |
| @@ -204,20 +204,20 @@ func (m *Manager) Load() error { | |||
| 204 | 204 | config.Database.Path = dbPath | |
| 205 | 205 | } | |
| 206 | 206 | ||
| 207 | - | // Normalize/validate rendering mode | |
| 208 | - | switch strings.ToLower(string(config.RenderingMode)) { | |
| 209 | - | case "", string(RenderingModeAuto): | |
| 210 | - | config.RenderingMode = RenderingModeAuto | |
| 211 | - | case string(RenderingModeGPU): | |
| 212 | - | config.RenderingMode = RenderingModeGPU | |
| 213 | - | case string(RenderingModeCPU): | |
| 214 | - | config.RenderingMode = RenderingModeCPU | |
| 215 | - | default: | |
| 216 | - | config.RenderingMode = RenderingModeAuto | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | m.config = config | |
| 220 | - | return nil | |
| 207 | + | // Normalize/validate rendering mode | |
| 208 | + | switch strings.ToLower(string(config.RenderingMode)) { | |
| 209 | + | case "", string(RenderingModeAuto): | |
| 210 | + | config.RenderingMode = RenderingModeAuto | |
| 211 | + | case string(RenderingModeGPU): | |
| 212 | + | config.RenderingMode = RenderingModeGPU | |
| 213 | + | case string(RenderingModeCPU): | |
| 214 | + | config.RenderingMode = RenderingModeCPU | |
| 215 | + | default: | |
| 216 | + | config.RenderingMode = RenderingModeAuto | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | m.config = config | |
| 220 | + | return nil | |
| 221 | 221 | } | |
| 222 | 222 | ||
| 223 | 223 | // Get returns the current configuration (thread-safe). | |
| @@ -277,10 +277,10 @@ func (m *Manager) reload() error { | |||
| 277 | 277 | return err | |
| 278 | 278 | } | |
| 279 | 279 | ||
| 280 | - | config := &Config{} | |
| 281 | - | if err := m.viper.Unmarshal(config); err != nil { | |
| 282 | - | return err | |
| 283 | - | } | |
| 280 | + | config := &Config{} | |
| 281 | + | if err := m.viper.Unmarshal(config); err != nil { | |
| 282 | + | return err | |
| 283 | + | } | |
| 284 | 284 | ||
| 285 | 285 | // Set database path if not specified | |
| 286 | 286 | if config.Database.Path == "" { | |
| @@ -291,25 +291,25 @@ func (m *Manager) reload() error { | |||
| 291 | 291 | config.Database.Path = dbPath | |
| 292 | 292 | } | |
| 293 | 293 | ||
| 294 | - | // Normalize/validate rendering mode | |
| 295 | - | switch strings.ToLower(string(config.RenderingMode)) { | |
| 296 | - | case "", string(RenderingModeAuto): | |
| 297 | - | config.RenderingMode = RenderingModeAuto | |
| 298 | - | case string(RenderingModeGPU): | |
| 299 | - | config.RenderingMode = RenderingModeGPU | |
| 300 | - | case string(RenderingModeCPU): | |
| 301 | - | config.RenderingMode = RenderingModeCPU | |
| 302 | - | default: | |
| 303 | - | config.RenderingMode = RenderingModeAuto | |
| 304 | - | } | |
| 305 | - | ||
| 306 | - | m.config = config | |
| 307 | - | return nil | |
| 294 | + | // Normalize/validate rendering mode | |
| 295 | + | switch strings.ToLower(string(config.RenderingMode)) { | |
| 296 | + | case "", string(RenderingModeAuto): | |
| 297 | + | config.RenderingMode = RenderingModeAuto | |
| 298 | + | case string(RenderingModeGPU): | |
| 299 | + | config.RenderingMode = RenderingModeGPU | |
| 300 | + | case string(RenderingModeCPU): | |
| 301 | + | config.RenderingMode = RenderingModeCPU | |
| 302 | + | default: | |
| 303 | + | config.RenderingMode = RenderingModeAuto | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | m.config = config | |
| 307 | + | return nil | |
| 308 | 308 | } | |
| 309 | 309 | ||
| 310 | 310 | // setDefaults sets default configuration values in Viper. | |
| 311 | 311 | func (m *Manager) setDefaults() { | |
| 312 | - | defaults := DefaultConfig() | |
| 312 | + | defaults := DefaultConfig() | |
| 313 | 313 | ||
| 314 | 314 | // Database defaults | |
| 315 | 315 | m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections) | |
| @@ -336,23 +336,23 @@ func (m *Manager) setDefaults() { | |||
| 336 | 336 | m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat) | |
| 337 | 337 | m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount) | |
| 338 | 338 | ||
| 339 | - | // Logging defaults | |
| 340 | - | m.viper.SetDefault("logging.level", defaults.Logging.Level) | |
| 341 | - | m.viper.SetDefault("logging.format", defaults.Logging.Format) | |
| 342 | - | m.viper.SetDefault("logging.filename", defaults.Logging.Filename) | |
| 343 | - | m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize) | |
| 344 | - | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) | |
| 345 | - | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | |
| 346 | - | m.viper.SetDefault("logging.compress", defaults.Logging.Compress) | |
| 347 | - | ||
| 348 | - | // Appearance defaults | |
| 349 | - | m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont) | |
| 350 | - | m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont) | |
| 351 | - | m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont) | |
| 352 | - | m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize) | |
| 353 | - | ||
| 354 | - | // Rendering defaults | |
| 355 | - | m.viper.SetDefault("rendering_mode", string(RenderingModeAuto)) | |
| 339 | + | // Logging defaults | |
| 340 | + | m.viper.SetDefault("logging.level", defaults.Logging.Level) | |
| 341 | + | m.viper.SetDefault("logging.format", defaults.Logging.Format) | |
| 342 | + | m.viper.SetDefault("logging.filename", defaults.Logging.Filename) | |
| 343 | + | m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize) | |
| 344 | + | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) | |
| 345 | + | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | |
| 346 | + | m.viper.SetDefault("logging.compress", defaults.Logging.Compress) | |
| 347 | + | ||
| 348 | + | // Appearance defaults | |
| 349 | + | m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont) | |
| 350 | + | m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont) | |
| 351 | + | m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont) | |
| 352 | + | m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize) | |
| 353 | + | ||
| 354 | + | // Rendering defaults | |
| 355 | + | m.viper.SetDefault("rendering_mode", string(RenderingModeAuto)) | |
| 356 | 356 | } | |
| 357 | 357 | ||
| 358 | 358 | // createDefaultConfig creates a default configuration file. | |
M internal/config/defaults.go +18 -18
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | // DefaultConfig returns the default configuration values for dumber. | |
| 9 | 9 | func DefaultConfig() *Config { | |
| 10 | - | return &Config{ | |
| 10 | + | return &Config{ | |
| 11 | 11 | Database: DatabaseConfig{ | |
| 12 | 12 | MaxConnections: 1, | |
| 13 | 13 | MaxIdleTime: time.Minute * 5, | |
| @@ -46,23 +46,23 @@ func DefaultConfig() *Config { | |||
| 46 | 46 | DateFormat: "2006-01-02 15:04", | |
| 47 | 47 | SortByVisitCount: true, | |
| 48 | 48 | }, | |
| 49 | - | Logging: LoggingConfig{ | |
| 50 | - | Level: "info", | |
| 51 | - | Format: "text", // text or json | |
| 52 | - | Filename: "", // empty means stdout | |
| 53 | - | MaxSize: 100, // MB | |
| 54 | - | MaxBackups: 3, | |
| 55 | - | MaxAge: 7, // days | |
| 56 | - | Compress: true, | |
| 57 | - | }, | |
| 58 | - | Appearance: AppearanceConfig{ | |
| 59 | - | SansFont: "Fira Sans", | |
| 60 | - | SerifFont: "Fira Sans", | |
| 61 | - | MonospaceFont: "Fira Code", | |
| 62 | - | DefaultFontSize: 16, | |
| 63 | - | }, | |
| 64 | - | RenderingMode: RenderingModeAuto, | |
| 65 | - | } | |
| 49 | + | Logging: LoggingConfig{ | |
| 50 | + | Level: "info", | |
| 51 | + | Format: "text", // text or json | |
| 52 | + | Filename: "", // empty means stdout | |
| 53 | + | MaxSize: 100, // MB | |
| 54 | + | MaxBackups: 3, | |
| 55 | + | MaxAge: 7, // days | |
| 56 | + | Compress: true, | |
| 57 | + | }, | |
| 58 | + | Appearance: AppearanceConfig{ | |
| 59 | + | SansFont: "Fira Sans", | |
| 60 | + | SerifFont: "Fira Sans", | |
| 61 | + | MonospaceFont: "Fira Code", | |
| 62 | + | DefaultFontSize: 16, | |
| 63 | + | }, | |
| 64 | + | RenderingMode: RenderingModeAuto, | |
| 65 | + | } | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | 68 | // GetDefaultSearchShortcuts returns the default search shortcuts. | |
M internal/db/init.go +1 -1
| @@ -117,4 +117,4 @@ func initializeShortcuts(db *sql.DB, cfg *config.Config) error { | |||
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | 119 | return nil | |
| 120 | - | }⏎̸ | |
| 120 | + | } | |
M internal/db/interfaces.go +1 -1
| @@ -34,4 +34,4 @@ type DatabaseQuerier interface { | |||
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | // Ensure that *Queries implements DatabaseQuerier interface | |
| 37 | - | var _ DatabaseQuerier = (*Queries)(nil)⏎̸ | |
| 37 | + | var _ DatabaseQuerier = (*Queries)(nil) | |
M internal/parser/fuzzy.go +4 -4
| @@ -437,12 +437,12 @@ func (fm *FuzzyMatcher) isDomainMatch(query, url string) bool { | |||
| 437 | 437 | if domain == query { | |
| 438 | 438 | return true | |
| 439 | 439 | } | |
| 440 | - | ||
| 441 | - | // Check if query is part of domain (e.g., "github" in "github.com" or "www.github.com") | |
| 440 | + | ||
| 441 | + | // Check if query is part of domain (e.g., "github" in "github.com" or "www.github.com") | |
| 442 | 442 | if strings.HasSuffix(domain, "."+query) || strings.HasPrefix(domain, query+".") { | |
| 443 | 443 | return true | |
| 444 | 444 | } | |
| 445 | - | ||
| 445 | + | ||
| 446 | 446 | // Check if query matches a significant part of domain (not just a substring) | |
| 447 | 447 | // Split domain by dots and check if any part matches query | |
| 448 | 448 | domainParts := strings.Split(domain, ".") | |
| @@ -451,7 +451,7 @@ func (fm *FuzzyMatcher) isDomainMatch(query, url string) bool { | |||
| 451 | 451 | return true | |
| 452 | 452 | } | |
| 453 | 453 | } | |
| 454 | - | ||
| 454 | + | ||
| 455 | 455 | return false | |
| 456 | 456 | } | |
| 457 | 457 | ||
M internal/parser/parser.go +32 -32
| @@ -1,12 +1,12 @@ | |||
| 1 | 1 | package parser | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "strings" | |
| 6 | - | "time" | |
| 7 | - | neturl "net/url" | |
| 4 | + | "fmt" | |
| 5 | + | neturl "net/url" | |
| 6 | + | "strings" | |
| 7 | + | "time" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/dumber/internal/config" | |
| 9 | + | "github.com/bnema/dumber/internal/config" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | // ParseInput parses user input and returns a ParseResult with URL and metadata. | |
| @@ -157,12 +157,12 @@ func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime | |||
| 157 | 157 | ||
| 158 | 158 | // processShortcut processes a search shortcut and builds the final URL. | |
| 159 | 159 | func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) { | |
| 160 | - | urlTemplate := shortcut.URL | |
| 160 | + | urlTemplate := shortcut.URL | |
| 161 | 161 | ||
| 162 | - | // Replace placeholder with query | |
| 163 | - | q := neturl.QueryEscape(query) | |
| 164 | - | finalURL := strings.ReplaceAll(urlTemplate, "{query}", q) | |
| 165 | - | finalURL = strings.ReplaceAll(finalURL, "%s", q) | |
| 162 | + | // Replace placeholder with query | |
| 163 | + | q := neturl.QueryEscape(query) | |
| 164 | + | finalURL := strings.ReplaceAll(urlTemplate, "{query}", q) | |
| 165 | + | finalURL = strings.ReplaceAll(finalURL, "%s", q) | |
| 166 | 166 | ||
| 167 | 167 | // Validate the resulting URL | |
| 168 | 168 | validator := NewURLValidator() | |
| @@ -175,28 +175,28 @@ func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.Sear | |||
| 175 | 175 | ||
| 176 | 176 | // buildSearchURL builds a search URL using the default search engine. | |
| 177 | 177 | func (p *Parser) buildSearchURL(searchEngine, query string) string { | |
| 178 | - | q := neturl.QueryEscape(query) | |
| 179 | - | // If no specific search engine, use Google as default | |
| 180 | - | if searchEngine == "" { | |
| 181 | - | defaultShortcut, exists := p.config.SearchShortcuts["g"] | |
| 182 | - | if exists { | |
| 183 | - | url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q) | |
| 184 | - | url = strings.ReplaceAll(url, "%s", q) | |
| 185 | - | return url | |
| 186 | - | } | |
| 187 | - | // Fallback to Google if no "g" shortcut configured | |
| 188 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | // Use specified search engine | |
| 192 | - | shortcut, exists := p.config.SearchShortcuts[searchEngine] | |
| 193 | - | if !exists { | |
| 194 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | u := strings.ReplaceAll(shortcut.URL, "{query}", q) | |
| 198 | - | u = strings.ReplaceAll(u, "%s", q) | |
| 199 | - | return u | |
| 178 | + | q := neturl.QueryEscape(query) | |
| 179 | + | // If no specific search engine, use Google as default | |
| 180 | + | if searchEngine == "" { | |
| 181 | + | defaultShortcut, exists := p.config.SearchShortcuts["g"] | |
| 182 | + | if exists { | |
| 183 | + | url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q) | |
| 184 | + | url = strings.ReplaceAll(url, "%s", q) | |
| 185 | + | return url | |
| 186 | + | } | |
| 187 | + | // Fallback to Google if no "g" shortcut configured | |
| 188 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // Use specified search engine | |
| 192 | + | shortcut, exists := p.config.SearchShortcuts[searchEngine] | |
| 193 | + | if !exists { | |
| 194 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | u := strings.ReplaceAll(shortcut.URL, "{query}", q) | |
| 198 | + | u = strings.ReplaceAll(u, "%s", q) | |
| 199 | + | return u | |
| 200 | 200 | } | |
| 201 | 201 | ||
| 202 | 202 | // FuzzySearchHistory performs fuzzy search on history and returns ranked matches. | |
M internal/parser/types.go +1 -1
| @@ -4,8 +4,8 @@ package parser | |||
| 4 | 4 | import ( | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/db" | |
| 8 | 7 | "github.com/bnema/dumber/internal/config" | |
| 8 | + | "github.com/bnema/dumber/internal/db" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | // InputType represents the type of user input detected. | |
M main.go +622 -483
| @@ -1,26 +1,25 @@ | |||
| 1 | 1 | package main | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | - | "embed" | |
| 6 | - | "encoding/json" | |
| 7 | - | "fmt" | |
| 8 | - | "mime" | |
| 9 | - | "log" | |
| 10 | - | "os" | |
| 11 | - | "runtime" | |
| 12 | - | neturl "net/url" | |
| 13 | - | "path/filepath" | |
| 14 | - | "strings" | |
| 15 | - | "strconv" | |
| 16 | - | "os/exec" | |
| 17 | - | ||
| 18 | - | ||
| 19 | - | "github.com/bnema/dumber/internal/cli" | |
| 20 | - | "github.com/bnema/dumber/internal/config" | |
| 21 | - | "github.com/bnema/dumber/internal/db" | |
| 22 | - | "github.com/bnema/dumber/services" | |
| 23 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "context" | |
| 5 | + | "embed" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "log" | |
| 9 | + | "mime" | |
| 10 | + | neturl "net/url" | |
| 11 | + | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "path/filepath" | |
| 14 | + | "runtime" | |
| 15 | + | "strconv" | |
| 16 | + | "strings" | |
| 17 | + | ||
| 18 | + | "github.com/bnema/dumber/internal/cli" | |
| 19 | + | "github.com/bnema/dumber/internal/config" | |
| 20 | + | "github.com/bnema/dumber/internal/db" | |
| 21 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 22 | + | "github.com/bnema/dumber/services" | |
| 24 | 23 | ) | |
| 25 | 24 | ||
| 26 | 25 | // Build information set via ldflags | |
| @@ -37,8 +36,8 @@ func main() { | |||
| 37 | 36 | return | |
| 38 | 37 | } | |
| 39 | 38 | ||
| 40 | - | // Otherwise run the GUI browser | |
| 41 | - | runBrowser() | |
| 39 | + | // Otherwise run the GUI browser | |
| 40 | + | runBrowser() | |
| 42 | 41 | } | |
| 43 | 42 | ||
| 44 | 43 | // shouldRunCLI determines if we should run in CLI mode based on arguments | |
| @@ -107,7 +106,7 @@ func runCLI() { | |||
| 107 | 106 | if err := rootCmd.Execute(); err != nil { | |
| 108 | 107 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | |
| 109 | 108 | os.Exit(1) | |
| 110 | - | } | |
| 109 | + | } | |
| 111 | 110 | } | |
| 112 | 111 | ||
| 113 | 112 | //go:embed frontend/dist | |
| @@ -115,469 +114,609 @@ var assets embed.FS | |||
| 115 | 114 | ||
| 116 | 115 | // runBrowser launches a minimal native WebKit window and integrates services. | |
| 117 | 116 | func runBrowser() { | |
| 118 | - | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | |
| 119 | - | // GTK requires all UI calls to run on the main OS thread | |
| 120 | - | if webkit.IsNativeAvailable() { | |
| 121 | - | runtime.LockOSThread() | |
| 122 | - | defer runtime.UnlockOSThread() | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // Proactively harden media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins. | |
| 126 | - | // Can be disabled with DUMBER_MEDIA_SAFE=0 | |
| 127 | - | if os.Getenv("DUMBER_MEDIA_SAFE") != "0" { | |
| 128 | - | // Demote VAAPI-related elements so they won't be auto-picked | |
| 129 | - | if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" { | |
| 130 | - | os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0") | |
| 131 | - | } | |
| 132 | - | // Provide a safe audio sink to avoid autoaudiosink failures | |
| 133 | - | if os.Getenv("GST_AUDIO_SINK") == "" { | |
| 134 | - | os.Setenv("GST_AUDIO_SINK", "fakesink") | |
| 135 | - | } | |
| 136 | - | // Optionally disable media stream features that can engage complex pipelines | |
| 137 | - | if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" { | |
| 138 | - | os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1") | |
| 139 | - | } | |
| 140 | - | log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)") | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | if err := config.Init(); err != nil { | |
| 144 | - | log.Fatalf("Failed to initialize config: %v", err) | |
| 145 | - | } | |
| 146 | - | cfg := config.Get() | |
| 147 | - | log.Printf("Config initialized") | |
| 148 | - | ||
| 149 | - | // Detect keyboard layout/locale and hint to webkit layer | |
| 150 | - | detectAndSetKeyboardLocale() | |
| 151 | - | ||
| 152 | - | // Initialize database | |
| 153 | - | database, err := db.InitDB(cfg.Database.Path) | |
| 154 | - | if err != nil { | |
| 155 | - | log.Fatalf("Failed to initialize database: %v", err) | |
| 156 | - | } | |
| 157 | - | defer database.Close() | |
| 158 | - | queries := db.New(database) | |
| 159 | - | log.Printf("Database opened at %s", cfg.Database.Path) | |
| 160 | - | ||
| 161 | - | // Initialize services | |
| 162 | - | parserService := services.NewParserService(cfg, queries) | |
| 163 | - | browserService := services.NewBrowserService(cfg, queries) | |
| 164 | - | // JS controls injection is disabled; using native shortcuts via GTK. | |
| 165 | - | ||
| 166 | - | // Track current URL for zoom persistence | |
| 167 | - | // Initialize to the initial homepage so shortcuts work immediately | |
| 168 | - | var currentURL string = "dumb://homepage" | |
| 169 | - | ||
| 170 | - | // Register custom scheme resolver for dumb:// | |
| 171 | - | webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) { | |
| 172 | - | log.Printf("[scheme] request: %s", uri) | |
| 173 | - | // Known forms: | |
| 174 | - | // - dumb://homepage or dumb:homepage → index.html | |
| 175 | - | // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path> | |
| 176 | - | // - dumb://<anything> without path → index.html | |
| 177 | - | u, err := neturl.Parse(uri) | |
| 178 | - | if err != nil || u.Scheme != "dumb" { | |
| 179 | - | return "", nil, false | |
| 180 | - | } | |
| 181 | - | // API routes | |
| 182 | - | if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") { | |
| 183 | - | // Normalize path for opaque or hierarchical forms | |
| 184 | - | path := u.Path | |
| 185 | - | if path == "" && u.Opaque != "" { | |
| 186 | - | // e.g., dumb:api/config or dumb:api/history/recent | |
| 187 | - | parts := strings.SplitN(u.Opaque, ":", 2) | |
| 188 | - | if len(parts) == 2 { | |
| 189 | - | path = "/" + parts[1] | |
| 190 | - | } | |
| 191 | - | } | |
| 192 | - | if strings.HasPrefix(path, "/api/") { path = strings.TrimPrefix(path, "/api") } | |
| 193 | - | if path == "/api" { path = "/" } | |
| 194 | - | switch { | |
| 195 | - | case strings.HasPrefix(path, "/rendering/status"): | |
| 196 | - | log.Printf("[api] GET /rendering/status") | |
| 197 | - | // Report configured rendering mode; runtime GPU state depends on WebKit internals | |
| 198 | - | resp := struct{ | |
| 199 | - | Mode string `json:"mode"` | |
| 200 | - | }{Mode: string(cfg.RenderingMode)} | |
| 201 | - | b, _ := json.Marshal(resp) | |
| 202 | - | return "application/json; charset=utf-8", b, true | |
| 203 | - | case strings.HasPrefix(path, "/config"): | |
| 204 | - | log.Printf("[api] GET /config") | |
| 205 | - | // Build config info | |
| 206 | - | cfgPath, _ := config.GetConfigFile() | |
| 207 | - | info := struct { | |
| 208 | - | ConfigPath string `json:"config_path"` | |
| 209 | - | DatabasePath string `json:"database_path"` | |
| 210 | - | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |
| 211 | - | Appearance config.AppearanceConfig `json:"appearance"` | |
| 212 | - | }{ | |
| 213 | - | ConfigPath: cfgPath, | |
| 214 | - | DatabasePath: cfg.Database.Path, | |
| 215 | - | SearchShortcuts: cfg.SearchShortcuts, | |
| 216 | - | Appearance: cfg.Appearance, | |
| 217 | - | } | |
| 218 | - | b, _ := json.Marshal(info) | |
| 219 | - | return "application/json; charset=utf-8", b, true | |
| 220 | - | case strings.HasPrefix(path, "/history/recent"): | |
| 221 | - | log.Printf("[api] GET /history/recent%s", u.RawQuery) | |
| 222 | - | // Parse limit | |
| 223 | - | q := u.Query() | |
| 224 | - | limit := 50 | |
| 225 | - | if l := q.Get("limit"); l != "" { | |
| 226 | - | if n, err := strconv.Atoi(l); err == nil && n > 0 { | |
| 227 | - | limit = n | |
| 228 | - | } | |
| 229 | - | } | |
| 230 | - | ctx := context.Background() | |
| 231 | - | entries, err := browserService.GetRecentHistory(ctx, limit) | |
| 232 | - | if err != nil { | |
| 233 | - | return "application/json; charset=utf-8", []byte("[]"), true | |
| 234 | - | } | |
| 235 | - | b, _ := json.Marshal(entries) | |
| 236 | - | return "application/json; charset=utf-8", b, true | |
| 237 | - | case strings.HasPrefix(path, "/history/search"): | |
| 238 | - | log.Printf("[api] GET /history/search%s", u.RawQuery) | |
| 239 | - | q := u.Query() | |
| 240 | - | query := q.Get("q") | |
| 241 | - | limit := 50 | |
| 242 | - | if l := q.Get("limit"); l != "" { | |
| 243 | - | if n, err := strconv.Atoi(l); err == nil && n > 0 { | |
| 244 | - | limit = n | |
| 245 | - | } | |
| 246 | - | } | |
| 247 | - | ctx := context.Background() | |
| 248 | - | entries, err := browserService.SearchHistory(ctx, query, limit) | |
| 249 | - | if err != nil { | |
| 250 | - | return "application/json; charset=utf-8", []byte("[]"), true | |
| 251 | - | } | |
| 252 | - | b, _ := json.Marshal(entries) | |
| 253 | - | return "application/json; charset=utf-8", b, true | |
| 254 | - | case strings.HasPrefix(path, "/history/stats"): | |
| 255 | - | log.Printf("[api] GET /history/stats") | |
| 256 | - | ctx := context.Background() | |
| 257 | - | stats, err := browserService.GetHistoryStats(ctx) | |
| 258 | - | if err != nil { return "application/json; charset=utf-8", []byte("{}"), true } | |
| 259 | - | b, _ := json.Marshal(stats) | |
| 260 | - | return "application/json; charset=utf-8", b, true | |
| 261 | - | default: | |
| 262 | - | return "application/json; charset=utf-8", []byte("{}"), true | |
| 263 | - | } | |
| 264 | - | } | |
| 265 | - | // Resolve target path inside embed FS | |
| 266 | - | var rel string | |
| 267 | - | if u.Opaque == "homepage" || (u.Host == "homepage" && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) { | |
| 268 | - | rel = "index.html" | |
| 269 | - | } else { | |
| 270 | - | host := u.Host | |
| 271 | - | p := strings.TrimPrefix(u.Path, "/") | |
| 272 | - | if host == "app" && p == "" { | |
| 273 | - | rel = "index.html" | |
| 274 | - | } else if host == "app" { | |
| 275 | - | rel = p | |
| 276 | - | } else if host == "homepage" && p != "" { | |
| 277 | - | // dumb://homepage/<asset> | |
| 278 | - | rel = p | |
| 279 | - | } else if p != "" { | |
| 280 | - | rel = p | |
| 281 | - | } else { | |
| 282 | - | rel = "index.html" | |
| 283 | - | } | |
| 284 | - | } | |
| 285 | - | log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path) | |
| 286 | - | data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel))) | |
| 287 | - | if rerr != nil { | |
| 288 | - | log.Printf("[scheme] not found: %s", rel) | |
| 289 | - | return "", nil, false | |
| 290 | - | } | |
| 291 | - | // Determine mime type | |
| 292 | - | mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel))) | |
| 293 | - | if mt == "" { | |
| 294 | - | // Fallbacks | |
| 295 | - | switch strings.ToLower(filepath.Ext(rel)) { | |
| 296 | - | case ".js": mt = "application/javascript" | |
| 297 | - | case ".css": mt = "text/css" | |
| 298 | - | case ".svg": mt = "image/svg+xml" | |
| 299 | - | case ".ico": mt = "image/x-icon" | |
| 300 | - | default: mt = "text/html; charset=utf-8" | |
| 301 | - | } | |
| 302 | - | } | |
| 303 | - | return mt, data, true | |
| 304 | - | }) | |
| 305 | - | ||
| 306 | - | // Create WebKit view | |
| 307 | - | log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable()) | |
| 308 | - | dataDir, _ := config.GetDataDir() | |
| 309 | - | stateDir, _ := config.GetStateDir() | |
| 310 | - | webkitData := filepath.Join(dataDir, "webkit") | |
| 311 | - | webkitCache := filepath.Join(stateDir, "webkit-cache") | |
| 312 | - | _ = os.MkdirAll(webkitData, 0o755) | |
| 313 | - | _ = os.MkdirAll(webkitCache, 0o755) | |
| 314 | - | view, err := webkit.NewWebView(&webkit.Config{ | |
| 315 | - | InitialURL: "dumb://homepage", | |
| 316 | - | ZoomDefault: 1.0, | |
| 317 | - | EnableDeveloperExtras: true, | |
| 318 | - | DataDir: webkitData, | |
| 319 | - | CacheDir: webkitCache, | |
| 320 | - | DefaultSansFont: cfg.Appearance.SansFont, | |
| 321 | - | DefaultSerifFont: cfg.Appearance.SerifFont, | |
| 322 | - | DefaultMonospaceFont: cfg.Appearance.MonospaceFont, | |
| 323 | - | DefaultFontSize: cfg.Appearance.DefaultFontSize, | |
| 324 | - | Rendering: webkit.RenderingConfig{Mode: string(cfg.RenderingMode)}, | |
| 325 | - | }) | |
| 326 | - | if err != nil { | |
| 327 | - | log.Printf("Warning: failed to create WebView: %v", err) | |
| 328 | - | } else { | |
| 329 | - | browserService.AttachWebView(view) | |
| 330 | - | // Use native window as title updater | |
| 331 | - | if win := view.Window(); win != nil { | |
| 332 | - | browserService.SetWindowTitleUpdater(win) | |
| 333 | - | } | |
| 334 | - | // Persist page titles to DB when they change | |
| 335 | - | view.RegisterTitleChangedHandler(func(title string) { | |
| 336 | - | ctx := context.Background() | |
| 337 | - | url := view.GetCurrentURL() | |
| 338 | - | if url != "" && title != "" { | |
| 339 | - | if err := browserService.UpdatePageTitle(ctx, url, title); err != nil { | |
| 340 | - | log.Printf("Warning: failed to update page title: %v", err) | |
| 341 | - | } | |
| 342 | - | } | |
| 343 | - | }) | |
| 344 | - | // Track current URL changes and apply saved zoom | |
| 345 | - | view.RegisterURIChangedHandler(func(u string) { | |
| 346 | - | currentURL = u | |
| 347 | - | if u == "" { return } | |
| 348 | - | ctx := context.Background() | |
| 349 | - | if z, err := browserService.GetZoomLevel(ctx, u); err == nil { | |
| 350 | - | _ = view.SetZoom(z) | |
| 351 | - | key := services.ZoomKeyForLog(u) | |
| 352 | - | log.Printf("[zoom] loaded %.2f for %s", z, key) | |
| 353 | - | } | |
| 354 | - | }) | |
| 355 | - | // Persist zoom changes per-domain | |
| 356 | - | view.RegisterZoomChangedHandler(func(level float64) { | |
| 357 | - | url := view.GetCurrentURL() | |
| 358 | - | if url == "" { return } | |
| 359 | - | ctx := context.Background() | |
| 360 | - | if err := browserService.SetZoomLevel(ctx, url, level); err != nil { | |
| 361 | - | log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err) | |
| 362 | - | return | |
| 363 | - | } | |
| 364 | - | key := services.ZoomKeyForLog(url) | |
| 365 | - | log.Printf("[zoom] saved %.2f for %s", level, key) | |
| 366 | - | }) | |
| 367 | - | // Bridge UCM messages: navigate + history queries | |
| 368 | - | view.RegisterScriptMessageHandler(func(payload string) { | |
| 369 | - | type msg struct { | |
| 370 | - | Type string `json:"type"` | |
| 371 | - | URL string `json:"url"` | |
| 372 | - | Q string `json:"q"` | |
| 373 | - | Limit int `json:"limit"` | |
| 374 | - | Value string `json:"value"` | |
| 375 | - | // Wails fetch bridge | |
| 376 | - | ID string `json:"id"` | |
| 377 | - | Payload json.RawMessage `json:"payload"` | |
| 378 | - | } | |
| 379 | - | var m msg | |
| 380 | - | if err := json.Unmarshal([]byte(payload), &m); err != nil { return } | |
| 381 | - | switch m.Type { | |
| 382 | - | case "navigate": | |
| 383 | - | ctx := context.Background() | |
| 384 | - | res, err := parserService.ParseInput(ctx, m.URL) | |
| 385 | - | if err == nil { | |
| 386 | - | currentURL = res.URL | |
| 387 | - | browserService.Navigate(ctx, currentURL) | |
| 388 | - | _ = view.LoadURL(currentURL) | |
| 389 | - | if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil { _ = view.SetZoom(z) } | |
| 390 | - | } | |
| 391 | - | case "query": | |
| 392 | - | ctx := context.Background() | |
| 393 | - | lim := m.Limit | |
| 394 | - | if lim <= 0 || lim > 25 { lim = 10 } | |
| 395 | - | entries, err := browserService.SearchHistory(ctx, m.Q, lim) | |
| 396 | - | if err != nil { return } | |
| 397 | - | // Map to lightweight items | |
| 398 | - | type item struct { URL string `json:"url"`; Favicon string `json:"favicon"` } | |
| 399 | - | buildFavicon := func(raw string) string { | |
| 400 | - | u, err := parserService.ParseInput(ctx, raw) | |
| 401 | - | if err != nil || u.URL == "" { return "" } | |
| 402 | - | parsed, perr := neturl.Parse(u.URL) | |
| 403 | - | if perr != nil || parsed.Host == "" { return "" } | |
| 404 | - | scheme := parsed.Scheme | |
| 405 | - | if scheme == "" { scheme = "https" } | |
| 406 | - | return scheme + "://" + parsed.Host + "/favicon.ico" | |
| 407 | - | } | |
| 408 | - | items := make([]item, 0, len(entries)) | |
| 409 | - | for _, e := range entries { items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)}) } | |
| 410 | - | b, _ := json.Marshal(items) | |
| 411 | - | // Update suggestions in page | |
| 412 | - | _ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")") | |
| 413 | - | case "wails": | |
| 414 | - | // Handle Wails runtime fetch bridge calls for homepage | |
| 415 | - | // Payload contains { methodID, methodName?, args } | |
| 416 | - | var p struct { | |
| 417 | - | MethodID uint32 `json:"methodID"` | |
| 418 | - | MethodName string `json:"methodName"` | |
| 419 | - | Args json.RawMessage `json:"args"` | |
| 420 | - | } | |
| 421 | - | if err := json.Unmarshal(m.Payload, &p); err != nil { return } | |
| 422 | - | // Only implement the IDs we need | |
| 423 | - | switch p.MethodID { | |
| 424 | - | case 3708519028: // BrowserService.GetRecentHistory(limit) | |
| 425 | - | var args []interface{} | |
| 426 | - | _ = json.Unmarshal(p.Args, &args) | |
| 427 | - | limit := 50 | |
| 428 | - | if len(args) > 0 { | |
| 429 | - | if f, ok := args[0].(float64); ok { limit = int(f) } | |
| 430 | - | } | |
| 431 | - | ctx := context.Background() | |
| 432 | - | entries, err := browserService.GetRecentHistory(ctx, limit) | |
| 433 | - | if err != nil { return } | |
| 434 | - | resp, _ := json.Marshal(entries) | |
| 435 | - | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")") | |
| 436 | - | case 4078533762: // BrowserService.GetSearchShortcuts() | |
| 437 | - | ctx := context.Background() | |
| 438 | - | shortcuts, err := browserService.GetSearchShortcuts(ctx) | |
| 439 | - | if err != nil { return } | |
| 440 | - | resp, _ := json.Marshal(shortcuts) | |
| 441 | - | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")") | |
| 442 | - | default: | |
| 443 | - | // Return empty JSON to avoid breaking UI | |
| 444 | - | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')") | |
| 445 | - | } | |
| 446 | - | case "theme": | |
| 447 | - | if m.Value != "" { log.Printf("[theme] page reported color-scheme: %s", m.Value) } | |
| 448 | - | } | |
| 449 | - | }) | |
| 450 | - | // Apply initial zoom setting and log it before showing the window | |
| 451 | - | { | |
| 452 | - | ctx := context.Background() | |
| 453 | - | u := view.GetCurrentURL() | |
| 454 | - | if u == "" { u = "dumb://homepage" } | |
| 455 | - | if z, zerr := browserService.GetZoomLevel(ctx, u); zerr == nil { | |
| 456 | - | if err := view.SetZoom(z); err != nil { | |
| 457 | - | log.Printf("Warning: failed to set initial zoom: %v", err) | |
| 458 | - | } else { | |
| 459 | - | key := services.ZoomKeyForLog(u) | |
| 460 | - | log.Printf("[zoom] loaded %.2f for %s", z, key) | |
| 461 | - | } | |
| 462 | - | } | |
| 463 | - | } | |
| 464 | - | ||
| 465 | - | log.Printf("Showing WebView window…") | |
| 466 | - | if err := view.Show(); err != nil { | |
| 467 | - | log.Printf("Warning: failed to show WebView: %v", err) | |
| 468 | - | } else { | |
| 469 | - | if !webkit.IsNativeAvailable() { | |
| 470 | - | log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.") | |
| 471 | - | } | |
| 472 | - | } | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | // Parse browse argument if present | |
| 476 | - | if len(os.Args) >= 3 && os.Args[1] == "browse" { | |
| 477 | - | log.Printf("Browse command detected: %s", os.Args[2]) | |
| 478 | - | ctx := context.Background() | |
| 479 | - | result, err := parserService.ParseInput(ctx, os.Args[2]) | |
| 480 | - | if err == nil { | |
| 481 | - | currentURL = result.URL | |
| 482 | - | log.Printf("Parsed input → URL: %s", currentURL) | |
| 483 | - | browserService.Navigate(ctx, currentURL) | |
| 484 | - | if view != nil { | |
| 485 | - | log.Printf("Loading URL in WebView: %s", currentURL) | |
| 486 | - | if err := view.LoadURL(currentURL); err != nil { | |
| 487 | - | log.Printf("Warning: failed to load URL: %v", err) | |
| 488 | - | } | |
| 489 | - | if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil { | |
| 490 | - | if err := view.SetZoom(z); err != nil { | |
| 491 | - | log.Printf("Warning: failed to set zoom: %v", err) | |
| 492 | - | } else { | |
| 493 | - | key := services.ZoomKeyForLog(currentURL) | |
| 494 | - | log.Printf("[zoom] loaded %.2f for %s", z, key) | |
| 495 | - | } | |
| 496 | - | } | |
| 497 | - | // No JS injection; native accelerators handle zoom/devtools. | |
| 498 | - | } | |
| 499 | - | } | |
| 500 | - | } | |
| 501 | - | ||
| 502 | - | // Register basic keyboard shortcuts on the native view to preserve behavior | |
| 503 | - | if view != nil { | |
| 504 | - | // DevTools | |
| 505 | - | _ = view.RegisterKeyboardShortcut("F12", func() { log.Printf("Shortcut: F12 (devtools)"); _ = view.ShowDevTools() }) | |
| 506 | - | // Omnibox (Ctrl+L): rely on injected script listener | |
| 507 | - | _ = view.RegisterKeyboardShortcut("cmdorctrl+l", func() { | |
| 508 | - | log.Printf("Shortcut: Omnibox toggle") | |
| 509 | - | _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()") | |
| 510 | - | }) | |
| 511 | - | // Zoom handled natively in webkit package (built-in shortcuts) | |
| 512 | - | } | |
| 513 | - | ||
| 514 | - | // Enter GTK main loop only when native backend is available. | |
| 515 | - | if webkit.IsNativeAvailable() { | |
| 516 | - | log.Printf("Entering GTK main loop…") | |
| 517 | - | webkit.RunMainLoop() | |
| 518 | - | log.Printf("GTK main loop exited") | |
| 519 | - | } else { | |
| 520 | - | log.Printf("Not entering GUI loop (non-CGO build)") | |
| 521 | - | } | |
| 117 | + | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | |
| 118 | + | // GTK requires all UI calls to run on the main OS thread | |
| 119 | + | if webkit.IsNativeAvailable() { | |
| 120 | + | runtime.LockOSThread() | |
| 121 | + | defer runtime.UnlockOSThread() | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Proactively harden media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins. | |
| 125 | + | // Can be disabled with DUMBER_MEDIA_SAFE=0 | |
| 126 | + | if os.Getenv("DUMBER_MEDIA_SAFE") != "0" { | |
| 127 | + | // Demote VAAPI-related elements so they won't be auto-picked | |
| 128 | + | if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" { | |
| 129 | + | os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0") | |
| 130 | + | } | |
| 131 | + | // Provide a safe audio sink to avoid autoaudiosink failures | |
| 132 | + | if os.Getenv("GST_AUDIO_SINK") == "" { | |
| 133 | + | os.Setenv("GST_AUDIO_SINK", "fakesink") | |
| 134 | + | } | |
| 135 | + | // Optionally disable media stream features that can engage complex pipelines | |
| 136 | + | if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" { | |
| 137 | + | os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1") | |
| 138 | + | } | |
| 139 | + | log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)") | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | if err := config.Init(); err != nil { | |
| 143 | + | log.Fatalf("Failed to initialize config: %v", err) | |
| 144 | + | } | |
| 145 | + | cfg := config.Get() | |
| 146 | + | log.Printf("Config initialized") | |
| 147 | + | ||
| 148 | + | // Detect keyboard layout/locale and hint to webkit layer | |
| 149 | + | detectAndSetKeyboardLocale() | |
| 150 | + | ||
| 151 | + | // Initialize database | |
| 152 | + | database, err := db.InitDB(cfg.Database.Path) | |
| 153 | + | if err != nil { | |
| 154 | + | log.Fatalf("Failed to initialize database: %v", err) | |
| 155 | + | } | |
| 156 | + | defer database.Close() | |
| 157 | + | queries := db.New(database) | |
| 158 | + | log.Printf("Database opened at %s", cfg.Database.Path) | |
| 159 | + | ||
| 160 | + | // Initialize services | |
| 161 | + | parserService := services.NewParserService(cfg, queries) | |
| 162 | + | browserService := services.NewBrowserService(cfg, queries) | |
| 163 | + | // JS controls injection is disabled; using native shortcuts via GTK. | |
| 164 | + | ||
| 165 | + | // Track current URL for zoom persistence | |
| 166 | + | // Initialize to the initial homepage so shortcuts work immediately | |
| 167 | + | var currentURL string = "dumb://homepage" | |
| 168 | + | ||
| 169 | + | // Register custom scheme resolver for dumb:// | |
| 170 | + | webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) { | |
| 171 | + | log.Printf("[scheme] request: %s", uri) | |
| 172 | + | // Known forms: | |
| 173 | + | // - dumb://homepage or dumb:homepage → index.html | |
| 174 | + | // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path> | |
| 175 | + | // - dumb://<anything> without path → index.html | |
| 176 | + | u, err := neturl.Parse(uri) | |
| 177 | + | if err != nil || u.Scheme != "dumb" { | |
| 178 | + | return "", nil, false | |
| 179 | + | } | |
| 180 | + | // API routes | |
| 181 | + | if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") { | |
| 182 | + | // Normalize path for opaque or hierarchical forms | |
| 183 | + | path := u.Path | |
| 184 | + | if path == "" && u.Opaque != "" { | |
| 185 | + | // e.g., dumb:api/config or dumb:api/history/recent | |
| 186 | + | parts := strings.SplitN(u.Opaque, ":", 2) | |
| 187 | + | if len(parts) == 2 { | |
| 188 | + | path = "/" + parts[1] | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | if strings.HasPrefix(path, "/api/") { | |
| 192 | + | path = strings.TrimPrefix(path, "/api") | |
| 193 | + | } | |
| 194 | + | if path == "/api" { | |
| 195 | + | path = "/" | |
| 196 | + | } | |
| 197 | + | switch { | |
| 198 | + | case strings.HasPrefix(path, "/rendering/status"): | |
| 199 | + | log.Printf("[api] GET /rendering/status") | |
| 200 | + | // Report configured rendering mode; runtime GPU state depends on WebKit internals | |
| 201 | + | resp := struct { | |
| 202 | + | Mode string `json:"mode"` | |
| 203 | + | }{Mode: string(cfg.RenderingMode)} | |
| 204 | + | b, _ := json.Marshal(resp) | |
| 205 | + | return "application/json; charset=utf-8", b, true | |
| 206 | + | case strings.HasPrefix(path, "/config"): | |
| 207 | + | log.Printf("[api] GET /config") | |
| 208 | + | // Build config info | |
| 209 | + | cfgPath, _ := config.GetConfigFile() | |
| 210 | + | info := struct { | |
| 211 | + | ConfigPath string `json:"config_path"` | |
| 212 | + | DatabasePath string `json:"database_path"` | |
| 213 | + | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |
| 214 | + | Appearance config.AppearanceConfig `json:"appearance"` | |
| 215 | + | }{ | |
| 216 | + | ConfigPath: cfgPath, | |
| 217 | + | DatabasePath: cfg.Database.Path, | |
| 218 | + | SearchShortcuts: cfg.SearchShortcuts, | |
| 219 | + | Appearance: cfg.Appearance, | |
| 220 | + | } | |
| 221 | + | b, _ := json.Marshal(info) | |
| 222 | + | return "application/json; charset=utf-8", b, true | |
| 223 | + | case strings.HasPrefix(path, "/history/recent"): | |
| 224 | + | log.Printf("[api] GET /history/recent%s", u.RawQuery) | |
| 225 | + | // Parse limit | |
| 226 | + | q := u.Query() | |
| 227 | + | limit := 50 | |
| 228 | + | if l := q.Get("limit"); l != "" { | |
| 229 | + | if n, err := strconv.Atoi(l); err == nil && n > 0 { | |
| 230 | + | limit = n | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ctx := context.Background() | |
| 234 | + | entries, err := browserService.GetRecentHistory(ctx, limit) | |
| 235 | + | if err != nil { | |
| 236 | + | return "application/json; charset=utf-8", []byte("[]"), true | |
| 237 | + | } | |
| 238 | + | b, _ := json.Marshal(entries) | |
| 239 | + | return "application/json; charset=utf-8", b, true | |
| 240 | + | case strings.HasPrefix(path, "/history/search"): | |
| 241 | + | log.Printf("[api] GET /history/search%s", u.RawQuery) | |
| 242 | + | q := u.Query() | |
| 243 | + | query := q.Get("q") | |
| 244 | + | limit := 50 | |
| 245 | + | if l := q.Get("limit"); l != "" { | |
| 246 | + | if n, err := strconv.Atoi(l); err == nil && n > 0 { | |
| 247 | + | limit = n | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ctx := context.Background() | |
| 251 | + | entries, err := browserService.SearchHistory(ctx, query, limit) | |
| 252 | + | if err != nil { | |
| 253 | + | return "application/json; charset=utf-8", []byte("[]"), true | |
| 254 | + | } | |
| 255 | + | b, _ := json.Marshal(entries) | |
| 256 | + | return "application/json; charset=utf-8", b, true | |
| 257 | + | case strings.HasPrefix(path, "/history/stats"): | |
| 258 | + | log.Printf("[api] GET /history/stats") | |
| 259 | + | ctx := context.Background() | |
| 260 | + | stats, err := browserService.GetHistoryStats(ctx) | |
| 261 | + | if err != nil { | |
| 262 | + | return "application/json; charset=utf-8", []byte("{}"), true | |
| 263 | + | } | |
| 264 | + | b, _ := json.Marshal(stats) | |
| 265 | + | return "application/json; charset=utf-8", b, true | |
| 266 | + | default: | |
| 267 | + | return "application/json; charset=utf-8", []byte("{}"), true | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | // Resolve target path inside embed FS | |
| 271 | + | var rel string | |
| 272 | + | if u.Opaque == "homepage" || (u.Host == "homepage" && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) { | |
| 273 | + | rel = "index.html" | |
| 274 | + | } else { | |
| 275 | + | host := u.Host | |
| 276 | + | p := strings.TrimPrefix(u.Path, "/") | |
| 277 | + | if host == "app" && p == "" { | |
| 278 | + | rel = "index.html" | |
| 279 | + | } else if host == "app" { | |
| 280 | + | rel = p | |
| 281 | + | } else if host == "homepage" && p != "" { | |
| 282 | + | // dumb://homepage/<asset> | |
| 283 | + | rel = p | |
| 284 | + | } else if p != "" { | |
| 285 | + | rel = p | |
| 286 | + | } else { | |
| 287 | + | rel = "index.html" | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path) | |
| 291 | + | data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel))) | |
| 292 | + | if rerr != nil { | |
| 293 | + | log.Printf("[scheme] not found: %s", rel) | |
| 294 | + | return "", nil, false | |
| 295 | + | } | |
| 296 | + | // Determine mime type | |
| 297 | + | mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel))) | |
| 298 | + | if mt == "" { | |
| 299 | + | // Fallbacks | |
| 300 | + | switch strings.ToLower(filepath.Ext(rel)) { | |
| 301 | + | case ".js": | |
| 302 | + | mt = "application/javascript" | |
| 303 | + | case ".css": | |
| 304 | + | mt = "text/css" | |
| 305 | + | case ".svg": | |
| 306 | + | mt = "image/svg+xml" | |
| 307 | + | case ".ico": | |
| 308 | + | mt = "image/x-icon" | |
| 309 | + | default: | |
| 310 | + | mt = "text/html; charset=utf-8" | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | return mt, data, true | |
| 314 | + | }) | |
| 315 | + | ||
| 316 | + | // Create WebKit view | |
| 317 | + | log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable()) | |
| 318 | + | dataDir, _ := config.GetDataDir() | |
| 319 | + | stateDir, _ := config.GetStateDir() | |
| 320 | + | webkitData := filepath.Join(dataDir, "webkit") | |
| 321 | + | webkitCache := filepath.Join(stateDir, "webkit-cache") | |
| 322 | + | _ = os.MkdirAll(webkitData, 0o755) | |
| 323 | + | _ = os.MkdirAll(webkitCache, 0o755) | |
| 324 | + | view, err := webkit.NewWebView(&webkit.Config{ | |
| 325 | + | InitialURL: "dumb://homepage", | |
| 326 | + | ZoomDefault: 1.0, | |
| 327 | + | EnableDeveloperExtras: true, | |
| 328 | + | DataDir: webkitData, | |
| 329 | + | CacheDir: webkitCache, | |
| 330 | + | DefaultSansFont: cfg.Appearance.SansFont, | |
| 331 | + | DefaultSerifFont: cfg.Appearance.SerifFont, | |
| 332 | + | DefaultMonospaceFont: cfg.Appearance.MonospaceFont, | |
| 333 | + | DefaultFontSize: cfg.Appearance.DefaultFontSize, | |
| 334 | + | Rendering: webkit.RenderingConfig{Mode: string(cfg.RenderingMode)}, | |
| 335 | + | Memory: readMemoryConfigFromEnv(), | |
| 336 | + | }) | |
| 337 | + | if err != nil { | |
| 338 | + | log.Printf("Warning: failed to create WebView: %v", err) | |
| 339 | + | } else { | |
| 340 | + | browserService.AttachWebView(view) | |
| 341 | + | // Use native window as title updater | |
| 342 | + | if win := view.Window(); win != nil { | |
| 343 | + | browserService.SetWindowTitleUpdater(win) | |
| 344 | + | } | |
| 345 | + | // Persist page titles to DB when they change | |
| 346 | + | view.RegisterTitleChangedHandler(func(title string) { | |
| 347 | + | ctx := context.Background() | |
| 348 | + | url := view.GetCurrentURL() | |
| 349 | + | if url != "" && title != "" { | |
| 350 | + | if err := browserService.UpdatePageTitle(ctx, url, title); err != nil { | |
| 351 | + | log.Printf("Warning: failed to update page title: %v", err) | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | }) | |
| 355 | + | // Track current URL changes and apply saved zoom | |
| 356 | + | view.RegisterURIChangedHandler(func(u string) { | |
| 357 | + | currentURL = u | |
| 358 | + | if u == "" { | |
| 359 | + | return | |
| 360 | + | } | |
| 361 | + | ctx := context.Background() | |
| 362 | + | if z, err := browserService.GetZoomLevel(ctx, u); err == nil { | |
| 363 | + | _ = view.SetZoom(z) | |
| 364 | + | key := services.ZoomKeyForLog(u) | |
| 365 | + | log.Printf("[zoom] loaded %.2f for %s", z, key) | |
| 366 | + | } | |
| 367 | + | }) | |
| 368 | + | // Persist zoom changes per-domain | |
| 369 | + | view.RegisterZoomChangedHandler(func(level float64) { | |
| 370 | + | url := view.GetCurrentURL() | |
| 371 | + | if url == "" { | |
| 372 | + | return | |
| 373 | + | } | |
| 374 | + | ctx := context.Background() | |
| 375 | + | if err := browserService.SetZoomLevel(ctx, url, level); err != nil { | |
| 376 | + | log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err) | |
| 377 | + | return | |
| 378 | + | } | |
| 379 | + | key := services.ZoomKeyForLog(url) | |
| 380 | + | log.Printf("[zoom] saved %.2f for %s", level, key) | |
| 381 | + | }) | |
| 382 | + | // Bridge UCM messages: navigate + history queries | |
| 383 | + | view.RegisterScriptMessageHandler(func(payload string) { | |
| 384 | + | type msg struct { | |
| 385 | + | Type string `json:"type"` | |
| 386 | + | URL string `json:"url"` | |
| 387 | + | Q string `json:"q"` | |
| 388 | + | Limit int `json:"limit"` | |
| 389 | + | Value string `json:"value"` | |
| 390 | + | // Wails fetch bridge | |
| 391 | + | ID string `json:"id"` | |
| 392 | + | Payload json.RawMessage `json:"payload"` | |
| 393 | + | } | |
| 394 | + | var m msg | |
| 395 | + | if err := json.Unmarshal([]byte(payload), &m); err != nil { | |
| 396 | + | return | |
| 397 | + | } | |
| 398 | + | switch m.Type { | |
| 399 | + | case "navigate": | |
| 400 | + | ctx := context.Background() | |
| 401 | + | res, err := parserService.ParseInput(ctx, m.URL) | |
| 402 | + | if err == nil { | |
| 403 | + | currentURL = res.URL | |
| 404 | + | browserService.Navigate(ctx, currentURL) | |
| 405 | + | _ = view.LoadURL(currentURL) | |
| 406 | + | if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil { | |
| 407 | + | _ = view.SetZoom(z) | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | case "query": | |
| 411 | + | ctx := context.Background() | |
| 412 | + | lim := m.Limit | |
| 413 | + | if lim <= 0 || lim > 25 { | |
| 414 | + | lim = 10 | |
| 415 | + | } | |
| 416 | + | entries, err := browserService.SearchHistory(ctx, m.Q, lim) | |
| 417 | + | if err != nil { | |
| 418 | + | return | |
| 419 | + | } | |
| 420 | + | // Map to lightweight items | |
| 421 | + | type item struct { | |
| 422 | + | URL string `json:"url"` | |
| 423 | + | Favicon string `json:"favicon"` | |
| 424 | + | } | |
| 425 | + | buildFavicon := func(raw string) string { | |
| 426 | + | u, err := parserService.ParseInput(ctx, raw) | |
| 427 | + | if err != nil || u.URL == "" { | |
| 428 | + | return "" | |
| 429 | + | } | |
| 430 | + | parsed, perr := neturl.Parse(u.URL) | |
| 431 | + | if perr != nil || parsed.Host == "" { | |
| 432 | + | return "" | |
| 433 | + | } | |
| 434 | + | scheme := parsed.Scheme | |
| 435 | + | if scheme == "" { | |
| 436 | + | scheme = "https" | |
| 437 | + | } | |
| 438 | + | return scheme + "://" + parsed.Host + "/favicon.ico" | |
| 439 | + | } | |
| 440 | + | items := make([]item, 0, len(entries)) | |
| 441 | + | for _, e := range entries { | |
| 442 | + | items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)}) | |
| 443 | + | } | |
| 444 | + | b, _ := json.Marshal(items) | |
| 445 | + | // Update suggestions in page | |
| 446 | + | _ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")") | |
| 447 | + | case "wails": | |
| 448 | + | // Handle Wails runtime fetch bridge calls for homepage | |
| 449 | + | // Payload contains { methodID, methodName?, args } | |
| 450 | + | var p struct { | |
| 451 | + | MethodID uint32 `json:"methodID"` | |
| 452 | + | MethodName string `json:"methodName"` | |
| 453 | + | Args json.RawMessage `json:"args"` | |
| 454 | + | } | |
| 455 | + | if err := json.Unmarshal(m.Payload, &p); err != nil { | |
| 456 | + | return | |
| 457 | + | } | |
| 458 | + | // Only implement the IDs we need | |
| 459 | + | switch p.MethodID { | |
| 460 | + | case 3708519028: // BrowserService.GetRecentHistory(limit) | |
| 461 | + | var args []interface{} | |
| 462 | + | _ = json.Unmarshal(p.Args, &args) | |
| 463 | + | limit := 50 | |
| 464 | + | if len(args) > 0 { | |
| 465 | + | if f, ok := args[0].(float64); ok { | |
| 466 | + | limit = int(f) | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | ctx := context.Background() | |
| 470 | + | entries, err := browserService.GetRecentHistory(ctx, limit) | |
| 471 | + | if err != nil { | |
| 472 | + | return | |
| 473 | + | } | |
| 474 | + | resp, _ := json.Marshal(entries) | |
| 475 | + | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")") | |
| 476 | + | case 4078533762: // BrowserService.GetSearchShortcuts() | |
| 477 | + | ctx := context.Background() | |
| 478 | + | shortcuts, err := browserService.GetSearchShortcuts(ctx) | |
| 479 | + | if err != nil { | |
| 480 | + | return | |
| 481 | + | } | |
| 482 | + | resp, _ := json.Marshal(shortcuts) | |
| 483 | + | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")") | |
| 484 | + | default: | |
| 485 | + | // Return empty JSON to avoid breaking UI | |
| 486 | + | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')") | |
| 487 | + | } | |
| 488 | + | case "theme": | |
| 489 | + | if m.Value != "" { | |
| 490 | + | log.Printf("[theme] page reported color-scheme: %s", m.Value) | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | }) | |
| 494 | + | // Apply initial zoom setting and log it before showing the window | |
| 495 | + | { | |
| 496 | + | ctx := context.Background() | |
| 497 | + | u := view.GetCurrentURL() | |
| 498 | + | if u == "" { | |
| 499 | + | u = "dumb://homepage" | |
| 500 | + | } | |
| 501 | + | if z, zerr := browserService.GetZoomLevel(ctx, u); zerr == nil { | |
| 502 | + | if err := view.SetZoom(z); err != nil { | |
| 503 | + | log.Printf("Warning: failed to set initial zoom: %v", err) | |
| 504 | + | } else { | |
| 505 | + | key := services.ZoomKeyForLog(u) | |
| 506 | + | log.Printf("[zoom] loaded %.2f for %s", z, key) | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | log.Printf("Showing WebView window…") | |
| 512 | + | if err := view.Show(); err != nil { | |
| 513 | + | log.Printf("Warning: failed to show WebView: %v", err) | |
| 514 | + | } else { | |
| 515 | + | if !webkit.IsNativeAvailable() { | |
| 516 | + | log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.") | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | // Parse browse argument if present | |
| 522 | + | if len(os.Args) >= 3 && os.Args[1] == "browse" { | |
| 523 | + | log.Printf("Browse command detected: %s", os.Args[2]) | |
| 524 | + | ctx := context.Background() | |
| 525 | + | result, err := parserService.ParseInput(ctx, os.Args[2]) | |
| 526 | + | if err == nil { | |
| 527 | + | currentURL = result.URL | |
| 528 | + | log.Printf("Parsed input → URL: %s", currentURL) | |
| 529 | + | browserService.Navigate(ctx, currentURL) | |
| 530 | + | if view != nil { | |
| 531 | + | log.Printf("Loading URL in WebView: %s", currentURL) | |
| 532 | + | if err := view.LoadURL(currentURL); err != nil { | |
| 533 | + | log.Printf("Warning: failed to load URL: %v", err) | |
| 534 | + | } | |
| 535 | + | if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil { | |
| 536 | + | if err := view.SetZoom(z); err != nil { | |
| 537 | + | log.Printf("Warning: failed to set zoom: %v", err) | |
| 538 | + | } else { | |
| 539 | + | key := services.ZoomKeyForLog(currentURL) | |
| 540 | + | log.Printf("[zoom] loaded %.2f for %s", z, key) | |
| 541 | + | } | |
| 542 | + | } | |
| 543 | + | // No JS injection; native accelerators handle zoom/devtools. | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | // Register basic keyboard shortcuts on the native view to preserve behavior | |
| 549 | + | if view != nil { | |
| 550 | + | // DevTools | |
| 551 | + | _ = view.RegisterKeyboardShortcut("F12", func() { log.Printf("Shortcut: F12 (devtools)"); _ = view.ShowDevTools() }) | |
| 552 | + | // Omnibox (Ctrl+L): rely on injected script listener | |
| 553 | + | _ = view.RegisterKeyboardShortcut("cmdorctrl+l", func() { | |
| 554 | + | log.Printf("Shortcut: Omnibox toggle") | |
| 555 | + | _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()") | |
| 556 | + | }) | |
| 557 | + | // Zoom handled natively in webkit package (built-in shortcuts) | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | // Enter GTK main loop only when native backend is available. | |
| 561 | + | if webkit.IsNativeAvailable() { | |
| 562 | + | log.Printf("Entering GTK main loop…") | |
| 563 | + | webkit.RunMainLoop() | |
| 564 | + | log.Printf("GTK main loop exited") | |
| 565 | + | } else { | |
| 566 | + | log.Printf("Not entering GUI loop (non-CGO build)") | |
| 567 | + | } | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | // readMemoryConfigFromEnv reads optional memory optimization knobs from environment variables. | |
| 571 | + | // Defaults favor performance; only applied when env vars are set. | |
| 572 | + | func readMemoryConfigFromEnv() webkit.MemoryConfig { | |
| 573 | + | var mc webkit.MemoryConfig | |
| 574 | + | ||
| 575 | + | // Cache model (document_viewer | web_browser | primary_web_browser) | |
| 576 | + | if v := os.Getenv("DUMBER_CACHE_MODEL"); v != "" { | |
| 577 | + | switch strings.ToLower(v) { | |
| 578 | + | case "document_viewer", "documentviewer", "doc": | |
| 579 | + | mc.CacheModel = webkit.CacheModelDocumentViewer | |
| 580 | + | case "primary_web_browser", "primary", "primarywebbrowser": | |
| 581 | + | mc.CacheModel = webkit.CacheModelPrimaryWebBrowser | |
| 582 | + | default: | |
| 583 | + | mc.CacheModel = webkit.CacheModelWebBrowser | |
| 584 | + | } | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | // Page cache toggle (default true, only override if provided) | |
| 588 | + | if v := os.Getenv("DUMBER_ENABLE_PAGE_CACHE"); v != "" { | |
| 589 | + | mc.EnablePageCache = v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") | |
| 590 | + | } else { | |
| 591 | + | mc.EnablePageCache = true | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | // Memory limit (MB) | |
| 595 | + | if v := os.Getenv("DUMBER_MEMORY_LIMIT_MB"); v != "" { | |
| 596 | + | if n, err := strconv.Atoi(v); err == nil && n >= 0 { | |
| 597 | + | mc.MemoryLimitMB = n | |
| 598 | + | } | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | // Thresholds | |
| 602 | + | if v := os.Getenv("DUMBER_MEMORY_CONSERVATIVE"); v != "" { | |
| 603 | + | if f, err := strconv.ParseFloat(v, 64); err == nil { | |
| 604 | + | mc.ConservativeThreshold = f | |
| 605 | + | } | |
| 606 | + | } | |
| 607 | + | if v := os.Getenv("DUMBER_MEMORY_STRICT"); v != "" { | |
| 608 | + | if f, err := strconv.ParseFloat(v, 64); err == nil { | |
| 609 | + | mc.StrictThreshold = f | |
| 610 | + | } | |
| 611 | + | } | |
| 612 | + | if v := os.Getenv("DUMBER_MEMORY_KILL"); v != "" { | |
| 613 | + | if f, err := strconv.ParseFloat(v, 64); err == nil { | |
| 614 | + | mc.KillThreshold = f | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | if v := os.Getenv("DUMBER_MEMORY_POLL_INTERVAL"); v != "" { | |
| 618 | + | if f, err := strconv.ParseFloat(v, 64); err == nil { | |
| 619 | + | mc.PollIntervalSeconds = f | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | // Periodic GC (seconds) | |
| 624 | + | if v := os.Getenv("DUMBER_GC_INTERVAL"); v != "" { | |
| 625 | + | if n, err := strconv.Atoi(v); err == nil && n >= 0 { | |
| 626 | + | mc.EnableGCInterval = n | |
| 627 | + | } | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // Recycle after N page loads | |
| 631 | + | if v := os.Getenv("DUMBER_RECYCLE_THRESHOLD"); v != "" { | |
| 632 | + | if n, err := strconv.Atoi(v); err == nil && n >= 0 { | |
| 633 | + | mc.ProcessRecycleThreshold = n | |
| 634 | + | } | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | // Enable memory monitoring logs | |
| 638 | + | if v := os.Getenv("DUMBER_ENABLE_MEMORY_MONITORING"); v != "" { | |
| 639 | + | mc.EnableMemoryMonitoring = v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | return mc | |
| 522 | 643 | } | |
| 523 | 644 | ||
| 524 | 645 | // detectAndSetKeyboardLocale attempts to determine the current keyboard layout | |
| 525 | 646 | // and hints it to the webkit input layer for accelerator compatibility. | |
| 526 | 647 | func detectAndSetKeyboardLocale() { | |
| 527 | - | // 0) Explicit override | |
| 528 | - | locale := os.Getenv("DUMBER_KEYBOARD_LOCALE") | |
| 529 | - | if locale == "" { | |
| 530 | - | locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix | |
| 531 | - | } | |
| 532 | - | // 1) Environment | |
| 533 | - | if locale == "" { locale = os.Getenv("LC_ALL") } | |
| 534 | - | if locale == "" { locale = os.Getenv("LANG") } | |
| 535 | - | if locale == "" { locale = os.Getenv("LC_CTYPE") } | |
| 536 | - | // Trim variants | |
| 537 | - | if i := strings.IndexByte(locale, '.'); i > 0 { locale = locale[:i] } | |
| 538 | - | if i := strings.IndexByte(locale, '@'); i > 0 { locale = locale[:i] } | |
| 539 | - | if i := strings.IndexByte(locale, '_'); i > 0 { locale = locale[:i] } | |
| 540 | - | ||
| 541 | - | // 2) Try XKB env override | |
| 542 | - | if locale == "" { | |
| 543 | - | locale = os.Getenv("XKB_DEFAULT_LAYOUT") | |
| 544 | - | } | |
| 545 | - | ||
| 546 | - | // 3) Best-effort probe of setxkbmap/localectl (non-fatal) | |
| 547 | - | if locale == "" { | |
| 548 | - | if out, err := exec.Command("localectl", "status").Output(); err == nil { | |
| 549 | - | s := string(out) | |
| 550 | - | for _, line := range strings.Split(s, "\n") { | |
| 551 | - | line = strings.TrimSpace(line) | |
| 552 | - | if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") { | |
| 553 | - | parts := strings.SplitN(line, ":", 2) | |
| 554 | - | if len(parts) == 2 { | |
| 555 | - | cand := strings.TrimSpace(parts[1]) | |
| 556 | - | if cand != "" { locale = cand } | |
| 557 | - | } | |
| 558 | - | break | |
| 559 | - | } | |
| 560 | - | } | |
| 561 | - | } | |
| 562 | - | } | |
| 563 | - | if locale == "" { | |
| 564 | - | if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil { | |
| 565 | - | s := string(out) | |
| 566 | - | for _, line := range strings.Split(s, "\n") { | |
| 567 | - | line = strings.TrimSpace(line) | |
| 568 | - | if strings.HasPrefix(strings.ToLower(line), "layout:") { | |
| 569 | - | parts := strings.SplitN(line, ":", 2) | |
| 570 | - | if len(parts) == 2 { | |
| 571 | - | cand := strings.TrimSpace(parts[1]) | |
| 572 | - | if cand != "" { locale = cand } | |
| 573 | - | } | |
| 574 | - | break | |
| 575 | - | } | |
| 576 | - | } | |
| 577 | - | } | |
| 578 | - | } | |
| 579 | - | ||
| 580 | - | if locale == "" { locale = "en" } | |
| 581 | - | // No layout-specific remaps; log for diagnostics only | |
| 582 | - | log.Printf("[locale] keyboard locale detected: %s", locale) | |
| 648 | + | // 0) Explicit override | |
| 649 | + | locale := os.Getenv("DUMBER_KEYBOARD_LOCALE") | |
| 650 | + | if locale == "" { | |
| 651 | + | locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix | |
| 652 | + | } | |
| 653 | + | // 1) Environment | |
| 654 | + | if locale == "" { | |
| 655 | + | locale = os.Getenv("LC_ALL") | |
| 656 | + | } | |
| 657 | + | if locale == "" { | |
| 658 | + | locale = os.Getenv("LANG") | |
| 659 | + | } | |
| 660 | + | if locale == "" { | |
| 661 | + | locale = os.Getenv("LC_CTYPE") | |
| 662 | + | } | |
| 663 | + | // Trim variants | |
| 664 | + | if i := strings.IndexByte(locale, '.'); i > 0 { | |
| 665 | + | locale = locale[:i] | |
| 666 | + | } | |
| 667 | + | if i := strings.IndexByte(locale, '@'); i > 0 { | |
| 668 | + | locale = locale[:i] | |
| 669 | + | } | |
| 670 | + | if i := strings.IndexByte(locale, '_'); i > 0 { | |
| 671 | + | locale = locale[:i] | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | // 2) Try XKB env override | |
| 675 | + | if locale == "" { | |
| 676 | + | locale = os.Getenv("XKB_DEFAULT_LAYOUT") | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | // 3) Best-effort probe of setxkbmap/localectl (non-fatal) | |
| 680 | + | if locale == "" { | |
| 681 | + | if out, err := exec.Command("localectl", "status").Output(); err == nil { | |
| 682 | + | s := string(out) | |
| 683 | + | for _, line := range strings.Split(s, "\n") { | |
| 684 | + | line = strings.TrimSpace(line) | |
| 685 | + | if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") { | |
| 686 | + | parts := strings.SplitN(line, ":", 2) | |
| 687 | + | if len(parts) == 2 { | |
| 688 | + | cand := strings.TrimSpace(parts[1]) | |
| 689 | + | if cand != "" { | |
| 690 | + | locale = cand | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | break | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | } | |
| 697 | + | } | |
| 698 | + | if locale == "" { | |
| 699 | + | if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil { | |
| 700 | + | s := string(out) | |
| 701 | + | for _, line := range strings.Split(s, "\n") { | |
| 702 | + | line = strings.TrimSpace(line) | |
| 703 | + | if strings.HasPrefix(strings.ToLower(line), "layout:") { | |
| 704 | + | parts := strings.SplitN(line, ":", 2) | |
| 705 | + | if len(parts) == 2 { | |
| 706 | + | cand := strings.TrimSpace(parts[1]) | |
| 707 | + | if cand != "" { | |
| 708 | + | locale = cand | |
| 709 | + | } | |
| 710 | + | } | |
| 711 | + | break | |
| 712 | + | } | |
| 713 | + | } | |
| 714 | + | } | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | if locale == "" { | |
| 718 | + | locale = "en" | |
| 719 | + | } | |
| 720 | + | // No layout-specific remaps; log for diagnostics only | |
| 721 | + | log.Printf("[locale] keyboard locale detected: %s", locale) | |
| 583 | 722 | } | |
M pkg/webkit/build_flags_cgo.go +0 -1
| @@ -4,4 +4,3 @@ package webkit | |||
| 4 | 4 | ||
| 5 | 5 | // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in. | |
| 6 | 6 | func IsNativeAvailable() bool { return true } | |
| 7 | - | ||
M pkg/webkit/build_flags_stub.go +0 -1
| @@ -5,4 +5,3 @@ package webkit | |||
| 5 | 5 | // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in. | |
| 6 | 6 | // In non-CGO builds, this returns false and WebView methods are logical no-ops. | |
| 7 | 7 | func IsNativeAvailable() bool { return false } | |
| 8 | - | ||
M pkg/webkit/devtools_cgo.go +18 -10
| @@ -9,17 +9,25 @@ package webkit | |||
| 9 | 9 | import "C" | |
| 10 | 10 | ||
| 11 | 11 | func (w *WebView) ShowDevTools() error { | |
| 12 | - | if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented } | |
| 13 | - | insp := C.webkit_web_view_get_inspector(w.native.wv) | |
| 14 | - | if insp == nil { return ErrNotImplemented } | |
| 15 | - | C.webkit_web_inspector_show(insp) | |
| 16 | - | return nil | |
| 12 | + | if w == nil || w.native == nil || w.native.wv == nil { | |
| 13 | + | return ErrNotImplemented | |
| 14 | + | } | |
| 15 | + | insp := C.webkit_web_view_get_inspector(w.native.wv) | |
| 16 | + | if insp == nil { | |
| 17 | + | return ErrNotImplemented | |
| 18 | + | } | |
| 19 | + | C.webkit_web_inspector_show(insp) | |
| 20 | + | return nil | |
| 17 | 21 | } | |
| 18 | 22 | ||
| 19 | 23 | func (w *WebView) CloseDevTools() error { | |
| 20 | - | if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented } | |
| 21 | - | insp := C.webkit_web_view_get_inspector(w.native.wv) | |
| 22 | - | if insp == nil { return ErrNotImplemented } | |
| 23 | - | C.webkit_web_inspector_close(insp) | |
| 24 | - | return nil | |
| 24 | + | if w == nil || w.native == nil || w.native.wv == nil { | |
| 25 | + | return ErrNotImplemented | |
| 26 | + | } | |
| 27 | + | insp := C.webkit_web_view_get_inspector(w.native.wv) | |
| 28 | + | if insp == nil { | |
| 29 | + | return ErrNotImplemented | |
| 30 | + | } | |
| 31 | + | C.webkit_web_inspector_close(insp) | |
| 32 | + | return nil | |
| 25 | 33 | } | |
M pkg/webkit/keyboard.go +17 -17
| @@ -6,17 +6,17 @@ import "fmt" | |||
| 6 | 6 | ||
| 7 | 7 | // RegisterKeyboardShortcut binds a global or window-scoped accelerator to a callback. | |
| 8 | 8 | func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error { | |
| 9 | - | if w == nil || w.destroyed { | |
| 10 | - | return ErrNotImplemented | |
| 11 | - | } | |
| 12 | - | if accel == "" || callback == nil { | |
| 13 | - | return fmt.Errorf("invalid shortcut registration") | |
| 14 | - | } | |
| 15 | - | // Placeholder: in real impl, bind GTK accelerator; for now, store for tests. | |
| 16 | - | if shortcuts := getShortcutRegistry(w); shortcuts != nil { | |
| 17 | - | shortcuts[accel] = callback | |
| 18 | - | } | |
| 19 | - | return nil | |
| 9 | + | if w == nil || w.destroyed { | |
| 10 | + | return ErrNotImplemented | |
| 11 | + | } | |
| 12 | + | if accel == "" || callback == nil { | |
| 13 | + | return fmt.Errorf("invalid shortcut registration") | |
| 14 | + | } | |
| 15 | + | // Placeholder: in real impl, bind GTK accelerator; for now, store for tests. | |
| 16 | + | if shortcuts := getShortcutRegistry(w); shortcuts != nil { | |
| 17 | + | shortcuts[accel] = callback | |
| 18 | + | } | |
| 19 | + | return nil | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | // internal registry per view while no GTK binding exists. | |
| @@ -25,12 +25,12 @@ type shortcutRegistry map[string]func() | |||
| 25 | 25 | var viewShortcuts = make(map[*WebView]shortcutRegistry) | |
| 26 | 26 | ||
| 27 | 27 | func getShortcutRegistry(w *WebView) shortcutRegistry { | |
| 28 | - | reg, ok := viewShortcuts[w] | |
| 29 | - | if !ok { | |
| 30 | - | reg = make(shortcutRegistry) | |
| 31 | - | viewShortcuts[w] = reg | |
| 32 | - | } | |
| 33 | - | return reg | |
| 28 | + | reg, ok := viewShortcuts[w] | |
| 29 | + | if !ok { | |
| 30 | + | reg = make(shortcutRegistry) | |
| 31 | + | viewShortcuts[w] = reg | |
| 32 | + | } | |
| 33 | + | return reg | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | // No layout-specific remaps in non-CGO build either. | |
M pkg/webkit/keyboard_cgo.go +206 -182
| @@ -78,193 +78,211 @@ static void attach_mouse_legacy(GtkWidget* widget, unsigned long id) { | |||
| 78 | 78 | import "C" | |
| 79 | 79 | ||
| 80 | 80 | import ( | |
| 81 | - | "fmt" | |
| 82 | - | "log" | |
| 83 | - | "sync/atomic" | |
| 84 | - | "sync" | |
| 85 | - | "time" | |
| 81 | + | "fmt" | |
| 82 | + | "log" | |
| 83 | + | "sync" | |
| 84 | + | "sync/atomic" | |
| 85 | + | "time" | |
| 86 | 86 | ) | |
| 87 | 87 | ||
| 88 | 88 | // Registry of accelerators per view id. | |
| 89 | 89 | type shortcutRegistry map[string]func() | |
| 90 | 90 | ||
| 91 | 91 | var ( | |
| 92 | - | regMu sync.RWMutex | |
| 93 | - | viewShortcuts = make(map[uintptr]shortcutRegistry) | |
| 94 | - | viewIDCounter uint64 | |
| 95 | - | viewByID = make(map[uintptr]*WebView) | |
| 96 | - | lastKeyTime = make(map[uintptr]map[string]time.Time) | |
| 92 | + | regMu sync.RWMutex | |
| 93 | + | viewShortcuts = make(map[uintptr]shortcutRegistry) | |
| 94 | + | viewIDCounter uint64 | |
| 95 | + | viewByID = make(map[uintptr]*WebView) | |
| 96 | + | lastKeyTime = make(map[uintptr]map[string]time.Time) | |
| 97 | 97 | ) | |
| 98 | 98 | ||
| 99 | 99 | // Locale hint function removed; no layout-specific remaps. | |
| 100 | 100 | ||
| 101 | 101 | // RegisterKeyboardShortcut registers a callback under an accelerator string. | |
| 102 | 102 | func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error { | |
| 103 | - | if w == nil || w.destroyed { | |
| 104 | - | return ErrNotImplemented | |
| 105 | - | } | |
| 106 | - | if accel == "" || callback == nil { | |
| 107 | - | return fmt.Errorf("invalid shortcut registration") | |
| 108 | - | } | |
| 109 | - | regMu.Lock() | |
| 110 | - | if _, ok := viewShortcuts[w.id]; !ok { | |
| 111 | - | viewShortcuts[w.id] = make(shortcutRegistry) | |
| 112 | - | } | |
| 113 | - | viewShortcuts[w.id][accel] = callback | |
| 114 | - | regMu.Unlock() | |
| 115 | - | return nil | |
| 103 | + | if w == nil || w.destroyed { | |
| 104 | + | return ErrNotImplemented | |
| 105 | + | } | |
| 106 | + | if accel == "" || callback == nil { | |
| 107 | + | return fmt.Errorf("invalid shortcut registration") | |
| 108 | + | } | |
| 109 | + | regMu.Lock() | |
| 110 | + | if _, ok := viewShortcuts[w.id]; !ok { | |
| 111 | + | viewShortcuts[w.id] = make(shortcutRegistry) | |
| 112 | + | } | |
| 113 | + | viewShortcuts[w.id][accel] = callback | |
| 114 | + | regMu.Unlock() | |
| 115 | + | return nil | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | 118 | //export goOnKeyPress | |
| 119 | 119 | func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) { | |
| 120 | - | // Normalize and dispatch | |
| 121 | - | uid := uintptr(id) | |
| 122 | - | kv := uint(keyval) | |
| 123 | - | st := uint(state) | |
| 124 | - | dispatchAccelerator(uid, kv, st) | |
| 120 | + | // Normalize and dispatch | |
| 121 | + | uid := uintptr(id) | |
| 122 | + | kv := uint(keyval) | |
| 123 | + | st := uint(state) | |
| 124 | + | dispatchAccelerator(uid, kv, st) | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | 127 | func dispatchAccelerator(uid uintptr, keyval uint, state uint) { | |
| 128 | - | ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0 | |
| 129 | - | alt := (state & uint(C.GDK_ALT_MASK)) != 0 | |
| 130 | - | // Map keyval to string names | |
| 131 | - | var keyName string | |
| 132 | - | switch keyval { | |
| 133 | - | case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract), uint(C.GDK_KEY_underscore): | |
| 134 | - | // Normalize minus; log raw detection for diagnostics | |
| 135 | - | log.Printf("[accelerator-raw] minus key detected ctrl=%v alt=%v keyval=0x%x", ctrl, alt, keyval) | |
| 136 | - | keyName = "-" | |
| 137 | - | case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add): | |
| 138 | - | keyName = "=" | |
| 139 | - | case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0): | |
| 140 | - | keyName = "0" | |
| 141 | - | case uint(C.GDK_KEY_Left): | |
| 142 | - | keyName = "ArrowLeft" | |
| 143 | - | case uint(C.GDK_KEY_Right): | |
| 144 | - | keyName = "ArrowRight" | |
| 145 | - | case uint(C.GDK_KEY_F12): | |
| 146 | - | keyName = "F12" | |
| 147 | - | default: | |
| 148 | - | // If Control held, log unknown keyval for diagnostics | |
| 149 | - | if ctrl { | |
| 150 | - | log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval) | |
| 151 | - | } | |
| 152 | - | return | |
| 153 | - | } | |
| 128 | + | ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0 | |
| 129 | + | alt := (state & uint(C.GDK_ALT_MASK)) != 0 | |
| 130 | + | // Map keyval to string names | |
| 131 | + | var keyName string | |
| 132 | + | switch keyval { | |
| 133 | + | case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract), uint(C.GDK_KEY_underscore): | |
| 134 | + | // Normalize minus; log raw detection for diagnostics | |
| 135 | + | log.Printf("[accelerator-raw] minus key detected ctrl=%v alt=%v keyval=0x%x", ctrl, alt, keyval) | |
| 136 | + | keyName = "-" | |
| 137 | + | case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add): | |
| 138 | + | keyName = "=" | |
| 139 | + | case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0): | |
| 140 | + | keyName = "0" | |
| 141 | + | case uint(C.GDK_KEY_Left): | |
| 142 | + | keyName = "ArrowLeft" | |
| 143 | + | case uint(C.GDK_KEY_Right): | |
| 144 | + | keyName = "ArrowRight" | |
| 145 | + | case uint(C.GDK_KEY_F12): | |
| 146 | + | keyName = "F12" | |
| 147 | + | default: | |
| 148 | + | // If Control held, log unknown keyval for diagnostics | |
| 149 | + | if ctrl { | |
| 150 | + | log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval) | |
| 151 | + | } | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | 154 | ||
| 155 | - | // Candidate accelerator strings in order | |
| 156 | - | candidates := []string{keyName} | |
| 157 | - | if ctrl { | |
| 158 | - | // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=) | |
| 159 | - | candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...) | |
| 160 | - | // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-) | |
| 161 | - | candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...) | |
| 162 | - | } | |
| 163 | - | if alt { | |
| 164 | - | candidates = append([]string{"alt+" + keyName}, candidates...) | |
| 165 | - | } | |
| 155 | + | // Candidate accelerator strings in order | |
| 156 | + | candidates := []string{keyName} | |
| 157 | + | if ctrl { | |
| 158 | + | // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=) | |
| 159 | + | candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...) | |
| 160 | + | // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-) | |
| 161 | + | candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...) | |
| 162 | + | } | |
| 163 | + | if alt { | |
| 164 | + | candidates = append([]string{"alt+" + keyName}, candidates...) | |
| 165 | + | } | |
| 166 | 166 | ||
| 167 | - | regMu.RLock() | |
| 168 | - | reg := viewShortcuts[uid] | |
| 169 | - | regMu.RUnlock() | |
| 170 | - | if reg == nil { | |
| 171 | - | return | |
| 172 | - | } | |
| 173 | - | for _, name := range candidates { | |
| 174 | - | if cb, ok := reg[name]; ok { | |
| 175 | - | // Debounce duplicates within 120ms for the same view+key | |
| 176 | - | regMu.Lock() | |
| 177 | - | if _, ok := lastKeyTime[uid]; !ok { lastKeyTime[uid] = make(map[string]time.Time) } | |
| 178 | - | last := lastKeyTime[uid][name] | |
| 179 | - | now := time.Now() | |
| 180 | - | if now.Sub(last) < 120*time.Millisecond { | |
| 181 | - | // Skip duplicate | |
| 182 | - | regMu.Unlock() | |
| 183 | - | return | |
| 184 | - | } | |
| 185 | - | lastKeyTime[uid][name] = now | |
| 186 | - | regMu.Unlock() | |
| 187 | - | log.Printf("[accelerator] %s", name) | |
| 188 | - | cb() | |
| 189 | - | break | |
| 190 | - | } | |
| 191 | - | } | |
| 167 | + | regMu.RLock() | |
| 168 | + | reg := viewShortcuts[uid] | |
| 169 | + | regMu.RUnlock() | |
| 170 | + | if reg == nil { | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | for _, name := range candidates { | |
| 174 | + | if cb, ok := reg[name]; ok { | |
| 175 | + | // Debounce duplicates within 120ms for the same view+key | |
| 176 | + | regMu.Lock() | |
| 177 | + | if _, ok := lastKeyTime[uid]; !ok { | |
| 178 | + | lastKeyTime[uid] = make(map[string]time.Time) | |
| 179 | + | } | |
| 180 | + | last := lastKeyTime[uid][name] | |
| 181 | + | now := time.Now() | |
| 182 | + | if now.Sub(last) < 120*time.Millisecond { | |
| 183 | + | // Skip duplicate | |
| 184 | + | regMu.Unlock() | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | lastKeyTime[uid][name] = now | |
| 188 | + | regMu.Unlock() | |
| 189 | + | log.Printf("[accelerator] %s", name) | |
| 190 | + | cb() | |
| 191 | + | break | |
| 192 | + | } | |
| 193 | + | } | |
| 192 | 194 | } | |
| 193 | 195 | ||
| 194 | 196 | func nextViewID() uintptr { | |
| 195 | - | return uintptr(atomic.AddUint64(&viewIDCounter, 1)) | |
| 197 | + | return uintptr(atomic.AddUint64(&viewIDCounter, 1)) | |
| 196 | 198 | } | |
| 197 | 199 | ||
| 198 | 200 | func registerView(id uintptr, w *WebView) { | |
| 199 | - | regMu.Lock() | |
| 200 | - | viewByID[id] = w | |
| 201 | - | regMu.Unlock() | |
| 201 | + | regMu.Lock() | |
| 202 | + | viewByID[id] = w | |
| 203 | + | regMu.Unlock() | |
| 202 | 204 | } | |
| 203 | 205 | ||
| 204 | 206 | func unregisterView(id uintptr) { | |
| 205 | - | regMu.Lock() | |
| 206 | - | delete(viewByID, id) | |
| 207 | - | delete(viewShortcuts, id) | |
| 208 | - | regMu.Unlock() | |
| 207 | + | regMu.Lock() | |
| 208 | + | delete(viewByID, id) | |
| 209 | + | delete(viewShortcuts, id) | |
| 210 | + | regMu.Unlock() | |
| 209 | 211 | } | |
| 210 | 212 | ||
| 211 | 213 | // AttachKeyboardControllers attaches GTK4 key and mouse controllers to the view widget. | |
| 212 | 214 | func AttachKeyboardControllers(w *WebView) { | |
| 213 | - | if w == nil || w.native == nil || w.native.view == nil { | |
| 214 | - | return | |
| 215 | - | } | |
| 216 | - | C.attach_key_controller(w.native.view, C.ulong(w.id)) | |
| 217 | - | if w.native.win != nil { | |
| 218 | - | C.attach_key_controller(w.native.win, C.ulong(w.id)) | |
| 219 | - | } | |
| 220 | - | C.attach_mouse_gesture(w.native.view, C.ulong(w.id)) | |
| 221 | - | // Legacy controller captures raw button presses (8/9) for back/forward reliably | |
| 222 | - | C.attach_mouse_legacy(w.native.view, C.ulong(w.id)) | |
| 223 | - | log.Printf("[input] GTK4 key/mouse controllers attached") | |
| 215 | + | if w == nil || w.native == nil || w.native.view == nil { | |
| 216 | + | return | |
| 217 | + | } | |
| 218 | + | C.attach_key_controller(w.native.view, C.ulong(w.id)) | |
| 219 | + | if w.native.win != nil { | |
| 220 | + | C.attach_key_controller(w.native.win, C.ulong(w.id)) | |
| 221 | + | } | |
| 222 | + | C.attach_mouse_gesture(w.native.view, C.ulong(w.id)) | |
| 223 | + | // Legacy controller captures raw button presses (8/9) for back/forward reliably | |
| 224 | + | C.attach_mouse_legacy(w.native.view, C.ulong(w.id)) | |
| 225 | + | log.Printf("[input] GTK4 key/mouse controllers attached") | |
| 224 | 226 | } | |
| 225 | 227 | ||
| 226 | 228 | //export goOnScroll | |
| 227 | 229 | func goOnScroll(id C.ulong, dx C.double, dy C.double, state C.GdkModifierType) { | |
| 228 | - | uid := uintptr(id) | |
| 229 | - | regMu.RLock() | |
| 230 | - | vw := viewByID[uid] | |
| 231 | - | regMu.RUnlock() | |
| 232 | - | if vw == nil { return } | |
| 233 | - | // Ctrl+scroll zooms | |
| 234 | - | if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 { | |
| 235 | - | if float64(dy) < 0 { | |
| 236 | - | // scroll up -> zoom in | |
| 237 | - | nz := vw.zoom | |
| 238 | - | if nz <= 0 { nz = 1.0 } | |
| 239 | - | nz *= 1.1 | |
| 240 | - | if nz < 0.25 { nz = 0.25 } | |
| 241 | - | if nz > 5.0 { nz = 5.0 } | |
| 242 | - | _ = vw.SetZoom(nz) | |
| 243 | - | } else if float64(dy) > 0 { | |
| 244 | - | // scroll down -> zoom out | |
| 245 | - | nz := vw.zoom | |
| 246 | - | if nz <= 0 { nz = 1.0 } | |
| 247 | - | nz /= 1.1 | |
| 248 | - | if nz < 0.25 { nz = 0.25 } | |
| 249 | - | if nz > 5.0 { nz = 5.0 } | |
| 250 | - | _ = vw.SetZoom(nz) | |
| 251 | - | } | |
| 252 | - | } | |
| 230 | + | uid := uintptr(id) | |
| 231 | + | regMu.RLock() | |
| 232 | + | vw := viewByID[uid] | |
| 233 | + | regMu.RUnlock() | |
| 234 | + | if vw == nil { | |
| 235 | + | return | |
| 236 | + | } | |
| 237 | + | // Ctrl+scroll zooms | |
| 238 | + | if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 { | |
| 239 | + | if float64(dy) < 0 { | |
| 240 | + | // scroll up -> zoom in | |
| 241 | + | nz := vw.zoom | |
| 242 | + | if nz <= 0 { | |
| 243 | + | nz = 1.0 | |
| 244 | + | } | |
| 245 | + | nz *= 1.1 | |
| 246 | + | if nz < 0.25 { | |
| 247 | + | nz = 0.25 | |
| 248 | + | } | |
| 249 | + | if nz > 5.0 { | |
| 250 | + | nz = 5.0 | |
| 251 | + | } | |
| 252 | + | _ = vw.SetZoom(nz) | |
| 253 | + | } else if float64(dy) > 0 { | |
| 254 | + | // scroll down -> zoom out | |
| 255 | + | nz := vw.zoom | |
| 256 | + | if nz <= 0 { | |
| 257 | + | nz = 1.0 | |
| 258 | + | } | |
| 259 | + | nz /= 1.1 | |
| 260 | + | if nz < 0.25 { | |
| 261 | + | nz = 0.25 | |
| 262 | + | } | |
| 263 | + | if nz > 5.0 { | |
| 264 | + | nz = 5.0 | |
| 265 | + | } | |
| 266 | + | _ = vw.SetZoom(nz) | |
| 267 | + | } | |
| 268 | + | } | |
| 253 | 269 | } | |
| 254 | 270 | ||
| 255 | 271 | //export goOnThemeChanged | |
| 256 | 272 | func goOnThemeChanged(id C.ulong, preferDark C.int) { | |
| 257 | - | uid := uintptr(id) | |
| 258 | - | regMu.RLock() | |
| 259 | - | vw := viewByID[uid] | |
| 260 | - | regMu.RUnlock() | |
| 261 | - | if vw == nil { | |
| 262 | - | return | |
| 263 | - | } | |
| 264 | - | // Inject runtime theme update into the page | |
| 265 | - | dval := "false" | |
| 266 | - | if int(preferDark) != 0 { dval = "true" } | |
| 267 | - | js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs); | |
| 273 | + | uid := uintptr(id) | |
| 274 | + | regMu.RLock() | |
| 275 | + | vw := viewByID[uid] | |
| 276 | + | regMu.RUnlock() | |
| 277 | + | if vw == nil { | |
| 278 | + | return | |
| 279 | + | } | |
| 280 | + | // Inject runtime theme update into the page | |
| 281 | + | dval := "false" | |
| 282 | + | if int(preferDark) != 0 { | |
| 283 | + | dval = "true" | |
| 284 | + | } | |
| 285 | + | js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs); | |
| 268 | 286 | let m=document.querySelector('meta[name="color-scheme"]'); | |
| 269 | 287 | if(!m){ m=document.createElement('meta'); m.name='color-scheme'; document.head.appendChild(m); } | |
| 270 | 288 | m.setAttribute('content', d ? 'dark light' : 'light dark'); | |
| @@ -272,57 +290,63 @@ let s=document.getElementById('__dumber_theme_style'); | |||
| 272 | 290 | if(!s){ s=document.createElement('style'); s.id='__dumber_theme_style'; document.documentElement.appendChild(s); } | |
| 273 | 291 | s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}'; | |
| 274 | 292 | } catch(e) { console.warn('[dumber] theme runtime update failed', e); } })();`, dval) | |
| 275 | - | _ = vw.InjectScript(js) | |
| 293 | + | _ = vw.InjectScript(js) | |
| 276 | 294 | } | |
| 277 | 295 | ||
| 278 | 296 | //export goOnUcmMessage | |
| 279 | 297 | func goOnUcmMessage(id C.ulong, json *C.char) { | |
| 280 | - | uid := uintptr(id) | |
| 281 | - | regMu.RLock() | |
| 282 | - | vw := viewByID[uid] | |
| 283 | - | regMu.RUnlock() | |
| 284 | - | if vw == nil || json == nil { return } | |
| 285 | - | goPayload := C.GoString(json) | |
| 286 | - | vw.dispatchScriptMessage(goPayload) | |
| 298 | + | uid := uintptr(id) | |
| 299 | + | regMu.RLock() | |
| 300 | + | vw := viewByID[uid] | |
| 301 | + | regMu.RUnlock() | |
| 302 | + | if vw == nil || json == nil { | |
| 303 | + | return | |
| 304 | + | } | |
| 305 | + | goPayload := C.GoString(json) | |
| 306 | + | vw.dispatchScriptMessage(goPayload) | |
| 287 | 307 | } | |
| 288 | 308 | ||
| 289 | 309 | //export goOnTitleChanged | |
| 290 | 310 | func goOnTitleChanged(id C.ulong, ctitle *C.char) { | |
| 291 | - | uid := uintptr(id) | |
| 292 | - | regMu.RLock() | |
| 293 | - | vw := viewByID[uid] | |
| 294 | - | regMu.RUnlock() | |
| 295 | - | if vw == nil || ctitle == nil { return } | |
| 296 | - | title := C.GoString(ctitle) | |
| 297 | - | vw.dispatchTitleChanged(title) | |
| 311 | + | uid := uintptr(id) | |
| 312 | + | regMu.RLock() | |
| 313 | + | vw := viewByID[uid] | |
| 314 | + | regMu.RUnlock() | |
| 315 | + | if vw == nil || ctitle == nil { | |
| 316 | + | return | |
| 317 | + | } | |
| 318 | + | title := C.GoString(ctitle) | |
| 319 | + | vw.dispatchTitleChanged(title) | |
| 298 | 320 | } | |
| 299 | 321 | ||
| 300 | 322 | //export goOnURIChanged | |
| 301 | 323 | func goOnURIChanged(id C.ulong, curi *C.char) { | |
| 302 | - | uid := uintptr(id) | |
| 303 | - | regMu.RLock() | |
| 304 | - | vw := viewByID[uid] | |
| 305 | - | regMu.RUnlock() | |
| 306 | - | if vw == nil || curi == nil { return } | |
| 307 | - | uri := C.GoString(curi) | |
| 308 | - | vw.dispatchURIChanged(uri) | |
| 324 | + | uid := uintptr(id) | |
| 325 | + | regMu.RLock() | |
| 326 | + | vw := viewByID[uid] | |
| 327 | + | regMu.RUnlock() | |
| 328 | + | if vw == nil || curi == nil { | |
| 329 | + | return | |
| 330 | + | } | |
| 331 | + | uri := C.GoString(curi) | |
| 332 | + | vw.dispatchURIChanged(uri) | |
| 309 | 333 | } | |
| 310 | 334 | ||
| 311 | 335 | //export goOnButtonPress | |
| 312 | 336 | func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) { | |
| 313 | - | uid := uintptr(id) | |
| 314 | - | btn := uint(button) | |
| 315 | - | log.Printf("[mouse] button=%d state=0x%x", btn, uint(state)) | |
| 316 | - | regMu.RLock() | |
| 317 | - | vw := viewByID[uid] | |
| 318 | - | regMu.RUnlock() | |
| 319 | - | if vw == nil { | |
| 320 | - | return | |
| 321 | - | } | |
| 322 | - | switch btn { | |
| 323 | - | case 8: // Back button on many mice | |
| 324 | - | _ = vw.GoBack() | |
| 325 | - | case 9: // Forward button on many mice | |
| 326 | - | _ = vw.GoForward() | |
| 327 | - | } | |
| 337 | + | uid := uintptr(id) | |
| 338 | + | btn := uint(button) | |
| 339 | + | log.Printf("[mouse] button=%d state=0x%x", btn, uint(state)) | |
| 340 | + | regMu.RLock() | |
| 341 | + | vw := viewByID[uid] | |
| 342 | + | regMu.RUnlock() | |
| 343 | + | if vw == nil { | |
| 344 | + | return | |
| 345 | + | } | |
| 346 | + | switch btn { | |
| 347 | + | case 8: // Back button on many mice | |
| 348 | + | _ = vw.GoBack() | |
| 349 | + | case 9: // Forward button on many mice | |
| 350 | + | _ = vw.GoForward() | |
| 351 | + | } | |
| 328 | 352 | } | |
M pkg/webkit/loop.go +1 -2
| @@ -6,9 +6,8 @@ import "log" | |||
| 6 | 6 | ||
| 7 | 7 | // RunMainLoop is a no-op in non-CGO builds. | |
| 8 | 8 | func RunMainLoop() { | |
| 9 | - | log.Printf("[webkit] RunMainLoop (non-CGO): no GUI loop available") | |
| 9 | + | log.Printf("[webkit] RunMainLoop (non-CGO): no GUI loop available") | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | 12 | // QuitMainLoop is a no-op in non-CGO builds. | |
| 13 | 13 | func QuitMainLoop() {} | |
| 14 | - | ||
M pkg/webkit/loop_cgo.go +7 -7
| @@ -13,15 +13,15 @@ var mainLoop *C.GMainLoop | |||
| 13 | 13 | ||
| 14 | 14 | // RunMainLoop enters the GLib main loop (GTK4) and blocks until quit. | |
| 15 | 15 | func RunMainLoop() { | |
| 16 | - | if mainLoop == nil { | |
| 17 | - | mainLoop = C.g_main_loop_new(nil, C.gboolean(0)) | |
| 18 | - | } | |
| 19 | - | C.g_main_loop_run(mainLoop) | |
| 16 | + | if mainLoop == nil { | |
| 17 | + | mainLoop = C.g_main_loop_new(nil, C.gboolean(0)) | |
| 18 | + | } | |
| 19 | + | C.g_main_loop_run(mainLoop) | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | // QuitMainLoop requests the main loop to exit. | |
| 23 | 23 | func QuitMainLoop() { | |
| 24 | - | if mainLoop != nil { | |
| 25 | - | C.g_main_loop_quit(mainLoop) | |
| 26 | - | } | |
| 24 | + | if mainLoop != nil { | |
| 25 | + | C.g_main_loop_quit(mainLoop) | |
| 26 | + | } | |
| 27 | 27 | } | |
A pkg/webkit/memory.go +275 -0
| @@ -0,0 +1,275 @@ | |||
| 1 | + | //go:build webkit_cgo | |
| 2 | + | ||
| 3 | + | package webkit | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "log" | |
| 8 | + | "os" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ProcessMemoryInfo contains memory information for a WebKit process | |
| 15 | + | type ProcessMemoryInfo struct { | |
| 16 | + | PID int `json:"pid"` | |
| 17 | + | VmRSS int64 `json:"vm_rss"` // Resident Set Size in KB | |
| 18 | + | VmSize int64 `json:"vm_size"` // Virtual Memory Size in KB | |
| 19 | + | VmPeak int64 `json:"vm_peak"` // Peak Virtual Memory in KB | |
| 20 | + | ProcessName string `json:"process_name"` | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // WebViewMemoryManager handles memory monitoring and lifecycle management for WebViews | |
| 24 | + | type WebViewMemoryManager struct { | |
| 25 | + | views map[uintptr]*WebView | |
| 26 | + | enableMonitoring bool | |
| 27 | + | recycleThreshold int | |
| 28 | + | monitoringInterval time.Duration | |
| 29 | + | stopMonitoring chan struct{} | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // NewWebViewMemoryManager creates a new memory manager | |
| 33 | + | func NewWebViewMemoryManager(enableMonitoring bool, recycleThreshold int, monitoringInterval time.Duration) *WebViewMemoryManager { | |
| 34 | + | return &WebViewMemoryManager{ | |
| 35 | + | views: make(map[uintptr]*WebView), | |
| 36 | + | enableMonitoring: enableMonitoring, | |
| 37 | + | recycleThreshold: recycleThreshold, | |
| 38 | + | monitoringInterval: monitoringInterval, | |
| 39 | + | stopMonitoring: make(chan struct{}), | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // RegisterWebView registers a WebView for monitoring | |
| 44 | + | func (mgr *WebViewMemoryManager) RegisterWebView(view *WebView) { | |
| 45 | + | if mgr == nil || view == nil { | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | mgr.views[view.id] = view | |
| 49 | + | ||
| 50 | + | if mgr.enableMonitoring && len(mgr.views) == 1 { | |
| 51 | + | // Start monitoring when first view is registered | |
| 52 | + | go mgr.startMemoryMonitoring() | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // UnregisterWebView removes a WebView from monitoring | |
| 57 | + | func (mgr *WebViewMemoryManager) UnregisterWebView(viewID uintptr) { | |
| 58 | + | if mgr == nil { | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | delete(mgr.views, viewID) | |
| 62 | + | ||
| 63 | + | if len(mgr.views) == 0 { | |
| 64 | + | // Stop monitoring when no views left | |
| 65 | + | close(mgr.stopMonitoring) | |
| 66 | + | mgr.stopMonitoring = make(chan struct{}) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // startMemoryMonitoring starts the background memory monitoring routine | |
| 71 | + | func (mgr *WebViewMemoryManager) startMemoryMonitoring() { | |
| 72 | + | if mgr == nil || mgr.monitoringInterval <= 0 { | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | ticker := time.NewTicker(mgr.monitoringInterval) | |
| 77 | + | defer ticker.Stop() | |
| 78 | + | ||
| 79 | + | for { | |
| 80 | + | select { | |
| 81 | + | case <-ticker.C: | |
| 82 | + | mgr.checkMemoryUsage() | |
| 83 | + | case <-mgr.stopMonitoring: | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // checkMemoryUsage monitors memory usage of WebKit processes | |
| 90 | + | func (mgr *WebViewMemoryManager) checkMemoryUsage() { | |
| 91 | + | if mgr == nil { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | processes, err := mgr.getWebKitProcesses() | |
| 96 | + | if err != nil { | |
| 97 | + | if mgr.enableMonitoring { | |
| 98 | + | log.Printf("[webkit] Error monitoring processes: %v", err) | |
| 99 | + | } | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | totalMemory := int64(0) | |
| 104 | + | for _, proc := range processes { | |
| 105 | + | totalMemory += proc.VmRSS | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | if mgr.enableMonitoring { | |
| 109 | + | log.Printf("[webkit] Memory usage: %d processes, total %.1f MB RSS", | |
| 110 | + | len(processes), float64(totalMemory)/1024.0) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Check for recycling needs | |
| 114 | + | for _, view := range mgr.views { | |
| 115 | + | if view.memStats != nil && mgr.recycleThreshold > 0 { | |
| 116 | + | if view.memStats.pageLoadCount >= mgr.recycleThreshold { | |
| 117 | + | if mgr.enableMonitoring { | |
| 118 | + | log.Printf("[webkit] WebView %d needs recycling (%d pages loaded)", | |
| 119 | + | view.id, view.memStats.pageLoadCount) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // getWebKitProcesses returns memory info for all WebKit-related processes | |
| 127 | + | func (mgr *WebViewMemoryManager) getWebKitProcesses() ([]ProcessMemoryInfo, error) { | |
| 128 | + | var processes []ProcessMemoryInfo | |
| 129 | + | ||
| 130 | + | // Find all WebKit processes by scanning /proc | |
| 131 | + | procDir, err := os.ReadDir("/proc") | |
| 132 | + | if err != nil { | |
| 133 | + | return nil, fmt.Errorf("failed to read /proc: %w", err) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | for _, entry := range procDir { | |
| 137 | + | if !entry.IsDir() { | |
| 138 | + | continue | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | pid, err := strconv.Atoi(entry.Name()) | |
| 142 | + | if err != nil { | |
| 143 | + | continue // Not a PID directory | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Check if this is a WebKit process | |
| 147 | + | cmdlinePath := fmt.Sprintf("/proc/%d/cmdline", pid) | |
| 148 | + | cmdlineBytes, err := os.ReadFile(cmdlinePath) | |
| 149 | + | if err != nil { | |
| 150 | + | continue // Process might have exited | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | cmdline := string(cmdlineBytes) | |
| 154 | + | if !strings.Contains(cmdline, "WebKit") && !strings.Contains(cmdline, "dumber") { | |
| 155 | + | continue // Not a WebKit or dumber process | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // Get memory information | |
| 159 | + | statusPath := fmt.Sprintf("/proc/%d/status", pid) | |
| 160 | + | statusData, err := os.ReadFile(statusPath) | |
| 161 | + | if err != nil { | |
| 162 | + | continue | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | memInfo := mgr.parseMemoryStatus(string(statusData), pid) | |
| 166 | + | if memInfo != nil { | |
| 167 | + | // Extract process name from cmdline | |
| 168 | + | parts := strings.Split(cmdline, "\x00") | |
| 169 | + | if len(parts) > 0 { | |
| 170 | + | memInfo.ProcessName = parts[0] | |
| 171 | + | } | |
| 172 | + | processes = append(processes, *memInfo) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | return processes, nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // parseMemoryStatus parses /proc/[pid]/status for memory information | |
| 180 | + | func (mgr *WebViewMemoryManager) parseMemoryStatus(status string, pid int) *ProcessMemoryInfo { | |
| 181 | + | lines := strings.Split(status, "\n") | |
| 182 | + | info := &ProcessMemoryInfo{PID: pid} | |
| 183 | + | ||
| 184 | + | for _, line := range lines { | |
| 185 | + | if strings.HasPrefix(line, "VmRSS:") { | |
| 186 | + | if val := mgr.extractMemoryValue(line); val > 0 { | |
| 187 | + | info.VmRSS = val | |
| 188 | + | } | |
| 189 | + | } else if strings.HasPrefix(line, "VmSize:") { | |
| 190 | + | if val := mgr.extractMemoryValue(line); val > 0 { | |
| 191 | + | info.VmSize = val | |
| 192 | + | } | |
| 193 | + | } else if strings.HasPrefix(line, "VmPeak:") { | |
| 194 | + | if val := mgr.extractMemoryValue(line); val > 0 { | |
| 195 | + | info.VmPeak = val | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Only return if we found at least VmRSS | |
| 201 | + | if info.VmRSS > 0 { | |
| 202 | + | return info | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | return nil | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // extractMemoryValue extracts memory value in KB from status lines like "VmRSS: 12345 kB" | |
| 209 | + | func (mgr *WebViewMemoryManager) extractMemoryValue(line string) int64 { | |
| 210 | + | parts := strings.Fields(line) | |
| 211 | + | if len(parts) >= 2 { | |
| 212 | + | val, err := strconv.ParseInt(parts[1], 10, 64) | |
| 213 | + | if err == nil { | |
| 214 | + | return val | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | return 0 | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // GetTotalMemoryUsage returns total memory usage of all WebKit processes in MB | |
| 221 | + | func (mgr *WebViewMemoryManager) GetTotalMemoryUsage() (float64, error) { | |
| 222 | + | if mgr == nil { | |
| 223 | + | return 0, fmt.Errorf("memory manager is nil") | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | processes, err := mgr.getWebKitProcesses() | |
| 227 | + | if err != nil { | |
| 228 | + | return 0, err | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | totalKB := int64(0) | |
| 232 | + | for _, proc := range processes { | |
| 233 | + | totalKB += proc.VmRSS | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | return float64(totalKB) / 1024.0, nil | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // GetDetailedMemoryInfo returns detailed memory information for all WebKit processes | |
| 240 | + | func (mgr *WebViewMemoryManager) GetDetailedMemoryInfo() ([]ProcessMemoryInfo, error) { | |
| 241 | + | if mgr == nil { | |
| 242 | + | return nil, fmt.Errorf("memory manager is nil") | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | return mgr.getWebKitProcesses() | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // ShouldRecycleWebView checks if a WebView should be recycled based on memory pressure | |
| 249 | + | func (mgr *WebViewMemoryManager) ShouldRecycleWebView(view *WebView) bool { | |
| 250 | + | if mgr == nil || view == nil || view.memStats == nil { | |
| 251 | + | return false | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | if mgr.recycleThreshold > 0 && view.memStats.pageLoadCount >= mgr.recycleThreshold { | |
| 255 | + | return true | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | return false | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // Global memory manager instance | |
| 262 | + | var globalMemoryManager *WebViewMemoryManager | |
| 263 | + | ||
| 264 | + | // InitializeGlobalMemoryManager initializes the global memory manager | |
| 265 | + | func InitializeGlobalMemoryManager(enableMonitoring bool, recycleThreshold int, monitoringInterval time.Duration) { | |
| 266 | + | if monitoringInterval == 0 { | |
| 267 | + | monitoringInterval = 30 * time.Second // Default 30 seconds | |
| 268 | + | } | |
| 269 | + | globalMemoryManager = NewWebViewMemoryManager(enableMonitoring, recycleThreshold, monitoringInterval) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // GetGlobalMemoryManager returns the global memory manager | |
| 273 | + | func GetGlobalMemoryManager() *WebViewMemoryManager { | |
| 274 | + | return globalMemoryManager | |
| 275 | + | } | |
A pkg/webkit/memory_example.go +189 -0
| @@ -0,0 +1,189 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | // GetMemoryOptimizedConfig returns a Config optimized for low memory usage | |
| 4 | + | // This configuration aims to reduce memory usage by 40-60% compared to defaults | |
| 5 | + | func GetMemoryOptimizedConfig() *Config { | |
| 6 | + | return &Config{ | |
| 7 | + | Memory: MemoryConfig{ | |
| 8 | + | // Limit each WebView to 256MB (default: unlimited) | |
| 9 | + | MemoryLimitMB: 256, | |
| 10 | + | ||
| 11 | + | // Aggressive memory pressure thresholds | |
| 12 | + | ConservativeThreshold: 0.2, // Trigger cleanup at 20% (default: 33%) | |
| 13 | + | StrictThreshold: 0.35, // Aggressive cleanup at 35% (default: 50%) | |
| 14 | + | KillThreshold: 0.8, // Kill process at 80% to prevent OOM | |
| 15 | + | ||
| 16 | + | // Check memory usage every 2 seconds | |
| 17 | + | PollIntervalSeconds: 2.0, | |
| 18 | + | ||
| 19 | + | // Use minimal caching for lowest memory usage | |
| 20 | + | CacheModel: CacheModelDocumentViewer, | |
| 21 | + | ||
| 22 | + | // Disable memory-intensive caches | |
| 23 | + | EnablePageCache: false, // Saves ~30-50MB but slower back/forward | |
| 24 | + | EnableOfflineAppCache: false, // Saves ~10-20MB | |
| 25 | + | ||
| 26 | + | // Recycle WebView after 50 page loads to prevent memory accumulation | |
| 27 | + | ProcessRecycleThreshold: 50, | |
| 28 | + | ||
| 29 | + | // Trigger JavaScript GC every 60 seconds | |
| 30 | + | EnableGCInterval: 60, | |
| 31 | + | ||
| 32 | + | // Enable detailed memory monitoring and logging | |
| 33 | + | EnableMemoryMonitoring: true, | |
| 34 | + | }, | |
| 35 | + | ||
| 36 | + | // Disable developer tools to save memory (unless debugging) | |
| 37 | + | EnableDeveloperExtras: false, | |
| 38 | + | ||
| 39 | + | // Use CPU rendering to save GPU memory (trade-off: slower rendering) | |
| 40 | + | Rendering: RenderingConfig{ | |
| 41 | + | Mode: "cpu", | |
| 42 | + | }, | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // GetBalancedConfig returns a Config with moderate memory optimizations | |
| 47 | + | // This configuration aims to reduce memory usage by 20-30% with minimal performance impact | |
| 48 | + | func GetBalancedConfig() *Config { | |
| 49 | + | return &Config{ | |
| 50 | + | Memory: MemoryConfig{ | |
| 51 | + | // Limit each WebView to 512MB | |
| 52 | + | MemoryLimitMB: 512, | |
| 53 | + | ||
| 54 | + | // Moderate memory pressure thresholds | |
| 55 | + | ConservativeThreshold: 0.25, | |
| 56 | + | StrictThreshold: 0.4, | |
| 57 | + | KillThreshold: 0.7, | |
| 58 | + | ||
| 59 | + | PollIntervalSeconds: 5.0, | |
| 60 | + | ||
| 61 | + | // Use default web browser caching | |
| 62 | + | CacheModel: CacheModelWebBrowser, | |
| 63 | + | ||
| 64 | + | // Keep page cache but disable offline app cache | |
| 65 | + | EnablePageCache: true, | |
| 66 | + | EnableOfflineAppCache: false, | |
| 67 | + | ||
| 68 | + | // Recycle after more page loads | |
| 69 | + | ProcessRecycleThreshold: 100, | |
| 70 | + | ||
| 71 | + | // Less frequent GC | |
| 72 | + | EnableGCInterval: 120, | |
| 73 | + | ||
| 74 | + | EnableMemoryMonitoring: true, | |
| 75 | + | }, | |
| 76 | + | ||
| 77 | + | EnableDeveloperExtras: false, | |
| 78 | + | ||
| 79 | + | // Use auto rendering (WebKit decides GPU vs CPU) | |
| 80 | + | Rendering: RenderingConfig{ | |
| 81 | + | Mode: "auto", | |
| 82 | + | }, | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // GetHighPerformanceConfig returns a Config optimized for performance over memory | |
| 87 | + | // This uses default/high memory settings for maximum performance | |
| 88 | + | func GetHighPerformanceConfig() *Config { | |
| 89 | + | return &Config{ | |
| 90 | + | Memory: MemoryConfig{ | |
| 91 | + | // No memory limit (use system default) | |
| 92 | + | MemoryLimitMB: 0, | |
| 93 | + | ||
| 94 | + | // Relaxed memory pressure thresholds | |
| 95 | + | ConservativeThreshold: 0.4, | |
| 96 | + | StrictThreshold: 0.6, | |
| 97 | + | KillThreshold: 0, // Disable kill threshold | |
| 98 | + | ||
| 99 | + | PollIntervalSeconds: 10.0, | |
| 100 | + | ||
| 101 | + | // Maximum caching for best performance | |
| 102 | + | CacheModel: CacheModelPrimaryWebBrowser, | |
| 103 | + | ||
| 104 | + | // Enable all caches | |
| 105 | + | EnablePageCache: true, | |
| 106 | + | EnableOfflineAppCache: true, | |
| 107 | + | ||
| 108 | + | // Disable recycling | |
| 109 | + | ProcessRecycleThreshold: 0, | |
| 110 | + | ||
| 111 | + | // Disable periodic GC | |
| 112 | + | EnableGCInterval: 0, | |
| 113 | + | ||
| 114 | + | EnableMemoryMonitoring: false, | |
| 115 | + | }, | |
| 116 | + | ||
| 117 | + | EnableDeveloperExtras: true, | |
| 118 | + | ||
| 119 | + | // Use GPU rendering for best performance | |
| 120 | + | Rendering: RenderingConfig{ | |
| 121 | + | Mode: "gpu", | |
| 122 | + | }, | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // ValidateMemoryConfig validates memory configuration parameters | |
| 127 | + | func ValidateMemoryConfig(config *MemoryConfig) error { | |
| 128 | + | if config == nil { | |
| 129 | + | return nil // Use defaults | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | if config.ConservativeThreshold < 0 || config.ConservativeThreshold > 1 { | |
| 133 | + | return &ValidationError{Field: "ConservativeThreshold", Message: "must be between 0.0 and 1.0"} | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | if config.StrictThreshold < 0 || config.StrictThreshold > 1 { | |
| 137 | + | return &ValidationError{Field: "StrictThreshold", Message: "must be between 0.0 and 1.0"} | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | if config.KillThreshold < 0 || config.KillThreshold > 1 { | |
| 141 | + | return &ValidationError{Field: "KillThreshold", Message: "must be between 0.0 and 1.0"} | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | if config.ConservativeThreshold >= config.StrictThreshold { | |
| 145 | + | return &ValidationError{Field: "StrictThreshold", Message: "must be greater than ConservativeThreshold"} | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | if config.PollIntervalSeconds < 0 { | |
| 149 | + | return &ValidationError{Field: "PollIntervalSeconds", Message: "must be non-negative"} | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // ValidationError represents a configuration validation error | |
| 156 | + | type ValidationError struct { | |
| 157 | + | Field string | |
| 158 | + | Message string | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (e *ValidationError) Error() string { | |
| 162 | + | return "validation error for " + e.Field + ": " + e.Message | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // LogMemoryConfiguration logs the current memory configuration for debugging | |
| 166 | + | func LogMemoryConfiguration(config *Config) { | |
| 167 | + | if config == nil || !config.Memory.EnableMemoryMonitoring { | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | log := func(format string, args ...interface{}) { | |
| 172 | + | // Use Go's standard log package | |
| 173 | + | // log.Printf("[webkit-config] "+format, args...) | |
| 174 | + | // For now, we'll just ignore since we don't have access to log here | |
| 175 | + | _ = format | |
| 176 | + | _ = args | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | log("Memory configuration:") | |
| 180 | + | log(" MemoryLimitMB: %d", config.Memory.MemoryLimitMB) | |
| 181 | + | log(" ConservativeThreshold: %.2f", config.Memory.ConservativeThreshold) | |
| 182 | + | log(" StrictThreshold: %.2f", config.Memory.StrictThreshold) | |
| 183 | + | log(" KillThreshold: %.2f", config.Memory.KillThreshold) | |
| 184 | + | log(" CacheModel: %s", config.Memory.CacheModel) | |
| 185 | + | log(" EnablePageCache: %t", config.Memory.EnablePageCache) | |
| 186 | + | log(" EnableOfflineAppCache: %t", config.Memory.EnableOfflineAppCache) | |
| 187 | + | log(" ProcessRecycleThreshold: %d", config.Memory.ProcessRecycleThreshold) | |
| 188 | + | log(" EnableGCInterval: %d seconds", config.Memory.EnableGCInterval) | |
| 189 | + | } | |
M pkg/webkit/scheme_cgo.go +28 -27
| @@ -8,7 +8,7 @@ package webkit | |||
| 8 | 8 | import "C" | |
| 9 | 9 | ||
| 10 | 10 | import ( | |
| 11 | - | "unsafe" | |
| 11 | + | "unsafe" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // Resolver provides bytes and mime for a given custom URI. | |
| @@ -21,30 +21,31 @@ func SetURISchemeResolver(r SchemeResolver) { globalResolver = r } | |||
| 21 | 21 | ||
| 22 | 22 | //export goResolveURIScheme | |
| 23 | 23 | func goResolveURIScheme(curi *C.char, outLen *C.size_t, outMime **C.char) unsafe.Pointer { | |
| 24 | - | if curi == nil || outLen == nil || outMime == nil { return nil } | |
| 25 | - | uri := C.GoString(curi) | |
| 26 | - | if globalResolver == nil { | |
| 27 | - | *outLen = 0 | |
| 28 | - | *outMime = nil | |
| 29 | - | return nil | |
| 30 | - | } | |
| 31 | - | mime, data, ok := globalResolver(uri) | |
| 32 | - | if !ok || len(data) == 0 { | |
| 33 | - | *outLen = 0 | |
| 34 | - | *outMime = nil | |
| 35 | - | return nil | |
| 36 | - | } | |
| 37 | - | buf := C.malloc(C.size_t(len(data))) | |
| 38 | - | if buf == nil { | |
| 39 | - | *outLen = 0 | |
| 40 | - | *outMime = nil | |
| 41 | - | return nil | |
| 42 | - | } | |
| 43 | - | // Copy data into C buffer | |
| 44 | - | dst := (*[1 << 30]byte)(buf)[:len(data):len(data)] | |
| 45 | - | copy(dst, data) | |
| 46 | - | *outLen = C.size_t(len(data)) | |
| 47 | - | *outMime = C.CString(mime) | |
| 48 | - | return buf | |
| 24 | + | if curi == nil || outLen == nil || outMime == nil { | |
| 25 | + | return nil | |
| 26 | + | } | |
| 27 | + | uri := C.GoString(curi) | |
| 28 | + | if globalResolver == nil { | |
| 29 | + | *outLen = 0 | |
| 30 | + | *outMime = nil | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | mime, data, ok := globalResolver(uri) | |
| 34 | + | if !ok || len(data) == 0 { | |
| 35 | + | *outLen = 0 | |
| 36 | + | *outMime = nil | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | buf := C.malloc(C.size_t(len(data))) | |
| 40 | + | if buf == nil { | |
| 41 | + | *outLen = 0 | |
| 42 | + | *outMime = nil | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | // Copy data into C buffer | |
| 46 | + | dst := (*[1 << 30]byte)(buf)[:len(data):len(data)] | |
| 47 | + | copy(dst, data) | |
| 48 | + | *outLen = C.size_t(len(data)) | |
| 49 | + | *outMime = C.CString(mime) | |
| 50 | + | return buf | |
| 49 | 51 | } | |
| 50 | - | ||
M pkg/webkit/script.go +6 -6
| @@ -4,10 +4,10 @@ package webkit | |||
| 4 | 4 | ||
| 5 | 5 | // InjectScript evaluates JavaScript in the active WebView context. | |
| 6 | 6 | func (w *WebView) InjectScript(js string) error { | |
| 7 | - | if w == nil || w.destroyed { | |
| 8 | - | return ErrNotImplemented | |
| 9 | - | } | |
| 10 | - | // Store last script for potential debugging/testing. | |
| 11 | - | _ = js | |
| 12 | - | return nil | |
| 7 | + | if w == nil || w.destroyed { | |
| 8 | + | return ErrNotImplemented | |
| 9 | + | } | |
| 10 | + | // Store last script for potential debugging/testing. | |
| 11 | + | _ = js | |
| 12 | + | return nil | |
| 13 | 13 | } | |
M pkg/webkit/script_cgo.go +18 -18
| @@ -12,22 +12,22 @@ import "C" | |||
| 12 | 12 | import "unsafe" | |
| 13 | 13 | ||
| 14 | 14 | func (w *WebView) InjectScript(js string) error { | |
| 15 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 16 | - | return ErrNotImplemented | |
| 17 | - | } | |
| 18 | - | cjs := C.CString(js) | |
| 19 | - | defer C.free(unsafe.Pointer(cjs)) | |
| 20 | - | // Fire-and-forget evaluation using the modern API | |
| 21 | - | // length: -1 (NUL-terminated), world_name/source_uri: NULL, starting_line_number: 0 | |
| 22 | - | C.webkit_web_view_evaluate_javascript( | |
| 23 | - | w.native.wv, | |
| 24 | - | (*C.gchar)(cjs), | |
| 25 | - | C.gssize(-1), | |
| 26 | - | nil, // world_name | |
| 27 | - | nil, // source_uri | |
| 28 | - | nil, // cancellable | |
| 29 | - | nil, // callback | |
| 30 | - | nil, // user_data | |
| 31 | - | ) | |
| 32 | - | return nil | |
| 15 | + | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 16 | + | return ErrNotImplemented | |
| 17 | + | } | |
| 18 | + | cjs := C.CString(js) | |
| 19 | + | defer C.free(unsafe.Pointer(cjs)) | |
| 20 | + | // Fire-and-forget evaluation using the modern API | |
| 21 | + | // length: -1 (NUL-terminated), world_name/source_uri: NULL, starting_line_number: 0 | |
| 22 | + | C.webkit_web_view_evaluate_javascript( | |
| 23 | + | w.native.wv, | |
| 24 | + | (*C.gchar)(cjs), | |
| 25 | + | C.gssize(-1), | |
| 26 | + | nil, // world_name | |
| 27 | + | nil, // source_uri | |
| 28 | + | nil, // cancellable | |
| 29 | + | nil, // callback | |
| 30 | + | nil, // user_data | |
| 31 | + | ) | |
| 32 | + | return nil | |
| 33 | 33 | } | |
M pkg/webkit/settings.go +69 -21
| @@ -3,37 +3,85 @@ package webkit | |||
| 3 | 3 | // Config holds initialization settings for the WebKit-based browser components. | |
| 4 | 4 | // This will map to WebKit2GTK and GTK settings in the real implementation. | |
| 5 | 5 | type Config struct { | |
| 6 | - | // InitialURL is the first URL to load when creating a WebView. | |
| 7 | - | InitialURL string | |
| 6 | + | // InitialURL is the first URL to load when creating a WebView. | |
| 7 | + | InitialURL string | |
| 8 | 8 | ||
| 9 | - | // UserAgent allows overriding the default user agent string. | |
| 10 | - | UserAgent string | |
| 9 | + | // UserAgent allows overriding the default user agent string. | |
| 10 | + | UserAgent string | |
| 11 | 11 | ||
| 12 | - | // EnableDeveloperExtras enables devtools/inspector. | |
| 13 | - | EnableDeveloperExtras bool | |
| 12 | + | // EnableDeveloperExtras enables devtools/inspector. | |
| 13 | + | EnableDeveloperExtras bool | |
| 14 | 14 | ||
| 15 | - | // ZoomDefault sets an initial zoom factor (1.0 = 100%). | |
| 16 | - | ZoomDefault float64 | |
| 15 | + | // ZoomDefault sets an initial zoom factor (1.0 = 100%). | |
| 16 | + | ZoomDefault float64 | |
| 17 | 17 | ||
| 18 | - | // DataDir is the base directory for persistent website data (cookies, localStorage, etc.). | |
| 19 | - | DataDir string | |
| 20 | - | // CacheDir is the base directory for cache data. | |
| 21 | - | CacheDir string | |
| 18 | + | // DataDir is the base directory for persistent website data (cookies, localStorage, etc.). | |
| 19 | + | DataDir string | |
| 20 | + | // CacheDir is the base directory for cache data. | |
| 21 | + | CacheDir string | |
| 22 | 22 | ||
| 23 | - | // Fonts apply to pages that don't specify fonts (browser defaults). | |
| 24 | - | DefaultSansFont string | |
| 25 | - | DefaultSerifFont string | |
| 26 | - | DefaultMonospaceFont string | |
| 27 | - | DefaultFontSize int // CSS px (~points) | |
| 23 | + | // Fonts apply to pages that don't specify fonts (browser defaults). | |
| 24 | + | DefaultSansFont string | |
| 25 | + | DefaultSerifFont string | |
| 26 | + | DefaultMonospaceFont string | |
| 27 | + | DefaultFontSize int // CSS px (~points) | |
| 28 | 28 | ||
| 29 | - | // Rendering controls GPU/CPU selection and debug options | |
| 30 | - | Rendering RenderingConfig | |
| 29 | + | // Rendering controls GPU/CPU selection and debug options | |
| 30 | + | Rendering RenderingConfig | |
| 31 | + | ||
| 32 | + | // Memory controls memory optimization settings | |
| 33 | + | Memory MemoryConfig | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 33 | 36 | // RenderingConfig controls hardware acceleration preferences. | |
| 34 | 37 | // Mode accepts: "auto" (default), "gpu", or "cpu". | |
| 35 | 38 | // DebugGPU enables compositing indicators if supported. | |
| 36 | 39 | type RenderingConfig struct { | |
| 37 | - | Mode string | |
| 38 | - | DebugGPU bool | |
| 40 | + | Mode string | |
| 41 | + | DebugGPU bool | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // CacheModel represents WebKit cache model options for memory optimization. | |
| 45 | + | type CacheModel string | |
| 46 | + | ||
| 47 | + | const ( | |
| 48 | + | CacheModelDocumentViewer CacheModel = "document_viewer" // Minimal caching, lowest memory | |
| 49 | + | CacheModelWebBrowser CacheModel = "web_browser" // Default, balanced caching | |
| 50 | + | CacheModelPrimaryWebBrowser CacheModel = "primary_web_browser" // Maximum caching, highest memory | |
| 51 | + | ) | |
| 52 | + | ||
| 53 | + | // MemoryConfig controls memory optimization settings. | |
| 54 | + | type MemoryConfig struct { | |
| 55 | + | // MemoryLimitMB sets the maximum memory limit per WebView in MB (0 = unlimited) | |
| 56 | + | MemoryLimitMB int | |
| 57 | + | ||
| 58 | + | // ConservativeThreshold triggers early memory cleanup (0.0-1.0, default 0.33) | |
| 59 | + | ConservativeThreshold float64 | |
| 60 | + | ||
| 61 | + | // StrictThreshold triggers aggressive memory cleanup (0.0-1.0, default 0.5) | |
| 62 | + | StrictThreshold float64 | |
| 63 | + | ||
| 64 | + | // KillThreshold kills processes at this memory usage (0.0-1.0, 0 = disabled) | |
| 65 | + | KillThreshold float64 | |
| 66 | + | ||
| 67 | + | // PollIntervalSeconds sets how often to check memory usage (default 2.0) | |
| 68 | + | PollIntervalSeconds float64 | |
| 69 | + | ||
| 70 | + | // CacheModel determines caching strategy (default: web_browser) | |
| 71 | + | CacheModel CacheModel | |
| 72 | + | ||
| 73 | + | // EnablePageCache enables back/forward page caching (default: true) | |
| 74 | + | EnablePageCache bool | |
| 75 | + | ||
| 76 | + | // EnableOfflineAppCache enables offline application caching (default: true) | |
| 77 | + | EnableOfflineAppCache bool | |
| 78 | + | ||
| 79 | + | // ProcessRecycleThreshold recycles WebView after N page loads (0 = disabled) | |
| 80 | + | ProcessRecycleThreshold int | |
| 81 | + | ||
| 82 | + | // EnableGCInterval enables periodic JS garbage collection in seconds (0 = disabled) | |
| 83 | + | EnableGCInterval int | |
| 84 | + | ||
| 85 | + | // EnableMemoryMonitoring logs memory usage and pressure events | |
| 86 | + | EnableMemoryMonitoring bool | |
| 39 | 87 | } | |
M pkg/webkit/webview.go +71 -53
| @@ -3,81 +3,82 @@ | |||
| 3 | 3 | package webkit | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "fmt" | |
| 7 | - | "log" | |
| 6 | + | "fmt" | |
| 7 | + | "log" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | // WebView represents a browser view powered by WebKit2GTK. | |
| 11 | 11 | // Methods are currently stubs returning ErrNotImplemented to satisfy TDD ordering. | |
| 12 | 12 | type WebView struct { | |
| 13 | - | config *Config | |
| 14 | - | visible bool | |
| 15 | - | zoom float64 | |
| 16 | - | url string | |
| 17 | - | destroyed bool | |
| 18 | - | window *Window | |
| 19 | - | msgHandler func(payload string) | |
| 20 | - | titleHandler func(title string) | |
| 21 | - | uriHandler func(uri string) | |
| 13 | + | config *Config | |
| 14 | + | visible bool | |
| 15 | + | zoom float64 | |
| 16 | + | url string | |
| 17 | + | destroyed bool | |
| 18 | + | window *Window | |
| 19 | + | msgHandler func(payload string) | |
| 20 | + | titleHandler func(title string) | |
| 21 | + | uriHandler func(uri string) | |
| 22 | + | zoomHandler func(level float64) | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | // NewWebView constructs a new WebView instance. | |
| 25 | 26 | func NewWebView(cfg *Config) (*WebView, error) { | |
| 26 | - | if cfg == nil { | |
| 27 | - | cfg = &Config{} | |
| 28 | - | } | |
| 29 | - | log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.") | |
| 30 | - | wv := &WebView{config: cfg} | |
| 31 | - | if cfg.ZoomDefault <= 0 { | |
| 32 | - | wv.zoom = 1.0 | |
| 33 | - | } else { | |
| 34 | - | wv.zoom = cfg.ZoomDefault | |
| 35 | - | } | |
| 36 | - | // Construction succeeds; create a logical window placeholder. | |
| 37 | - | wv.window = &Window{Title: "Dumber Browser"} | |
| 38 | - | return wv, nil | |
| 27 | + | if cfg == nil { | |
| 28 | + | cfg = &Config{} | |
| 29 | + | } | |
| 30 | + | log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.") | |
| 31 | + | wv := &WebView{config: cfg} | |
| 32 | + | if cfg.ZoomDefault <= 0 { | |
| 33 | + | wv.zoom = 1.0 | |
| 34 | + | } else { | |
| 35 | + | wv.zoom = cfg.ZoomDefault | |
| 36 | + | } | |
| 37 | + | // Construction succeeds; create a logical window placeholder. | |
| 38 | + | wv.window = &Window{Title: "Dumber Browser"} | |
| 39 | + | return wv, nil | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 41 | 42 | // LoadURL navigates the webview to the specified URL. | |
| 42 | 43 | func (w *WebView) LoadURL(rawURL string) error { | |
| 43 | - | if w == nil || w.destroyed { | |
| 44 | - | return ErrNotImplemented | |
| 45 | - | } | |
| 46 | - | if rawURL == "" { | |
| 47 | - | return fmt.Errorf("url cannot be empty") | |
| 48 | - | } | |
| 49 | - | // Minimal validation; actual navigation handled by CGO bridge later. | |
| 50 | - | w.url = rawURL | |
| 51 | - | log.Printf("[webkit] LoadURL (non-CGO stub): %s", rawURL) | |
| 52 | - | return nil | |
| 44 | + | if w == nil || w.destroyed { | |
| 45 | + | return ErrNotImplemented | |
| 46 | + | } | |
| 47 | + | if rawURL == "" { | |
| 48 | + | return fmt.Errorf("url cannot be empty") | |
| 49 | + | } | |
| 50 | + | // Minimal validation; actual navigation handled by CGO bridge later. | |
| 51 | + | w.url = rawURL | |
| 52 | + | log.Printf("[webkit] LoadURL (non-CGO stub): %s", rawURL) | |
| 53 | + | return nil | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 55 | 56 | // Show makes the WebView visible. | |
| 56 | 57 | func (w *WebView) Show() error { | |
| 57 | - | if w == nil || w.destroyed { | |
| 58 | - | return ErrNotImplemented | |
| 59 | - | } | |
| 60 | - | w.visible = true | |
| 61 | - | log.Printf("[webkit] Show (non-CGO stub): no native window will appear") | |
| 62 | - | return nil | |
| 58 | + | if w == nil || w.destroyed { | |
| 59 | + | return ErrNotImplemented | |
| 60 | + | } | |
| 61 | + | w.visible = true | |
| 62 | + | log.Printf("[webkit] Show (non-CGO stub): no native window will appear") | |
| 63 | + | return nil | |
| 63 | 64 | } | |
| 64 | 65 | ||
| 65 | 66 | // Hide hides the WebView window. | |
| 66 | 67 | func (w *WebView) Hide() error { | |
| 67 | - | if w == nil || w.destroyed { | |
| 68 | - | return ErrNotImplemented | |
| 69 | - | } | |
| 70 | - | w.visible = false | |
| 71 | - | return nil | |
| 68 | + | if w == nil || w.destroyed { | |
| 69 | + | return ErrNotImplemented | |
| 70 | + | } | |
| 71 | + | w.visible = false | |
| 72 | + | return nil | |
| 72 | 73 | } | |
| 73 | 74 | ||
| 74 | 75 | // Destroy releases native resources. | |
| 75 | 76 | func (w *WebView) Destroy() error { | |
| 76 | - | if w == nil { | |
| 77 | - | return ErrNotImplemented | |
| 78 | - | } | |
| 79 | - | w.destroyed = true | |
| 80 | - | return nil | |
| 77 | + | if w == nil { | |
| 78 | + | return ErrNotImplemented | |
| 79 | + | } | |
| 80 | + | w.destroyed = true | |
| 81 | + | return nil | |
| 81 | 82 | } | |
| 82 | 83 | ||
| 83 | 84 | // Window returns the associated native window wrapper (non-nil). | |
| @@ -88,11 +89,13 @@ func (w *WebView) GetCurrentURL() string { return w.url } | |||
| 88 | 89 | ||
| 89 | 90 | // GoBack is not supported in the non-CGO stub. | |
| 90 | 91 | func (w *WebView) GoBack() error { return ErrNotImplemented } | |
| 92 | + | ||
| 91 | 93 | // GoForward is not supported in the non-CGO stub. | |
| 92 | 94 | func (w *WebView) GoForward() error { return ErrNotImplemented } | |
| 93 | 95 | ||
| 94 | 96 | // ShowDevTools is a no-op in the non-CGO build. | |
| 95 | 97 | func (w *WebView) ShowDevTools() error { return nil } | |
| 98 | + | ||
| 96 | 99 | // CloseDevTools is a no-op in the non-CGO build. | |
| 97 | 100 | func (w *WebView) CloseDevTools() error { return nil } | |
| 98 | 101 | ||
| @@ -100,19 +103,34 @@ func (w *WebView) CloseDevTools() error { return nil } | |||
| 100 | 103 | func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb } | |
| 101 | 104 | ||
| 102 | 105 | func (w *WebView) dispatchScriptMessage(payload string) { | |
| 103 | - | if w != nil && w.msgHandler != nil { w.msgHandler(payload) } | |
| 106 | + | if w != nil && w.msgHandler != nil { | |
| 107 | + | w.msgHandler(payload) | |
| 108 | + | } | |
| 104 | 109 | } | |
| 105 | 110 | ||
| 106 | 111 | // RegisterTitleChangedHandler registers a callback invoked when the page title changes. | |
| 107 | 112 | func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb } | |
| 108 | 113 | ||
| 109 | 114 | func (w *WebView) dispatchTitleChanged(title string) { | |
| 110 | - | if w != nil && w.titleHandler != nil { w.titleHandler(title) } | |
| 115 | + | if w != nil && w.titleHandler != nil { | |
| 116 | + | w.titleHandler(title) | |
| 117 | + | } | |
| 111 | 118 | } | |
| 112 | 119 | ||
| 113 | 120 | // RegisterURIChangedHandler registers a callback invoked when the current page URI changes. | |
| 114 | 121 | func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb } | |
| 115 | 122 | ||
| 116 | 123 | func (w *WebView) dispatchURIChanged(uri string) { | |
| 117 | - | if w != nil && w.uriHandler != nil { w.uriHandler(uri) } | |
| 124 | + | if w != nil && w.uriHandler != nil { | |
| 125 | + | w.uriHandler(uri) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // RegisterZoomChangedHandler registers a callback invoked when zoom level changes. | |
| 130 | + | func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb } | |
| 131 | + | ||
| 132 | + | func (w *WebView) dispatchZoomChanged(level float64) { | |
| 133 | + | if w != nil && w.zoomHandler != nil { | |
| 134 | + | w.zoomHandler(level) | |
| 135 | + | } | |
| 118 | 136 | } | |
M pkg/webkit/webview_cgo.go +649 -303
| @@ -23,7 +23,7 @@ static WebKitWebView* as_webview(GtkWidget* w) { return WEBKIT_WEB_VIEW(w); } | |||
| 23 | 23 | static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy); | |
| 24 | 24 | ||
| 25 | 25 | // Construct a WebKitWebView via g_object_new with a fresh UserContentManager. | |
| 26 | - | static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const char* cache_dir, const char* cookie_path, WebKitUserContentManager** out_ucm) { | |
| 26 | + | static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const char* cache_dir, const char* cookie_path, WebKitUserContentManager** out_ucm, WebKitMemoryPressureSettings* pressure_settings) { | |
| 27 | 27 | WebKitUserContentManager* u = webkit_user_content_manager_new(); | |
| 28 | 28 | if (!u) return NULL; | |
| 29 | 29 | // WebKitGTK 6: create NetworkSession with data/cache directories | |
| @@ -31,6 +31,11 @@ static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const c | |||
| 31 | 31 | data_dir ? data_dir : "", | |
| 32 | 32 | cache_dir ? cache_dir : ""); | |
| 33 | 33 | if (!sess) { g_object_unref(u); return NULL; } | |
| 34 | + | // Apply memory pressure settings to the session when provided | |
| 35 | + | if (pressure_settings) { | |
| 36 | + | // WebKitGTK 6.0 API: setter expects only the settings pointer | |
| 37 | + | webkit_network_session_set_memory_pressure_settings(pressure_settings); | |
| 38 | + | } | |
| 34 | 39 | // Configure cookie persistence and policy (no third-party by default) | |
| 35 | 40 | WebKitCookieManager* cm = webkit_network_session_get_cookie_manager(sess); | |
| 36 | 41 | if (cm) { | |
| @@ -231,18 +236,106 @@ static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy) { | |||
| 231 | 236 | (void)policy; // Not supported in this header version | |
| 232 | 237 | #endif | |
| 233 | 238 | } | |
| 239 | + | ||
| 240 | + | // Memory pressure settings helpers (version-guarded) | |
| 241 | + | static WebKitMemoryPressureSettings* create_memory_pressure_settings( | |
| 242 | + | unsigned int memory_limit_mb, | |
| 243 | + | double conservative_threshold, | |
| 244 | + | double strict_threshold, | |
| 245 | + | double kill_threshold, | |
| 246 | + | double poll_interval) { | |
| 247 | + | // If headers don't expose memory pressure APIs, return NULL to signal unsupported | |
| 248 | + | #if WEBKIT_CHECK_VERSION(2,44,0) | |
| 249 | + | WebKitMemoryPressureSettings* settings = webkit_memory_pressure_settings_new(); | |
| 250 | + | if (!settings) return NULL; | |
| 251 | + | ||
| 252 | + | if (memory_limit_mb > 0) { | |
| 253 | + | webkit_memory_pressure_settings_set_memory_limit(settings, (guint)(memory_limit_mb * 1024 * 1024)); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | webkit_memory_pressure_settings_set_conservative_threshold(settings, conservative_threshold); | |
| 257 | + | webkit_memory_pressure_settings_set_strict_threshold(settings, strict_threshold); | |
| 258 | + | ||
| 259 | + | if (kill_threshold > 0) { | |
| 260 | + | webkit_memory_pressure_settings_set_kill_threshold(settings, kill_threshold); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | webkit_memory_pressure_settings_set_poll_interval(settings, poll_interval); | |
| 264 | + | ||
| 265 | + | return settings; | |
| 266 | + | #else | |
| 267 | + | (void)memory_limit_mb; (void)conservative_threshold; (void)strict_threshold; (void)kill_threshold; (void)poll_interval; | |
| 268 | + | return NULL; | |
| 269 | + | #endif | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // Cache model configuration | |
| 273 | + | static void maybe_set_cache_model(WebKitWebContext* context, int model) { | |
| 274 | + | if (!context) return; | |
| 275 | + | WebKitCacheModel cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; // default | |
| 276 | + | ||
| 277 | + | switch (model) { | |
| 278 | + | case 0: cache_model = WEBKIT_CACHE_MODEL_DOCUMENT_VIEWER; break; | |
| 279 | + | case 1: cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; break; | |
| 280 | + | case 2: | |
| 281 | + | #ifdef WEBKIT_CACHE_MODEL_PRIMARY_WEB_BROWSER | |
| 282 | + | cache_model = WEBKIT_CACHE_MODEL_PRIMARY_WEB_BROWSER; | |
| 283 | + | #else | |
| 284 | + | cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; | |
| 285 | + | #endif | |
| 286 | + | break; | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | webkit_web_context_set_cache_model(context, cache_model); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // Additional settings for memory optimization | |
| 293 | + | static void maybe_set_page_cache(WebKitSettings* settings, int enable) { | |
| 294 | + | #if WEBKIT_CHECK_VERSION(2,6,0) | |
| 295 | + | if (!settings) return; | |
| 296 | + | webkit_settings_set_enable_page_cache(settings, enable ? TRUE : FALSE); | |
| 297 | + | #else | |
| 298 | + | (void)settings; (void)enable; | |
| 299 | + | #endif | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // Offline Web Application Cache is deprecated in modern WebKit; keep as no-op to avoid warnings. | |
| 303 | + | static void maybe_set_offline_app_cache(WebKitSettings* settings, int enable) { | |
| 304 | + | (void)settings; (void)enable; | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // Apply memory pressure settings to context if available | |
| 308 | + | static void maybe_set_memory_pressure_settings(WebKitWebContext* context, WebKitMemoryPressureSettings* settings) { | |
| 309 | + | // No-op wrapper kept for compatibility; settings applied to NetworkSession instead. | |
| 310 | + | (void)context; (void)settings; | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // Trigger JavaScript GC if supported by headers | |
| 314 | + | static void maybe_collect_js(WebKitWebContext* context) { | |
| 315 | + | // Disabled by default due to API availability variance across 6.0 builds. | |
| 316 | + | (void)context; | |
| 317 | + | } | |
| 234 | 318 | */ | |
| 235 | 319 | import "C" | |
| 236 | 320 | ||
| 237 | 321 | import ( | |
| 238 | - | "errors" | |
| 239 | - | "log" | |
| 240 | - | "os" | |
| 241 | - | "path/filepath" | |
| 242 | - | "runtime" | |
| 243 | - | "unsafe" | |
| 322 | + | "errors" | |
| 323 | + | "log" | |
| 324 | + | "os" | |
| 325 | + | "path/filepath" | |
| 326 | + | "runtime" | |
| 327 | + | "time" | |
| 328 | + | "unsafe" | |
| 244 | 329 | ) | |
| 245 | 330 | ||
| 331 | + | // Helper function to convert bool to int for C interop | |
| 332 | + | func boolToInt(b bool) int { | |
| 333 | + | if b { | |
| 334 | + | return 1 | |
| 335 | + | } | |
| 336 | + | return 0 | |
| 337 | + | } | |
| 338 | + | ||
| 246 | 339 | const omniboxDefaultScript = `(() => { | |
| 247 | 340 | try { | |
| 248 | 341 | if (window.__dumber_omnibox_loaded) return; window.__dumber_omnibox_loaded = true; | |
| @@ -274,261 +367,408 @@ const omniboxDefaultScript = `(() => { | |||
| 274 | 367 | })();` | |
| 275 | 368 | ||
| 276 | 369 | type nativeView struct { | |
| 277 | - | win *C.GtkWidget | |
| 278 | - | view *C.GtkWidget | |
| 279 | - | wv *C.WebKitWebView | |
| 280 | - | ucm *C.WebKitUserContentManager | |
| 370 | + | win *C.GtkWidget | |
| 371 | + | view *C.GtkWidget | |
| 372 | + | wv *C.WebKitWebView | |
| 373 | + | ucm *C.WebKitUserContentManager | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | type memoryStats struct { | |
| 377 | + | pageLoadCount int | |
| 378 | + | lastGCTime time.Time | |
| 379 | + | memoryPressureSettings *C.WebKitMemoryPressureSettings | |
| 281 | 380 | } | |
| 282 | 381 | ||
| 283 | 382 | // WebView represents a browser view powered by WebKit2GTK. | |
| 284 | 383 | type WebView struct { | |
| 285 | - | config *Config | |
| 286 | - | zoom float64 | |
| 287 | - | url string | |
| 288 | - | destroyed bool | |
| 289 | - | native *nativeView | |
| 290 | - | window *Window | |
| 291 | - | id uintptr | |
| 292 | - | msgHandler func(payload string) | |
| 293 | - | titleHandler func(title string) | |
| 294 | - | uriHandler func(uri string) | |
| 295 | - | zoomHandler func(level float64) | |
| 384 | + | config *Config | |
| 385 | + | zoom float64 | |
| 386 | + | url string | |
| 387 | + | destroyed bool | |
| 388 | + | native *nativeView | |
| 389 | + | window *Window | |
| 390 | + | id uintptr | |
| 391 | + | msgHandler func(payload string) | |
| 392 | + | titleHandler func(title string) | |
| 393 | + | uriHandler func(uri string) | |
| 394 | + | zoomHandler func(level float64) | |
| 395 | + | memStats *memoryStats | |
| 396 | + | gcTicker *time.Ticker | |
| 397 | + | gcDone chan struct{} | |
| 296 | 398 | } | |
| 297 | 399 | ||
| 298 | 400 | // NewWebView constructs a new WebView instance with native WebKit2GTK widgets. | |
| 299 | 401 | func NewWebView(cfg *Config) (*WebView, error) { | |
| 300 | - | log.Printf("[webkit] Initializing GTK and creating WebView (CGO)") | |
| 301 | - | if C.gtk_init_check() == 0 { | |
| 302 | - | return nil, errors.New("failed to initialize GTK") | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | if cfg == nil { | |
| 306 | - | cfg = &Config{} | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | // Prepare persistent website data/caches | |
| 310 | - | dataDir := cfg.DataDir | |
| 311 | - | cacheDir := cfg.CacheDir | |
| 312 | - | if dataDir == "" { dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data") } | |
| 313 | - | if cacheDir == "" { cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache") } | |
| 314 | - | _ = os.MkdirAll(dataDir, 0o755) | |
| 315 | - | _ = os.MkdirAll(cacheDir, 0o755) | |
| 316 | - | ||
| 317 | - | cData := C.CString(dataDir) | |
| 318 | - | cCache := C.CString(cacheDir) | |
| 319 | - | cookieFile := filepath.Join(dataDir, "cookies.db") | |
| 320 | - | cCookie := C.CString(cookieFile) | |
| 321 | - | defer C.free(unsafe.Pointer(cData)) | |
| 322 | - | defer C.free(unsafe.Pointer(cCache)) | |
| 323 | - | defer C.free(unsafe.Pointer(cCookie)) | |
| 324 | - | // Use default WebContext to register the custom URI scheme handler; the | |
| 325 | - | // WebView itself is created with a WebKitNetworkSession for persistence. | |
| 326 | - | ctx := C.webkit_web_context_get_default() | |
| 327 | - | ||
| 328 | - | // Register custom URI scheme handler for "dumb://" | |
| 329 | - | { | |
| 330 | - | sch := C.CString("dumb") | |
| 331 | - | C.webkit_web_context_register_uri_scheme(ctx, sch, (C.WebKitURISchemeRequestCallback)(C.on_uri_scheme), nil, nil) | |
| 332 | - | C.free(unsafe.Pointer(sch)) | |
| 333 | - | } | |
| 334 | - | // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session | |
| 335 | - | ||
| 336 | - | // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style) | |
| 337 | - | var createdUcm *C.WebKitUserContentManager | |
| 338 | - | viewWidget := C.new_webview_with_ucm_and_session(cData, cCache, cCookie, &createdUcm) | |
| 339 | - | if viewWidget == nil { | |
| 340 | - | return nil, errors.New("failed to create WebKitWebView") | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | // Create a top-level window to host the view | |
| 344 | - | win := C.new_window() | |
| 345 | - | if win == nil { | |
| 346 | - | return nil, errors.New("failed to create GtkWindow") | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | // Pack view widget into the window | |
| 350 | - | // GTK4: containers removed; use gtk_window_set_child | |
| 351 | - | C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), viewWidget) | |
| 352 | - | C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768) | |
| 353 | - | C.connect_destroy_quit(win) | |
| 354 | - | ||
| 355 | - | v := &WebView{ | |
| 356 | - | config: cfg, | |
| 357 | - | zoom: 1.0, | |
| 358 | - | native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm}, | |
| 359 | - | window: &Window{win: win}, | |
| 360 | - | } | |
| 361 | - | // Assign an ID for accelerator dispatch | |
| 362 | - | v.id = nextViewID() | |
| 363 | - | registerView(v.id, v) | |
| 364 | - | // Attach GTK4 input controllers (keyboard, mouse) | |
| 365 | - | AttachKeyboardControllers(v) | |
| 366 | - | ||
| 367 | - | // Watch for GTK theme changes and propagate to page at runtime | |
| 368 | - | if settings := C.gtk_settings_get_default(); settings != nil { | |
| 369 | - | cprop := C.CString("notify::gtk-application-prefer-dark-theme") | |
| 370 | - | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(settings)), cprop, C.GCallback(C.on_theme_changed), C.gpointer(v.id), nil, 0) | |
| 371 | - | C.free(unsafe.Pointer(cprop)) | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | // Native zoom shortcuts (independent of app services) | |
| 375 | - | // Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset | |
| 376 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() { | |
| 377 | - | nz := v.zoom | |
| 378 | - | if nz <= 0 { nz = 1.0 } | |
| 379 | - | nz *= 1.1 | |
| 380 | - | if nz < 0.25 { nz = 0.25 } | |
| 381 | - | if nz > 5.0 { nz = 5.0 } | |
| 382 | - | _ = v.SetZoom(nz) | |
| 383 | - | }) | |
| 384 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+plus", func() { | |
| 385 | - | nz := v.zoom | |
| 386 | - | if nz <= 0 { nz = 1.0 } | |
| 387 | - | nz *= 1.1 | |
| 388 | - | if nz < 0.25 { nz = 0.25 } | |
| 389 | - | if nz > 5.0 { nz = 5.0 } | |
| 390 | - | _ = v.SetZoom(nz) | |
| 391 | - | }) | |
| 392 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl-", func() { | |
| 393 | - | nz := v.zoom | |
| 394 | - | if nz <= 0 { nz = 1.0 } | |
| 395 | - | nz /= 1.1 | |
| 396 | - | if nz < 0.25 { nz = 0.25 } | |
| 397 | - | if nz > 5.0 { nz = 5.0 } | |
| 398 | - | _ = v.SetZoom(nz) | |
| 399 | - | }) | |
| 400 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+0", func() { _ = v.SetZoom(1.0) }) | |
| 401 | - | // Navigation with Alt+Arrow keys | |
| 402 | - | _ = v.RegisterKeyboardShortcut("alt+ArrowLeft", func() { _ = v.GoBack() }) | |
| 403 | - | _ = v.RegisterKeyboardShortcut("alt+ArrowRight", func() { _ = v.GoForward() }) | |
| 404 | - | // Update window title when page title changes | |
| 405 | - | cNotifyTitle1 := C.CString("notify::title") | |
| 406 | - | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyTitle1, C.GCallback(C.on_title_notify), C.gpointer(unsafe.Pointer(win)), nil, 0) | |
| 407 | - | C.free(unsafe.Pointer(cNotifyTitle1)) | |
| 408 | - | // Also dispatch title change to Go with view id | |
| 409 | - | cNotifyTitle2 := C.CString("notify::title") | |
| 410 | - | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyTitle2, C.GCallback(C.on_title_notify_id), C.gpointer(v.id), nil, 0) | |
| 411 | - | C.free(unsafe.Pointer(cNotifyTitle2)) | |
| 412 | - | // Notify URI changes to Go to keep current URL in sync | |
| 413 | - | cNotifyURI := C.CString("notify::uri") | |
| 414 | - | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyURI, C.GCallback(C.on_uri_notify), C.gpointer(v.id), nil, 0) | |
| 415 | - | C.free(unsafe.Pointer(cNotifyURI)) | |
| 416 | - | // Apply hardware acceleration and related settings based on cfg.Rendering | |
| 417 | - | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 418 | - | // Hardware acceleration policy (guarded by version) | |
| 419 | - | switch cfg.Rendering.Mode { | |
| 420 | - | case "gpu": | |
| 421 | - | C.maybe_set_hw_policy(settings, 1) | |
| 422 | - | C.maybe_set_webgl(settings, 1) | |
| 423 | - | C.maybe_set_canvas_accel(settings, 1) | |
| 424 | - | case "cpu": | |
| 425 | - | C.maybe_set_hw_policy(settings, 2) | |
| 426 | - | C.maybe_set_webgl(settings, 0) | |
| 427 | - | C.maybe_set_canvas_accel(settings, 0) | |
| 428 | - | default: // auto | |
| 429 | - | C.maybe_set_hw_policy(settings, 0) | |
| 430 | - | C.maybe_set_webgl(settings, 1) | |
| 431 | - | C.maybe_set_canvas_accel(settings, 1) | |
| 432 | - | } | |
| 433 | - | // Optional compositing indicators for debugging (guarded by version) | |
| 434 | - | if cfg.Rendering.DebugGPU { C.maybe_set_draw_indicators(settings, 1) } | |
| 435 | - | // Reduce media pipeline churn by requiring a user gesture for playback | |
| 436 | - | C.maybe_set_media_user_gesture(settings, 1) | |
| 437 | - | // Enable trackpad back/forward gestures when available | |
| 438 | - | C.maybe_set_back_forward_gestures(settings, 1) | |
| 439 | - | } | |
| 440 | - | ||
| 441 | - | if cfg.ZoomDefault > 0 { | |
| 442 | - | v.zoom = cfg.ZoomDefault | |
| 443 | - | C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom)) | |
| 444 | - | } | |
| 445 | - | if cfg.EnableDeveloperExtras { | |
| 446 | - | settings := C.webkit_web_view_get_settings(v.native.wv) | |
| 447 | - | if settings != nil { | |
| 448 | - | C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1)) | |
| 449 | - | } | |
| 450 | - | } | |
| 451 | - | // Apply default fonts if provided in cfg | |
| 452 | - | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 453 | - | if cfg.DefaultSansFont != "" { | |
| 454 | - | csans := C.CString(cfg.DefaultSansFont) | |
| 455 | - | C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans)) | |
| 456 | - | C.free(unsafe.Pointer(csans)) | |
| 457 | - | // Also set default font family to sans | |
| 458 | - | csans2 := C.CString(cfg.DefaultSansFont) | |
| 459 | - | C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2)) | |
| 460 | - | C.free(unsafe.Pointer(csans2)) | |
| 461 | - | } | |
| 462 | - | if cfg.DefaultSerifFont != "" { | |
| 463 | - | cserif := C.CString(cfg.DefaultSerifFont) | |
| 464 | - | C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif)) | |
| 465 | - | C.free(unsafe.Pointer(cserif)) | |
| 466 | - | } | |
| 467 | - | if cfg.DefaultMonospaceFont != "" { | |
| 468 | - | cmono := C.CString(cfg.DefaultMonospaceFont) | |
| 469 | - | C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono)) | |
| 470 | - | C.free(unsafe.Pointer(cmono)) | |
| 471 | - | } | |
| 472 | - | if cfg.DefaultFontSize > 0 { | |
| 473 | - | C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize)) | |
| 474 | - | } | |
| 475 | - | } | |
| 476 | - | if cfg.InitialURL != "" { | |
| 477 | - | _ = v.LoadURL(cfg.InitialURL) | |
| 478 | - | } | |
| 479 | - | // Ensure native resources are released if user forgets to call Destroy | |
| 480 | - | runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() }) | |
| 481 | - | log.Printf("[webkit] WebView created (CGO)") | |
| 482 | - | ||
| 483 | - | // Initialize UCM scripts and handlers | |
| 484 | - | if v.native != nil && v.native.ucm != nil { | |
| 485 | - | v.enableUserContentManager() | |
| 486 | - | } | |
| 487 | - | return v, nil | |
| 402 | + | log.Printf("[webkit] Initializing GTK and creating WebView (CGO)") | |
| 403 | + | if C.gtk_init_check() == 0 { | |
| 404 | + | return nil, errors.New("failed to initialize GTK") | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | if cfg == nil { | |
| 408 | + | cfg = &Config{} | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // Prepare persistent website data/caches | |
| 412 | + | dataDir := cfg.DataDir | |
| 413 | + | cacheDir := cfg.CacheDir | |
| 414 | + | if dataDir == "" { | |
| 415 | + | dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data") | |
| 416 | + | } | |
| 417 | + | if cacheDir == "" { | |
| 418 | + | cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache") | |
| 419 | + | } | |
| 420 | + | _ = os.MkdirAll(dataDir, 0o755) | |
| 421 | + | _ = os.MkdirAll(cacheDir, 0o755) | |
| 422 | + | ||
| 423 | + | cData := C.CString(dataDir) | |
| 424 | + | cCache := C.CString(cacheDir) | |
| 425 | + | cookieFile := filepath.Join(dataDir, "cookies.db") | |
| 426 | + | cCookie := C.CString(cookieFile) | |
| 427 | + | defer C.free(unsafe.Pointer(cData)) | |
| 428 | + | defer C.free(unsafe.Pointer(cCache)) | |
| 429 | + | defer C.free(unsafe.Pointer(cCookie)) | |
| 430 | + | // Use default WebContext to register the custom URI scheme handler; the | |
| 431 | + | // WebView itself is created with a WebKitNetworkSession for persistence. | |
| 432 | + | ctx := C.webkit_web_context_get_default() | |
| 433 | + | ||
| 434 | + | // Apply cache model to WebContext | |
| 435 | + | // Default to WEBKIT_CACHE_MODEL_WEB_BROWSER to keep caching enabled | |
| 436 | + | cacheModel := 1 | |
| 437 | + | if cfg.Memory.CacheModel != "" { | |
| 438 | + | switch cfg.Memory.CacheModel { | |
| 439 | + | case CacheModelWebBrowser: | |
| 440 | + | cacheModel = 1 | |
| 441 | + | case CacheModelPrimaryWebBrowser: | |
| 442 | + | cacheModel = 2 | |
| 443 | + | default: // CacheModelDocumentViewer or unset | |
| 444 | + | cacheModel = 0 | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | C.maybe_set_cache_model(ctx, C.int(cacheModel)) | |
| 448 | + | ||
| 449 | + | // Configure memory pressure settings if specified | |
| 450 | + | var pressureSettings *C.WebKitMemoryPressureSettings | |
| 451 | + | // Only configure memory pressure if explicitly requested via config | |
| 452 | + | if cfg.Memory.MemoryLimitMB > 0 || cfg.Memory.ConservativeThreshold > 0 || cfg.Memory.StrictThreshold > 0 || cfg.Memory.KillThreshold > 0 || cfg.Memory.PollIntervalSeconds > 0 { | |
| 453 | + | memLimitMB := cfg.Memory.MemoryLimitMB | |
| 454 | + | conservativeThreshold := cfg.Memory.ConservativeThreshold | |
| 455 | + | strictThreshold := cfg.Memory.StrictThreshold | |
| 456 | + | killThreshold := cfg.Memory.KillThreshold | |
| 457 | + | pollInterval := cfg.Memory.PollIntervalSeconds | |
| 458 | + | ||
| 459 | + | pressureSettings = C.create_memory_pressure_settings( | |
| 460 | + | C.uint(memLimitMB), | |
| 461 | + | C.double(conservativeThreshold), | |
| 462 | + | C.double(strictThreshold), | |
| 463 | + | C.double(killThreshold), | |
| 464 | + | C.double(pollInterval)) | |
| 465 | + | ||
| 466 | + | if pressureSettings != nil { | |
| 467 | + | C.maybe_set_memory_pressure_settings(ctx, pressureSettings) | |
| 468 | + | if cfg.Memory.EnableMemoryMonitoring { | |
| 469 | + | log.Printf("[webkit] Memory pressure settings applied: limit=%dMB, conservative=%.2f, strict=%.2f, poll=%.1fs", | |
| 470 | + | memLimitMB, conservativeThreshold, strictThreshold, pollInterval) | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | // Register custom URI scheme handler for "dumb://" | |
| 476 | + | { | |
| 477 | + | sch := C.CString("dumb") | |
| 478 | + | C.webkit_web_context_register_uri_scheme(ctx, sch, (C.WebKitURISchemeRequestCallback)(C.on_uri_scheme), nil, nil) | |
| 479 | + | C.free(unsafe.Pointer(sch)) | |
| 480 | + | } | |
| 481 | + | // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session | |
| 482 | + | ||
| 483 | + | // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style) | |
| 484 | + | var createdUcm *C.WebKitUserContentManager | |
| 485 | + | viewWidget := C.new_webview_with_ucm_and_session(cData, cCache, cCookie, &createdUcm, pressureSettings) | |
| 486 | + | if viewWidget == nil { | |
| 487 | + | return nil, errors.New("failed to create WebKitWebView") | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | // Create a top-level window to host the view | |
| 491 | + | win := C.new_window() | |
| 492 | + | if win == nil { | |
| 493 | + | return nil, errors.New("failed to create GtkWindow") | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // Pack view widget into the window | |
| 497 | + | // GTK4: containers removed; use gtk_window_set_child | |
| 498 | + | C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), viewWidget) | |
| 499 | + | C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768) | |
| 500 | + | C.connect_destroy_quit(win) | |
| 501 | + | ||
| 502 | + | v := &WebView{ | |
| 503 | + | config: cfg, | |
| 504 | + | zoom: 1.0, | |
| 505 | + | native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm}, | |
| 506 | + | window: &Window{win: win}, | |
| 507 | + | memStats: &memoryStats{ | |
| 508 | + | pageLoadCount: 0, | |
| 509 | + | lastGCTime: time.Now(), | |
| 510 | + | memoryPressureSettings: pressureSettings, | |
| 511 | + | }, | |
| 512 | + | gcDone: make(chan struct{}), | |
| 513 | + | } | |
| 514 | + | // Assign an ID for accelerator dispatch | |
| 515 | + | v.id = nextViewID() | |
| 516 | + | registerView(v.id, v) | |
| 517 | + | ||
| 518 | + | // Register with global memory manager if monitoring is enabled | |
| 519 | + | if cfg.Memory.EnableMemoryMonitoring { | |
| 520 | + | if globalMemoryManager == nil { | |
| 521 | + | InitializeGlobalMemoryManager( | |
| 522 | + | cfg.Memory.EnableMemoryMonitoring, | |
| 523 | + | cfg.Memory.ProcessRecycleThreshold, | |
| 524 | + | 30*time.Second, // Default monitoring interval | |
| 525 | + | ) | |
| 526 | + | } | |
| 527 | + | if globalMemoryManager != nil { | |
| 528 | + | globalMemoryManager.RegisterWebView(v) | |
| 529 | + | } | |
| 530 | + | } | |
| 531 | + | // Attach GTK4 input controllers (keyboard, mouse) | |
| 532 | + | AttachKeyboardControllers(v) | |
| 533 | + | ||
| 534 | + | // Watch for GTK theme changes and propagate to page at runtime | |
| 535 | + | if settings := C.gtk_settings_get_default(); settings != nil { | |
| 536 | + | cprop := C.CString("notify::gtk-application-prefer-dark-theme") | |
| 537 | + | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(settings)), cprop, C.GCallback(C.on_theme_changed), C.gpointer(v.id), nil, 0) | |
| 538 | + | C.free(unsafe.Pointer(cprop)) | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | // Native zoom shortcuts (independent of app services) | |
| 542 | + | // Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset | |
| 543 | + | _ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() { | |
| 544 | + | nz := v.zoom | |
| 545 | + | if nz <= 0 { | |
| 546 | + | nz = 1.0 | |
| 547 | + | } | |
| 548 | + | nz *= 1.1 | |
| 549 | + | if nz < 0.25 { | |
| 550 | + | nz = 0.25 | |
| 551 | + | } | |
| 552 | + | if nz > 5.0 { | |
| 553 | + | nz = 5.0 | |
| 554 | + | } | |
| 555 | + | _ = v.SetZoom(nz) | |
| 556 | + | }) | |
| 557 | + | _ = v.RegisterKeyboardShortcut("cmdorctrl+plus", func() { | |
| 558 | + | nz := v.zoom | |
| 559 | + | if nz <= 0 { | |
| 560 | + | nz = 1.0 | |
| 561 | + | } | |
| 562 | + | nz *= 1.1 | |
| 563 | + | if nz < 0.25 { | |
| 564 | + | nz = 0.25 | |
| 565 | + | } | |
| 566 | + | if nz > 5.0 { | |
| 567 | + | nz = 5.0 | |
| 568 | + | } | |
| 569 | + | _ = v.SetZoom(nz) | |
| 570 | + | }) | |
| 571 | + | _ = v.RegisterKeyboardShortcut("cmdorctrl-", func() { | |
| 572 | + | nz := v.zoom | |
| 573 | + | if nz <= 0 { | |
| 574 | + | nz = 1.0 | |
| 575 | + | } | |
| 576 | + | nz /= 1.1 | |
| 577 | + | if nz < 0.25 { | |
| 578 | + | nz = 0.25 | |
| 579 | + | } | |
| 580 | + | if nz > 5.0 { | |
| 581 | + | nz = 5.0 | |
| 582 | + | } | |
| 583 | + | _ = v.SetZoom(nz) | |
| 584 | + | }) | |
| 585 | + | _ = v.RegisterKeyboardShortcut("cmdorctrl+0", func() { _ = v.SetZoom(1.0) }) | |
| 586 | + | // Navigation with Alt+Arrow keys | |
| 587 | + | _ = v.RegisterKeyboardShortcut("alt+ArrowLeft", func() { _ = v.GoBack() }) | |
| 588 | + | _ = v.RegisterKeyboardShortcut("alt+ArrowRight", func() { _ = v.GoForward() }) | |
| 589 | + | // Update window title when page title changes | |
| 590 | + | cNotifyTitle1 := C.CString("notify::title") | |
| 591 | + | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyTitle1, C.GCallback(C.on_title_notify), C.gpointer(unsafe.Pointer(win)), nil, 0) | |
| 592 | + | C.free(unsafe.Pointer(cNotifyTitle1)) | |
| 593 | + | // Also dispatch title change to Go with view id | |
| 594 | + | cNotifyTitle2 := C.CString("notify::title") | |
| 595 | + | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyTitle2, C.GCallback(C.on_title_notify_id), C.gpointer(v.id), nil, 0) | |
| 596 | + | C.free(unsafe.Pointer(cNotifyTitle2)) | |
| 597 | + | // Notify URI changes to Go to keep current URL in sync | |
| 598 | + | cNotifyURI := C.CString("notify::uri") | |
| 599 | + | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyURI, C.GCallback(C.on_uri_notify), C.gpointer(v.id), nil, 0) | |
| 600 | + | C.free(unsafe.Pointer(cNotifyURI)) | |
| 601 | + | // Apply hardware acceleration and related settings based on cfg.Rendering | |
| 602 | + | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 603 | + | // Hardware acceleration policy (guarded by version) | |
| 604 | + | switch cfg.Rendering.Mode { | |
| 605 | + | case "gpu": | |
| 606 | + | C.maybe_set_hw_policy(settings, 1) | |
| 607 | + | C.maybe_set_webgl(settings, 1) | |
| 608 | + | C.maybe_set_canvas_accel(settings, 1) | |
| 609 | + | case "cpu": | |
| 610 | + | C.maybe_set_hw_policy(settings, 2) | |
| 611 | + | C.maybe_set_webgl(settings, 0) | |
| 612 | + | C.maybe_set_canvas_accel(settings, 0) | |
| 613 | + | default: // auto | |
| 614 | + | C.maybe_set_hw_policy(settings, 0) | |
| 615 | + | C.maybe_set_webgl(settings, 1) | |
| 616 | + | C.maybe_set_canvas_accel(settings, 1) | |
| 617 | + | } | |
| 618 | + | // Optional compositing indicators for debugging (guarded by version) | |
| 619 | + | if cfg.Rendering.DebugGPU { | |
| 620 | + | C.maybe_set_draw_indicators(settings, 1) | |
| 621 | + | } | |
| 622 | + | // Reduce media pipeline churn by requiring a user gesture for playback | |
| 623 | + | C.maybe_set_media_user_gesture(settings, 1) | |
| 624 | + | // Enable trackpad back/forward gestures when available | |
| 625 | + | C.maybe_set_back_forward_gestures(settings, 1) | |
| 626 | + | ||
| 627 | + | // Keep page cache enabled by default for performance | |
| 628 | + | enablePageCache := true | |
| 629 | + | if cfg.Memory.EnablePageCache { | |
| 630 | + | enablePageCache = true | |
| 631 | + | } | |
| 632 | + | C.maybe_set_page_cache(settings, C.int(boolToInt(enablePageCache))) | |
| 633 | + | ||
| 634 | + | // Offline app cache is deprecated; keep as no-op | |
| 635 | + | C.maybe_set_offline_app_cache(settings, C.int(0)) | |
| 636 | + | ||
| 637 | + | if cfg.Memory.EnableMemoryMonitoring { | |
| 638 | + | log.Printf("[webkit] Memory settings applied: page_cache=%v", enablePageCache) | |
| 639 | + | } | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | if cfg.ZoomDefault > 0 { | |
| 643 | + | v.zoom = cfg.ZoomDefault | |
| 644 | + | C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom)) | |
| 645 | + | } | |
| 646 | + | if cfg.EnableDeveloperExtras { | |
| 647 | + | settings := C.webkit_web_view_get_settings(v.native.wv) | |
| 648 | + | if settings != nil { | |
| 649 | + | C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1)) | |
| 650 | + | } | |
| 651 | + | } | |
| 652 | + | // Apply default fonts if provided in cfg | |
| 653 | + | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 654 | + | if cfg.DefaultSansFont != "" { | |
| 655 | + | csans := C.CString(cfg.DefaultSansFont) | |
| 656 | + | C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans)) | |
| 657 | + | C.free(unsafe.Pointer(csans)) | |
| 658 | + | // Also set default font family to sans | |
| 659 | + | csans2 := C.CString(cfg.DefaultSansFont) | |
| 660 | + | C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2)) | |
| 661 | + | C.free(unsafe.Pointer(csans2)) | |
| 662 | + | } | |
| 663 | + | if cfg.DefaultSerifFont != "" { | |
| 664 | + | cserif := C.CString(cfg.DefaultSerifFont) | |
| 665 | + | C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif)) | |
| 666 | + | C.free(unsafe.Pointer(cserif)) | |
| 667 | + | } | |
| 668 | + | if cfg.DefaultMonospaceFont != "" { | |
| 669 | + | cmono := C.CString(cfg.DefaultMonospaceFont) | |
| 670 | + | C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono)) | |
| 671 | + | C.free(unsafe.Pointer(cmono)) | |
| 672 | + | } | |
| 673 | + | if cfg.DefaultFontSize > 0 { | |
| 674 | + | C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize)) | |
| 675 | + | } | |
| 676 | + | } | |
| 677 | + | if cfg.InitialURL != "" { | |
| 678 | + | _ = v.LoadURL(cfg.InitialURL) | |
| 679 | + | } | |
| 680 | + | // Ensure native resources are released if user forgets to call Destroy | |
| 681 | + | runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() }) | |
| 682 | + | log.Printf("[webkit] WebView created (CGO)") | |
| 683 | + | ||
| 684 | + | // Initialize UCM scripts and handlers | |
| 685 | + | if v.native != nil && v.native.ucm != nil { | |
| 686 | + | v.enableUserContentManager() | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | // Start periodic JavaScript garbage collection if configured | |
| 690 | + | if cfg.Memory.EnableGCInterval > 0 { | |
| 691 | + | v.startPeriodicGC(time.Duration(cfg.Memory.EnableGCInterval) * time.Second) | |
| 692 | + | if cfg.Memory.EnableMemoryMonitoring { | |
| 693 | + | log.Printf("[webkit] Periodic GC enabled: interval=%ds", cfg.Memory.EnableGCInterval) | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | return v, nil | |
| 488 | 698 | } | |
| 489 | 699 | ||
| 490 | 700 | func (w *WebView) LoadURL(rawURL string) error { | |
| 491 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 492 | - | return ErrNotImplemented | |
| 493 | - | } | |
| 494 | - | w.url = rawURL | |
| 495 | - | curl := C.CString(rawURL) | |
| 496 | - | defer C.free(unsafe.Pointer(curl)) | |
| 497 | - | C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl)) | |
| 498 | - | log.Printf("[webkit] LoadURL: %s", rawURL) | |
| 499 | - | return nil | |
| 701 | + | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 702 | + | return ErrNotImplemented | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | // Track page loads for process recycling | |
| 706 | + | if w.memStats != nil { | |
| 707 | + | w.memStats.pageLoadCount++ | |
| 708 | + | ||
| 709 | + | // Check if we need to recycle this WebView | |
| 710 | + | if w.config.Memory.ProcessRecycleThreshold > 0 && | |
| 711 | + | w.memStats.pageLoadCount >= w.config.Memory.ProcessRecycleThreshold { | |
| 712 | + | if w.config.Memory.EnableMemoryMonitoring { | |
| 713 | + | log.Printf("[webkit] Process recycle threshold reached (%d pages), consider creating new WebView", | |
| 714 | + | w.memStats.pageLoadCount) | |
| 715 | + | } | |
| 716 | + | } | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | w.url = rawURL | |
| 720 | + | curl := C.CString(rawURL) | |
| 721 | + | defer C.free(unsafe.Pointer(curl)) | |
| 722 | + | C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl)) | |
| 723 | + | log.Printf("[webkit] LoadURL: %s", rawURL) | |
| 724 | + | return nil | |
| 500 | 725 | } | |
| 501 | 726 | ||
| 502 | 727 | func (w *WebView) Show() error { | |
| 503 | - | if w == nil || w.destroyed || w.native == nil || w.native.win == nil { | |
| 504 | - | return ErrNotImplemented | |
| 505 | - | } | |
| 506 | - | C.gtk_widget_set_visible(w.native.view, C.gboolean(1)) | |
| 507 | - | C.gtk_widget_set_visible(w.native.win, C.gboolean(1)) | |
| 508 | - | C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | |
| 509 | - | log.Printf("[webkit] Show window") | |
| 510 | - | return nil | |
| 728 | + | if w == nil || w.destroyed || w.native == nil || w.native.win == nil { | |
| 729 | + | return ErrNotImplemented | |
| 730 | + | } | |
| 731 | + | C.gtk_widget_set_visible(w.native.view, C.gboolean(1)) | |
| 732 | + | C.gtk_widget_set_visible(w.native.win, C.gboolean(1)) | |
| 733 | + | C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | |
| 734 | + | log.Printf("[webkit] Show window") | |
| 735 | + | return nil | |
| 511 | 736 | } | |
| 512 | 737 | ||
| 513 | 738 | func (w *WebView) Hide() error { | |
| 514 | - | if w == nil || w.destroyed || w.native == nil || w.native.win == nil { | |
| 515 | - | return ErrNotImplemented | |
| 516 | - | } | |
| 517 | - | C.gtk_widget_set_visible(w.native.view, C.gboolean(0)) | |
| 518 | - | C.gtk_widget_set_visible(w.native.win, C.gboolean(0)) | |
| 519 | - | return nil | |
| 739 | + | if w == nil || w.destroyed || w.native == nil || w.native.win == nil { | |
| 740 | + | return ErrNotImplemented | |
| 741 | + | } | |
| 742 | + | C.gtk_widget_set_visible(w.native.view, C.gboolean(0)) | |
| 743 | + | C.gtk_widget_set_visible(w.native.win, C.gboolean(0)) | |
| 744 | + | return nil | |
| 520 | 745 | } | |
| 521 | 746 | ||
| 522 | 747 | func (w *WebView) Destroy() error { | |
| 523 | - | if w == nil || w.native == nil || w.native.win == nil { | |
| 524 | - | return ErrNotImplemented | |
| 525 | - | } | |
| 526 | - | // GTK4: destroy windows via gtk_window_destroy | |
| 527 | - | C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | |
| 528 | - | w.destroyed = true | |
| 529 | - | unregisterView(w.id) | |
| 530 | - | log.Printf("[webkit] Destroy window") | |
| 531 | - | return nil | |
| 748 | + | if w == nil || w.native == nil || w.native.win == nil { | |
| 749 | + | return ErrNotImplemented | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | // Stop periodic GC if running | |
| 753 | + | w.stopPeriodicGC() | |
| 754 | + | ||
| 755 | + | // Clean up memory pressure settings | |
| 756 | + | if w.memStats != nil && w.memStats.memoryPressureSettings != nil { | |
| 757 | + | // WebKit will clean up the memory pressure settings when context is destroyed | |
| 758 | + | w.memStats.memoryPressureSettings = nil | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | // Unregister from memory manager | |
| 762 | + | if globalMemoryManager != nil { | |
| 763 | + | globalMemoryManager.UnregisterWebView(w.id) | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | // GTK4: destroy windows via gtk_window_destroy | |
| 767 | + | C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | |
| 768 | + | w.destroyed = true | |
| 769 | + | unregisterView(w.id) | |
| 770 | + | log.Printf("[webkit] Destroy window") | |
| 771 | + | return nil | |
| 532 | 772 | } | |
| 533 | 773 | ||
| 534 | 774 | // Window returns the associated native window. | |
| @@ -536,103 +776,209 @@ func (w *WebView) Window() *Window { return w.window } | |||
| 536 | 776 | ||
| 537 | 777 | // GetCurrentURL returns the current URI from WebKit. | |
| 538 | 778 | func (w *WebView) GetCurrentURL() string { | |
| 539 | - | if w == nil || w.native == nil || w.native.wv == nil { return "" } | |
| 540 | - | uri := C.webkit_web_view_get_uri(w.native.wv) | |
| 541 | - | if uri == nil { return "" } | |
| 542 | - | return C.GoString((*C.char)(unsafe.Pointer(uri))) | |
| 779 | + | if w == nil || w.native == nil || w.native.wv == nil { | |
| 780 | + | return "" | |
| 781 | + | } | |
| 782 | + | uri := C.webkit_web_view_get_uri(w.native.wv) | |
| 783 | + | if uri == nil { | |
| 784 | + | return "" | |
| 785 | + | } | |
| 786 | + | return C.GoString((*C.char)(unsafe.Pointer(uri))) | |
| 543 | 787 | } | |
| 544 | 788 | ||
| 545 | 789 | func (w *WebView) GoBack() error { | |
| 546 | - | if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented } | |
| 547 | - | C.webkit_web_view_go_back(w.native.wv) | |
| 548 | - | return nil | |
| 790 | + | if w == nil || w.native == nil || w.native.wv == nil { | |
| 791 | + | return ErrNotImplemented | |
| 792 | + | } | |
| 793 | + | C.webkit_web_view_go_back(w.native.wv) | |
| 794 | + | return nil | |
| 549 | 795 | } | |
| 550 | 796 | ||
| 551 | 797 | func (w *WebView) GoForward() error { | |
| 552 | - | if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented } | |
| 553 | - | C.webkit_web_view_go_forward(w.native.wv) | |
| 554 | - | return nil | |
| 798 | + | if w == nil || w.native == nil || w.native.wv == nil { | |
| 799 | + | return ErrNotImplemented | |
| 800 | + | } | |
| 801 | + | C.webkit_web_view_go_forward(w.native.wv) | |
| 802 | + | return nil | |
| 555 | 803 | } | |
| 556 | 804 | ||
| 557 | 805 | // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message. | |
| 558 | 806 | func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb } | |
| 559 | 807 | ||
| 560 | 808 | func (w *WebView) dispatchScriptMessage(payload string) { | |
| 561 | - | if w != nil && w.msgHandler != nil { w.msgHandler(payload) } | |
| 809 | + | if w != nil && w.msgHandler != nil { | |
| 810 | + | w.msgHandler(payload) | |
| 811 | + | } | |
| 562 | 812 | } | |
| 563 | 813 | ||
| 564 | 814 | // RegisterTitleChangedHandler registers a callback invoked when the page title changes. | |
| 565 | 815 | func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb } | |
| 566 | 816 | ||
| 567 | 817 | func (w *WebView) dispatchTitleChanged(title string) { | |
| 568 | - | if w != nil && w.titleHandler != nil { w.titleHandler(title) } | |
| 818 | + | if w != nil && w.titleHandler != nil { | |
| 819 | + | w.titleHandler(title) | |
| 820 | + | } | |
| 569 | 821 | } | |
| 570 | 822 | ||
| 571 | 823 | // RegisterURIChangedHandler registers a callback invoked when the current page URI changes. | |
| 572 | 824 | func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb } | |
| 573 | 825 | ||
| 574 | 826 | func (w *WebView) dispatchURIChanged(uri string) { | |
| 575 | - | if w != nil && w.uriHandler != nil { w.uriHandler(uri) } | |
| 827 | + | if w != nil && w.uriHandler != nil { | |
| 828 | + | w.uriHandler(uri) | |
| 829 | + | } | |
| 576 | 830 | } | |
| 577 | 831 | ||
| 578 | 832 | // RegisterZoomChangedHandler registers a callback invoked when zoom level changes. | |
| 579 | 833 | func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb } | |
| 580 | 834 | ||
| 581 | 835 | func (w *WebView) dispatchZoomChanged(level float64) { | |
| 582 | - | if w != nil && w.zoomHandler != nil { w.zoomHandler(level) } | |
| 836 | + | if w != nil && w.zoomHandler != nil { | |
| 837 | + | w.zoomHandler(level) | |
| 838 | + | } | |
| 583 | 839 | } | |
| 584 | 840 | ||
| 585 | 841 | // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script. | |
| 586 | 842 | func (w *WebView) enableUserContentManager() { | |
| 587 | - | if w == nil || w.native == nil || w.native.ucm == nil { return } | |
| 588 | - | // Register handler "dumber" | |
| 589 | - | cname := C.CString("dumber") | |
| 590 | - | ok := C.register_ucm_handler(w.native.ucm, cname, C.ulong(w.id)) | |
| 591 | - | if ok == 0 { | |
| 592 | - | log.Printf("[webkit] Failed to register UCM handler; fallback bridge will handle messages") | |
| 593 | - | } | |
| 594 | - | C.free(unsafe.Pointer(cname)) | |
| 843 | + | if w == nil || w.native == nil || w.native.ucm == nil { | |
| 844 | + | return | |
| 845 | + | } | |
| 846 | + | // Register handler "dumber" | |
| 847 | + | cname := C.CString("dumber") | |
| 848 | + | ok := C.register_ucm_handler(w.native.ucm, cname, C.ulong(w.id)) | |
| 849 | + | if ok == 0 { | |
| 850 | + | log.Printf("[webkit] Failed to register UCM handler; fallback bridge will handle messages") | |
| 851 | + | } | |
| 852 | + | C.free(unsafe.Pointer(cname)) | |
| 853 | + | ||
| 854 | + | // Inject color-scheme preference script at document-start to inform sites of system theme | |
| 855 | + | preferDark := C.gtk_prefers_dark() != 0 | |
| 856 | + | if preferDark { | |
| 857 | + | log.Printf("[theme] GTK prefers: dark") | |
| 858 | + | } else { | |
| 859 | + | log.Printf("[theme] GTK prefers: light") | |
| 860 | + | } | |
| 861 | + | var schemeJS string | |
| 862 | + | if preferDark { | |
| 863 | + | schemeJS = "(() => { try { const d=true; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='dark light'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:dark;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();" | |
| 864 | + | } else { | |
| 865 | + | schemeJS = "(() => { try { const d=false; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='light dark'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:light;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();" | |
| 866 | + | } | |
| 867 | + | cScheme := C.CString(schemeJS) | |
| 868 | + | defer C.free(unsafe.Pointer(cScheme)) | |
| 869 | + | schemeScript := C.webkit_user_script_new((*C.gchar)(cScheme), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |
| 870 | + | if schemeScript != nil { | |
| 871 | + | C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript) | |
| 872 | + | C.webkit_user_script_unref(schemeScript) | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | // Add user script at document-start | |
| 876 | + | src := C.CString(omniboxDefaultScript) | |
| 877 | + | defer C.free(unsafe.Pointer(src)) | |
| 878 | + | script := C.webkit_user_script_new((*C.gchar)(src), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |
| 879 | + | if script != nil { | |
| 880 | + | C.webkit_user_content_manager_add_script(w.native.ucm, script) | |
| 881 | + | C.webkit_user_script_unref(script) | |
| 882 | + | log.Printf("[webkit] UCM omnibox script injected at document-start") | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | // Inject Wails runtime fetch interceptor for homepage bridging | |
| 886 | + | wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();` | |
| 887 | + | cWails := C.CString(wailsBridge) | |
| 888 | + | defer C.free(unsafe.Pointer(cWails)) | |
| 889 | + | wailsScript := C.webkit_user_script_new((*C.gchar)(cWails), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |
| 890 | + | if wailsScript != nil { | |
| 891 | + | C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript) | |
| 892 | + | C.webkit_user_script_unref(wailsScript) | |
| 893 | + | } | |
| 894 | + | ||
| 895 | + | // No JS fallback bridge — native UCM is active | |
| 896 | + | } | |
| 595 | 897 | ||
| 596 | - | // Inject color-scheme preference script at document-start to inform sites of system theme | |
| 597 | - | preferDark := C.gtk_prefers_dark() != 0 | |
| 598 | - | if preferDark { | |
| 599 | - | log.Printf("[theme] GTK prefers: dark") | |
| 600 | - | } else { | |
| 601 | - | log.Printf("[theme] GTK prefers: light") | |
| 602 | - | } | |
| 603 | - | var schemeJS string | |
| 604 | - | if preferDark { | |
| 605 | - | schemeJS = "(() => { try { const d=true; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='dark light'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:dark;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();" | |
| 606 | - | } else { | |
| 607 | - | schemeJS = "(() => { try { const d=false; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='light dark'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:light;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();" | |
| 608 | - | } | |
| 609 | - | cScheme := C.CString(schemeJS) | |
| 610 | - | defer C.free(unsafe.Pointer(cScheme)) | |
| 611 | - | schemeScript := C.webkit_user_script_new((*C.gchar)(cScheme), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |
| 612 | - | if schemeScript != nil { | |
| 613 | - | C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript) | |
| 614 | - | C.webkit_user_script_unref(schemeScript) | |
| 615 | - | } | |
| 898 | + | // startPeriodicGC starts a ticker to trigger JavaScript garbage collection periodically | |
| 899 | + | func (w *WebView) startPeriodicGC(interval time.Duration) { | |
| 900 | + | if w == nil || w.gcTicker != nil { | |
| 901 | + | return // Already running or invalid | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | w.gcTicker = time.NewTicker(interval) | |
| 905 | + | ||
| 906 | + | go func() { | |
| 907 | + | defer func() { | |
| 908 | + | if w.gcTicker != nil { | |
| 909 | + | w.gcTicker.Stop() | |
| 910 | + | w.gcTicker = nil | |
| 911 | + | } | |
| 912 | + | }() | |
| 913 | + | ||
| 914 | + | for { | |
| 915 | + | select { | |
| 916 | + | case <-w.gcTicker.C: | |
| 917 | + | w.triggerGC() | |
| 918 | + | case <-w.gcDone: | |
| 919 | + | return | |
| 920 | + | } | |
| 921 | + | } | |
| 922 | + | }() | |
| 923 | + | } | |
| 616 | 924 | ||
| 617 | - | // Add user script at document-start | |
| 618 | - | src := C.CString(omniboxDefaultScript) | |
| 619 | - | defer C.free(unsafe.Pointer(src)) | |
| 620 | - | script := C.webkit_user_script_new((*C.gchar)(src), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |
| 621 | - | if script != nil { | |
| 622 | - | C.webkit_user_content_manager_add_script(w.native.ucm, script) | |
| 623 | - | C.webkit_user_script_unref(script) | |
| 624 | - | log.Printf("[webkit] UCM omnibox script injected at document-start") | |
| 625 | - | } | |
| 925 | + | // stopPeriodicGC stops the periodic garbage collection | |
| 926 | + | func (w *WebView) stopPeriodicGC() { | |
| 927 | + | if w == nil { | |
| 928 | + | return | |
| 929 | + | } | |
| 930 | + | ||
| 931 | + | if w.gcDone != nil { | |
| 932 | + | close(w.gcDone) | |
| 933 | + | w.gcDone = nil | |
| 934 | + | } | |
| 935 | + | ||
| 936 | + | if w.gcTicker != nil { | |
| 937 | + | w.gcTicker.Stop() | |
| 938 | + | w.gcTicker = nil | |
| 939 | + | } | |
| 940 | + | } | |
| 626 | 941 | ||
| 627 | - | // Inject Wails runtime fetch interceptor for homepage bridging | |
| 628 | - | wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();` | |
| 629 | - | cWails := C.CString(wailsBridge) | |
| 630 | - | defer C.free(unsafe.Pointer(cWails)) | |
| 631 | - | wailsScript := C.webkit_user_script_new((*C.gchar)(cWails), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |
| 632 | - | if wailsScript != nil { | |
| 633 | - | C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript) | |
| 634 | - | C.webkit_user_script_unref(wailsScript) | |
| 635 | - | } | |
| 942 | + | // triggerGC manually triggers JavaScript garbage collection | |
| 943 | + | func (w *WebView) triggerGC() { | |
| 944 | + | if w == nil || w.memStats == nil { | |
| 945 | + | return | |
| 946 | + | } | |
| 947 | + | ||
| 948 | + | // Get default WebContext and trigger GC | |
| 949 | + | ctx := C.webkit_web_context_get_default() | |
| 950 | + | if ctx != nil { | |
| 951 | + | C.maybe_collect_js(ctx) | |
| 952 | + | w.memStats.lastGCTime = time.Now() | |
| 953 | + | ||
| 954 | + | if w.config.Memory.EnableMemoryMonitoring { | |
| 955 | + | log.Printf("[webkit] JavaScript GC triggered (page loads: %d)", w.memStats.pageLoadCount) | |
| 956 | + | } | |
| 957 | + | } | |
| 958 | + | } | |
| 959 | + | ||
| 960 | + | // GetMemoryStats returns current memory statistics for this WebView | |
| 961 | + | func (w *WebView) GetMemoryStats() map[string]interface{} { | |
| 962 | + | if w == nil || w.memStats == nil { | |
| 963 | + | return nil | |
| 964 | + | } | |
| 965 | + | ||
| 966 | + | return map[string]interface{}{ | |
| 967 | + | "page_load_count": w.memStats.pageLoadCount, | |
| 968 | + | "last_gc_time": w.memStats.lastGCTime, | |
| 969 | + | "has_memory_pressure_settings": w.memStats.memoryPressureSettings != nil, | |
| 970 | + | } | |
| 971 | + | } | |
| 972 | + | ||
| 973 | + | // TriggerMemoryCleanup manually triggers garbage collection and memory cleanup | |
| 974 | + | func (w *WebView) TriggerMemoryCleanup() { | |
| 975 | + | if w == nil { | |
| 976 | + | return | |
| 977 | + | } | |
| 978 | + | ||
| 979 | + | w.triggerGC() | |
| 636 | 980 | ||
| 637 | - | // No JS fallback bridge — native UCM is active | |
| 981 | + | if w.config.Memory.EnableMemoryMonitoring { | |
| 982 | + | log.Printf("[webkit] Manual memory cleanup triggered") | |
| 983 | + | } | |
| 638 | 984 | } | |
M pkg/webkit/window.go +4 -4
| @@ -5,16 +5,16 @@ package webkit | |||
| 5 | 5 | // Window represents a top-level application window hosting a WebView. | |
| 6 | 6 | // Stub for future GTK application window management. | |
| 7 | 7 | type Window struct { | |
| 8 | - | Title string | |
| 8 | + | Title string | |
| 9 | 9 | } | |
| 10 | 10 | ||
| 11 | 11 | // NewWindow constructs a Window. In real implementation, this would create a GTK window. | |
| 12 | 12 | func NewWindow(title string) (*Window, error) { | |
| 13 | - | _ = title | |
| 14 | - | return &Window{Title: title}, ErrNotImplemented | |
| 13 | + | _ = title | |
| 14 | + | return &Window{Title: title}, ErrNotImplemented | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | // SetTitle updates the window title (no-op in non-CGO build). | |
| 18 | 18 | func (w *Window) SetTitle(title string) { | |
| 19 | - | w.Title = title | |
| 19 | + | w.Title = title | |
| 20 | 20 | } | |
M pkg/webkit/window_cgo.go +19 -17
| @@ -12,27 +12,29 @@ import "C" | |||
| 12 | 12 | import "unsafe" | |
| 13 | 13 | ||
| 14 | 14 | type Window struct { | |
| 15 | - | win *C.GtkWidget | |
| 15 | + | win *C.GtkWidget | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | func NewWindow(title string) (*Window, error) { | |
| 19 | - | if C.gtk_init_check() == 0 { | |
| 20 | - | return nil, ErrNotImplemented | |
| 21 | - | } | |
| 22 | - | // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum | |
| 23 | - | w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new())) | |
| 24 | - | if w == nil { | |
| 25 | - | return nil, ErrNotImplemented | |
| 26 | - | } | |
| 27 | - | ctitle := C.CString(title) | |
| 28 | - | defer C.free(unsafe.Pointer(ctitle)) | |
| 29 | - | C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w)), (*C.gchar)(ctitle)) | |
| 30 | - | return &Window{win: w}, nil | |
| 19 | + | if C.gtk_init_check() == 0 { | |
| 20 | + | return nil, ErrNotImplemented | |
| 21 | + | } | |
| 22 | + | // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum | |
| 23 | + | w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new())) | |
| 24 | + | if w == nil { | |
| 25 | + | return nil, ErrNotImplemented | |
| 26 | + | } | |
| 27 | + | ctitle := C.CString(title) | |
| 28 | + | defer C.free(unsafe.Pointer(ctitle)) | |
| 29 | + | C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w)), (*C.gchar)(ctitle)) | |
| 30 | + | return &Window{win: w}, nil | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | func (w *Window) SetTitle(title string) { | |
| 34 | - | if w == nil || w.win == nil { return } | |
| 35 | - | ctitle := C.CString(title) | |
| 36 | - | defer C.free(unsafe.Pointer(ctitle)) | |
| 37 | - | C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w.win)), (*C.gchar)(ctitle)) | |
| 34 | + | if w == nil || w.win == nil { | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | ctitle := C.CString(title) | |
| 38 | + | defer C.free(unsafe.Pointer(ctitle)) | |
| 39 | + | C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w.win)), (*C.gchar)(ctitle)) | |
| 38 | 40 | } | |
M pkg/webkit/zoom.go +18 -18
| @@ -3,30 +3,30 @@ | |||
| 3 | 3 | package webkit | |
| 4 | 4 | ||
| 5 | 5 | const ( | |
| 6 | - | zoomMin = 0.25 | |
| 7 | - | zoomMax = 5.0 | |
| 6 | + | zoomMin = 0.25 | |
| 7 | + | zoomMax = 5.0 | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | // SetZoom sets the current zoom factor for the WebView. | |
| 11 | 11 | func (w *WebView) SetZoom(level float64) error { | |
| 12 | - | if w == nil || w.destroyed { | |
| 13 | - | return ErrNotImplemented | |
| 14 | - | } | |
| 15 | - | if level < zoomMin { | |
| 16 | - | level = zoomMin | |
| 17 | - | } | |
| 18 | - | if level > zoomMax { | |
| 19 | - | level = zoomMax | |
| 20 | - | } | |
| 21 | - | w.zoom = level | |
| 22 | - | w.dispatchZoomChanged(w.zoom) | |
| 23 | - | return nil | |
| 12 | + | if w == nil || w.destroyed { | |
| 13 | + | return ErrNotImplemented | |
| 14 | + | } | |
| 15 | + | if level < zoomMin { | |
| 16 | + | level = zoomMin | |
| 17 | + | } | |
| 18 | + | if level > zoomMax { | |
| 19 | + | level = zoomMax | |
| 20 | + | } | |
| 21 | + | w.zoom = level | |
| 22 | + | w.dispatchZoomChanged(w.zoom) | |
| 23 | + | return nil | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | // GetZoom returns the current zoom factor. | |
| 27 | 27 | func (w *WebView) GetZoom() (float64, error) { | |
| 28 | - | if w == nil || w.destroyed { | |
| 29 | - | return 1.0, ErrNotImplemented | |
| 30 | - | } | |
| 31 | - | return w.zoom, nil | |
| 28 | + | if w == nil || w.destroyed { | |
| 29 | + | return 1.0, ErrNotImplemented | |
| 30 | + | } | |
| 31 | + | return w.zoom, nil | |
| 32 | 32 | } | |
M pkg/webkit/zoom_cgo.go +19 -19
| @@ -9,26 +9,26 @@ package webkit | |||
| 9 | 9 | import "C" | |
| 10 | 10 | ||
| 11 | 11 | func (w *WebView) SetZoom(level float64) error { | |
| 12 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 13 | - | return ErrNotImplemented | |
| 14 | - | } | |
| 15 | - | if level < 0.25 { | |
| 16 | - | level = 0.25 | |
| 17 | - | } | |
| 18 | - | if level > 5.0 { | |
| 19 | - | level = 5.0 | |
| 20 | - | } | |
| 21 | - | w.zoom = level | |
| 22 | - | C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level)) | |
| 23 | - | w.dispatchZoomChanged(w.zoom) | |
| 24 | - | return nil | |
| 12 | + | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 13 | + | return ErrNotImplemented | |
| 14 | + | } | |
| 15 | + | if level < 0.25 { | |
| 16 | + | level = 0.25 | |
| 17 | + | } | |
| 18 | + | if level > 5.0 { | |
| 19 | + | level = 5.0 | |
| 20 | + | } | |
| 21 | + | w.zoom = level | |
| 22 | + | C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level)) | |
| 23 | + | w.dispatchZoomChanged(w.zoom) | |
| 24 | + | return nil | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | func (w *WebView) GetZoom() (float64, error) { | |
| 28 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 29 | - | return 1.0, ErrNotImplemented | |
| 30 | - | } | |
| 31 | - | z := float64(C.webkit_web_view_get_zoom_level(w.native.wv)) | |
| 32 | - | w.zoom = z | |
| 33 | - | return z, nil | |
| 28 | + | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 29 | + | return 1.0, ErrNotImplemented | |
| 30 | + | } | |
| 31 | + | z := float64(C.webkit_web_view_get_zoom_level(w.native.wv)) | |
| 32 | + | w.zoom = z | |
| 33 | + | return z, nil | |
| 34 | 34 | } | |
M services/browser_service.go +164 -164
| @@ -2,44 +2,44 @@ | |||
| 2 | 2 | package services | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "context" | |
| 6 | - | "database/sql" | |
| 7 | - | "embed" | |
| 8 | - | "fmt" | |
| 9 | - | "errors" | |
| 10 | - | "math" | |
| 11 | - | "time" | |
| 12 | - | neturl "net/url" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/dumber/internal/config" | |
| 15 | - | "github.com/bnema/dumber/internal/db" | |
| 16 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "context" | |
| 6 | + | "database/sql" | |
| 7 | + | "embed" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "math" | |
| 11 | + | neturl "net/url" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/config" | |
| 15 | + | "github.com/bnema/dumber/internal/db" | |
| 16 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | func clampToInt32(i int64) int32 { | |
| 20 | - | if i > math.MaxInt32 { | |
| 21 | - | i = math.MaxInt32 | |
| 22 | - | } | |
| 23 | - | if i < math.MinInt32 { | |
| 24 | - | i = math.MinInt32 | |
| 25 | - | } | |
| 26 | - | return int32(i) //nolint:gosec // value is clamped to int32 bounds | |
| 20 | + | if i > math.MaxInt32 { | |
| 21 | + | i = math.MaxInt32 | |
| 22 | + | } | |
| 23 | + | if i < math.MinInt32 { | |
| 24 | + | i = math.MinInt32 | |
| 25 | + | } | |
| 26 | + | return int32(i) //nolint:gosec // value is clamped to int32 bounds | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | // WindowTitleUpdater interface allows the service to update the window title | |
| 30 | 30 | // Decoupled from any specific GUI framework. | |
| 31 | 31 | type WindowTitleUpdater interface { | |
| 32 | - | SetTitle(title string) | |
| 32 | + | SetTitle(title string) | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | // BrowserService handles browser-related operations for the built-in browser. | |
| 36 | 36 | type BrowserService struct { | |
| 37 | - | config *config.Config | |
| 38 | - | dbQueries db.DatabaseQuerier | |
| 39 | - | windowTitleUpdater WindowTitleUpdater | |
| 40 | - | webView *webkit.WebView | |
| 41 | - | initialURL string | |
| 42 | - | injectableScript string | |
| 37 | + | config *config.Config | |
| 38 | + | dbQueries db.DatabaseQuerier | |
| 39 | + | windowTitleUpdater WindowTitleUpdater | |
| 40 | + | webView *webkit.WebView | |
| 41 | + | initialURL string | |
| 42 | + | injectableScript string | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | // ServiceName returns the service name for frontend binding | |
| @@ -69,14 +69,14 @@ type HistoryEntry struct { | |||
| 69 | 69 | ||
| 70 | 70 | // NewBrowserService creates a new BrowserService instance. | |
| 71 | 71 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | |
| 72 | - | return &BrowserService{ | |
| 73 | - | config: cfg, | |
| 74 | - | dbQueries: queries, | |
| 75 | - | windowTitleUpdater: nil, | |
| 76 | - | webView: nil, | |
| 77 | - | initialURL: "", | |
| 78 | - | injectableScript: "", | |
| 79 | - | } | |
| 72 | + | return &BrowserService{ | |
| 73 | + | config: cfg, | |
| 74 | + | dbQueries: queries, | |
| 75 | + | windowTitleUpdater: nil, | |
| 76 | + | webView: nil, | |
| 77 | + | initialURL: "", | |
| 78 | + | injectableScript: "", | |
| 79 | + | } | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | 82 | // SetWindowTitleUpdater sets the window title updater interface | |
| @@ -86,7 +86,7 @@ func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) { | |||
| 86 | 86 | ||
| 87 | 87 | // AttachWebView connects a native WebKit WebView to this service for integration. | |
| 88 | 88 | func (s *BrowserService) AttachWebView(view *webkit.WebView) { | |
| 89 | - | s.webView = view | |
| 89 | + | s.webView = view | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 92 | 92 | // LoadInjectableScript loads the minified injectable controls script from assets | |
| @@ -101,26 +101,26 @@ func (s *BrowserService) LoadInjectableScript(assets embed.FS) error { | |||
| 101 | 101 | ||
| 102 | 102 | // InjectControlScript injects the global controls script into the current page | |
| 103 | 103 | func (s *BrowserService) InjectControlScript(ctx context.Context) error { | |
| 104 | - | _ = ctx | |
| 105 | - | if s.webView == nil { | |
| 106 | - | return fmt.Errorf("webview not attached") | |
| 107 | - | } | |
| 108 | - | if s.injectableScript == "" { | |
| 109 | - | return fmt.Errorf("injectable script not loaded") | |
| 110 | - | } | |
| 111 | - | wrappedScript := "(function(){try{" + | |
| 112 | - | "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){try{" + | |
| 113 | - | s.injectableScript + | |
| 114 | - | "}catch(e){console.warn('Dumber Browser controls failed to load on DOMContentLoaded:', e);}});}else{" + | |
| 115 | - | s.injectableScript + | |
| 116 | - | "}}catch(e){console.warn('Dumber Browser controls injection failed:', e);}})();" | |
| 117 | - | return s.webView.InjectScript(wrappedScript) | |
| 104 | + | _ = ctx | |
| 105 | + | if s.webView == nil { | |
| 106 | + | return fmt.Errorf("webview not attached") | |
| 107 | + | } | |
| 108 | + | if s.injectableScript == "" { | |
| 109 | + | return fmt.Errorf("injectable script not loaded") | |
| 110 | + | } | |
| 111 | + | wrappedScript := "(function(){try{" + | |
| 112 | + | "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){try{" + | |
| 113 | + | s.injectableScript + | |
| 114 | + | "}catch(e){console.warn('Dumber Browser controls failed to load on DOMContentLoaded:', e);}});}else{" + | |
| 115 | + | s.injectableScript + | |
| 116 | + | "}}catch(e){console.warn('Dumber Browser controls injection failed:', e);}})();" | |
| 117 | + | return s.webView.InjectScript(wrappedScript) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | 120 | // Navigate handles navigation to a URL and records it in history. | |
| 121 | 121 | func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) { | |
| 122 | 122 | startTime := time.Now() | |
| 123 | - | ||
| 123 | + | ||
| 124 | 124 | if url == "" { | |
| 125 | 125 | return &NavigationResult{ | |
| 126 | 126 | Success: false, | |
| @@ -182,16 +182,16 @@ func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]His | |||
| 182 | 182 | ||
| 183 | 183 | result := make([]HistoryEntry, len(entries)) | |
| 184 | 184 | for i, entry := range entries { | |
| 185 | - | // Defensive cast to int32 to prevent overflow | |
| 186 | - | vc := clampToInt32(entry.VisitCount.Int64) | |
| 187 | - | result[i] = HistoryEntry{ | |
| 188 | - | ID: entry.ID, | |
| 189 | - | URL: entry.Url, | |
| 190 | - | Title: entry.Title.String, | |
| 191 | - | VisitCount: vc, | |
| 192 | - | LastVisited: entry.LastVisited.Time, | |
| 193 | - | CreatedAt: entry.CreatedAt.Time, | |
| 194 | - | } | |
| 185 | + | // Defensive cast to int32 to prevent overflow | |
| 186 | + | vc := clampToInt32(entry.VisitCount.Int64) | |
| 187 | + | result[i] = HistoryEntry{ | |
| 188 | + | ID: entry.ID, | |
| 189 | + | URL: entry.Url, | |
| 190 | + | Title: entry.Title.String, | |
| 191 | + | VisitCount: vc, | |
| 192 | + | LastVisited: entry.LastVisited.Time, | |
| 193 | + | CreatedAt: entry.CreatedAt.Time, | |
| 194 | + | } | |
| 195 | 195 | } | |
| 196 | 196 | ||
| 197 | 197 | return result, nil | |
| @@ -209,7 +209,7 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||
| 209 | 209 | ||
| 210 | 210 | urlPattern := sql.NullString{String: "%" + query + "%", Valid: true} | |
| 211 | 211 | titlePattern := sql.NullString{String: "%" + query + "%", Valid: true} | |
| 212 | - | ||
| 212 | + | ||
| 213 | 213 | entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit)) | |
| 214 | 214 | if err != nil { | |
| 215 | 215 | return nil, err | |
| @@ -217,15 +217,15 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||
| 217 | 217 | ||
| 218 | 218 | result := make([]HistoryEntry, len(entries)) | |
| 219 | 219 | for i, entry := range entries { | |
| 220 | - | vc := clampToInt32(entry.VisitCount.Int64) | |
| 221 | - | result[i] = HistoryEntry{ | |
| 222 | - | ID: entry.ID, | |
| 223 | - | URL: entry.Url, | |
| 224 | - | Title: entry.Title.String, | |
| 225 | - | VisitCount: vc, | |
| 226 | - | LastVisited: entry.LastVisited.Time, | |
| 227 | - | CreatedAt: entry.CreatedAt.Time, | |
| 228 | - | } | |
| 220 | + | vc := clampToInt32(entry.VisitCount.Int64) | |
| 221 | + | result[i] = HistoryEntry{ | |
| 222 | + | ID: entry.ID, | |
| 223 | + | URL: entry.Url, | |
| 224 | + | Title: entry.Title.String, | |
| 225 | + | VisitCount: vc, | |
| 226 | + | LastVisited: entry.LastVisited.Time, | |
| 227 | + | CreatedAt: entry.CreatedAt.Time, | |
| 228 | + | } | |
| 229 | 229 | } | |
| 230 | 230 | ||
| 231 | 231 | return result, nil | |
| @@ -233,25 +233,25 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||
| 233 | 233 | ||
| 234 | 234 | // DeleteHistoryEntry removes a specific history entry. | |
| 235 | 235 | func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error { | |
| 236 | - | _ = ctx | |
| 237 | - | _ = id | |
| 238 | - | // Note: DeleteHistory method doesn't exist in current schema | |
| 239 | - | // This would need to be implemented in the database layer | |
| 240 | - | return fmt.Errorf("delete history not implemented yet") | |
| 236 | + | _ = ctx | |
| 237 | + | _ = id | |
| 238 | + | // Note: DeleteHistory method doesn't exist in current schema | |
| 239 | + | // This would need to be implemented in the database layer | |
| 240 | + | return fmt.Errorf("delete history not implemented yet") | |
| 241 | 241 | } | |
| 242 | 242 | ||
| 243 | 243 | // ClearHistory removes all history entries. | |
| 244 | 244 | func (s *BrowserService) ClearHistory(ctx context.Context) error { | |
| 245 | - | _ = ctx | |
| 246 | - | // Note: ClearAllHistory method doesn't exist in current schema | |
| 247 | - | // This would need to be implemented in the database layer | |
| 248 | - | return fmt.Errorf("clear all history not implemented yet") | |
| 245 | + | _ = ctx | |
| 246 | + | // Note: ClearAllHistory method doesn't exist in current schema | |
| 247 | + | // This would need to be implemented in the database layer | |
| 248 | + | return fmt.Errorf("clear all history not implemented yet") | |
| 249 | 249 | } | |
| 250 | 250 | ||
| 251 | 251 | // GetHistoryStats returns statistics about browser history. | |
| 252 | 252 | func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) { | |
| 253 | - | // Get recent entries for basic stats | |
| 254 | - | recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit) | |
| 253 | + | // Get recent entries for basic stats | |
| 254 | + | recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit) | |
| 255 | 255 | if err != nil { | |
| 256 | 256 | return nil, err | |
| 257 | 257 | } | |
| @@ -273,16 +273,16 @@ func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interf | |||
| 273 | 273 | ||
| 274 | 274 | // GetConfig returns the current browser configuration. | |
| 275 | 275 | func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) { | |
| 276 | - | _ = ctx | |
| 277 | - | return s.config, nil | |
| 276 | + | _ = ctx | |
| 277 | + | return s.config, nil | |
| 278 | 278 | } | |
| 279 | 279 | ||
| 280 | 280 | // UpdateConfig updates the browser configuration. | |
| 281 | 281 | func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error { | |
| 282 | - | _ = ctx | |
| 283 | - | // In a real implementation, you'd want to validate and persist the config | |
| 284 | - | s.config = newConfig | |
| 285 | - | return nil | |
| 282 | + | _ = ctx | |
| 283 | + | // In a real implementation, you'd want to validate and persist the config | |
| 284 | + | s.config = newConfig | |
| 285 | + | return nil | |
| 286 | 286 | } | |
| 287 | 287 | ||
| 288 | 288 | // GetSearchShortcuts returns available search shortcuts. | |
| @@ -297,7 +297,7 @@ func (s *BrowserService) recordHistory(ctx context.Context, url, title string) e | |||
| 297 | 297 | if title != "" { | |
| 298 | 298 | titleNull = sql.NullString{String: title, Valid: true} | |
| 299 | 299 | } | |
| 300 | - | ||
| 300 | + | ||
| 301 | 301 | return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull) | |
| 302 | 302 | } | |
| 303 | 303 | ||
| @@ -310,13 +310,13 @@ func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error | |||
| 310 | 310 | if err != nil { | |
| 311 | 311 | return 1.0, err | |
| 312 | 312 | } | |
| 313 | - | ||
| 313 | + | ||
| 314 | 314 | newZoom := s.getNextZoomLevel(currentZoom, true) | |
| 315 | 315 | err = s.SetZoomLevel(ctx, url, newZoom) | |
| 316 | 316 | if err != nil { | |
| 317 | 317 | return currentZoom, err | |
| 318 | 318 | } | |
| 319 | - | ||
| 319 | + | ||
| 320 | 320 | return newZoom, nil | |
| 321 | 321 | } | |
| 322 | 322 | ||
| @@ -326,13 +326,13 @@ func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, erro | |||
| 326 | 326 | if err != nil { | |
| 327 | 327 | return 1.0, err | |
| 328 | 328 | } | |
| 329 | - | ||
| 329 | + | ||
| 330 | 330 | newZoom := s.getNextZoomLevel(currentZoom, false) | |
| 331 | 331 | err = s.SetZoomLevel(ctx, url, newZoom) | |
| 332 | 332 | if err != nil { | |
| 333 | 333 | return currentZoom, err | |
| 334 | 334 | } | |
| 335 | - | ||
| 335 | + | ||
| 336 | 336 | return newZoom, nil | |
| 337 | 337 | } | |
| 338 | 338 | ||
| @@ -343,51 +343,51 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er | |||
| 343 | 343 | ||
| 344 | 344 | // GetZoomLevel retrieves the saved zoom level for a URL. | |
| 345 | 345 | func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) { | |
| 346 | - | if url == "" { | |
| 347 | - | return 1.0, nil | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | key := zoomKeyFromURL(url) | |
| 351 | - | zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key) | |
| 352 | - | if err != nil { | |
| 353 | - | if errors.Is(err, sql.ErrNoRows) { | |
| 354 | - | // Fallback to exact URL record if present (backward compatibility) | |
| 355 | - | zl, err2 := s.dbQueries.GetZoomLevel(ctx, url) | |
| 356 | - | if err2 == nil { | |
| 357 | - | return zl, nil | |
| 358 | - | } | |
| 359 | - | // No zoom setting found, return default | |
| 360 | - | return 1.0, nil | |
| 361 | - | } | |
| 362 | - | return 1.0, err | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | return zoomLevel, nil | |
| 346 | + | if url == "" { | |
| 347 | + | return 1.0, nil | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | key := zoomKeyFromURL(url) | |
| 351 | + | zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key) | |
| 352 | + | if err != nil { | |
| 353 | + | if errors.Is(err, sql.ErrNoRows) { | |
| 354 | + | // Fallback to exact URL record if present (backward compatibility) | |
| 355 | + | zl, err2 := s.dbQueries.GetZoomLevel(ctx, url) | |
| 356 | + | if err2 == nil { | |
| 357 | + | return zl, nil | |
| 358 | + | } | |
| 359 | + | // No zoom setting found, return default | |
| 360 | + | return 1.0, nil | |
| 361 | + | } | |
| 362 | + | return 1.0, err | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | return zoomLevel, nil | |
| 366 | 366 | } | |
| 367 | 367 | ||
| 368 | 368 | // SetZoomLevel sets the zoom level for a URL. | |
| 369 | 369 | func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error { | |
| 370 | - | if url == "" { | |
| 371 | - | return fmt.Errorf("URL cannot be empty") | |
| 372 | - | } | |
| 370 | + | if url == "" { | |
| 371 | + | return fmt.Errorf("URL cannot be empty") | |
| 372 | + | } | |
| 373 | 373 | ||
| 374 | - | // Clamp zoom level to reasonable bounds | |
| 375 | - | if zoomLevel < zoomMin { | |
| 376 | - | zoomLevel = zoomMin | |
| 377 | - | } else if zoomLevel > zoomMax { | |
| 378 | - | zoomLevel = zoomMax | |
| 379 | - | } | |
| 374 | + | // Clamp zoom level to reasonable bounds | |
| 375 | + | if zoomLevel < zoomMin { | |
| 376 | + | zoomLevel = zoomMin | |
| 377 | + | } else if zoomLevel > zoomMax { | |
| 378 | + | zoomLevel = zoomMax | |
| 379 | + | } | |
| 380 | 380 | ||
| 381 | - | key := zoomKeyFromURL(url) | |
| 382 | - | return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel) | |
| 381 | + | key := zoomKeyFromURL(url) | |
| 382 | + | return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel) | |
| 383 | 383 | } | |
| 384 | 384 | ||
| 385 | 385 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | |
| 386 | 386 | func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) { | |
| 387 | - | if s.webView == nil { | |
| 388 | - | return "", nil | |
| 389 | - | } | |
| 390 | - | return s.webView.GetCurrentURL(), nil | |
| 387 | + | if s.webView == nil { | |
| 388 | + | return "", nil | |
| 389 | + | } | |
| 390 | + | return s.webView.GetCurrentURL(), nil | |
| 391 | 391 | } | |
| 392 | 392 | ||
| 393 | 393 | // GetInitialURL returns the initially navigated URL for frontend synchronization | |
| @@ -408,34 +408,34 @@ func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error { | |||
| 408 | 408 | ||
| 409 | 409 | // GoBack provides navigation back functionality | |
| 410 | 410 | func (s *BrowserService) GoBack(ctx context.Context) error { | |
| 411 | - | if s.webView != nil { | |
| 412 | - | return s.webView.GoBack() | |
| 413 | - | } | |
| 414 | - | fmt.Println("Navigation: Go back") | |
| 415 | - | return nil | |
| 411 | + | if s.webView != nil { | |
| 412 | + | return s.webView.GoBack() | |
| 413 | + | } | |
| 414 | + | fmt.Println("Navigation: Go back") | |
| 415 | + | return nil | |
| 416 | 416 | } | |
| 417 | 417 | ||
| 418 | 418 | // GoForward provides navigation forward functionality | |
| 419 | 419 | func (s *BrowserService) GoForward(ctx context.Context) error { | |
| 420 | - | if s.webView != nil { | |
| 421 | - | return s.webView.GoForward() | |
| 422 | - | } | |
| 423 | - | fmt.Println("Navigation: Go forward") | |
| 424 | - | return nil | |
| 420 | + | if s.webView != nil { | |
| 421 | + | return s.webView.GoForward() | |
| 422 | + | } | |
| 423 | + | fmt.Println("Navigation: Go forward") | |
| 424 | + | return nil | |
| 425 | 425 | } | |
| 426 | 426 | ||
| 427 | 427 | // getNextZoomLevel returns the next Firefox zoom level in the specified direction | |
| 428 | 428 | const ( | |
| 429 | - | zoomMin = 0.25 | |
| 430 | - | zoomMax = 5.0 | |
| 431 | - | recentHistoryLimit = 1000 | |
| 429 | + | zoomMin = 0.25 | |
| 430 | + | zoomMax = 5.0 | |
| 431 | + | recentHistoryLimit = 1000 | |
| 432 | 432 | ) | |
| 433 | 433 | ||
| 434 | 434 | func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 { | |
| 435 | 435 | // Find the closest current zoom level | |
| 436 | 436 | closestIndex := 0 | |
| 437 | 437 | minDiff := 10.0 // Large initial value | |
| 438 | - | ||
| 438 | + | ||
| 439 | 439 | for i, level := range firefoxZoomLevels { | |
| 440 | 440 | diff := currentZoom - level | |
| 441 | 441 | if diff < 0 { | |
| @@ -446,38 +446,38 @@ func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) floa | |||
| 446 | 446 | closestIndex = i | |
| 447 | 447 | } | |
| 448 | 448 | } | |
| 449 | - | ||
| 450 | - | if zoomIn { | |
| 451 | - | // Move to next higher zoom level | |
| 452 | - | if closestIndex < len(firefoxZoomLevels)-1 { | |
| 453 | - | return firefoxZoomLevels[closestIndex+1] | |
| 454 | - | } | |
| 455 | - | return firefoxZoomLevels[closestIndex] // Already at max | |
| 456 | - | } | |
| 457 | - | // Move to next lower zoom level | |
| 458 | - | if closestIndex > 0 { | |
| 459 | - | return firefoxZoomLevels[closestIndex-1] | |
| 460 | - | } | |
| 461 | - | return firefoxZoomLevels[closestIndex] // Already at min | |
| 449 | + | ||
| 450 | + | if zoomIn { | |
| 451 | + | // Move to next higher zoom level | |
| 452 | + | if closestIndex < len(firefoxZoomLevels)-1 { | |
| 453 | + | return firefoxZoomLevels[closestIndex+1] | |
| 454 | + | } | |
| 455 | + | return firefoxZoomLevels[closestIndex] // Already at max | |
| 456 | + | } | |
| 457 | + | // Move to next lower zoom level | |
| 458 | + | if closestIndex > 0 { | |
| 459 | + | return firefoxZoomLevels[closestIndex-1] | |
| 460 | + | } | |
| 461 | + | return firefoxZoomLevels[closestIndex] // Already at min | |
| 462 | 462 | } | |
| 463 | 463 | ||
| 464 | 464 | // setZoom sets the zoom level to a specific value | |
| 465 | 465 | func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) { | |
| 466 | - | err := s.SetZoomLevel(ctx, url, zoomLevel) | |
| 467 | - | if err != nil { | |
| 468 | - | return 1.0, err | |
| 469 | - | } | |
| 470 | - | return zoomLevel, nil | |
| 466 | + | err := s.SetZoomLevel(ctx, url, zoomLevel) | |
| 467 | + | if err != nil { | |
| 468 | + | return 1.0, err | |
| 469 | + | } | |
| 470 | + | return zoomLevel, nil | |
| 471 | 471 | } | |
| 472 | 472 | ||
| 473 | 473 | // zoomKeyFromURL extracts a stable per-domain key for zoom persistence. | |
| 474 | 474 | // Uses hostname if present; falls back to the full input URL string. | |
| 475 | 475 | func zoomKeyFromURL(raw string) string { | |
| 476 | - | u, err := neturl.Parse(raw) | |
| 477 | - | if err != nil || u.Host == "" { | |
| 478 | - | return raw | |
| 479 | - | } | |
| 480 | - | return u.Host | |
| 476 | + | u, err := neturl.Parse(raw) | |
| 477 | + | if err != nil || u.Host == "" { | |
| 478 | + | return raw | |
| 479 | + | } | |
| 480 | + | return u.Host | |
| 481 | 481 | } | |
| 482 | 482 | ||
| 483 | 483 | // extractDomain extracts the domain from a URL for zoom level storage | |
M services/config_service.go +84 -84
| @@ -1,30 +1,30 @@ | |||
| 1 | 1 | package services | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "os" | |
| 7 | - | "path/filepath" | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/dumber/internal/config" | |
| 10 | - | "strings" | |
| 9 | + | "github.com/bnema/dumber/internal/config" | |
| 10 | + | "strings" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | // ConfigService handles configuration operations for the application. | |
| 14 | 14 | type ConfigService struct { | |
| 15 | - | config *config.Config | |
| 16 | - | configPath string | |
| 15 | + | config *config.Config | |
| 16 | + | configPath string | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | const defaultDirPerm = 0o750 | |
| 20 | 20 | ||
| 21 | 21 | // ConfigInfo represents configuration information for the frontend. | |
| 22 | 22 | type ConfigInfo struct { | |
| 23 | - | ConfigPath string `json:"config_path"` | |
| 24 | - | DatabasePath string `json:"database_path"` | |
| 25 | - | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |
| 26 | - | DmenuSettings *config.DmenuConfig `json:"dmenu_settings"` | |
| 27 | - | RenderingMode string `json:"rendering_mode"` | |
| 23 | + | ConfigPath string `json:"config_path"` | |
| 24 | + | DatabasePath string `json:"database_path"` | |
| 25 | + | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |
| 26 | + | DmenuSettings *config.DmenuConfig `json:"dmenu_settings"` | |
| 27 | + | RenderingMode string `json:"rendering_mode"` | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | // NewConfigService creates a new ConfigService instance. | |
| @@ -37,29 +37,29 @@ func NewConfigService(cfg *config.Config, configPath string) *ConfigService { | |||
| 37 | 37 | ||
| 38 | 38 | // GetConfigInfo returns comprehensive configuration information. | |
| 39 | 39 | func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) { | |
| 40 | - | _ = ctx | |
| 41 | - | return &ConfigInfo{ | |
| 42 | - | ConfigPath: s.configPath, | |
| 43 | - | DatabasePath: s.config.Database.Path, | |
| 44 | - | SearchShortcuts: s.config.SearchShortcuts, | |
| 45 | - | DmenuSettings: &s.config.Dmenu, | |
| 46 | - | RenderingMode: string(s.config.RenderingMode), | |
| 47 | - | }, nil | |
| 40 | + | _ = ctx | |
| 41 | + | return &ConfigInfo{ | |
| 42 | + | ConfigPath: s.configPath, | |
| 43 | + | DatabasePath: s.config.Database.Path, | |
| 44 | + | SearchShortcuts: s.config.SearchShortcuts, | |
| 45 | + | DmenuSettings: &s.config.Dmenu, | |
| 46 | + | RenderingMode: string(s.config.RenderingMode), | |
| 47 | + | }, nil | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | // GetSearchShortcuts returns all configured search shortcuts. | |
| 51 | 51 | func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | |
| 52 | - | _ = ctx | |
| 53 | - | return s.config.SearchShortcuts, nil | |
| 52 | + | _ = ctx | |
| 53 | + | return s.config.SearchShortcuts, nil | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | // AddSearchShortcut adds a new search shortcut. | |
| 57 | 57 | func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | |
| 58 | - | _ = ctx | |
| 59 | - | if key == "" { | |
| 60 | - | return fmt.Errorf("shortcut key cannot be empty") | |
| 61 | - | } | |
| 62 | - | ||
| 58 | + | _ = ctx | |
| 59 | + | if key == "" { | |
| 60 | + | return fmt.Errorf("shortcut key cannot be empty") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | 63 | if shortcut.URL == "" { | |
| 64 | 64 | return fmt.Errorf("shortcut URL cannot be empty") | |
| 65 | 65 | } | |
| @@ -74,10 +74,10 @@ func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, short | |||
| 74 | 74 | ||
| 75 | 75 | // UpdateSearchShortcut updates an existing search shortcut. | |
| 76 | 76 | func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | |
| 77 | - | _ = ctx | |
| 78 | - | if key == "" { | |
| 79 | - | return fmt.Errorf("shortcut key cannot be empty") | |
| 80 | - | } | |
| 77 | + | _ = ctx | |
| 78 | + | if key == "" { | |
| 79 | + | return fmt.Errorf("shortcut key cannot be empty") | |
| 80 | + | } | |
| 81 | 81 | ||
| 82 | 82 | if _, exists := s.config.SearchShortcuts[key]; !exists { | |
| 83 | 83 | return fmt.Errorf("shortcut '%s' does not exist", key) | |
| @@ -93,10 +93,10 @@ func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, sh | |||
| 93 | 93 | ||
| 94 | 94 | // DeleteSearchShortcut removes a search shortcut. | |
| 95 | 95 | func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error { | |
| 96 | - | _ = ctx | |
| 97 | - | if key == "" { | |
| 98 | - | return fmt.Errorf("shortcut key cannot be empty") | |
| 99 | - | } | |
| 96 | + | _ = ctx | |
| 97 | + | if key == "" { | |
| 98 | + | return fmt.Errorf("shortcut key cannot be empty") | |
| 99 | + | } | |
| 100 | 100 | ||
| 101 | 101 | if _, exists := s.config.SearchShortcuts[key]; !exists { | |
| 102 | 102 | return fmt.Errorf("shortcut '%s' does not exist", key) | |
| @@ -108,16 +108,16 @@ func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) er | |||
| 108 | 108 | ||
| 109 | 109 | // GetDmenuSettings returns current dmenu settings. | |
| 110 | 110 | func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) { | |
| 111 | - | _ = ctx | |
| 112 | - | return &s.config.Dmenu, nil | |
| 111 | + | _ = ctx | |
| 112 | + | return &s.config.Dmenu, nil | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | 115 | // UpdateDmenuSettings updates dmenu configuration. | |
| 116 | 116 | func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error { | |
| 117 | - | _ = ctx | |
| 118 | - | if settings == nil { | |
| 119 | - | return fmt.Errorf("dmenu settings cannot be nil") | |
| 120 | - | } | |
| 117 | + | _ = ctx | |
| 118 | + | if settings == nil { | |
| 119 | + | return fmt.Errorf("dmenu settings cannot be nil") | |
| 120 | + | } | |
| 121 | 121 | ||
| 122 | 122 | s.config.Dmenu = *settings | |
| 123 | 123 | return s.saveConfig() | |
| @@ -125,22 +125,22 @@ func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *confi | |||
| 125 | 125 | ||
| 126 | 126 | // GetDatabasePath returns the current database path. | |
| 127 | 127 | func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) { | |
| 128 | - | _ = ctx | |
| 129 | - | return s.config.Database.Path, nil | |
| 128 | + | _ = ctx | |
| 129 | + | return s.config.Database.Path, nil | |
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | 132 | // SetDatabasePath updates the database path. | |
| 133 | 133 | func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error { | |
| 134 | - | _ = ctx | |
| 135 | - | if path == "" { | |
| 136 | - | return fmt.Errorf("database path cannot be empty") | |
| 137 | - | } | |
| 134 | + | _ = ctx | |
| 135 | + | if path == "" { | |
| 136 | + | return fmt.Errorf("database path cannot be empty") | |
| 137 | + | } | |
| 138 | 138 | ||
| 139 | 139 | // Validate that the directory exists or can be created | |
| 140 | 140 | dir := filepath.Dir(path) | |
| 141 | - | if err := os.MkdirAll(dir, defaultDirPerm); err != nil { | |
| 142 | - | return fmt.Errorf("failed to create database directory: %w", err) | |
| 143 | - | } | |
| 141 | + | if err := os.MkdirAll(dir, defaultDirPerm); err != nil { | |
| 142 | + | return fmt.Errorf("failed to create database directory: %w", err) | |
| 143 | + | } | |
| 144 | 144 | ||
| 145 | 145 | s.config.Database.Path = path | |
| 146 | 146 | return s.saveConfig() | |
| @@ -148,28 +148,28 @@ func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error | |||
| 148 | 148 | ||
| 149 | 149 | // GetDefaultBrowser returns the default browser setting. | |
| 150 | 150 | func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) { | |
| 151 | - | _ = ctx | |
| 152 | - | // Default browser is now built into the app | |
| 153 | - | return "dumber (built-in)", nil | |
| 151 | + | _ = ctx | |
| 152 | + | // Default browser is now built into the app | |
| 153 | + | return "dumber (built-in)", nil | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | 156 | // SetDefaultBrowser updates the default browser setting. | |
| 157 | 157 | func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error { | |
| 158 | - | _ = ctx | |
| 159 | - | _ = browser | |
| 160 | - | // Default browser setting is not needed for built-in browser | |
| 161 | - | return fmt.Errorf("default browser is built-in and cannot be changed") | |
| 158 | + | _ = ctx | |
| 159 | + | _ = browser | |
| 160 | + | // Default browser setting is not needed for built-in browser | |
| 161 | + | return fmt.Errorf("default browser is built-in and cannot be changed") | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | 164 | // ReloadConfig reloads configuration from file. | |
| 165 | 165 | func (s *ConfigService) ReloadConfig(ctx context.Context) error { | |
| 166 | - | _ = ctx | |
| 167 | - | // Create new manager and load config | |
| 168 | - | manager, err := config.NewManager() | |
| 169 | - | if err != nil { | |
| 170 | - | return fmt.Errorf("failed to create config manager: %w", err) | |
| 171 | - | } | |
| 172 | - | ||
| 166 | + | _ = ctx | |
| 167 | + | // Create new manager and load config | |
| 168 | + | manager, err := config.NewManager() | |
| 169 | + | if err != nil { | |
| 170 | + | return fmt.Errorf("failed to create config manager: %w", err) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | 173 | if err := manager.Load(); err != nil { | |
| 174 | 174 | return fmt.Errorf("failed to load config: %w", err) | |
| 175 | 175 | } | |
| @@ -180,8 +180,8 @@ func (s *ConfigService) ReloadConfig(ctx context.Context) error { | |||
| 180 | 180 | ||
| 181 | 181 | // ValidateConfig validates the current configuration. | |
| 182 | 182 | func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | |
| 183 | - | _ = ctx | |
| 184 | - | var errors []string | |
| 183 | + | _ = ctx | |
| 184 | + | var errors []string | |
| 185 | 185 | ||
| 186 | 186 | // Validate database path | |
| 187 | 187 | if s.config.Database.Path == "" { | |
| @@ -219,16 +219,16 @@ func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | |||
| 219 | 219 | ||
| 220 | 220 | // GetConfigPath returns the path to the configuration file. | |
| 221 | 221 | func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) { | |
| 222 | - | _ = ctx | |
| 223 | - | return s.configPath, nil | |
| 222 | + | _ = ctx | |
| 223 | + | return s.configPath, nil | |
| 224 | 224 | } | |
| 225 | 225 | ||
| 226 | 226 | // ExportConfig returns the current configuration as JSON. | |
| 227 | 227 | func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) { | |
| 228 | - | _ = ctx | |
| 229 | - | // Return a copy of the config to prevent modifications | |
| 230 | - | configCopy := *s.config | |
| 231 | - | return &configCopy, nil | |
| 228 | + | _ = ctx | |
| 229 | + | // Return a copy of the config to prevent modifications | |
| 230 | + | configCopy := *s.config | |
| 231 | + | return &configCopy, nil | |
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | 234 | // ImportConfig imports configuration from a Config object. | |
| @@ -255,21 +255,21 @@ func (s *ConfigService) ImportConfig(ctx context.Context, newConfig *config.Conf | |||
| 255 | 255 | ||
| 256 | 256 | // GetRenderingMode returns the current rendering mode (auto|gpu|cpu). | |
| 257 | 257 | func (s *ConfigService) GetRenderingMode(ctx context.Context) (string, error) { | |
| 258 | - | _ = ctx | |
| 259 | - | return string(s.config.RenderingMode), nil | |
| 258 | + | _ = ctx | |
| 259 | + | return string(s.config.RenderingMode), nil | |
| 260 | 260 | } | |
| 261 | 261 | ||
| 262 | 262 | // SetRenderingMode updates the rendering mode (auto|gpu|cpu). | |
| 263 | 263 | func (s *ConfigService) SetRenderingMode(ctx context.Context, mode string) error { | |
| 264 | - | _ = ctx | |
| 265 | - | m := strings.ToLower(strings.TrimSpace(mode)) | |
| 266 | - | switch m { | |
| 267 | - | case "auto", "gpu", "cpu": | |
| 268 | - | s.config.RenderingMode = config.RenderingMode(m) | |
| 269 | - | return s.saveConfig() | |
| 270 | - | default: | |
| 271 | - | return fmt.Errorf("invalid rendering mode: %s", mode) | |
| 272 | - | } | |
| 264 | + | _ = ctx | |
| 265 | + | m := strings.ToLower(strings.TrimSpace(mode)) | |
| 266 | + | switch m { | |
| 267 | + | case "auto", "gpu", "cpu": | |
| 268 | + | s.config.RenderingMode = config.RenderingMode(m) | |
| 269 | + | return s.saveConfig() | |
| 270 | + | default: | |
| 271 | + | return fmt.Errorf("invalid rendering mode: %s", mode) | |
| 272 | + | } | |
| 273 | 273 | } | |
| 274 | 274 | ||
| 275 | 275 | // saveConfig saves the current configuration to file. | |
M services/parser_service.go +57 -57
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | // ParserService wraps the parser functionality for the application. | |
| 14 | 14 | type ParserService struct { | |
| 15 | - | parser *parser.Parser | |
| 15 | + | parser *parser.Parser | |
| 16 | 16 | dbQueries *db.Queries | |
| 17 | 17 | } | |
| 18 | 18 | ||
| @@ -20,93 +20,93 @@ type ParserService struct { | |||
| 20 | 20 | func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService { | |
| 21 | 21 | // Create history provider from database | |
| 22 | 22 | historyProvider := &DatabaseHistoryProvider{queries: queries} | |
| 23 | - | ||
| 23 | + | ||
| 24 | 24 | // Create parser with configuration | |
| 25 | 25 | p := parser.NewParser(cfg, historyProvider) | |
| 26 | - | ||
| 26 | + | ||
| 27 | 27 | return &ParserService{ | |
| 28 | - | parser: p, | |
| 28 | + | parser: p, | |
| 29 | 29 | dbQueries: queries, | |
| 30 | 30 | } | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | // ParseInput parses user input and returns navigation result. | |
| 34 | 34 | func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) { | |
| 35 | - | _ = ctx | |
| 36 | - | if input == "" { | |
| 37 | - | return nil, fmt.Errorf("input cannot be empty") | |
| 38 | - | } | |
| 39 | - | ||
| 35 | + | _ = ctx | |
| 36 | + | if input == "" { | |
| 37 | + | return nil, fmt.Errorf("input cannot be empty") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | 40 | return s.parser.ParseInput(input) | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | // GetCompletions provides URL/search completions based on input. | |
| 44 | 44 | func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) { | |
| 45 | - | _ = ctx | |
| 46 | - | if limit <= 0 { | |
| 47 | - | limit = 10 // Default limit | |
| 48 | - | } | |
| 49 | - | ||
| 45 | + | _ = ctx | |
| 46 | + | if limit <= 0 { | |
| 47 | + | limit = 10 // Default limit | |
| 48 | + | } | |
| 49 | + | ||
| 50 | 50 | return s.parser.SuggestCompletions(input, limit) | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | 53 | // FuzzySearchHistory performs fuzzy search on history entries. | |
| 54 | 54 | func (s *ParserService) FuzzySearchHistory( | |
| 55 | - | ctx context.Context, query string, threshold float64, | |
| 55 | + | ctx context.Context, query string, threshold float64, | |
| 56 | 56 | ) ([]parser.FuzzyMatch, error) { | |
| 57 | - | _ = ctx | |
| 58 | - | if threshold <= 0 { | |
| 59 | - | threshold = 0.3 // Default threshold | |
| 60 | - | } | |
| 61 | - | ||
| 57 | + | _ = ctx | |
| 58 | + | if threshold <= 0 { | |
| 59 | + | threshold = 0.3 // Default threshold | |
| 60 | + | } | |
| 61 | + | ||
| 62 | 62 | return s.parser.FuzzySearchHistory(query, threshold) | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | 65 | // GetSupportedShortcuts returns configured search shortcuts. | |
| 66 | 66 | func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | |
| 67 | - | _ = ctx | |
| 68 | - | return s.parser.GetSupportedShortcuts(), nil | |
| 67 | + | _ = ctx | |
| 68 | + | return s.parser.GetSupportedShortcuts(), nil | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | 71 | // ProcessShortcut processes a shortcut and returns the resulting URL. | |
| 72 | 72 | func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) { | |
| 73 | - | _ = ctx | |
| 74 | - | shortcuts := s.parser.GetSupportedShortcuts() | |
| 75 | - | return s.parser.ProcessShortcut(shortcut, query, shortcuts) | |
| 73 | + | _ = ctx | |
| 74 | + | shortcuts := s.parser.GetSupportedShortcuts() | |
| 75 | + | return s.parser.ProcessShortcut(shortcut, query, shortcuts) | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | 78 | // CalculateSimilarity calculates similarity between two strings. | |
| 79 | 79 | func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) { | |
| 80 | - | _ = ctx | |
| 81 | - | return s.parser.CalculateSimilarity(s1, s2), nil | |
| 80 | + | _ = ctx | |
| 81 | + | return s.parser.CalculateSimilarity(s1, s2), nil | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | // ValidateURL checks if the input represents a valid URL. | |
| 85 | 85 | func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) { | |
| 86 | - | _ = ctx | |
| 87 | - | return s.parser.IsValidURL(input), nil | |
| 86 | + | _ = ctx | |
| 87 | + | return s.parser.IsValidURL(input), nil | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | 90 | // PreviewParse provides a preview of what ParseInput would return. | |
| 91 | 91 | func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) { | |
| 92 | - | _ = ctx | |
| 93 | - | if input == "" { | |
| 94 | - | return nil, fmt.Errorf("input cannot be empty") | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | return s.parser.PreviewParse(input) | |
| 92 | + | _ = ctx | |
| 93 | + | if input == "" { | |
| 94 | + | return nil, fmt.Errorf("input cannot be empty") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | return s.parser.PreviewParse(input) | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | 100 | // GetFuzzyConfig returns current fuzzy matching configuration. | |
| 101 | 101 | func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) { | |
| 102 | - | _ = ctx | |
| 103 | - | return s.parser.GetFuzzyConfig(), nil | |
| 102 | + | _ = ctx | |
| 103 | + | return s.parser.GetFuzzyConfig(), nil | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | 106 | // UpdateFuzzyConfig updates the fuzzy matching configuration. | |
| 107 | 107 | func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error { | |
| 108 | - | _ = ctx | |
| 109 | - | return s.parser.UpdateFuzzyConfig(config) | |
| 108 | + | _ = ctx | |
| 109 | + | return s.parser.UpdateFuzzyConfig(config) | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | 112 | // DatabaseHistoryProvider implements HistoryProvider using the database. | |
| @@ -117,82 +117,82 @@ type DatabaseHistoryProvider struct { | |||
| 117 | 117 | // GetRecentHistory returns recent history entries limited by count. | |
| 118 | 118 | func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) { | |
| 119 | 119 | ctx := context.Background() | |
| 120 | - | ||
| 120 | + | ||
| 121 | 121 | entries, err := h.queries.GetHistory(ctx, int64(limit)) | |
| 122 | 122 | if err != nil { | |
| 123 | 123 | return nil, err | |
| 124 | 124 | } | |
| 125 | - | ||
| 125 | + | ||
| 126 | 126 | // Convert to pointer slice | |
| 127 | 127 | result := make([]*db.History, len(entries)) | |
| 128 | 128 | for i, entry := range entries { | |
| 129 | 129 | entryCopy := entry | |
| 130 | 130 | result[i] = &entryCopy | |
| 131 | 131 | } | |
| 132 | - | ||
| 132 | + | ||
| 133 | 133 | return result, nil | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | 136 | // GetAllHistory returns all history entries for fuzzy search. | |
| 137 | 137 | func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) { | |
| 138 | - | ctx := context.Background() | |
| 139 | - | ||
| 140 | - | // Use a large limit to get all history | |
| 141 | - | const allHistoryLimit = 10000 | |
| 142 | - | entries, err := h.queries.GetHistory(ctx, allHistoryLimit) | |
| 138 | + | ctx := context.Background() | |
| 139 | + | ||
| 140 | + | // Use a large limit to get all history | |
| 141 | + | const allHistoryLimit = 10000 | |
| 142 | + | entries, err := h.queries.GetHistory(ctx, allHistoryLimit) | |
| 143 | 143 | if err != nil { | |
| 144 | 144 | return nil, err | |
| 145 | 145 | } | |
| 146 | - | ||
| 146 | + | ||
| 147 | 147 | // Convert to pointer slice | |
| 148 | 148 | result := make([]*db.History, len(entries)) | |
| 149 | 149 | for i, entry := range entries { | |
| 150 | 150 | entryCopy := entry | |
| 151 | 151 | result[i] = &entryCopy | |
| 152 | 152 | } | |
| 153 | - | ||
| 153 | + | ||
| 154 | 154 | return result, nil | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | 157 | // SearchHistory performs a basic text search in history. | |
| 158 | 158 | func (h *DatabaseHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) { | |
| 159 | 159 | ctx := context.Background() | |
| 160 | - | ||
| 160 | + | ||
| 161 | 161 | // Use SQL LIKE pattern for basic search | |
| 162 | 162 | urlPattern := sql.NullString{String: "%" + query + "%", Valid: true} | |
| 163 | 163 | titlePattern := sql.NullString{String: "%" + query + "%", Valid: true} | |
| 164 | - | ||
| 164 | + | ||
| 165 | 165 | entries, err := h.queries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit)) | |
| 166 | 166 | if err != nil { | |
| 167 | 167 | return nil, err | |
| 168 | 168 | } | |
| 169 | - | ||
| 169 | + | ||
| 170 | 170 | // Convert to pointer slice | |
| 171 | 171 | result := make([]*db.History, len(entries)) | |
| 172 | 172 | for i, entry := range entries { | |
| 173 | 173 | entryCopy := entry | |
| 174 | 174 | result[i] = &entryCopy | |
| 175 | 175 | } | |
| 176 | - | ||
| 176 | + | ||
| 177 | 177 | return result, nil | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | 180 | // GetHistoryByURL retrieves history entry by exact URL match. | |
| 181 | 181 | func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, error) { | |
| 182 | 182 | ctx := context.Background() | |
| 183 | - | ||
| 183 | + | ||
| 184 | 184 | // Search for exact URL match using SearchHistory | |
| 185 | 185 | urlPattern := sql.NullString{String: url, Valid: true} | |
| 186 | 186 | emptyPattern := sql.NullString{Valid: false} | |
| 187 | - | ||
| 187 | + | ||
| 188 | 188 | entries, err := h.queries.SearchHistory(ctx, urlPattern, emptyPattern, 1) | |
| 189 | 189 | if err != nil { | |
| 190 | 190 | return nil, err | |
| 191 | 191 | } | |
| 192 | - | ||
| 192 | + | ||
| 193 | 193 | if len(entries) == 0 { | |
| 194 | 194 | return nil, fmt.Errorf("history entry not found for URL: %s", url) | |
| 195 | 195 | } | |
| 196 | - | ||
| 196 | + | ||
| 197 | 197 | return &entries[0], nil | |
| 198 | 198 | } | |
A specs/005-webkit-memory-optimization/contracts/webkit_memory_api.go +184 -0
| @@ -0,0 +1,184 @@ | |||
| 1 | + | // Package contracts defines the WebKit memory optimization API contracts | |
| 2 | + | // This file contains the expected function signatures and behavior contracts | |
| 3 | + | package contracts | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "time" | |
| 7 | + | "unsafe" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // WebKitMemoryAPI defines the contract for WebKit memory management functions | |
| 11 | + | type WebKitMemoryAPI interface { | |
| 12 | + | // Memory Pressure Settings | |
| 13 | + | CreateMemoryPressureSettings(memoryLimitMB int, conservativeThreshold, strictThreshold, killThreshold, pollInterval float64) (unsafe.Pointer, error) | |
| 14 | + | ApplyMemoryPressureSettings(context unsafe.Pointer, settings unsafe.Pointer) error | |
| 15 | + | ||
| 16 | + | // Cache Model Management | |
| 17 | + | SetCacheModel(context unsafe.Pointer, model CacheModel) error | |
| 18 | + | GetCurrentCacheModel(context unsafe.Pointer) (CacheModel, error) | |
| 19 | + | ||
| 20 | + | // JavaScript Garbage Collection | |
| 21 | + | TriggerJavaScriptGC(context unsafe.Pointer) error | |
| 22 | + | ||
| 23 | + | // WebView Settings | |
| 24 | + | SetPageCacheEnabled(settings unsafe.Pointer, enabled bool) error | |
| 25 | + | SetOfflineAppCacheEnabled(settings unsafe.Pointer, enabled bool) error | |
| 26 | + | ||
| 27 | + | // Memory Monitoring | |
| 28 | + | GetProcessMemoryInfo(pid int) (ProcessMemoryInfo, error) | |
| 29 | + | ListWebKitProcesses() ([]ProcessMemoryInfo, error) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // CacheModel represents WebKit cache model options | |
| 33 | + | type CacheModel int | |
| 34 | + | ||
| 35 | + | const ( | |
| 36 | + | CacheModelDocumentViewer CacheModel = iota | |
| 37 | + | CacheModelWebBrowser | |
| 38 | + | CacheModelPrimaryWebBrowser | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | // ProcessMemoryInfo represents process memory statistics | |
| 42 | + | type ProcessMemoryInfo struct { | |
| 43 | + | PID int `json:"pid"` | |
| 44 | + | VmRSS int64 `json:"vm_rss"` // KB | |
| 45 | + | VmSize int64 `json:"vm_size"` // KB | |
| 46 | + | VmPeak int64 `json:"vm_peak"` // KB | |
| 47 | + | ProcessName string `json:"process_name"` | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // MemoryConfigAPI defines the contract for memory configuration management | |
| 51 | + | type MemoryConfigAPI interface { | |
| 52 | + | // Configuration Validation | |
| 53 | + | ValidateMemoryConfig(config MemoryConfig) error | |
| 54 | + | ||
| 55 | + | // Preset Management | |
| 56 | + | GetMemoryOptimizedPreset() MemoryConfig | |
| 57 | + | GetBalancedPreset() MemoryConfig | |
| 58 | + | GetHighPerformancePreset() MemoryConfig | |
| 59 | + | ||
| 60 | + | // Configuration Application | |
| 61 | + | ApplyMemoryConfig(webview unsafe.Pointer, config MemoryConfig) error | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // MemoryConfig represents memory optimization configuration | |
| 65 | + | type MemoryConfig struct { | |
| 66 | + | MemoryLimitMB int `json:"memory_limit_mb"` | |
| 67 | + | ConservativeThreshold float64 `json:"conservative_threshold"` | |
| 68 | + | StrictThreshold float64 `json:"strict_threshold"` | |
| 69 | + | KillThreshold float64 `json:"kill_threshold"` | |
| 70 | + | PollIntervalSeconds float64 `json:"poll_interval_seconds"` | |
| 71 | + | CacheModel string `json:"cache_model"` | |
| 72 | + | EnablePageCache bool `json:"enable_page_cache"` | |
| 73 | + | EnableOfflineAppCache bool `json:"enable_offline_app_cache"` | |
| 74 | + | ProcessRecycleThreshold int `json:"process_recycle_threshold"` | |
| 75 | + | EnableGCInterval int `json:"enable_gc_interval"` | |
| 76 | + | EnableMemoryMonitoring bool `json:"enable_memory_monitoring"` | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // MemoryStatsAPI defines the contract for memory statistics tracking | |
| 80 | + | type MemoryStatsAPI interface { | |
| 81 | + | // Statistics Retrieval | |
| 82 | + | GetMemoryStats(webviewID uintptr) (MemoryStats, error) | |
| 83 | + | GetAllMemoryStats() (map[uintptr]MemoryStats, error) | |
| 84 | + | ||
| 85 | + | // Memory Management Operations | |
| 86 | + | TriggerMemoryCleanup(webviewID uintptr) error | |
| 87 | + | ShouldRecycleWebView(webviewID uintptr) (bool, error) | |
| 88 | + | ||
| 89 | + | // Monitoring Operations | |
| 90 | + | StartMemoryMonitoring(interval time.Duration) error | |
| 91 | + | StopMemoryMonitoring() error | |
| 92 | + | GetTotalMemoryUsage() (float64, error) // MB | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // MemoryStats represents WebView memory statistics | |
| 96 | + | type MemoryStats struct { | |
| 97 | + | PageLoadCount int `json:"page_load_count"` | |
| 98 | + | LastGCTime time.Time `json:"last_gc_time"` | |
| 99 | + | HasMemoryPressureSettings bool `json:"has_memory_pressure_settings"` | |
| 100 | + | WebViewID uintptr `json:"webview_id"` | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // WebViewLifecycleAPI defines the contract for WebView lifecycle management | |
| 104 | + | type WebViewLifecycleAPI interface { | |
| 105 | + | // WebView Registration | |
| 106 | + | RegisterWebView(webviewID uintptr, config MemoryConfig) error | |
| 107 | + | UnregisterWebView(webviewID uintptr) error | |
| 108 | + | ||
| 109 | + | // Lifecycle Events | |
| 110 | + | OnPageLoad(webviewID uintptr, url string) error | |
| 111 | + | OnMemoryPressure(webviewID uintptr, pressureLevel int) error | |
| 112 | + | OnGarbageCollection(webviewID uintptr) error | |
| 113 | + | ||
| 114 | + | // Recycling Operations | |
| 115 | + | ShouldRecycleWebView(webviewID uintptr) bool | |
| 116 | + | RequestWebViewRecycling(webviewID uintptr) error | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Error Types - define expected error behaviors | |
| 120 | + | ||
| 121 | + | // ErrMemoryLimitExceeded indicates memory usage exceeded configured limits | |
| 122 | + | type ErrMemoryLimitExceeded struct { | |
| 123 | + | Limit int64 // bytes | |
| 124 | + | Actual int64 // bytes | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (e ErrMemoryLimitExceeded) Error() string { | |
| 128 | + | return "memory limit exceeded" | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // ErrInvalidMemoryConfig indicates invalid memory configuration parameters | |
| 132 | + | type ErrInvalidMemoryConfig struct { | |
| 133 | + | Field string | |
| 134 | + | Message string | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (e ErrInvalidMemoryConfig) Error() string { | |
| 138 | + | return "invalid memory configuration" | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // ErrWebKitAPIUnavailable indicates WebKit memory APIs are not available | |
| 142 | + | type ErrWebKitAPIUnavailable struct { | |
| 143 | + | Function string | |
| 144 | + | Reason string | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (e ErrWebKitAPIUnavailable) Error() string { | |
| 148 | + | return "WebKit API unavailable" | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // Behavioral Contracts - define expected behaviors as comments | |
| 152 | + | ||
| 153 | + | /* | |
| 154 | + | MEMORY_PRESSURE_CONTRACT: | |
| 155 | + | - Conservative threshold (0.2) should trigger background cleanup | |
| 156 | + | - Strict threshold (0.35) should trigger aggressive cleanup | |
| 157 | + | - Kill threshold (0.8) should trigger process termination warning | |
| 158 | + | - Poll interval determines monitoring frequency (2.0s recommended) | |
| 159 | + | - Memory limit applies per-WebView instance, not global | |
| 160 | + | ||
| 161 | + | CACHE_MODEL_CONTRACT: | |
| 162 | + | - DocumentViewer: Minimal caching, ~40% less memory than WebBrowser | |
| 163 | + | - WebBrowser: Default caching, balanced performance/memory | |
| 164 | + | - PrimaryWebBrowser: Maximum caching, highest memory usage | |
| 165 | + | - Changes take effect on next navigation | |
| 166 | + | ||
| 167 | + | GC_CONTRACT: | |
| 168 | + | - JavaScript GC triggers should complete within 2 seconds | |
| 169 | + | - GC should not block UI thread or navigation | |
| 170 | + | - Periodic GC intervals should be 30-120 seconds for effectiveness | |
| 171 | + | - Manual GC triggers should be idempotent (safe to call repeatedly) | |
| 172 | + | ||
| 173 | + | PROCESS_RECYCLING_CONTRACT: | |
| 174 | + | - Page load counting should be accurate and monotonic | |
| 175 | + | - Recycling recommendations should not force immediate action | |
| 176 | + | - New WebView creation should reset all memory counters | |
| 177 | + | - Recycling should preserve user session state until destruction | |
| 178 | + | ||
| 179 | + | MONITORING_CONTRACT: | |
| 180 | + | - Memory statistics should be updated within poll interval | |
| 181 | + | - /proc parsing should handle process disappearance gracefully | |
| 182 | + | - Memory reporting should be accurate within 5% of system tools | |
| 183 | + | - Monitoring overhead should be <1% of total browser memory usage | |
| 184 | + | */ | |
A specs/005-webkit-memory-optimization/data-model.md +185 -0
| @@ -0,0 +1,185 @@ | |||
| 1 | + | # Data Model: WebKit Memory Optimization | |
| 2 | + | ||
| 3 | + | **Feature**: 005-webkit-memory-optimization | |
| 4 | + | **Phase**: 1 - Design & Data Model | |
| 5 | + | **Date**: 2025-01-10 | |
| 6 | + | ||
| 7 | + | ## Core Entities | |
| 8 | + | ||
| 9 | + | ### MemoryConfig | |
| 10 | + | **Purpose**: Configuration settings that control WebKit memory optimization behavior | |
| 11 | + | ||
| 12 | + | **Fields**: | |
| 13 | + | - `MemoryLimitMB` (int): Maximum memory limit per WebView in MB (0 = unlimited) | |
| 14 | + | - `ConservativeThreshold` (float64): Early memory cleanup threshold (0.0-1.0, default 0.33) | |
| 15 | + | - `StrictThreshold` (float64): Aggressive memory cleanup threshold (0.0-1.0, default 0.5) | |
| 16 | + | - `KillThreshold` (float64): Process termination threshold (0.0-1.0, 0 = disabled) | |
| 17 | + | - `PollIntervalSeconds` (float64): Memory usage polling interval (default 2.0) | |
| 18 | + | - `CacheModel` (CacheModel): WebKit caching strategy (document_viewer/web_browser/primary_web_browser) | |
| 19 | + | - `EnablePageCache` (bool): Enable/disable page cache for back/forward navigation | |
| 20 | + | - `EnableOfflineAppCache` (bool): Enable/disable offline application caching | |
| 21 | + | - `ProcessRecycleThreshold` (int): Recycle WebView after N page loads (0 = disabled) | |
| 22 | + | - `EnableGCInterval` (int): JavaScript garbage collection interval in seconds (0 = disabled) | |
| 23 | + | - `EnableMemoryMonitoring` (bool): Enable detailed memory logging and monitoring | |
| 24 | + | ||
| 25 | + | **Validation Rules**: | |
| 26 | + | - ConservativeThreshold must be between 0.0 and 1.0 | |
| 27 | + | - StrictThreshold must be between 0.0 and 1.0 | |
| 28 | + | - StrictThreshold must be greater than ConservativeThreshold | |
| 29 | + | - KillThreshold must be between 0.0 and 1.0 | |
| 30 | + | - PollIntervalSeconds must be non-negative | |
| 31 | + | - MemoryLimitMB must be non-negative | |
| 32 | + | ||
| 33 | + | **State Transitions**: Immutable configuration object (changes require new instance) | |
| 34 | + | ||
| 35 | + | ### MemoryStats | |
| 36 | + | **Purpose**: Real-time and historical memory usage statistics for WebView instances | |
| 37 | + | ||
| 38 | + | **Fields**: | |
| 39 | + | - `pageLoadCount` (int): Number of pages loaded in this WebView instance | |
| 40 | + | - `lastGCTime` (time.Time): Timestamp of last JavaScript garbage collection | |
| 41 | + | - `memoryPressureSettings` (*C.WebKitMemoryPressureSettings): Native WebKit memory settings | |
| 42 | + | ||
| 43 | + | **Relationships**: | |
| 44 | + | - Owned by WebView instance (1:1 relationship) | |
| 45 | + | - References native WebKit memory pressure settings | |
| 46 | + | - Tracked by WebViewMemoryManager (many:1 relationship) | |
| 47 | + | ||
| 48 | + | **Validation Rules**: Read-only statistics, no validation required | |
| 49 | + | ||
| 50 | + | **State Transitions**: | |
| 51 | + | - `pageLoadCount` increments on each page load | |
| 52 | + | - `lastGCTime` updates when garbage collection is triggered | |
| 53 | + | - `memoryPressureSettings` set once during WebView creation | |
| 54 | + | ||
| 55 | + | ### ProcessMemoryInfo | |
| 56 | + | **Purpose**: System-level memory information for WebKit processes | |
| 57 | + | ||
| 58 | + | **Fields**: | |
| 59 | + | - `PID` (int): Process identifier | |
| 60 | + | - `VmRSS` (int64): Resident Set Size in KB (actual RAM usage) | |
| 61 | + | - `VmSize` (int64): Virtual Memory Size in KB (total virtual memory) | |
| 62 | + | - `VmPeak` (int64): Peak Virtual Memory in KB (maximum ever used) | |
| 63 | + | - `ProcessName` (string): Name of the process (extracted from cmdline) | |
| 64 | + | ||
| 65 | + | **Relationships**: | |
| 66 | + | - Collected by WebViewMemoryManager for all WebKit processes | |
| 67 | + | - Associated with specific WebView instances (many:1 for multi-process WebKit) | |
| 68 | + | ||
| 69 | + | **Validation Rules**: | |
| 70 | + | - All memory values must be non-negative | |
| 71 | + | - PID must be positive integer | |
| 72 | + | ||
| 73 | + | **State Transitions**: Immutable snapshot of process memory state | |
| 74 | + | ||
| 75 | + | ### OptimizationPreset | |
| 76 | + | **Purpose**: Pre-configured memory optimization settings for common use cases | |
| 77 | + | ||
| 78 | + | **Fields**: Embeds MemoryConfig with preset values | |
| 79 | + | ||
| 80 | + | **Preset Types**: | |
| 81 | + | 1. **MemoryOptimized**: Maximum memory reduction (256MB limit, aggressive thresholds) | |
| 82 | + | 2. **Balanced**: Moderate optimization (512MB limit, balanced thresholds) | |
| 83 | + | 3. **HighPerformance**: Maximum performance (no limits, minimal optimization) | |
| 84 | + | ||
| 85 | + | **Validation Rules**: Inherits MemoryConfig validation rules | |
| 86 | + | ||
| 87 | + | **State Transitions**: Immutable preset definitions | |
| 88 | + | ||
| 89 | + | ### WebViewMemoryManager | |
| 90 | + | **Purpose**: Global memory monitoring and lifecycle management for multiple WebViews | |
| 91 | + | ||
| 92 | + | **Fields**: | |
| 93 | + | - `views` (map[uintptr]*WebView): Registry of active WebViews by ID | |
| 94 | + | - `enableMonitoring` (bool): Enable background memory monitoring | |
| 95 | + | - `recycleThreshold` (int): Global recycling threshold | |
| 96 | + | - `monitoringInterval` (time.Duration): Monitoring check frequency | |
| 97 | + | - `stopMonitoring` (chan struct{}): Channel for stopping monitoring goroutine | |
| 98 | + | ||
| 99 | + | **Relationships**: | |
| 100 | + | - Manages multiple WebView instances (1:many) | |
| 101 | + | - Monitors ProcessMemoryInfo for all WebKit processes | |
| 102 | + | - Singleton pattern for global memory management | |
| 103 | + | ||
| 104 | + | **Validation Rules**: | |
| 105 | + | - monitoringInterval must be positive | |
| 106 | + | - recycleThreshold must be non-negative | |
| 107 | + | ||
| 108 | + | **State Transitions**: | |
| 109 | + | - WebViews registered on creation, unregistered on destruction | |
| 110 | + | - Monitoring starts when first WebView registered | |
| 111 | + | - Monitoring stops when last WebView unregistered | |
| 112 | + | ||
| 113 | + | ## Entity Relationships | |
| 114 | + | ||
| 115 | + | ``` | |
| 116 | + | WebView (1) ----owns----> (1) MemoryStats | |
| 117 | + | | | | |
| 118 | + | | | | |
| 119 | + | v v | |
| 120 | + | WebViewMemoryManager <-tracks- ProcessMemoryInfo | |
| 121 | + | | | |
| 122 | + | | | |
| 123 | + | v (configures) | |
| 124 | + | MemoryConfig <-preset- OptimizationPreset | |
| 125 | + | ``` | |
| 126 | + | ||
| 127 | + | ## Data Flow | |
| 128 | + | ||
| 129 | + | ### Memory Configuration Flow | |
| 130 | + | 1. User selects optimization preset or custom settings | |
| 131 | + | 2. MemoryConfig validated and applied to WebView creation | |
| 132 | + | 3. WebKit memory pressure settings configured via C API | |
| 133 | + | 4. Settings persisted for process lifecycle | |
| 134 | + | ||
| 135 | + | ### Memory Monitoring Flow | |
| 136 | + | 1. WebViewMemoryManager polls /proc filesystem for process memory | |
| 137 | + | 2. WebKit memory pressure callbacks trigger on internal events | |
| 138 | + | 3. MemoryStats updated with current usage and GC events | |
| 139 | + | 4. Memory events logged if monitoring enabled | |
| 140 | + | ||
| 141 | + | ### Process Recycling Flow | |
| 142 | + | 1. MemoryStats.pageLoadCount incremented on navigation | |
| 143 | + | 2. WebViewMemoryManager checks recycling threshold | |
| 144 | + | 3. Recycling recommendation logged (user/application decision) | |
| 145 | + | 4. New WebView creation triggers fresh memory allocation | |
| 146 | + | ||
| 147 | + | ## Memory Layout Considerations | |
| 148 | + | ||
| 149 | + | ### Go Memory Management | |
| 150 | + | - WebView structs include finalizers for cleanup | |
| 151 | + | - CGO memory requires explicit free() calls | |
| 152 | + | - Channel-based coordination for goroutine cleanup | |
| 153 | + | - Careful pointer management for C callbacks | |
| 154 | + | ||
| 155 | + | ### WebKit Native Memory | |
| 156 | + | - WebKitMemoryPressureSettings allocated via C API | |
| 157 | + | - GObject reference counting for WebKit objects | |
| 158 | + | - Memory pressure thresholds enforced by WebKit internally | |
| 159 | + | - Process memory isolated per WebView instance | |
| 160 | + | ||
| 161 | + | ### Monitoring Memory Overhead | |
| 162 | + | - /proc parsing: ~1KB per process check | |
| 163 | + | - Memory statistics storage: ~100 bytes per WebView | |
| 164 | + | - Monitoring goroutine: ~8KB stack space | |
| 165 | + | - Logging overhead: configurable via EnableMemoryMonitoring flag | |
| 166 | + | ||
| 167 | + | ## Testing Considerations | |
| 168 | + | ||
| 169 | + | ### Unit Tests | |
| 170 | + | - MemoryConfig validation rules | |
| 171 | + | - OptimizationPreset value correctness | |
| 172 | + | - ProcessMemoryInfo parsing accuracy | |
| 173 | + | - WebViewMemoryManager lifecycle management | |
| 174 | + | ||
| 175 | + | ### Integration Tests | |
| 176 | + | - WebKit memory pressure settings application | |
| 177 | + | - Real memory usage reduction measurement | |
| 178 | + | - Process recycling effectiveness | |
| 179 | + | - Memory monitoring accuracy vs /proc ground truth | |
| 180 | + | ||
| 181 | + | ### Contract Tests | |
| 182 | + | - WebKit C API memory functions available | |
| 183 | + | - Memory pressure callbacks functional | |
| 184 | + | - GObject reference counting correct | |
| 185 | + | - CGO memory safety verified⏎̸ | |
A specs/005-webkit-memory-optimization/plan.md +259 -0
| @@ -0,0 +1,259 @@ | |||
| 1 | + | # Implementation Plan: WebKit Memory Optimization | |
| 2 | + | ||
| 3 | + | **Branch**: `005-webkit-memory-optimization` | **Date**: 2025-01-10 | **Spec**: [spec.md](./spec.md) | |
| 4 | + | **Input**: Feature specification from `/specs/005-webkit-memory-optimization/spec.md` | |
| 5 | + | ||
| 6 | + | ## Execution Flow (/plan command scope) | |
| 7 | + | ``` | |
| 8 | + | 1. Load feature spec from Input path | |
| 9 | + | → If not found: ERROR "No feature spec at {path}" | |
| 10 | + | 2. Fill Technical Context (scan for NEEDS CLARIFICATION) | |
| 11 | + | → Detect Project Type from context (web=frontend+backend, mobile=app+api) | |
| 12 | + | → Set Structure Decision based on project type | |
| 13 | + | 3. Evaluate Constitution Check section below | |
| 14 | + | → If violations exist: Document in Complexity Tracking | |
| 15 | + | → If no justification possible: ERROR "Simplify approach first" | |
| 16 | + | → Update Progress Tracking: Initial Constitution Check | |
| 17 | + | 4. Execute Phase 0 → research.md | |
| 18 | + | → If NEEDS CLARIFICATION remain: ERROR "Resolve unknowns" | |
| 19 | + | 5. Execute Phase 1 → contracts, data-model.md, quickstart.md, agent-specific template file (e.g., `CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for GitHub Copilot, or `GEMINI.md` for Gemini CLI). | |
| 20 | + | 6. Re-evaluate Constitution Check section | |
| 21 | + | → If new violations: Refactor design, return to Phase 1 | |
| 22 | + | → Update Progress Tracking: Post-Design Constitution Check | |
| 23 | + | 7. Plan Phase 2 → Describe task generation approach (DO NOT create tasks.md) | |
| 24 | + | 8. STOP - Ready for /tasks command | |
| 25 | + | ``` | |
| 26 | + | ||
| 27 | + | **IMPORTANT**: The /plan command STOPS at step 7. Phases 2-4 are executed by other commands: | |
| 28 | + | - Phase 2: /tasks command creates tasks.md | |
| 29 | + | - Phase 3-4: Implementation execution (manual or via tools) | |
| 30 | + | ||
| 31 | + | ## Summary | |
| 32 | + | **Primary Requirement**: Reduce WebKit browser memory usage from ~400MB to 150-250MB per instance (40-60% reduction) while maintaining browser stability and essential functionality. | |
| 33 | + | ||
| 34 | + | **Technical Approach**: Implement WebKit memory pressure settings, optimize cache models, add periodic JavaScript garbage collection, enable process recycling, and provide configurable memory optimization presets for different usage scenarios. | |
| 35 | + | ||
| 36 | + | ## What’s Implemented So Far | |
| 37 | + | Based on current branch diffs (uncommitted changes): | |
| 38 | + | - Config: Added `MemoryConfig` to `pkg/webkit/settings.go` with thresholds, cache model, GC interval, page/offline cache toggles, monitoring, and recycling. | |
| 39 | + | - WebKit integration (`pkg/webkit/webview_cgo.go`): | |
| 40 | + | - Applies `WebKitMemoryPressureSettings` (limit, thresholds, poll interval) to default `WebContext`. | |
| 41 | + | - Sets cache model (DocumentViewer/WebBrowser/Primary). | |
| 42 | + | - Toggles page cache and offline app cache. | |
| 43 | + | - Adds periodic JavaScript GC (ticker) and manual cleanup. | |
| 44 | + | - Tracks memory stats (page loads, last GC), exposes `GetMemoryStats()`. | |
| 45 | + | - Counts page loads and logs when recycle threshold is reached. | |
| 46 | + | - Hooks WebViews into a global memory manager when monitoring enabled. | |
| 47 | + | - Memory manager (`pkg/webkit/memory.go`): | |
| 48 | + | - Scans `/proc` for WebKit-related processes, parses memory (`VmRSS`, `VmSize`, `VmPeak`). | |
| 49 | + | - Provides totals, detailed info, and recycling recommendation helpers. | |
| 50 | + | - Global manager lifecycle with background monitoring ticker. | |
| 51 | + | - Presets and validation (`pkg/webkit/memory_example.go`): Memory-optimized, Balanced, High-performance configs and `ValidateMemoryConfig`. | |
| 52 | + | ||
| 53 | + | Observations: | |
| 54 | + | - Implementation aligns with spec goals: limits, cache tuning, GC, monitoring, and recycling are in place. | |
| 55 | + | - Tests exist but are placeholders; contract/integration coverage for memory features is still missing. | |
| 56 | + | ||
| 57 | + | ## Technical Context | |
| 58 | + | **Language/Version**: Go 1.25.1 with CGO (WebKit2GTK 6.0 bindings) | |
| 59 | + | **Primary Dependencies**: WebKit2GTK 6.0, GTK4, JavaScriptCoreGTK 6.0, GLib/GObject | |
| 60 | + | **Storage**: Configuration files (memory settings), /proc filesystem (memory monitoring) | |
| 61 | + | **Testing**: Go testing framework, contract tests for WebKit memory APIs, integration tests with real WebKit processes | |
| 62 | + | **Target Platform**: Linux (Wayland/X11) with WebKit2GTK 6.0+ system packages | |
| 63 | + | **Project Type**: single (WebKit browser enhancement) | |
| 64 | + | **Performance Goals**: 40-60% memory reduction (400MB → 150-250MB), <2s garbage collection impact, <500ms startup overhead | |
| 65 | + | **Constraints**: Maintain browser stability, preserve essential web functionality, no performance degradation >20% | |
| 66 | + | **Scale/Scope**: Single WebView instances, multiple browser windows, up to 100+ page loads per process lifecycle | |
| 67 | + | ||
| 68 | + | ## Constitution Check | |
| 69 | + | *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | |
| 70 | + | ||
| 71 | + | **Simplicity**: | |
| 72 | + | - Projects: 1 (enhancing existing webkit package - no new projects) | |
| 73 | + | - Using framework directly? YES (WebKit2GTK C APIs via CGO) | |
| 74 | + | - Single data model? YES (MemoryConfig and MemoryStats structs) | |
| 75 | + | - Avoiding patterns? YES (direct WebKit API calls, no abstraction layers) | |
| 76 | + | ||
| 77 | + | **Architecture**: | |
| 78 | + | - EVERY feature as library? PARTIAL (webkit package is library, memory features enhance it) | |
| 79 | + | - Libraries listed: pkg/webkit (WebKit2GTK Go bindings + memory optimization) | |
| 80 | + | - CLI per library: N/A (browser features, not CLI commands) | |
| 81 | + | - Library docs: Will update CLAUDE.md with memory optimization context | |
| 82 | + | ||
| 83 | + | **Testing (NON-NEGOTIABLE)**: | |
| 84 | + | - RED-GREEN-Refactor cycle enforced? YES (tests for memory limits, GC triggers, monitoring) | |
| 85 | + | - Git commits show tests before implementation? YES (TDD approach planned) | |
| 86 | + | - Order: Contract→Integration→E2E→Unit strictly followed? YES | |
| 87 | + | - Real dependencies used? YES (actual WebKit processes, real memory monitoring) | |
| 88 | + | - Integration tests for: YES (memory pressure settings, process recycling, GC effectiveness) | |
| 89 | + | - FORBIDDEN: Implementation before test, skipping RED phase | |
| 90 | + | ||
| 91 | + | **Observability**: | |
| 92 | + | - Structured logging included? YES (using Go log package with memory monitoring flags) | |
| 93 | + | - Frontend logs → backend? N/A (single process browser) | |
| 94 | + | - Error context sufficient? YES (memory pressure events, GC failures, recycling triggers) | |
| 95 | + | ||
| 96 | + | **Versioning**: | |
| 97 | + | - Version number assigned? 005 (feature number, follows existing pattern) | |
| 98 | + | - BUILD increments on every change? YES (follows project build process) | |
| 99 | + | - Breaking changes handled? YES (backwards compatible config with defaults) | |
| 100 | + | ||
| 101 | + | ## Project Structure | |
| 102 | + | ||
| 103 | + | ### Documentation (this feature) | |
| 104 | + | ``` | |
| 105 | + | specs/[###-feature]/ | |
| 106 | + | ├── plan.md # This file (/plan command output) | |
| 107 | + | ├── research.md # Phase 0 output (/plan command) | |
| 108 | + | ├── data-model.md # Phase 1 output (/plan command) | |
| 109 | + | ├── quickstart.md # Phase 1 output (/plan command) | |
| 110 | + | ├── contracts/ # Phase 1 output (/plan command) | |
| 111 | + | └── tasks.md # Phase 2 output (/tasks command - NOT created by /plan) | |
| 112 | + | ``` | |
| 113 | + | ||
| 114 | + | ### Source Code (repository root) | |
| 115 | + | ``` | |
| 116 | + | pkg/webkit/ # WebKit2GTK Go bindings + memory features | |
| 117 | + | - settings.go # Config + MemoryConfig (added) | |
| 118 | + | - webview.go # WebView public API | |
| 119 | + | - webview_cgo.go # CGO integration (memory pressure, cache model, GC) | |
| 120 | + | - memory.go # Memory manager (/proc, totals, recycling) | |
| 121 | + | - memory_example.go# Presets + validation helpers | |
| 122 | + | ||
| 123 | + | tests/ | |
| 124 | + | contract/ # Contract tests (to add) | |
| 125 | + | integration/ # Integration tests (to add) | |
| 126 | + | unit/ # Unit tests (placeholders present) | |
| 127 | + | ||
| 128 | + | internal/, services/ # Existing project modules (unchanged) | |
| 129 | + | frontend/ # Ancillary assets (unchanged) | |
| 130 | + | ``` | |
| 131 | + | ||
| 132 | + | **Structure Decision**: Single Go project — enhance `pkg/webkit` with memory features; tests live under `tests/*`. | |
| 133 | + | ||
| 134 | + | ## Phase 0: Outline & Research | |
| 135 | + | 1. **Extract unknowns from Technical Context** above: | |
| 136 | + | - For each NEEDS CLARIFICATION → research task | |
| 137 | + | - For each dependency → best practices task | |
| 138 | + | - For each integration → patterns task | |
| 139 | + | ||
| 140 | + | 2. **Generate and dispatch research agents**: | |
| 141 | + | ``` | |
| 142 | + | For each unknown in Technical Context: | |
| 143 | + | Task: "Research {unknown} for {feature context}" | |
| 144 | + | For each technology choice: | |
| 145 | + | Task: "Find best practices for {tech} in {domain}" | |
| 146 | + | ``` | |
| 147 | + | ||
| 148 | + | 3. **Consolidate findings** in `research.md` using format: | |
| 149 | + | - Decision: [what was chosen] | |
| 150 | + | - Rationale: [why chosen] | |
| 151 | + | - Alternatives considered: [what else evaluated] | |
| 152 | + | ||
| 153 | + | **Output**: research.md with all NEEDS CLARIFICATION resolved | |
| 154 | + | ||
| 155 | + | ## Phase 1: Design & Contracts | |
| 156 | + | *Prerequisites: research.md complete* | |
| 157 | + | ||
| 158 | + | 1. **Extract entities from feature spec** → `data-model.md`: | |
| 159 | + | - Entity name, fields, relationships | |
| 160 | + | - Validation rules from requirements | |
| 161 | + | - State transitions if applicable | |
| 162 | + | ||
| 163 | + | 2. **Generate API contracts** from functional requirements: | |
| 164 | + | - For each user action → endpoint | |
| 165 | + | - Use standard REST/GraphQL patterns | |
| 166 | + | - Output OpenAPI/GraphQL schema to `/contracts/` | |
| 167 | + | ||
| 168 | + | 3. **Generate contract tests** from contracts: | |
| 169 | + | - One test file per endpoint | |
| 170 | + | - Assert request/response schemas | |
| 171 | + | - Tests must fail (no implementation yet) | |
| 172 | + | ||
| 173 | + | 4. **Extract test scenarios** from user stories: | |
| 174 | + | - Each story → integration test scenario | |
| 175 | + | - Quickstart test = story validation steps | |
| 176 | + | ||
| 177 | + | 5. **Update agent file incrementally** (O(1) operation): | |
| 178 | + | - Run `/scripts/update-agent-context.sh [claude|gemini|copilot]` for your AI assistant | |
| 179 | + | - If exists: Add only NEW tech from current plan | |
| 180 | + | - Preserve manual additions between markers | |
| 181 | + | - Update recent changes (keep last 3) | |
| 182 | + | - Keep under 150 lines for token efficiency | |
| 183 | + | - Output to repository root | |
| 184 | + | ||
| 185 | + | **Output**: data-model.md, /contracts/*, failing tests, quickstart.md, agent-specific file | |
| 186 | + | ||
| 187 | + | ## Phase 2: Task Planning Approach | |
| 188 | + | *This section describes what the /tasks command will do - DO NOT execute during /plan* | |
| 189 | + | ||
| 190 | + | **Task Generation Strategy**: | |
| 191 | + | - Load `/templates/tasks-template.md` as base | |
| 192 | + | - Generate tasks from Phase 1 design docs (contracts, data model, quickstart) | |
| 193 | + | - Contract tests from webkit_memory_api.go interfaces (5-6 tasks) | |
| 194 | + | - Entity implementation tasks from data-model.md (4-5 tasks) | |
| 195 | + | - Integration tests from quickstart.md scenarios (8-10 tasks) | |
| 196 | + | - Memory optimization implementation tasks (6-8 tasks) | |
| 197 | + | ||
| 198 | + | **Specific Task Categories**: | |
| 199 | + | 1. **Contract Tests** [P]: WebKitMemoryAPI, MemoryConfigAPI, MemoryStatsAPI, WebViewLifecycleAPI | |
| 200 | + | 2. **Data Model** [P]: MemoryConfig validation, MemoryStats tracking, ProcessMemoryInfo parsing | |
| 201 | + | 3. **WebKit Integration**: Memory pressure settings, cache model configuration, GC triggers | |
| 202 | + | 4. **Memory Monitoring**: Process memory tracking, statistics collection, recycling logic | |
| 203 | + | 5. **Configuration**: Preset management, validation rules, settings persistence | |
| 204 | + | 6. **Integration Tests**: Memory reduction validation, stability testing, performance benchmarks | |
| 205 | + | ||
| 206 | + | **Ordering Strategy**: | |
| 207 | + | - TDD order: Contract tests → Integration tests → Implementation → Unit tests | |
| 208 | + | - Dependencies: MemoryConfig → WebKit integration → Memory monitoring → Statistics | |
| 209 | + | - Parallel tasks [P]: Independent contract tests, separate entity implementations | |
| 210 | + | - Sequential tasks: WebKit integration depends on configuration, monitoring depends on integration | |
| 211 | + | ||
| 212 | + | **Estimated Output**: 22-28 numbered, ordered tasks in tasks.md | |
| 213 | + | ||
| 214 | + | **IMPORTANT**: This phase is executed by the /tasks command, NOT by /plan | |
| 215 | + | ||
| 216 | + | ## Gaps & Next Steps | |
| 217 | + | - Tests: Add contract tests for memory APIs and integration tests to verify 40–60% reduction and stability. | |
| 218 | + | - CLI/Config wiring: Expose flags/env for presets and limits; persist user choices. | |
| 219 | + | - Safeguards: Handle absence of memory APIs at runtime; version-checks where needed. | |
| 220 | + | - Observability: Ensure structured logs; optionally add an API to fetch `MemoryStats`. | |
| 221 | + | - CGO hygiene: Audit ownership/unref and goroutine shutdown paths; finalizeizers as needed. | |
| 222 | + | - Benchmarks: Script baseline vs optimized memory and performance impact. | |
| 223 | + | - Docs: Quickstart verification steps are present; add examples for presets and flags. | |
| 224 | + | ||
| 225 | + | ## Phase 3+: Future Implementation | |
| 226 | + | *These phases are beyond the scope of the /plan command* | |
| 227 | + | ||
| 228 | + | **Phase 3**: Task execution (/tasks command creates tasks.md) | |
| 229 | + | **Phase 4**: Implementation (execute tasks.md following constitutional principles) | |
| 230 | + | **Phase 5**: Validation (run tests, execute quickstart.md, performance validation) | |
| 231 | + | ||
| 232 | + | ## Complexity Tracking | |
| 233 | + | *Fill ONLY if Constitution Check has violations that must be justified* | |
| 234 | + | ||
| 235 | + | | Violation | Why Needed | Simpler Alternative Rejected Because | | |
| 236 | + | |-----------|------------|-------------------------------------| | |
| 237 | + | | [e.g., 4th project] | [current need] | [why 3 projects insufficient] | | |
| 238 | + | | [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | | |
| 239 | + | ||
| 240 | + | ||
| 241 | + | ## Progress Tracking | |
| 242 | + | *This checklist is updated during execution flow* | |
| 243 | + | ||
| 244 | + | **Phase Status**: | |
| 245 | + | - [x] Phase 0: Research complete (/plan command) | |
| 246 | + | - [x] Phase 1: Design complete (/plan command) | |
| 247 | + | - [x] Phase 2: Task planning complete (/plan command - describe approach only) | |
| 248 | + | - [x] Phase 3: Tasks generated (tasks.md present) | |
| 249 | + | - [ ] Phase 4: Implementation complete | |
| 250 | + | - [ ] Phase 5: Validation passed | |
| 251 | + | ||
| 252 | + | **Gate Status**: | |
| 253 | + | - [x] Initial Constitution Check: PASS | |
| 254 | + | - [x] Post-Design Constitution Check: PASS | |
| 255 | + | - [x] All NEEDS CLARIFICATION resolved | |
| 256 | + | - [ ] Complexity deviations documented | |
| 257 | + | ||
| 258 | + | --- | |
| 259 | + | *Based on Constitution v2.1.1 - See `/memory/constitution.md`* | |
A specs/005-webkit-memory-optimization/quickstart.md +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | # Quickstart: WebKit Memory Optimization | |
| 2 | + | ||
| 3 | + | **Feature**: 005-webkit-memory-optimization | |
| 4 | + | **Purpose**: Validate that WebKit memory optimization reduces memory usage by 40-60% while maintaining browser functionality | |
| 5 | + | **Estimated Time**: 15 minutes | |
| 6 | + | ||
| 7 | + | ## Prerequisites | |
| 8 | + | - dumb-browser built with webkit_cgo tags | |
| 9 | + | - WebKit2GTK 6.0+ installed | |
| 10 | + | - Linux system with /proc filesystem | |
| 11 | + | - At least 2GB available RAM for testing | |
| 12 | + | ||
| 13 | + | ## Quick Validation Test | |
| 14 | + | ||
| 15 | + | ### Step 1: Baseline Memory Measurement | |
| 16 | + | ```bash | |
| 17 | + | # Start browser with default settings | |
| 18 | + | ./dumber --url=https://example.com | |
| 19 | + | ||
| 20 | + | # In another terminal, measure baseline memory | |
| 21 | + | ps aux | grep dumber | grep -v grep | |
| 22 | + | # Note the RSS memory usage (column 6) - should be ~400MB | |
| 23 | + | ``` | |
| 24 | + | ||
| 25 | + | ### Step 2: Enable Memory Optimization | |
| 26 | + | ```bash | |
| 27 | + | # Stop the browser and restart with memory optimization | |
| 28 | + | ./dumber --memory-limit=256 --cache-model=document_viewer --url=https://example.com | |
| 29 | + | ||
| 30 | + | # Measure optimized memory usage | |
| 31 | + | ps aux | grep dumber | grep -v grep | |
| 32 | + | # Note the RSS memory usage - should be 150-250MB (40-60% reduction) | |
| 33 | + | ``` | |
| 34 | + | ||
| 35 | + | ### Step 3: Test Browser Functionality | |
| 36 | + | Navigate to several websites to ensure functionality is preserved: | |
| 37 | + | ```bash | |
| 38 | + | # Test different website types | |
| 39 | + | https://example.com # Simple static site | |
| 40 | + | https://news.ycombinator.com # Content-heavy site | |
| 41 | + | https://github.com # Interactive web app | |
| 42 | + | ``` | |
| 43 | + | ||
| 44 | + | **Expected**: All sites should load correctly with no functionality loss | |
| 45 | + | ||
| 46 | + | ### Step 4: Test Memory Monitoring | |
| 47 | + | ```bash | |
| 48 | + | # Enable detailed memory monitoring | |
| 49 | + | ./dumber --memory-monitoring=true --url=https://example.com | |
| 50 | + | ||
| 51 | + | # Check logs for memory events | |
| 52 | + | tail -f ~/.config/dumber/logs/memory.log | |
| 53 | + | ``` | |
| 54 | + | ||
| 55 | + | **Expected**: Log entries showing memory usage, GC events, and optimization activities | |
| 56 | + | ||
| 57 | + | ### Step 5: Test Memory Pressure Response | |
| 58 | + | ```bash | |
| 59 | + | # Simulate high memory usage by opening many tabs or heavy websites | |
| 60 | + | # Browser should automatically trigger cleanup when approaching limits | |
| 61 | + | ||
| 62 | + | # Monitor memory stats via API endpoint (if implemented) | |
| 63 | + | curl http://localhost:8080/api/memory/stats | |
| 64 | + | ``` | |
| 65 | + | ||
| 66 | + | **Expected**: Memory usage should stabilize, cleanup events should be logged | |
| 67 | + | ||
| 68 | + | ## Integration Test Scenarios | |
| 69 | + | ||
| 70 | + | ### Scenario 1: Memory-Optimized Configuration | |
| 71 | + | **Given** browser started with memory-optimized preset | |
| 72 | + | **When** I navigate to a typical website | |
| 73 | + | **Then** memory usage should be ≤250MB | |
| 74 | + | ||
| 75 | + | ```bash | |
| 76 | + | # Test memory-optimized preset | |
| 77 | + | ./dumber --preset=memory-optimized --url=https://example.com | |
| 78 | + | ps aux | grep dumber | awk '{print $6}' # Should be ≤256MB | |
| 79 | + | ``` | |
| 80 | + | ||
| 81 | + | ### Scenario 2: Process Recycling | |
| 82 | + | **Given** browser with recycling threshold set to 10 pages | |
| 83 | + | **When** I navigate to 15 different pages | |
| 84 | + | **Then** browser should recommend process recycling | |
| 85 | + | ||
| 86 | + | ```bash | |
| 87 | + | # Test process recycling | |
| 88 | + | ./dumber --recycle-threshold=10 --memory-monitoring=true | |
| 89 | + | # Navigate to 15 different URLs | |
| 90 | + | # Check logs for recycling recommendation | |
| 91 | + | ``` | |
| 92 | + | ||
| 93 | + | ### Scenario 3: Memory Stability | |
| 94 | + | **Given** browser with memory optimization enabled | |
| 95 | + | **When** I browse 50 different web pages | |
| 96 | + | **Then** memory usage should remain stable (no significant growth) | |
| 97 | + | ||
| 98 | + | ```bash | |
| 99 | + | # Test memory stability over time | |
| 100 | + | for i in {1..50}; do | |
| 101 | + | echo "Loading page $i" | |
| 102 | + | # Navigate to different pages | |
| 103 | + | sleep 2 | |
| 104 | + | ps aux | grep dumber | awk '{print $6}' >> memory_usage.log | |
| 105 | + | done | |
| 106 | + | # Analyze memory_usage.log for growth trends | |
| 107 | + | ``` | |
| 108 | + | ||
| 109 | + | ## Acceptance Criteria Validation | |
| 110 | + | ||
| 111 | + | ### ✅ FR-001: Memory Usage Reduction | |
| 112 | + | - [ ] Baseline measurement: ~400MB | |
| 113 | + | - [ ] Optimized measurement: 150-250MB | |
| 114 | + | - [ ] Reduction percentage: 40-60% | |
| 115 | + | ||
| 116 | + | ### ✅ FR-002: Configurable Memory Limits | |
| 117 | + | - [ ] Memory limit configuration working | |
| 118 | + | - [ ] Different presets produce different memory usage | |
| 119 | + | - [ ] Custom thresholds respected | |
| 120 | + | ||
| 121 | + | ### ✅ FR-003: Optimization Presets | |
| 122 | + | - [ ] Memory-optimized preset: lowest memory usage | |
| 123 | + | - [ ] Balanced preset: moderate memory usage | |
| 124 | + | - [ ] Performance preset: highest memory usage | |
| 125 | + | ||
| 126 | + | ### ✅ FR-004: Automatic Memory Cleanup | |
| 127 | + | - [ ] Memory pressure triggers cleanup | |
| 128 | + | - [ ] Cleanup completes without crashing | |
| 129 | + | - [ ] Memory usage decreases after cleanup | |
| 130 | + | ||
| 131 | + | ### ✅ FR-005: Memory Statistics | |
| 132 | + | - [ ] Memory stats API accessible | |
| 133 | + | - [ ] Statistics updated regularly | |
| 134 | + | - [ ] Historical data tracked | |
| 135 | + | ||
| 136 | + | ### ✅ FR-006: Browser Stability | |
| 137 | + | - [ ] No crashes during optimization | |
| 138 | + | - [ ] All websites load correctly | |
| 139 | + | - [ ] Navigation remains smooth | |
| 140 | + | ||
| 141 | + | ### ✅ FR-007: Essential Functionality Preserved | |
| 142 | + | - [ ] Page navigation works | |
| 143 | + | - [ ] JavaScript execution works | |
| 144 | + | - [ ] Form submission works | |
| 145 | + | - [ ] Media playback works (if applicable) | |
| 146 | + | ||
| 147 | + | ### ✅ FR-008: Manual Memory Cleanup | |
| 148 | + | - [ ] Manual cleanup trigger available | |
| 149 | + | - [ ] Cleanup executes successfully | |
| 150 | + | - [ ] Memory usage decreases after manual trigger | |
| 151 | + | ||
| 152 | + | ### ✅ FR-009: Process Recycling | |
| 153 | + | - [ ] Page load counting accurate | |
| 154 | + | - [ ] Recycling recommendations logged | |
| 155 | + | - [ ] New process has reset counters | |
| 156 | + | ||
| 157 | + | ### ✅ FR-010: Memory Event Logging | |
| 158 | + | - [ ] Memory optimization events logged | |
| 159 | + | - [ ] Log format is structured and readable | |
| 160 | + | - [ ] Log verbosity controllable | |
| 161 | + | ||
| 162 | + | ## Performance Validation | |
| 163 | + | ||
| 164 | + | ### Memory Usage Benchmarks | |
| 165 | + | ```bash | |
| 166 | + | # Run benchmark script to measure memory over time | |
| 167 | + | ./scripts/memory-benchmark.sh | |
| 168 | + | ||
| 169 | + | Expected results: | |
| 170 | + | - Startup memory: <100MB | |
| 171 | + | - Single page memory: 150-250MB (optimized) vs 350-450MB (default) | |
| 172 | + | - 10 page memory: <300MB (optimized) vs >500MB (default) | |
| 173 | + | - Memory stability: <10% growth over 50 page loads | |
| 174 | + | ``` | |
| 175 | + | ||
| 176 | + | ### Performance Impact Assessment | |
| 177 | + | ```bash | |
| 178 | + | # Measure page load times with/without optimization | |
| 179 | + | ./scripts/performance-benchmark.sh | |
| 180 | + | ||
| 181 | + | Expected results: | |
| 182 | + | - Page load time increase: <20% | |
| 183 | + | - JavaScript performance impact: <15% | |
| 184 | + | - Navigation responsiveness: maintained | |
| 185 | + | ``` | |
| 186 | + | ||
| 187 | + | ## Troubleshooting | |
| 188 | + | ||
| 189 | + | ### Common Issues | |
| 190 | + | ||
| 191 | + | **Memory optimization not working**: | |
| 192 | + | - Check WebKit2GTK version (requires 6.0+) | |
| 193 | + | - Verify webkit_cgo build tags enabled | |
| 194 | + | - Check configuration values in logs | |
| 195 | + | ||
| 196 | + | **Browser crashes with memory limits**: | |
| 197 | + | - Increase memory limit (try 512MB) | |
| 198 | + | - Disable kill threshold (set to 0) | |
| 199 | + | - Check for website-specific memory requirements | |
| 200 | + | ||
| 201 | + | **Memory monitoring not working**: | |
| 202 | + | - Verify /proc filesystem accessible | |
| 203 | + | - Check process permissions | |
| 204 | + | - Enable debug logging | |
| 205 | + | ||
| 206 | + | ### Validation Commands | |
| 207 | + | ```bash | |
| 208 | + | # Check WebKit version | |
| 209 | + | pkg-config --modversion webkitgtk-6.0 | |
| 210 | + | ||
| 211 | + | # Verify build configuration | |
| 212 | + | ./dumber --version | grep webkit_cgo | |
| 213 | + | ||
| 214 | + | # Test memory monitoring | |
| 215 | + | ./dumber --memory-monitoring=true --verbose | |
| 216 | + | ``` | |
| 217 | + | ||
| 218 | + | ## Success Criteria | |
| 219 | + | - [ ] Memory usage reduced by 40-60% compared to baseline | |
| 220 | + | - [ ] All browser functionality preserved | |
| 221 | + | - [ ] Memory monitoring and statistics working | |
| 222 | + | - [ ] Process recycling recommendations functional | |
| 223 | + | - [ ] No stability regressions | |
| 224 | + | - [ ] Configuration presets working correctly | |
| 225 | + | - [ ] Performance impact within acceptable limits (<20%) | |
| 226 | + | ||
| 227 | + | **Expected Completion Time**: 15-30 minutes depending on thorough testing | |
| 228 | + | **Pass Criteria**: All acceptance criteria validated, no critical functionality broken⏎̸ | |
A specs/005-webkit-memory-optimization/research.md +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | # Research: WebKit Memory Optimization | |
| 2 | + | ||
| 3 | + | **Feature**: 005-webkit-memory-optimization | |
| 4 | + | **Phase**: 0 - Research & Technical Decisions | |
| 5 | + | **Date**: 2025-01-10 | |
| 6 | + | ||
| 7 | + | ## Technical Decisions Made | |
| 8 | + | ||
| 9 | + | ### WebKit Memory Pressure API Selection | |
| 10 | + | **Decision**: Use WebKitMemoryPressureSettings introduced in WebKit2GTK 6.0 | |
| 11 | + | **Rationale**: | |
| 12 | + | - Provides granular control over memory limits and cleanup thresholds | |
| 13 | + | - Native WebKit memory management rather than external process monitoring | |
| 14 | + | - Integrates with WebKit's internal memory pressure signals | |
| 15 | + | - Available in WebKit2GTK 6.0+ (already required by project) | |
| 16 | + | ||
| 17 | + | **Alternatives considered**: | |
| 18 | + | - Manual process monitoring via /proc filesystem: Less integrated, more overhead | |
| 19 | + | - System-level memory pressure (systemd, kernel): Too coarse-grained for per-WebView control | |
| 20 | + | - JavaScript-only GC triggers: Insufficient for WebKit native memory (images, CSS, DOM) | |
| 21 | + | ||
| 22 | + | ### Cache Model Optimization Strategy | |
| 23 | + | **Decision**: Use WEBKIT_CACHE_MODEL_DOCUMENT_VIEWER as memory-optimized default | |
| 24 | + | **Rationale**: | |
| 25 | + | - Minimal memory caching strategy designed for document viewing | |
| 26 | + | - 40-60% less memory usage than WEBKIT_CACHE_MODEL_WEB_BROWSER | |
| 27 | + | - Still maintains reasonable performance for typical browsing | |
| 28 | + | - Configurable - users can choose more performance-oriented models | |
| 29 | + | ||
| 30 | + | **Alternatives considered**: | |
| 31 | + | - Custom cache implementation: Too complex, reinvents WebKit internals | |
| 32 | + | - Disable all caching: Would break many websites and hurt performance significantly | |
| 33 | + | - Dynamic cache model switching: Added complexity without clear benefits | |
| 34 | + | ||
| 35 | + | ### Process Recycling Implementation | |
| 36 | + | **Decision**: Track page load count per WebView, recommend recycling at configurable threshold | |
| 37 | + | **Rationale**: | |
| 38 | + | - WebKit processes accumulate memory over time due to fragmentation | |
| 39 | + | - Simple counter-based approach is reliable and predictable | |
| 40 | + | - User/application controls recycling policy, not automatic termination | |
| 41 | + | - Preserves user experience while managing long-term memory growth | |
| 42 | + | ||
| 43 | + | **Alternatives considered**: | |
| 44 | + | - Time-based recycling: Unpredictable memory impact, could interrupt user workflows | |
| 45 | + | - Memory-threshold recycling: More complex, could lead to recycling loops | |
| 46 | + | - Automatic process termination: Too aggressive, would lose user state | |
| 47 | + | ||
| 48 | + | ### Memory Monitoring Architecture | |
| 49 | + | **Decision**: Hybrid approach using /proc filesystem + WebKit memory pressure callbacks | |
| 50 | + | **Rationale**: | |
| 51 | + | - /proc provides detailed RSS/VmSize metrics for external monitoring tools | |
| 52 | + | - WebKit callbacks provide internal memory pressure events | |
| 53 | + | - Both together give complete picture for debugging and optimization | |
| 54 | + | - Enables both real-time monitoring and historical tracking | |
| 55 | + | ||
| 56 | + | **Alternatives considered**: | |
| 57 | + | - WebKit-only monitoring: Limited visibility into actual system memory usage | |
| 58 | + | - /proc-only monitoring: Misses WebKit internal memory pressure states | |
| 59 | + | - External monitoring tools: Adds dependencies, complicates deployment | |
| 60 | + | ||
| 61 | + | ### Configuration Preset Strategy | |
| 62 | + | **Decision**: Provide three preset configurations (memory-optimized, balanced, performance) | |
| 63 | + | **Rationale**: | |
| 64 | + | - Covers main use cases: low-memory devices, general use, high-performance needs | |
| 65 | + | - Simple choice for users, comprehensive under the hood | |
| 66 | + | - Provides sensible defaults while allowing customization | |
| 67 | + | - Aligns with constitutional simplicity requirements | |
| 68 | + | ||
| 69 | + | **Alternatives considered**: | |
| 70 | + | - Single configuration: Too rigid, doesn't accommodate different hardware/needs | |
| 71 | + | - Extensive customization UI: Too complex, violates constitutional simplicity | |
| 72 | + | - Auto-detection: Unpredictable, users prefer explicit control | |
| 73 | + | ||
| 74 | + | ## Implementation Technologies Confirmed | |
| 75 | + | ||
| 76 | + | ### Core Technologies | |
| 77 | + | - **WebKit2GTK 6.0**: Memory pressure settings, cache model control, JavaScript GC triggers | |
| 78 | + | - **GTK4**: Event handling, settings management (already in use) | |
| 79 | + | - **Go 1.25.1 + CGO**: C API bindings for WebKit memory features | |
| 80 | + | - **GLib/GObject**: Memory management, signal handling for WebKit callbacks | |
| 81 | + | ||
| 82 | + | ### Testing Technologies | |
| 83 | + | - **Go testing**: Unit tests for configuration validation, memory calculations | |
| 84 | + | - **Integration testing**: Real WebKit processes, actual memory measurement | |
| 85 | + | - **Contract testing**: WebKit C API contract verification | |
| 86 | + | - **Performance testing**: Memory usage benchmarking, garbage collection impact | |
| 87 | + | ||
| 88 | + | ### Monitoring Technologies | |
| 89 | + | - **/proc filesystem**: RSS, VmSize, VmPeak memory metrics | |
| 90 | + | - **WebKit memory pressure callbacks**: Internal memory state changes | |
| 91 | + | - **Go log package**: Structured logging for memory events | |
| 92 | + | - **Time-based metrics**: GC frequency, process lifecycle tracking | |
| 93 | + | ||
| 94 | + | ## Best Practices Research | |
| 95 | + | ||
| 96 | + | ### WebKit Memory Management Patterns | |
| 97 | + | - **Memory pressure thresholds**: Conservative (20-33%), Strict (35-50%), Kill (70-80%) | |
| 98 | + | - **Cache model hierarchy**: DocumentViewer < WebBrowser < PrimaryWebBrowser | |
| 99 | + | - **GC trigger frequency**: 30-120 second intervals optimal for background cleanup | |
| 100 | + | - **Process recycling**: 25-100 page loads typical before fragmentation impact | |
| 101 | + | ||
| 102 | + | ### CGO Memory Safety Patterns | |
| 103 | + | - **Finalizers**: Set on WebView structs to cleanup native resources | |
| 104 | + | - **Reference counting**: Proper release of WebKit objects via g_object_unref | |
| 105 | + | - **String handling**: CString/free pairs, defer cleanup patterns | |
| 106 | + | - **Callback safety**: Store Go pointers safely for C callback functions | |
| 107 | + | ||
| 108 | + | ### Performance Impact Mitigation | |
| 109 | + | - **Lazy configuration**: Apply memory settings only when explicitly requested | |
| 110 | + | - **Background operations**: GC and monitoring in separate goroutines | |
| 111 | + | - **Graceful degradation**: Fallback to defaults if memory APIs unavailable | |
| 112 | + | - **User control**: Make all optimizations opt-in to preserve existing performance | |
| 113 | + | ||
| 114 | + | ## Risk Assessment | |
| 115 | + | ||
| 116 | + | ### Low Risk | |
| 117 | + | - Configuration storage and validation (standard Go patterns) | |
| 118 | + | - Memory monitoring (/proc parsing is well-established) | |
| 119 | + | - Preset configurations (simple struct initialization) | |
| 120 | + | ||
| 121 | + | ### Medium Risk | |
| 122 | + | - WebKit memory pressure integration (newer API, potential version compatibility) | |
| 123 | + | - Process recycling coordination (timing and state management complexity) | |
| 124 | + | - CGO callback handling (requires careful pointer management) | |
| 125 | + | ||
| 126 | + | ### High Risk | |
| 127 | + | - Aggressive memory limits causing browser instability | |
| 128 | + | - Performance degradation from excessive GC or monitoring | |
| 129 | + | - Memory pressure false positives leading to unnecessary cleanup | |
| 130 | + | ||
| 131 | + | ### Mitigation Strategies | |
| 132 | + | - Comprehensive integration testing with real websites | |
| 133 | + | - Conservative default values with gradual optimization options | |
| 134 | + | - Extensive logging and monitoring to detect issues early | |
| 135 | + | - Graceful fallback when memory optimization fails | |
| 136 | + | ||
| 137 | + | ## Unknowns Resolved | |
| 138 | + | ✅ All technical unknowns from Phase 0 resolved | |
| 139 | + | ✅ WebKit2GTK 6.0 memory APIs researched and confirmed available | |
| 140 | + | ✅ CGO integration patterns established for memory management | |
| 141 | + | ✅ Testing approach defined for memory-related features | |
| 142 | + | ✅ Performance impact mitigation strategies identified⏎̸ | |
A specs/005-webkit-memory-optimization/spec.md +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | # Feature Specification: WebKit Memory Optimization | |
| 2 | + | ||
| 3 | + | **Feature Branch**: `005-webkit-memory-optimization` | |
| 4 | + | **Created**: 2025-01-10 | |
| 5 | + | **Status**: Draft | |
| 6 | + | **Input**: User description: "webkit-memory-optimization" | |
| 7 | + | ||
| 8 | + | ## Execution Flow (main) | |
| 9 | + | ``` | |
| 10 | + | 1. Parse user description from Input | |
| 11 | + | → Feature: Reduce WebKit browser memory usage from ~400MB per instance | |
| 12 | + | 2. Extract key concepts from description | |
| 13 | + | → Actors: browser users, system administrators | |
| 14 | + | → Actions: browse websites with lower memory footprint | |
| 15 | + | → Data: memory usage metrics, configuration settings | |
| 16 | + | → Constraints: maintain browsing stability and reasonable performance | |
| 17 | + | 3. For each unclear aspect: | |
| 18 | + | → No major ambiguities identified | |
| 19 | + | 4. Fill User Scenarios & Testing section | |
| 20 | + | → Primary scenario: user browses multiple websites without high memory usage | |
| 21 | + | 5. Generate Functional Requirements | |
| 22 | + | → Each requirement focuses on memory reduction and monitoring capabilities | |
| 23 | + | 6. Identify Key Entities | |
| 24 | + | → Memory configurations, usage statistics, optimization settings | |
| 25 | + | 7. Run Review Checklist | |
| 26 | + | → No [NEEDS CLARIFICATION] markers present | |
| 27 | + | → Implementation details excluded from spec | |
| 28 | + | 8. Return: SUCCESS (spec ready for planning) | |
| 29 | + | ``` | |
| 30 | + | ||
| 31 | + | --- | |
| 32 | + | ||
| 33 | + | ## ⚡ Quick Guidelines | |
| 34 | + | - ✅ Focus on WHAT users need and WHY | |
| 35 | + | - ❌ Avoid HOW to implement (no tech stack, APIs, code structure) | |
| 36 | + | - 👥 Written for business stakeholders, not developers | |
| 37 | + | ||
| 38 | + | --- | |
| 39 | + | ||
| 40 | + | ## User Scenarios & Testing *(mandatory)* | |
| 41 | + | ||
| 42 | + | ### Primary User Story | |
| 43 | + | As a browser user, I want the browser to consume significantly less memory (40-60% reduction from current ~400MB per website) so that I can keep more browser instances open on my system without experiencing performance degradation or running out of memory. | |
| 44 | + | ||
| 45 | + | ### Acceptance Scenarios | |
| 46 | + | 1. **Given** a browser instance with default settings, **When** I navigate to a typical website, **Then** the browser should consume no more than 250MB of memory | |
| 47 | + | 2. **Given** I have browsed to 50 different web pages, **When** I check memory usage, **Then** memory consumption should remain stable and not exceed reasonable limits due to memory leaks | |
| 48 | + | 3. **Given** memory optimization is enabled, **When** the browser approaches memory limits, **Then** it should automatically clean up memory and maintain responsive performance | |
| 49 | + | 4. **Given** I configure memory optimization settings, **When** I restart the browser, **Then** my memory preferences should be preserved and applied | |
| 50 | + | ||
| 51 | + | ### Edge Cases | |
| 52 | + | - What happens when memory pressure is extremely high (system running out of RAM)? | |
| 53 | + | - How does the system handle memory optimization on low-memory devices (<4GB RAM)? | |
| 54 | + | - What occurs when a single webpage requires more memory than the configured limit? | |
| 55 | + | ||
| 56 | + | ## Requirements *(mandatory)* | |
| 57 | + | ||
| 58 | + | ### Functional Requirements | |
| 59 | + | - **FR-001**: System MUST reduce per-browser-instance memory usage by 40-60% compared to current baseline (~400MB to 150-250MB) | |
| 60 | + | - **FR-002**: System MUST provide configurable memory limits and thresholds for different usage scenarios | |
| 61 | + | - **FR-003**: Users MUST be able to choose between memory-optimized, balanced, and performance-optimized configurations | |
| 62 | + | - **FR-004**: System MUST automatically trigger memory cleanup when approaching configured memory limits | |
| 63 | + | - **FR-005**: System MUST monitor and report memory usage statistics for transparency | |
| 64 | + | - **FR-006**: System MUST maintain browser stability and prevent crashes during memory optimization | |
| 65 | + | - **FR-007**: System MUST preserve essential browsing functionality (navigation, page rendering, basic JavaScript support) | |
| 66 | + | - **FR-008**: Users MUST be able to manually trigger memory cleanup operations | |
| 67 | + | - **FR-009**: System MUST support process recycling to prevent long-term memory accumulation | |
| 68 | + | - **FR-010**: System MUST log memory optimization events for debugging and monitoring purposes | |
| 69 | + | ||
| 70 | + | ### Key Entities *(include if feature involves data)* | |
| 71 | + | - **Memory Configuration**: Settings that control memory optimization behavior, including memory limits, cleanup thresholds, caching policies, and garbage collection intervals | |
| 72 | + | - **Memory Statistics**: Real-time and historical data about browser memory usage, including RSS memory, virtual memory, page load counts, and cleanup events | |
| 73 | + | - **Optimization Preset**: Pre-configured sets of memory settings for different use cases (low-memory devices, balanced performance, high-performance scenarios) | |
| 74 | + | ||
| 75 | + | --- | |
| 76 | + | ||
| 77 | + | ## Review & Acceptance Checklist | |
| 78 | + | *GATE: Automated checks run during main() execution* | |
| 79 | + | ||
| 80 | + | ### Content Quality | |
| 81 | + | - [x] No implementation details (languages, frameworks, APIs) | |
| 82 | + | - [x] Focused on user value and business needs | |
| 83 | + | - [x] Written for non-technical stakeholders | |
| 84 | + | - [x] All mandatory sections completed | |
| 85 | + | ||
| 86 | + | ### Requirement Completeness | |
| 87 | + | - [x] No [NEEDS CLARIFICATION] markers remain | |
| 88 | + | - [x] Requirements are testable and unambiguous | |
| 89 | + | - [x] Success criteria are measurable | |
| 90 | + | - [x] Scope is clearly bounded | |
| 91 | + | - [x] Dependencies and assumptions identified | |
| 92 | + | ||
| 93 | + | --- | |
| 94 | + | ||
| 95 | + | ## Execution Status | |
| 96 | + | *Updated by main() during processing* | |
| 97 | + | ||
| 98 | + | - [x] User description parsed | |
| 99 | + | - [x] Key concepts extracted | |
| 100 | + | - [x] Ambiguities marked | |
| 101 | + | - [x] User scenarios defined | |
| 102 | + | - [x] Requirements generated | |
| 103 | + | - [x] Entities identified | |
| 104 | + | - [x] Review checklist passed | |
| 105 | + | ||
| 106 | + | ---⏎̸ | |
A specs/005-webkit-memory-optimization/tasks.md +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | # Tasks: WebKit Memory Optimization | |
| 2 | + | ||
| 3 | + | **Input**: Design documents from `/specs/005-webkit-memory-optimization/` | |
| 4 | + | **Prerequisites**: plan.md (required), research.md, data-model.md, contracts/ | |
| 5 | + | ||
| 6 | + | ## Execution Flow (main) | |
| 7 | + | ``` | |
| 8 | + | 1. Load plan.md from feature directory | |
| 9 | + | → Extract: Go 1.25.1 + CGO, WebKit2GTK 6.0, memory optimization approach | |
| 10 | + | 2. Load optional design documents: | |
| 11 | + | → data-model.md: MemoryConfig, MemoryStats, ProcessMemoryInfo, WebViewMemoryManager → model tasks | |
| 12 | + | → contracts/webkit_memory_api.go: 4 interfaces → contract test tasks | |
| 13 | + | → quickstart.md: 5 validation scenarios → integration test tasks | |
| 14 | + | 3. Generate tasks by category: | |
| 15 | + | → Setup: CGO configuration, WebKit dependencies, linting | |
| 16 | + | → Tests: contract tests, integration tests for memory reduction | |
| 17 | + | → Core: memory configuration, stats tracking, WebKit integration | |
| 18 | + | → Integration: process monitoring, GC management, recycling logic | |
| 19 | + | → Polish: unit tests, performance validation, documentation | |
| 20 | + | 4. Apply task rules: | |
| 21 | + | → Different files = mark [P] for parallel | |
| 22 | + | → WebKit integration = sequential (shared CGO context) | |
| 23 | + | → Tests before implementation (TDD) | |
| 24 | + | 5. Number tasks sequentially (T001, T002...) | |
| 25 | + | 6. Generate dependency graph | |
| 26 | + | 7. Create parallel execution examples | |
| 27 | + | 8. Validate: All contracts tested, all entities modeled, all scenarios covered | |
| 28 | + | 9. Return: SUCCESS (tasks ready for execution) | |
| 29 | + | ``` | |
| 30 | + | ||
| 31 | + | ## Format: `[ID] [P?] Description` | |
| 32 | + | - **[P]**: Can run in parallel (different files, no dependencies) | |
| 33 | + | - Include exact file paths in descriptions | |
| 34 | + | ||
| 35 | + | ## Path Conventions | |
| 36 | + | - **Single project**: `pkg/webkit/` for WebKit bindings enhancement | |
| 37 | + | - Tests in `tests/contract/`, `tests/integration/`, `tests/unit/` | |
| 38 | + | - Memory optimization files in `pkg/webkit/` package | |
| 39 | + | ||
| 40 | + | ## Phase 3.1: Setup | |
| 41 | + | - [ ] T001 Verify WebKit2GTK 6.0+ dependencies and CGO build environment | |
| 42 | + | - [ ] T002 Configure webkit_cgo build tags in existing project structure | |
| 43 | + | - [ ] T003 [P] Update linting rules for CGO memory safety patterns | |
| 44 | + | ||
| 45 | + | ## Phase 3.2: Tests First (TDD) ⚠️ MUST COMPLETE BEFORE 3.3 | |
| 46 | + | **CRITICAL: These tests MUST be written and MUST FAIL before ANY implementation** | |
| 47 | + | - [ ] T004 [P] Contract test WebKitMemoryAPI in tests/contract/test_webkit_memory_api_test.go | |
| 48 | + | - [ ] T005 [P] Contract test MemoryConfigAPI in tests/contract/test_memory_config_api_test.go | |
| 49 | + | - [ ] T006 [P] Contract test MemoryStatsAPI in tests/contract/test_memory_stats_api_test.go | |
| 50 | + | - [ ] T007 [P] Contract test WebViewLifecycleAPI in tests/contract/test_webview_lifecycle_api_test.go | |
| 51 | + | - [ ] T008 [P] Integration test memory reduction scenario in tests/integration/test_memory_reduction_test.go | |
| 52 | + | - [ ] T009 [P] Integration test memory stability scenario in tests/integration/test_memory_stability_test.go | |
| 53 | + | - [ ] T010 [P] Integration test process recycling scenario in tests/integration/test_process_recycling_test.go | |
| 54 | + | - [ ] T011 [P] Integration test memory monitoring scenario in tests/integration/test_memory_monitoring_test.go | |
| 55 | + | - [ ] T012 [P] Integration test optimization presets scenario in tests/integration/test_optimization_presets_test.go | |
| 56 | + | ||
| 57 | + | ## Phase 3.3: Core Implementation (ONLY after tests are failing) | |
| 58 | + | - [ ] T013 [P] MemoryConfig struct with validation in pkg/webkit/memory_config.go | |
| 59 | + | - [ ] T014 [P] ProcessMemoryInfo struct and parsing logic in pkg/webkit/process_memory.go | |
| 60 | + | - [ ] T015 [P] Optimization preset configurations in pkg/webkit/memory_presets.go | |
| 61 | + | - [ ] T016 WebViewMemoryManager singleton in pkg/webkit/memory_manager.go | |
| 62 | + | - [ ] T017 WebKit memory pressure settings integration in pkg/webkit/webview_cgo.go | |
| 63 | + | - [ ] T018 Cache model configuration in pkg/webkit/webview_cgo.go | |
| 64 | + | - [ ] T019 JavaScript garbage collection triggers in pkg/webkit/webview_cgo.go | |
| 65 | + | - [ ] T020 Page cache and offline cache controls in pkg/webkit/webview_cgo.go | |
| 66 | + | - [ ] T021 Memory statistics tracking in pkg/webkit/webview_cgo.go | |
| 67 | + | ||
| 68 | + | ## Phase 3.4: Integration | |
| 69 | + | - [ ] T022 Process memory monitoring via /proc filesystem in pkg/webkit/memory_manager.go | |
| 70 | + | - [ ] T023 Memory pressure callback handling in pkg/webkit/webview_cgo.go | |
| 71 | + | - [ ] T024 Periodic garbage collection scheduler in pkg/webkit/webview_cgo.go | |
| 72 | + | - [ ] T025 Process recycling recommendation logic in pkg/webkit/memory_manager.go | |
| 73 | + | - [ ] T026 Memory event logging and monitoring in pkg/webkit/memory_manager.go | |
| 74 | + | ||
| 75 | + | ## Phase 3.5: Polish | |
| 76 | + | - [ ] T027 [P] Unit tests for MemoryConfig validation in tests/unit/test_memory_config_test.go | |
| 77 | + | - [ ] T028 [P] Unit tests for ProcessMemoryInfo parsing in tests/unit/test_process_memory_test.go | |
| 78 | + | - [ ] T029 [P] Unit tests for optimization presets in tests/unit/test_memory_presets_test.go | |
| 79 | + | - [ ] T030 Performance validation: 40-60% memory reduction benchmark | |
| 80 | + | - [ ] T031 [P] Update CLAUDE.md with memory optimization context | |
| 81 | + | - [ ] T032 Memory safety review for CGO pointer management | |
| 82 | + | - [ ] T033 Execute quickstart.md validation scenarios | |
| 83 | + | ||
| 84 | + | ## Dependencies | |
| 85 | + | - Setup (T001-T003) before all other tasks | |
| 86 | + | - Contract tests (T004-T007) before any implementation | |
| 87 | + | - Integration tests (T008-T012) before implementation | |
| 88 | + | - T013-T015 (data models) before T016-T021 (WebKit integration) | |
| 89 | + | - T017-T021 (WebKit changes) are sequential (shared webview_cgo.go file) | |
| 90 | + | - T022-T026 (integration features) depend on T016-T021 | |
| 91 | + | - Polish (T027-T033) after all core implementation | |
| 92 | + | ||
| 93 | + | ## Parallel Example | |
| 94 | + | ``` | |
| 95 | + | # Launch contract tests together (T004-T007): | |
| 96 | + | Task: "Contract test WebKitMemoryAPI in tests/contract/test_webkit_memory_api_test.go" | |
| 97 | + | Task: "Contract test MemoryConfigAPI in tests/contract/test_memory_config_api_test.go" | |
| 98 | + | Task: "Contract test MemoryStatsAPI in tests/contract/test_memory_stats_api_test.go" | |
| 99 | + | Task: "Contract test WebViewLifecycleAPI in tests/contract/test_webview_lifecycle_api_test.go" | |
| 100 | + | ||
| 101 | + | # Launch integration tests together (T008-T012): | |
| 102 | + | Task: "Integration test memory reduction in tests/integration/test_memory_reduction_test.go" | |
| 103 | + | Task: "Integration test memory stability in tests/integration/test_memory_stability_test.go" | |
| 104 | + | Task: "Integration test process recycling in tests/integration/test_process_recycling_test.go" | |
| 105 | + | ||
| 106 | + | # Launch data model implementations together (T013-T015): | |
| 107 | + | Task: "MemoryConfig struct with validation in pkg/webkit/memory_config.go" | |
| 108 | + | Task: "ProcessMemoryInfo struct and parsing in pkg/webkit/process_memory.go" | |
| 109 | + | Task: "Optimization preset configurations in pkg/webkit/memory_presets.go" | |
| 110 | + | ``` | |
| 111 | + | ||
| 112 | + | ## Notes | |
| 113 | + | - WebKit2GTK 6.0 memory pressure APIs are the core technical dependency | |
| 114 | + | - CGO memory safety requires careful pointer management and finalizers | |
| 115 | + | - Tests must use real WebKit processes for accurate memory measurements | |
| 116 | + | - Memory reduction target: 400MB → 150-250MB (40-60% reduction) | |
| 117 | + | - All WebKit integration changes modify shared webview_cgo.go file (sequential) | |
| 118 | + | ||
| 119 | + | ## Task Generation Rules | |
| 120 | + | *Applied during main() execution* | |
| 121 | + | ||
| 122 | + | 1. **From Contracts**: | |
| 123 | + | - WebKitMemoryAPI → T004 contract test, T017-T021 implementation | |
| 124 | + | - MemoryConfigAPI → T005 contract test, T013 implementation | |
| 125 | + | - MemoryStatsAPI → T006 contract test, T021 implementation | |
| 126 | + | - WebViewLifecycleAPI → T007 contract test, T025 implementation | |
| 127 | + | ||
| 128 | + | 2. **From Data Model**: | |
| 129 | + | - MemoryConfig entity → T013 model creation task [P] | |
| 130 | + | - ProcessMemoryInfo entity → T014 model creation task [P] | |
| 131 | + | - WebViewMemoryManager entity → T016 manager implementation | |
| 132 | + | - MemoryStats → integrated into WebView (T021) | |
| 133 | + | ||
| 134 | + | 3. **From Quickstart Scenarios**: | |
| 135 | + | - Memory reduction validation → T008 integration test [P] | |
| 136 | + | - Memory stability testing → T009 integration test [P] | |
| 137 | + | - Process recycling testing → T010 integration test [P] | |
| 138 | + | - Memory monitoring testing → T011 integration test [P] | |
| 139 | + | - Optimization presets testing → T012 integration test [P] | |
| 140 | + | ||
| 141 | + | 4. **Ordering**: | |
| 142 | + | - Setup → Contract tests → Integration tests → Models → WebKit integration → Polish | |
| 143 | + | - Data models before WebKit integration | |
| 144 | + | - WebKit integration tasks sequential due to shared file | |
| 145 | + | ||
| 146 | + | ## Validation Checklist | |
| 147 | + | *GATE: Checked by main() before returning* | |
| 148 | + | ||
| 149 | + | - [x] All contracts have corresponding tests (T004-T007) | |
| 150 | + | - [x] All entities have model tasks (T013-T016) | |
| 151 | + | - [x] All tests come before implementation (T004-T012 before T013+) | |
| 152 | + | - [x] Parallel tasks truly independent (different files, no shared dependencies) | |
| 153 | + | - [x] Each task specifies exact file path | |
| 154 | + | - [x] No task modifies same file as another [P] task (webview_cgo.go tasks are sequential) | |
| 155 | + | - [x] Memory reduction goal (40-60%) addressed in T008, T030 | |
| 156 | + | - [x] All quickstart scenarios covered (T008-T012, T033) | |
| 157 | + | - [x] CGO memory safety addressed (T032)⏎̸ | |
M tests/contract/browser_controls_test.go +48 -48
| @@ -7,21 +7,21 @@ import ( | |||
| 7 | 7 | "math" | |
| 8 | 8 | "testing" | |
| 9 | 9 | ||
| 10 | - | "github.com/bnema/dumber/services" | |
| 11 | 10 | "github.com/bnema/dumber/internal/config" | |
| 12 | 11 | mock_db "github.com/bnema/dumber/internal/db/mocks" | |
| 12 | + | "github.com/bnema/dumber/services" | |
| 13 | 13 | "github.com/golang/mock/gomock" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // floatMatcher implements gomock.Matcher for floating point comparison with tolerance | |
| 17 | 17 | type floatMatcher struct { | |
| 18 | - | expected float64 | |
| 18 | + | expected float64 | |
| 19 | 19 | tolerance float64 | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | func (f floatMatcher) Matches(x interface{}) bool { | |
| 23 | 23 | if actual, ok := x.(float64); ok { | |
| 24 | - | return math.Abs(actual - f.expected) < f.tolerance | |
| 24 | + | return math.Abs(actual-f.expected) < f.tolerance | |
| 25 | 25 | } | |
| 26 | 26 | return false | |
| 27 | 27 | } | |
| @@ -55,112 +55,112 @@ func TestBrowserService_ZoomControls_Contract(t *testing.T) { | |||
| 55 | 55 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |
| 56 | 56 | cfg := &config.Config{} | |
| 57 | 57 | service := services.NewBrowserService(cfg, mockDB) | |
| 58 | - | ||
| 58 | + | ||
| 59 | 59 | ctx := context.Background() | |
| 60 | 60 | testURL := "https://example.com" | |
| 61 | - | ||
| 61 | + | ||
| 62 | 62 | t.Run("ZoomIn increases zoom level by 0.1", func(t *testing.T) { | |
| 63 | 63 | // Setup mock expectations | |
| 64 | 64 | mockDB.EXPECT(). | |
| 65 | 65 | GetZoomLevel(ctx, testURL). | |
| 66 | 66 | Return(1.0, nil) | |
| 67 | - | ||
| 67 | + | ||
| 68 | 68 | mockDB.EXPECT(). | |
| 69 | 69 | SetZoomLevel(ctx, testURL, floatEq(1.1)). | |
| 70 | 70 | Return(nil) | |
| 71 | - | ||
| 71 | + | ||
| 72 | 72 | // Contract: ZoomIn should increase zoom by 0.1 and return new level | |
| 73 | 73 | newZoom, err := service.ZoomIn(ctx, testURL) | |
| 74 | - | ||
| 74 | + | ||
| 75 | 75 | // Contract assertions | |
| 76 | 76 | if err != nil { | |
| 77 | 77 | t.Fatalf("ZoomIn should not return error: %v", err) | |
| 78 | 78 | } | |
| 79 | - | ||
| 79 | + | ||
| 80 | 80 | expectedZoom := 1.1 | |
| 81 | 81 | if newZoom != expectedZoom { | |
| 82 | 82 | t.Errorf("Expected zoom level %f, got %f", expectedZoom, newZoom) | |
| 83 | 83 | } | |
| 84 | 84 | }) | |
| 85 | - | ||
| 85 | + | ||
| 86 | 86 | t.Run("ZoomOut decreases zoom level by 0.1", func(t *testing.T) { | |
| 87 | 87 | // Setup mock expectations for current zoom level | |
| 88 | 88 | mockDB.EXPECT(). | |
| 89 | 89 | GetZoomLevel(ctx, testURL). | |
| 90 | 90 | Return(1.2, nil) | |
| 91 | - | ||
| 91 | + | ||
| 92 | 92 | mockDB.EXPECT(). | |
| 93 | 93 | SetZoomLevel(ctx, testURL, floatEq(1.1)). | |
| 94 | 94 | Return(nil) | |
| 95 | - | ||
| 95 | + | ||
| 96 | 96 | // Contract: ZoomOut should decrease zoom by 0.1 and return new level | |
| 97 | 97 | newZoom, err := service.ZoomOut(ctx, testURL) | |
| 98 | - | ||
| 98 | + | ||
| 99 | 99 | // Contract assertions | |
| 100 | 100 | if err != nil { | |
| 101 | 101 | t.Fatalf("ZoomOut should not return error: %v", err) | |
| 102 | 102 | } | |
| 103 | - | ||
| 103 | + | ||
| 104 | 104 | expectedZoom := 1.1 | |
| 105 | - | if math.Abs(newZoom - expectedZoom) > 1e-10 { | |
| 105 | + | if math.Abs(newZoom-expectedZoom) > 1e-10 { | |
| 106 | 106 | t.Errorf("Expected zoom level %f, got %f", expectedZoom, newZoom) | |
| 107 | 107 | } | |
| 108 | 108 | }) | |
| 109 | - | ||
| 109 | + | ||
| 110 | 110 | t.Run("ZoomReset sets zoom to 1.0", func(t *testing.T) { | |
| 111 | 111 | // Setup mock expectations | |
| 112 | 112 | mockDB.EXPECT(). | |
| 113 | 113 | SetZoomLevel(ctx, testURL, floatEq(1.0)). | |
| 114 | 114 | Return(nil) | |
| 115 | - | ||
| 115 | + | ||
| 116 | 116 | // Contract: ZoomReset should always return zoom to default (1.0) | |
| 117 | 117 | newZoom, err := service.ResetZoom(ctx, testURL) | |
| 118 | - | ||
| 118 | + | ||
| 119 | 119 | // Contract assertions | |
| 120 | 120 | if err != nil { | |
| 121 | 121 | t.Fatalf("ResetZoom should not return error: %v", err) | |
| 122 | 122 | } | |
| 123 | - | ||
| 123 | + | ||
| 124 | 124 | expectedZoom := 1.0 | |
| 125 | 125 | if newZoom != expectedZoom { | |
| 126 | 126 | t.Errorf("ResetZoom should return 1.0, got %f", expectedZoom) | |
| 127 | 127 | } | |
| 128 | 128 | }) | |
| 129 | - | ||
| 129 | + | ||
| 130 | 130 | t.Run("GetZoomLevel returns current zoom level", func(t *testing.T) { | |
| 131 | 131 | // Setup mock expectations | |
| 132 | 132 | expectedZoom := 1.5 | |
| 133 | 133 | mockDB.EXPECT(). | |
| 134 | 134 | GetZoomLevel(ctx, testURL). | |
| 135 | 135 | Return(expectedZoom, nil) | |
| 136 | - | ||
| 136 | + | ||
| 137 | 137 | // Contract: GetZoomLevel should return current zoom level | |
| 138 | 138 | currentZoom, err := service.GetZoomLevel(ctx, testURL) | |
| 139 | - | ||
| 139 | + | ||
| 140 | 140 | // Contract assertions | |
| 141 | 141 | if err != nil { | |
| 142 | 142 | t.Fatalf("GetZoomLevel should not return error: %v", err) | |
| 143 | 143 | } | |
| 144 | - | ||
| 144 | + | ||
| 145 | 145 | if currentZoom != expectedZoom { | |
| 146 | 146 | t.Errorf("Expected zoom level %f, got %f", expectedZoom, currentZoom) | |
| 147 | 147 | } | |
| 148 | 148 | }) | |
| 149 | - | ||
| 149 | + | ||
| 150 | 150 | t.Run("GetZoomLevel returns default 1.0 for new URLs", func(t *testing.T) { | |
| 151 | 151 | // Setup mock expectations for non-existent URL | |
| 152 | 152 | mockDB.EXPECT(). | |
| 153 | 153 | GetZoomLevel(ctx, testURL). | |
| 154 | 154 | Return(0.0, sql.ErrNoRows) | |
| 155 | - | ||
| 155 | + | ||
| 156 | 156 | // Contract: GetZoomLevel should return 1.0 for URLs without zoom settings | |
| 157 | 157 | currentZoom, err := service.GetZoomLevel(ctx, testURL) | |
| 158 | - | ||
| 158 | + | ||
| 159 | 159 | // Contract assertions | |
| 160 | 160 | if err != nil { | |
| 161 | 161 | t.Fatalf("GetZoomLevel should not return error for new URLs: %v", err) | |
| 162 | 162 | } | |
| 163 | - | ||
| 163 | + | ||
| 164 | 164 | expectedZoom := 1.0 | |
| 165 | 165 | if currentZoom != expectedZoom { | |
| 166 | 166 | t.Errorf("Expected default zoom level %f for new URL, got %f", expectedZoom, currentZoom) | |
| @@ -176,23 +176,23 @@ func TestBrowserService_NavigationControls_Contract(t *testing.T) { | |||
| 176 | 176 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |
| 177 | 177 | cfg := &config.Config{} | |
| 178 | 178 | service := services.NewBrowserService(cfg, mockDB) | |
| 179 | - | ||
| 179 | + | ||
| 180 | 180 | ctx := context.Background() | |
| 181 | - | ||
| 181 | + | ||
| 182 | 182 | t.Run("GoBack handles gracefully when no history", func(t *testing.T) { | |
| 183 | 183 | // Contract: GoBack should handle empty history gracefully | |
| 184 | 184 | err := service.GoBack(ctx) | |
| 185 | - | ||
| 185 | + | ||
| 186 | 186 | // May return error for empty history, which is acceptable | |
| 187 | 187 | if err != nil { | |
| 188 | 188 | t.Logf("GoBack returned error (acceptable for empty history): %v", err) | |
| 189 | 189 | } | |
| 190 | 190 | }) | |
| 191 | - | ||
| 191 | + | ||
| 192 | 192 | t.Run("GoForward handles gracefully when no forward history", func(t *testing.T) { | |
| 193 | 193 | // Contract: GoForward should handle empty forward history gracefully | |
| 194 | 194 | err := service.GoForward(ctx) | |
| 195 | - | ||
| 195 | + | ||
| 196 | 196 | // May return error for empty forward history, which is acceptable | |
| 197 | 197 | if err != nil { | |
| 198 | 198 | t.Logf("GoForward returned error (acceptable for empty forward history): %v", err) | |
| @@ -208,18 +208,18 @@ func TestBrowserService_WindowTitleUpdate_Contract(t *testing.T) { | |||
| 208 | 208 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |
| 209 | 209 | cfg := &config.Config{} | |
| 210 | 210 | service := services.NewBrowserService(cfg, mockDB) | |
| 211 | - | ||
| 211 | + | ||
| 212 | 212 | // Setup mock window title updater | |
| 213 | 213 | mockUpdater := &MockWindowTitleUpdater{} | |
| 214 | 214 | service.SetWindowTitleUpdater(mockUpdater) | |
| 215 | - | ||
| 215 | + | ||
| 216 | 216 | t.Run("SetWindowTitleUpdater accepts WindowTitleUpdater interface", func(t *testing.T) { | |
| 217 | 217 | // Contract: Service should accept any WindowTitleUpdater implementation | |
| 218 | 218 | newMockUpdater := &MockWindowTitleUpdater{} | |
| 219 | - | ||
| 219 | + | ||
| 220 | 220 | // This should not panic or error | |
| 221 | 221 | service.SetWindowTitleUpdater(newMockUpdater) | |
| 222 | - | ||
| 222 | + | ||
| 223 | 223 | // Verify the updater was set properly | |
| 224 | 224 | if newMockUpdater.callCount < 0 { | |
| 225 | 225 | t.Errorf("WindowTitleUpdater should be properly initialized") | |
| @@ -235,31 +235,31 @@ func TestBrowserService_URLOperations_Contract(t *testing.T) { | |||
| 235 | 235 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |
| 236 | 236 | cfg := &config.Config{} | |
| 237 | 237 | service := services.NewBrowserService(cfg, mockDB) | |
| 238 | - | ||
| 238 | + | ||
| 239 | 239 | t.Run("GetCurrentURL returns current URL", func(t *testing.T) { | |
| 240 | 240 | // Contract: GetCurrentURL should return the current URL string | |
| 241 | 241 | ctx := context.Background() | |
| 242 | - | ||
| 242 | + | ||
| 243 | 243 | url, err := service.GetCurrentURL(ctx) | |
| 244 | - | ||
| 244 | + | ||
| 245 | 245 | // Contract assertions | |
| 246 | 246 | if err != nil { | |
| 247 | 247 | t.Fatalf("GetCurrentURL should not return error: %v", err) | |
| 248 | 248 | } | |
| 249 | - | ||
| 249 | + | ||
| 250 | 250 | // URL should be a valid string (can be empty for new browser) | |
| 251 | 251 | if url != "" && len(url) < 4 { // Minimum valid URL length | |
| 252 | 252 | t.Errorf("Invalid URL returned: %s", url) | |
| 253 | 253 | } | |
| 254 | 254 | }) | |
| 255 | - | ||
| 255 | + | ||
| 256 | 256 | t.Run("CopyCurrentURL accepts URL parameter", func(t *testing.T) { | |
| 257 | 257 | // Contract: CopyCurrentURL should accept URL and handle clipboard | |
| 258 | 258 | ctx := context.Background() | |
| 259 | 259 | testURL := "https://example.com" | |
| 260 | - | ||
| 260 | + | ||
| 261 | 261 | err := service.CopyCurrentURL(ctx, testURL) | |
| 262 | - | ||
| 262 | + | ||
| 263 | 263 | // Contract assertions - should not return error for valid URL | |
| 264 | 264 | if err != nil { | |
| 265 | 265 | t.Fatalf("CopyCurrentURL should not return error for valid URL: %v", err) | |
| @@ -276,27 +276,27 @@ func TestBrowserService_ErrorHandling_Contract(t *testing.T) { | |||
| 276 | 276 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |
| 277 | 277 | service := services.NewBrowserService(nil, mockDB) | |
| 278 | 278 | ctx := context.Background() | |
| 279 | - | ||
| 279 | + | ||
| 280 | 280 | t.Run("Service handles nil config gracefully", func(t *testing.T) { | |
| 281 | 281 | // Contract: Service methods should handle nil config gracefully | |
| 282 | - | ||
| 282 | + | ||
| 283 | 283 | // Setup mock expectations for operations that might still work | |
| 284 | 284 | mockDB.EXPECT(). | |
| 285 | 285 | GetZoomLevel(ctx, "test"). | |
| 286 | 286 | Return(1.0, nil). | |
| 287 | 287 | AnyTimes() | |
| 288 | - | ||
| 288 | + | ||
| 289 | 289 | mockDB.EXPECT(). | |
| 290 | 290 | SetZoomLevel(ctx, gomock.Any(), gomock.Any()). | |
| 291 | 291 | Return(nil). | |
| 292 | 292 | AnyTimes() | |
| 293 | - | ||
| 293 | + | ||
| 294 | 294 | // These should work with valid database but nil config | |
| 295 | 295 | _, err := service.ZoomIn(ctx, "test") | |
| 296 | 296 | if err != nil { | |
| 297 | 297 | t.Logf("ZoomIn returned error with nil config (may be expected): %v", err) | |
| 298 | 298 | } | |
| 299 | - | ||
| 299 | + | ||
| 300 | 300 | err = service.GoBack(ctx) | |
| 301 | 301 | if err != nil { | |
| 302 | 302 | t.Logf("GoBack returned error with nil config (may be expected): %v", err) | |
M tests/contract/test_keyboard_shortcuts.go +10 -6
| @@ -1,14 +1,18 @@ | |||
| 1 | 1 | package contract | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Contract: RegisterKeyboardShortcut binds accelerator without error and triggers callback on press. | |
| 9 | 9 | func Test_RegisterKeyboardShortcut_Contract(t *testing.T) { | |
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | |
| 12 | - | err = view.RegisterKeyboardShortcut("Ctrl+L", func() {}) | |
| 13 | - | if err != nil { t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err) } | |
| 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("NewWebView failed: %v", err) | |
| 13 | + | } | |
| 14 | + | err = view.RegisterKeyboardShortcut("Ctrl+L", func() {}) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err) | |
| 17 | + | } | |
| 14 | 18 | } | |
M tests/contract/test_lifecycle.go +15 -7
| @@ -1,15 +1,23 @@ | |||
| 1 | 1 | package contract | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Contract: Show/Hide/Destroy lifecycle operations succeed without error. | |
| 9 | 9 | func Test_Lifecycle_Contract(t *testing.T) { | |
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | |
| 12 | - | if err := view.Show(); err != nil { t.Fatalf("Show should succeed: %v", err) } | |
| 13 | - | if err := view.Hide(); err != nil { t.Fatalf("Hide should succeed: %v", err) } | |
| 14 | - | if err := view.Destroy(); err != nil { t.Fatalf("Destroy should succeed: %v", err) } | |
| 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("NewWebView failed: %v", err) | |
| 13 | + | } | |
| 14 | + | if err := view.Show(); err != nil { | |
| 15 | + | t.Fatalf("Show should succeed: %v", err) | |
| 16 | + | } | |
| 17 | + | if err := view.Hide(); err != nil { | |
| 18 | + | t.Fatalf("Hide should succeed: %v", err) | |
| 19 | + | } | |
| 20 | + | if err := view.Destroy(); err != nil { | |
| 21 | + | t.Fatalf("Destroy should succeed: %v", err) | |
| 22 | + | } | |
| 15 | 23 | } | |
M tests/contract/test_navigation.go +9 -9
| @@ -1,17 +1,17 @@ | |||
| 1 | 1 | package contract | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Contract: LoadURL navigates to a valid URL without error. | |
| 9 | 9 | func Test_LoadURL_Navigation_Contract(t *testing.T) { | |
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | - | if err != nil { | |
| 12 | - | t.Fatalf("NewWebView failed: %v", err) | |
| 13 | - | } | |
| 14 | - | if err := view.LoadURL("https://example.com"); err != nil { | |
| 15 | - | t.Fatalf("LoadURL should succeed, got: %v", err) | |
| 16 | - | } | |
| 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("NewWebView failed: %v", err) | |
| 13 | + | } | |
| 14 | + | if err := view.LoadURL("https://example.com"); err != nil { | |
| 15 | + | t.Fatalf("LoadURL should succeed, got: %v", err) | |
| 16 | + | } | |
| 17 | 17 | } | |
M tests/contract/test_script_injection.go +9 -7
| @@ -1,15 +1,17 @@ | |||
| 1 | 1 | package contract | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Contract: InjectScript executes JS without error once implemented. | |
| 9 | 9 | func Test_InjectScript_Contract(t *testing.T) { | |
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | |
| 12 | - | if err := view.InjectScript("console.log('hello')"); err != nil { | |
| 13 | - | t.Fatalf("InjectScript should succeed, got: %v", err) | |
| 14 | - | } | |
| 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("NewWebView failed: %v", err) | |
| 13 | + | } | |
| 14 | + | if err := view.InjectScript("console.log('hello')"); err != nil { | |
| 15 | + | t.Fatalf("InjectScript should succeed, got: %v", err) | |
| 16 | + | } | |
| 15 | 17 | } | |
M tests/contract/test_webview_creation.go +10 -10
| @@ -1,19 +1,19 @@ | |||
| 1 | 1 | package contract | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 4 | + | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 6 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | // Contract: NewWebView creates a WebView without error and allows initial configuration. | |
| 10 | 10 | func Test_NewWebView_Contract(t *testing.T) { | |
| 11 | - | cfg := &webkit.Config{InitialURL: "https://example.com", ZoomDefault: 1.0} | |
| 12 | - | view, err := webkit.NewWebView(cfg) | |
| 13 | - | if err != nil { | |
| 14 | - | t.Fatalf("expected NewWebView to succeed, got error: %v", err) | |
| 15 | - | } | |
| 16 | - | if view == nil { | |
| 17 | - | t.Fatalf("expected non-nil WebView") | |
| 18 | - | } | |
| 11 | + | cfg := &webkit.Config{InitialURL: "https://example.com", ZoomDefault: 1.0} | |
| 12 | + | view, err := webkit.NewWebView(cfg) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatalf("expected NewWebView to succeed, got error: %v", err) | |
| 15 | + | } | |
| 16 | + | if view == nil { | |
| 17 | + | t.Fatalf("expected non-nil WebView") | |
| 18 | + | } | |
| 19 | 19 | } | |
M tests/contract/test_zoom_control.go +16 -10
| @@ -1,18 +1,24 @@ | |||
| 1 | 1 | package contract | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Contract: SetZoom/GetZoom operate without error and persist value. | |
| 9 | 9 | func Test_ZoomControl_Contract(t *testing.T) { | |
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0}) | |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | |
| 12 | - | if err := view.SetZoom(1.25); err != nil { | |
| 13 | - | t.Fatalf("SetZoom should succeed, got: %v", err) | |
| 14 | - | } | |
| 15 | - | z, err := view.GetZoom() | |
| 16 | - | if err != nil { t.Fatalf("GetZoom should succeed, got: %v", err) } | |
| 17 | - | if z != 1.25 { t.Fatalf("expected zoom 1.25, got %v", z) } | |
| 10 | + | view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0}) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("NewWebView failed: %v", err) | |
| 13 | + | } | |
| 14 | + | if err := view.SetZoom(1.25); err != nil { | |
| 15 | + | t.Fatalf("SetZoom should succeed, got: %v", err) | |
| 16 | + | } | |
| 17 | + | z, err := view.GetZoom() | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatalf("GetZoom should succeed, got: %v", err) | |
| 20 | + | } | |
| 21 | + | if z != 1.25 { | |
| 22 | + | t.Fatalf("expected zoom 1.25, got %v", z) | |
| 23 | + | } | |
| 18 | 24 | } | |
M tests/integration/browser_controls_integration_test.go +61 -61
| @@ -34,14 +34,14 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) { | |||
| 34 | 34 | // Create temporary database file | |
| 35 | 35 | tmpDir := t.TempDir() | |
| 36 | 36 | dbPath := filepath.Join(tmpDir, "test_browser.db") | |
| 37 | - | ||
| 37 | + | ||
| 38 | 38 | database, err := sql.Open("sqlite3", dbPath) | |
| 39 | 39 | if err != nil { | |
| 40 | 40 | t.Fatalf("Failed to open test database: %v", err) | |
| 41 | 41 | } | |
| 42 | - | ||
| 42 | + | ||
| 43 | 43 | // Create tables using the migration schema | |
| 44 | - | createTablesSQL := ` | |
| 44 | + | createTablesSQL := ` | |
| 45 | 45 | CREATE TABLE IF NOT EXISTS history ( | |
| 46 | 46 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 47 | 47 | url TEXT NOT NULL, | |
| @@ -65,29 +65,29 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) { | |||
| 65 | 65 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| 66 | 66 | ); | |
| 67 | 67 | ` | |
| 68 | - | ||
| 68 | + | ||
| 69 | 69 | if _, err := database.Exec(createTablesSQL); err != nil { | |
| 70 | 70 | t.Fatalf("Failed to create test tables: %v", err) | |
| 71 | 71 | } | |
| 72 | - | ||
| 72 | + | ||
| 73 | 73 | queries := db.New(database) | |
| 74 | - | ||
| 74 | + | ||
| 75 | 75 | cleanup := func() { | |
| 76 | 76 | database.Close() | |
| 77 | 77 | os.Remove(dbPath) | |
| 78 | 78 | } | |
| 79 | - | ||
| 79 | + | ||
| 80 | 80 | return database, queries, cleanup | |
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | 83 | // setupTestBrowserService creates a BrowserService with test database | |
| 84 | 84 | func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) { | |
| 85 | 85 | _, queries, cleanup := setupTestDB(t) | |
| 86 | - | ||
| 86 | + | ||
| 87 | 87 | cfg := &config.Config{} | |
| 88 | - | ||
| 88 | + | ||
| 89 | 89 | service := services.NewBrowserService(cfg, queries) | |
| 90 | - | ||
| 90 | + | ||
| 91 | 91 | return service, cleanup | |
| 92 | 92 | } | |
| 93 | 93 | ||
| @@ -95,109 +95,109 @@ func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) { | |||
| 95 | 95 | func TestBrowserControls_ZoomPersistence_Integration(t *testing.T) { | |
| 96 | 96 | service, cleanup := setupTestBrowserService(t) | |
| 97 | 97 | defer cleanup() | |
| 98 | - | ||
| 98 | + | ||
| 99 | 99 | ctx := context.Background() | |
| 100 | 100 | testURL := "https://example.com" | |
| 101 | - | ||
| 101 | + | ||
| 102 | 102 | t.Run("Zoom level persists across service operations", func(t *testing.T) { | |
| 103 | 103 | // Set initial zoom level | |
| 104 | 104 | zoomLevel, err := service.ZoomIn(ctx, testURL) | |
| 105 | 105 | if err != nil { | |
| 106 | 106 | t.Fatalf("Failed to zoom in: %v", err) | |
| 107 | 107 | } | |
| 108 | - | ||
| 108 | + | ||
| 109 | 109 | expectedZoom := 1.1 | |
| 110 | 110 | if zoomLevel != expectedZoom { | |
| 111 | 111 | t.Errorf("Expected zoom %f, got %f", expectedZoom, zoomLevel) | |
| 112 | 112 | } | |
| 113 | - | ||
| 113 | + | ||
| 114 | 114 | // Zoom in again | |
| 115 | 115 | zoomLevel, err = service.ZoomIn(ctx, testURL) | |
| 116 | 116 | if err != nil { | |
| 117 | 117 | t.Fatalf("Failed to zoom in again: %v", err) | |
| 118 | 118 | } | |
| 119 | - | ||
| 119 | + | ||
| 120 | 120 | expectedZoom = 1.2 | |
| 121 | 121 | if zoomLevel != expectedZoom { | |
| 122 | 122 | t.Errorf("Expected zoom %f, got %f", expectedZoom, zoomLevel) | |
| 123 | 123 | } | |
| 124 | - | ||
| 124 | + | ||
| 125 | 125 | // Test that zoom persists for the same URL | |
| 126 | 126 | // Simulate getting zoom level (this would typically happen on page load) | |
| 127 | 127 | currentZoom, err := service.GetZoomLevel(ctx, testURL) | |
| 128 | 128 | if err != nil { | |
| 129 | 129 | t.Fatalf("Failed to get zoom level: %v", err) | |
| 130 | 130 | } | |
| 131 | - | ||
| 131 | + | ||
| 132 | 132 | if currentZoom != expectedZoom { | |
| 133 | 133 | t.Errorf("Zoom level not persisted. Expected %f, got %f", expectedZoom, currentZoom) | |
| 134 | 134 | } | |
| 135 | 135 | }) | |
| 136 | - | ||
| 136 | + | ||
| 137 | 137 | t.Run("Different URLs have independent zoom levels", func(t *testing.T) { | |
| 138 | 138 | url1 := "https://example.com" | |
| 139 | 139 | url2 := "https://google.com" | |
| 140 | - | ||
| 140 | + | ||
| 141 | 141 | // Set different zoom levels for different URLs | |
| 142 | 142 | zoom1, err := service.ZoomIn(ctx, url1) | |
| 143 | 143 | if err != nil { | |
| 144 | 144 | t.Fatalf("Failed to zoom in for url1: %v", err) | |
| 145 | 145 | } | |
| 146 | - | ||
| 146 | + | ||
| 147 | 147 | zoom2, err := service.ZoomOut(ctx, url2) | |
| 148 | 148 | if err != nil { | |
| 149 | 149 | t.Fatalf("Failed to zoom out for url2: %v", err) | |
| 150 | 150 | } | |
| 151 | - | ||
| 151 | + | ||
| 152 | 152 | // Verify they are different | |
| 153 | 153 | if zoom1 == zoom2 { | |
| 154 | 154 | t.Errorf("Expected different zoom levels for different URLs, both got %f", zoom1) | |
| 155 | 155 | } | |
| 156 | - | ||
| 156 | + | ||
| 157 | 157 | // Verify persistence for each URL | |
| 158 | 158 | persistedZoom1, err := service.GetZoomLevel(ctx, url1) | |
| 159 | 159 | if err != nil { | |
| 160 | 160 | t.Fatalf("Failed to get zoom level for url1: %v", err) | |
| 161 | 161 | } | |
| 162 | - | ||
| 162 | + | ||
| 163 | 163 | persistedZoom2, err := service.GetZoomLevel(ctx, url2) | |
| 164 | 164 | if err != nil { | |
| 165 | 165 | t.Fatalf("Failed to get zoom level for url2: %v", err) | |
| 166 | 166 | } | |
| 167 | - | ||
| 167 | + | ||
| 168 | 168 | if persistedZoom1 != zoom1 { | |
| 169 | 169 | t.Errorf("URL1 zoom not persisted. Expected %f, got %f", zoom1, persistedZoom1) | |
| 170 | 170 | } | |
| 171 | - | ||
| 171 | + | ||
| 172 | 172 | if persistedZoom2 != zoom2 { | |
| 173 | 173 | t.Errorf("URL2 zoom not persisted. Expected %f, got %f", zoom2, persistedZoom2) | |
| 174 | 174 | } | |
| 175 | 175 | }) | |
| 176 | - | ||
| 176 | + | ||
| 177 | 177 | t.Run("Zoom reset works correctly", func(t *testing.T) { | |
| 178 | 178 | testURL := "https://test.com" | |
| 179 | - | ||
| 179 | + | ||
| 180 | 180 | // Change zoom level | |
| 181 | 181 | service.ZoomIn(ctx, testURL) | |
| 182 | 182 | service.ZoomIn(ctx, testURL) | |
| 183 | - | ||
| 183 | + | ||
| 184 | 184 | // Reset zoom | |
| 185 | 185 | resetZoom, err := service.ResetZoom(ctx, testURL) | |
| 186 | 186 | if err != nil { | |
| 187 | 187 | t.Fatalf("Failed to reset zoom: %v", err) | |
| 188 | 188 | } | |
| 189 | - | ||
| 189 | + | ||
| 190 | 190 | expectedZoom := 1.0 | |
| 191 | 191 | if resetZoom != expectedZoom { | |
| 192 | 192 | t.Errorf("Expected reset zoom %f, got %f", expectedZoom, resetZoom) | |
| 193 | 193 | } | |
| 194 | - | ||
| 194 | + | ||
| 195 | 195 | // Verify persistence of reset | |
| 196 | 196 | persistedZoom, err := service.GetZoomLevel(ctx, testURL) | |
| 197 | 197 | if err != nil { | |
| 198 | 198 | t.Fatalf("Failed to get zoom level after reset: %v", err) | |
| 199 | 199 | } | |
| 200 | - | ||
| 200 | + | ||
| 201 | 201 | if persistedZoom != expectedZoom { | |
| 202 | 202 | t.Errorf("Reset zoom not persisted. Expected %f, got %f", expectedZoom, persistedZoom) | |
| 203 | 203 | } | |
| @@ -208,24 +208,24 @@ func TestBrowserControls_ZoomPersistence_Integration(t *testing.T) { | |||
| 208 | 208 | func TestBrowserControls_NavigationHistory_Integration(t *testing.T) { | |
| 209 | 209 | _, cleanup := setupTestBrowserService(t) | |
| 210 | 210 | defer cleanup() | |
| 211 | - | ||
| 211 | + | ||
| 212 | 212 | ctx := context.Background() | |
| 213 | - | ||
| 213 | + | ||
| 214 | 214 | t.Run("Navigation builds history correctly", func(t *testing.T) { | |
| 215 | 215 | // This test assumes the service has methods to add history entries | |
| 216 | 216 | // and that navigation uses real history data | |
| 217 | - | ||
| 217 | + | ||
| 218 | 218 | // Add some test history entries directly to database for testing | |
| 219 | 219 | database, queries, dbCleanup := setupTestDB(t) | |
| 220 | 220 | defer dbCleanup() | |
| 221 | - | ||
| 221 | + | ||
| 222 | 222 | // Insert test history entries | |
| 223 | 223 | testURLs := []string{ | |
| 224 | 224 | "https://example.com", | |
| 225 | - | "https://google.com", | |
| 225 | + | "https://google.com", | |
| 226 | 226 | "https://github.com", | |
| 227 | 227 | } | |
| 228 | - | ||
| 228 | + | ||
| 229 | 229 | for _, url := range testURLs { | |
| 230 | 230 | _, err := database.Exec( | |
| 231 | 231 | `INSERT INTO history (url, title, visit_count) VALUES (?, ?, ?)`, | |
| @@ -235,16 +235,16 @@ func TestBrowserControls_NavigationHistory_Integration(t *testing.T) { | |||
| 235 | 235 | t.Fatalf("Failed to insert test history: %v", err) | |
| 236 | 236 | } | |
| 237 | 237 | } | |
| 238 | - | ||
| 238 | + | ||
| 239 | 239 | // Create service with populated database | |
| 240 | 240 | serviceWithHistory := services.NewBrowserService(&config.Config{}, queries) | |
| 241 | - | ||
| 241 | + | ||
| 242 | 242 | // Test navigation operations | |
| 243 | 243 | err := serviceWithHistory.GoBack(ctx) | |
| 244 | 244 | if err != nil { | |
| 245 | 245 | t.Logf("GoBack returned error (acceptable for test): %v", err) | |
| 246 | 246 | } | |
| 247 | - | ||
| 247 | + | ||
| 248 | 248 | err = serviceWithHistory.GoForward(ctx) | |
| 249 | 249 | if err != nil { | |
| 250 | 250 | t.Logf("GoForward returned error (acceptable for test): %v", err) | |
| @@ -256,29 +256,29 @@ func TestBrowserControls_NavigationHistory_Integration(t *testing.T) { | |||
| 256 | 256 | func TestBrowserControls_WindowTitleUpdater_Integration(t *testing.T) { | |
| 257 | 257 | service, cleanup := setupTestBrowserService(t) | |
| 258 | 258 | defer cleanup() | |
| 259 | - | ||
| 259 | + | ||
| 260 | 260 | // Create mock window title updater | |
| 261 | 261 | mockUpdater := &MockWindowUpdater{ | |
| 262 | 262 | titles: make([]string, 0), | |
| 263 | 263 | } | |
| 264 | - | ||
| 264 | + | ||
| 265 | 265 | // This test would need the actual WindowTitleUpdater interface to be implemented | |
| 266 | 266 | // For now, we test that the service accepts the updater | |
| 267 | 267 | t.Run("Service integrates with WindowTitleUpdater", func(t *testing.T) { | |
| 268 | 268 | // This is a placeholder test - would need actual implementation | |
| 269 | 269 | // to verify title updates during navigation operations | |
| 270 | - | ||
| 270 | + | ||
| 271 | 271 | service.SetWindowTitleUpdater(mockUpdater) | |
| 272 | - | ||
| 272 | + | ||
| 273 | 273 | // Simulate navigation that should trigger title update | |
| 274 | 274 | ctx := context.Background() | |
| 275 | 275 | service.GoBack(ctx) | |
| 276 | - | ||
| 276 | + | ||
| 277 | 277 | // In a full integration test, we would verify that: | |
| 278 | 278 | // 1. Title updates were called | |
| 279 | 279 | // 2. Correct titles were set | |
| 280 | 280 | // 3. Updates happened at appropriate times | |
| 281 | - | ||
| 281 | + | ||
| 282 | 282 | // For now, just verify no panic occurred | |
| 283 | 283 | if len(mockUpdater.titles) < 0 { | |
| 284 | 284 | t.Errorf("WindowTitleUpdater integration failed") | |
| @@ -290,17 +290,17 @@ func TestBrowserControls_WindowTitleUpdater_Integration(t *testing.T) { | |||
| 290 | 290 | func TestBrowserControls_URLCopying_Integration(t *testing.T) { | |
| 291 | 291 | service, cleanup := setupTestBrowserService(t) | |
| 292 | 292 | defer cleanup() | |
| 293 | - | ||
| 293 | + | ||
| 294 | 294 | t.Run("URL copying returns current browser URL", func(t *testing.T) { | |
| 295 | 295 | ctx := context.Background() | |
| 296 | - | ||
| 296 | + | ||
| 297 | 297 | // Test URL copying functionality | |
| 298 | 298 | testURL := "https://example.com" | |
| 299 | 299 | err := service.CopyCurrentURL(ctx, testURL) | |
| 300 | 300 | if err != nil { | |
| 301 | 301 | t.Fatalf("CopyCurrentURL failed: %v", err) | |
| 302 | 302 | } | |
| 303 | - | ||
| 303 | + | ||
| 304 | 304 | // In a full integration test, we would also verify: | |
| 305 | 305 | // 1. System clipboard is updated | |
| 306 | 306 | // 2. Correct URL format | |
| @@ -312,38 +312,38 @@ func TestBrowserControls_URLCopying_Integration(t *testing.T) { | |||
| 312 | 312 | func TestBrowserControls_Performance_Integration(t *testing.T) { | |
| 313 | 313 | service, cleanup := setupTestBrowserService(t) | |
| 314 | 314 | defer cleanup() | |
| 315 | - | ||
| 315 | + | ||
| 316 | 316 | ctx := context.Background() | |
| 317 | 317 | testURL := "https://example.com" | |
| 318 | - | ||
| 318 | + | ||
| 319 | 319 | t.Run("Zoom operations complete within performance requirements", func(t *testing.T) { | |
| 320 | 320 | // Spec requires <50ms for history search, similar expectation for zoom | |
| 321 | 321 | start := time.Now() | |
| 322 | - | ||
| 322 | + | ||
| 323 | 323 | _, err := service.ZoomIn(ctx, testURL) | |
| 324 | 324 | if err != nil { | |
| 325 | 325 | t.Fatalf("ZoomIn failed: %v", err) | |
| 326 | 326 | } | |
| 327 | - | ||
| 327 | + | ||
| 328 | 328 | duration := time.Since(start) | |
| 329 | - | ||
| 329 | + | ||
| 330 | 330 | // Zoom operations should be fast (<10ms typically) | |
| 331 | 331 | maxDuration := 50 * time.Millisecond | |
| 332 | 332 | if duration > maxDuration { | |
| 333 | 333 | t.Errorf("Zoom operation too slow: %v (max: %v)", duration, maxDuration) | |
| 334 | 334 | } | |
| 335 | 335 | }) | |
| 336 | - | ||
| 336 | + | ||
| 337 | 337 | t.Run("Navigation operations complete within reasonable time", func(t *testing.T) { | |
| 338 | 338 | start := time.Now() | |
| 339 | - | ||
| 339 | + | ||
| 340 | 340 | err := service.GoBack(ctx) | |
| 341 | 341 | if err != nil { | |
| 342 | 342 | t.Fatalf("GoBack failed: %v", err) | |
| 343 | 343 | } | |
| 344 | - | ||
| 344 | + | ||
| 345 | 345 | duration := time.Since(start) | |
| 346 | - | ||
| 346 | + | ||
| 347 | 347 | // Navigation should be reasonably fast | |
| 348 | 348 | maxDuration := 100 * time.Millisecond | |
| 349 | 349 | if duration > maxDuration { | |
| @@ -355,15 +355,15 @@ func TestBrowserControls_Performance_Integration(t *testing.T) { | |||
| 355 | 355 | // Helper function to verify database state | |
| 356 | 356 | func verifyZoomInDatabase(t *testing.T, queries *db.Queries, url string, expectedZoom float64) { | |
| 357 | 357 | _ = context.Background() // Test placeholder | |
| 358 | - | ||
| 358 | + | ||
| 359 | 359 | // This would use the actual SQLC generated methods | |
| 360 | 360 | // For now, it's a placeholder for the verification logic | |
| 361 | - | ||
| 361 | + | ||
| 362 | 362 | // zoom, err := queries.GetZoomLevel(ctx, url) | |
| 363 | 363 | // if err != nil { | |
| 364 | 364 | // t.Fatalf("Failed to get zoom from database: %v", err) | |
| 365 | 365 | // } | |
| 366 | - | // | |
| 366 | + | // | |
| 367 | 367 | // if zoom != expectedZoom { | |
| 368 | 368 | // t.Errorf("Database zoom mismatch. Expected %f, got %f", expectedZoom, zoom) | |
| 369 | 369 | // } | |
M tests/integration/test_external_shortcuts.go +9 -7
| @@ -1,15 +1,17 @@ | |||
| 1 | 1 | package integration | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Integration: keyboard shortcuts work on external sites once implemented. | |
| 9 | 9 | func Test_WebKit_ExternalShortcuts_Integration(t *testing.T) { | |
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | |
| 12 | - | if err := view.RegisterKeyboardShortcut("Ctrl+K", func(){}); err != nil { | |
| 13 | - | t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err) | |
| 14 | - | } | |
| 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("NewWebView failed: %v", err) | |
| 13 | + | } | |
| 14 | + | if err := view.RegisterKeyboardShortcut("Ctrl+K", func() {}); err != nil { | |
| 15 | + | t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err) | |
| 16 | + | } | |
| 15 | 17 | } | |
M tests/integration/test_google_navigation.go +9 -7
| @@ -1,15 +1,17 @@ | |||
| 1 | 1 | package integration | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Integration: navigate to google.fr without error once implemented. | |
| 9 | 9 | func Test_WebKit_GoogleNavigation_Integration(t *testing.T) { | |
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | |
| 12 | - | if err := view.LoadURL("https://www.google.fr"); err != nil { | |
| 13 | - | t.Fatalf("LoadURL should succeed, got: %v", err) | |
| 14 | - | } | |
| 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("NewWebView failed: %v", err) | |
| 13 | + | } | |
| 14 | + | if err := view.LoadURL("https://www.google.fr"); err != nil { | |
| 15 | + | t.Fatalf("LoadURL should succeed, got: %v", err) | |
| 16 | + | } | |
| 15 | 17 | } | |
M tests/integration/test_webkit_basic.go +9 -7
| @@ -1,15 +1,17 @@ | |||
| 1 | 1 | package integration | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Integration: basic WebKit window and navigation works. | |
| 9 | 9 | func Test_WebKit_BasicWindow_Integration(t *testing.T) { | |
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{InitialURL: "about:blank"}) | |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | |
| 12 | - | if err := view.LoadURL("https://example.com"); err != nil { | |
| 13 | - | t.Fatalf("LoadURL should succeed, got: %v", err) | |
| 14 | - | } | |
| 10 | + | view, err := webkit.NewWebView(&webkit.Config{InitialURL: "about:blank"}) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("NewWebView failed: %v", err) | |
| 13 | + | } | |
| 14 | + | if err := view.LoadURL("https://example.com"); err != nil { | |
| 15 | + | t.Fatalf("LoadURL should succeed, got: %v", err) | |
| 16 | + | } | |
| 15 | 17 | } | |
M tests/integration/test_zoom_persistence.go +16 -10
| @@ -1,18 +1,24 @@ | |||
| 1 | 1 | package integration | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Integration: zoom persists across operations (with DB) once implemented. | |
| 9 | 9 | func Test_WebKit_ZoomPersistence_Integration(t *testing.T) { | |
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0}) | |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | |
| 12 | - | if err := view.SetZoom(1.1); err != nil { | |
| 13 | - | t.Fatalf("SetZoom should succeed, got: %v", err) | |
| 14 | - | } | |
| 15 | - | z, err := view.GetZoom() | |
| 16 | - | if err != nil { t.Fatalf("GetZoom should succeed, got: %v", err) } | |
| 17 | - | if z != 1.1 { t.Fatalf("expected zoom 1.1, got %v", z) } | |
| 10 | + | view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0}) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("NewWebView failed: %v", err) | |
| 13 | + | } | |
| 14 | + | if err := view.SetZoom(1.1); err != nil { | |
| 15 | + | t.Fatalf("SetZoom should succeed, got: %v", err) | |
| 16 | + | } | |
| 17 | + | z, err := view.GetZoom() | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatalf("GetZoom should succeed, got: %v", err) | |
| 20 | + | } | |
| 21 | + | if z != 1.1 { | |
| 22 | + | t.Fatalf("expected zoom 1.1, got %v", z) | |
| 23 | + | } | |
| 18 | 24 | } | |
M tests/unit/test_error_handling.go +10 -11
| @@ -1,19 +1,18 @@ | |||
| 1 | 1 | package unit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "errors" | |
| 5 | - | "testing" | |
| 6 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "errors" | |
| 5 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 6 | + | "testing" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | // Unit: WebKit error handling should propagate errors with context once implemented. | |
| 10 | 10 | func Test_WebKit_ErrorHandling(t *testing.T) { | |
| 11 | - | _, err := webkit.NewWebView(nil) | |
| 12 | - | if err == nil { | |
| 13 | - | t.Fatalf("expected error prior to implementation (RED phase)") | |
| 14 | - | } | |
| 15 | - | if !errors.Is(err, webkit.ErrNotImplemented) { | |
| 16 | - | t.Fatalf("expected ErrNotImplemented, got %v", err) | |
| 17 | - | } | |
| 11 | + | _, err := webkit.NewWebView(nil) | |
| 12 | + | if err == nil { | |
| 13 | + | t.Fatalf("expected error prior to implementation (RED phase)") | |
| 14 | + | } | |
| 15 | + | if !errors.Is(err, webkit.ErrNotImplemented) { | |
| 16 | + | t.Fatalf("expected ErrNotImplemented, got %v", err) | |
| 17 | + | } | |
| 18 | 18 | } | |
| 19 | - | ||
M tests/unit/test_memory.go +5 -6
| @@ -1,14 +1,13 @@ | |||
| 1 | 1 | package unit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Memory test placeholder: baseline <100MB once implemented. | |
| 9 | 9 | func Test_WebKit_Memory_Baseline(t *testing.T) { | |
| 10 | - | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { | |
| 11 | - | t.Fatalf("expected construction to fail prior to implementation (RED phase)") | |
| 12 | - | } | |
| 10 | + | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { | |
| 11 | + | t.Fatalf("expected construction to fail prior to implementation (RED phase)") | |
| 12 | + | } | |
| 13 | 13 | } | |
| 14 | - | ||
M tests/unit/test_memory_management.go +5 -6
| @@ -1,14 +1,13 @@ | |||
| 1 | 1 | package unit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Unit: CGO memory management semantics (finalizers, ownership) to be verified post-implementation. | |
| 9 | 9 | func Test_WebKit_CGOMemory_Management(t *testing.T) { | |
| 10 | - | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { | |
| 11 | - | t.Fatalf("expected construction to fail prior to implementation (RED phase)") | |
| 12 | - | } | |
| 10 | + | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { | |
| 11 | + | t.Fatalf("expected construction to fail prior to implementation (RED phase)") | |
| 12 | + | } | |
| 13 | 13 | } | |
| 14 | - | ||
M tests/unit/test_performance.go +5 -6
| @@ -1,14 +1,13 @@ | |||
| 1 | 1 | package unit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "testing" | |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 4 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 5 | + | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Performance test placeholder: startup time <500ms (to be measured once implemented). | |
| 9 | 9 | func Test_WebKit_Performance_StartupTime(t *testing.T) { | |
| 10 | - | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { | |
| 11 | - | t.Fatalf("expected construction to fail prior to implementation (RED phase)") | |
| 12 | - | } | |
| 10 | + | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { | |
| 11 | + | t.Fatalf("expected construction to fail prior to implementation (RED phase)") | |
| 12 | + | } | |
| 13 | 13 | } | |
| 14 | - | ||