webkit: opt-in memory optimization with performance-first defaults; harden CGO wrappers; remove deprecated offline cache APIs; add env-based memory controls; update specs and tests
- commit
- aedb9698eb2f66199aee02ae4e9322e4be5da754
- author
- bamen · · 2025-09-09 21:23 UTC
- committer
- bamen · · 2025-09-09 19:41 UTC
- parent
- eea4870
- signature
- unsigned
55 files · +4221 -1885
M internal/cli/browse.go +22 -22
| @@ -17,11 +17,11 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // NewBrowseCmd creates the browse command | 18 | // NewBrowseCmd creates the browse command | ||
| 19 | func NewBrowseCmd() *cobra.Command { | 19 | func NewBrowseCmd() *cobra.Command { | ||
| 20 | - | var renderingMode string | 20 | + | var renderingMode string |
| 21 | 21 | ||||
| 22 | - | cmd := &cobra.Command{ | 22 | + | cmd := &cobra.Command{ |
| 23 | - | Use: "browse <url|query>", | 23 | + | Use: "browse <url|query>", |
| 24 | - | Short: "Browse a URL or search query", | 24 | + | Short: "Browse a URL or search query", |
| 25 | Long: `Browse a URL directly or use search shortcuts like: | 25 | Long: `Browse a URL directly or use search shortcuts like: | ||
| 26 | g:golang -> Google search for "golang" | 26 | g:golang -> Google search for "golang" | ||
| 27 | gh:cobra -> GitHub search for "cobra" | 27 | gh:cobra -> GitHub search for "cobra" | ||
| @@ -32,21 +32,21 @@ Direct URLs are also supported: | |||||
| 32 | https://example.com | 32 | https://example.com | ||
| 33 | example.com (automatically adds https://)`, | 33 | example.com (automatically adds https://)`, | ||
| 34 | Args: cobra.ExactArgs(1), | 34 | Args: cobra.ExactArgs(1), | ||
| 35 | - | RunE: func(_ *cobra.Command, args []string) error { | 35 | + | RunE: func(_ *cobra.Command, args []string) error { |
| 36 | - | // Propagate rendering mode to GUI process via env var | 36 | + | // Propagate rendering mode to GUI process via env var |
| 37 | - | if renderingMode != "" { | 37 | + | if renderingMode != "" { |
| 38 | - | rm := strings.ToLower(renderingMode) | 38 | + | rm := strings.ToLower(renderingMode) |
| 39 | - | switch rm { | 39 | + | switch rm { |
| 40 | - | case "auto", "gpu", "cpu": | 40 | + | case "auto", "gpu", "cpu": |
| 41 | - | _ = os.Setenv("DUMBER_RENDERING_MODE", rm) | 41 | + | _ = os.Setenv("DUMBER_RENDERING_MODE", rm) |
| 42 | - | default: | 42 | + | default: |
| 43 | - | return fmt.Errorf("invalid --rendering-mode: %s (expected auto|gpu|cpu)", renderingMode) | 43 | + | return fmt.Errorf("invalid --rendering-mode: %s (expected auto|gpu|cpu)", renderingMode) |
| 44 | - | } | 44 | + | } |
| 45 | - | } | 45 | + | } |
| 46 | - | cli, err := NewCLI() | 46 | + | cli, err := NewCLI() |
| 47 | - | if err != nil { | 47 | + | if err != nil { |
| 48 | - | return fmt.Errorf("failed to initialize CLI: %w", err) | 48 | + | return fmt.Errorf("failed to initialize CLI: %w", err) |
| 49 | - | } | 49 | + | } |
| 50 | defer func() { | 50 | defer func() { | ||
| 51 | if closeErr := cli.Close(); closeErr != nil { | 51 | if closeErr := cli.Close(); closeErr != nil { | ||
| 52 | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | 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 | 60 | + | // Flags |
| 61 | - | cmd.Flags().StringVar(&renderingMode, "rendering-mode", "", "Rendering mode: auto|gpu|cpu") | 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 | // browse handles the core browsing logic | 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 | ctx := context.Background() | 104 | ctx := context.Background() | ||
| 105 | - | 105 | + | ||
| 106 | limit, _ := cmd.Flags().GetInt("limit") | 106 | limit, _ := cmd.Flags().GetInt("limit") | ||
| 107 | verbose, _ := cmd.Flags().GetBool("verbose") | 107 | verbose, _ := cmd.Flags().GetBool("verbose") | ||
| 108 | 108 | ||||
| @@ -123,7 +123,7 @@ func listHistory(cmd *cobra.Command, _ []string) error { | |||||
| 123 | if verbose { | 123 | if verbose { | ||
| 124 | fmt.Fprintln(w, "ID\tVISITS\tLAST VISITED\tURL\tTITLE") | 124 | fmt.Fprintln(w, "ID\tVISITS\tLAST VISITED\tURL\tTITLE") | ||
| 125 | fmt.Fprintln(w, "--\t------\t------------\t---\t-----") | 125 | fmt.Fprintln(w, "--\t------\t------------\t---\t-----") | ||
| 126 | - | 126 | + | ||
| 127 | for _, entry := range history { | 127 | for _, entry := range history { | ||
| 128 | visits := "1" | 128 | visits := "1" | ||
| 129 | if entry.VisitCount.Valid { | 129 | if entry.VisitCount.Valid { | ||
| @@ -143,7 +143,7 @@ func listHistory(cmd *cobra.Command, _ []string) error { | |||||
| 143 | url := truncateString(entry.Url, 50) | 143 | url := truncateString(entry.Url, 50) | ||
| 144 | title = truncateString(title, 40) | 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 | entry.ID, visits, lastVisited, url, title) | 147 | entry.ID, visits, lastVisited, url, title) | ||
| 148 | } | 148 | } | ||
| 149 | } else { | 149 | } else { | ||
| @@ -250,7 +250,7 @@ func clearHistory(cmd *cobra.Command, _ []string) error { | |||||
| 250 | var response string | 250 | var response string | ||
| 251 | _, _ = fmt.Scanln(&response) | 251 | _, _ = fmt.Scanln(&response) | ||
| 252 | response = strings.ToLower(strings.TrimSpace(response)) | 252 | response = strings.ToLower(strings.TrimSpace(response)) | ||
| 253 | - | 253 | + | ||
| 254 | if response != "y" && response != "yes" { | 254 | if response != "y" && response != "yes" { | ||
| 255 | fmt.Println("Operation cancelled.") | 255 | fmt.Println("Operation cancelled.") | ||
| 256 | return nil | 256 | return nil | ||
| @@ -340,20 +340,20 @@ func showStats(_ *cobra.Command, _ []string) error { | |||||
| 340 | fmt.Println("Browsing History Statistics") | 340 | fmt.Println("Browsing History Statistics") | ||
| 341 | fmt.Println("==========================") | 341 | fmt.Println("==========================") | ||
| 342 | fmt.Printf("Total unique URLs: %d\n", totalEntries) | 342 | fmt.Printf("Total unique URLs: %d\n", totalEntries) | ||
| 343 | - | 343 | + | ||
| 344 | if totalVisits.Valid { | 344 | if totalVisits.Valid { | ||
| 345 | fmt.Printf("Total visits: %d\n", totalVisits.Int64) | 345 | fmt.Printf("Total visits: %d\n", totalVisits.Int64) | ||
| 346 | } | 346 | } | ||
| 347 | 347 | ||||
| 348 | if oldestDate.Valid && newestDate.Valid { | 348 | if oldestDate.Valid && newestDate.Valid { | ||
| 349 | - | fmt.Printf("Date range: %s to %s\n", | 349 | + | fmt.Printf("Date range: %s to %s\n", |
| 350 | - | oldestDate.Time.Format("2006-01-02"), | 350 | + | oldestDate.Time.Format("2006-01-02"), |
| 351 | newestDate.Time.Format("2006-01-02")) | 351 | newestDate.Time.Format("2006-01-02")) | ||
| 352 | } | 352 | } | ||
| 353 | 353 | ||||
| 354 | fmt.Println("\nTop 5 domains:") | 354 | fmt.Println("\nTop 5 domains:") | ||
| 355 | fmt.Println("--------------") | 355 | fmt.Println("--------------") | ||
| 356 | - | 356 | + | ||
| 357 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | 357 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | ||
| 358 | defer w.Flush() | 358 | defer w.Flush() | ||
| 359 | 359 | ||||
| @@ -363,7 +363,7 @@ func showStats(_ *cobra.Command, _ []string) error { | |||||
| 363 | for rows.Next() { | 363 | for rows.Next() { | ||
| 364 | var domain string | 364 | var domain string | ||
| 365 | var urlCount, visitCount int64 | 365 | var urlCount, visitCount int64 | ||
| 366 | - | 366 | + | ||
| 367 | if err := rows.Scan(&domain, &urlCount, &visitCount); err != nil { | 367 | if err := rows.Scan(&domain, &urlCount, &visitCount); err != nil { | ||
| 368 | continue | 368 | continue | ||
| 369 | } | 369 | } | ||
| @@ -377,4 +377,4 @@ func showStats(_ *cobra.Command, _ []string) error { | |||||
| 377 | } | 377 | } | ||
| 378 | 378 | ||||
| 379 | return nil | 379 | return nil | ||
| 380 | - | }⏎̸ | 380 | + | } |
M internal/config/config.go +84 -84
| @@ -16,23 +16,23 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | // Config represents the complete configuration for dumber. | 17 | // Config represents the complete configuration for dumber. | ||
| 18 | type Config struct { | 18 | type Config struct { | ||
| 19 | - | Database DatabaseConfig `mapstructure:"database" yaml:"database"` | 19 | + | Database DatabaseConfig `mapstructure:"database" yaml:"database"` |
| 20 | - | History HistoryConfig `mapstructure:"history" yaml:"history"` | 20 | + | History HistoryConfig `mapstructure:"history" yaml:"history"` |
| 21 | - | SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"` | 21 | + | SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"` |
| 22 | - | Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"` | 22 | + | Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"` |
| 23 | - | Logging LoggingConfig `mapstructure:"logging" yaml:"logging"` | 23 | + | Logging LoggingConfig `mapstructure:"logging" yaml:"logging"` |
| 24 | - | Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"` | 24 | + | Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"` |
| 25 | - | // RenderingMode controls GPU/CPU rendering selection for WebKit | 25 | + | // RenderingMode controls GPU/CPU rendering selection for WebKit |
| 26 | - | RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"` | 26 | + | RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"` |
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| 29 | // RenderingMode selects GPU vs CPU rendering. | 29 | // RenderingMode selects GPU vs CPU rendering. | ||
| 30 | type RenderingMode string | 30 | type RenderingMode string | ||
| 31 | 31 | ||||
| 32 | const ( | 32 | const ( | ||
| 33 | - | RenderingModeAuto RenderingMode = "auto" | 33 | + | RenderingModeAuto RenderingMode = "auto" |
| 34 | - | RenderingModeGPU RenderingMode = "gpu" | 34 | + | RenderingModeGPU RenderingMode = "gpu" |
| 35 | - | RenderingModeCPU RenderingMode = "cpu" | 35 | + | RenderingModeCPU RenderingMode = "cpu" |
| 36 | ) | 36 | ) | ||
| 37 | 37 | ||||
| 38 | // DatabaseConfig holds database-related configuration. | 38 | // DatabaseConfig holds database-related configuration. | ||
| @@ -52,8 +52,8 @@ type HistoryConfig struct { | |||||
| 52 | 52 | ||||
| 53 | // SearchShortcut represents a search shortcut configuration. | 53 | // SearchShortcut represents a search shortcut configuration. | ||
| 54 | type SearchShortcut struct { | 54 | type SearchShortcut struct { | ||
| 55 | - | URL string `mapstructure:"url" yaml:"url" json:"url"` | 55 | + | URL string `mapstructure:"url" yaml:"url" json:"url"` |
| 56 | - | Description string `mapstructure:"description" yaml:"description" json:"description"` | 56 | + | Description string `mapstructure:"description" yaml:"description" json:"description"` |
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | // DmenuConfig holds dmenu/rofi integration configuration. | 59 | // DmenuConfig holds dmenu/rofi integration configuration. | ||
| @@ -81,12 +81,12 @@ type LoggingConfig struct { | |||||
| 81 | 81 | ||||
| 82 | // AppearanceConfig holds UI/rendering preferences. | 82 | // AppearanceConfig holds UI/rendering preferences. | ||
| 83 | type AppearanceConfig struct { | 83 | type AppearanceConfig struct { | ||
| 84 | - | // Default fonts for pages that do not specify fonts. | 84 | + | // Default fonts for pages that do not specify fonts. |
| 85 | - | SansFont string `mapstructure:"sans_font" yaml:"sans_font"` | 85 | + | SansFont string `mapstructure:"sans_font" yaml:"sans_font"` |
| 86 | - | SerifFont string `mapstructure:"serif_font" yaml:"serif_font"` | 86 | + | SerifFont string `mapstructure:"serif_font" yaml:"serif_font"` |
| 87 | - | MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"` | 87 | + | MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"` |
| 88 | - | // Default font size in CSS pixels (approx). | 88 | + | // Default font size in CSS pixels (approx). |
| 89 | - | DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"` | 89 | + | DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"` |
| 90 | } | 90 | } | ||
| 91 | 91 | ||||
| 92 | // Manager handles configuration loading, watching, and reloading. | 92 | // Manager handles configuration loading, watching, and reloading. | ||
| @@ -119,7 +119,7 @@ func NewManager() (*Manager, error) { | |||||
| 119 | v.AutomaticEnv() | 119 | v.AutomaticEnv() | ||
| 120 | 120 | ||||
| 121 | // Bind specific environment variables | 121 | // Bind specific environment variables | ||
| 122 | - | bindings := map[string]string{ | 122 | + | bindings := map[string]string{ |
| 123 | "database.path": "DATABASE_PATH", | 123 | "database.path": "DATABASE_PATH", | ||
| 124 | "database.max_connections": "DATABASE_MAX_CONNECTIONS", | 124 | "database.max_connections": "DATABASE_MAX_CONNECTIONS", | ||
| 125 | "database.max_idle_time": "DATABASE_MAX_IDLE_TIME", | 125 | "database.max_idle_time": "DATABASE_MAX_IDLE_TIME", | ||
| @@ -147,16 +147,16 @@ func NewManager() (*Manager, error) { | |||||
| 147 | "logging.compress": "LOGGING_COMPRESS", | 147 | "logging.compress": "LOGGING_COMPRESS", | ||
| 148 | } | 148 | } | ||
| 149 | 149 | ||||
| 150 | - | for key, env := range bindings { | 150 | + | for key, env := range bindings { |
| 151 | - | if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil { | 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) | 152 | + | return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err) |
| 153 | - | } | 153 | + | } |
| 154 | - | } | 154 | + | } |
| 155 | 155 | ||||
| 156 | - | // Explicit binding for rendering mode via dedicated env var | 156 | + | // Explicit binding for rendering mode via dedicated env var |
| 157 | - | if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil { | 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) | 158 | + | return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err) |
| 159 | - | } | 159 | + | } |
| 160 | 160 | ||||
| 161 | return &Manager{ | 161 | return &Manager{ | ||
| 162 | viper: v, | 162 | viper: v, | ||
| @@ -189,11 +189,11 @@ func (m *Manager) Load() error { | |||||
| 189 | } | 189 | } | ||
| 190 | } | 190 | } | ||
| 191 | 191 | ||||
| 192 | - | // Unmarshal into config struct | 192 | + | // Unmarshal into config struct |
| 193 | - | config := &Config{} | 193 | + | config := &Config{} |
| 194 | - | if err := m.viper.Unmarshal(config); err != nil { | 194 | + | if err := m.viper.Unmarshal(config); err != nil { |
| 195 | - | return fmt.Errorf("failed to unmarshal config: %w", err) | 195 | + | return fmt.Errorf("failed to unmarshal config: %w", err) |
| 196 | - | } | 196 | + | } |
| 197 | 197 | ||||
| 198 | // Set database path if not specified | 198 | // Set database path if not specified | ||
| 199 | if config.Database.Path == "" { | 199 | if config.Database.Path == "" { | ||
| @@ -204,20 +204,20 @@ func (m *Manager) Load() error { | |||||
| 204 | config.Database.Path = dbPath | 204 | config.Database.Path = dbPath | ||
| 205 | } | 205 | } | ||
| 206 | 206 | ||||
| 207 | - | // Normalize/validate rendering mode | 207 | + | // Normalize/validate rendering mode |
| 208 | - | switch strings.ToLower(string(config.RenderingMode)) { | 208 | + | switch strings.ToLower(string(config.RenderingMode)) { |
| 209 | - | case "", string(RenderingModeAuto): | 209 | + | case "", string(RenderingModeAuto): |
| 210 | - | config.RenderingMode = RenderingModeAuto | 210 | + | config.RenderingMode = RenderingModeAuto |
| 211 | - | case string(RenderingModeGPU): | 211 | + | case string(RenderingModeGPU): |
| 212 | - | config.RenderingMode = RenderingModeGPU | 212 | + | config.RenderingMode = RenderingModeGPU |
| 213 | - | case string(RenderingModeCPU): | 213 | + | case string(RenderingModeCPU): |
| 214 | - | config.RenderingMode = RenderingModeCPU | 214 | + | config.RenderingMode = RenderingModeCPU |
| 215 | - | default: | 215 | + | default: |
| 216 | - | config.RenderingMode = RenderingModeAuto | 216 | + | config.RenderingMode = RenderingModeAuto |
| 217 | - | } | 217 | + | } |
| 218 | - | 218 | + | ||
| 219 | - | m.config = config | 219 | + | m.config = config |
| 220 | - | return nil | 220 | + | return nil |
| 221 | } | 221 | } | ||
| 222 | 222 | ||||
| 223 | // Get returns the current configuration (thread-safe). | 223 | // Get returns the current configuration (thread-safe). | ||
| @@ -277,10 +277,10 @@ func (m *Manager) reload() error { | |||||
| 277 | return err | 277 | return err | ||
| 278 | } | 278 | } | ||
| 279 | 279 | ||||
| 280 | - | config := &Config{} | 280 | + | config := &Config{} |
| 281 | - | if err := m.viper.Unmarshal(config); err != nil { | 281 | + | if err := m.viper.Unmarshal(config); err != nil { |
| 282 | - | return err | 282 | + | return err |
| 283 | - | } | 283 | + | } |
| 284 | 284 | ||||
| 285 | // Set database path if not specified | 285 | // Set database path if not specified | ||
| 286 | if config.Database.Path == "" { | 286 | if config.Database.Path == "" { | ||
| @@ -291,25 +291,25 @@ func (m *Manager) reload() error { | |||||
| 291 | config.Database.Path = dbPath | 291 | config.Database.Path = dbPath | ||
| 292 | } | 292 | } | ||
| 293 | 293 | ||||
| 294 | - | // Normalize/validate rendering mode | 294 | + | // Normalize/validate rendering mode |
| 295 | - | switch strings.ToLower(string(config.RenderingMode)) { | 295 | + | switch strings.ToLower(string(config.RenderingMode)) { |
| 296 | - | case "", string(RenderingModeAuto): | 296 | + | case "", string(RenderingModeAuto): |
| 297 | - | config.RenderingMode = RenderingModeAuto | 297 | + | config.RenderingMode = RenderingModeAuto |
| 298 | - | case string(RenderingModeGPU): | 298 | + | case string(RenderingModeGPU): |
| 299 | - | config.RenderingMode = RenderingModeGPU | 299 | + | config.RenderingMode = RenderingModeGPU |
| 300 | - | case string(RenderingModeCPU): | 300 | + | case string(RenderingModeCPU): |
| 301 | - | config.RenderingMode = RenderingModeCPU | 301 | + | config.RenderingMode = RenderingModeCPU |
| 302 | - | default: | 302 | + | default: |
| 303 | - | config.RenderingMode = RenderingModeAuto | 303 | + | config.RenderingMode = RenderingModeAuto |
| 304 | - | } | 304 | + | } |
| 305 | - | 305 | + | ||
| 306 | - | m.config = config | 306 | + | m.config = config |
| 307 | - | return nil | 307 | + | return nil |
| 308 | } | 308 | } | ||
| 309 | 309 | ||||
| 310 | // setDefaults sets default configuration values in Viper. | 310 | // setDefaults sets default configuration values in Viper. | ||
| 311 | func (m *Manager) setDefaults() { | 311 | func (m *Manager) setDefaults() { | ||
| 312 | - | defaults := DefaultConfig() | 312 | + | defaults := DefaultConfig() |
| 313 | 313 | ||||
| 314 | // Database defaults | 314 | // Database defaults | ||
| 315 | m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections) | 315 | m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections) | ||
| @@ -336,23 +336,23 @@ func (m *Manager) setDefaults() { | |||||
| 336 | m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat) | 336 | m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat) | ||
| 337 | m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount) | 337 | m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount) | ||
| 338 | 338 | ||||
| 339 | - | // Logging defaults | 339 | + | // Logging defaults |
| 340 | - | m.viper.SetDefault("logging.level", defaults.Logging.Level) | 340 | + | m.viper.SetDefault("logging.level", defaults.Logging.Level) |
| 341 | - | m.viper.SetDefault("logging.format", defaults.Logging.Format) | 341 | + | m.viper.SetDefault("logging.format", defaults.Logging.Format) |
| 342 | - | m.viper.SetDefault("logging.filename", defaults.Logging.Filename) | 342 | + | m.viper.SetDefault("logging.filename", defaults.Logging.Filename) |
| 343 | - | m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize) | 343 | + | m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize) |
| 344 | - | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) | 344 | + | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) |
| 345 | - | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | 345 | + | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) |
| 346 | - | m.viper.SetDefault("logging.compress", defaults.Logging.Compress) | 346 | + | m.viper.SetDefault("logging.compress", defaults.Logging.Compress) |
| 347 | - | 347 | + | ||
| 348 | - | // Appearance defaults | 348 | + | // Appearance defaults |
| 349 | - | m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont) | 349 | + | m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont) |
| 350 | - | m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont) | 350 | + | m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont) |
| 351 | - | m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont) | 351 | + | m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont) |
| 352 | - | m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize) | 352 | + | m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize) |
| 353 | - | 353 | + | ||
| 354 | - | // Rendering defaults | 354 | + | // Rendering defaults |
| 355 | - | m.viper.SetDefault("rendering_mode", string(RenderingModeAuto)) | 355 | + | m.viper.SetDefault("rendering_mode", string(RenderingModeAuto)) |
| 356 | } | 356 | } | ||
| 357 | 357 | ||||
| 358 | // createDefaultConfig creates a default configuration file. | 358 | // createDefaultConfig creates a default configuration file. | ||
M internal/config/defaults.go +18 -18
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | // DefaultConfig returns the default configuration values for dumber. | 8 | // DefaultConfig returns the default configuration values for dumber. | ||
| 9 | func DefaultConfig() *Config { | 9 | func DefaultConfig() *Config { | ||
| 10 | - | return &Config{ | 10 | + | return &Config{ |
| 11 | Database: DatabaseConfig{ | 11 | Database: DatabaseConfig{ | ||
| 12 | MaxConnections: 1, | 12 | MaxConnections: 1, | ||
| 13 | MaxIdleTime: time.Minute * 5, | 13 | MaxIdleTime: time.Minute * 5, | ||
| @@ -46,23 +46,23 @@ func DefaultConfig() *Config { | |||||
| 46 | DateFormat: "2006-01-02 15:04", | 46 | DateFormat: "2006-01-02 15:04", | ||
| 47 | SortByVisitCount: true, | 47 | SortByVisitCount: true, | ||
| 48 | }, | 48 | }, | ||
| 49 | - | Logging: LoggingConfig{ | 49 | + | Logging: LoggingConfig{ |
| 50 | - | Level: "info", | 50 | + | Level: "info", |
| 51 | - | Format: "text", // text or json | 51 | + | Format: "text", // text or json |
| 52 | - | Filename: "", // empty means stdout | 52 | + | Filename: "", // empty means stdout |
| 53 | - | MaxSize: 100, // MB | 53 | + | MaxSize: 100, // MB |
| 54 | - | MaxBackups: 3, | 54 | + | MaxBackups: 3, |
| 55 | - | MaxAge: 7, // days | 55 | + | MaxAge: 7, // days |
| 56 | - | Compress: true, | 56 | + | Compress: true, |
| 57 | - | }, | 57 | + | }, |
| 58 | - | Appearance: AppearanceConfig{ | 58 | + | Appearance: AppearanceConfig{ |
| 59 | - | SansFont: "Fira Sans", | 59 | + | SansFont: "Fira Sans", |
| 60 | - | SerifFont: "Fira Sans", | 60 | + | SerifFont: "Fira Sans", |
| 61 | - | MonospaceFont: "Fira Code", | 61 | + | MonospaceFont: "Fira Code", |
| 62 | - | DefaultFontSize: 16, | 62 | + | DefaultFontSize: 16, |
| 63 | - | }, | 63 | + | }, |
| 64 | - | RenderingMode: RenderingModeAuto, | 64 | + | RenderingMode: RenderingModeAuto, |
| 65 | - | } | 65 | + | } |
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | // GetDefaultSearchShortcuts returns the default search shortcuts. | 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 | return nil | 119 | return nil | ||
| 120 | - | }⏎̸ | 120 | + | } |
M internal/db/interfaces.go +1 -1
| @@ -34,4 +34,4 @@ type DatabaseQuerier interface { | |||||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | // Ensure that *Queries implements DatabaseQuerier interface | 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 | if domain == query { | 437 | if domain == query { | ||
| 438 | return true | 438 | return true | ||
| 439 | } | 439 | } | ||
| 440 | - | 440 | + | ||
| 441 | - | // Check if query is part of domain (e.g., "github" in "github.com" or "www.github.com") | 441 | + | // Check if query is part of domain (e.g., "github" in "github.com" or "www.github.com") |
| 442 | if strings.HasSuffix(domain, "."+query) || strings.HasPrefix(domain, query+".") { | 442 | if strings.HasSuffix(domain, "."+query) || strings.HasPrefix(domain, query+".") { | ||
| 443 | return true | 443 | return true | ||
| 444 | } | 444 | } | ||
| 445 | - | 445 | + | ||
| 446 | // Check if query matches a significant part of domain (not just a substring) | 446 | // Check if query matches a significant part of domain (not just a substring) | ||
| 447 | // Split domain by dots and check if any part matches query | 447 | // Split domain by dots and check if any part matches query | ||
| 448 | domainParts := strings.Split(domain, ".") | 448 | domainParts := strings.Split(domain, ".") | ||
| @@ -451,7 +451,7 @@ func (fm *FuzzyMatcher) isDomainMatch(query, url string) bool { | |||||
| 451 | return true | 451 | return true | ||
| 452 | } | 452 | } | ||
| 453 | } | 453 | } | ||
| 454 | - | 454 | + | ||
| 455 | return false | 455 | return false | ||
| 456 | } | 456 | } | ||
| 457 | 457 | ||||
M internal/parser/parser.go +32 -32
| @@ -1,12 +1,12 @@ | |||||
| 1 | package parser | 1 | package parser | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "fmt" | 4 | + | "fmt" |
| 5 | - | "strings" | 5 | + | neturl "net/url" |
| 6 | - | "time" | 6 | + | "strings" |
| 7 | - | neturl "net/url" | 7 | + | "time" |
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/dumber/internal/config" | 9 | + | "github.com/bnema/dumber/internal/config" |
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | // ParseInput parses user input and returns a ParseResult with URL and metadata. | 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 | // processShortcut processes a search shortcut and builds the final URL. | 158 | // processShortcut processes a search shortcut and builds the final URL. | ||
| 159 | func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) { | 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 | 162 | + | // Replace placeholder with query |
| 163 | - | q := neturl.QueryEscape(query) | 163 | + | q := neturl.QueryEscape(query) |
| 164 | - | finalURL := strings.ReplaceAll(urlTemplate, "{query}", q) | 164 | + | finalURL := strings.ReplaceAll(urlTemplate, "{query}", q) |
| 165 | - | finalURL = strings.ReplaceAll(finalURL, "%s", q) | 165 | + | finalURL = strings.ReplaceAll(finalURL, "%s", q) |
| 166 | 166 | ||||
| 167 | // Validate the resulting URL | 167 | // Validate the resulting URL | ||
| 168 | validator := NewURLValidator() | 168 | validator := NewURLValidator() | ||
| @@ -175,28 +175,28 @@ func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.Sear | |||||
| 175 | 175 | ||||
| 176 | // buildSearchURL builds a search URL using the default search engine. | 176 | // buildSearchURL builds a search URL using the default search engine. | ||
| 177 | func (p *Parser) buildSearchURL(searchEngine, query string) string { | 177 | func (p *Parser) buildSearchURL(searchEngine, query string) string { | ||
| 178 | - | q := neturl.QueryEscape(query) | 178 | + | q := neturl.QueryEscape(query) |
| 179 | - | // If no specific search engine, use Google as default | 179 | + | // If no specific search engine, use Google as default |
| 180 | - | if searchEngine == "" { | 180 | + | if searchEngine == "" { |
| 181 | - | defaultShortcut, exists := p.config.SearchShortcuts["g"] | 181 | + | defaultShortcut, exists := p.config.SearchShortcuts["g"] |
| 182 | - | if exists { | 182 | + | if exists { |
| 183 | - | url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q) | 183 | + | url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q) |
| 184 | - | url = strings.ReplaceAll(url, "%s", q) | 184 | + | url = strings.ReplaceAll(url, "%s", q) |
| 185 | - | return url | 185 | + | return url |
| 186 | - | } | 186 | + | } |
| 187 | - | // Fallback to Google if no "g" shortcut configured | 187 | + | // Fallback to Google if no "g" shortcut configured |
| 188 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | 188 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", q) |
| 189 | - | } | 189 | + | } |
| 190 | - | 190 | + | ||
| 191 | - | // Use specified search engine | 191 | + | // Use specified search engine |
| 192 | - | shortcut, exists := p.config.SearchShortcuts[searchEngine] | 192 | + | shortcut, exists := p.config.SearchShortcuts[searchEngine] |
| 193 | - | if !exists { | 193 | + | if !exists { |
| 194 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | 194 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", q) |
| 195 | - | } | 195 | + | } |
| 196 | - | 196 | + | ||
| 197 | - | u := strings.ReplaceAll(shortcut.URL, "{query}", q) | 197 | + | u := strings.ReplaceAll(shortcut.URL, "{query}", q) |
| 198 | - | u = strings.ReplaceAll(u, "%s", q) | 198 | + | u = strings.ReplaceAll(u, "%s", q) |
| 199 | - | return u | 199 | + | return u |
| 200 | } | 200 | } | ||
| 201 | 201 | ||||
| 202 | // FuzzySearchHistory performs fuzzy search on history and returns ranked matches. | 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 | import ( | 4 | import ( | ||
| 5 | "time" | 5 | "time" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/bnema/dumber/internal/db" | |||
| 8 | "github.com/bnema/dumber/internal/config" | 7 | "github.com/bnema/dumber/internal/config" | ||
| 8 | + | "github.com/bnema/dumber/internal/db" | |||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | // InputType represents the type of user input detected. | 11 | // InputType represents the type of user input detected. | ||
M main.go +622 -483
| @@ -1,26 +1,25 @@ | |||||
| 1 | package main | 1 | package main | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | 4 | + | "context" |
| 5 | - | "embed" | 5 | + | "embed" |
| 6 | - | "encoding/json" | 6 | + | "encoding/json" |
| 7 | - | "fmt" | 7 | + | "fmt" |
| 8 | - | "mime" | 8 | + | "log" |
| 9 | - | "log" | 9 | + | "mime" |
| 10 | - | "os" | 10 | + | neturl "net/url" |
| 11 | - | "runtime" | 11 | + | "os" |
| 12 | - | neturl "net/url" | 12 | + | "os/exec" |
| 13 | - | "path/filepath" | 13 | + | "path/filepath" |
| 14 | - | "strings" | 14 | + | "runtime" |
| 15 | - | "strconv" | 15 | + | "strconv" |
| 16 | - | "os/exec" | 16 | + | "strings" |
| 17 | - | 17 | + | ||
| 18 | - | 18 | + | "github.com/bnema/dumber/internal/cli" | |
| 19 | - | "github.com/bnema/dumber/internal/cli" | 19 | + | "github.com/bnema/dumber/internal/config" |
| 20 | - | "github.com/bnema/dumber/internal/config" | 20 | + | "github.com/bnema/dumber/internal/db" |
| 21 | - | "github.com/bnema/dumber/internal/db" | 21 | + | "github.com/bnema/dumber/pkg/webkit" |
| 22 | - | "github.com/bnema/dumber/services" | 22 | + | "github.com/bnema/dumber/services" |
| 23 | - | "github.com/bnema/dumber/pkg/webkit" | |||
| 24 | ) | 23 | ) | ||
| 25 | 24 | ||||
| 26 | // Build information set via ldflags | 25 | // Build information set via ldflags | ||
| @@ -37,8 +36,8 @@ func main() { | |||||
| 37 | return | 36 | return | ||
| 38 | } | 37 | } | ||
| 39 | 38 | ||||
| 40 | - | // Otherwise run the GUI browser | 39 | + | // Otherwise run the GUI browser |
| 41 | - | runBrowser() | 40 | + | runBrowser() |
| 42 | } | 41 | } | ||
| 43 | 42 | ||||
| 44 | // shouldRunCLI determines if we should run in CLI mode based on arguments | 43 | // shouldRunCLI determines if we should run in CLI mode based on arguments | ||
| @@ -107,7 +106,7 @@ func runCLI() { | |||||
| 107 | if err := rootCmd.Execute(); err != nil { | 106 | if err := rootCmd.Execute(); err != nil { | ||
| 108 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | 107 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 109 | os.Exit(1) | 108 | os.Exit(1) | ||
| 110 | - | } | 109 | + | } |
| 111 | } | 110 | } | ||
| 112 | 111 | ||||
| 113 | //go:embed frontend/dist | 112 | //go:embed frontend/dist | ||
| @@ -115,469 +114,609 @@ var assets embed.FS | |||||
| 115 | 114 | ||||
| 116 | // runBrowser launches a minimal native WebKit window and integrates services. | 115 | // runBrowser launches a minimal native WebKit window and integrates services. | ||
| 117 | func runBrowser() { | 116 | func runBrowser() { | ||
| 118 | - | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | 117 | + | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) |
| 119 | - | // GTK requires all UI calls to run on the main OS thread | 118 | + | // GTK requires all UI calls to run on the main OS thread |
| 120 | - | if webkit.IsNativeAvailable() { | 119 | + | if webkit.IsNativeAvailable() { |
| 121 | - | runtime.LockOSThread() | 120 | + | runtime.LockOSThread() |
| 122 | - | defer runtime.UnlockOSThread() | 121 | + | defer runtime.UnlockOSThread() |
| 123 | - | } | 122 | + | } |
| 124 | - | 123 | + | ||
| 125 | - | // Proactively harden media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins. | 124 | + | // Proactively harden media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins. |
| 126 | - | // Can be disabled with DUMBER_MEDIA_SAFE=0 | 125 | + | // Can be disabled with DUMBER_MEDIA_SAFE=0 |
| 127 | - | if os.Getenv("DUMBER_MEDIA_SAFE") != "0" { | 126 | + | if os.Getenv("DUMBER_MEDIA_SAFE") != "0" { |
| 128 | - | // Demote VAAPI-related elements so they won't be auto-picked | 127 | + | // Demote VAAPI-related elements so they won't be auto-picked |
| 129 | - | if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" { | 128 | + | if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" { |
| 130 | - | os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0") | 129 | + | os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0") |
| 131 | - | } | 130 | + | } |
| 132 | - | // Provide a safe audio sink to avoid autoaudiosink failures | 131 | + | // Provide a safe audio sink to avoid autoaudiosink failures |
| 133 | - | if os.Getenv("GST_AUDIO_SINK") == "" { | 132 | + | if os.Getenv("GST_AUDIO_SINK") == "" { |
| 134 | - | os.Setenv("GST_AUDIO_SINK", "fakesink") | 133 | + | os.Setenv("GST_AUDIO_SINK", "fakesink") |
| 135 | - | } | 134 | + | } |
| 136 | - | // Optionally disable media stream features that can engage complex pipelines | 135 | + | // Optionally disable media stream features that can engage complex pipelines |
| 137 | - | if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" { | 136 | + | if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" { |
| 138 | - | os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1") | 137 | + | os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1") |
| 139 | - | } | 138 | + | } |
| 140 | - | log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)") | 139 | + | log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)") |
| 141 | - | } | 140 | + | } |
| 142 | - | 141 | + | ||
| 143 | - | if err := config.Init(); err != nil { | 142 | + | if err := config.Init(); err != nil { |
| 144 | - | log.Fatalf("Failed to initialize config: %v", err) | 143 | + | log.Fatalf("Failed to initialize config: %v", err) |
| 145 | - | } | 144 | + | } |
| 146 | - | cfg := config.Get() | 145 | + | cfg := config.Get() |
| 147 | - | log.Printf("Config initialized") | 146 | + | log.Printf("Config initialized") |
| 148 | - | 147 | + | ||
| 149 | - | // Detect keyboard layout/locale and hint to webkit layer | 148 | + | // Detect keyboard layout/locale and hint to webkit layer |
| 150 | - | detectAndSetKeyboardLocale() | 149 | + | detectAndSetKeyboardLocale() |
| 151 | - | 150 | + | ||
| 152 | - | // Initialize database | 151 | + | // Initialize database |
| 153 | - | database, err := db.InitDB(cfg.Database.Path) | 152 | + | database, err := db.InitDB(cfg.Database.Path) |
| 154 | - | if err != nil { | 153 | + | if err != nil { |
| 155 | - | log.Fatalf("Failed to initialize database: %v", err) | 154 | + | log.Fatalf("Failed to initialize database: %v", err) |
| 156 | - | } | 155 | + | } |
| 157 | - | defer database.Close() | 156 | + | defer database.Close() |
| 158 | - | queries := db.New(database) | 157 | + | queries := db.New(database) |
| 159 | - | log.Printf("Database opened at %s", cfg.Database.Path) | 158 | + | log.Printf("Database opened at %s", cfg.Database.Path) |
| 160 | - | 159 | + | ||
| 161 | - | // Initialize services | 160 | + | // Initialize services |
| 162 | - | parserService := services.NewParserService(cfg, queries) | 161 | + | parserService := services.NewParserService(cfg, queries) |
| 163 | - | browserService := services.NewBrowserService(cfg, queries) | 162 | + | browserService := services.NewBrowserService(cfg, queries) |
| 164 | - | // JS controls injection is disabled; using native shortcuts via GTK. | 163 | + | // JS controls injection is disabled; using native shortcuts via GTK. |
| 165 | - | 164 | + | ||
| 166 | - | // Track current URL for zoom persistence | 165 | + | // Track current URL for zoom persistence |
| 167 | - | // Initialize to the initial homepage so shortcuts work immediately | 166 | + | // Initialize to the initial homepage so shortcuts work immediately |
| 168 | - | var currentURL string = "dumb://homepage" | 167 | + | var currentURL string = "dumb://homepage" |
| 169 | - | 168 | + | ||
| 170 | - | // Register custom scheme resolver for dumb:// | 169 | + | // Register custom scheme resolver for dumb:// |
| 171 | - | webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) { | 170 | + | webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) { |
| 172 | - | log.Printf("[scheme] request: %s", uri) | 171 | + | log.Printf("[scheme] request: %s", uri) |
| 173 | - | // Known forms: | 172 | + | // Known forms: |
| 174 | - | // - dumb://homepage or dumb:homepage → index.html | 173 | + | // - dumb://homepage or dumb:homepage → index.html |
| 175 | - | // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path> | 174 | + | // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path> |
| 176 | - | // - dumb://<anything> without path → index.html | 175 | + | // - dumb://<anything> without path → index.html |
| 177 | - | u, err := neturl.Parse(uri) | 176 | + | u, err := neturl.Parse(uri) |
| 178 | - | if err != nil || u.Scheme != "dumb" { | 177 | + | if err != nil || u.Scheme != "dumb" { |
| 179 | - | return "", nil, false | 178 | + | return "", nil, false |
| 180 | - | } | 179 | + | } |
| 181 | - | // API routes | 180 | + | // API routes |
| 182 | - | if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") { | 181 | + | if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") { |
| 183 | - | // Normalize path for opaque or hierarchical forms | 182 | + | // Normalize path for opaque or hierarchical forms |
| 184 | - | path := u.Path | 183 | + | path := u.Path |
| 185 | - | if path == "" && u.Opaque != "" { | 184 | + | if path == "" && u.Opaque != "" { |
| 186 | - | // e.g., dumb:api/config or dumb:api/history/recent | 185 | + | // e.g., dumb:api/config or dumb:api/history/recent |
| 187 | - | parts := strings.SplitN(u.Opaque, ":", 2) | 186 | + | parts := strings.SplitN(u.Opaque, ":", 2) |
| 188 | - | if len(parts) == 2 { | 187 | + | if len(parts) == 2 { |
| 189 | - | path = "/" + parts[1] | 188 | + | path = "/" + parts[1] |
| 190 | - | } | 189 | + | } |
| 191 | - | } | 190 | + | } |
| 192 | - | if strings.HasPrefix(path, "/api/") { path = strings.TrimPrefix(path, "/api") } | 191 | + | if strings.HasPrefix(path, "/api/") { |
| 193 | - | if path == "/api" { path = "/" } | 192 | + | path = strings.TrimPrefix(path, "/api") |
| 194 | - | switch { | 193 | + | } |
| 195 | - | case strings.HasPrefix(path, "/rendering/status"): | 194 | + | if path == "/api" { |
| 196 | - | log.Printf("[api] GET /rendering/status") | 195 | + | path = "/" |
| 197 | - | // Report configured rendering mode; runtime GPU state depends on WebKit internals | 196 | + | } |
| 198 | - | resp := struct{ | 197 | + | switch { |
| 199 | - | Mode string `json:"mode"` | 198 | + | case strings.HasPrefix(path, "/rendering/status"): |
| 200 | - | }{Mode: string(cfg.RenderingMode)} | 199 | + | log.Printf("[api] GET /rendering/status") |
| 201 | - | b, _ := json.Marshal(resp) | 200 | + | // Report configured rendering mode; runtime GPU state depends on WebKit internals |
| 202 | - | return "application/json; charset=utf-8", b, true | 201 | + | resp := struct { |
| 203 | - | case strings.HasPrefix(path, "/config"): | 202 | + | Mode string `json:"mode"` |
| 204 | - | log.Printf("[api] GET /config") | 203 | + | }{Mode: string(cfg.RenderingMode)} |
| 205 | - | // Build config info | 204 | + | b, _ := json.Marshal(resp) |
| 206 | - | cfgPath, _ := config.GetConfigFile() | 205 | + | return "application/json; charset=utf-8", b, true |
| 207 | - | info := struct { | 206 | + | case strings.HasPrefix(path, "/config"): |
| 208 | - | ConfigPath string `json:"config_path"` | 207 | + | log.Printf("[api] GET /config") |
| 209 | - | DatabasePath string `json:"database_path"` | 208 | + | // Build config info |
| 210 | - | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | 209 | + | cfgPath, _ := config.GetConfigFile() |
| 211 | - | Appearance config.AppearanceConfig `json:"appearance"` | 210 | + | info := struct { |
| 212 | - | }{ | 211 | + | ConfigPath string `json:"config_path"` |
| 213 | - | ConfigPath: cfgPath, | 212 | + | DatabasePath string `json:"database_path"` |
| 214 | - | DatabasePath: cfg.Database.Path, | 213 | + | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` |
| 215 | - | SearchShortcuts: cfg.SearchShortcuts, | 214 | + | Appearance config.AppearanceConfig `json:"appearance"` |
| 216 | - | Appearance: cfg.Appearance, | 215 | + | }{ |
| 217 | - | } | 216 | + | ConfigPath: cfgPath, |
| 218 | - | b, _ := json.Marshal(info) | 217 | + | DatabasePath: cfg.Database.Path, |
| 219 | - | return "application/json; charset=utf-8", b, true | 218 | + | SearchShortcuts: cfg.SearchShortcuts, |
| 220 | - | case strings.HasPrefix(path, "/history/recent"): | 219 | + | Appearance: cfg.Appearance, |
| 221 | - | log.Printf("[api] GET /history/recent%s", u.RawQuery) | 220 | + | } |
| 222 | - | // Parse limit | 221 | + | b, _ := json.Marshal(info) |
| 223 | - | q := u.Query() | 222 | + | return "application/json; charset=utf-8", b, true |
| 224 | - | limit := 50 | 223 | + | case strings.HasPrefix(path, "/history/recent"): |
| 225 | - | if l := q.Get("limit"); l != "" { | 224 | + | log.Printf("[api] GET /history/recent%s", u.RawQuery) |
| 226 | - | if n, err := strconv.Atoi(l); err == nil && n > 0 { | 225 | + | // Parse limit |
| 227 | - | limit = n | 226 | + | q := u.Query() |
| 228 | - | } | 227 | + | limit := 50 |
| 229 | - | } | 228 | + | if l := q.Get("limit"); l != "" { |
| 230 | - | ctx := context.Background() | 229 | + | if n, err := strconv.Atoi(l); err == nil && n > 0 { |
| 231 | - | entries, err := browserService.GetRecentHistory(ctx, limit) | 230 | + | limit = n |
| 232 | - | if err != nil { | 231 | + | } |
| 233 | - | return "application/json; charset=utf-8", []byte("[]"), true | 232 | + | } |
| 234 | - | } | 233 | + | ctx := context.Background() |
| 235 | - | b, _ := json.Marshal(entries) | 234 | + | entries, err := browserService.GetRecentHistory(ctx, limit) |
| 236 | - | return "application/json; charset=utf-8", b, true | 235 | + | if err != nil { |
| 237 | - | case strings.HasPrefix(path, "/history/search"): | 236 | + | return "application/json; charset=utf-8", []byte("[]"), true |
| 238 | - | log.Printf("[api] GET /history/search%s", u.RawQuery) | 237 | + | } |
| 239 | - | q := u.Query() | 238 | + | b, _ := json.Marshal(entries) |
| 240 | - | query := q.Get("q") | 239 | + | return "application/json; charset=utf-8", b, true |
| 241 | - | limit := 50 | 240 | + | case strings.HasPrefix(path, "/history/search"): |
| 242 | - | if l := q.Get("limit"); l != "" { | 241 | + | log.Printf("[api] GET /history/search%s", u.RawQuery) |
| 243 | - | if n, err := strconv.Atoi(l); err == nil && n > 0 { | 242 | + | q := u.Query() |
| 244 | - | limit = n | 243 | + | query := q.Get("q") |
| 245 | - | } | 244 | + | limit := 50 |
| 246 | - | } | 245 | + | if l := q.Get("limit"); l != "" { |
| 247 | - | ctx := context.Background() | 246 | + | if n, err := strconv.Atoi(l); err == nil && n > 0 { |
| 248 | - | entries, err := browserService.SearchHistory(ctx, query, limit) | 247 | + | limit = n |
| 249 | - | if err != nil { | 248 | + | } |
| 250 | - | return "application/json; charset=utf-8", []byte("[]"), true | 249 | + | } |
| 251 | - | } | 250 | + | ctx := context.Background() |
| 252 | - | b, _ := json.Marshal(entries) | 251 | + | entries, err := browserService.SearchHistory(ctx, query, limit) |
| 253 | - | return "application/json; charset=utf-8", b, true | 252 | + | if err != nil { |
| 254 | - | case strings.HasPrefix(path, "/history/stats"): | 253 | + | return "application/json; charset=utf-8", []byte("[]"), true |
| 255 | - | log.Printf("[api] GET /history/stats") | 254 | + | } |
| 256 | - | ctx := context.Background() | 255 | + | b, _ := json.Marshal(entries) |
| 257 | - | stats, err := browserService.GetHistoryStats(ctx) | 256 | + | return "application/json; charset=utf-8", b, true |
| 258 | - | if err != nil { return "application/json; charset=utf-8", []byte("{}"), true } | 257 | + | case strings.HasPrefix(path, "/history/stats"): |
| 259 | - | b, _ := json.Marshal(stats) | 258 | + | log.Printf("[api] GET /history/stats") |
| 260 | - | return "application/json; charset=utf-8", b, true | 259 | + | ctx := context.Background() |
| 261 | - | default: | 260 | + | stats, err := browserService.GetHistoryStats(ctx) |
| 262 | - | return "application/json; charset=utf-8", []byte("{}"), true | 261 | + | if err != nil { |
| 263 | - | } | 262 | + | return "application/json; charset=utf-8", []byte("{}"), true |
| 264 | - | } | 263 | + | } |
| 265 | - | // Resolve target path inside embed FS | 264 | + | b, _ := json.Marshal(stats) |
| 266 | - | var rel string | 265 | + | return "application/json; charset=utf-8", b, true |
| 267 | - | if u.Opaque == "homepage" || (u.Host == "homepage" && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) { | 266 | + | default: |
| 268 | - | rel = "index.html" | 267 | + | return "application/json; charset=utf-8", []byte("{}"), true |
| 269 | - | } else { | 268 | + | } |
| 270 | - | host := u.Host | 269 | + | } |
| 271 | - | p := strings.TrimPrefix(u.Path, "/") | 270 | + | // Resolve target path inside embed FS |
| 272 | - | if host == "app" && p == "" { | 271 | + | var rel string |
| 273 | - | rel = "index.html" | 272 | + | if u.Opaque == "homepage" || (u.Host == "homepage" && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) { |
| 274 | - | } else if host == "app" { | 273 | + | rel = "index.html" |
| 275 | - | rel = p | 274 | + | } else { |
| 276 | - | } else if host == "homepage" && p != "" { | 275 | + | host := u.Host |
| 277 | - | // dumb://homepage/<asset> | 276 | + | p := strings.TrimPrefix(u.Path, "/") |
| 278 | - | rel = p | 277 | + | if host == "app" && p == "" { |
| 279 | - | } else if p != "" { | 278 | + | rel = "index.html" |
| 280 | - | rel = p | 279 | + | } else if host == "app" { |
| 281 | - | } else { | 280 | + | rel = p |
| 282 | - | rel = "index.html" | 281 | + | } else if host == "homepage" && p != "" { |
| 283 | - | } | 282 | + | // dumb://homepage/<asset> |
| 284 | - | } | 283 | + | rel = p |
| 285 | - | log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path) | 284 | + | } else if p != "" { |
| 286 | - | data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel))) | 285 | + | rel = p |
| 287 | - | if rerr != nil { | 286 | + | } else { |
| 288 | - | log.Printf("[scheme] not found: %s", rel) | 287 | + | rel = "index.html" |
| 289 | - | return "", nil, false | 288 | + | } |
| 290 | - | } | 289 | + | } |
| 291 | - | // Determine mime type | 290 | + | log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path) |
| 292 | - | mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel))) | 291 | + | data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel))) |
| 293 | - | if mt == "" { | 292 | + | if rerr != nil { |
| 294 | - | // Fallbacks | 293 | + | log.Printf("[scheme] not found: %s", rel) |
| 295 | - | switch strings.ToLower(filepath.Ext(rel)) { | 294 | + | return "", nil, false |
| 296 | - | case ".js": mt = "application/javascript" | 295 | + | } |
| 297 | - | case ".css": mt = "text/css" | 296 | + | // Determine mime type |
| 298 | - | case ".svg": mt = "image/svg+xml" | 297 | + | mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel))) |
| 299 | - | case ".ico": mt = "image/x-icon" | 298 | + | if mt == "" { |
| 300 | - | default: mt = "text/html; charset=utf-8" | 299 | + | // Fallbacks |
| 301 | - | } | 300 | + | switch strings.ToLower(filepath.Ext(rel)) { |
| 302 | - | } | 301 | + | case ".js": |
| 303 | - | return mt, data, true | 302 | + | mt = "application/javascript" |
| 304 | - | }) | 303 | + | case ".css": |
| 305 | - | 304 | + | mt = "text/css" | |
| 306 | - | // Create WebKit view | 305 | + | case ".svg": |
| 307 | - | log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable()) | 306 | + | mt = "image/svg+xml" |
| 308 | - | dataDir, _ := config.GetDataDir() | 307 | + | case ".ico": |
| 309 | - | stateDir, _ := config.GetStateDir() | 308 | + | mt = "image/x-icon" |
| 310 | - | webkitData := filepath.Join(dataDir, "webkit") | 309 | + | default: |
| 311 | - | webkitCache := filepath.Join(stateDir, "webkit-cache") | 310 | + | mt = "text/html; charset=utf-8" |
| 312 | - | _ = os.MkdirAll(webkitData, 0o755) | 311 | + | } |
| 313 | - | _ = os.MkdirAll(webkitCache, 0o755) | 312 | + | } |
| 314 | - | view, err := webkit.NewWebView(&webkit.Config{ | 313 | + | return mt, data, true |
| 315 | - | InitialURL: "dumb://homepage", | 314 | + | }) |
| 316 | - | ZoomDefault: 1.0, | 315 | + | |
| 317 | - | EnableDeveloperExtras: true, | 316 | + | // Create WebKit view |
| 318 | - | DataDir: webkitData, | 317 | + | log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable()) |
| 319 | - | CacheDir: webkitCache, | 318 | + | dataDir, _ := config.GetDataDir() |
| 320 | - | DefaultSansFont: cfg.Appearance.SansFont, | 319 | + | stateDir, _ := config.GetStateDir() |
| 321 | - | DefaultSerifFont: cfg.Appearance.SerifFont, | 320 | + | webkitData := filepath.Join(dataDir, "webkit") |
| 322 | - | DefaultMonospaceFont: cfg.Appearance.MonospaceFont, | 321 | + | webkitCache := filepath.Join(stateDir, "webkit-cache") |
| 323 | - | DefaultFontSize: cfg.Appearance.DefaultFontSize, | 322 | + | _ = os.MkdirAll(webkitData, 0o755) |
| 324 | - | Rendering: webkit.RenderingConfig{Mode: string(cfg.RenderingMode)}, | 323 | + | _ = os.MkdirAll(webkitCache, 0o755) |
| 325 | - | }) | 324 | + | view, err := webkit.NewWebView(&webkit.Config{ |
| 326 | - | if err != nil { | 325 | + | InitialURL: "dumb://homepage", |
| 327 | - | log.Printf("Warning: failed to create WebView: %v", err) | 326 | + | ZoomDefault: 1.0, |
| 328 | - | } else { | 327 | + | EnableDeveloperExtras: true, |
| 329 | - | browserService.AttachWebView(view) | 328 | + | DataDir: webkitData, |
| 330 | - | // Use native window as title updater | 329 | + | CacheDir: webkitCache, |
| 331 | - | if win := view.Window(); win != nil { | 330 | + | DefaultSansFont: cfg.Appearance.SansFont, |
| 332 | - | browserService.SetWindowTitleUpdater(win) | 331 | + | DefaultSerifFont: cfg.Appearance.SerifFont, |
| 333 | - | } | 332 | + | DefaultMonospaceFont: cfg.Appearance.MonospaceFont, |
| 334 | - | // Persist page titles to DB when they change | 333 | + | DefaultFontSize: cfg.Appearance.DefaultFontSize, |
| 335 | - | view.RegisterTitleChangedHandler(func(title string) { | 334 | + | Rendering: webkit.RenderingConfig{Mode: string(cfg.RenderingMode)}, |
| 336 | - | ctx := context.Background() | 335 | + | Memory: readMemoryConfigFromEnv(), |
| 337 | - | url := view.GetCurrentURL() | 336 | + | }) |
| 338 | - | if url != "" && title != "" { | 337 | + | if err != nil { |
| 339 | - | if err := browserService.UpdatePageTitle(ctx, url, title); err != nil { | 338 | + | log.Printf("Warning: failed to create WebView: %v", err) |
| 340 | - | log.Printf("Warning: failed to update page title: %v", err) | 339 | + | } else { |
| 341 | - | } | 340 | + | browserService.AttachWebView(view) |
| 342 | - | } | 341 | + | // Use native window as title updater |
| 343 | - | }) | 342 | + | if win := view.Window(); win != nil { |
| 344 | - | // Track current URL changes and apply saved zoom | 343 | + | browserService.SetWindowTitleUpdater(win) |
| 345 | - | view.RegisterURIChangedHandler(func(u string) { | 344 | + | } |
| 346 | - | currentURL = u | 345 | + | // Persist page titles to DB when they change |
| 347 | - | if u == "" { return } | 346 | + | view.RegisterTitleChangedHandler(func(title string) { |
| 348 | - | ctx := context.Background() | 347 | + | ctx := context.Background() |
| 349 | - | if z, err := browserService.GetZoomLevel(ctx, u); err == nil { | 348 | + | url := view.GetCurrentURL() |
| 350 | - | _ = view.SetZoom(z) | 349 | + | if url != "" && title != "" { |
| 351 | - | key := services.ZoomKeyForLog(u) | 350 | + | if err := browserService.UpdatePageTitle(ctx, url, title); err != nil { |
| 352 | - | log.Printf("[zoom] loaded %.2f for %s", z, key) | 351 | + | log.Printf("Warning: failed to update page title: %v", err) |
| 353 | - | } | 352 | + | } |
| 354 | - | }) | 353 | + | } |
| 355 | - | // Persist zoom changes per-domain | 354 | + | }) |
| 356 | - | view.RegisterZoomChangedHandler(func(level float64) { | 355 | + | // Track current URL changes and apply saved zoom |
| 357 | - | url := view.GetCurrentURL() | 356 | + | view.RegisterURIChangedHandler(func(u string) { |
| 358 | - | if url == "" { return } | 357 | + | currentURL = u |
| 359 | - | ctx := context.Background() | 358 | + | if u == "" { |
| 360 | - | if err := browserService.SetZoomLevel(ctx, url, level); err != nil { | 359 | + | return |
| 361 | - | log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err) | 360 | + | } |
| 362 | - | return | 361 | + | ctx := context.Background() |
| 363 | - | } | 362 | + | if z, err := browserService.GetZoomLevel(ctx, u); err == nil { |
| 364 | - | key := services.ZoomKeyForLog(url) | 363 | + | _ = view.SetZoom(z) |
| 365 | - | log.Printf("[zoom] saved %.2f for %s", level, key) | 364 | + | key := services.ZoomKeyForLog(u) |
| 366 | - | }) | 365 | + | log.Printf("[zoom] loaded %.2f for %s", z, key) |
| 367 | - | // Bridge UCM messages: navigate + history queries | 366 | + | } |
| 368 | - | view.RegisterScriptMessageHandler(func(payload string) { | 367 | + | }) |
| 369 | - | type msg struct { | 368 | + | // Persist zoom changes per-domain |
| 370 | - | Type string `json:"type"` | 369 | + | view.RegisterZoomChangedHandler(func(level float64) { |
| 371 | - | URL string `json:"url"` | 370 | + | url := view.GetCurrentURL() |
| 372 | - | Q string `json:"q"` | 371 | + | if url == "" { |
| 373 | - | Limit int `json:"limit"` | 372 | + | return |
| 374 | - | Value string `json:"value"` | 373 | + | } |
| 375 | - | // Wails fetch bridge | 374 | + | ctx := context.Background() |
| 376 | - | ID string `json:"id"` | 375 | + | if err := browserService.SetZoomLevel(ctx, url, level); err != nil { |
| 377 | - | Payload json.RawMessage `json:"payload"` | 376 | + | log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err) |
| 378 | - | } | 377 | + | return |
| 379 | - | var m msg | 378 | + | } |
| 380 | - | if err := json.Unmarshal([]byte(payload), &m); err != nil { return } | 379 | + | key := services.ZoomKeyForLog(url) |
| 381 | - | switch m.Type { | 380 | + | log.Printf("[zoom] saved %.2f for %s", level, key) |
| 382 | - | case "navigate": | 381 | + | }) |
| 383 | - | ctx := context.Background() | 382 | + | // Bridge UCM messages: navigate + history queries |
| 384 | - | res, err := parserService.ParseInput(ctx, m.URL) | 383 | + | view.RegisterScriptMessageHandler(func(payload string) { |
| 385 | - | if err == nil { | 384 | + | type msg struct { |
| 386 | - | currentURL = res.URL | 385 | + | Type string `json:"type"` |
| 387 | - | browserService.Navigate(ctx, currentURL) | 386 | + | URL string `json:"url"` |
| 388 | - | _ = view.LoadURL(currentURL) | 387 | + | Q string `json:"q"` |
| 389 | - | if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil { _ = view.SetZoom(z) } | 388 | + | Limit int `json:"limit"` |
| 390 | - | } | 389 | + | Value string `json:"value"` |
| 391 | - | case "query": | 390 | + | // Wails fetch bridge |
| 392 | - | ctx := context.Background() | 391 | + | ID string `json:"id"` |
| 393 | - | lim := m.Limit | 392 | + | Payload json.RawMessage `json:"payload"` |
| 394 | - | if lim <= 0 || lim > 25 { lim = 10 } | 393 | + | } |
| 395 | - | entries, err := browserService.SearchHistory(ctx, m.Q, lim) | 394 | + | var m msg |
| 396 | - | if err != nil { return } | 395 | + | if err := json.Unmarshal([]byte(payload), &m); err != nil { |
| 397 | - | // Map to lightweight items | 396 | + | return |
| 398 | - | type item struct { URL string `json:"url"`; Favicon string `json:"favicon"` } | 397 | + | } |
| 399 | - | buildFavicon := func(raw string) string { | 398 | + | switch m.Type { |
| 400 | - | u, err := parserService.ParseInput(ctx, raw) | 399 | + | case "navigate": |
| 401 | - | if err != nil || u.URL == "" { return "" } | 400 | + | ctx := context.Background() |
| 402 | - | parsed, perr := neturl.Parse(u.URL) | 401 | + | res, err := parserService.ParseInput(ctx, m.URL) |
| 403 | - | if perr != nil || parsed.Host == "" { return "" } | 402 | + | if err == nil { |
| 404 | - | scheme := parsed.Scheme | 403 | + | currentURL = res.URL |
| 405 | - | if scheme == "" { scheme = "https" } | 404 | + | browserService.Navigate(ctx, currentURL) |
| 406 | - | return scheme + "://" + parsed.Host + "/favicon.ico" | 405 | + | _ = view.LoadURL(currentURL) |
| 407 | - | } | 406 | + | if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil { |
| 408 | - | items := make([]item, 0, len(entries)) | 407 | + | _ = view.SetZoom(z) |
| 409 | - | for _, e := range entries { items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)}) } | 408 | + | } |
| 410 | - | b, _ := json.Marshal(items) | 409 | + | } |
| 411 | - | // Update suggestions in page | 410 | + | case "query": |
| 412 | - | _ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")") | 411 | + | ctx := context.Background() |
| 413 | - | case "wails": | 412 | + | lim := m.Limit |
| 414 | - | // Handle Wails runtime fetch bridge calls for homepage | 413 | + | if lim <= 0 || lim > 25 { |
| 415 | - | // Payload contains { methodID, methodName?, args } | 414 | + | lim = 10 |
| 416 | - | var p struct { | 415 | + | } |
| 417 | - | MethodID uint32 `json:"methodID"` | 416 | + | entries, err := browserService.SearchHistory(ctx, m.Q, lim) |
| 418 | - | MethodName string `json:"methodName"` | 417 | + | if err != nil { |
| 419 | - | Args json.RawMessage `json:"args"` | 418 | + | return |
| 420 | - | } | 419 | + | } |
| 421 | - | if err := json.Unmarshal(m.Payload, &p); err != nil { return } | 420 | + | // Map to lightweight items |
| 422 | - | // Only implement the IDs we need | 421 | + | type item struct { |
| 423 | - | switch p.MethodID { | 422 | + | URL string `json:"url"` |
| 424 | - | case 3708519028: // BrowserService.GetRecentHistory(limit) | 423 | + | Favicon string `json:"favicon"` |
| 425 | - | var args []interface{} | 424 | + | } |
| 426 | - | _ = json.Unmarshal(p.Args, &args) | 425 | + | buildFavicon := func(raw string) string { |
| 427 | - | limit := 50 | 426 | + | u, err := parserService.ParseInput(ctx, raw) |
| 428 | - | if len(args) > 0 { | 427 | + | if err != nil || u.URL == "" { |
| 429 | - | if f, ok := args[0].(float64); ok { limit = int(f) } | 428 | + | return "" |
| 430 | - | } | 429 | + | } |
| 431 | - | ctx := context.Background() | 430 | + | parsed, perr := neturl.Parse(u.URL) |
| 432 | - | entries, err := browserService.GetRecentHistory(ctx, limit) | 431 | + | if perr != nil || parsed.Host == "" { |
| 433 | - | if err != nil { return } | 432 | + | return "" |
| 434 | - | resp, _ := json.Marshal(entries) | 433 | + | } |
| 435 | - | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")") | 434 | + | scheme := parsed.Scheme |
| 436 | - | case 4078533762: // BrowserService.GetSearchShortcuts() | 435 | + | if scheme == "" { |
| 437 | - | ctx := context.Background() | 436 | + | scheme = "https" |
| 438 | - | shortcuts, err := browserService.GetSearchShortcuts(ctx) | 437 | + | } |
| 439 | - | if err != nil { return } | 438 | + | return scheme + "://" + parsed.Host + "/favicon.ico" |
| 440 | - | resp, _ := json.Marshal(shortcuts) | 439 | + | } |
| 441 | - | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")") | 440 | + | items := make([]item, 0, len(entries)) |
| 442 | - | default: | 441 | + | for _, e := range entries { |
| 443 | - | // Return empty JSON to avoid breaking UI | 442 | + | items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)}) |
| 444 | - | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')") | 443 | + | } |
| 445 | - | } | 444 | + | b, _ := json.Marshal(items) |
| 446 | - | case "theme": | 445 | + | // Update suggestions in page |
| 447 | - | if m.Value != "" { log.Printf("[theme] page reported color-scheme: %s", m.Value) } | 446 | + | _ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")") |
| 448 | - | } | 447 | + | case "wails": |
| 449 | - | }) | 448 | + | // Handle Wails runtime fetch bridge calls for homepage |
| 450 | - | // Apply initial zoom setting and log it before showing the window | 449 | + | // Payload contains { methodID, methodName?, args } |
| 451 | - | { | 450 | + | var p struct { |
| 452 | - | ctx := context.Background() | 451 | + | MethodID uint32 `json:"methodID"` |
| 453 | - | u := view.GetCurrentURL() | 452 | + | MethodName string `json:"methodName"` |
| 454 | - | if u == "" { u = "dumb://homepage" } | 453 | + | Args json.RawMessage `json:"args"` |
| 455 | - | if z, zerr := browserService.GetZoomLevel(ctx, u); zerr == nil { | 454 | + | } |
| 456 | - | if err := view.SetZoom(z); err != nil { | 455 | + | if err := json.Unmarshal(m.Payload, &p); err != nil { |
| 457 | - | log.Printf("Warning: failed to set initial zoom: %v", err) | 456 | + | return |
| 458 | - | } else { | 457 | + | } |
| 459 | - | key := services.ZoomKeyForLog(u) | 458 | + | // Only implement the IDs we need |
| 460 | - | log.Printf("[zoom] loaded %.2f for %s", z, key) | 459 | + | switch p.MethodID { |
| 461 | - | } | 460 | + | case 3708519028: // BrowserService.GetRecentHistory(limit) |
| 462 | - | } | 461 | + | var args []interface{} |
| 463 | - | } | 462 | + | _ = json.Unmarshal(p.Args, &args) |
| 464 | - | 463 | + | limit := 50 | |
| 465 | - | log.Printf("Showing WebView window…") | 464 | + | if len(args) > 0 { |
| 466 | - | if err := view.Show(); err != nil { | 465 | + | if f, ok := args[0].(float64); ok { |
| 467 | - | log.Printf("Warning: failed to show WebView: %v", err) | 466 | + | limit = int(f) |
| 468 | - | } else { | 467 | + | } |
| 469 | - | if !webkit.IsNativeAvailable() { | 468 | + | } |
| 470 | - | log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.") | 469 | + | ctx := context.Background() |
| 471 | - | } | 470 | + | entries, err := browserService.GetRecentHistory(ctx, limit) |
| 472 | - | } | 471 | + | if err != nil { |
| 473 | - | } | 472 | + | return |
| 474 | - | 473 | + | } | |
| 475 | - | // Parse browse argument if present | 474 | + | resp, _ := json.Marshal(entries) |
| 476 | - | if len(os.Args) >= 3 && os.Args[1] == "browse" { | 475 | + | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")") |
| 477 | - | log.Printf("Browse command detected: %s", os.Args[2]) | 476 | + | case 4078533762: // BrowserService.GetSearchShortcuts() |
| 478 | - | ctx := context.Background() | 477 | + | ctx := context.Background() |
| 479 | - | result, err := parserService.ParseInput(ctx, os.Args[2]) | 478 | + | shortcuts, err := browserService.GetSearchShortcuts(ctx) |
| 480 | - | if err == nil { | 479 | + | if err != nil { |
| 481 | - | currentURL = result.URL | 480 | + | return |
| 482 | - | log.Printf("Parsed input → URL: %s", currentURL) | 481 | + | } |
| 483 | - | browserService.Navigate(ctx, currentURL) | 482 | + | resp, _ := json.Marshal(shortcuts) |
| 484 | - | if view != nil { | 483 | + | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")") |
| 485 | - | log.Printf("Loading URL in WebView: %s", currentURL) | 484 | + | default: |
| 486 | - | if err := view.LoadURL(currentURL); err != nil { | 485 | + | // Return empty JSON to avoid breaking UI |
| 487 | - | log.Printf("Warning: failed to load URL: %v", err) | 486 | + | _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')") |
| 488 | - | } | 487 | + | } |
| 489 | - | if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil { | 488 | + | case "theme": |
| 490 | - | if err := view.SetZoom(z); err != nil { | 489 | + | if m.Value != "" { |
| 491 | - | log.Printf("Warning: failed to set zoom: %v", err) | 490 | + | log.Printf("[theme] page reported color-scheme: %s", m.Value) |
| 492 | - | } else { | 491 | + | } |
| 493 | - | key := services.ZoomKeyForLog(currentURL) | 492 | + | } |
| 494 | - | log.Printf("[zoom] loaded %.2f for %s", z, key) | 493 | + | }) |
| 495 | - | } | 494 | + | // Apply initial zoom setting and log it before showing the window |
| 496 | - | } | 495 | + | { |
| 497 | - | // No JS injection; native accelerators handle zoom/devtools. | 496 | + | ctx := context.Background() |
| 498 | - | } | 497 | + | u := view.GetCurrentURL() |
| 499 | - | } | 498 | + | if u == "" { |
| 500 | - | } | 499 | + | u = "dumb://homepage" |
| 501 | - | 500 | + | } | |
| 502 | - | // Register basic keyboard shortcuts on the native view to preserve behavior | 501 | + | if z, zerr := browserService.GetZoomLevel(ctx, u); zerr == nil { |
| 503 | - | if view != nil { | 502 | + | if err := view.SetZoom(z); err != nil { |
| 504 | - | // DevTools | 503 | + | log.Printf("Warning: failed to set initial zoom: %v", err) |
| 505 | - | _ = view.RegisterKeyboardShortcut("F12", func() { log.Printf("Shortcut: F12 (devtools)"); _ = view.ShowDevTools() }) | 504 | + | } else { |
| 506 | - | // Omnibox (Ctrl+L): rely on injected script listener | 505 | + | key := services.ZoomKeyForLog(u) |
| 507 | - | _ = view.RegisterKeyboardShortcut("cmdorctrl+l", func() { | 506 | + | log.Printf("[zoom] loaded %.2f for %s", z, key) |
| 508 | - | log.Printf("Shortcut: Omnibox toggle") | 507 | + | } |
| 509 | - | _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()") | 508 | + | } |
| 510 | - | }) | 509 | + | } |
| 511 | - | // Zoom handled natively in webkit package (built-in shortcuts) | 510 | + | |
| 512 | - | } | 511 | + | log.Printf("Showing WebView window…") |
| 513 | - | 512 | + | if err := view.Show(); err != nil { | |
| 514 | - | // Enter GTK main loop only when native backend is available. | 513 | + | log.Printf("Warning: failed to show WebView: %v", err) |
| 515 | - | if webkit.IsNativeAvailable() { | 514 | + | } else { |
| 516 | - | log.Printf("Entering GTK main loop…") | 515 | + | if !webkit.IsNativeAvailable() { |
| 517 | - | webkit.RunMainLoop() | 516 | + | log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.") |
| 518 | - | log.Printf("GTK main loop exited") | 517 | + | } |
| 519 | - | } else { | 518 | + | } |
| 520 | - | log.Printf("Not entering GUI loop (non-CGO build)") | 519 | + | } |
| 521 | - | } | 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 | // detectAndSetKeyboardLocale attempts to determine the current keyboard layout | 645 | // detectAndSetKeyboardLocale attempts to determine the current keyboard layout | ||
| 525 | // and hints it to the webkit input layer for accelerator compatibility. | 646 | // and hints it to the webkit input layer for accelerator compatibility. | ||
| 526 | func detectAndSetKeyboardLocale() { | 647 | func detectAndSetKeyboardLocale() { | ||
| 527 | - | // 0) Explicit override | 648 | + | // 0) Explicit override |
| 528 | - | locale := os.Getenv("DUMBER_KEYBOARD_LOCALE") | 649 | + | locale := os.Getenv("DUMBER_KEYBOARD_LOCALE") |
| 529 | - | if locale == "" { | 650 | + | if locale == "" { |
| 530 | - | locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix | 651 | + | locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix |
| 531 | - | } | 652 | + | } |
| 532 | - | // 1) Environment | 653 | + | // 1) Environment |
| 533 | - | if locale == "" { locale = os.Getenv("LC_ALL") } | 654 | + | if locale == "" { |
| 534 | - | if locale == "" { locale = os.Getenv("LANG") } | 655 | + | locale = os.Getenv("LC_ALL") |
| 535 | - | if locale == "" { locale = os.Getenv("LC_CTYPE") } | 656 | + | } |
| 536 | - | // Trim variants | 657 | + | if locale == "" { |
| 537 | - | if i := strings.IndexByte(locale, '.'); i > 0 { locale = locale[:i] } | 658 | + | locale = os.Getenv("LANG") |
| 538 | - | if i := strings.IndexByte(locale, '@'); i > 0 { locale = locale[:i] } | 659 | + | } |
| 539 | - | if i := strings.IndexByte(locale, '_'); i > 0 { locale = locale[:i] } | 660 | + | if locale == "" { |
| 540 | - | 661 | + | locale = os.Getenv("LC_CTYPE") | |
| 541 | - | // 2) Try XKB env override | 662 | + | } |
| 542 | - | if locale == "" { | 663 | + | // Trim variants |
| 543 | - | locale = os.Getenv("XKB_DEFAULT_LAYOUT") | 664 | + | if i := strings.IndexByte(locale, '.'); i > 0 { |
| 544 | - | } | 665 | + | locale = locale[:i] |
| 545 | - | 666 | + | } | |
| 546 | - | // 3) Best-effort probe of setxkbmap/localectl (non-fatal) | 667 | + | if i := strings.IndexByte(locale, '@'); i > 0 { |
| 547 | - | if locale == "" { | 668 | + | locale = locale[:i] |
| 548 | - | if out, err := exec.Command("localectl", "status").Output(); err == nil { | 669 | + | } |
| 549 | - | s := string(out) | 670 | + | if i := strings.IndexByte(locale, '_'); i > 0 { |
| 550 | - | for _, line := range strings.Split(s, "\n") { | 671 | + | locale = locale[:i] |
| 551 | - | line = strings.TrimSpace(line) | 672 | + | } |
| 552 | - | if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") { | 673 | + | |
| 553 | - | parts := strings.SplitN(line, ":", 2) | 674 | + | // 2) Try XKB env override |
| 554 | - | if len(parts) == 2 { | 675 | + | if locale == "" { |
| 555 | - | cand := strings.TrimSpace(parts[1]) | 676 | + | locale = os.Getenv("XKB_DEFAULT_LAYOUT") |
| 556 | - | if cand != "" { locale = cand } | 677 | + | } |
| 557 | - | } | 678 | + | |
| 558 | - | break | 679 | + | // 3) Best-effort probe of setxkbmap/localectl (non-fatal) |
| 559 | - | } | 680 | + | if locale == "" { |
| 560 | - | } | 681 | + | if out, err := exec.Command("localectl", "status").Output(); err == nil { |
| 561 | - | } | 682 | + | s := string(out) |
| 562 | - | } | 683 | + | for _, line := range strings.Split(s, "\n") { |
| 563 | - | if locale == "" { | 684 | + | line = strings.TrimSpace(line) |
| 564 | - | if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil { | 685 | + | if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") { |
| 565 | - | s := string(out) | 686 | + | parts := strings.SplitN(line, ":", 2) |
| 566 | - | for _, line := range strings.Split(s, "\n") { | 687 | + | if len(parts) == 2 { |
| 567 | - | line = strings.TrimSpace(line) | 688 | + | cand := strings.TrimSpace(parts[1]) |
| 568 | - | if strings.HasPrefix(strings.ToLower(line), "layout:") { | 689 | + | if cand != "" { |
| 569 | - | parts := strings.SplitN(line, ":", 2) | 690 | + | locale = cand |
| 570 | - | if len(parts) == 2 { | 691 | + | } |
| 571 | - | cand := strings.TrimSpace(parts[1]) | 692 | + | } |
| 572 | - | if cand != "" { locale = cand } | 693 | + | break |
| 573 | - | } | 694 | + | } |
| 574 | - | break | 695 | + | } |
| 575 | - | } | 696 | + | } |
| 576 | - | } | 697 | + | } |
| 577 | - | } | 698 | + | if locale == "" { |
| 578 | - | } | 699 | + | if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil { |
| 579 | - | 700 | + | s := string(out) | |
| 580 | - | if locale == "" { locale = "en" } | 701 | + | for _, line := range strings.Split(s, "\n") { |
| 581 | - | // No layout-specific remaps; log for diagnostics only | 702 | + | line = strings.TrimSpace(line) |
| 582 | - | log.Printf("[locale] keyboard locale detected: %s", locale) | 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 | // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in. | 5 | // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in. | ||
| 6 | func IsNativeAvailable() bool { return true } | 6 | func IsNativeAvailable() bool { return true } | ||
| 7 | - | ||||
M pkg/webkit/build_flags_stub.go +0 -1
| @@ -5,4 +5,3 @@ package webkit | |||||
| 5 | // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in. | 5 | // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in. | ||
| 6 | // In non-CGO builds, this returns false and WebView methods are logical no-ops. | 6 | // In non-CGO builds, this returns false and WebView methods are logical no-ops. | ||
| 7 | func IsNativeAvailable() bool { return false } | 7 | func IsNativeAvailable() bool { return false } | ||
| 8 | - | ||||
M pkg/webkit/devtools_cgo.go +18 -10
| @@ -9,17 +9,25 @@ package webkit | |||||
| 9 | import "C" | 9 | import "C" | ||
| 10 | 10 | ||||
| 11 | func (w *WebView) ShowDevTools() error { | 11 | func (w *WebView) ShowDevTools() error { | ||
| 12 | - | if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented } | 12 | + | if w == nil || w.native == nil || w.native.wv == nil { |
| 13 | - | insp := C.webkit_web_view_get_inspector(w.native.wv) | 13 | + | return ErrNotImplemented |
| 14 | - | if insp == nil { return ErrNotImplemented } | 14 | + | } |
| 15 | - | C.webkit_web_inspector_show(insp) | 15 | + | insp := C.webkit_web_view_get_inspector(w.native.wv) |
| 16 | - | return nil | 16 | + | if insp == nil { |
| 17 | + | return ErrNotImplemented | |||
| 18 | + | } | |||
| 19 | + | C.webkit_web_inspector_show(insp) | |||
| 20 | + | return nil | |||
| 17 | } | 21 | } | ||
| 18 | 22 | ||||
| 19 | func (w *WebView) CloseDevTools() error { | 23 | func (w *WebView) CloseDevTools() error { | ||
| 20 | - | if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented } | 24 | + | if w == nil || w.native == nil || w.native.wv == nil { |
| 21 | - | insp := C.webkit_web_view_get_inspector(w.native.wv) | 25 | + | return ErrNotImplemented |
| 22 | - | if insp == nil { return ErrNotImplemented } | 26 | + | } |
| 23 | - | C.webkit_web_inspector_close(insp) | 27 | + | insp := C.webkit_web_view_get_inspector(w.native.wv) |
| 24 | - | return nil | 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 | // RegisterKeyboardShortcut binds a global or window-scoped accelerator to a callback. | 7 | // RegisterKeyboardShortcut binds a global or window-scoped accelerator to a callback. | ||
| 8 | func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error { | 8 | func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error { | ||
| 9 | - | if w == nil || w.destroyed { | 9 | + | if w == nil || w.destroyed { |
| 10 | - | return ErrNotImplemented | 10 | + | return ErrNotImplemented |
| 11 | - | } | 11 | + | } |
| 12 | - | if accel == "" || callback == nil { | 12 | + | if accel == "" || callback == nil { |
| 13 | - | return fmt.Errorf("invalid shortcut registration") | 13 | + | return fmt.Errorf("invalid shortcut registration") |
| 14 | - | } | 14 | + | } |
| 15 | - | // Placeholder: in real impl, bind GTK accelerator; for now, store for tests. | 15 | + | // Placeholder: in real impl, bind GTK accelerator; for now, store for tests. |
| 16 | - | if shortcuts := getShortcutRegistry(w); shortcuts != nil { | 16 | + | if shortcuts := getShortcutRegistry(w); shortcuts != nil { |
| 17 | - | shortcuts[accel] = callback | 17 | + | shortcuts[accel] = callback |
| 18 | - | } | 18 | + | } |
| 19 | - | return nil | 19 | + | return nil |
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | // internal registry per view while no GTK binding exists. | 22 | // internal registry per view while no GTK binding exists. | ||
| @@ -25,12 +25,12 @@ type shortcutRegistry map[string]func() | |||||
| 25 | var viewShortcuts = make(map[*WebView]shortcutRegistry) | 25 | var viewShortcuts = make(map[*WebView]shortcutRegistry) | ||
| 26 | 26 | ||||
| 27 | func getShortcutRegistry(w *WebView) shortcutRegistry { | 27 | func getShortcutRegistry(w *WebView) shortcutRegistry { | ||
| 28 | - | reg, ok := viewShortcuts[w] | 28 | + | reg, ok := viewShortcuts[w] |
| 29 | - | if !ok { | 29 | + | if !ok { |
| 30 | - | reg = make(shortcutRegistry) | 30 | + | reg = make(shortcutRegistry) |
| 31 | - | viewShortcuts[w] = reg | 31 | + | viewShortcuts[w] = reg |
| 32 | - | } | 32 | + | } |
| 33 | - | return reg | 33 | + | return reg |
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | // No layout-specific remaps in non-CGO build either. | 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 | import "C" | 78 | import "C" | ||
| 79 | 79 | ||||
| 80 | import ( | 80 | import ( | ||
| 81 | - | "fmt" | 81 | + | "fmt" |
| 82 | - | "log" | 82 | + | "log" |
| 83 | - | "sync/atomic" | 83 | + | "sync" |
| 84 | - | "sync" | 84 | + | "sync/atomic" |
| 85 | - | "time" | 85 | + | "time" |
| 86 | ) | 86 | ) | ||
| 87 | 87 | ||||
| 88 | // Registry of accelerators per view id. | 88 | // Registry of accelerators per view id. | ||
| 89 | type shortcutRegistry map[string]func() | 89 | type shortcutRegistry map[string]func() | ||
| 90 | 90 | ||||
| 91 | var ( | 91 | var ( | ||
| 92 | - | regMu sync.RWMutex | 92 | + | regMu sync.RWMutex |
| 93 | - | viewShortcuts = make(map[uintptr]shortcutRegistry) | 93 | + | viewShortcuts = make(map[uintptr]shortcutRegistry) |
| 94 | - | viewIDCounter uint64 | 94 | + | viewIDCounter uint64 |
| 95 | - | viewByID = make(map[uintptr]*WebView) | 95 | + | viewByID = make(map[uintptr]*WebView) |
| 96 | - | lastKeyTime = make(map[uintptr]map[string]time.Time) | 96 | + | lastKeyTime = make(map[uintptr]map[string]time.Time) |
| 97 | ) | 97 | ) | ||
| 98 | 98 | ||||
| 99 | // Locale hint function removed; no layout-specific remaps. | 99 | // Locale hint function removed; no layout-specific remaps. | ||
| 100 | 100 | ||||
| 101 | // RegisterKeyboardShortcut registers a callback under an accelerator string. | 101 | // RegisterKeyboardShortcut registers a callback under an accelerator string. | ||
| 102 | func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error { | 102 | func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error { | ||
| 103 | - | if w == nil || w.destroyed { | 103 | + | if w == nil || w.destroyed { |
| 104 | - | return ErrNotImplemented | 104 | + | return ErrNotImplemented |
| 105 | - | } | 105 | + | } |
| 106 | - | if accel == "" || callback == nil { | 106 | + | if accel == "" || callback == nil { |
| 107 | - | return fmt.Errorf("invalid shortcut registration") | 107 | + | return fmt.Errorf("invalid shortcut registration") |
| 108 | - | } | 108 | + | } |
| 109 | - | regMu.Lock() | 109 | + | regMu.Lock() |
| 110 | - | if _, ok := viewShortcuts[w.id]; !ok { | 110 | + | if _, ok := viewShortcuts[w.id]; !ok { |
| 111 | - | viewShortcuts[w.id] = make(shortcutRegistry) | 111 | + | viewShortcuts[w.id] = make(shortcutRegistry) |
| 112 | - | } | 112 | + | } |
| 113 | - | viewShortcuts[w.id][accel] = callback | 113 | + | viewShortcuts[w.id][accel] = callback |
| 114 | - | regMu.Unlock() | 114 | + | regMu.Unlock() |
| 115 | - | return nil | 115 | + | return nil |
| 116 | } | 116 | } | ||
| 117 | 117 | ||||
| 118 | //export goOnKeyPress | 118 | //export goOnKeyPress | ||
| 119 | func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) { | 119 | func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) { | ||
| 120 | - | // Normalize and dispatch | 120 | + | // Normalize and dispatch |
| 121 | - | uid := uintptr(id) | 121 | + | uid := uintptr(id) |
| 122 | - | kv := uint(keyval) | 122 | + | kv := uint(keyval) |
| 123 | - | st := uint(state) | 123 | + | st := uint(state) |
| 124 | - | dispatchAccelerator(uid, kv, st) | 124 | + | dispatchAccelerator(uid, kv, st) |
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | func dispatchAccelerator(uid uintptr, keyval uint, state uint) { | 127 | func dispatchAccelerator(uid uintptr, keyval uint, state uint) { | ||
| 128 | - | ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0 | 128 | + | ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0 |
| 129 | - | alt := (state & uint(C.GDK_ALT_MASK)) != 0 | 129 | + | alt := (state & uint(C.GDK_ALT_MASK)) != 0 |
| 130 | - | // Map keyval to string names | 130 | + | // Map keyval to string names |
| 131 | - | var keyName string | 131 | + | var keyName string |
| 132 | - | switch keyval { | 132 | + | switch keyval { |
| 133 | - | case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract), uint(C.GDK_KEY_underscore): | 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 | 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) | 135 | + | log.Printf("[accelerator-raw] minus key detected ctrl=%v alt=%v keyval=0x%x", ctrl, alt, keyval) |
| 136 | - | keyName = "-" | 136 | + | keyName = "-" |
| 137 | - | case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add): | 137 | + | case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add): |
| 138 | - | keyName = "=" | 138 | + | keyName = "=" |
| 139 | - | case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0): | 139 | + | case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0): |
| 140 | - | keyName = "0" | 140 | + | keyName = "0" |
| 141 | - | case uint(C.GDK_KEY_Left): | 141 | + | case uint(C.GDK_KEY_Left): |
| 142 | - | keyName = "ArrowLeft" | 142 | + | keyName = "ArrowLeft" |
| 143 | - | case uint(C.GDK_KEY_Right): | 143 | + | case uint(C.GDK_KEY_Right): |
| 144 | - | keyName = "ArrowRight" | 144 | + | keyName = "ArrowRight" |
| 145 | - | case uint(C.GDK_KEY_F12): | 145 | + | case uint(C.GDK_KEY_F12): |
| 146 | - | keyName = "F12" | 146 | + | keyName = "F12" |
| 147 | - | default: | 147 | + | default: |
| 148 | - | // If Control held, log unknown keyval for diagnostics | 148 | + | // If Control held, log unknown keyval for diagnostics |
| 149 | - | if ctrl { | 149 | + | if ctrl { |
| 150 | - | log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval) | 150 | + | log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval) |
| 151 | - | } | 151 | + | } |
| 152 | - | return | 152 | + | return |
| 153 | - | } | 153 | + | } |
| 154 | 154 | ||||
| 155 | - | // Candidate accelerator strings in order | 155 | + | // Candidate accelerator strings in order |
| 156 | - | candidates := []string{keyName} | 156 | + | candidates := []string{keyName} |
| 157 | - | if ctrl { | 157 | + | if ctrl { |
| 158 | - | // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=) | 158 | + | // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=) |
| 159 | - | candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...) | 159 | + | candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...) |
| 160 | - | // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-) | 160 | + | // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-) |
| 161 | - | candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...) | 161 | + | candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...) |
| 162 | - | } | 162 | + | } |
| 163 | - | if alt { | 163 | + | if alt { |
| 164 | - | candidates = append([]string{"alt+" + keyName}, candidates...) | 164 | + | candidates = append([]string{"alt+" + keyName}, candidates...) |
| 165 | - | } | 165 | + | } |
| 166 | 166 | ||||
| 167 | - | regMu.RLock() | 167 | + | regMu.RLock() |
| 168 | - | reg := viewShortcuts[uid] | 168 | + | reg := viewShortcuts[uid] |
| 169 | - | regMu.RUnlock() | 169 | + | regMu.RUnlock() |
| 170 | - | if reg == nil { | 170 | + | if reg == nil { |
| 171 | - | return | 171 | + | return |
| 172 | - | } | 172 | + | } |
| 173 | - | for _, name := range candidates { | 173 | + | for _, name := range candidates { |
| 174 | - | if cb, ok := reg[name]; ok { | 174 | + | if cb, ok := reg[name]; ok { |
| 175 | - | // Debounce duplicates within 120ms for the same view+key | 175 | + | // Debounce duplicates within 120ms for the same view+key |
| 176 | - | regMu.Lock() | 176 | + | regMu.Lock() |
| 177 | - | if _, ok := lastKeyTime[uid]; !ok { lastKeyTime[uid] = make(map[string]time.Time) } | 177 | + | if _, ok := lastKeyTime[uid]; !ok { |
| 178 | - | last := lastKeyTime[uid][name] | 178 | + | lastKeyTime[uid] = make(map[string]time.Time) |
| 179 | - | now := time.Now() | 179 | + | } |
| 180 | - | if now.Sub(last) < 120*time.Millisecond { | 180 | + | last := lastKeyTime[uid][name] |
| 181 | - | // Skip duplicate | 181 | + | now := time.Now() |
| 182 | - | regMu.Unlock() | 182 | + | if now.Sub(last) < 120*time.Millisecond { |
| 183 | - | return | 183 | + | // Skip duplicate |
| 184 | - | } | 184 | + | regMu.Unlock() |
| 185 | - | lastKeyTime[uid][name] = now | 185 | + | return |
| 186 | - | regMu.Unlock() | 186 | + | } |
| 187 | - | log.Printf("[accelerator] %s", name) | 187 | + | lastKeyTime[uid][name] = now |
| 188 | - | cb() | 188 | + | regMu.Unlock() |
| 189 | - | break | 189 | + | log.Printf("[accelerator] %s", name) |
| 190 | - | } | 190 | + | cb() |
| 191 | - | } | 191 | + | break |
| 192 | + | } | |||
| 193 | + | } | |||
| 192 | } | 194 | } | ||
| 193 | 195 | ||||
| 194 | func nextViewID() uintptr { | 196 | func nextViewID() uintptr { | ||
| 195 | - | return uintptr(atomic.AddUint64(&viewIDCounter, 1)) | 197 | + | return uintptr(atomic.AddUint64(&viewIDCounter, 1)) |
| 196 | } | 198 | } | ||
| 197 | 199 | ||||
| 198 | func registerView(id uintptr, w *WebView) { | 200 | func registerView(id uintptr, w *WebView) { | ||
| 199 | - | regMu.Lock() | 201 | + | regMu.Lock() |
| 200 | - | viewByID[id] = w | 202 | + | viewByID[id] = w |
| 201 | - | regMu.Unlock() | 203 | + | regMu.Unlock() |
| 202 | } | 204 | } | ||
| 203 | 205 | ||||
| 204 | func unregisterView(id uintptr) { | 206 | func unregisterView(id uintptr) { | ||
| 205 | - | regMu.Lock() | 207 | + | regMu.Lock() |
| 206 | - | delete(viewByID, id) | 208 | + | delete(viewByID, id) |
| 207 | - | delete(viewShortcuts, id) | 209 | + | delete(viewShortcuts, id) |
| 208 | - | regMu.Unlock() | 210 | + | regMu.Unlock() |
| 209 | } | 211 | } | ||
| 210 | 212 | ||||
| 211 | // AttachKeyboardControllers attaches GTK4 key and mouse controllers to the view widget. | 213 | // AttachKeyboardControllers attaches GTK4 key and mouse controllers to the view widget. | ||
| 212 | func AttachKeyboardControllers(w *WebView) { | 214 | func AttachKeyboardControllers(w *WebView) { | ||
| 213 | - | if w == nil || w.native == nil || w.native.view == nil { | 215 | + | if w == nil || w.native == nil || w.native.view == nil { |
| 214 | - | return | 216 | + | return |
| 215 | - | } | 217 | + | } |
| 216 | - | C.attach_key_controller(w.native.view, C.ulong(w.id)) | 218 | + | C.attach_key_controller(w.native.view, C.ulong(w.id)) |
| 217 | - | if w.native.win != nil { | 219 | + | if w.native.win != nil { |
| 218 | - | C.attach_key_controller(w.native.win, C.ulong(w.id)) | 220 | + | C.attach_key_controller(w.native.win, C.ulong(w.id)) |
| 219 | - | } | 221 | + | } |
| 220 | - | C.attach_mouse_gesture(w.native.view, C.ulong(w.id)) | 222 | + | C.attach_mouse_gesture(w.native.view, C.ulong(w.id)) |
| 221 | - | // Legacy controller captures raw button presses (8/9) for back/forward reliably | 223 | + | // Legacy controller captures raw button presses (8/9) for back/forward reliably |
| 222 | - | C.attach_mouse_legacy(w.native.view, C.ulong(w.id)) | 224 | + | C.attach_mouse_legacy(w.native.view, C.ulong(w.id)) |
| 223 | - | log.Printf("[input] GTK4 key/mouse controllers attached") | 225 | + | log.Printf("[input] GTK4 key/mouse controllers attached") |
| 224 | } | 226 | } | ||
| 225 | 227 | ||||
| 226 | //export goOnScroll | 228 | //export goOnScroll | ||
| 227 | func goOnScroll(id C.ulong, dx C.double, dy C.double, state C.GdkModifierType) { | 229 | func goOnScroll(id C.ulong, dx C.double, dy C.double, state C.GdkModifierType) { | ||
| 228 | - | uid := uintptr(id) | 230 | + | uid := uintptr(id) |
| 229 | - | regMu.RLock() | 231 | + | regMu.RLock() |
| 230 | - | vw := viewByID[uid] | 232 | + | vw := viewByID[uid] |
| 231 | - | regMu.RUnlock() | 233 | + | regMu.RUnlock() |
| 232 | - | if vw == nil { return } | 234 | + | if vw == nil { |
| 233 | - | // Ctrl+scroll zooms | 235 | + | return |
| 234 | - | if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 { | 236 | + | } |
| 235 | - | if float64(dy) < 0 { | 237 | + | // Ctrl+scroll zooms |
| 236 | - | // scroll up -> zoom in | 238 | + | if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 { |
| 237 | - | nz := vw.zoom | 239 | + | if float64(dy) < 0 { |
| 238 | - | if nz <= 0 { nz = 1.0 } | 240 | + | // scroll up -> zoom in |
| 239 | - | nz *= 1.1 | 241 | + | nz := vw.zoom |
| 240 | - | if nz < 0.25 { nz = 0.25 } | 242 | + | if nz <= 0 { |
| 241 | - | if nz > 5.0 { nz = 5.0 } | 243 | + | nz = 1.0 |
| 242 | - | _ = vw.SetZoom(nz) | 244 | + | } |
| 243 | - | } else if float64(dy) > 0 { | 245 | + | nz *= 1.1 |
| 244 | - | // scroll down -> zoom out | 246 | + | if nz < 0.25 { |
| 245 | - | nz := vw.zoom | 247 | + | nz = 0.25 |
| 246 | - | if nz <= 0 { nz = 1.0 } | 248 | + | } |
| 247 | - | nz /= 1.1 | 249 | + | if nz > 5.0 { |
| 248 | - | if nz < 0.25 { nz = 0.25 } | 250 | + | nz = 5.0 |
| 249 | - | if nz > 5.0 { nz = 5.0 } | 251 | + | } |
| 250 | - | _ = vw.SetZoom(nz) | 252 | + | _ = vw.SetZoom(nz) |
| 251 | - | } | 253 | + | } else if float64(dy) > 0 { |
| 252 | - | } | 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 | //export goOnThemeChanged | 271 | //export goOnThemeChanged | ||
| 256 | func goOnThemeChanged(id C.ulong, preferDark C.int) { | 272 | func goOnThemeChanged(id C.ulong, preferDark C.int) { | ||
| 257 | - | uid := uintptr(id) | 273 | + | uid := uintptr(id) |
| 258 | - | regMu.RLock() | 274 | + | regMu.RLock() |
| 259 | - | vw := viewByID[uid] | 275 | + | vw := viewByID[uid] |
| 260 | - | regMu.RUnlock() | 276 | + | regMu.RUnlock() |
| 261 | - | if vw == nil { | 277 | + | if vw == nil { |
| 262 | - | return | 278 | + | return |
| 263 | - | } | 279 | + | } |
| 264 | - | // Inject runtime theme update into the page | 280 | + | // Inject runtime theme update into the page |
| 265 | - | dval := "false" | 281 | + | dval := "false" |
| 266 | - | if int(preferDark) != 0 { dval = "true" } | 282 | + | if int(preferDark) != 0 { |
| 267 | - | js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs); | 283 | + | dval = "true" |
| 284 | + | } | |||
| 285 | + | js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs); | |||
| 268 | let m=document.querySelector('meta[name="color-scheme"]'); | 286 | let m=document.querySelector('meta[name="color-scheme"]'); | ||
| 269 | if(!m){ m=document.createElement('meta'); m.name='color-scheme'; document.head.appendChild(m); } | 287 | if(!m){ m=document.createElement('meta'); m.name='color-scheme'; document.head.appendChild(m); } | ||
| 270 | m.setAttribute('content', d ? 'dark light' : 'light dark'); | 288 | m.setAttribute('content', d ? 'dark light' : 'light dark'); | ||
| @@ -272,57 +290,63 @@ let s=document.getElementById('__dumber_theme_style'); | |||||
| 272 | if(!s){ s=document.createElement('style'); s.id='__dumber_theme_style'; document.documentElement.appendChild(s); } | 290 | if(!s){ s=document.createElement('style'); s.id='__dumber_theme_style'; document.documentElement.appendChild(s); } | ||
| 273 | s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}'; | 291 | s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}'; | ||
| 274 | } catch(e) { console.warn('[dumber] theme runtime update failed', e); } })();`, dval) | 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 | //export goOnUcmMessage | 296 | //export goOnUcmMessage | ||
| 279 | func goOnUcmMessage(id C.ulong, json *C.char) { | 297 | func goOnUcmMessage(id C.ulong, json *C.char) { | ||
| 280 | - | uid := uintptr(id) | 298 | + | uid := uintptr(id) |
| 281 | - | regMu.RLock() | 299 | + | regMu.RLock() |
| 282 | - | vw := viewByID[uid] | 300 | + | vw := viewByID[uid] |
| 283 | - | regMu.RUnlock() | 301 | + | regMu.RUnlock() |
| 284 | - | if vw == nil || json == nil { return } | 302 | + | if vw == nil || json == nil { |
| 285 | - | goPayload := C.GoString(json) | 303 | + | return |
| 286 | - | vw.dispatchScriptMessage(goPayload) | 304 | + | } |
| 305 | + | goPayload := C.GoString(json) | |||
| 306 | + | vw.dispatchScriptMessage(goPayload) | |||
| 287 | } | 307 | } | ||
| 288 | 308 | ||||
| 289 | //export goOnTitleChanged | 309 | //export goOnTitleChanged | ||
| 290 | func goOnTitleChanged(id C.ulong, ctitle *C.char) { | 310 | func goOnTitleChanged(id C.ulong, ctitle *C.char) { | ||
| 291 | - | uid := uintptr(id) | 311 | + | uid := uintptr(id) |
| 292 | - | regMu.RLock() | 312 | + | regMu.RLock() |
| 293 | - | vw := viewByID[uid] | 313 | + | vw := viewByID[uid] |
| 294 | - | regMu.RUnlock() | 314 | + | regMu.RUnlock() |
| 295 | - | if vw == nil || ctitle == nil { return } | 315 | + | if vw == nil || ctitle == nil { |
| 296 | - | title := C.GoString(ctitle) | 316 | + | return |
| 297 | - | vw.dispatchTitleChanged(title) | 317 | + | } |
| 318 | + | title := C.GoString(ctitle) | |||
| 319 | + | vw.dispatchTitleChanged(title) | |||
| 298 | } | 320 | } | ||
| 299 | 321 | ||||
| 300 | //export goOnURIChanged | 322 | //export goOnURIChanged | ||
| 301 | func goOnURIChanged(id C.ulong, curi *C.char) { | 323 | func goOnURIChanged(id C.ulong, curi *C.char) { | ||
| 302 | - | uid := uintptr(id) | 324 | + | uid := uintptr(id) |
| 303 | - | regMu.RLock() | 325 | + | regMu.RLock() |
| 304 | - | vw := viewByID[uid] | 326 | + | vw := viewByID[uid] |
| 305 | - | regMu.RUnlock() | 327 | + | regMu.RUnlock() |
| 306 | - | if vw == nil || curi == nil { return } | 328 | + | if vw == nil || curi == nil { |
| 307 | - | uri := C.GoString(curi) | 329 | + | return |
| 308 | - | vw.dispatchURIChanged(uri) | 330 | + | } |
| 331 | + | uri := C.GoString(curi) | |||
| 332 | + | vw.dispatchURIChanged(uri) | |||
| 309 | } | 333 | } | ||
| 310 | 334 | ||||
| 311 | //export goOnButtonPress | 335 | //export goOnButtonPress | ||
| 312 | func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) { | 336 | func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) { | ||
| 313 | - | uid := uintptr(id) | 337 | + | uid := uintptr(id) |
| 314 | - | btn := uint(button) | 338 | + | btn := uint(button) |
| 315 | - | log.Printf("[mouse] button=%d state=0x%x", btn, uint(state)) | 339 | + | log.Printf("[mouse] button=%d state=0x%x", btn, uint(state)) |
| 316 | - | regMu.RLock() | 340 | + | regMu.RLock() |
| 317 | - | vw := viewByID[uid] | 341 | + | vw := viewByID[uid] |
| 318 | - | regMu.RUnlock() | 342 | + | regMu.RUnlock() |
| 319 | - | if vw == nil { | 343 | + | if vw == nil { |
| 320 | - | return | 344 | + | return |
| 321 | - | } | 345 | + | } |
| 322 | - | switch btn { | 346 | + | switch btn { |
| 323 | - | case 8: // Back button on many mice | 347 | + | case 8: // Back button on many mice |
| 324 | - | _ = vw.GoBack() | 348 | + | _ = vw.GoBack() |
| 325 | - | case 9: // Forward button on many mice | 349 | + | case 9: // Forward button on many mice |
| 326 | - | _ = vw.GoForward() | 350 | + | _ = vw.GoForward() |
| 327 | - | } | 351 | + | } |
| 328 | } | 352 | } | ||
M pkg/webkit/loop.go +1 -2
| @@ -6,9 +6,8 @@ import "log" | |||||
| 6 | 6 | ||||
| 7 | // RunMainLoop is a no-op in non-CGO builds. | 7 | // RunMainLoop is a no-op in non-CGO builds. | ||
| 8 | func RunMainLoop() { | 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 | // QuitMainLoop is a no-op in non-CGO builds. | 12 | // QuitMainLoop is a no-op in non-CGO builds. | ||
| 13 | func QuitMainLoop() {} | 13 | func QuitMainLoop() {} | ||
| 14 | - | ||||
M pkg/webkit/loop_cgo.go +7 -7
| @@ -13,15 +13,15 @@ var mainLoop *C.GMainLoop | |||||
| 13 | 13 | ||||
| 14 | // RunMainLoop enters the GLib main loop (GTK4) and blocks until quit. | 14 | // RunMainLoop enters the GLib main loop (GTK4) and blocks until quit. | ||
| 15 | func RunMainLoop() { | 15 | func RunMainLoop() { | ||
| 16 | - | if mainLoop == nil { | 16 | + | if mainLoop == nil { |
| 17 | - | mainLoop = C.g_main_loop_new(nil, C.gboolean(0)) | 17 | + | mainLoop = C.g_main_loop_new(nil, C.gboolean(0)) |
| 18 | - | } | 18 | + | } |
| 19 | - | C.g_main_loop_run(mainLoop) | 19 | + | C.g_main_loop_run(mainLoop) |
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | // QuitMainLoop requests the main loop to exit. | 22 | // QuitMainLoop requests the main loop to exit. | ||
| 23 | func QuitMainLoop() { | 23 | func QuitMainLoop() { | ||
| 24 | - | if mainLoop != nil { | 24 | + | if mainLoop != nil { |
| 25 | - | C.g_main_loop_quit(mainLoop) | 25 | + | C.g_main_loop_quit(mainLoop) |
| 26 | - | } | 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 | import "C" | 8 | import "C" | ||
| 9 | 9 | ||||
| 10 | import ( | 10 | import ( | ||
| 11 | - | "unsafe" | 11 | + | "unsafe" |
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | // Resolver provides bytes and mime for a given custom URI. | 14 | // Resolver provides bytes and mime for a given custom URI. | ||
| @@ -21,30 +21,31 @@ func SetURISchemeResolver(r SchemeResolver) { globalResolver = r } | |||||
| 21 | 21 | ||||
| 22 | //export goResolveURIScheme | 22 | //export goResolveURIScheme | ||
| 23 | func goResolveURIScheme(curi *C.char, outLen *C.size_t, outMime **C.char) unsafe.Pointer { | 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 } | 24 | + | if curi == nil || outLen == nil || outMime == nil { |
| 25 | - | uri := C.GoString(curi) | 25 | + | return nil |
| 26 | - | if globalResolver == nil { | 26 | + | } |
| 27 | - | *outLen = 0 | 27 | + | uri := C.GoString(curi) |
| 28 | - | *outMime = nil | 28 | + | if globalResolver == nil { |
| 29 | - | return nil | 29 | + | *outLen = 0 |
| 30 | - | } | 30 | + | *outMime = nil |
| 31 | - | mime, data, ok := globalResolver(uri) | 31 | + | return nil |
| 32 | - | if !ok || len(data) == 0 { | 32 | + | } |
| 33 | - | *outLen = 0 | 33 | + | mime, data, ok := globalResolver(uri) |
| 34 | - | *outMime = nil | 34 | + | if !ok || len(data) == 0 { |
| 35 | - | return nil | 35 | + | *outLen = 0 |
| 36 | - | } | 36 | + | *outMime = nil |
| 37 | - | buf := C.malloc(C.size_t(len(data))) | 37 | + | return nil |
| 38 | - | if buf == nil { | 38 | + | } |
| 39 | - | *outLen = 0 | 39 | + | buf := C.malloc(C.size_t(len(data))) |
| 40 | - | *outMime = nil | 40 | + | if buf == nil { |
| 41 | - | return nil | 41 | + | *outLen = 0 |
| 42 | - | } | 42 | + | *outMime = nil |
| 43 | - | // Copy data into C buffer | 43 | + | return nil |
| 44 | - | dst := (*[1 << 30]byte)(buf)[:len(data):len(data)] | 44 | + | } |
| 45 | - | copy(dst, data) | 45 | + | // Copy data into C buffer |
| 46 | - | *outLen = C.size_t(len(data)) | 46 | + | dst := (*[1 << 30]byte)(buf)[:len(data):len(data)] |
| 47 | - | *outMime = C.CString(mime) | 47 | + | copy(dst, data) |
| 48 | - | return buf | 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 | // InjectScript evaluates JavaScript in the active WebView context. | 5 | // InjectScript evaluates JavaScript in the active WebView context. | ||
| 6 | func (w *WebView) InjectScript(js string) error { | 6 | func (w *WebView) InjectScript(js string) error { | ||
| 7 | - | if w == nil || w.destroyed { | 7 | + | if w == nil || w.destroyed { |
| 8 | - | return ErrNotImplemented | 8 | + | return ErrNotImplemented |
| 9 | - | } | 9 | + | } |
| 10 | - | // Store last script for potential debugging/testing. | 10 | + | // Store last script for potential debugging/testing. |
| 11 | - | _ = js | 11 | + | _ = js |
| 12 | - | return nil | 12 | + | return nil |
| 13 | } | 13 | } | ||
M pkg/webkit/script_cgo.go +18 -18
| @@ -12,22 +12,22 @@ import "C" | |||||
| 12 | import "unsafe" | 12 | import "unsafe" | ||
| 13 | 13 | ||||
| 14 | func (w *WebView) InjectScript(js string) error { | 14 | func (w *WebView) InjectScript(js string) error { | ||
| 15 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | 15 | + | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { |
| 16 | - | return ErrNotImplemented | 16 | + | return ErrNotImplemented |
| 17 | - | } | 17 | + | } |
| 18 | - | cjs := C.CString(js) | 18 | + | cjs := C.CString(js) |
| 19 | - | defer C.free(unsafe.Pointer(cjs)) | 19 | + | defer C.free(unsafe.Pointer(cjs)) |
| 20 | - | // Fire-and-forget evaluation using the modern API | 20 | + | // Fire-and-forget evaluation using the modern API |
| 21 | - | // length: -1 (NUL-terminated), world_name/source_uri: NULL, starting_line_number: 0 | 21 | + | // length: -1 (NUL-terminated), world_name/source_uri: NULL, starting_line_number: 0 |
| 22 | - | C.webkit_web_view_evaluate_javascript( | 22 | + | C.webkit_web_view_evaluate_javascript( |
| 23 | - | w.native.wv, | 23 | + | w.native.wv, |
| 24 | - | (*C.gchar)(cjs), | 24 | + | (*C.gchar)(cjs), |
| 25 | - | C.gssize(-1), | 25 | + | C.gssize(-1), |
| 26 | - | nil, // world_name | 26 | + | nil, // world_name |
| 27 | - | nil, // source_uri | 27 | + | nil, // source_uri |
| 28 | - | nil, // cancellable | 28 | + | nil, // cancellable |
| 29 | - | nil, // callback | 29 | + | nil, // callback |
| 30 | - | nil, // user_data | 30 | + | nil, // user_data |
| 31 | - | ) | 31 | + | ) |
| 32 | - | return nil | 32 | + | return nil |
| 33 | } | 33 | } | ||
M pkg/webkit/settings.go +69 -21
| @@ -3,37 +3,85 @@ package webkit | |||||
| 3 | // Config holds initialization settings for the WebKit-based browser components. | 3 | // Config holds initialization settings for the WebKit-based browser components. | ||
| 4 | // This will map to WebKit2GTK and GTK settings in the real implementation. | 4 | // This will map to WebKit2GTK and GTK settings in the real implementation. | ||
| 5 | type Config struct { | 5 | type Config struct { | ||
| 6 | - | // InitialURL is the first URL to load when creating a WebView. | 6 | + | // InitialURL is the first URL to load when creating a WebView. |
| 7 | - | InitialURL string | 7 | + | InitialURL string |
| 8 | 8 | ||||
| 9 | - | // UserAgent allows overriding the default user agent string. | 9 | + | // UserAgent allows overriding the default user agent string. |
| 10 | - | UserAgent string | 10 | + | UserAgent string |
| 11 | 11 | ||||
| 12 | - | // EnableDeveloperExtras enables devtools/inspector. | 12 | + | // EnableDeveloperExtras enables devtools/inspector. |
| 13 | - | EnableDeveloperExtras bool | 13 | + | EnableDeveloperExtras bool |
| 14 | 14 | ||||
| 15 | - | // ZoomDefault sets an initial zoom factor (1.0 = 100%). | 15 | + | // ZoomDefault sets an initial zoom factor (1.0 = 100%). |
| 16 | - | ZoomDefault float64 | 16 | + | ZoomDefault float64 |
| 17 | 17 | ||||
| 18 | - | // DataDir is the base directory for persistent website data (cookies, localStorage, etc.). | 18 | + | // DataDir is the base directory for persistent website data (cookies, localStorage, etc.). |
| 19 | - | DataDir string | 19 | + | DataDir string |
| 20 | - | // CacheDir is the base directory for cache data. | 20 | + | // CacheDir is the base directory for cache data. |
| 21 | - | CacheDir string | 21 | + | CacheDir string |
| 22 | 22 | ||||
| 23 | - | // Fonts apply to pages that don't specify fonts (browser defaults). | 23 | + | // Fonts apply to pages that don't specify fonts (browser defaults). |
| 24 | - | DefaultSansFont string | 24 | + | DefaultSansFont string |
| 25 | - | DefaultSerifFont string | 25 | + | DefaultSerifFont string |
| 26 | - | DefaultMonospaceFont string | 26 | + | DefaultMonospaceFont string |
| 27 | - | DefaultFontSize int // CSS px (~points) | 27 | + | DefaultFontSize int // CSS px (~points) |
| 28 | 28 | ||||
| 29 | - | // Rendering controls GPU/CPU selection and debug options | 29 | + | // Rendering controls GPU/CPU selection and debug options |
| 30 | - | Rendering RenderingConfig | 30 | + | Rendering RenderingConfig |
| 31 | + | ||||
| 32 | + | // Memory controls memory optimization settings | |||
| 33 | + | Memory MemoryConfig | |||
| 31 | } | 34 | } | ||
| 32 | 35 | ||||
| 33 | // RenderingConfig controls hardware acceleration preferences. | 36 | // RenderingConfig controls hardware acceleration preferences. | ||
| 34 | // Mode accepts: "auto" (default), "gpu", or "cpu". | 37 | // Mode accepts: "auto" (default), "gpu", or "cpu". | ||
| 35 | // DebugGPU enables compositing indicators if supported. | 38 | // DebugGPU enables compositing indicators if supported. | ||
| 36 | type RenderingConfig struct { | 39 | type RenderingConfig struct { | ||
| 37 | - | Mode string | 40 | + | Mode string |
| 38 | - | DebugGPU bool | 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 | package webkit | 3 | package webkit | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | - | "fmt" | 6 | + | "fmt" |
| 7 | - | "log" | 7 | + | "log" |
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | // WebView represents a browser view powered by WebKit2GTK. | 10 | // WebView represents a browser view powered by WebKit2GTK. | ||
| 11 | // Methods are currently stubs returning ErrNotImplemented to satisfy TDD ordering. | 11 | // Methods are currently stubs returning ErrNotImplemented to satisfy TDD ordering. | ||
| 12 | type WebView struct { | 12 | type WebView struct { | ||
| 13 | - | config *Config | 13 | + | config *Config |
| 14 | - | visible bool | 14 | + | visible bool |
| 15 | - | zoom float64 | 15 | + | zoom float64 |
| 16 | - | url string | 16 | + | url string |
| 17 | - | destroyed bool | 17 | + | destroyed bool |
| 18 | - | window *Window | 18 | + | window *Window |
| 19 | - | msgHandler func(payload string) | 19 | + | msgHandler func(payload string) |
| 20 | - | titleHandler func(title string) | 20 | + | titleHandler func(title string) |
| 21 | - | uriHandler func(uri string) | 21 | + | uriHandler func(uri string) |
| 22 | + | zoomHandler func(level float64) | |||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | // NewWebView constructs a new WebView instance. | 25 | // NewWebView constructs a new WebView instance. | ||
| 25 | func NewWebView(cfg *Config) (*WebView, error) { | 26 | func NewWebView(cfg *Config) (*WebView, error) { | ||
| 26 | - | if cfg == nil { | 27 | + | if cfg == nil { |
| 27 | - | cfg = &Config{} | 28 | + | cfg = &Config{} |
| 28 | - | } | 29 | + | } |
| 29 | - | log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.") | 30 | + | 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 | + | wv := &WebView{config: cfg} |
| 31 | - | if cfg.ZoomDefault <= 0 { | 32 | + | if cfg.ZoomDefault <= 0 { |
| 32 | - | wv.zoom = 1.0 | 33 | + | wv.zoom = 1.0 |
| 33 | - | } else { | 34 | + | } else { |
| 34 | - | wv.zoom = cfg.ZoomDefault | 35 | + | wv.zoom = cfg.ZoomDefault |
| 35 | - | } | 36 | + | } |
| 36 | - | // Construction succeeds; create a logical window placeholder. | 37 | + | // Construction succeeds; create a logical window placeholder. |
| 37 | - | wv.window = &Window{Title: "Dumber Browser"} | 38 | + | wv.window = &Window{Title: "Dumber Browser"} |
| 38 | - | return wv, nil | 39 | + | return wv, nil |
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 41 | // LoadURL navigates the webview to the specified URL. | 42 | // LoadURL navigates the webview to the specified URL. | ||
| 42 | func (w *WebView) LoadURL(rawURL string) error { | 43 | func (w *WebView) LoadURL(rawURL string) error { | ||
| 43 | - | if w == nil || w.destroyed { | 44 | + | if w == nil || w.destroyed { |
| 44 | - | return ErrNotImplemented | 45 | + | return ErrNotImplemented |
| 45 | - | } | 46 | + | } |
| 46 | - | if rawURL == "" { | 47 | + | if rawURL == "" { |
| 47 | - | return fmt.Errorf("url cannot be empty") | 48 | + | return fmt.Errorf("url cannot be empty") |
| 48 | - | } | 49 | + | } |
| 49 | - | // Minimal validation; actual navigation handled by CGO bridge later. | 50 | + | // Minimal validation; actual navigation handled by CGO bridge later. |
| 50 | - | w.url = rawURL | 51 | + | w.url = rawURL |
| 51 | - | log.Printf("[webkit] LoadURL (non-CGO stub): %s", rawURL) | 52 | + | log.Printf("[webkit] LoadURL (non-CGO stub): %s", rawURL) |
| 52 | - | return nil | 53 | + | return nil |
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| 55 | // Show makes the WebView visible. | 56 | // Show makes the WebView visible. | ||
| 56 | func (w *WebView) Show() error { | 57 | func (w *WebView) Show() error { | ||
| 57 | - | if w == nil || w.destroyed { | 58 | + | if w == nil || w.destroyed { |
| 58 | - | return ErrNotImplemented | 59 | + | return ErrNotImplemented |
| 59 | - | } | 60 | + | } |
| 60 | - | w.visible = true | 61 | + | w.visible = true |
| 61 | - | log.Printf("[webkit] Show (non-CGO stub): no native window will appear") | 62 | + | log.Printf("[webkit] Show (non-CGO stub): no native window will appear") |
| 62 | - | return nil | 63 | + | return nil |
| 63 | } | 64 | } | ||
| 64 | 65 | ||||
| 65 | // Hide hides the WebView window. | 66 | // Hide hides the WebView window. | ||
| 66 | func (w *WebView) Hide() error { | 67 | func (w *WebView) Hide() error { | ||
| 67 | - | if w == nil || w.destroyed { | 68 | + | if w == nil || w.destroyed { |
| 68 | - | return ErrNotImplemented | 69 | + | return ErrNotImplemented |
| 69 | - | } | 70 | + | } |
| 70 | - | w.visible = false | 71 | + | w.visible = false |
| 71 | - | return nil | 72 | + | return nil |
| 72 | } | 73 | } | ||
| 73 | 74 | ||||
| 74 | // Destroy releases native resources. | 75 | // Destroy releases native resources. | ||
| 75 | func (w *WebView) Destroy() error { | 76 | func (w *WebView) Destroy() error { | ||
| 76 | - | if w == nil { | 77 | + | if w == nil { |
| 77 | - | return ErrNotImplemented | 78 | + | return ErrNotImplemented |
| 78 | - | } | 79 | + | } |
| 79 | - | w.destroyed = true | 80 | + | w.destroyed = true |
| 80 | - | return nil | 81 | + | return nil |
| 81 | } | 82 | } | ||
| 82 | 83 | ||||
| 83 | // Window returns the associated native window wrapper (non-nil). | 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 | // GoBack is not supported in the non-CGO stub. | 90 | // GoBack is not supported in the non-CGO stub. | ||
| 90 | func (w *WebView) GoBack() error { return ErrNotImplemented } | 91 | func (w *WebView) GoBack() error { return ErrNotImplemented } | ||
| 92 | + | ||||
| 91 | // GoForward is not supported in the non-CGO stub. | 93 | // GoForward is not supported in the non-CGO stub. | ||
| 92 | func (w *WebView) GoForward() error { return ErrNotImplemented } | 94 | func (w *WebView) GoForward() error { return ErrNotImplemented } | ||
| 93 | 95 | ||||
| 94 | // ShowDevTools is a no-op in the non-CGO build. | 96 | // ShowDevTools is a no-op in the non-CGO build. | ||
| 95 | func (w *WebView) ShowDevTools() error { return nil } | 97 | func (w *WebView) ShowDevTools() error { return nil } | ||
| 98 | + | ||||
| 96 | // CloseDevTools is a no-op in the non-CGO build. | 99 | // CloseDevTools is a no-op in the non-CGO build. | ||
| 97 | func (w *WebView) CloseDevTools() error { return nil } | 100 | func (w *WebView) CloseDevTools() error { return nil } | ||
| 98 | 101 | ||||
| @@ -100,19 +103,34 @@ func (w *WebView) CloseDevTools() error { return nil } | |||||
| 100 | func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb } | 103 | func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb } | ||
| 101 | 104 | ||||
| 102 | func (w *WebView) dispatchScriptMessage(payload string) { | 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 | // RegisterTitleChangedHandler registers a callback invoked when the page title changes. | 111 | // RegisterTitleChangedHandler registers a callback invoked when the page title changes. | ||
| 107 | func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb } | 112 | func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb } | ||
| 108 | 113 | ||||
| 109 | func (w *WebView) dispatchTitleChanged(title string) { | 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 | // RegisterURIChangedHandler registers a callback invoked when the current page URI changes. | 120 | // RegisterURIChangedHandler registers a callback invoked when the current page URI changes. | ||
| 114 | func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb } | 121 | func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb } | ||
| 115 | 122 | ||||
| 116 | func (w *WebView) dispatchURIChanged(uri string) { | 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 | static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy); | 23 | static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy); | ||
| 24 | 24 | ||||
| 25 | // Construct a WebKitWebView via g_object_new with a fresh UserContentManager. | 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 | WebKitUserContentManager* u = webkit_user_content_manager_new(); | 27 | WebKitUserContentManager* u = webkit_user_content_manager_new(); | ||
| 28 | if (!u) return NULL; | 28 | if (!u) return NULL; | ||
| 29 | // WebKitGTK 6: create NetworkSession with data/cache directories | 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 | data_dir ? data_dir : "", | 31 | data_dir ? data_dir : "", | ||
| 32 | cache_dir ? cache_dir : ""); | 32 | cache_dir ? cache_dir : ""); | ||
| 33 | if (!sess) { g_object_unref(u); return NULL; } | 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 | // Configure cookie persistence and policy (no third-party by default) | 39 | // Configure cookie persistence and policy (no third-party by default) | ||
| 35 | WebKitCookieManager* cm = webkit_network_session_get_cookie_manager(sess); | 40 | WebKitCookieManager* cm = webkit_network_session_get_cookie_manager(sess); | ||
| 36 | if (cm) { | 41 | if (cm) { | ||
| @@ -231,18 +236,106 @@ static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy) { | |||||
| 231 | (void)policy; // Not supported in this header version | 236 | (void)policy; // Not supported in this header version | ||
| 232 | #endif | 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 | import "C" | 319 | import "C" | ||
| 236 | 320 | ||||
| 237 | import ( | 321 | import ( | ||
| 238 | - | "errors" | 322 | + | "errors" |
| 239 | - | "log" | 323 | + | "log" |
| 240 | - | "os" | 324 | + | "os" |
| 241 | - | "path/filepath" | 325 | + | "path/filepath" |
| 242 | - | "runtime" | 326 | + | "runtime" |
| 243 | - | "unsafe" | 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 | const omniboxDefaultScript = `(() => { | 339 | const omniboxDefaultScript = `(() => { | ||
| 247 | try { | 340 | try { | ||
| 248 | if (window.__dumber_omnibox_loaded) return; window.__dumber_omnibox_loaded = true; | 341 | if (window.__dumber_omnibox_loaded) return; window.__dumber_omnibox_loaded = true; | ||
| @@ -274,261 +367,408 @@ const omniboxDefaultScript = `(() => { | |||||
| 274 | })();` | 367 | })();` | ||
| 275 | 368 | ||||
| 276 | type nativeView struct { | 369 | type nativeView struct { | ||
| 277 | - | win *C.GtkWidget | 370 | + | win *C.GtkWidget |
| 278 | - | view *C.GtkWidget | 371 | + | view *C.GtkWidget |
| 279 | - | wv *C.WebKitWebView | 372 | + | wv *C.WebKitWebView |
| 280 | - | ucm *C.WebKitUserContentManager | 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 | // WebView represents a browser view powered by WebKit2GTK. | 382 | // WebView represents a browser view powered by WebKit2GTK. | ||
| 284 | type WebView struct { | 383 | type WebView struct { | ||
| 285 | - | config *Config | 384 | + | config *Config |
| 286 | - | zoom float64 | 385 | + | zoom float64 |
| 287 | - | url string | 386 | + | url string |
| 288 | - | destroyed bool | 387 | + | destroyed bool |
| 289 | - | native *nativeView | 388 | + | native *nativeView |
| 290 | - | window *Window | 389 | + | window *Window |
| 291 | - | id uintptr | 390 | + | id uintptr |
| 292 | - | msgHandler func(payload string) | 391 | + | msgHandler func(payload string) |
| 293 | - | titleHandler func(title string) | 392 | + | titleHandler func(title string) |
| 294 | - | uriHandler func(uri string) | 393 | + | uriHandler func(uri string) |
| 295 | - | zoomHandler func(level float64) | 394 | + | zoomHandler func(level float64) |
| 395 | + | memStats *memoryStats | |||
| 396 | + | gcTicker *time.Ticker | |||
| 397 | + | gcDone chan struct{} | |||
| 296 | } | 398 | } | ||
| 297 | 399 | ||||
| 298 | // NewWebView constructs a new WebView instance with native WebKit2GTK widgets. | 400 | // NewWebView constructs a new WebView instance with native WebKit2GTK widgets. | ||
| 299 | func NewWebView(cfg *Config) (*WebView, error) { | 401 | func NewWebView(cfg *Config) (*WebView, error) { | ||
| 300 | - | log.Printf("[webkit] Initializing GTK and creating WebView (CGO)") | 402 | + | log.Printf("[webkit] Initializing GTK and creating WebView (CGO)") |
| 301 | - | if C.gtk_init_check() == 0 { | 403 | + | if C.gtk_init_check() == 0 { |
| 302 | - | return nil, errors.New("failed to initialize GTK") | 404 | + | return nil, errors.New("failed to initialize GTK") |
| 303 | - | } | 405 | + | } |
| 304 | - | 406 | + | ||
| 305 | - | if cfg == nil { | 407 | + | if cfg == nil { |
| 306 | - | cfg = &Config{} | 408 | + | cfg = &Config{} |
| 307 | - | } | 409 | + | } |
| 308 | - | 410 | + | ||
| 309 | - | // Prepare persistent website data/caches | 411 | + | // Prepare persistent website data/caches |
| 310 | - | dataDir := cfg.DataDir | 412 | + | dataDir := cfg.DataDir |
| 311 | - | cacheDir := cfg.CacheDir | 413 | + | cacheDir := cfg.CacheDir |
| 312 | - | if dataDir == "" { dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data") } | 414 | + | if dataDir == "" { |
| 313 | - | if cacheDir == "" { cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache") } | 415 | + | dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data") |
| 314 | - | _ = os.MkdirAll(dataDir, 0o755) | 416 | + | } |
| 315 | - | _ = os.MkdirAll(cacheDir, 0o755) | 417 | + | if cacheDir == "" { |
| 316 | - | 418 | + | cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache") | |
| 317 | - | cData := C.CString(dataDir) | 419 | + | } |
| 318 | - | cCache := C.CString(cacheDir) | 420 | + | _ = os.MkdirAll(dataDir, 0o755) |
| 319 | - | cookieFile := filepath.Join(dataDir, "cookies.db") | 421 | + | _ = os.MkdirAll(cacheDir, 0o755) |
| 320 | - | cCookie := C.CString(cookieFile) | 422 | + | |
| 321 | - | defer C.free(unsafe.Pointer(cData)) | 423 | + | cData := C.CString(dataDir) |
| 322 | - | defer C.free(unsafe.Pointer(cCache)) | 424 | + | cCache := C.CString(cacheDir) |
| 323 | - | defer C.free(unsafe.Pointer(cCookie)) | 425 | + | cookieFile := filepath.Join(dataDir, "cookies.db") |
| 324 | - | // Use default WebContext to register the custom URI scheme handler; the | 426 | + | cCookie := C.CString(cookieFile) |
| 325 | - | // WebView itself is created with a WebKitNetworkSession for persistence. | 427 | + | defer C.free(unsafe.Pointer(cData)) |
| 326 | - | ctx := C.webkit_web_context_get_default() | 428 | + | defer C.free(unsafe.Pointer(cCache)) |
| 327 | - | 429 | + | defer C.free(unsafe.Pointer(cCookie)) | |
| 328 | - | // Register custom URI scheme handler for "dumb://" | 430 | + | // Use default WebContext to register the custom URI scheme handler; the |
| 329 | - | { | 431 | + | // WebView itself is created with a WebKitNetworkSession for persistence. |
| 330 | - | sch := C.CString("dumb") | 432 | + | ctx := C.webkit_web_context_get_default() |
| 331 | - | C.webkit_web_context_register_uri_scheme(ctx, sch, (C.WebKitURISchemeRequestCallback)(C.on_uri_scheme), nil, nil) | 433 | + | |
| 332 | - | C.free(unsafe.Pointer(sch)) | 434 | + | // Apply cache model to WebContext |
| 333 | - | } | 435 | + | // Default to WEBKIT_CACHE_MODEL_WEB_BROWSER to keep caching enabled |
| 334 | - | // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session | 436 | + | cacheModel := 1 |
| 335 | - | 437 | + | if cfg.Memory.CacheModel != "" { | |
| 336 | - | // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style) | 438 | + | switch cfg.Memory.CacheModel { |
| 337 | - | var createdUcm *C.WebKitUserContentManager | 439 | + | case CacheModelWebBrowser: |
| 338 | - | viewWidget := C.new_webview_with_ucm_and_session(cData, cCache, cCookie, &createdUcm) | 440 | + | cacheModel = 1 |
| 339 | - | if viewWidget == nil { | 441 | + | case CacheModelPrimaryWebBrowser: |
| 340 | - | return nil, errors.New("failed to create WebKitWebView") | 442 | + | cacheModel = 2 |
| 341 | - | } | 443 | + | default: // CacheModelDocumentViewer or unset |
| 342 | - | 444 | + | cacheModel = 0 | |
| 343 | - | // Create a top-level window to host the view | 445 | + | } |
| 344 | - | win := C.new_window() | 446 | + | } |
| 345 | - | if win == nil { | 447 | + | C.maybe_set_cache_model(ctx, C.int(cacheModel)) |
| 346 | - | return nil, errors.New("failed to create GtkWindow") | 448 | + | |
| 347 | - | } | 449 | + | // Configure memory pressure settings if specified |
| 348 | - | 450 | + | var pressureSettings *C.WebKitMemoryPressureSettings | |
| 349 | - | // Pack view widget into the window | 451 | + | // Only configure memory pressure if explicitly requested via config |
| 350 | - | // GTK4: containers removed; use gtk_window_set_child | 452 | + | if cfg.Memory.MemoryLimitMB > 0 || cfg.Memory.ConservativeThreshold > 0 || cfg.Memory.StrictThreshold > 0 || cfg.Memory.KillThreshold > 0 || cfg.Memory.PollIntervalSeconds > 0 { |
| 351 | - | C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), viewWidget) | 453 | + | memLimitMB := cfg.Memory.MemoryLimitMB |
| 352 | - | C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768) | 454 | + | conservativeThreshold := cfg.Memory.ConservativeThreshold |
| 353 | - | C.connect_destroy_quit(win) | 455 | + | strictThreshold := cfg.Memory.StrictThreshold |
| 354 | - | 456 | + | killThreshold := cfg.Memory.KillThreshold | |
| 355 | - | v := &WebView{ | 457 | + | pollInterval := cfg.Memory.PollIntervalSeconds |
| 356 | - | config: cfg, | 458 | + | |
| 357 | - | zoom: 1.0, | 459 | + | pressureSettings = C.create_memory_pressure_settings( |
| 358 | - | native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm}, | 460 | + | C.uint(memLimitMB), |
| 359 | - | window: &Window{win: win}, | 461 | + | C.double(conservativeThreshold), |
| 360 | - | } | 462 | + | C.double(strictThreshold), |
| 361 | - | // Assign an ID for accelerator dispatch | 463 | + | C.double(killThreshold), |
| 362 | - | v.id = nextViewID() | 464 | + | C.double(pollInterval)) |
| 363 | - | registerView(v.id, v) | 465 | + | |
| 364 | - | // Attach GTK4 input controllers (keyboard, mouse) | 466 | + | if pressureSettings != nil { |
| 365 | - | AttachKeyboardControllers(v) | 467 | + | C.maybe_set_memory_pressure_settings(ctx, pressureSettings) |
| 366 | - | 468 | + | if cfg.Memory.EnableMemoryMonitoring { | |
| 367 | - | // Watch for GTK theme changes and propagate to page at runtime | 469 | + | log.Printf("[webkit] Memory pressure settings applied: limit=%dMB, conservative=%.2f, strict=%.2f, poll=%.1fs", |
| 368 | - | if settings := C.gtk_settings_get_default(); settings != nil { | 470 | + | memLimitMB, conservativeThreshold, strictThreshold, pollInterval) |
| 369 | - | cprop := C.CString("notify::gtk-application-prefer-dark-theme") | 471 | + | } |
| 370 | - | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(settings)), cprop, C.GCallback(C.on_theme_changed), C.gpointer(v.id), nil, 0) | 472 | + | } |
| 371 | - | C.free(unsafe.Pointer(cprop)) | 473 | + | } |
| 372 | - | } | 474 | + | |
| 373 | - | 475 | + | // Register custom URI scheme handler for "dumb://" | |
| 374 | - | // Native zoom shortcuts (independent of app services) | 476 | + | { |
| 375 | - | // Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset | 477 | + | sch := C.CString("dumb") |
| 376 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() { | 478 | + | C.webkit_web_context_register_uri_scheme(ctx, sch, (C.WebKitURISchemeRequestCallback)(C.on_uri_scheme), nil, nil) |
| 377 | - | nz := v.zoom | 479 | + | C.free(unsafe.Pointer(sch)) |
| 378 | - | if nz <= 0 { nz = 1.0 } | 480 | + | } |
| 379 | - | nz *= 1.1 | 481 | + | // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session |
| 380 | - | if nz < 0.25 { nz = 0.25 } | 482 | + | |
| 381 | - | if nz > 5.0 { nz = 5.0 } | 483 | + | // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style) |
| 382 | - | _ = v.SetZoom(nz) | 484 | + | var createdUcm *C.WebKitUserContentManager |
| 383 | - | }) | 485 | + | viewWidget := C.new_webview_with_ucm_and_session(cData, cCache, cCookie, &createdUcm, pressureSettings) |
| 384 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+plus", func() { | 486 | + | if viewWidget == nil { |
| 385 | - | nz := v.zoom | 487 | + | return nil, errors.New("failed to create WebKitWebView") |
| 386 | - | if nz <= 0 { nz = 1.0 } | 488 | + | } |
| 387 | - | nz *= 1.1 | 489 | + | |
| 388 | - | if nz < 0.25 { nz = 0.25 } | 490 | + | // Create a top-level window to host the view |
| 389 | - | if nz > 5.0 { nz = 5.0 } | 491 | + | win := C.new_window() |
| 390 | - | _ = v.SetZoom(nz) | 492 | + | if win == nil { |
| 391 | - | }) | 493 | + | return nil, errors.New("failed to create GtkWindow") |
| 392 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl-", func() { | 494 | + | } |
| 393 | - | nz := v.zoom | 495 | + | |
| 394 | - | if nz <= 0 { nz = 1.0 } | 496 | + | // Pack view widget into the window |
| 395 | - | nz /= 1.1 | 497 | + | // GTK4: containers removed; use gtk_window_set_child |
| 396 | - | if nz < 0.25 { nz = 0.25 } | 498 | + | C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), viewWidget) |
| 397 | - | if nz > 5.0 { nz = 5.0 } | 499 | + | C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768) |
| 398 | - | _ = v.SetZoom(nz) | 500 | + | C.connect_destroy_quit(win) |
| 399 | - | }) | 501 | + | |
| 400 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+0", func() { _ = v.SetZoom(1.0) }) | 502 | + | v := &WebView{ |
| 401 | - | // Navigation with Alt+Arrow keys | 503 | + | config: cfg, |
| 402 | - | _ = v.RegisterKeyboardShortcut("alt+ArrowLeft", func() { _ = v.GoBack() }) | 504 | + | zoom: 1.0, |
| 403 | - | _ = v.RegisterKeyboardShortcut("alt+ArrowRight", func() { _ = v.GoForward() }) | 505 | + | native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm}, |
| 404 | - | // Update window title when page title changes | 506 | + | window: &Window{win: win}, |
| 405 | - | cNotifyTitle1 := C.CString("notify::title") | 507 | + | memStats: &memoryStats{ |
| 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) | 508 | + | pageLoadCount: 0, |
| 407 | - | C.free(unsafe.Pointer(cNotifyTitle1)) | 509 | + | lastGCTime: time.Now(), |
| 408 | - | // Also dispatch title change to Go with view id | 510 | + | memoryPressureSettings: pressureSettings, |
| 409 | - | cNotifyTitle2 := C.CString("notify::title") | 511 | + | }, |
| 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) | 512 | + | gcDone: make(chan struct{}), |
| 411 | - | C.free(unsafe.Pointer(cNotifyTitle2)) | 513 | + | } |
| 412 | - | // Notify URI changes to Go to keep current URL in sync | 514 | + | // Assign an ID for accelerator dispatch |
| 413 | - | cNotifyURI := C.CString("notify::uri") | 515 | + | v.id = nextViewID() |
| 414 | - | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyURI, C.GCallback(C.on_uri_notify), C.gpointer(v.id), nil, 0) | 516 | + | registerView(v.id, v) |
| 415 | - | C.free(unsafe.Pointer(cNotifyURI)) | 517 | + | |
| 416 | - | // Apply hardware acceleration and related settings based on cfg.Rendering | 518 | + | // Register with global memory manager if monitoring is enabled |
| 417 | - | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | 519 | + | if cfg.Memory.EnableMemoryMonitoring { |
| 418 | - | // Hardware acceleration policy (guarded by version) | 520 | + | if globalMemoryManager == nil { |
| 419 | - | switch cfg.Rendering.Mode { | 521 | + | InitializeGlobalMemoryManager( |
| 420 | - | case "gpu": | 522 | + | cfg.Memory.EnableMemoryMonitoring, |
| 421 | - | C.maybe_set_hw_policy(settings, 1) | 523 | + | cfg.Memory.ProcessRecycleThreshold, |
| 422 | - | C.maybe_set_webgl(settings, 1) | 524 | + | 30*time.Second, // Default monitoring interval |
| 423 | - | C.maybe_set_canvas_accel(settings, 1) | 525 | + | ) |
| 424 | - | case "cpu": | 526 | + | } |
| 425 | - | C.maybe_set_hw_policy(settings, 2) | 527 | + | if globalMemoryManager != nil { |
| 426 | - | C.maybe_set_webgl(settings, 0) | 528 | + | globalMemoryManager.RegisterWebView(v) |
| 427 | - | C.maybe_set_canvas_accel(settings, 0) | 529 | + | } |
| 428 | - | default: // auto | 530 | + | } |
| 429 | - | C.maybe_set_hw_policy(settings, 0) | 531 | + | // Attach GTK4 input controllers (keyboard, mouse) |
| 430 | - | C.maybe_set_webgl(settings, 1) | 532 | + | AttachKeyboardControllers(v) |
| 431 | - | C.maybe_set_canvas_accel(settings, 1) | 533 | + | |
| 432 | - | } | 534 | + | // Watch for GTK theme changes and propagate to page at runtime |
| 433 | - | // Optional compositing indicators for debugging (guarded by version) | 535 | + | if settings := C.gtk_settings_get_default(); settings != nil { |
| 434 | - | if cfg.Rendering.DebugGPU { C.maybe_set_draw_indicators(settings, 1) } | 536 | + | cprop := C.CString("notify::gtk-application-prefer-dark-theme") |
| 435 | - | // Reduce media pipeline churn by requiring a user gesture for playback | 537 | + | C.g_signal_connect_data(C.gpointer(unsafe.Pointer(settings)), cprop, C.GCallback(C.on_theme_changed), C.gpointer(v.id), nil, 0) |
| 436 | - | C.maybe_set_media_user_gesture(settings, 1) | 538 | + | C.free(unsafe.Pointer(cprop)) |
| 437 | - | // Enable trackpad back/forward gestures when available | 539 | + | } |
| 438 | - | C.maybe_set_back_forward_gestures(settings, 1) | 540 | + | |
| 439 | - | } | 541 | + | // Native zoom shortcuts (independent of app services) |
| 440 | - | 542 | + | // Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset | |
| 441 | - | if cfg.ZoomDefault > 0 { | 543 | + | _ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() { |
| 442 | - | v.zoom = cfg.ZoomDefault | 544 | + | nz := v.zoom |
| 443 | - | C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom)) | 545 | + | if nz <= 0 { |
| 444 | - | } | 546 | + | nz = 1.0 |
| 445 | - | if cfg.EnableDeveloperExtras { | 547 | + | } |
| 446 | - | settings := C.webkit_web_view_get_settings(v.native.wv) | 548 | + | nz *= 1.1 |
| 447 | - | if settings != nil { | 549 | + | if nz < 0.25 { |
| 448 | - | C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1)) | 550 | + | nz = 0.25 |
| 449 | - | } | 551 | + | } |
| 450 | - | } | 552 | + | if nz > 5.0 { |
| 451 | - | // Apply default fonts if provided in cfg | 553 | + | nz = 5.0 |
| 452 | - | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | 554 | + | } |
| 453 | - | if cfg.DefaultSansFont != "" { | 555 | + | _ = v.SetZoom(nz) |
| 454 | - | csans := C.CString(cfg.DefaultSansFont) | 556 | + | }) |
| 455 | - | C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans)) | 557 | + | _ = v.RegisterKeyboardShortcut("cmdorctrl+plus", func() { |
| 456 | - | C.free(unsafe.Pointer(csans)) | 558 | + | nz := v.zoom |
| 457 | - | // Also set default font family to sans | 559 | + | if nz <= 0 { |
| 458 | - | csans2 := C.CString(cfg.DefaultSansFont) | 560 | + | nz = 1.0 |
| 459 | - | C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2)) | 561 | + | } |
| 460 | - | C.free(unsafe.Pointer(csans2)) | 562 | + | nz *= 1.1 |
| 461 | - | } | 563 | + | if nz < 0.25 { |
| 462 | - | if cfg.DefaultSerifFont != "" { | 564 | + | nz = 0.25 |
| 463 | - | cserif := C.CString(cfg.DefaultSerifFont) | 565 | + | } |
| 464 | - | C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif)) | 566 | + | if nz > 5.0 { |
| 465 | - | C.free(unsafe.Pointer(cserif)) | 567 | + | nz = 5.0 |
| 466 | - | } | 568 | + | } |
| 467 | - | if cfg.DefaultMonospaceFont != "" { | 569 | + | _ = v.SetZoom(nz) |
| 468 | - | cmono := C.CString(cfg.DefaultMonospaceFont) | 570 | + | }) |
| 469 | - | C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono)) | 571 | + | _ = v.RegisterKeyboardShortcut("cmdorctrl-", func() { |
| 470 | - | C.free(unsafe.Pointer(cmono)) | 572 | + | nz := v.zoom |
| 471 | - | } | 573 | + | if nz <= 0 { |
| 472 | - | if cfg.DefaultFontSize > 0 { | 574 | + | nz = 1.0 |
| 473 | - | C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize)) | 575 | + | } |
| 474 | - | } | 576 | + | nz /= 1.1 |
| 475 | - | } | 577 | + | if nz < 0.25 { |
| 476 | - | if cfg.InitialURL != "" { | 578 | + | nz = 0.25 |
| 477 | - | _ = v.LoadURL(cfg.InitialURL) | 579 | + | } |
| 478 | - | } | 580 | + | if nz > 5.0 { |
| 479 | - | // Ensure native resources are released if user forgets to call Destroy | 581 | + | nz = 5.0 |
| 480 | - | runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() }) | 582 | + | } |
| 481 | - | log.Printf("[webkit] WebView created (CGO)") | 583 | + | _ = v.SetZoom(nz) |
| 482 | - | 584 | + | }) | |
| 483 | - | // Initialize UCM scripts and handlers | 585 | + | _ = v.RegisterKeyboardShortcut("cmdorctrl+0", func() { _ = v.SetZoom(1.0) }) |
| 484 | - | if v.native != nil && v.native.ucm != nil { | 586 | + | // Navigation with Alt+Arrow keys |
| 485 | - | v.enableUserContentManager() | 587 | + | _ = v.RegisterKeyboardShortcut("alt+ArrowLeft", func() { _ = v.GoBack() }) |
| 486 | - | } | 588 | + | _ = v.RegisterKeyboardShortcut("alt+ArrowRight", func() { _ = v.GoForward() }) |
| 487 | - | return v, nil | 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 | func (w *WebView) LoadURL(rawURL string) error { | 700 | func (w *WebView) LoadURL(rawURL string) error { | ||
| 491 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | 701 | + | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { |
| 492 | - | return ErrNotImplemented | 702 | + | return ErrNotImplemented |
| 493 | - | } | 703 | + | } |
| 494 | - | w.url = rawURL | 704 | + | |
| 495 | - | curl := C.CString(rawURL) | 705 | + | // Track page loads for process recycling |
| 496 | - | defer C.free(unsafe.Pointer(curl)) | 706 | + | if w.memStats != nil { |
| 497 | - | C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl)) | 707 | + | w.memStats.pageLoadCount++ |
| 498 | - | log.Printf("[webkit] LoadURL: %s", rawURL) | 708 | + | |
| 499 | - | return nil | 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 | func (w *WebView) Show() error { | 727 | func (w *WebView) Show() error { | ||
| 503 | - | if w == nil || w.destroyed || w.native == nil || w.native.win == nil { | 728 | + | if w == nil || w.destroyed || w.native == nil || w.native.win == nil { |
| 504 | - | return ErrNotImplemented | 729 | + | return ErrNotImplemented |
| 505 | - | } | 730 | + | } |
| 506 | - | C.gtk_widget_set_visible(w.native.view, C.gboolean(1)) | 731 | + | C.gtk_widget_set_visible(w.native.view, C.gboolean(1)) |
| 507 | - | C.gtk_widget_set_visible(w.native.win, C.gboolean(1)) | 732 | + | C.gtk_widget_set_visible(w.native.win, C.gboolean(1)) |
| 508 | - | C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | 733 | + | C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win))) |
| 509 | - | log.Printf("[webkit] Show window") | 734 | + | log.Printf("[webkit] Show window") |
| 510 | - | return nil | 735 | + | return nil |
| 511 | } | 736 | } | ||
| 512 | 737 | ||||
| 513 | func (w *WebView) Hide() error { | 738 | func (w *WebView) Hide() error { | ||
| 514 | - | if w == nil || w.destroyed || w.native == nil || w.native.win == nil { | 739 | + | if w == nil || w.destroyed || w.native == nil || w.native.win == nil { |
| 515 | - | return ErrNotImplemented | 740 | + | return ErrNotImplemented |
| 516 | - | } | 741 | + | } |
| 517 | - | C.gtk_widget_set_visible(w.native.view, C.gboolean(0)) | 742 | + | C.gtk_widget_set_visible(w.native.view, C.gboolean(0)) |
| 518 | - | C.gtk_widget_set_visible(w.native.win, C.gboolean(0)) | 743 | + | C.gtk_widget_set_visible(w.native.win, C.gboolean(0)) |
| 519 | - | return nil | 744 | + | return nil |
| 520 | } | 745 | } | ||
| 521 | 746 | ||||
| 522 | func (w *WebView) Destroy() error { | 747 | func (w *WebView) Destroy() error { | ||
| 523 | - | if w == nil || w.native == nil || w.native.win == nil { | 748 | + | if w == nil || w.native == nil || w.native.win == nil { |
| 524 | - | return ErrNotImplemented | 749 | + | return ErrNotImplemented |
| 525 | - | } | 750 | + | } |
| 526 | - | // GTK4: destroy windows via gtk_window_destroy | 751 | + | |
| 527 | - | C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | 752 | + | // Stop periodic GC if running |
| 528 | - | w.destroyed = true | 753 | + | w.stopPeriodicGC() |
| 529 | - | unregisterView(w.id) | 754 | + | |
| 530 | - | log.Printf("[webkit] Destroy window") | 755 | + | // Clean up memory pressure settings |
| 531 | - | return nil | 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 | // Window returns the associated native window. | 774 | // Window returns the associated native window. | ||
| @@ -536,103 +776,209 @@ func (w *WebView) Window() *Window { return w.window } | |||||
| 536 | 776 | ||||
| 537 | // GetCurrentURL returns the current URI from WebKit. | 777 | // GetCurrentURL returns the current URI from WebKit. | ||
| 538 | func (w *WebView) GetCurrentURL() string { | 778 | func (w *WebView) GetCurrentURL() string { | ||
| 539 | - | if w == nil || w.native == nil || w.native.wv == nil { return "" } | 779 | + | if w == nil || w.native == nil || w.native.wv == nil { |
| 540 | - | uri := C.webkit_web_view_get_uri(w.native.wv) | 780 | + | return "" |
| 541 | - | if uri == nil { return "" } | 781 | + | } |
| 542 | - | return C.GoString((*C.char)(unsafe.Pointer(uri))) | 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 | func (w *WebView) GoBack() error { | 789 | func (w *WebView) GoBack() error { | ||
| 546 | - | if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented } | 790 | + | if w == nil || w.native == nil || w.native.wv == nil { |
| 547 | - | C.webkit_web_view_go_back(w.native.wv) | 791 | + | return ErrNotImplemented |
| 548 | - | return nil | 792 | + | } |
| 793 | + | C.webkit_web_view_go_back(w.native.wv) | |||
| 794 | + | return nil | |||
| 549 | } | 795 | } | ||
| 550 | 796 | ||||
| 551 | func (w *WebView) GoForward() error { | 797 | func (w *WebView) GoForward() error { | ||
| 552 | - | if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented } | 798 | + | if w == nil || w.native == nil || w.native.wv == nil { |
| 553 | - | C.webkit_web_view_go_forward(w.native.wv) | 799 | + | return ErrNotImplemented |
| 554 | - | return nil | 800 | + | } |
| 801 | + | C.webkit_web_view_go_forward(w.native.wv) | |||
| 802 | + | return nil | |||
| 555 | } | 803 | } | ||
| 556 | 804 | ||||
| 557 | // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message. | 805 | // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message. | ||
| 558 | func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb } | 806 | func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb } | ||
| 559 | 807 | ||||
| 560 | func (w *WebView) dispatchScriptMessage(payload string) { | 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 | // RegisterTitleChangedHandler registers a callback invoked when the page title changes. | 814 | // RegisterTitleChangedHandler registers a callback invoked when the page title changes. | ||
| 565 | func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb } | 815 | func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb } | ||
| 566 | 816 | ||||
| 567 | func (w *WebView) dispatchTitleChanged(title string) { | 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 | // RegisterURIChangedHandler registers a callback invoked when the current page URI changes. | 823 | // RegisterURIChangedHandler registers a callback invoked when the current page URI changes. | ||
| 572 | func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb } | 824 | func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb } | ||
| 573 | 825 | ||||
| 574 | func (w *WebView) dispatchURIChanged(uri string) { | 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 | // RegisterZoomChangedHandler registers a callback invoked when zoom level changes. | 832 | // RegisterZoomChangedHandler registers a callback invoked when zoom level changes. | ||
| 579 | func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb } | 833 | func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb } | ||
| 580 | 834 | ||||
| 581 | func (w *WebView) dispatchZoomChanged(level float64) { | 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 | // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script. | 841 | // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script. | ||
| 586 | func (w *WebView) enableUserContentManager() { | 842 | func (w *WebView) enableUserContentManager() { | ||
| 587 | - | if w == nil || w.native == nil || w.native.ucm == nil { return } | 843 | + | if w == nil || w.native == nil || w.native.ucm == nil { |
| 588 | - | // Register handler "dumber" | 844 | + | return |
| 589 | - | cname := C.CString("dumber") | 845 | + | } |
| 590 | - | ok := C.register_ucm_handler(w.native.ucm, cname, C.ulong(w.id)) | 846 | + | // Register handler "dumber" |
| 591 | - | if ok == 0 { | 847 | + | cname := C.CString("dumber") |
| 592 | - | log.Printf("[webkit] Failed to register UCM handler; fallback bridge will handle messages") | 848 | + | ok := C.register_ucm_handler(w.native.ucm, cname, C.ulong(w.id)) |
| 593 | - | } | 849 | + | if ok == 0 { |
| 594 | - | C.free(unsafe.Pointer(cname)) | 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 | 898 | + | // startPeriodicGC starts a ticker to trigger JavaScript garbage collection periodically |
| 597 | - | preferDark := C.gtk_prefers_dark() != 0 | 899 | + | func (w *WebView) startPeriodicGC(interval time.Duration) { |
| 598 | - | if preferDark { | 900 | + | if w == nil || w.gcTicker != nil { |
| 599 | - | log.Printf("[theme] GTK prefers: dark") | 901 | + | return // Already running or invalid |
| 600 | - | } else { | 902 | + | } |
| 601 | - | log.Printf("[theme] GTK prefers: light") | 903 | + | |
| 602 | - | } | 904 | + | w.gcTicker = time.NewTicker(interval) |
| 603 | - | var schemeJS string | 905 | + | |
| 604 | - | if preferDark { | 906 | + | go func() { |
| 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) } })();" | 907 | + | defer func() { |
| 606 | - | } else { | 908 | + | if w.gcTicker != nil { |
| 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) } })();" | 909 | + | w.gcTicker.Stop() |
| 608 | - | } | 910 | + | w.gcTicker = nil |
| 609 | - | cScheme := C.CString(schemeJS) | 911 | + | } |
| 610 | - | defer C.free(unsafe.Pointer(cScheme)) | 912 | + | }() |
| 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) | 913 | + | |
| 612 | - | if schemeScript != nil { | 914 | + | for { |
| 613 | - | C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript) | 915 | + | select { |
| 614 | - | C.webkit_user_script_unref(schemeScript) | 916 | + | case <-w.gcTicker.C: |
| 615 | - | } | 917 | + | w.triggerGC() |
| 918 | + | case <-w.gcDone: | |||
| 919 | + | return | |||
| 920 | + | } | |||
| 921 | + | } | |||
| 922 | + | }() | |||
| 923 | + | } | |||
| 616 | 924 | ||||
| 617 | - | // Add user script at document-start | 925 | + | // stopPeriodicGC stops the periodic garbage collection |
| 618 | - | src := C.CString(omniboxDefaultScript) | 926 | + | func (w *WebView) stopPeriodicGC() { |
| 619 | - | defer C.free(unsafe.Pointer(src)) | 927 | + | if w == nil { |
| 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) | 928 | + | return |
| 621 | - | if script != nil { | 929 | + | } |
| 622 | - | C.webkit_user_content_manager_add_script(w.native.ucm, script) | 930 | + | |
| 623 | - | C.webkit_user_script_unref(script) | 931 | + | if w.gcDone != nil { |
| 624 | - | log.Printf("[webkit] UCM omnibox script injected at document-start") | 932 | + | close(w.gcDone) |
| 625 | - | } | 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 | 942 | + | // triggerGC manually triggers JavaScript garbage collection |
| 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(_){} })();` | 943 | + | func (w *WebView) triggerGC() { |
| 629 | - | cWails := C.CString(wailsBridge) | 944 | + | if w == nil || w.memStats == nil { |
| 630 | - | defer C.free(unsafe.Pointer(cWails)) | 945 | + | return |
| 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) | 946 | + | } |
| 632 | - | if wailsScript != nil { | 947 | + | |
| 633 | - | C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript) | 948 | + | // Get default WebContext and trigger GC |
| 634 | - | C.webkit_user_script_unref(wailsScript) | 949 | + | ctx := C.webkit_web_context_get_default() |
| 635 | - | } | 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 | // Window represents a top-level application window hosting a WebView. | 5 | // Window represents a top-level application window hosting a WebView. | ||
| 6 | // Stub for future GTK application window management. | 6 | // Stub for future GTK application window management. | ||
| 7 | type Window struct { | 7 | type Window struct { | ||
| 8 | - | Title string | 8 | + | Title string |
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| 11 | // NewWindow constructs a Window. In real implementation, this would create a GTK window. | 11 | // NewWindow constructs a Window. In real implementation, this would create a GTK window. | ||
| 12 | func NewWindow(title string) (*Window, error) { | 12 | func NewWindow(title string) (*Window, error) { | ||
| 13 | - | _ = title | 13 | + | _ = title |
| 14 | - | return &Window{Title: title}, ErrNotImplemented | 14 | + | return &Window{Title: title}, ErrNotImplemented |
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | // SetTitle updates the window title (no-op in non-CGO build). | 17 | // SetTitle updates the window title (no-op in non-CGO build). | ||
| 18 | func (w *Window) SetTitle(title string) { | 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 | import "unsafe" | 12 | import "unsafe" | ||
| 13 | 13 | ||||
| 14 | type Window struct { | 14 | type Window struct { | ||
| 15 | - | win *C.GtkWidget | 15 | + | win *C.GtkWidget |
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | func NewWindow(title string) (*Window, error) { | 18 | func NewWindow(title string) (*Window, error) { | ||
| 19 | - | if C.gtk_init_check() == 0 { | 19 | + | if C.gtk_init_check() == 0 { |
| 20 | - | return nil, ErrNotImplemented | 20 | + | return nil, ErrNotImplemented |
| 21 | - | } | 21 | + | } |
| 22 | - | // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum | 22 | + | // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum |
| 23 | - | w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new())) | 23 | + | w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new())) |
| 24 | - | if w == nil { | 24 | + | if w == nil { |
| 25 | - | return nil, ErrNotImplemented | 25 | + | return nil, ErrNotImplemented |
| 26 | - | } | 26 | + | } |
| 27 | - | ctitle := C.CString(title) | 27 | + | ctitle := C.CString(title) |
| 28 | - | defer C.free(unsafe.Pointer(ctitle)) | 28 | + | defer C.free(unsafe.Pointer(ctitle)) |
| 29 | - | C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w)), (*C.gchar)(ctitle)) | 29 | + | C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w)), (*C.gchar)(ctitle)) |
| 30 | - | return &Window{win: w}, nil | 30 | + | return &Window{win: w}, nil |
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | func (w *Window) SetTitle(title string) { | 33 | func (w *Window) SetTitle(title string) { | ||
| 34 | - | if w == nil || w.win == nil { return } | 34 | + | if w == nil || w.win == nil { |
| 35 | - | ctitle := C.CString(title) | 35 | + | return |
| 36 | - | defer C.free(unsafe.Pointer(ctitle)) | 36 | + | } |
| 37 | - | C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w.win)), (*C.gchar)(ctitle)) | 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 | package webkit | 3 | package webkit | ||
| 4 | 4 | ||||
| 5 | const ( | 5 | const ( | ||
| 6 | - | zoomMin = 0.25 | 6 | + | zoomMin = 0.25 |
| 7 | - | zoomMax = 5.0 | 7 | + | zoomMax = 5.0 |
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | // SetZoom sets the current zoom factor for the WebView. | 10 | // SetZoom sets the current zoom factor for the WebView. | ||
| 11 | func (w *WebView) SetZoom(level float64) error { | 11 | func (w *WebView) SetZoom(level float64) error { | ||
| 12 | - | if w == nil || w.destroyed { | 12 | + | if w == nil || w.destroyed { |
| 13 | - | return ErrNotImplemented | 13 | + | return ErrNotImplemented |
| 14 | - | } | 14 | + | } |
| 15 | - | if level < zoomMin { | 15 | + | if level < zoomMin { |
| 16 | - | level = zoomMin | 16 | + | level = zoomMin |
| 17 | - | } | 17 | + | } |
| 18 | - | if level > zoomMax { | 18 | + | if level > zoomMax { |
| 19 | - | level = zoomMax | 19 | + | level = zoomMax |
| 20 | - | } | 20 | + | } |
| 21 | - | w.zoom = level | 21 | + | w.zoom = level |
| 22 | - | w.dispatchZoomChanged(w.zoom) | 22 | + | w.dispatchZoomChanged(w.zoom) |
| 23 | - | return nil | 23 | + | return nil |
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | // GetZoom returns the current zoom factor. | 26 | // GetZoom returns the current zoom factor. | ||
| 27 | func (w *WebView) GetZoom() (float64, error) { | 27 | func (w *WebView) GetZoom() (float64, error) { | ||
| 28 | - | if w == nil || w.destroyed { | 28 | + | if w == nil || w.destroyed { |
| 29 | - | return 1.0, ErrNotImplemented | 29 | + | return 1.0, ErrNotImplemented |
| 30 | - | } | 30 | + | } |
| 31 | - | return w.zoom, nil | 31 | + | return w.zoom, nil |
| 32 | } | 32 | } | ||
M pkg/webkit/zoom_cgo.go +19 -19
| @@ -9,26 +9,26 @@ package webkit | |||||
| 9 | import "C" | 9 | import "C" | ||
| 10 | 10 | ||||
| 11 | func (w *WebView) SetZoom(level float64) error { | 11 | func (w *WebView) SetZoom(level float64) error { | ||
| 12 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | 12 | + | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { |
| 13 | - | return ErrNotImplemented | 13 | + | return ErrNotImplemented |
| 14 | - | } | 14 | + | } |
| 15 | - | if level < 0.25 { | 15 | + | if level < 0.25 { |
| 16 | - | level = 0.25 | 16 | + | level = 0.25 |
| 17 | - | } | 17 | + | } |
| 18 | - | if level > 5.0 { | 18 | + | if level > 5.0 { |
| 19 | - | level = 5.0 | 19 | + | level = 5.0 |
| 20 | - | } | 20 | + | } |
| 21 | - | w.zoom = level | 21 | + | w.zoom = level |
| 22 | - | C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level)) | 22 | + | C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level)) |
| 23 | - | w.dispatchZoomChanged(w.zoom) | 23 | + | w.dispatchZoomChanged(w.zoom) |
| 24 | - | return nil | 24 | + | return nil |
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | func (w *WebView) GetZoom() (float64, error) { | 27 | func (w *WebView) GetZoom() (float64, error) { | ||
| 28 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | 28 | + | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { |
| 29 | - | return 1.0, ErrNotImplemented | 29 | + | return 1.0, ErrNotImplemented |
| 30 | - | } | 30 | + | } |
| 31 | - | z := float64(C.webkit_web_view_get_zoom_level(w.native.wv)) | 31 | + | z := float64(C.webkit_web_view_get_zoom_level(w.native.wv)) |
| 32 | - | w.zoom = z | 32 | + | w.zoom = z |
| 33 | - | return z, nil | 33 | + | return z, nil |
| 34 | } | 34 | } | ||
M services/browser_service.go +164 -164
| @@ -2,44 +2,44 @@ | |||||
| 2 | package services | 2 | package services | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | - | "context" | 5 | + | "context" |
| 6 | - | "database/sql" | 6 | + | "database/sql" |
| 7 | - | "embed" | 7 | + | "embed" |
| 8 | - | "fmt" | 8 | + | "errors" |
| 9 | - | "errors" | 9 | + | "fmt" |
| 10 | - | "math" | 10 | + | "math" |
| 11 | - | "time" | 11 | + | neturl "net/url" |
| 12 | - | neturl "net/url" | 12 | + | "time" |
| 13 | - | 13 | + | ||
| 14 | - | "github.com/bnema/dumber/internal/config" | 14 | + | "github.com/bnema/dumber/internal/config" |
| 15 | - | "github.com/bnema/dumber/internal/db" | 15 | + | "github.com/bnema/dumber/internal/db" |
| 16 | - | "github.com/bnema/dumber/pkg/webkit" | 16 | + | "github.com/bnema/dumber/pkg/webkit" |
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | func clampToInt32(i int64) int32 { | 19 | func clampToInt32(i int64) int32 { | ||
| 20 | - | if i > math.MaxInt32 { | 20 | + | if i > math.MaxInt32 { |
| 21 | - | i = math.MaxInt32 | 21 | + | i = math.MaxInt32 |
| 22 | - | } | 22 | + | } |
| 23 | - | if i < math.MinInt32 { | 23 | + | if i < math.MinInt32 { |
| 24 | - | i = math.MinInt32 | 24 | + | i = math.MinInt32 |
| 25 | - | } | 25 | + | } |
| 26 | - | return int32(i) //nolint:gosec // value is clamped to int32 bounds | 26 | + | return int32(i) //nolint:gosec // value is clamped to int32 bounds |
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| 29 | // WindowTitleUpdater interface allows the service to update the window title | 29 | // WindowTitleUpdater interface allows the service to update the window title | ||
| 30 | // Decoupled from any specific GUI framework. | 30 | // Decoupled from any specific GUI framework. | ||
| 31 | type WindowTitleUpdater interface { | 31 | type WindowTitleUpdater interface { | ||
| 32 | - | SetTitle(title string) | 32 | + | SetTitle(title string) |
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | // BrowserService handles browser-related operations for the built-in browser. | 35 | // BrowserService handles browser-related operations for the built-in browser. | ||
| 36 | type BrowserService struct { | 36 | type BrowserService struct { | ||
| 37 | - | config *config.Config | 37 | + | config *config.Config |
| 38 | - | dbQueries db.DatabaseQuerier | 38 | + | dbQueries db.DatabaseQuerier |
| 39 | - | windowTitleUpdater WindowTitleUpdater | 39 | + | windowTitleUpdater WindowTitleUpdater |
| 40 | - | webView *webkit.WebView | 40 | + | webView *webkit.WebView |
| 41 | - | initialURL string | 41 | + | initialURL string |
| 42 | - | injectableScript string | 42 | + | injectableScript string |
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | // ServiceName returns the service name for frontend binding | 45 | // ServiceName returns the service name for frontend binding | ||
| @@ -69,14 +69,14 @@ type HistoryEntry struct { | |||||
| 69 | 69 | ||||
| 70 | // NewBrowserService creates a new BrowserService instance. | 70 | // NewBrowserService creates a new BrowserService instance. | ||
| 71 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | 71 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | ||
| 72 | - | return &BrowserService{ | 72 | + | return &BrowserService{ |
| 73 | - | config: cfg, | 73 | + | config: cfg, |
| 74 | - | dbQueries: queries, | 74 | + | dbQueries: queries, |
| 75 | - | windowTitleUpdater: nil, | 75 | + | windowTitleUpdater: nil, |
| 76 | - | webView: nil, | 76 | + | webView: nil, |
| 77 | - | initialURL: "", | 77 | + | initialURL: "", |
| 78 | - | injectableScript: "", | 78 | + | injectableScript: "", |
| 79 | - | } | 79 | + | } |
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | // SetWindowTitleUpdater sets the window title updater interface | 82 | // SetWindowTitleUpdater sets the window title updater interface | ||
| @@ -86,7 +86,7 @@ func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) { | |||||
| 86 | 86 | ||||
| 87 | // AttachWebView connects a native WebKit WebView to this service for integration. | 87 | // AttachWebView connects a native WebKit WebView to this service for integration. | ||
| 88 | func (s *BrowserService) AttachWebView(view *webkit.WebView) { | 88 | func (s *BrowserService) AttachWebView(view *webkit.WebView) { | ||
| 89 | - | s.webView = view | 89 | + | s.webView = view |
| 90 | } | 90 | } | ||
| 91 | 91 | ||||
| 92 | // LoadInjectableScript loads the minified injectable controls script from assets | 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 | // InjectControlScript injects the global controls script into the current page | 102 | // InjectControlScript injects the global controls script into the current page | ||
| 103 | func (s *BrowserService) InjectControlScript(ctx context.Context) error { | 103 | func (s *BrowserService) InjectControlScript(ctx context.Context) error { | ||
| 104 | - | _ = ctx | 104 | + | _ = ctx |
| 105 | - | if s.webView == nil { | 105 | + | if s.webView == nil { |
| 106 | - | return fmt.Errorf("webview not attached") | 106 | + | return fmt.Errorf("webview not attached") |
| 107 | - | } | 107 | + | } |
| 108 | - | if s.injectableScript == "" { | 108 | + | if s.injectableScript == "" { |
| 109 | - | return fmt.Errorf("injectable script not loaded") | 109 | + | return fmt.Errorf("injectable script not loaded") |
| 110 | - | } | 110 | + | } |
| 111 | - | wrappedScript := "(function(){try{" + | 111 | + | wrappedScript := "(function(){try{" + |
| 112 | - | "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){try{" + | 112 | + | "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){try{" + |
| 113 | - | s.injectableScript + | 113 | + | s.injectableScript + |
| 114 | - | "}catch(e){console.warn('Dumber Browser controls failed to load on DOMContentLoaded:', e);}});}else{" + | 114 | + | "}catch(e){console.warn('Dumber Browser controls failed to load on DOMContentLoaded:', e);}});}else{" + |
| 115 | - | s.injectableScript + | 115 | + | s.injectableScript + |
| 116 | - | "}}catch(e){console.warn('Dumber Browser controls injection failed:', e);}})();" | 116 | + | "}}catch(e){console.warn('Dumber Browser controls injection failed:', e);}})();" |
| 117 | - | return s.webView.InjectScript(wrappedScript) | 117 | + | return s.webView.InjectScript(wrappedScript) |
| 118 | } | 118 | } | ||
| 119 | 119 | ||||
| 120 | // Navigate handles navigation to a URL and records it in history. | 120 | // Navigate handles navigation to a URL and records it in history. | ||
| 121 | func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) { | 121 | func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) { | ||
| 122 | startTime := time.Now() | 122 | startTime := time.Now() | ||
| 123 | - | 123 | + | ||
| 124 | if url == "" { | 124 | if url == "" { | ||
| 125 | return &NavigationResult{ | 125 | return &NavigationResult{ | ||
| 126 | Success: false, | 126 | Success: false, | ||
| @@ -182,16 +182,16 @@ func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]His | |||||
| 182 | 182 | ||||
| 183 | result := make([]HistoryEntry, len(entries)) | 183 | result := make([]HistoryEntry, len(entries)) | ||
| 184 | for i, entry := range entries { | 184 | for i, entry := range entries { | ||
| 185 | - | // Defensive cast to int32 to prevent overflow | 185 | + | // Defensive cast to int32 to prevent overflow |
| 186 | - | vc := clampToInt32(entry.VisitCount.Int64) | 186 | + | vc := clampToInt32(entry.VisitCount.Int64) |
| 187 | - | result[i] = HistoryEntry{ | 187 | + | result[i] = HistoryEntry{ |
| 188 | - | ID: entry.ID, | 188 | + | ID: entry.ID, |
| 189 | - | URL: entry.Url, | 189 | + | URL: entry.Url, |
| 190 | - | Title: entry.Title.String, | 190 | + | Title: entry.Title.String, |
| 191 | - | VisitCount: vc, | 191 | + | VisitCount: vc, |
| 192 | - | LastVisited: entry.LastVisited.Time, | 192 | + | LastVisited: entry.LastVisited.Time, |
| 193 | - | CreatedAt: entry.CreatedAt.Time, | 193 | + | CreatedAt: entry.CreatedAt.Time, |
| 194 | - | } | 194 | + | } |
| 195 | } | 195 | } | ||
| 196 | 196 | ||||
| 197 | return result, nil | 197 | return result, nil | ||
| @@ -209,7 +209,7 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||||
| 209 | 209 | ||||
| 210 | urlPattern := sql.NullString{String: "%" + query + "%", Valid: true} | 210 | urlPattern := sql.NullString{String: "%" + query + "%", Valid: true} | ||
| 211 | titlePattern := sql.NullString{String: "%" + query + "%", Valid: true} | 211 | titlePattern := sql.NullString{String: "%" + query + "%", Valid: true} | ||
| 212 | - | 212 | + | ||
| 213 | entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit)) | 213 | entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit)) | ||
| 214 | if err != nil { | 214 | if err != nil { | ||
| 215 | return nil, err | 215 | return nil, err | ||
| @@ -217,15 +217,15 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||||
| 217 | 217 | ||||
| 218 | result := make([]HistoryEntry, len(entries)) | 218 | result := make([]HistoryEntry, len(entries)) | ||
| 219 | for i, entry := range entries { | 219 | for i, entry := range entries { | ||
| 220 | - | vc := clampToInt32(entry.VisitCount.Int64) | 220 | + | vc := clampToInt32(entry.VisitCount.Int64) |
| 221 | - | result[i] = HistoryEntry{ | 221 | + | result[i] = HistoryEntry{ |
| 222 | - | ID: entry.ID, | 222 | + | ID: entry.ID, |
| 223 | - | URL: entry.Url, | 223 | + | URL: entry.Url, |
| 224 | - | Title: entry.Title.String, | 224 | + | Title: entry.Title.String, |
| 225 | - | VisitCount: vc, | 225 | + | VisitCount: vc, |
| 226 | - | LastVisited: entry.LastVisited.Time, | 226 | + | LastVisited: entry.LastVisited.Time, |
| 227 | - | CreatedAt: entry.CreatedAt.Time, | 227 | + | CreatedAt: entry.CreatedAt.Time, |
| 228 | - | } | 228 | + | } |
| 229 | } | 229 | } | ||
| 230 | 230 | ||||
| 231 | return result, nil | 231 | return result, nil | ||
| @@ -233,25 +233,25 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||||
| 233 | 233 | ||||
| 234 | // DeleteHistoryEntry removes a specific history entry. | 234 | // DeleteHistoryEntry removes a specific history entry. | ||
| 235 | func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error { | 235 | func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error { | ||
| 236 | - | _ = ctx | 236 | + | _ = ctx |
| 237 | - | _ = id | 237 | + | _ = id |
| 238 | - | // Note: DeleteHistory method doesn't exist in current schema | 238 | + | // Note: DeleteHistory method doesn't exist in current schema |
| 239 | - | // This would need to be implemented in the database layer | 239 | + | // This would need to be implemented in the database layer |
| 240 | - | return fmt.Errorf("delete history not implemented yet") | 240 | + | return fmt.Errorf("delete history not implemented yet") |
| 241 | } | 241 | } | ||
| 242 | 242 | ||||
| 243 | // ClearHistory removes all history entries. | 243 | // ClearHistory removes all history entries. | ||
| 244 | func (s *BrowserService) ClearHistory(ctx context.Context) error { | 244 | func (s *BrowserService) ClearHistory(ctx context.Context) error { | ||
| 245 | - | _ = ctx | 245 | + | _ = ctx |
| 246 | - | // Note: ClearAllHistory method doesn't exist in current schema | 246 | + | // Note: ClearAllHistory method doesn't exist in current schema |
| 247 | - | // This would need to be implemented in the database layer | 247 | + | // This would need to be implemented in the database layer |
| 248 | - | return fmt.Errorf("clear all history not implemented yet") | 248 | + | return fmt.Errorf("clear all history not implemented yet") |
| 249 | } | 249 | } | ||
| 250 | 250 | ||||
| 251 | // GetHistoryStats returns statistics about browser history. | 251 | // GetHistoryStats returns statistics about browser history. | ||
| 252 | func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) { | 252 | func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) { | ||
| 253 | - | // Get recent entries for basic stats | 253 | + | // Get recent entries for basic stats |
| 254 | - | recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit) | 254 | + | recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit) |
| 255 | if err != nil { | 255 | if err != nil { | ||
| 256 | return nil, err | 256 | return nil, err | ||
| 257 | } | 257 | } | ||
| @@ -273,16 +273,16 @@ func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interf | |||||
| 273 | 273 | ||||
| 274 | // GetConfig returns the current browser configuration. | 274 | // GetConfig returns the current browser configuration. | ||
| 275 | func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) { | 275 | func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) { | ||
| 276 | - | _ = ctx | 276 | + | _ = ctx |
| 277 | - | return s.config, nil | 277 | + | return s.config, nil |
| 278 | } | 278 | } | ||
| 279 | 279 | ||||
| 280 | // UpdateConfig updates the browser configuration. | 280 | // UpdateConfig updates the browser configuration. | ||
| 281 | func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error { | 281 | func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error { | ||
| 282 | - | _ = ctx | 282 | + | _ = ctx |
| 283 | - | // In a real implementation, you'd want to validate and persist the config | 283 | + | // In a real implementation, you'd want to validate and persist the config |
| 284 | - | s.config = newConfig | 284 | + | s.config = newConfig |
| 285 | - | return nil | 285 | + | return nil |
| 286 | } | 286 | } | ||
| 287 | 287 | ||||
| 288 | // GetSearchShortcuts returns available search shortcuts. | 288 | // GetSearchShortcuts returns available search shortcuts. | ||
| @@ -297,7 +297,7 @@ func (s *BrowserService) recordHistory(ctx context.Context, url, title string) e | |||||
| 297 | if title != "" { | 297 | if title != "" { | ||
| 298 | titleNull = sql.NullString{String: title, Valid: true} | 298 | titleNull = sql.NullString{String: title, Valid: true} | ||
| 299 | } | 299 | } | ||
| 300 | - | 300 | + | ||
| 301 | return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull) | 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 | if err != nil { | 310 | if err != nil { | ||
| 311 | return 1.0, err | 311 | return 1.0, err | ||
| 312 | } | 312 | } | ||
| 313 | - | 313 | + | ||
| 314 | newZoom := s.getNextZoomLevel(currentZoom, true) | 314 | newZoom := s.getNextZoomLevel(currentZoom, true) | ||
| 315 | err = s.SetZoomLevel(ctx, url, newZoom) | 315 | err = s.SetZoomLevel(ctx, url, newZoom) | ||
| 316 | if err != nil { | 316 | if err != nil { | ||
| 317 | return currentZoom, err | 317 | return currentZoom, err | ||
| 318 | } | 318 | } | ||
| 319 | - | 319 | + | ||
| 320 | return newZoom, nil | 320 | return newZoom, nil | ||
| 321 | } | 321 | } | ||
| 322 | 322 | ||||
| @@ -326,13 +326,13 @@ func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, erro | |||||
| 326 | if err != nil { | 326 | if err != nil { | ||
| 327 | return 1.0, err | 327 | return 1.0, err | ||
| 328 | } | 328 | } | ||
| 329 | - | 329 | + | ||
| 330 | newZoom := s.getNextZoomLevel(currentZoom, false) | 330 | newZoom := s.getNextZoomLevel(currentZoom, false) | ||
| 331 | err = s.SetZoomLevel(ctx, url, newZoom) | 331 | err = s.SetZoomLevel(ctx, url, newZoom) | ||
| 332 | if err != nil { | 332 | if err != nil { | ||
| 333 | return currentZoom, err | 333 | return currentZoom, err | ||
| 334 | } | 334 | } | ||
| 335 | - | 335 | + | ||
| 336 | return newZoom, nil | 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 | // GetZoomLevel retrieves the saved zoom level for a URL. | 344 | // GetZoomLevel retrieves the saved zoom level for a URL. | ||
| 345 | func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) { | 345 | func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) { | ||
| 346 | - | if url == "" { | 346 | + | if url == "" { |
| 347 | - | return 1.0, nil | 347 | + | return 1.0, nil |
| 348 | - | } | 348 | + | } |
| 349 | - | 349 | + | ||
| 350 | - | key := zoomKeyFromURL(url) | 350 | + | key := zoomKeyFromURL(url) |
| 351 | - | zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key) | 351 | + | zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key) |
| 352 | - | if err != nil { | 352 | + | if err != nil { |
| 353 | - | if errors.Is(err, sql.ErrNoRows) { | 353 | + | if errors.Is(err, sql.ErrNoRows) { |
| 354 | - | // Fallback to exact URL record if present (backward compatibility) | 354 | + | // Fallback to exact URL record if present (backward compatibility) |
| 355 | - | zl, err2 := s.dbQueries.GetZoomLevel(ctx, url) | 355 | + | zl, err2 := s.dbQueries.GetZoomLevel(ctx, url) |
| 356 | - | if err2 == nil { | 356 | + | if err2 == nil { |
| 357 | - | return zl, nil | 357 | + | return zl, nil |
| 358 | - | } | 358 | + | } |
| 359 | - | // No zoom setting found, return default | 359 | + | // No zoom setting found, return default |
| 360 | - | return 1.0, nil | 360 | + | return 1.0, nil |
| 361 | - | } | 361 | + | } |
| 362 | - | return 1.0, err | 362 | + | return 1.0, err |
| 363 | - | } | 363 | + | } |
| 364 | - | 364 | + | ||
| 365 | - | return zoomLevel, nil | 365 | + | return zoomLevel, nil |
| 366 | } | 366 | } | ||
| 367 | 367 | ||||
| 368 | // SetZoomLevel sets the zoom level for a URL. | 368 | // SetZoomLevel sets the zoom level for a URL. | ||
| 369 | func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error { | 369 | func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error { | ||
| 370 | - | if url == "" { | 370 | + | if url == "" { |
| 371 | - | return fmt.Errorf("URL cannot be empty") | 371 | + | return fmt.Errorf("URL cannot be empty") |
| 372 | - | } | 372 | + | } |
| 373 | 373 | ||||
| 374 | - | // Clamp zoom level to reasonable bounds | 374 | + | // Clamp zoom level to reasonable bounds |
| 375 | - | if zoomLevel < zoomMin { | 375 | + | if zoomLevel < zoomMin { |
| 376 | - | zoomLevel = zoomMin | 376 | + | zoomLevel = zoomMin |
| 377 | - | } else if zoomLevel > zoomMax { | 377 | + | } else if zoomLevel > zoomMax { |
| 378 | - | zoomLevel = zoomMax | 378 | + | zoomLevel = zoomMax |
| 379 | - | } | 379 | + | } |
| 380 | 380 | ||||
| 381 | - | key := zoomKeyFromURL(url) | 381 | + | key := zoomKeyFromURL(url) |
| 382 | - | return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel) | 382 | + | return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel) |
| 383 | } | 383 | } | ||
| 384 | 384 | ||||
| 385 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | 385 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | ||
| 386 | func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) { | 386 | func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) { | ||
| 387 | - | if s.webView == nil { | 387 | + | if s.webView == nil { |
| 388 | - | return "", nil | 388 | + | return "", nil |
| 389 | - | } | 389 | + | } |
| 390 | - | return s.webView.GetCurrentURL(), nil | 390 | + | return s.webView.GetCurrentURL(), nil |
| 391 | } | 391 | } | ||
| 392 | 392 | ||||
| 393 | // GetInitialURL returns the initially navigated URL for frontend synchronization | 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 | // GoBack provides navigation back functionality | 409 | // GoBack provides navigation back functionality | ||
| 410 | func (s *BrowserService) GoBack(ctx context.Context) error { | 410 | func (s *BrowserService) GoBack(ctx context.Context) error { | ||
| 411 | - | if s.webView != nil { | 411 | + | if s.webView != nil { |
| 412 | - | return s.webView.GoBack() | 412 | + | return s.webView.GoBack() |
| 413 | - | } | 413 | + | } |
| 414 | - | fmt.Println("Navigation: Go back") | 414 | + | fmt.Println("Navigation: Go back") |
| 415 | - | return nil | 415 | + | return nil |
| 416 | } | 416 | } | ||
| 417 | 417 | ||||
| 418 | // GoForward provides navigation forward functionality | 418 | // GoForward provides navigation forward functionality | ||
| 419 | func (s *BrowserService) GoForward(ctx context.Context) error { | 419 | func (s *BrowserService) GoForward(ctx context.Context) error { | ||
| 420 | - | if s.webView != nil { | 420 | + | if s.webView != nil { |
| 421 | - | return s.webView.GoForward() | 421 | + | return s.webView.GoForward() |
| 422 | - | } | 422 | + | } |
| 423 | - | fmt.Println("Navigation: Go forward") | 423 | + | fmt.Println("Navigation: Go forward") |
| 424 | - | return nil | 424 | + | return nil |
| 425 | } | 425 | } | ||
| 426 | 426 | ||||
| 427 | // getNextZoomLevel returns the next Firefox zoom level in the specified direction | 427 | // getNextZoomLevel returns the next Firefox zoom level in the specified direction | ||
| 428 | const ( | 428 | const ( | ||
| 429 | - | zoomMin = 0.25 | 429 | + | zoomMin = 0.25 |
| 430 | - | zoomMax = 5.0 | 430 | + | zoomMax = 5.0 |
| 431 | - | recentHistoryLimit = 1000 | 431 | + | recentHistoryLimit = 1000 |
| 432 | ) | 432 | ) | ||
| 433 | 433 | ||||
| 434 | func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 { | 434 | func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 { | ||
| 435 | // Find the closest current zoom level | 435 | // Find the closest current zoom level | ||
| 436 | closestIndex := 0 | 436 | closestIndex := 0 | ||
| 437 | minDiff := 10.0 // Large initial value | 437 | minDiff := 10.0 // Large initial value | ||
| 438 | - | 438 | + | ||
| 439 | for i, level := range firefoxZoomLevels { | 439 | for i, level := range firefoxZoomLevels { | ||
| 440 | diff := currentZoom - level | 440 | diff := currentZoom - level | ||
| 441 | if diff < 0 { | 441 | if diff < 0 { | ||
| @@ -446,38 +446,38 @@ func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) floa | |||||
| 446 | closestIndex = i | 446 | closestIndex = i | ||
| 447 | } | 447 | } | ||
| 448 | } | 448 | } | ||
| 449 | - | 449 | + | ||
| 450 | - | if zoomIn { | 450 | + | if zoomIn { |
| 451 | - | // Move to next higher zoom level | 451 | + | // Move to next higher zoom level |
| 452 | - | if closestIndex < len(firefoxZoomLevels)-1 { | 452 | + | if closestIndex < len(firefoxZoomLevels)-1 { |
| 453 | - | return firefoxZoomLevels[closestIndex+1] | 453 | + | return firefoxZoomLevels[closestIndex+1] |
| 454 | - | } | 454 | + | } |
| 455 | - | return firefoxZoomLevels[closestIndex] // Already at max | 455 | + | return firefoxZoomLevels[closestIndex] // Already at max |
| 456 | - | } | 456 | + | } |
| 457 | - | // Move to next lower zoom level | 457 | + | // Move to next lower zoom level |
| 458 | - | if closestIndex > 0 { | 458 | + | if closestIndex > 0 { |
| 459 | - | return firefoxZoomLevels[closestIndex-1] | 459 | + | return firefoxZoomLevels[closestIndex-1] |
| 460 | - | } | 460 | + | } |
| 461 | - | return firefoxZoomLevels[closestIndex] // Already at min | 461 | + | return firefoxZoomLevels[closestIndex] // Already at min |
| 462 | } | 462 | } | ||
| 463 | 463 | ||||
| 464 | // setZoom sets the zoom level to a specific value | 464 | // setZoom sets the zoom level to a specific value | ||
| 465 | func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) { | 465 | func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) { | ||
| 466 | - | err := s.SetZoomLevel(ctx, url, zoomLevel) | 466 | + | err := s.SetZoomLevel(ctx, url, zoomLevel) |
| 467 | - | if err != nil { | 467 | + | if err != nil { |
| 468 | - | return 1.0, err | 468 | + | return 1.0, err |
| 469 | - | } | 469 | + | } |
| 470 | - | return zoomLevel, nil | 470 | + | return zoomLevel, nil |
| 471 | } | 471 | } | ||
| 472 | 472 | ||||
| 473 | // zoomKeyFromURL extracts a stable per-domain key for zoom persistence. | 473 | // zoomKeyFromURL extracts a stable per-domain key for zoom persistence. | ||
| 474 | // Uses hostname if present; falls back to the full input URL string. | 474 | // Uses hostname if present; falls back to the full input URL string. | ||
| 475 | func zoomKeyFromURL(raw string) string { | 475 | func zoomKeyFromURL(raw string) string { | ||
| 476 | - | u, err := neturl.Parse(raw) | 476 | + | u, err := neturl.Parse(raw) |
| 477 | - | if err != nil || u.Host == "" { | 477 | + | if err != nil || u.Host == "" { |
| 478 | - | return raw | 478 | + | return raw |
| 479 | - | } | 479 | + | } |
| 480 | - | return u.Host | 480 | + | return u.Host |
| 481 | } | 481 | } | ||
| 482 | 482 | ||||
| 483 | // extractDomain extracts the domain from a URL for zoom level storage | 483 | // extractDomain extracts the domain from a URL for zoom level storage | ||
M services/config_service.go +84 -84
| @@ -1,30 +1,30 @@ | |||||
| 1 | package services | 1 | package services | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | 4 | + | "context" |
| 5 | - | "fmt" | 5 | + | "fmt" |
| 6 | - | "os" | 6 | + | "os" |
| 7 | - | "path/filepath" | 7 | + | "path/filepath" |
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/dumber/internal/config" | 9 | + | "github.com/bnema/dumber/internal/config" |
| 10 | - | "strings" | 10 | + | "strings" |
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | // ConfigService handles configuration operations for the application. | 13 | // ConfigService handles configuration operations for the application. | ||
| 14 | type ConfigService struct { | 14 | type ConfigService struct { | ||
| 15 | - | config *config.Config | 15 | + | config *config.Config |
| 16 | - | configPath string | 16 | + | configPath string |
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | const defaultDirPerm = 0o750 | 19 | const defaultDirPerm = 0o750 | ||
| 20 | 20 | ||||
| 21 | // ConfigInfo represents configuration information for the frontend. | 21 | // ConfigInfo represents configuration information for the frontend. | ||
| 22 | type ConfigInfo struct { | 22 | type ConfigInfo struct { | ||
| 23 | - | ConfigPath string `json:"config_path"` | 23 | + | ConfigPath string `json:"config_path"` |
| 24 | - | DatabasePath string `json:"database_path"` | 24 | + | DatabasePath string `json:"database_path"` |
| 25 | - | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | 25 | + | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` |
| 26 | - | DmenuSettings *config.DmenuConfig `json:"dmenu_settings"` | 26 | + | DmenuSettings *config.DmenuConfig `json:"dmenu_settings"` |
| 27 | - | RenderingMode string `json:"rendering_mode"` | 27 | + | RenderingMode string `json:"rendering_mode"` |
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | // NewConfigService creates a new ConfigService instance. | 30 | // NewConfigService creates a new ConfigService instance. | ||
| @@ -37,29 +37,29 @@ func NewConfigService(cfg *config.Config, configPath string) *ConfigService { | |||||
| 37 | 37 | ||||
| 38 | // GetConfigInfo returns comprehensive configuration information. | 38 | // GetConfigInfo returns comprehensive configuration information. | ||
| 39 | func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) { | 39 | func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) { | ||
| 40 | - | _ = ctx | 40 | + | _ = ctx |
| 41 | - | return &ConfigInfo{ | 41 | + | return &ConfigInfo{ |
| 42 | - | ConfigPath: s.configPath, | 42 | + | ConfigPath: s.configPath, |
| 43 | - | DatabasePath: s.config.Database.Path, | 43 | + | DatabasePath: s.config.Database.Path, |
| 44 | - | SearchShortcuts: s.config.SearchShortcuts, | 44 | + | SearchShortcuts: s.config.SearchShortcuts, |
| 45 | - | DmenuSettings: &s.config.Dmenu, | 45 | + | DmenuSettings: &s.config.Dmenu, |
| 46 | - | RenderingMode: string(s.config.RenderingMode), | 46 | + | RenderingMode: string(s.config.RenderingMode), |
| 47 | - | }, nil | 47 | + | }, nil |
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | // GetSearchShortcuts returns all configured search shortcuts. | 50 | // GetSearchShortcuts returns all configured search shortcuts. | ||
| 51 | func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | 51 | func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | ||
| 52 | - | _ = ctx | 52 | + | _ = ctx |
| 53 | - | return s.config.SearchShortcuts, nil | 53 | + | return s.config.SearchShortcuts, nil |
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | // AddSearchShortcut adds a new search shortcut. | 56 | // AddSearchShortcut adds a new search shortcut. | ||
| 57 | func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | 57 | func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | ||
| 58 | - | _ = ctx | 58 | + | _ = ctx |
| 59 | - | if key == "" { | 59 | + | if key == "" { |
| 60 | - | return fmt.Errorf("shortcut key cannot be empty") | 60 | + | return fmt.Errorf("shortcut key cannot be empty") |
| 61 | - | } | 61 | + | } |
| 62 | - | 62 | + | ||
| 63 | if shortcut.URL == "" { | 63 | if shortcut.URL == "" { | ||
| 64 | return fmt.Errorf("shortcut URL cannot be empty") | 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 | // UpdateSearchShortcut updates an existing search shortcut. | 75 | // UpdateSearchShortcut updates an existing search shortcut. | ||
| 76 | func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | 76 | func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | ||
| 77 | - | _ = ctx | 77 | + | _ = ctx |
| 78 | - | if key == "" { | 78 | + | if key == "" { |
| 79 | - | return fmt.Errorf("shortcut key cannot be empty") | 79 | + | return fmt.Errorf("shortcut key cannot be empty") |
| 80 | - | } | 80 | + | } |
| 81 | 81 | ||||
| 82 | if _, exists := s.config.SearchShortcuts[key]; !exists { | 82 | if _, exists := s.config.SearchShortcuts[key]; !exists { | ||
| 83 | return fmt.Errorf("shortcut '%s' does not exist", key) | 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 | // DeleteSearchShortcut removes a search shortcut. | 94 | // DeleteSearchShortcut removes a search shortcut. | ||
| 95 | func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error { | 95 | func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error { | ||
| 96 | - | _ = ctx | 96 | + | _ = ctx |
| 97 | - | if key == "" { | 97 | + | if key == "" { |
| 98 | - | return fmt.Errorf("shortcut key cannot be empty") | 98 | + | return fmt.Errorf("shortcut key cannot be empty") |
| 99 | - | } | 99 | + | } |
| 100 | 100 | ||||
| 101 | if _, exists := s.config.SearchShortcuts[key]; !exists { | 101 | if _, exists := s.config.SearchShortcuts[key]; !exists { | ||
| 102 | return fmt.Errorf("shortcut '%s' does not exist", key) | 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 | // GetDmenuSettings returns current dmenu settings. | 109 | // GetDmenuSettings returns current dmenu settings. | ||
| 110 | func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) { | 110 | func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) { | ||
| 111 | - | _ = ctx | 111 | + | _ = ctx |
| 112 | - | return &s.config.Dmenu, nil | 112 | + | return &s.config.Dmenu, nil |
| 113 | } | 113 | } | ||
| 114 | 114 | ||||
| 115 | // UpdateDmenuSettings updates dmenu configuration. | 115 | // UpdateDmenuSettings updates dmenu configuration. | ||
| 116 | func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error { | 116 | func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error { | ||
| 117 | - | _ = ctx | 117 | + | _ = ctx |
| 118 | - | if settings == nil { | 118 | + | if settings == nil { |
| 119 | - | return fmt.Errorf("dmenu settings cannot be nil") | 119 | + | return fmt.Errorf("dmenu settings cannot be nil") |
| 120 | - | } | 120 | + | } |
| 121 | 121 | ||||
| 122 | s.config.Dmenu = *settings | 122 | s.config.Dmenu = *settings | ||
| 123 | return s.saveConfig() | 123 | return s.saveConfig() | ||
| @@ -125,22 +125,22 @@ func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *confi | |||||
| 125 | 125 | ||||
| 126 | // GetDatabasePath returns the current database path. | 126 | // GetDatabasePath returns the current database path. | ||
| 127 | func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) { | 127 | func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) { | ||
| 128 | - | _ = ctx | 128 | + | _ = ctx |
| 129 | - | return s.config.Database.Path, nil | 129 | + | return s.config.Database.Path, nil |
| 130 | } | 130 | } | ||
| 131 | 131 | ||||
| 132 | // SetDatabasePath updates the database path. | 132 | // SetDatabasePath updates the database path. | ||
| 133 | func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error { | 133 | func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error { | ||
| 134 | - | _ = ctx | 134 | + | _ = ctx |
| 135 | - | if path == "" { | 135 | + | if path == "" { |
| 136 | - | return fmt.Errorf("database path cannot be empty") | 136 | + | return fmt.Errorf("database path cannot be empty") |
| 137 | - | } | 137 | + | } |
| 138 | 138 | ||||
| 139 | // Validate that the directory exists or can be created | 139 | // Validate that the directory exists or can be created | ||
| 140 | dir := filepath.Dir(path) | 140 | dir := filepath.Dir(path) | ||
| 141 | - | if err := os.MkdirAll(dir, defaultDirPerm); err != nil { | 141 | + | if err := os.MkdirAll(dir, defaultDirPerm); err != nil { |
| 142 | - | return fmt.Errorf("failed to create database directory: %w", err) | 142 | + | return fmt.Errorf("failed to create database directory: %w", err) |
| 143 | - | } | 143 | + | } |
| 144 | 144 | ||||
| 145 | s.config.Database.Path = path | 145 | s.config.Database.Path = path | ||
| 146 | return s.saveConfig() | 146 | return s.saveConfig() | ||
| @@ -148,28 +148,28 @@ func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error | |||||
| 148 | 148 | ||||
| 149 | // GetDefaultBrowser returns the default browser setting. | 149 | // GetDefaultBrowser returns the default browser setting. | ||
| 150 | func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) { | 150 | func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) { | ||
| 151 | - | _ = ctx | 151 | + | _ = ctx |
| 152 | - | // Default browser is now built into the app | 152 | + | // Default browser is now built into the app |
| 153 | - | return "dumber (built-in)", nil | 153 | + | return "dumber (built-in)", nil |
| 154 | } | 154 | } | ||
| 155 | 155 | ||||
| 156 | // SetDefaultBrowser updates the default browser setting. | 156 | // SetDefaultBrowser updates the default browser setting. | ||
| 157 | func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error { | 157 | func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error { | ||
| 158 | - | _ = ctx | 158 | + | _ = ctx |
| 159 | - | _ = browser | 159 | + | _ = browser |
| 160 | - | // Default browser setting is not needed for built-in 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") | 161 | + | return fmt.Errorf("default browser is built-in and cannot be changed") |
| 162 | } | 162 | } | ||
| 163 | 163 | ||||
| 164 | // ReloadConfig reloads configuration from file. | 164 | // ReloadConfig reloads configuration from file. | ||
| 165 | func (s *ConfigService) ReloadConfig(ctx context.Context) error { | 165 | func (s *ConfigService) ReloadConfig(ctx context.Context) error { | ||
| 166 | - | _ = ctx | 166 | + | _ = ctx |
| 167 | - | // Create new manager and load config | 167 | + | // Create new manager and load config |
| 168 | - | manager, err := config.NewManager() | 168 | + | manager, err := config.NewManager() |
| 169 | - | if err != nil { | 169 | + | if err != nil { |
| 170 | - | return fmt.Errorf("failed to create config manager: %w", err) | 170 | + | return fmt.Errorf("failed to create config manager: %w", err) |
| 171 | - | } | 171 | + | } |
| 172 | - | 172 | + | ||
| 173 | if err := manager.Load(); err != nil { | 173 | if err := manager.Load(); err != nil { | ||
| 174 | return fmt.Errorf("failed to load config: %w", err) | 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 | // ValidateConfig validates the current configuration. | 181 | // ValidateConfig validates the current configuration. | ||
| 182 | func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | 182 | func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | ||
| 183 | - | _ = ctx | 183 | + | _ = ctx |
| 184 | - | var errors []string | 184 | + | var errors []string |
| 185 | 185 | ||||
| 186 | // Validate database path | 186 | // Validate database path | ||
| 187 | if s.config.Database.Path == "" { | 187 | if s.config.Database.Path == "" { | ||
| @@ -219,16 +219,16 @@ func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | |||||
| 219 | 219 | ||||
| 220 | // GetConfigPath returns the path to the configuration file. | 220 | // GetConfigPath returns the path to the configuration file. | ||
| 221 | func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) { | 221 | func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) { | ||
| 222 | - | _ = ctx | 222 | + | _ = ctx |
| 223 | - | return s.configPath, nil | 223 | + | return s.configPath, nil |
| 224 | } | 224 | } | ||
| 225 | 225 | ||||
| 226 | // ExportConfig returns the current configuration as JSON. | 226 | // ExportConfig returns the current configuration as JSON. | ||
| 227 | func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) { | 227 | func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) { | ||
| 228 | - | _ = ctx | 228 | + | _ = ctx |
| 229 | - | // Return a copy of the config to prevent modifications | 229 | + | // Return a copy of the config to prevent modifications |
| 230 | - | configCopy := *s.config | 230 | + | configCopy := *s.config |
| 231 | - | return &configCopy, nil | 231 | + | return &configCopy, nil |
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | // ImportConfig imports configuration from a Config object. | 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 | // GetRenderingMode returns the current rendering mode (auto|gpu|cpu). | 256 | // GetRenderingMode returns the current rendering mode (auto|gpu|cpu). | ||
| 257 | func (s *ConfigService) GetRenderingMode(ctx context.Context) (string, error) { | 257 | func (s *ConfigService) GetRenderingMode(ctx context.Context) (string, error) { | ||
| 258 | - | _ = ctx | 258 | + | _ = ctx |
| 259 | - | return string(s.config.RenderingMode), nil | 259 | + | return string(s.config.RenderingMode), nil |
| 260 | } | 260 | } | ||
| 261 | 261 | ||||
| 262 | // SetRenderingMode updates the rendering mode (auto|gpu|cpu). | 262 | // SetRenderingMode updates the rendering mode (auto|gpu|cpu). | ||
| 263 | func (s *ConfigService) SetRenderingMode(ctx context.Context, mode string) error { | 263 | func (s *ConfigService) SetRenderingMode(ctx context.Context, mode string) error { | ||
| 264 | - | _ = ctx | 264 | + | _ = ctx |
| 265 | - | m := strings.ToLower(strings.TrimSpace(mode)) | 265 | + | m := strings.ToLower(strings.TrimSpace(mode)) |
| 266 | - | switch m { | 266 | + | switch m { |
| 267 | - | case "auto", "gpu", "cpu": | 267 | + | case "auto", "gpu", "cpu": |
| 268 | - | s.config.RenderingMode = config.RenderingMode(m) | 268 | + | s.config.RenderingMode = config.RenderingMode(m) |
| 269 | - | return s.saveConfig() | 269 | + | return s.saveConfig() |
| 270 | - | default: | 270 | + | default: |
| 271 | - | return fmt.Errorf("invalid rendering mode: %s", mode) | 271 | + | return fmt.Errorf("invalid rendering mode: %s", mode) |
| 272 | - | } | 272 | + | } |
| 273 | } | 273 | } | ||
| 274 | 274 | ||||
| 275 | // saveConfig saves the current configuration to file. | 275 | // saveConfig saves the current configuration to file. | ||
M services/parser_service.go +57 -57
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | // ParserService wraps the parser functionality for the application. | 13 | // ParserService wraps the parser functionality for the application. | ||
| 14 | type ParserService struct { | 14 | type ParserService struct { | ||
| 15 | - | parser *parser.Parser | 15 | + | parser *parser.Parser |
| 16 | dbQueries *db.Queries | 16 | dbQueries *db.Queries | ||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| @@ -20,93 +20,93 @@ type ParserService struct { | |||||
| 20 | func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService { | 20 | func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService { | ||
| 21 | // Create history provider from database | 21 | // Create history provider from database | ||
| 22 | historyProvider := &DatabaseHistoryProvider{queries: queries} | 22 | historyProvider := &DatabaseHistoryProvider{queries: queries} | ||
| 23 | - | 23 | + | ||
| 24 | // Create parser with configuration | 24 | // Create parser with configuration | ||
| 25 | p := parser.NewParser(cfg, historyProvider) | 25 | p := parser.NewParser(cfg, historyProvider) | ||
| 26 | - | 26 | + | ||
| 27 | return &ParserService{ | 27 | return &ParserService{ | ||
| 28 | - | parser: p, | 28 | + | parser: p, |
| 29 | dbQueries: queries, | 29 | dbQueries: queries, | ||
| 30 | } | 30 | } | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | // ParseInput parses user input and returns navigation result. | 33 | // ParseInput parses user input and returns navigation result. | ||
| 34 | func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) { | 34 | func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) { | ||
| 35 | - | _ = ctx | 35 | + | _ = ctx |
| 36 | - | if input == "" { | 36 | + | if input == "" { |
| 37 | - | return nil, fmt.Errorf("input cannot be empty") | 37 | + | return nil, fmt.Errorf("input cannot be empty") |
| 38 | - | } | 38 | + | } |
| 39 | - | 39 | + | ||
| 40 | return s.parser.ParseInput(input) | 40 | return s.parser.ParseInput(input) | ||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | // GetCompletions provides URL/search completions based on input. | 43 | // GetCompletions provides URL/search completions based on input. | ||
| 44 | func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) { | 44 | func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) { | ||
| 45 | - | _ = ctx | 45 | + | _ = ctx |
| 46 | - | if limit <= 0 { | 46 | + | if limit <= 0 { |
| 47 | - | limit = 10 // Default limit | 47 | + | limit = 10 // Default limit |
| 48 | - | } | 48 | + | } |
| 49 | - | 49 | + | ||
| 50 | return s.parser.SuggestCompletions(input, limit) | 50 | return s.parser.SuggestCompletions(input, limit) | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | // FuzzySearchHistory performs fuzzy search on history entries. | 53 | // FuzzySearchHistory performs fuzzy search on history entries. | ||
| 54 | func (s *ParserService) FuzzySearchHistory( | 54 | func (s *ParserService) FuzzySearchHistory( | ||
| 55 | - | ctx context.Context, query string, threshold float64, | 55 | + | ctx context.Context, query string, threshold float64, |
| 56 | ) ([]parser.FuzzyMatch, error) { | 56 | ) ([]parser.FuzzyMatch, error) { | ||
| 57 | - | _ = ctx | 57 | + | _ = ctx |
| 58 | - | if threshold <= 0 { | 58 | + | if threshold <= 0 { |
| 59 | - | threshold = 0.3 // Default threshold | 59 | + | threshold = 0.3 // Default threshold |
| 60 | - | } | 60 | + | } |
| 61 | - | 61 | + | ||
| 62 | return s.parser.FuzzySearchHistory(query, threshold) | 62 | return s.parser.FuzzySearchHistory(query, threshold) | ||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | // GetSupportedShortcuts returns configured search shortcuts. | 65 | // GetSupportedShortcuts returns configured search shortcuts. | ||
| 66 | func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | 66 | func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | ||
| 67 | - | _ = ctx | 67 | + | _ = ctx |
| 68 | - | return s.parser.GetSupportedShortcuts(), nil | 68 | + | return s.parser.GetSupportedShortcuts(), nil |
| 69 | } | 69 | } | ||
| 70 | 70 | ||||
| 71 | // ProcessShortcut processes a shortcut and returns the resulting URL. | 71 | // ProcessShortcut processes a shortcut and returns the resulting URL. | ||
| 72 | func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) { | 72 | func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) { | ||
| 73 | - | _ = ctx | 73 | + | _ = ctx |
| 74 | - | shortcuts := s.parser.GetSupportedShortcuts() | 74 | + | shortcuts := s.parser.GetSupportedShortcuts() |
| 75 | - | return s.parser.ProcessShortcut(shortcut, query, shortcuts) | 75 | + | return s.parser.ProcessShortcut(shortcut, query, shortcuts) |
| 76 | } | 76 | } | ||
| 77 | 77 | ||||
| 78 | // CalculateSimilarity calculates similarity between two strings. | 78 | // CalculateSimilarity calculates similarity between two strings. | ||
| 79 | func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) { | 79 | func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) { | ||
| 80 | - | _ = ctx | 80 | + | _ = ctx |
| 81 | - | return s.parser.CalculateSimilarity(s1, s2), nil | 81 | + | return s.parser.CalculateSimilarity(s1, s2), nil |
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | // ValidateURL checks if the input represents a valid URL. | 84 | // ValidateURL checks if the input represents a valid URL. | ||
| 85 | func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) { | 85 | func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) { | ||
| 86 | - | _ = ctx | 86 | + | _ = ctx |
| 87 | - | return s.parser.IsValidURL(input), nil | 87 | + | return s.parser.IsValidURL(input), nil |
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | // PreviewParse provides a preview of what ParseInput would return. | 90 | // PreviewParse provides a preview of what ParseInput would return. | ||
| 91 | func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) { | 91 | func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) { | ||
| 92 | - | _ = ctx | 92 | + | _ = ctx |
| 93 | - | if input == "" { | 93 | + | if input == "" { |
| 94 | - | return nil, fmt.Errorf("input cannot be empty") | 94 | + | return nil, fmt.Errorf("input cannot be empty") |
| 95 | - | } | 95 | + | } |
| 96 | - | 96 | + | ||
| 97 | - | return s.parser.PreviewParse(input) | 97 | + | return s.parser.PreviewParse(input) |
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | // GetFuzzyConfig returns current fuzzy matching configuration. | 100 | // GetFuzzyConfig returns current fuzzy matching configuration. | ||
| 101 | func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) { | 101 | func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) { | ||
| 102 | - | _ = ctx | 102 | + | _ = ctx |
| 103 | - | return s.parser.GetFuzzyConfig(), nil | 103 | + | return s.parser.GetFuzzyConfig(), nil |
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | // UpdateFuzzyConfig updates the fuzzy matching configuration. | 106 | // UpdateFuzzyConfig updates the fuzzy matching configuration. | ||
| 107 | func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error { | 107 | func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error { | ||
| 108 | - | _ = ctx | 108 | + | _ = ctx |
| 109 | - | return s.parser.UpdateFuzzyConfig(config) | 109 | + | return s.parser.UpdateFuzzyConfig(config) |
| 110 | } | 110 | } | ||
| 111 | 111 | ||||
| 112 | // DatabaseHistoryProvider implements HistoryProvider using the database. | 112 | // DatabaseHistoryProvider implements HistoryProvider using the database. | ||
| @@ -117,82 +117,82 @@ type DatabaseHistoryProvider struct { | |||||
| 117 | // GetRecentHistory returns recent history entries limited by count. | 117 | // GetRecentHistory returns recent history entries limited by count. | ||
| 118 | func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) { | 118 | func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) { | ||
| 119 | ctx := context.Background() | 119 | ctx := context.Background() | ||
| 120 | - | 120 | + | ||
| 121 | entries, err := h.queries.GetHistory(ctx, int64(limit)) | 121 | entries, err := h.queries.GetHistory(ctx, int64(limit)) | ||
| 122 | if err != nil { | 122 | if err != nil { | ||
| 123 | return nil, err | 123 | return nil, err | ||
| 124 | } | 124 | } | ||
| 125 | - | 125 | + | ||
| 126 | // Convert to pointer slice | 126 | // Convert to pointer slice | ||
| 127 | result := make([]*db.History, len(entries)) | 127 | result := make([]*db.History, len(entries)) | ||
| 128 | for i, entry := range entries { | 128 | for i, entry := range entries { | ||
| 129 | entryCopy := entry | 129 | entryCopy := entry | ||
| 130 | result[i] = &entryCopy | 130 | result[i] = &entryCopy | ||
| 131 | } | 131 | } | ||
| 132 | - | 132 | + | ||
| 133 | return result, nil | 133 | return result, nil | ||
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | // GetAllHistory returns all history entries for fuzzy search. | 136 | // GetAllHistory returns all history entries for fuzzy search. | ||
| 137 | func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) { | 137 | func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) { | ||
| 138 | - | ctx := context.Background() | 138 | + | ctx := context.Background() |
| 139 | - | 139 | + | ||
| 140 | - | // Use a large limit to get all history | 140 | + | // Use a large limit to get all history |
| 141 | - | const allHistoryLimit = 10000 | 141 | + | const allHistoryLimit = 10000 |
| 142 | - | entries, err := h.queries.GetHistory(ctx, allHistoryLimit) | 142 | + | entries, err := h.queries.GetHistory(ctx, allHistoryLimit) |
| 143 | if err != nil { | 143 | if err != nil { | ||
| 144 | return nil, err | 144 | return nil, err | ||
| 145 | } | 145 | } | ||
| 146 | - | 146 | + | ||
| 147 | // Convert to pointer slice | 147 | // Convert to pointer slice | ||
| 148 | result := make([]*db.History, len(entries)) | 148 | result := make([]*db.History, len(entries)) | ||
| 149 | for i, entry := range entries { | 149 | for i, entry := range entries { | ||
| 150 | entryCopy := entry | 150 | entryCopy := entry | ||
| 151 | result[i] = &entryCopy | 151 | result[i] = &entryCopy | ||
| 152 | } | 152 | } | ||
| 153 | - | 153 | + | ||
| 154 | return result, nil | 154 | return result, nil | ||
| 155 | } | 155 | } | ||
| 156 | 156 | ||||
| 157 | // SearchHistory performs a basic text search in history. | 157 | // SearchHistory performs a basic text search in history. | ||
| 158 | func (h *DatabaseHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) { | 158 | func (h *DatabaseHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) { | ||
| 159 | ctx := context.Background() | 159 | ctx := context.Background() | ||
| 160 | - | 160 | + | ||
| 161 | // Use SQL LIKE pattern for basic search | 161 | // Use SQL LIKE pattern for basic search | ||
| 162 | urlPattern := sql.NullString{String: "%" + query + "%", Valid: true} | 162 | urlPattern := sql.NullString{String: "%" + query + "%", Valid: true} | ||
| 163 | titlePattern := sql.NullString{String: "%" + query + "%", Valid: true} | 163 | titlePattern := sql.NullString{String: "%" + query + "%", Valid: true} | ||
| 164 | - | 164 | + | ||
| 165 | entries, err := h.queries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit)) | 165 | entries, err := h.queries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit)) | ||
| 166 | if err != nil { | 166 | if err != nil { | ||
| 167 | return nil, err | 167 | return nil, err | ||
| 168 | } | 168 | } | ||
| 169 | - | 169 | + | ||
| 170 | // Convert to pointer slice | 170 | // Convert to pointer slice | ||
| 171 | result := make([]*db.History, len(entries)) | 171 | result := make([]*db.History, len(entries)) | ||
| 172 | for i, entry := range entries { | 172 | for i, entry := range entries { | ||
| 173 | entryCopy := entry | 173 | entryCopy := entry | ||
| 174 | result[i] = &entryCopy | 174 | result[i] = &entryCopy | ||
| 175 | } | 175 | } | ||
| 176 | - | 176 | + | ||
| 177 | return result, nil | 177 | return result, nil | ||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | // GetHistoryByURL retrieves history entry by exact URL match. | 180 | // GetHistoryByURL retrieves history entry by exact URL match. | ||
| 181 | func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, error) { | 181 | func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, error) { | ||
| 182 | ctx := context.Background() | 182 | ctx := context.Background() | ||
| 183 | - | 183 | + | ||
| 184 | // Search for exact URL match using SearchHistory | 184 | // Search for exact URL match using SearchHistory | ||
| 185 | urlPattern := sql.NullString{String: url, Valid: true} | 185 | urlPattern := sql.NullString{String: url, Valid: true} | ||
| 186 | emptyPattern := sql.NullString{Valid: false} | 186 | emptyPattern := sql.NullString{Valid: false} | ||
| 187 | - | 187 | + | ||
| 188 | entries, err := h.queries.SearchHistory(ctx, urlPattern, emptyPattern, 1) | 188 | entries, err := h.queries.SearchHistory(ctx, urlPattern, emptyPattern, 1) | ||
| 189 | if err != nil { | 189 | if err != nil { | ||
| 190 | return nil, err | 190 | return nil, err | ||
| 191 | } | 191 | } | ||
| 192 | - | 192 | + | ||
| 193 | if len(entries) == 0 { | 193 | if len(entries) == 0 { | ||
| 194 | return nil, fmt.Errorf("history entry not found for URL: %s", url) | 194 | return nil, fmt.Errorf("history entry not found for URL: %s", url) | ||
| 195 | } | 195 | } | ||
| 196 | - | 196 | + | ||
| 197 | return &entries[0], nil | 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 | "math" | 7 | "math" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | 9 | ||||
| 10 | - | "github.com/bnema/dumber/services" | |||
| 11 | "github.com/bnema/dumber/internal/config" | 10 | "github.com/bnema/dumber/internal/config" | ||
| 12 | mock_db "github.com/bnema/dumber/internal/db/mocks" | 11 | mock_db "github.com/bnema/dumber/internal/db/mocks" | ||
| 12 | + | "github.com/bnema/dumber/services" | |||
| 13 | "github.com/golang/mock/gomock" | 13 | "github.com/golang/mock/gomock" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // floatMatcher implements gomock.Matcher for floating point comparison with tolerance | 16 | // floatMatcher implements gomock.Matcher for floating point comparison with tolerance | ||
| 17 | type floatMatcher struct { | 17 | type floatMatcher struct { | ||
| 18 | - | expected float64 | 18 | + | expected float64 |
| 19 | tolerance float64 | 19 | tolerance float64 | ||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | func (f floatMatcher) Matches(x interface{}) bool { | 22 | func (f floatMatcher) Matches(x interface{}) bool { | ||
| 23 | if actual, ok := x.(float64); ok { | 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 | return false | 26 | return false | ||
| 27 | } | 27 | } | ||
| @@ -55,112 +55,112 @@ func TestBrowserService_ZoomControls_Contract(t *testing.T) { | |||||
| 55 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | 55 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | ||
| 56 | cfg := &config.Config{} | 56 | cfg := &config.Config{} | ||
| 57 | service := services.NewBrowserService(cfg, mockDB) | 57 | service := services.NewBrowserService(cfg, mockDB) | ||
| 58 | - | 58 | + | ||
| 59 | ctx := context.Background() | 59 | ctx := context.Background() | ||
| 60 | testURL := "https://example.com" | 60 | testURL := "https://example.com" | ||
| 61 | - | 61 | + | ||
| 62 | t.Run("ZoomIn increases zoom level by 0.1", func(t *testing.T) { | 62 | t.Run("ZoomIn increases zoom level by 0.1", func(t *testing.T) { | ||
| 63 | // Setup mock expectations | 63 | // Setup mock expectations | ||
| 64 | mockDB.EXPECT(). | 64 | mockDB.EXPECT(). | ||
| 65 | GetZoomLevel(ctx, testURL). | 65 | GetZoomLevel(ctx, testURL). | ||
| 66 | Return(1.0, nil) | 66 | Return(1.0, nil) | ||
| 67 | - | 67 | + | ||
| 68 | mockDB.EXPECT(). | 68 | mockDB.EXPECT(). | ||
| 69 | SetZoomLevel(ctx, testURL, floatEq(1.1)). | 69 | SetZoomLevel(ctx, testURL, floatEq(1.1)). | ||
| 70 | Return(nil) | 70 | Return(nil) | ||
| 71 | - | 71 | + | ||
| 72 | // Contract: ZoomIn should increase zoom by 0.1 and return new level | 72 | // Contract: ZoomIn should increase zoom by 0.1 and return new level | ||
| 73 | newZoom, err := service.ZoomIn(ctx, testURL) | 73 | newZoom, err := service.ZoomIn(ctx, testURL) | ||
| 74 | - | 74 | + | ||
| 75 | // Contract assertions | 75 | // Contract assertions | ||
| 76 | if err != nil { | 76 | if err != nil { | ||
| 77 | t.Fatalf("ZoomIn should not return error: %v", err) | 77 | t.Fatalf("ZoomIn should not return error: %v", err) | ||
| 78 | } | 78 | } | ||
| 79 | - | 79 | + | ||
| 80 | expectedZoom := 1.1 | 80 | expectedZoom := 1.1 | ||
| 81 | if newZoom != expectedZoom { | 81 | if newZoom != expectedZoom { | ||
| 82 | t.Errorf("Expected zoom level %f, got %f", expectedZoom, newZoom) | 82 | t.Errorf("Expected zoom level %f, got %f", expectedZoom, newZoom) | ||
| 83 | } | 83 | } | ||
| 84 | }) | 84 | }) | ||
| 85 | - | 85 | + | ||
| 86 | t.Run("ZoomOut decreases zoom level by 0.1", func(t *testing.T) { | 86 | t.Run("ZoomOut decreases zoom level by 0.1", func(t *testing.T) { | ||
| 87 | // Setup mock expectations for current zoom level | 87 | // Setup mock expectations for current zoom level | ||
| 88 | mockDB.EXPECT(). | 88 | mockDB.EXPECT(). | ||
| 89 | GetZoomLevel(ctx, testURL). | 89 | GetZoomLevel(ctx, testURL). | ||
| 90 | Return(1.2, nil) | 90 | Return(1.2, nil) | ||
| 91 | - | 91 | + | ||
| 92 | mockDB.EXPECT(). | 92 | mockDB.EXPECT(). | ||
| 93 | SetZoomLevel(ctx, testURL, floatEq(1.1)). | 93 | SetZoomLevel(ctx, testURL, floatEq(1.1)). | ||
| 94 | Return(nil) | 94 | Return(nil) | ||
| 95 | - | 95 | + | ||
| 96 | // Contract: ZoomOut should decrease zoom by 0.1 and return new level | 96 | // Contract: ZoomOut should decrease zoom by 0.1 and return new level | ||
| 97 | newZoom, err := service.ZoomOut(ctx, testURL) | 97 | newZoom, err := service.ZoomOut(ctx, testURL) | ||
| 98 | - | 98 | + | ||
| 99 | // Contract assertions | 99 | // Contract assertions | ||
| 100 | if err != nil { | 100 | if err != nil { | ||
| 101 | t.Fatalf("ZoomOut should not return error: %v", err) | 101 | t.Fatalf("ZoomOut should not return error: %v", err) | ||
| 102 | } | 102 | } | ||
| 103 | - | 103 | + | ||
| 104 | expectedZoom := 1.1 | 104 | expectedZoom := 1.1 | ||
| 105 | - | if math.Abs(newZoom - expectedZoom) > 1e-10 { | 105 | + | if math.Abs(newZoom-expectedZoom) > 1e-10 { |
| 106 | t.Errorf("Expected zoom level %f, got %f", expectedZoom, newZoom) | 106 | t.Errorf("Expected zoom level %f, got %f", expectedZoom, newZoom) | ||
| 107 | } | 107 | } | ||
| 108 | }) | 108 | }) | ||
| 109 | - | 109 | + | ||
| 110 | t.Run("ZoomReset sets zoom to 1.0", func(t *testing.T) { | 110 | t.Run("ZoomReset sets zoom to 1.0", func(t *testing.T) { | ||
| 111 | // Setup mock expectations | 111 | // Setup mock expectations | ||
| 112 | mockDB.EXPECT(). | 112 | mockDB.EXPECT(). | ||
| 113 | SetZoomLevel(ctx, testURL, floatEq(1.0)). | 113 | SetZoomLevel(ctx, testURL, floatEq(1.0)). | ||
| 114 | Return(nil) | 114 | Return(nil) | ||
| 115 | - | 115 | + | ||
| 116 | // Contract: ZoomReset should always return zoom to default (1.0) | 116 | // Contract: ZoomReset should always return zoom to default (1.0) | ||
| 117 | newZoom, err := service.ResetZoom(ctx, testURL) | 117 | newZoom, err := service.ResetZoom(ctx, testURL) | ||
| 118 | - | 118 | + | ||
| 119 | // Contract assertions | 119 | // Contract assertions | ||
| 120 | if err != nil { | 120 | if err != nil { | ||
| 121 | t.Fatalf("ResetZoom should not return error: %v", err) | 121 | t.Fatalf("ResetZoom should not return error: %v", err) | ||
| 122 | } | 122 | } | ||
| 123 | - | 123 | + | ||
| 124 | expectedZoom := 1.0 | 124 | expectedZoom := 1.0 | ||
| 125 | if newZoom != expectedZoom { | 125 | if newZoom != expectedZoom { | ||
| 126 | t.Errorf("ResetZoom should return 1.0, got %f", expectedZoom) | 126 | t.Errorf("ResetZoom should return 1.0, got %f", expectedZoom) | ||
| 127 | } | 127 | } | ||
| 128 | }) | 128 | }) | ||
| 129 | - | 129 | + | ||
| 130 | t.Run("GetZoomLevel returns current zoom level", func(t *testing.T) { | 130 | t.Run("GetZoomLevel returns current zoom level", func(t *testing.T) { | ||
| 131 | // Setup mock expectations | 131 | // Setup mock expectations | ||
| 132 | expectedZoom := 1.5 | 132 | expectedZoom := 1.5 | ||
| 133 | mockDB.EXPECT(). | 133 | mockDB.EXPECT(). | ||
| 134 | GetZoomLevel(ctx, testURL). | 134 | GetZoomLevel(ctx, testURL). | ||
| 135 | Return(expectedZoom, nil) | 135 | Return(expectedZoom, nil) | ||
| 136 | - | 136 | + | ||
| 137 | // Contract: GetZoomLevel should return current zoom level | 137 | // Contract: GetZoomLevel should return current zoom level | ||
| 138 | currentZoom, err := service.GetZoomLevel(ctx, testURL) | 138 | currentZoom, err := service.GetZoomLevel(ctx, testURL) | ||
| 139 | - | 139 | + | ||
| 140 | // Contract assertions | 140 | // Contract assertions | ||
| 141 | if err != nil { | 141 | if err != nil { | ||
| 142 | t.Fatalf("GetZoomLevel should not return error: %v", err) | 142 | t.Fatalf("GetZoomLevel should not return error: %v", err) | ||
| 143 | } | 143 | } | ||
| 144 | - | 144 | + | ||
| 145 | if currentZoom != expectedZoom { | 145 | if currentZoom != expectedZoom { | ||
| 146 | t.Errorf("Expected zoom level %f, got %f", expectedZoom, currentZoom) | 146 | t.Errorf("Expected zoom level %f, got %f", expectedZoom, currentZoom) | ||
| 147 | } | 147 | } | ||
| 148 | }) | 148 | }) | ||
| 149 | - | 149 | + | ||
| 150 | t.Run("GetZoomLevel returns default 1.0 for new URLs", func(t *testing.T) { | 150 | t.Run("GetZoomLevel returns default 1.0 for new URLs", func(t *testing.T) { | ||
| 151 | // Setup mock expectations for non-existent URL | 151 | // Setup mock expectations for non-existent URL | ||
| 152 | mockDB.EXPECT(). | 152 | mockDB.EXPECT(). | ||
| 153 | GetZoomLevel(ctx, testURL). | 153 | GetZoomLevel(ctx, testURL). | ||
| 154 | Return(0.0, sql.ErrNoRows) | 154 | Return(0.0, sql.ErrNoRows) | ||
| 155 | - | 155 | + | ||
| 156 | // Contract: GetZoomLevel should return 1.0 for URLs without zoom settings | 156 | // Contract: GetZoomLevel should return 1.0 for URLs without zoom settings | ||
| 157 | currentZoom, err := service.GetZoomLevel(ctx, testURL) | 157 | currentZoom, err := service.GetZoomLevel(ctx, testURL) | ||
| 158 | - | 158 | + | ||
| 159 | // Contract assertions | 159 | // Contract assertions | ||
| 160 | if err != nil { | 160 | if err != nil { | ||
| 161 | t.Fatalf("GetZoomLevel should not return error for new URLs: %v", err) | 161 | t.Fatalf("GetZoomLevel should not return error for new URLs: %v", err) | ||
| 162 | } | 162 | } | ||
| 163 | - | 163 | + | ||
| 164 | expectedZoom := 1.0 | 164 | expectedZoom := 1.0 | ||
| 165 | if currentZoom != expectedZoom { | 165 | if currentZoom != expectedZoom { | ||
| 166 | t.Errorf("Expected default zoom level %f for new URL, got %f", expectedZoom, currentZoom) | 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 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | 176 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | ||
| 177 | cfg := &config.Config{} | 177 | cfg := &config.Config{} | ||
| 178 | service := services.NewBrowserService(cfg, mockDB) | 178 | service := services.NewBrowserService(cfg, mockDB) | ||
| 179 | - | 179 | + | ||
| 180 | ctx := context.Background() | 180 | ctx := context.Background() | ||
| 181 | - | 181 | + | ||
| 182 | t.Run("GoBack handles gracefully when no history", func(t *testing.T) { | 182 | t.Run("GoBack handles gracefully when no history", func(t *testing.T) { | ||
| 183 | // Contract: GoBack should handle empty history gracefully | 183 | // Contract: GoBack should handle empty history gracefully | ||
| 184 | err := service.GoBack(ctx) | 184 | err := service.GoBack(ctx) | ||
| 185 | - | 185 | + | ||
| 186 | // May return error for empty history, which is acceptable | 186 | // May return error for empty history, which is acceptable | ||
| 187 | if err != nil { | 187 | if err != nil { | ||
| 188 | t.Logf("GoBack returned error (acceptable for empty history): %v", err) | 188 | t.Logf("GoBack returned error (acceptable for empty history): %v", err) | ||
| 189 | } | 189 | } | ||
| 190 | }) | 190 | }) | ||
| 191 | - | 191 | + | ||
| 192 | t.Run("GoForward handles gracefully when no forward history", func(t *testing.T) { | 192 | t.Run("GoForward handles gracefully when no forward history", func(t *testing.T) { | ||
| 193 | // Contract: GoForward should handle empty forward history gracefully | 193 | // Contract: GoForward should handle empty forward history gracefully | ||
| 194 | err := service.GoForward(ctx) | 194 | err := service.GoForward(ctx) | ||
| 195 | - | 195 | + | ||
| 196 | // May return error for empty forward history, which is acceptable | 196 | // May return error for empty forward history, which is acceptable | ||
| 197 | if err != nil { | 197 | if err != nil { | ||
| 198 | t.Logf("GoForward returned error (acceptable for empty forward history): %v", err) | 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 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | 208 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | ||
| 209 | cfg := &config.Config{} | 209 | cfg := &config.Config{} | ||
| 210 | service := services.NewBrowserService(cfg, mockDB) | 210 | service := services.NewBrowserService(cfg, mockDB) | ||
| 211 | - | 211 | + | ||
| 212 | // Setup mock window title updater | 212 | // Setup mock window title updater | ||
| 213 | mockUpdater := &MockWindowTitleUpdater{} | 213 | mockUpdater := &MockWindowTitleUpdater{} | ||
| 214 | service.SetWindowTitleUpdater(mockUpdater) | 214 | service.SetWindowTitleUpdater(mockUpdater) | ||
| 215 | - | 215 | + | ||
| 216 | t.Run("SetWindowTitleUpdater accepts WindowTitleUpdater interface", func(t *testing.T) { | 216 | t.Run("SetWindowTitleUpdater accepts WindowTitleUpdater interface", func(t *testing.T) { | ||
| 217 | // Contract: Service should accept any WindowTitleUpdater implementation | 217 | // Contract: Service should accept any WindowTitleUpdater implementation | ||
| 218 | newMockUpdater := &MockWindowTitleUpdater{} | 218 | newMockUpdater := &MockWindowTitleUpdater{} | ||
| 219 | - | 219 | + | ||
| 220 | // This should not panic or error | 220 | // This should not panic or error | ||
| 221 | service.SetWindowTitleUpdater(newMockUpdater) | 221 | service.SetWindowTitleUpdater(newMockUpdater) | ||
| 222 | - | 222 | + | ||
| 223 | // Verify the updater was set properly | 223 | // Verify the updater was set properly | ||
| 224 | if newMockUpdater.callCount < 0 { | 224 | if newMockUpdater.callCount < 0 { | ||
| 225 | t.Errorf("WindowTitleUpdater should be properly initialized") | 225 | t.Errorf("WindowTitleUpdater should be properly initialized") | ||
| @@ -235,31 +235,31 @@ func TestBrowserService_URLOperations_Contract(t *testing.T) { | |||||
| 235 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | 235 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | ||
| 236 | cfg := &config.Config{} | 236 | cfg := &config.Config{} | ||
| 237 | service := services.NewBrowserService(cfg, mockDB) | 237 | service := services.NewBrowserService(cfg, mockDB) | ||
| 238 | - | 238 | + | ||
| 239 | t.Run("GetCurrentURL returns current URL", func(t *testing.T) { | 239 | t.Run("GetCurrentURL returns current URL", func(t *testing.T) { | ||
| 240 | // Contract: GetCurrentURL should return the current URL string | 240 | // Contract: GetCurrentURL should return the current URL string | ||
| 241 | ctx := context.Background() | 241 | ctx := context.Background() | ||
| 242 | - | 242 | + | ||
| 243 | url, err := service.GetCurrentURL(ctx) | 243 | url, err := service.GetCurrentURL(ctx) | ||
| 244 | - | 244 | + | ||
| 245 | // Contract assertions | 245 | // Contract assertions | ||
| 246 | if err != nil { | 246 | if err != nil { | ||
| 247 | t.Fatalf("GetCurrentURL should not return error: %v", err) | 247 | t.Fatalf("GetCurrentURL should not return error: %v", err) | ||
| 248 | } | 248 | } | ||
| 249 | - | 249 | + | ||
| 250 | // URL should be a valid string (can be empty for new browser) | 250 | // URL should be a valid string (can be empty for new browser) | ||
| 251 | if url != "" && len(url) < 4 { // Minimum valid URL length | 251 | if url != "" && len(url) < 4 { // Minimum valid URL length | ||
| 252 | t.Errorf("Invalid URL returned: %s", url) | 252 | t.Errorf("Invalid URL returned: %s", url) | ||
| 253 | } | 253 | } | ||
| 254 | }) | 254 | }) | ||
| 255 | - | 255 | + | ||
| 256 | t.Run("CopyCurrentURL accepts URL parameter", func(t *testing.T) { | 256 | t.Run("CopyCurrentURL accepts URL parameter", func(t *testing.T) { | ||
| 257 | // Contract: CopyCurrentURL should accept URL and handle clipboard | 257 | // Contract: CopyCurrentURL should accept URL and handle clipboard | ||
| 258 | ctx := context.Background() | 258 | ctx := context.Background() | ||
| 259 | testURL := "https://example.com" | 259 | testURL := "https://example.com" | ||
| 260 | - | 260 | + | ||
| 261 | err := service.CopyCurrentURL(ctx, testURL) | 261 | err := service.CopyCurrentURL(ctx, testURL) | ||
| 262 | - | 262 | + | ||
| 263 | // Contract assertions - should not return error for valid URL | 263 | // Contract assertions - should not return error for valid URL | ||
| 264 | if err != nil { | 264 | if err != nil { | ||
| 265 | t.Fatalf("CopyCurrentURL should not return error for valid URL: %v", err) | 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 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | 276 | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | ||
| 277 | service := services.NewBrowserService(nil, mockDB) | 277 | service := services.NewBrowserService(nil, mockDB) | ||
| 278 | ctx := context.Background() | 278 | ctx := context.Background() | ||
| 279 | - | 279 | + | ||
| 280 | t.Run("Service handles nil config gracefully", func(t *testing.T) { | 280 | t.Run("Service handles nil config gracefully", func(t *testing.T) { | ||
| 281 | // Contract: Service methods should handle nil config gracefully | 281 | // Contract: Service methods should handle nil config gracefully | ||
| 282 | - | 282 | + | ||
| 283 | // Setup mock expectations for operations that might still work | 283 | // Setup mock expectations for operations that might still work | ||
| 284 | mockDB.EXPECT(). | 284 | mockDB.EXPECT(). | ||
| 285 | GetZoomLevel(ctx, "test"). | 285 | GetZoomLevel(ctx, "test"). | ||
| 286 | Return(1.0, nil). | 286 | Return(1.0, nil). | ||
| 287 | AnyTimes() | 287 | AnyTimes() | ||
| 288 | - | 288 | + | ||
| 289 | mockDB.EXPECT(). | 289 | mockDB.EXPECT(). | ||
| 290 | SetZoomLevel(ctx, gomock.Any(), gomock.Any()). | 290 | SetZoomLevel(ctx, gomock.Any(), gomock.Any()). | ||
| 291 | Return(nil). | 291 | Return(nil). | ||
| 292 | AnyTimes() | 292 | AnyTimes() | ||
| 293 | - | 293 | + | ||
| 294 | // These should work with valid database but nil config | 294 | // These should work with valid database but nil config | ||
| 295 | _, err := service.ZoomIn(ctx, "test") | 295 | _, err := service.ZoomIn(ctx, "test") | ||
| 296 | if err != nil { | 296 | if err != nil { | ||
| 297 | t.Logf("ZoomIn returned error with nil config (may be expected): %v", err) | 297 | t.Logf("ZoomIn returned error with nil config (may be expected): %v", err) | ||
| 298 | } | 298 | } | ||
| 299 | - | 299 | + | ||
| 300 | err = service.GoBack(ctx) | 300 | err = service.GoBack(ctx) | ||
| 301 | if err != nil { | 301 | if err != nil { | ||
| 302 | t.Logf("GoBack returned error with nil config (may be expected): %v", err) | 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 | package contract | 1 | package contract | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Contract: RegisterKeyboardShortcut binds accelerator without error and triggers callback on press. | 8 | // Contract: RegisterKeyboardShortcut binds accelerator without error and triggers callback on press. | ||
| 9 | func Test_RegisterKeyboardShortcut_Contract(t *testing.T) { | 9 | func Test_RegisterKeyboardShortcut_Contract(t *testing.T) { | ||
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | 11 | + | if err != nil { |
| 12 | - | err = view.RegisterKeyboardShortcut("Ctrl+L", func() {}) | 12 | + | t.Fatalf("NewWebView failed: %v", err) |
| 13 | - | if err != nil { t.Fatalf("RegisterKeyboardShortcut should succeed, got: %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 | package contract | 1 | package contract | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Contract: Show/Hide/Destroy lifecycle operations succeed without error. | 8 | // Contract: Show/Hide/Destroy lifecycle operations succeed without error. | ||
| 9 | func Test_Lifecycle_Contract(t *testing.T) { | 9 | func Test_Lifecycle_Contract(t *testing.T) { | ||
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | 11 | + | if err != nil { |
| 12 | - | if err := view.Show(); err != nil { t.Fatalf("Show should succeed: %v", err) } | 12 | + | t.Fatalf("NewWebView failed: %v", err) |
| 13 | - | if err := view.Hide(); err != nil { t.Fatalf("Hide should succeed: %v", err) } | 13 | + | } |
| 14 | - | if err := view.Destroy(); err != nil { t.Fatalf("Destroy should succeed: %v", err) } | 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 | package contract | 1 | package contract | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Contract: LoadURL navigates to a valid URL without error. | 8 | // Contract: LoadURL navigates to a valid URL without error. | ||
| 9 | func Test_LoadURL_Navigation_Contract(t *testing.T) { | 9 | func Test_LoadURL_Navigation_Contract(t *testing.T) { | ||
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) |
| 11 | - | if err != nil { | 11 | + | if err != nil { |
| 12 | - | t.Fatalf("NewWebView failed: %v", err) | 12 | + | t.Fatalf("NewWebView failed: %v", err) |
| 13 | - | } | 13 | + | } |
| 14 | - | if err := view.LoadURL("https://example.com"); err != nil { | 14 | + | if err := view.LoadURL("https://example.com"); err != nil { |
| 15 | - | t.Fatalf("LoadURL should succeed, got: %v", err) | 15 | + | t.Fatalf("LoadURL should succeed, got: %v", err) |
| 16 | - | } | 16 | + | } |
| 17 | } | 17 | } | ||
M tests/contract/test_script_injection.go +9 -7
| @@ -1,15 +1,17 @@ | |||||
| 1 | package contract | 1 | package contract | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Contract: InjectScript executes JS without error once implemented. | 8 | // Contract: InjectScript executes JS without error once implemented. | ||
| 9 | func Test_InjectScript_Contract(t *testing.T) { | 9 | func Test_InjectScript_Contract(t *testing.T) { | ||
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | 11 | + | if err != nil { |
| 12 | - | if err := view.InjectScript("console.log('hello')"); err != nil { | 12 | + | t.Fatalf("NewWebView failed: %v", err) |
| 13 | - | t.Fatalf("InjectScript should succeed, got: %v", err) | 13 | + | } |
| 14 | - | } | 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 | package contract | 1 | package contract | ||
| 2 | 2 | ||||
| 3 | import ( | 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 | // Contract: NewWebView creates a WebView without error and allows initial configuration. | 9 | // Contract: NewWebView creates a WebView without error and allows initial configuration. | ||
| 10 | func Test_NewWebView_Contract(t *testing.T) { | 10 | func Test_NewWebView_Contract(t *testing.T) { | ||
| 11 | - | cfg := &webkit.Config{InitialURL: "https://example.com", ZoomDefault: 1.0} | 11 | + | cfg := &webkit.Config{InitialURL: "https://example.com", ZoomDefault: 1.0} |
| 12 | - | view, err := webkit.NewWebView(cfg) | 12 | + | view, err := webkit.NewWebView(cfg) |
| 13 | - | if err != nil { | 13 | + | if err != nil { |
| 14 | - | t.Fatalf("expected NewWebView to succeed, got error: %v", err) | 14 | + | t.Fatalf("expected NewWebView to succeed, got error: %v", err) |
| 15 | - | } | 15 | + | } |
| 16 | - | if view == nil { | 16 | + | if view == nil { |
| 17 | - | t.Fatalf("expected non-nil WebView") | 17 | + | t.Fatalf("expected non-nil WebView") |
| 18 | - | } | 18 | + | } |
| 19 | } | 19 | } | ||
M tests/contract/test_zoom_control.go +16 -10
| @@ -1,18 +1,24 @@ | |||||
| 1 | package contract | 1 | package contract | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Contract: SetZoom/GetZoom operate without error and persist value. | 8 | // Contract: SetZoom/GetZoom operate without error and persist value. | ||
| 9 | func Test_ZoomControl_Contract(t *testing.T) { | 9 | func Test_ZoomControl_Contract(t *testing.T) { | ||
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0}) | 10 | + | view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0}) |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | 11 | + | if err != nil { |
| 12 | - | if err := view.SetZoom(1.25); err != nil { | 12 | + | t.Fatalf("NewWebView failed: %v", err) |
| 13 | - | t.Fatalf("SetZoom should succeed, got: %v", err) | 13 | + | } |
| 14 | - | } | 14 | + | if err := view.SetZoom(1.25); err != nil { |
| 15 | - | z, err := view.GetZoom() | 15 | + | t.Fatalf("SetZoom should succeed, got: %v", err) |
| 16 | - | if err != nil { t.Fatalf("GetZoom should succeed, got: %v", err) } | 16 | + | } |
| 17 | - | if z != 1.25 { t.Fatalf("expected zoom 1.25, got %v", z) } | 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 | // Create temporary database file | 34 | // Create temporary database file | ||
| 35 | tmpDir := t.TempDir() | 35 | tmpDir := t.TempDir() | ||
| 36 | dbPath := filepath.Join(tmpDir, "test_browser.db") | 36 | dbPath := filepath.Join(tmpDir, "test_browser.db") | ||
| 37 | - | 37 | + | ||
| 38 | database, err := sql.Open("sqlite3", dbPath) | 38 | database, err := sql.Open("sqlite3", dbPath) | ||
| 39 | if err != nil { | 39 | if err != nil { | ||
| 40 | t.Fatalf("Failed to open test database: %v", err) | 40 | t.Fatalf("Failed to open test database: %v", err) | ||
| 41 | } | 41 | } | ||
| 42 | - | 42 | + | ||
| 43 | // Create tables using the migration schema | 43 | // Create tables using the migration schema | ||
| 44 | - | createTablesSQL := ` | 44 | + | createTablesSQL := ` |
| 45 | CREATE TABLE IF NOT EXISTS history ( | 45 | CREATE TABLE IF NOT EXISTS history ( | ||
| 46 | id INTEGER PRIMARY KEY AUTOINCREMENT, | 46 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 47 | url TEXT NOT NULL, | 47 | url TEXT NOT NULL, | ||
| @@ -65,29 +65,29 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) { | |||||
| 65 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP | 65 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP | ||
| 66 | ); | 66 | ); | ||
| 67 | ` | 67 | ` | ||
| 68 | - | 68 | + | ||
| 69 | if _, err := database.Exec(createTablesSQL); err != nil { | 69 | if _, err := database.Exec(createTablesSQL); err != nil { | ||
| 70 | t.Fatalf("Failed to create test tables: %v", err) | 70 | t.Fatalf("Failed to create test tables: %v", err) | ||
| 71 | } | 71 | } | ||
| 72 | - | 72 | + | ||
| 73 | queries := db.New(database) | 73 | queries := db.New(database) | ||
| 74 | - | 74 | + | ||
| 75 | cleanup := func() { | 75 | cleanup := func() { | ||
| 76 | database.Close() | 76 | database.Close() | ||
| 77 | os.Remove(dbPath) | 77 | os.Remove(dbPath) | ||
| 78 | } | 78 | } | ||
| 79 | - | 79 | + | ||
| 80 | return database, queries, cleanup | 80 | return database, queries, cleanup | ||
| 81 | } | 81 | } | ||
| 82 | 82 | ||||
| 83 | // setupTestBrowserService creates a BrowserService with test database | 83 | // setupTestBrowserService creates a BrowserService with test database | ||
| 84 | func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) { | 84 | func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) { | ||
| 85 | _, queries, cleanup := setupTestDB(t) | 85 | _, queries, cleanup := setupTestDB(t) | ||
| 86 | - | 86 | + | ||
| 87 | cfg := &config.Config{} | 87 | cfg := &config.Config{} | ||
| 88 | - | 88 | + | ||
| 89 | service := services.NewBrowserService(cfg, queries) | 89 | service := services.NewBrowserService(cfg, queries) | ||
| 90 | - | 90 | + | ||
| 91 | return service, cleanup | 91 | return service, cleanup | ||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| @@ -95,109 +95,109 @@ func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) { | |||||
| 95 | func TestBrowserControls_ZoomPersistence_Integration(t *testing.T) { | 95 | func TestBrowserControls_ZoomPersistence_Integration(t *testing.T) { | ||
| 96 | service, cleanup := setupTestBrowserService(t) | 96 | service, cleanup := setupTestBrowserService(t) | ||
| 97 | defer cleanup() | 97 | defer cleanup() | ||
| 98 | - | 98 | + | ||
| 99 | ctx := context.Background() | 99 | ctx := context.Background() | ||
| 100 | testURL := "https://example.com" | 100 | testURL := "https://example.com" | ||
| 101 | - | 101 | + | ||
| 102 | t.Run("Zoom level persists across service operations", func(t *testing.T) { | 102 | t.Run("Zoom level persists across service operations", func(t *testing.T) { | ||
| 103 | // Set initial zoom level | 103 | // Set initial zoom level | ||
| 104 | zoomLevel, err := service.ZoomIn(ctx, testURL) | 104 | zoomLevel, err := service.ZoomIn(ctx, testURL) | ||
| 105 | if err != nil { | 105 | if err != nil { | ||
| 106 | t.Fatalf("Failed to zoom in: %v", err) | 106 | t.Fatalf("Failed to zoom in: %v", err) | ||
| 107 | } | 107 | } | ||
| 108 | - | 108 | + | ||
| 109 | expectedZoom := 1.1 | 109 | expectedZoom := 1.1 | ||
| 110 | if zoomLevel != expectedZoom { | 110 | if zoomLevel != expectedZoom { | ||
| 111 | t.Errorf("Expected zoom %f, got %f", expectedZoom, zoomLevel) | 111 | t.Errorf("Expected zoom %f, got %f", expectedZoom, zoomLevel) | ||
| 112 | } | 112 | } | ||
| 113 | - | 113 | + | ||
| 114 | // Zoom in again | 114 | // Zoom in again | ||
| 115 | zoomLevel, err = service.ZoomIn(ctx, testURL) | 115 | zoomLevel, err = service.ZoomIn(ctx, testURL) | ||
| 116 | if err != nil { | 116 | if err != nil { | ||
| 117 | t.Fatalf("Failed to zoom in again: %v", err) | 117 | t.Fatalf("Failed to zoom in again: %v", err) | ||
| 118 | } | 118 | } | ||
| 119 | - | 119 | + | ||
| 120 | expectedZoom = 1.2 | 120 | expectedZoom = 1.2 | ||
| 121 | if zoomLevel != expectedZoom { | 121 | if zoomLevel != expectedZoom { | ||
| 122 | t.Errorf("Expected zoom %f, got %f", expectedZoom, zoomLevel) | 122 | t.Errorf("Expected zoom %f, got %f", expectedZoom, zoomLevel) | ||
| 123 | } | 123 | } | ||
| 124 | - | 124 | + | ||
| 125 | // Test that zoom persists for the same URL | 125 | // Test that zoom persists for the same URL | ||
| 126 | // Simulate getting zoom level (this would typically happen on page load) | 126 | // Simulate getting zoom level (this would typically happen on page load) | ||
| 127 | currentZoom, err := service.GetZoomLevel(ctx, testURL) | 127 | currentZoom, err := service.GetZoomLevel(ctx, testURL) | ||
| 128 | if err != nil { | 128 | if err != nil { | ||
| 129 | t.Fatalf("Failed to get zoom level: %v", err) | 129 | t.Fatalf("Failed to get zoom level: %v", err) | ||
| 130 | } | 130 | } | ||
| 131 | - | 131 | + | ||
| 132 | if currentZoom != expectedZoom { | 132 | if currentZoom != expectedZoom { | ||
| 133 | t.Errorf("Zoom level not persisted. Expected %f, got %f", expectedZoom, currentZoom) | 133 | t.Errorf("Zoom level not persisted. Expected %f, got %f", expectedZoom, currentZoom) | ||
| 134 | } | 134 | } | ||
| 135 | }) | 135 | }) | ||
| 136 | - | 136 | + | ||
| 137 | t.Run("Different URLs have independent zoom levels", func(t *testing.T) { | 137 | t.Run("Different URLs have independent zoom levels", func(t *testing.T) { | ||
| 138 | url1 := "https://example.com" | 138 | url1 := "https://example.com" | ||
| 139 | url2 := "https://google.com" | 139 | url2 := "https://google.com" | ||
| 140 | - | 140 | + | ||
| 141 | // Set different zoom levels for different URLs | 141 | // Set different zoom levels for different URLs | ||
| 142 | zoom1, err := service.ZoomIn(ctx, url1) | 142 | zoom1, err := service.ZoomIn(ctx, url1) | ||
| 143 | if err != nil { | 143 | if err != nil { | ||
| 144 | t.Fatalf("Failed to zoom in for url1: %v", err) | 144 | t.Fatalf("Failed to zoom in for url1: %v", err) | ||
| 145 | } | 145 | } | ||
| 146 | - | 146 | + | ||
| 147 | zoom2, err := service.ZoomOut(ctx, url2) | 147 | zoom2, err := service.ZoomOut(ctx, url2) | ||
| 148 | if err != nil { | 148 | if err != nil { | ||
| 149 | t.Fatalf("Failed to zoom out for url2: %v", err) | 149 | t.Fatalf("Failed to zoom out for url2: %v", err) | ||
| 150 | } | 150 | } | ||
| 151 | - | 151 | + | ||
| 152 | // Verify they are different | 152 | // Verify they are different | ||
| 153 | if zoom1 == zoom2 { | 153 | if zoom1 == zoom2 { | ||
| 154 | t.Errorf("Expected different zoom levels for different URLs, both got %f", zoom1) | 154 | t.Errorf("Expected different zoom levels for different URLs, both got %f", zoom1) | ||
| 155 | } | 155 | } | ||
| 156 | - | 156 | + | ||
| 157 | // Verify persistence for each URL | 157 | // Verify persistence for each URL | ||
| 158 | persistedZoom1, err := service.GetZoomLevel(ctx, url1) | 158 | persistedZoom1, err := service.GetZoomLevel(ctx, url1) | ||
| 159 | if err != nil { | 159 | if err != nil { | ||
| 160 | t.Fatalf("Failed to get zoom level for url1: %v", err) | 160 | t.Fatalf("Failed to get zoom level for url1: %v", err) | ||
| 161 | } | 161 | } | ||
| 162 | - | 162 | + | ||
| 163 | persistedZoom2, err := service.GetZoomLevel(ctx, url2) | 163 | persistedZoom2, err := service.GetZoomLevel(ctx, url2) | ||
| 164 | if err != nil { | 164 | if err != nil { | ||
| 165 | t.Fatalf("Failed to get zoom level for url2: %v", err) | 165 | t.Fatalf("Failed to get zoom level for url2: %v", err) | ||
| 166 | } | 166 | } | ||
| 167 | - | 167 | + | ||
| 168 | if persistedZoom1 != zoom1 { | 168 | if persistedZoom1 != zoom1 { | ||
| 169 | t.Errorf("URL1 zoom not persisted. Expected %f, got %f", zoom1, persistedZoom1) | 169 | t.Errorf("URL1 zoom not persisted. Expected %f, got %f", zoom1, persistedZoom1) | ||
| 170 | } | 170 | } | ||
| 171 | - | 171 | + | ||
| 172 | if persistedZoom2 != zoom2 { | 172 | if persistedZoom2 != zoom2 { | ||
| 173 | t.Errorf("URL2 zoom not persisted. Expected %f, got %f", zoom2, persistedZoom2) | 173 | t.Errorf("URL2 zoom not persisted. Expected %f, got %f", zoom2, persistedZoom2) | ||
| 174 | } | 174 | } | ||
| 175 | }) | 175 | }) | ||
| 176 | - | 176 | + | ||
| 177 | t.Run("Zoom reset works correctly", func(t *testing.T) { | 177 | t.Run("Zoom reset works correctly", func(t *testing.T) { | ||
| 178 | testURL := "https://test.com" | 178 | testURL := "https://test.com" | ||
| 179 | - | 179 | + | ||
| 180 | // Change zoom level | 180 | // Change zoom level | ||
| 181 | service.ZoomIn(ctx, testURL) | 181 | service.ZoomIn(ctx, testURL) | ||
| 182 | service.ZoomIn(ctx, testURL) | 182 | service.ZoomIn(ctx, testURL) | ||
| 183 | - | 183 | + | ||
| 184 | // Reset zoom | 184 | // Reset zoom | ||
| 185 | resetZoom, err := service.ResetZoom(ctx, testURL) | 185 | resetZoom, err := service.ResetZoom(ctx, testURL) | ||
| 186 | if err != nil { | 186 | if err != nil { | ||
| 187 | t.Fatalf("Failed to reset zoom: %v", err) | 187 | t.Fatalf("Failed to reset zoom: %v", err) | ||
| 188 | } | 188 | } | ||
| 189 | - | 189 | + | ||
| 190 | expectedZoom := 1.0 | 190 | expectedZoom := 1.0 | ||
| 191 | if resetZoom != expectedZoom { | 191 | if resetZoom != expectedZoom { | ||
| 192 | t.Errorf("Expected reset zoom %f, got %f", expectedZoom, resetZoom) | 192 | t.Errorf("Expected reset zoom %f, got %f", expectedZoom, resetZoom) | ||
| 193 | } | 193 | } | ||
| 194 | - | 194 | + | ||
| 195 | // Verify persistence of reset | 195 | // Verify persistence of reset | ||
| 196 | persistedZoom, err := service.GetZoomLevel(ctx, testURL) | 196 | persistedZoom, err := service.GetZoomLevel(ctx, testURL) | ||
| 197 | if err != nil { | 197 | if err != nil { | ||
| 198 | t.Fatalf("Failed to get zoom level after reset: %v", err) | 198 | t.Fatalf("Failed to get zoom level after reset: %v", err) | ||
| 199 | } | 199 | } | ||
| 200 | - | 200 | + | ||
| 201 | if persistedZoom != expectedZoom { | 201 | if persistedZoom != expectedZoom { | ||
| 202 | t.Errorf("Reset zoom not persisted. Expected %f, got %f", expectedZoom, persistedZoom) | 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 | func TestBrowserControls_NavigationHistory_Integration(t *testing.T) { | 208 | func TestBrowserControls_NavigationHistory_Integration(t *testing.T) { | ||
| 209 | _, cleanup := setupTestBrowserService(t) | 209 | _, cleanup := setupTestBrowserService(t) | ||
| 210 | defer cleanup() | 210 | defer cleanup() | ||
| 211 | - | 211 | + | ||
| 212 | ctx := context.Background() | 212 | ctx := context.Background() | ||
| 213 | - | 213 | + | ||
| 214 | t.Run("Navigation builds history correctly", func(t *testing.T) { | 214 | t.Run("Navigation builds history correctly", func(t *testing.T) { | ||
| 215 | // This test assumes the service has methods to add history entries | 215 | // This test assumes the service has methods to add history entries | ||
| 216 | // and that navigation uses real history data | 216 | // and that navigation uses real history data | ||
| 217 | - | 217 | + | ||
| 218 | // Add some test history entries directly to database for testing | 218 | // Add some test history entries directly to database for testing | ||
| 219 | database, queries, dbCleanup := setupTestDB(t) | 219 | database, queries, dbCleanup := setupTestDB(t) | ||
| 220 | defer dbCleanup() | 220 | defer dbCleanup() | ||
| 221 | - | 221 | + | ||
| 222 | // Insert test history entries | 222 | // Insert test history entries | ||
| 223 | testURLs := []string{ | 223 | testURLs := []string{ | ||
| 224 | "https://example.com", | 224 | "https://example.com", | ||
| 225 | - | "https://google.com", | 225 | + | "https://google.com", |
| 226 | "https://github.com", | 226 | "https://github.com", | ||
| 227 | } | 227 | } | ||
| 228 | - | 228 | + | ||
| 229 | for _, url := range testURLs { | 229 | for _, url := range testURLs { | ||
| 230 | _, err := database.Exec( | 230 | _, err := database.Exec( | ||
| 231 | `INSERT INTO history (url, title, visit_count) VALUES (?, ?, ?)`, | 231 | `INSERT INTO history (url, title, visit_count) VALUES (?, ?, ?)`, | ||
| @@ -235,16 +235,16 @@ func TestBrowserControls_NavigationHistory_Integration(t *testing.T) { | |||||
| 235 | t.Fatalf("Failed to insert test history: %v", err) | 235 | t.Fatalf("Failed to insert test history: %v", err) | ||
| 236 | } | 236 | } | ||
| 237 | } | 237 | } | ||
| 238 | - | 238 | + | ||
| 239 | // Create service with populated database | 239 | // Create service with populated database | ||
| 240 | serviceWithHistory := services.NewBrowserService(&config.Config{}, queries) | 240 | serviceWithHistory := services.NewBrowserService(&config.Config{}, queries) | ||
| 241 | - | 241 | + | ||
| 242 | // Test navigation operations | 242 | // Test navigation operations | ||
| 243 | err := serviceWithHistory.GoBack(ctx) | 243 | err := serviceWithHistory.GoBack(ctx) | ||
| 244 | if err != nil { | 244 | if err != nil { | ||
| 245 | t.Logf("GoBack returned error (acceptable for test): %v", err) | 245 | t.Logf("GoBack returned error (acceptable for test): %v", err) | ||
| 246 | } | 246 | } | ||
| 247 | - | 247 | + | ||
| 248 | err = serviceWithHistory.GoForward(ctx) | 248 | err = serviceWithHistory.GoForward(ctx) | ||
| 249 | if err != nil { | 249 | if err != nil { | ||
| 250 | t.Logf("GoForward returned error (acceptable for test): %v", err) | 250 | t.Logf("GoForward returned error (acceptable for test): %v", err) | ||
| @@ -256,29 +256,29 @@ func TestBrowserControls_NavigationHistory_Integration(t *testing.T) { | |||||
| 256 | func TestBrowserControls_WindowTitleUpdater_Integration(t *testing.T) { | 256 | func TestBrowserControls_WindowTitleUpdater_Integration(t *testing.T) { | ||
| 257 | service, cleanup := setupTestBrowserService(t) | 257 | service, cleanup := setupTestBrowserService(t) | ||
| 258 | defer cleanup() | 258 | defer cleanup() | ||
| 259 | - | 259 | + | ||
| 260 | // Create mock window title updater | 260 | // Create mock window title updater | ||
| 261 | mockUpdater := &MockWindowUpdater{ | 261 | mockUpdater := &MockWindowUpdater{ | ||
| 262 | titles: make([]string, 0), | 262 | titles: make([]string, 0), | ||
| 263 | } | 263 | } | ||
| 264 | - | 264 | + | ||
| 265 | // This test would need the actual WindowTitleUpdater interface to be implemented | 265 | // This test would need the actual WindowTitleUpdater interface to be implemented | ||
| 266 | // For now, we test that the service accepts the updater | 266 | // For now, we test that the service accepts the updater | ||
| 267 | t.Run("Service integrates with WindowTitleUpdater", func(t *testing.T) { | 267 | t.Run("Service integrates with WindowTitleUpdater", func(t *testing.T) { | ||
| 268 | // This is a placeholder test - would need actual implementation | 268 | // This is a placeholder test - would need actual implementation | ||
| 269 | // to verify title updates during navigation operations | 269 | // to verify title updates during navigation operations | ||
| 270 | - | 270 | + | ||
| 271 | service.SetWindowTitleUpdater(mockUpdater) | 271 | service.SetWindowTitleUpdater(mockUpdater) | ||
| 272 | - | 272 | + | ||
| 273 | // Simulate navigation that should trigger title update | 273 | // Simulate navigation that should trigger title update | ||
| 274 | ctx := context.Background() | 274 | ctx := context.Background() | ||
| 275 | service.GoBack(ctx) | 275 | service.GoBack(ctx) | ||
| 276 | - | 276 | + | ||
| 277 | // In a full integration test, we would verify that: | 277 | // In a full integration test, we would verify that: | ||
| 278 | // 1. Title updates were called | 278 | // 1. Title updates were called | ||
| 279 | // 2. Correct titles were set | 279 | // 2. Correct titles were set | ||
| 280 | // 3. Updates happened at appropriate times | 280 | // 3. Updates happened at appropriate times | ||
| 281 | - | 281 | + | ||
| 282 | // For now, just verify no panic occurred | 282 | // For now, just verify no panic occurred | ||
| 283 | if len(mockUpdater.titles) < 0 { | 283 | if len(mockUpdater.titles) < 0 { | ||
| 284 | t.Errorf("WindowTitleUpdater integration failed") | 284 | t.Errorf("WindowTitleUpdater integration failed") | ||
| @@ -290,17 +290,17 @@ func TestBrowserControls_WindowTitleUpdater_Integration(t *testing.T) { | |||||
| 290 | func TestBrowserControls_URLCopying_Integration(t *testing.T) { | 290 | func TestBrowserControls_URLCopying_Integration(t *testing.T) { | ||
| 291 | service, cleanup := setupTestBrowserService(t) | 291 | service, cleanup := setupTestBrowserService(t) | ||
| 292 | defer cleanup() | 292 | defer cleanup() | ||
| 293 | - | 293 | + | ||
| 294 | t.Run("URL copying returns current browser URL", func(t *testing.T) { | 294 | t.Run("URL copying returns current browser URL", func(t *testing.T) { | ||
| 295 | ctx := context.Background() | 295 | ctx := context.Background() | ||
| 296 | - | 296 | + | ||
| 297 | // Test URL copying functionality | 297 | // Test URL copying functionality | ||
| 298 | testURL := "https://example.com" | 298 | testURL := "https://example.com" | ||
| 299 | err := service.CopyCurrentURL(ctx, testURL) | 299 | err := service.CopyCurrentURL(ctx, testURL) | ||
| 300 | if err != nil { | 300 | if err != nil { | ||
| 301 | t.Fatalf("CopyCurrentURL failed: %v", err) | 301 | t.Fatalf("CopyCurrentURL failed: %v", err) | ||
| 302 | } | 302 | } | ||
| 303 | - | 303 | + | ||
| 304 | // In a full integration test, we would also verify: | 304 | // In a full integration test, we would also verify: | ||
| 305 | // 1. System clipboard is updated | 305 | // 1. System clipboard is updated | ||
| 306 | // 2. Correct URL format | 306 | // 2. Correct URL format | ||
| @@ -312,38 +312,38 @@ func TestBrowserControls_URLCopying_Integration(t *testing.T) { | |||||
| 312 | func TestBrowserControls_Performance_Integration(t *testing.T) { | 312 | func TestBrowserControls_Performance_Integration(t *testing.T) { | ||
| 313 | service, cleanup := setupTestBrowserService(t) | 313 | service, cleanup := setupTestBrowserService(t) | ||
| 314 | defer cleanup() | 314 | defer cleanup() | ||
| 315 | - | 315 | + | ||
| 316 | ctx := context.Background() | 316 | ctx := context.Background() | ||
| 317 | testURL := "https://example.com" | 317 | testURL := "https://example.com" | ||
| 318 | - | 318 | + | ||
| 319 | t.Run("Zoom operations complete within performance requirements", func(t *testing.T) { | 319 | t.Run("Zoom operations complete within performance requirements", func(t *testing.T) { | ||
| 320 | // Spec requires <50ms for history search, similar expectation for zoom | 320 | // Spec requires <50ms for history search, similar expectation for zoom | ||
| 321 | start := time.Now() | 321 | start := time.Now() | ||
| 322 | - | 322 | + | ||
| 323 | _, err := service.ZoomIn(ctx, testURL) | 323 | _, err := service.ZoomIn(ctx, testURL) | ||
| 324 | if err != nil { | 324 | if err != nil { | ||
| 325 | t.Fatalf("ZoomIn failed: %v", err) | 325 | t.Fatalf("ZoomIn failed: %v", err) | ||
| 326 | } | 326 | } | ||
| 327 | - | 327 | + | ||
| 328 | duration := time.Since(start) | 328 | duration := time.Since(start) | ||
| 329 | - | 329 | + | ||
| 330 | // Zoom operations should be fast (<10ms typically) | 330 | // Zoom operations should be fast (<10ms typically) | ||
| 331 | maxDuration := 50 * time.Millisecond | 331 | maxDuration := 50 * time.Millisecond | ||
| 332 | if duration > maxDuration { | 332 | if duration > maxDuration { | ||
| 333 | t.Errorf("Zoom operation too slow: %v (max: %v)", duration, maxDuration) | 333 | t.Errorf("Zoom operation too slow: %v (max: %v)", duration, maxDuration) | ||
| 334 | } | 334 | } | ||
| 335 | }) | 335 | }) | ||
| 336 | - | 336 | + | ||
| 337 | t.Run("Navigation operations complete within reasonable time", func(t *testing.T) { | 337 | t.Run("Navigation operations complete within reasonable time", func(t *testing.T) { | ||
| 338 | start := time.Now() | 338 | start := time.Now() | ||
| 339 | - | 339 | + | ||
| 340 | err := service.GoBack(ctx) | 340 | err := service.GoBack(ctx) | ||
| 341 | if err != nil { | 341 | if err != nil { | ||
| 342 | t.Fatalf("GoBack failed: %v", err) | 342 | t.Fatalf("GoBack failed: %v", err) | ||
| 343 | } | 343 | } | ||
| 344 | - | 344 | + | ||
| 345 | duration := time.Since(start) | 345 | duration := time.Since(start) | ||
| 346 | - | 346 | + | ||
| 347 | // Navigation should be reasonably fast | 347 | // Navigation should be reasonably fast | ||
| 348 | maxDuration := 100 * time.Millisecond | 348 | maxDuration := 100 * time.Millisecond | ||
| 349 | if duration > maxDuration { | 349 | if duration > maxDuration { | ||
| @@ -355,15 +355,15 @@ func TestBrowserControls_Performance_Integration(t *testing.T) { | |||||
| 355 | // Helper function to verify database state | 355 | // Helper function to verify database state | ||
| 356 | func verifyZoomInDatabase(t *testing.T, queries *db.Queries, url string, expectedZoom float64) { | 356 | func verifyZoomInDatabase(t *testing.T, queries *db.Queries, url string, expectedZoom float64) { | ||
| 357 | _ = context.Background() // Test placeholder | 357 | _ = context.Background() // Test placeholder | ||
| 358 | - | 358 | + | ||
| 359 | // This would use the actual SQLC generated methods | 359 | // This would use the actual SQLC generated methods | ||
| 360 | // For now, it's a placeholder for the verification logic | 360 | // For now, it's a placeholder for the verification logic | ||
| 361 | - | 361 | + | ||
| 362 | // zoom, err := queries.GetZoomLevel(ctx, url) | 362 | // zoom, err := queries.GetZoomLevel(ctx, url) | ||
| 363 | // if err != nil { | 363 | // if err != nil { | ||
| 364 | // t.Fatalf("Failed to get zoom from database: %v", err) | 364 | // t.Fatalf("Failed to get zoom from database: %v", err) | ||
| 365 | // } | 365 | // } | ||
| 366 | - | // | 366 | + | // |
| 367 | // if zoom != expectedZoom { | 367 | // if zoom != expectedZoom { | ||
| 368 | // t.Errorf("Database zoom mismatch. Expected %f, got %f", expectedZoom, zoom) | 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 | package integration | 1 | package integration | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Integration: keyboard shortcuts work on external sites once implemented. | 8 | // Integration: keyboard shortcuts work on external sites once implemented. | ||
| 9 | func Test_WebKit_ExternalShortcuts_Integration(t *testing.T) { | 9 | func Test_WebKit_ExternalShortcuts_Integration(t *testing.T) { | ||
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | 11 | + | if err != nil { |
| 12 | - | if err := view.RegisterKeyboardShortcut("Ctrl+K", func(){}); err != nil { | 12 | + | t.Fatalf("NewWebView failed: %v", err) |
| 13 | - | t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err) | 13 | + | } |
| 14 | - | } | 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 | package integration | 1 | package integration | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Integration: navigate to google.fr without error once implemented. | 8 | // Integration: navigate to google.fr without error once implemented. | ||
| 9 | func Test_WebKit_GoogleNavigation_Integration(t *testing.T) { | 9 | func Test_WebKit_GoogleNavigation_Integration(t *testing.T) { | ||
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{}) | 10 | + | view, err := webkit.NewWebView(&webkit.Config{}) |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | 11 | + | if err != nil { |
| 12 | - | if err := view.LoadURL("https://www.google.fr"); err != nil { | 12 | + | t.Fatalf("NewWebView failed: %v", err) |
| 13 | - | t.Fatalf("LoadURL should succeed, got: %v", err) | 13 | + | } |
| 14 | - | } | 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 | package integration | 1 | package integration | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Integration: basic WebKit window and navigation works. | 8 | // Integration: basic WebKit window and navigation works. | ||
| 9 | func Test_WebKit_BasicWindow_Integration(t *testing.T) { | 9 | func Test_WebKit_BasicWindow_Integration(t *testing.T) { | ||
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{InitialURL: "about:blank"}) | 10 | + | view, err := webkit.NewWebView(&webkit.Config{InitialURL: "about:blank"}) |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | 11 | + | if err != nil { |
| 12 | - | if err := view.LoadURL("https://example.com"); err != nil { | 12 | + | t.Fatalf("NewWebView failed: %v", err) |
| 13 | - | t.Fatalf("LoadURL should succeed, got: %v", err) | 13 | + | } |
| 14 | - | } | 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 | package integration | 1 | package integration | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Integration: zoom persists across operations (with DB) once implemented. | 8 | // Integration: zoom persists across operations (with DB) once implemented. | ||
| 9 | func Test_WebKit_ZoomPersistence_Integration(t *testing.T) { | 9 | func Test_WebKit_ZoomPersistence_Integration(t *testing.T) { | ||
| 10 | - | view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0}) | 10 | + | view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0}) |
| 11 | - | if err != nil { t.Fatalf("NewWebView failed: %v", err) } | 11 | + | if err != nil { |
| 12 | - | if err := view.SetZoom(1.1); err != nil { | 12 | + | t.Fatalf("NewWebView failed: %v", err) |
| 13 | - | t.Fatalf("SetZoom should succeed, got: %v", err) | 13 | + | } |
| 14 | - | } | 14 | + | if err := view.SetZoom(1.1); err != nil { |
| 15 | - | z, err := view.GetZoom() | 15 | + | t.Fatalf("SetZoom should succeed, got: %v", err) |
| 16 | - | if err != nil { t.Fatalf("GetZoom should succeed, got: %v", err) } | 16 | + | } |
| 17 | - | if z != 1.1 { t.Fatalf("expected zoom 1.1, got %v", z) } | 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 | package unit | 1 | package unit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "errors" | 4 | + | "errors" |
| 5 | - | "testing" | 5 | + | "github.com/bnema/dumber/pkg/webkit" |
| 6 | - | "github.com/bnema/dumber/pkg/webkit" | 6 | + | "testing" |
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | // Unit: WebKit error handling should propagate errors with context once implemented. | 9 | // Unit: WebKit error handling should propagate errors with context once implemented. | ||
| 10 | func Test_WebKit_ErrorHandling(t *testing.T) { | 10 | func Test_WebKit_ErrorHandling(t *testing.T) { | ||
| 11 | - | _, err := webkit.NewWebView(nil) | 11 | + | _, err := webkit.NewWebView(nil) |
| 12 | - | if err == nil { | 12 | + | if err == nil { |
| 13 | - | t.Fatalf("expected error prior to implementation (RED phase)") | 13 | + | t.Fatalf("expected error prior to implementation (RED phase)") |
| 14 | - | } | 14 | + | } |
| 15 | - | if !errors.Is(err, webkit.ErrNotImplemented) { | 15 | + | if !errors.Is(err, webkit.ErrNotImplemented) { |
| 16 | - | t.Fatalf("expected ErrNotImplemented, got %v", err) | 16 | + | t.Fatalf("expected ErrNotImplemented, got %v", err) |
| 17 | - | } | 17 | + | } |
| 18 | } | 18 | } | ||
| 19 | - | ||||
M tests/unit/test_memory.go +5 -6
| @@ -1,14 +1,13 @@ | |||||
| 1 | package unit | 1 | package unit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Memory test placeholder: baseline <100MB once implemented. | 8 | // Memory test placeholder: baseline <100MB once implemented. | ||
| 9 | func Test_WebKit_Memory_Baseline(t *testing.T) { | 9 | func Test_WebKit_Memory_Baseline(t *testing.T) { | ||
| 10 | - | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { | 10 | + | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { |
| 11 | - | t.Fatalf("expected construction to fail prior to implementation (RED phase)") | 11 | + | t.Fatalf("expected construction to fail prior to implementation (RED phase)") |
| 12 | - | } | 12 | + | } |
| 13 | } | 13 | } | ||
| 14 | - | ||||
M tests/unit/test_memory_management.go +5 -6
| @@ -1,14 +1,13 @@ | |||||
| 1 | package unit | 1 | package unit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Unit: CGO memory management semantics (finalizers, ownership) to be verified post-implementation. | 8 | // Unit: CGO memory management semantics (finalizers, ownership) to be verified post-implementation. | ||
| 9 | func Test_WebKit_CGOMemory_Management(t *testing.T) { | 9 | func Test_WebKit_CGOMemory_Management(t *testing.T) { | ||
| 10 | - | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { | 10 | + | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { |
| 11 | - | t.Fatalf("expected construction to fail prior to implementation (RED phase)") | 11 | + | t.Fatalf("expected construction to fail prior to implementation (RED phase)") |
| 12 | - | } | 12 | + | } |
| 13 | } | 13 | } | ||
| 14 | - | ||||
M tests/unit/test_performance.go +5 -6
| @@ -1,14 +1,13 @@ | |||||
| 1 | package unit | 1 | package unit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "testing" | 4 | + | "github.com/bnema/dumber/pkg/webkit" |
| 5 | - | "github.com/bnema/dumber/pkg/webkit" | 5 | + | "testing" |
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Performance test placeholder: startup time <500ms (to be measured once implemented). | 8 | // Performance test placeholder: startup time <500ms (to be measured once implemented). | ||
| 9 | func Test_WebKit_Performance_StartupTime(t *testing.T) { | 9 | func Test_WebKit_Performance_StartupTime(t *testing.T) { | ||
| 10 | - | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { | 10 | + | if _, err := webkit.NewWebView(&webkit.Config{}); err == nil { |
| 11 | - | t.Fatalf("expected construction to fail prior to implementation (RED phase)") | 11 | + | t.Fatalf("expected construction to fail prior to implementation (RED phase)") |
| 12 | - | } | 12 | + | } |
| 13 | } | 13 | } | ||
| 14 | - | ||||